Tasks, eigene Typen und Metadaten pro Editor-Aufruf laden und an den Aufrufer zurückgeben. MongoDB-Persistenz entfernen und Header auf Rückgängig, Abbrechen und Fertig umstellen. Python-Testserver und Regressionstests ergänzen; Verbindungslinien nach Import und Layoutänderungen neu berechnen.
286 lines
13 KiB
Python
286 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Lokaler Aufrufer für den TaskList Editor; benötigt nur Python 3.9+.
|
|
|
|
Start: python3 tools/editor_test_server.py --json tools/example_tasks.json
|
|
Der Editor muss separat laufen (standardmäßig http://127.0.0.1:8080).
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import webbrowser
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
|
|
MAX_BODY_BYTES = 2 * 1024 * 1024
|
|
TOKEN_TTL_SECONDS = 60 * 60
|
|
RESULT_TYPE = "tasklist-editor:result"
|
|
HTML_PATH = Path(__file__).with_suffix(".html")
|
|
PRINT_LOCK = threading.Lock()
|
|
|
|
|
|
class RequestError(Exception):
|
|
def __init__(self, status, message):
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
def reject_constant(value):
|
|
raise ValueError("Ungültiger JSON-Zahlenwert: " + value)
|
|
|
|
|
|
def decode_json(raw):
|
|
try:
|
|
return json.loads(raw.decode("utf-8-sig"), parse_constant=reject_constant)
|
|
except (UnicodeError, ValueError, RecursionError) as exc:
|
|
raise RequestError(400, "Ungültiges JSON: " + str(exc)) from exc
|
|
|
|
|
|
def origin_for(url):
|
|
parsed = urllib.parse.urlsplit(url)
|
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
raise ValueError("Die Editor-URL muss eine vollständige http(s)-URL sein.")
|
|
if parsed.username or parsed.password:
|
|
raise ValueError("Zugangsdaten in der Editor-URL werden nicht unterstützt.")
|
|
port = parsed.port
|
|
host = parsed.hostname.lower()
|
|
if ":" in host:
|
|
host = "[" + host + "]"
|
|
if port and port != {"http": 80, "https": 443}[parsed.scheme]:
|
|
host += ":" + str(port)
|
|
return parsed.scheme + "://" + host
|
|
|
|
|
|
def normalize_payload(value):
|
|
if isinstance(value, list):
|
|
value = {"tasks": value}
|
|
if not isinstance(value, dict):
|
|
raise RequestError(400, "Erwartet wird ein tasks-Array oder ein Objekt mit name, tasks, customTypes und metadata.")
|
|
tasks = value.get("tasks")
|
|
if tasks is None:
|
|
tasks = []
|
|
if not isinstance(tasks, list) or any(not isinstance(task, dict) for task in tasks):
|
|
raise RequestError(400, "tasks muss ein Array aus Task-Objekten sein; [] legt eine neue Liste an.")
|
|
custom_types = value.get("customTypes")
|
|
if custom_types is None:
|
|
custom_types = []
|
|
if not isinstance(custom_types, list) or any(not isinstance(item, dict) for item in custom_types):
|
|
raise RequestError(400, "customTypes muss ein Array aus Typ-Objekten sein; [] übergibt keine eigenen Typen.")
|
|
if value.get("name") is not None and not isinstance(value["name"], str):
|
|
raise RequestError(400, "name muss eine Zeichenkette oder null sein.")
|
|
payload = {"tasks": tasks, "customTypes": custom_types}
|
|
for key in ("name", "metadata"):
|
|
if key in value:
|
|
payload[key] = value[key]
|
|
return payload
|
|
|
|
|
|
class EditorTestServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
|
|
def __init__(self, port, editor_url, initial_payload):
|
|
editor_url = editor_url.rstrip("/")
|
|
self.editor_origin = origin_for(editor_url)
|
|
parsed = urllib.parse.urlsplit(editor_url)
|
|
if parsed.query or parsed.fragment:
|
|
raise ValueError("Die Editor-URL darf keine Query-Parameter oder Fragmente enthalten.")
|
|
self.editor_url = editor_url
|
|
self.initial_payload = normalize_payload(initial_payload)
|
|
self.sessions = {}
|
|
self.session_lock = threading.Lock()
|
|
super().__init__(("127.0.0.1", port), EditorTestHandler)
|
|
self.local_origin = "http://127.0.0.1:" + str(self.server_port)
|
|
|
|
def open_editor(self, value):
|
|
payload = normalize_payload(value)
|
|
payload["returnOrigin"] = self.local_origin
|
|
request = urllib.request.Request(
|
|
self.editor_url + "/api/editor/import",
|
|
data=json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8"),
|
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
raw = response.read(MAX_BODY_BYTES + 1)
|
|
except urllib.error.HTTPError as exc:
|
|
detail = exc.read(4096).decode("utf-8", errors="replace").strip()
|
|
raise RequestError(502, "Editor lehnt den Import ab (HTTP {}): {}".format(
|
|
exc.code, detail or exc.reason)) from exc
|
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
raise RequestError(502, "Editor unter {} nicht erreichbar: {}".format(self.editor_url, exc)) from exc
|
|
if len(raw) > MAX_BODY_BYTES:
|
|
raise RequestError(502, "Die Antwort des Editors ist zu groß.")
|
|
try:
|
|
result = decode_json(raw)
|
|
token = result.get("token") if isinstance(result, dict) else None
|
|
deep_link = result.get("deepLink") if isinstance(result, dict) else None
|
|
if not isinstance(token, str) or not token or len(token) > 512:
|
|
raise ValueError("token fehlt oder ist ungültig")
|
|
if not isinstance(deep_link, str) or origin_for(deep_link) != self.editor_origin:
|
|
raise ValueError("deepLink muss auf den konfigurierten Editor-Origin zeigen")
|
|
except (RequestError, ValueError) as exc:
|
|
raise RequestError(502, "Ungültige Import-Antwort des Editors: " + str(exc)) from exc
|
|
with self.session_lock:
|
|
now = time.monotonic()
|
|
self.sessions = {key: session for key, session in self.sessions.items()
|
|
if now - session["created"] < TOKEN_TTL_SECONDS}
|
|
if len(self.sessions) >= 1000:
|
|
raise RequestError(503, "Zu viele offene Tests. Bitte den Testserver neu starten.")
|
|
self.sessions[token] = {"created": now, "result": None}
|
|
return {"token": token, "deepLink": deep_link}
|
|
|
|
def receive_result(self, result):
|
|
if not isinstance(result, dict) or result.get("type") != RESULT_TYPE:
|
|
raise RequestError(400, "Ungültiger Rückgabetyp.")
|
|
token = result.get("token")
|
|
if not isinstance(token, str):
|
|
raise RequestError(400, "Die Rückgabe benötigt ein token.")
|
|
status = result.get("status")
|
|
if status not in ("completed", "cancelled"):
|
|
raise RequestError(400, "status muss completed oder cancelled sein.")
|
|
if status == "completed" and (not isinstance(result.get("tasks"), list)
|
|
or any(not isinstance(task, dict) for task in result["tasks"])):
|
|
raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt ein tasks-Array.")
|
|
if status == "completed" and (not isinstance(result.get("customTypes"), list)
|
|
or any(not isinstance(item, dict) for item in result["customTypes"])):
|
|
raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt den vollständigen customTypes-Katalog als Array.")
|
|
if status == "cancelled" and ("tasks" in result or "customTypes" in result):
|
|
raise RequestError(400, "Eine abgebrochene Bearbeitung darf keine tasks oder customTypes zurückgeben.")
|
|
if result.get("name") is not None and not isinstance(result["name"], str):
|
|
raise RequestError(400, "name muss eine Zeichenkette oder null sein.")
|
|
with self.session_lock:
|
|
session = self.sessions.get(token)
|
|
if session is None or time.monotonic() - session["created"] >= TOKEN_TTL_SECONDS:
|
|
raise RequestError(404, "Unbekanntes oder abgelaufenes token. Bitte einen neuen Test öffnen.")
|
|
# Ein Retry nach verlorener HTTP-Antwort darf dieselbe Rückgabe erneut bestätigen.
|
|
if session["result"] is not None:
|
|
if session["result"] != result:
|
|
raise RequestError(409, "Für dieses token wurde bereits ein anderes Ergebnis empfangen.")
|
|
return {"ok": True, "duplicate": True}
|
|
with PRINT_LOCK:
|
|
print("\n--- Editor-Rückgabe ---", flush=True)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False), flush=True)
|
|
session["result"] = result
|
|
return {"ok": True}
|
|
|
|
|
|
class EditorTestHandler(BaseHTTPRequestHandler):
|
|
server_version = "TaskListTestServer/1.0"
|
|
|
|
def setup(self):
|
|
super().setup()
|
|
self.connection.settimeout(20)
|
|
|
|
def log_message(self, fmt, *args):
|
|
with PRINT_LOCK:
|
|
print("[{}] {}".format(self.log_date_time_string(), fmt % args), file=sys.stderr, flush=True)
|
|
|
|
def send_bytes(self, status, body, content_type):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def send_json(self, status, value):
|
|
self.send_bytes(status, json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8"),
|
|
"application/json; charset=utf-8")
|
|
|
|
def check_host(self):
|
|
expected = urllib.parse.urlsplit(self.server.local_origin).netloc
|
|
if self.headers.get("Host") != expected:
|
|
raise RequestError(403, "Bitte den Testserver über {} öffnen.".format(self.server.local_origin))
|
|
|
|
def read_json(self):
|
|
if self.headers.get("Origin") != self.server.local_origin:
|
|
raise RequestError(403, "Anfragen müssen von der lokalen Testseite stammen.")
|
|
if self.headers.get_content_type() != "application/json":
|
|
raise RequestError(415, "Content-Type application/json wird benötigt.")
|
|
if self.headers.get("Transfer-Encoding"):
|
|
raise RequestError(400, "Transfer-Encoding wird nicht unterstützt.")
|
|
try:
|
|
length = int(self.headers["Content-Length"])
|
|
except (TypeError, ValueError) as exc:
|
|
raise RequestError(411, "Content-Length wird benötigt.") from exc
|
|
if length <= 0 or length > MAX_BODY_BYTES:
|
|
raise RequestError(413, "Der JSON-Body muss zwischen 1 Byte und 2 MiB groß sein.")
|
|
raw = self.rfile.read(length)
|
|
if len(raw) != length:
|
|
raise RequestError(400, "Unvollständiger Request-Body.")
|
|
return decode_json(raw)
|
|
|
|
def do_GET(self):
|
|
try:
|
|
self.check_host()
|
|
if self.path in ("/", "/index.html"):
|
|
self.send_bytes(200, HTML_PATH.read_bytes(), "text/html; charset=utf-8")
|
|
elif self.path == "/api/config":
|
|
self.send_json(200, {"editorUrl": self.server.editor_url,
|
|
"editorOrigin": self.server.editor_origin,
|
|
"initialPayload": self.server.initial_payload})
|
|
else:
|
|
raise RequestError(404, "Nicht gefunden.")
|
|
except RequestError as exc:
|
|
self.send_json(exc.status, {"error": str(exc)})
|
|
except OSError as exc:
|
|
self.send_json(500, {"error": "Testseite konnte nicht geladen werden: " + str(exc)})
|
|
|
|
def do_POST(self):
|
|
try:
|
|
self.check_host()
|
|
if self.path not in ("/api/open", "/api/result"):
|
|
raise RequestError(404, "Nicht gefunden.")
|
|
value = self.read_json()
|
|
result = self.server.open_editor(value) if self.path == "/api/open" else self.server.receive_result(value)
|
|
self.send_json(200, result)
|
|
except RequestError as exc:
|
|
self.send_json(exc.status, {"error": str(exc)})
|
|
except (TimeoutError, OSError) as exc:
|
|
self.send_json(408, {"error": "Zeitüberschreitung beim Lesen der Anfrage: " + str(exc)})
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--port", type=int, default=8090, help="Lokaler Port (Standard: 8090)")
|
|
parser.add_argument("--editor-url", default="http://127.0.0.1:8080", help="Basis-URL des laufenden Editors")
|
|
parser.add_argument("--json", type=Path, help="JSON-Datei zum Vorbelegen (Standard: tools/example_tasks.json)")
|
|
parser.add_argument("--no-browser", action="store_true", help="Browser nicht automatisch öffnen")
|
|
args = parser.parse_args()
|
|
if not 0 <= args.port <= 65535:
|
|
parser.error("--port muss zwischen 0 und 65535 liegen.")
|
|
try:
|
|
json_path = args.json or Path(__file__).with_name("example_tasks.json")
|
|
with json_path.open("rb") as source:
|
|
raw = source.read(MAX_BODY_BYTES + 1)
|
|
if len(raw) > MAX_BODY_BYTES:
|
|
raise ValueError("Die JSON-Datei darf höchstens 2 MiB groß sein.")
|
|
initial_payload = decode_json(raw)
|
|
server = EditorTestServer(args.port, args.editor_url, initial_payload)
|
|
except (RequestError, ValueError, OSError) as exc:
|
|
parser.error(str(exc))
|
|
print("Testseite: " + server.local_origin + "/", flush=True)
|
|
print("Editor: " + server.editor_url, flush=True)
|
|
print("Rückgaben erscheinen hier als JSON. Beenden mit Strg+C.", flush=True)
|
|
if not args.no_browser:
|
|
webbrowser.open(server.local_origin + "/")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nTestserver beendet.", flush=True)
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|