included scripts for api interaction

This commit is contained in:
Florian Zumpe 2026-06-17 11:41:36 +02:00
parent a697e293cb
commit 3b3069ca8c
16 changed files with 573 additions and 0 deletions

67
tools/python/README.md Normal file
View File

@ -0,0 +1,67 @@
# Python-Werkzeuge für die Wegwichtel-REST-API
Die Skripte benötigen nur Python 3 und die Standardbibliothek. Node.js, `curl` und zusätzliche Python-Pakete sind auf dem aufrufenden Rechner nicht erforderlich.
## Authentifizierung
Jeder Request wird zunächst mit den bereits bekannten Zugangsdaten oder ohne Zugangsdaten ausgeführt. Antwortet ein vorgeschalteter Webserver mit `401 Unauthorized`, fragt das Werkzeug Benutzername und Passwort interaktiv ab und wiederholt denselben Request. Das Passwort wird mit `getpass` ohne Bildschirmausgabe eingelesen.
Optionale Umgebungsvariablen:
```bash
export WEGWICHTEL_URL='https://wegwichtel.de'
export WEGWICHTEL_USER='florian'
# WEGWICHTEL_PASSWORD nur für unbeaufsichtigte Aufrufe verwenden.
```
Alle Skripte unterstützen außerdem `--base-url` und `--username`. Fehlende fachliche Parameter werden interaktiv abgefragt.
## Routen
```bash
python3 tools/python/create_route.py
python3 tools/python/update_route.py
python3 tools/python/append_route.py
python3 tools/python/delete_route.py
python3 tools/python/restore_route.py
```
## POIs
```bash
python3 tools/python/create_poi.py
python3 tools/python/update_poi.py
python3 tools/python/delete_poi.py
```
Beim Löschen eines POIs entfernt die API auch dessen Bilder und Audiodatei.
## Bilder
```bash
python3 tools/python/create_picture.py
python3 tools/python/update_picture.py
python3 tools/python/delete_picture.py
```
## Audio
```bash
python3 tools/python/create_audio.py
python3 tools/python/update_audio.py
python3 tools/python/delete_audio.py
```
## Beispiel mit Parametern
```bash
python3 tools/python/create_picture.py \
--base-url https://wegwichtel.de \
--route-id 1 \
--poi-id 7 \
--file ./eiche.jpg \
--caption 'Blick auf die Baumkrone' \
--sequence 0
```
Die Multipart-Hilfsfunktion sendet für Dateien absichtlich keinen behaupteten MIME-Typ. Der Server bestimmt das Format aus dem Dateiinhalt.

View File

@ -0,0 +1,14 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="GPX-Punkte an eine Route anhängen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--gpx")
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
gpx = ask_path("GPX-Datei", args.gpx)
return client.request("POST", f"/api/routes/{route_id}/append", files={"gpx": gpx})
run(action)

241
tools/python/common.py Normal file
View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Gemeinsame Funktionen für die Wegwichtel-API-Werkzeuge."""
from __future__ import annotations
import argparse
import base64
import getpass
import json
import os
import sys
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin
from urllib.request import Request, urlopen
DEFAULT_BASE_URL = "https://wegwichtel.de"
class ApiError(RuntimeError):
def __init__(self, status: int, message: str, payload: Any = None):
super().__init__(f"HTTP {status}: {message}")
self.status = status
self.payload = payload
@dataclass
class Credentials:
username: str
password: str
class ApiClient:
"""Kleine HTTP-Schicht mit dynamischer Basic-Auth-Nachfrage bei 401."""
def __init__(self, base_url: str, username: str | None = None, password: str | None = None):
self.base_url = base_url.rstrip("/") + "/"
self.credentials = Credentials(username, password) if username and password else None
def prompt_credentials(self) -> None:
default_user = self.credentials.username if self.credentials else os.getenv("WEGWICHTEL_USER", "")
username = input(f"Benutzername{f' [{default_user}]' if default_user else ''}: ").strip() or default_user
if not username:
raise ApiError(401, "Für den geschützten API-Zugriff ist ein Benutzername erforderlich.")
password = getpass.getpass("Passwort: ")
self.credentials = Credentials(username, password)
def _authorization_header(self) -> str | None:
if not self.credentials:
return None
token = base64.b64encode(
f"{self.credentials.username}:{self.credentials.password}".encode("utf-8")
).decode("ascii")
return f"Basic {token}"
def request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
fields: dict[str, Any] | None = None,
files: dict[str, Path] | None = None,
expected: tuple[int, ...] = (200, 201),
) -> Any:
data: bytes | None = None
headers = {"Accept": "application/json"}
if files:
data, content_type = encode_multipart(fields or {}, files)
headers["Content-Type"] = content_type
elif json_body is not None:
data = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
elif fields:
data = json.dumps(fields, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
url = urljoin(self.base_url, path.lstrip("/"))
attempts = 0
while True:
request_headers = dict(headers)
authorization = self._authorization_header()
if authorization:
request_headers["Authorization"] = authorization
request = Request(url, data=data, headers=request_headers, method=method.upper())
try:
with urlopen(request, timeout=120) as response:
raw = response.read()
status = response.status
if status not in expected:
raise ApiError(status, decode_error(raw), parse_json(raw))
return parse_response(raw, response.headers.get("Content-Type", ""))
except HTTPError as error:
raw = error.read()
if error.code == 401 and attempts < 3:
print("Die API verlangt Zugangsdaten (HTTP 401).", file=sys.stderr)
self.prompt_credentials()
attempts += 1
continue
raise ApiError(error.code, decode_error(raw), parse_json(raw)) from error
except URLError as error:
raise RuntimeError(f"API nicht erreichbar: {error.reason}") from error
def encode_multipart(fields: dict[str, Any], files: dict[str, Path]) -> tuple[bytes, str]:
"""Erzeugt multipart/form-data; Dateitypen werden absichtlich nicht mitgesendet."""
boundary = f"----wegwichtel-{uuid.uuid4().hex}"
chunks: list[bytes] = []
for name, value in fields.items():
if value is None:
continue
chunks.extend([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
str(value).encode("utf-8"),
b"\r\n",
])
for name, file_path in files.items():
path = Path(file_path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"Datei nicht gefunden: {path}")
safe_name = path.name.replace('"', "_")
chunks.extend([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{name}"; filename="{safe_name}"\r\n\r\n'.encode(),
path.read_bytes(),
b"\r\n",
])
chunks.append(f"--{boundary}--\r\n".encode())
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
def parse_json(raw: bytes) -> Any:
if not raw:
return None
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
def parse_response(raw: bytes, content_type: str) -> Any:
if not raw:
return None
if "json" in content_type.lower():
parsed = parse_json(raw)
if parsed is not None:
return parsed
return raw.decode("utf-8", errors="replace")
def decode_error(raw: bytes) -> str:
parsed = parse_json(raw)
if isinstance(parsed, dict):
return str(parsed.get("message") or parsed.get("error") or parsed)
text = raw.decode("utf-8", errors="replace").strip()
return text or "Unbekannter API-Fehler"
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--base-url", help="API-Basisadresse, Standard: WEGWICHTEL_URL oder https://wegwichtel.de")
parser.add_argument("--username", help="Benutzername; Passwort wird bei HTTP 401 sicher abgefragt")
def client_from_args(args: argparse.Namespace) -> ApiClient:
base_url = args.base_url or os.getenv("WEGWICHTEL_URL") or DEFAULT_BASE_URL
username = args.username or os.getenv("WEGWICHTEL_USER")
password = os.getenv("WEGWICHTEL_PASSWORD") if username else None
return ApiClient(base_url, username, password)
def ask(label: str, value: Any = None, *, required: bool = False, cast: Callable[[str], Any] = str) -> Any:
if value is not None:
return cast(str(value))
while True:
raw = input(f"{label}: ").strip()
if raw:
try:
return cast(raw)
except (TypeError, ValueError) as error:
print(f"Ungültiger Wert: {error}", file=sys.stderr)
continue
if not required:
return None
print("Dieses Feld ist erforderlich.", file=sys.stderr)
def ask_with_default(label: str, current: Any, cast: Callable[[str], Any] = str) -> Any:
shown = "" if current is None else str(current)
while True:
raw = input(f"{label} [{shown}]: ").strip()
if not raw:
return current
try:
return cast(raw)
except (TypeError, ValueError) as error:
print(f"Ungültiger Wert: {error}", file=sys.stderr)
def ask_path(label: str, value: str | None = None, *, required: bool = True) -> Path | None:
if value is not None:
path = Path(value).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"Datei nicht gefunden: {path}")
return path
while True:
raw = input(f"{label}: ").strip()
if not raw and not required:
return None
if not raw:
print("Dieses Feld ist erforderlich.", file=sys.stderr)
continue
path = Path(raw).expanduser().resolve()
if path.is_file():
return path
print(f"Datei nicht gefunden: {path}", file=sys.stderr)
def confirm(question: str) -> bool:
return input(f"{question} [j/N]: ").strip().lower() in {"j", "ja", "y", "yes"}
def print_result(result: Any) -> None:
print(json.dumps(result, ensure_ascii=False, indent=2) if not isinstance(result, str) else result)
def run(action: Callable[[], Any]) -> None:
try:
print_result(action())
except (ApiError, RuntimeError, FileNotFoundError, ValueError) as error:
print(f"Fehler: {error}", file=sys.stderr)
raise SystemExit(1) from error

View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="Audiodatei zu einem POI hochladen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
parser.add_argument("--file")
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
audio = ask_path("Audiodatei", args.file)
return client.request("POST", f"/api/routes/{route_id}/pois/{poi_id}/audio", files={"audio": audio})
run(action)

View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="Bild zu einem POI hochladen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
parser.add_argument("--file")
parser.add_argument("--caption")
parser.add_argument("--sequence", type=int)
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
picture = ask_path("Bilddatei", args.file)
fields = {
"caption": ask("Bildbeschreibung", args.caption),
"sequence": ask("Reihenfolge (leer: automatisch)", args.sequence, cast=int),
}
return client.request("POST", f"/api/routes/{route_id}/pois/{poi_id}/pictures", fields=fields, files={"picture": picture})
run(action)

View File

@ -0,0 +1,24 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, client_from_args, run
parser = argparse.ArgumentParser(description="POI anlegen")
add_common_arguments(parser)
for name, kwargs in [
("--route-id", {"type": int}), ("--title", {}), ("--description", {}),
("--lat", {"type": float}), ("--lon", {"type": float}),
("--trigger-radius", {"type": float}), ("--sequence", {"type": int})
]: parser.add_argument(name, **kwargs)
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
body = {
"title": ask("Titel", args.title),
"description": ask("Beschreibung", args.description),
"lat": ask("Breitengrad", args.lat, required=True, cast=float),
"lon": ask("Längengrad", args.lon, required=True, cast=float),
"triggerRadiusM": ask("Auslöseradius in Metern", args.trigger_radius, cast=float),
"sequence": ask("Reihenfolge", args.sequence, cast=int),
}
return client.request("POST", f"/api/routes/{route_id}/pois", json_body={k:v for k,v in body.items() if v is not None})
run(action)

View File

@ -0,0 +1,24 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="Neue Wegwichtel-Route anlegen")
add_common_arguments(parser)
parser.add_argument("--gpx")
parser.add_argument("--name")
parser.add_argument("--slug")
parser.add_argument("--description")
parser.add_argument("--school-name")
args = parser.parse_args()
def action():
client = client_from_args(args)
gpx = ask_path("GPX-Datei", args.gpx)
fields = {
"name": ask("Name (leer: GPX-Name)", args.name),
"slug": ask("Slug (leer: automatisch)", args.slug),
"description": ask("Beschreibung", args.description),
"schoolName": ask("Schule", args.school_name),
}
return client.request("POST", "/api/routes", fields=fields, files={"gpx": gpx})
run(action)

View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, client_from_args, confirm, run
parser = argparse.ArgumentParser(description="Audiodatei eines POIs löschen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
if not confirm(f"Audio für POI {poi_id} löschen?"):
return {"cancelled": True}
return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}/audio")
run(action)

View File

@ -0,0 +1,18 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, client_from_args, confirm, run
parser = argparse.ArgumentParser(description="Ein Bild löschen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
parser.add_argument("--picture-id", type=int)
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
picture_id = ask("Bild-ID", args.picture_id, required=True, cast=int)
if not confirm(f"Bild {picture_id} löschen?"):
return {"cancelled": True}
return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}/pictures/{picture_id}")
run(action)

View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, client_from_args, confirm, run
parser = argparse.ArgumentParser(description="POI einschließlich Medien löschen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
if not confirm(f"POI {poi_id} einschließlich Bilder und Audio löschen?"):
return {"cancelled": True}
return client.request("DELETE", f"/api/routes/{route_id}/pois/{poi_id}")
run(action)

View File

@ -0,0 +1,14 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, client_from_args, confirm, run
parser = argparse.ArgumentParser(description="Route weich löschen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
if not confirm(f"Route {route_id} in den Papierkorb verschieben?"):
return {"cancelled": True}
return client.request("DELETE", f"/api/routes/{route_id}")
run(action)

View File

@ -0,0 +1,12 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, client_from_args, run
parser = argparse.ArgumentParser(description="Gelöschte Route wiederherstellen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
return client.request("POST", f"/api/routes/{route_id}/restore", json_body={})
run(action)

View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, ask_path, client_from_args, run
parser = argparse.ArgumentParser(description="Audiodatei eines POIs ersetzen")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
parser.add_argument("--file")
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
audio = ask_path("Neue Audiodatei", args.file)
return client.request("PUT", f"/api/routes/{route_id}/pois/{poi_id}/audio", files={"audio": audio})
run(action)

View File

@ -0,0 +1,26 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, ask_path, ask_with_default, client_from_args, run
parser = argparse.ArgumentParser(description="Bilddatei oder Bildmetadaten ändern")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
parser.add_argument("--picture-id", type=int)
parser.add_argument("--file")
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
picture_id = ask("Bild-ID", args.picture_id, required=True, cast=int)
current = client.request("GET", f"/api/routes/{route_id}/pois/{poi_id}/pictures/{picture_id}?metadata=true")
fields = {
"caption": ask_with_default("Bildbeschreibung", current.get("caption", "")),
"sequence": ask_with_default("Reihenfolge", current.get("sequence", 0), int),
}
picture = ask_path("Neue Bilddatei (leer: unverändert)", args.file, required=False)
path = f"/api/routes/{route_id}/pois/{poi_id}/pictures/{picture_id}"
if picture:
return client.request("PUT", path, fields=fields, files={"picture": picture})
return client.request("PUT", path, json_body=fields)
run(action)

View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, ask_with_default, client_from_args, run
parser = argparse.ArgumentParser(description="POI ändern")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--poi-id", type=int)
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
poi_id = ask("POI-ID", args.poi_id, required=True, cast=int)
current = client.request("GET", f"/api/routes/{route_id}/pois/{poi_id}")
body = {
"title": ask_with_default("Titel", current.get("title", "")),
"description": ask_with_default("Beschreibung", current.get("description", "")),
"lat": ask_with_default("Breitengrad", current.get("lat"), float),
"lon": ask_with_default("Längengrad", current.get("lon"), float),
"triggerRadiusM": ask_with_default("Auslöseradius in Metern", current.get("triggerRadiusM"), float),
"sequence": ask_with_default("Reihenfolge", current.get("sequence"), int),
}
return client.request("PUT", f"/api/routes/{route_id}/pois/{poi_id}", json_body=body)
run(action)

View File

@ -0,0 +1,24 @@
#!/usr/bin/env python3
import argparse
from common import add_common_arguments, ask, ask_path, ask_with_default, client_from_args, run
parser = argparse.ArgumentParser(description="Wegwichtel-Route ändern")
add_common_arguments(parser)
parser.add_argument("--route-id", type=int)
parser.add_argument("--gpx")
args = parser.parse_args()
def action():
client = client_from_args(args)
route_id = ask("Route-ID", args.route_id, required=True, cast=int)
current = client.request("GET", f"/api/routes/{route_id}")
fields = {
"name": ask_with_default("Name", current.get("name", "")),
"description": ask_with_default("Beschreibung", current.get("description", "")),
"schoolName": ask_with_default("Schule", current.get("schoolName", "")),
}
gpx = ask_path("Neue GPX-Datei (leer: unverändert)", args.gpx, required=False)
if gpx:
return client.request("PUT", f"/api/routes/{route_id}", fields=fields, files={"gpx": gpx})
return client.request("PUT", f"/api/routes/{route_id}", json_body=fields)
run(action)