451 lines
21 KiB
Python
451 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Trainingsplan- und Rezepteditor mit Entwurf/Veröffentlichung und Konfliktschutz."""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from flask import Flask, Response, jsonify, request, send_file
|
|
|
|
import plan_builder
|
|
import recipe_builder
|
|
from schema_contract import migrate_training_config, validate_training_config, stable_id
|
|
from storage import atomic_json_write, utc_now
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data")).resolve()
|
|
PLANS_DIR = DATA_DIR / "plans"
|
|
PRESETS_DIR = BASE_DIR / "presets"
|
|
ACTIVE_FILE = DATA_DIR / "active.txt"
|
|
LEGACY_CONFIG = DATA_DIR / "config.json"
|
|
STATIC_DIR = BASE_DIR / "static"
|
|
BACKUPS_DIR = DATA_DIR / "backups"
|
|
TRACKER_PROPOSALS_DIR = DATA_DIR.parent / "trainingstracker" / "proposals"
|
|
TRACKER_SESSIONS_DIR = DATA_DIR.parent / "trainingstracker" / "sessions"
|
|
PLANS_DIR.mkdir(parents=True, exist_ok=True)
|
|
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
|
app = Flask(__name__, static_folder=None)
|
|
|
|
|
|
def builder_for(cfg: dict[str, Any]):
|
|
return recipe_builder if (cfg or {}).get("type") == "recipe" else plan_builder
|
|
|
|
|
|
def default_for(kind: str):
|
|
cfg = recipe_builder.default_config() if kind == "recipe" else plan_builder.default_config()
|
|
return cfg if kind == "recipe" else migrate_training_config(cfg)
|
|
|
|
|
|
def slugify(name: Any) -> str:
|
|
s = str(name or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
|
return re.sub(r"[^a-z0-9]+", "-", s).strip("-") or "phase"
|
|
|
|
|
|
def plan_path(pid: str) -> Path:
|
|
return PLANS_DIR / f"{pid}.json"
|
|
|
|
|
|
def unique_id(base: str) -> str:
|
|
stem, index = base, 2
|
|
while plan_path(stem).exists():
|
|
stem = f"{base}-{index}"; index += 1
|
|
return stem
|
|
|
|
|
|
def _normalize_wrapper(pid: str, raw: Any) -> dict[str, Any]:
|
|
if not isinstance(raw, dict):
|
|
raw = {}
|
|
name = str(raw.get("name") or pid)
|
|
published = raw.get("config") if isinstance(raw.get("config"), dict) else {}
|
|
is_recipe = published.get("type") == "recipe"
|
|
if not is_recipe:
|
|
published = migrate_training_config(published, plan_id=str(raw.get("plan_id") or pid))
|
|
draft = raw.get("draft") if isinstance(raw.get("draft"), dict) else deepcopy(published)
|
|
if not is_recipe:
|
|
draft = migrate_training_config(draft, plan_id=published.get("plan_id") or pid)
|
|
revision = max(1, int(raw.get("revision") or 1))
|
|
published_revision = max(1, int(raw.get("published_revision") or 1))
|
|
return {
|
|
"id": pid,
|
|
"plan_id": str(raw.get("plan_id") or published.get("plan_id") or stable_id("plan", pid, name)),
|
|
"name": name,
|
|
"revision": revision,
|
|
"published_revision": published_revision,
|
|
"created_at": str(raw.get("created_at") or utc_now()),
|
|
"updated_at": str(raw.get("updated_at") or utc_now()),
|
|
"published_at": str(raw.get("published_at") or raw.get("updated_at") or utc_now()),
|
|
"config": published,
|
|
"draft": draft,
|
|
}
|
|
|
|
|
|
def read_plan(pid: str) -> dict[str, Any]:
|
|
with plan_path(pid).open("r", encoding="utf-8") as handle:
|
|
return _normalize_wrapper(pid, json.load(handle))
|
|
|
|
|
|
def write_plan(pid: str, wrapper: dict[str, Any], *, backup: bool = True) -> dict[str, Any]:
|
|
normalized = _normalize_wrapper(pid, wrapper)
|
|
normalized["updated_at"] = utc_now()
|
|
atomic_json_write(plan_path(pid), {k: v for k, v in normalized.items() if k != "id"}, BACKUPS_DIR if backup else None)
|
|
return normalized
|
|
|
|
|
|
def create_wrapper(pid: str, name: str, cfg: dict[str, Any]) -> dict[str, Any]:
|
|
if cfg.get("type") != "recipe":
|
|
cfg = migrate_training_config(cfg, plan_id=pid)
|
|
now = utc_now()
|
|
return {
|
|
"id": pid, "plan_id": cfg.get("plan_id") or pid, "name": name,
|
|
"revision": 1, "published_revision": 1,
|
|
"created_at": now, "updated_at": now, "published_at": now,
|
|
"config": deepcopy(cfg), "draft": deepcopy(cfg),
|
|
}
|
|
|
|
|
|
def list_plan_ids() -> list[str]:
|
|
return sorted(path.stem for path in PLANS_DIR.glob("*.json"))
|
|
|
|
|
|
def plan_name(pid: str) -> str:
|
|
try: return read_plan(pid)["name"]
|
|
except Exception: return pid
|
|
|
|
|
|
def get_active() -> str | None:
|
|
pid = ACTIVE_FILE.read_text(encoding="utf-8").strip() if ACTIVE_FILE.exists() else None
|
|
ids = list_plan_ids()
|
|
return pid if pid in ids else (ids[0] if ids else None)
|
|
|
|
|
|
def set_active(pid: str) -> None:
|
|
ACTIVE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
ACTIVE_FILE.write_text(pid, encoding="utf-8")
|
|
|
|
|
|
def install_presets() -> None:
|
|
if not PRESETS_DIR.is_dir(): return
|
|
for source in sorted(PRESETS_DIR.glob("*.json")):
|
|
if plan_path(source.stem).exists(): continue
|
|
try:
|
|
raw = json.loads(source.read_text(encoding="utf-8"))
|
|
cfg = raw.get("config", {}) if isinstance(raw, dict) else {}
|
|
wrapper = create_wrapper(source.stem, str(raw.get("name") or source.stem), cfg)
|
|
write_plan(source.stem, wrapper, backup=False)
|
|
except Exception: pass
|
|
|
|
|
|
def ensure_seed() -> None:
|
|
if not list_plan_ids():
|
|
try: cfg = json.loads(LEGACY_CONFIG.read_text(encoding="utf-8")) if LEGACY_CONFIG.exists() else default_for("training")
|
|
except Exception: cfg = default_for("training")
|
|
write_plan("phase-1", create_wrapper("phase-1", "Phase 1", cfg), backup=False)
|
|
install_presets()
|
|
if get_active() is None and list_plan_ids(): set_active(list_plan_ids()[0])
|
|
|
|
|
|
def _tracked_session_count(pid: str) -> int:
|
|
path = TRACKER_SESSIONS_DIR / f"{pid}.json"
|
|
if not path.exists():
|
|
return 0
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return 0
|
|
sessions = raw.get("sessions") if isinstance(raw, dict) and isinstance(raw.get("sessions"), dict) else {}
|
|
count = 0
|
|
for session in sessions.values():
|
|
if not isinstance(session, dict):
|
|
continue
|
|
if str(session.get("status") or "planned") != "planned" or any(
|
|
isinstance(item, dict) and (
|
|
item.get("done") or item.get("completion_status") in {"completed", "partial", "skipped"}
|
|
or any(str(item.get(field) or "").strip() for field in ("result", "note", "progression", "skip_reason"))
|
|
) for item in (session.get("items") or {}).values()
|
|
):
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def public_wrapper(wrapper: dict[str, Any], *, draft: bool = True) -> dict[str, Any]:
|
|
return {
|
|
"id": wrapper["id"], "plan_id": wrapper["plan_id"], "name": wrapper["name"],
|
|
"revision": wrapper["revision"], "published_revision": wrapper["published_revision"],
|
|
"updated_at": wrapper["updated_at"], "published_at": wrapper["published_at"],
|
|
"has_unpublished_changes": wrapper["draft"] != wrapper["config"],
|
|
"tracked_sessions": _tracked_session_count(wrapper["id"]),
|
|
"config": deepcopy(wrapper["draft"] if draft else wrapper["config"]),
|
|
}
|
|
|
|
|
|
def _validation(config: dict[str, Any]) -> dict[str, Any]:
|
|
if config.get("type") == "recipe": return {"errors": [], "warnings": [], "config": config}
|
|
return validate_training_config(config)
|
|
|
|
|
|
def _diff_summary(old: Any, new: Any) -> dict[str, Any]:
|
|
"""Erzeugt einen stabil-ID-basierten Änderungsbericht für die Veröffentlichung."""
|
|
old_cfg = old if isinstance(old, dict) else {}
|
|
new_cfg = new if isinstance(new, dict) else {}
|
|
|
|
def collect(cfg: dict[str, Any]) -> dict[str, dict[str, dict[str, Any]]]:
|
|
result: dict[str, dict[str, dict[str, Any]]] = {
|
|
"days": {}, "rotations": {}, "exercises": {}, "progression_steps": {},
|
|
}
|
|
for di, day in enumerate(cfg.get("days", []) if isinstance(cfg.get("days"), list) else []):
|
|
if not isinstance(day, dict):
|
|
continue
|
|
day_id = str(day.get("id") or f"day-index-{di}")
|
|
result["days"][day_id] = {
|
|
"label": str(day.get("focus") or f"Tag {di + 1}"),
|
|
"value": {"num": day.get("num"), "focus": day.get("focus"), "light": bool(day.get("light"))},
|
|
}
|
|
for ri, rotation in enumerate(day.get("rotations", []) if isinstance(day.get("rotations"), list) else []):
|
|
if not isinstance(rotation, dict):
|
|
continue
|
|
rotation_id = str(rotation.get("id") or f"{day_id}:rotation:{ri}")
|
|
result["rotations"][rotation_id] = {
|
|
"label": f"{day.get('focus') or 'Tag'} · {rotation.get('label') or f'Block {ri + 1}'}",
|
|
"value": {"day_id": day_id, "label": rotation.get("label")},
|
|
}
|
|
for ei, exercise in enumerate(rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []):
|
|
if not isinstance(exercise, dict):
|
|
continue
|
|
exercise_id = str(exercise.get("id") or f"{rotation_id}:exercise:{ei}")
|
|
result["exercises"][exercise_id] = {
|
|
"label": str(exercise.get("name") or f"Übung {ei + 1}"),
|
|
"value": {
|
|
"rotation_id": rotation_id,
|
|
"name": exercise.get("name"), "cue": exercise.get("cue"),
|
|
"progression_id": exercise.get("progression_id") or exercise.get("key"),
|
|
"exercise_id": exercise.get("exercise_id"),
|
|
"movement_cluster": exercise.get("movement_cluster"),
|
|
"result_schema": exercise.get("result_schema"),
|
|
},
|
|
}
|
|
stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {}
|
|
for key, stage in stages.items():
|
|
if not isinstance(stage, dict):
|
|
continue
|
|
for si, step in enumerate(stage.get("steps", []) if isinstance(stage.get("steps"), list) else []):
|
|
if not isinstance(step, dict):
|
|
continue
|
|
step_id = str(step.get("id") or f"{key}:step:{si}")
|
|
result["progression_steps"][step_id] = {
|
|
"label": f"{stage.get('name') or key} · {step.get('name') or f'Stufe {si + 1}'}",
|
|
"value": {
|
|
"progression_id": key, "name": step.get("name"),
|
|
"phase_id": step.get("phase_id"), "factor": step.get("factor"),
|
|
"movement_cluster": step.get("movement_cluster"),
|
|
"result_schema": step.get("result_schema"),
|
|
},
|
|
}
|
|
return result
|
|
|
|
before, after = collect(old_cfg), collect(new_cfg)
|
|
summary: dict[str, Any] = {}
|
|
changes: list[dict[str, Any]] = []
|
|
for section in ("days", "rotations", "exercises", "progression_steps"):
|
|
left, right = before[section], after[section]
|
|
added = sorted(set(right) - set(left))
|
|
removed = sorted(set(left) - set(right))
|
|
changed = sorted(key for key in set(left) & set(right) if left[key]["value"] != right[key]["value"])
|
|
summary[section] = {
|
|
"before": len(left), "after": len(right), "delta": len(right) - len(left),
|
|
"added": len(added), "removed": len(removed), "changed": len(changed),
|
|
}
|
|
for key in added:
|
|
changes.append({"section": section, "kind": "added", "id": key, "label": right[key]["label"]})
|
|
for key in removed:
|
|
changes.append({"section": section, "kind": "removed", "id": key, "label": left[key]["label"]})
|
|
for key in changed:
|
|
changes.append({"section": section, "kind": "changed", "id": key, "label": right[key]["label"]})
|
|
summary["changes"] = changes[:50]
|
|
summary["total_changes"] = len(changes)
|
|
summary["training_format_changed"] = old_cfg.get("training_format") != new_cfg.get("training_format")
|
|
summary["plan_meta_changed"] = old_cfg.get("meta") != new_cfg.get("meta")
|
|
return summary
|
|
|
|
|
|
def _proposal_file(pid: str) -> Path:
|
|
return TRACKER_PROPOSALS_DIR / f"{pid}.json"
|
|
|
|
|
|
ensure_seed()
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return Response((STATIC_DIR / "index.html").read_text(encoding="utf-8"), mimetype="text/html")
|
|
|
|
@app.route("/api/plans", methods=["GET"])
|
|
def api_plans():
|
|
active = get_active(); plans = []
|
|
for pid in list_plan_ids():
|
|
try:
|
|
p = read_plan(pid)
|
|
plans.append({"id": pid, "name": p["name"], "active": pid == active, "revision": p["revision"], "published_revision": p["published_revision"], "has_unpublished_changes": p["draft"] != p["config"]})
|
|
except Exception: continue
|
|
return jsonify({"plans": plans, "active": active})
|
|
|
|
@app.route("/api/plans", methods=["POST"])
|
|
def api_plan_create():
|
|
body = request.get_json(force=True) or {}; name = str(body.get("name") or "Neue Phase").strip()
|
|
src = body.get("from"); kind = body.get("kind") or body.get("type") or "training"
|
|
cfg = deepcopy(read_plan(src)["draft"]) if src and plan_path(str(src)).exists() else default_for(str(kind))
|
|
pid = unique_id(slugify(name)); wrapper = create_wrapper(pid, name, cfg); write_plan(pid, wrapper, backup=False); set_active(pid)
|
|
return jsonify(public_wrapper(wrapper))
|
|
|
|
@app.route("/api/plans/<pid>", methods=["GET"])
|
|
def api_plan_get(pid: str):
|
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
|
return jsonify(public_wrapper(read_plan(pid)))
|
|
|
|
@app.route("/api/plans/<pid>/select", methods=["POST"])
|
|
def api_plan_select(pid: str):
|
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
|
set_active(pid); return jsonify(public_wrapper(read_plan(pid)))
|
|
|
|
@app.route("/api/plans/<pid>", methods=["POST"])
|
|
def api_plan_save(pid: str):
|
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
|
body = request.get_json(force=True) or {}
|
|
config = body.get("config") if isinstance(body.get("config"), dict) else body
|
|
expected = body.get("expected_revision") if isinstance(body, dict) else None
|
|
wrapper = read_plan(pid)
|
|
if expected is not None and int(expected) != wrapper["revision"]:
|
|
return jsonify({"error": "Der Plan wurde zwischenzeitlich geändert.", "code": "revision_conflict", "current_revision": wrapper["revision"]}), 409
|
|
validation = _validation(config)
|
|
wrapper["draft"] = validation["config"]
|
|
wrapper["revision"] += 1
|
|
saved = write_plan(pid, wrapper)
|
|
return jsonify({"ok": True, **public_wrapper(saved), "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}})
|
|
|
|
@app.route("/api/plans/<pid>/validate", methods=["POST"])
|
|
def api_plan_validate(pid: str):
|
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
|
body = request.get_json(force=True) or {}
|
|
cfg = body.get("config") if isinstance(body.get("config"), dict) else body
|
|
result = _validation(cfg)
|
|
wrapper = read_plan(pid)
|
|
diff = _diff_summary(wrapper["config"], result["config"])
|
|
diff["tracked_sessions"] = _tracked_session_count(pid)
|
|
return jsonify({"errors": result["errors"], "warnings": result["warnings"], "config": result["config"], "diff": diff})
|
|
|
|
@app.route("/api/plans/<pid>/publish", methods=["POST"])
|
|
def api_plan_publish(pid: str):
|
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
|
body = request.get_json(silent=True) or {}; wrapper = read_plan(pid)
|
|
expected = body.get("expected_revision")
|
|
if expected is not None and int(expected) != wrapper["revision"]:
|
|
return jsonify({"error": "Der Entwurf wurde zwischenzeitlich geändert.", "code": "revision_conflict", "current_revision": wrapper["revision"]}), 409
|
|
validation = _validation(wrapper["draft"])
|
|
if validation["errors"]:
|
|
return jsonify({"error": "Der Plan enthält Validierungsfehler.", "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}}), 400
|
|
diff = _diff_summary(wrapper["config"], validation["config"])
|
|
diff["tracked_sessions"] = _tracked_session_count(pid)
|
|
wrapper["config"] = deepcopy(validation["config"])
|
|
wrapper["draft"] = deepcopy(validation["config"])
|
|
wrapper["published_revision"] += 1
|
|
wrapper["revision"] += 1
|
|
wrapper["published_at"] = utc_now()
|
|
saved = write_plan(pid, wrapper)
|
|
return jsonify({"ok": True, **public_wrapper(saved), "diff": diff, "validation": {"errors": [], "warnings": validation["warnings"]}})
|
|
|
|
@app.route("/api/plans/<pid>/rename", methods=["POST"])
|
|
def api_plan_rename(pid: str):
|
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
|
name = str((request.get_json(force=True) or {}).get("name") or "").strip()
|
|
if not name: return jsonify({"error": "name required"}), 400
|
|
wrapper = read_plan(pid); wrapper["name"] = name; wrapper["revision"] += 1; saved = write_plan(pid, wrapper)
|
|
return jsonify({"id": pid, "name": name, "revision": saved["revision"]})
|
|
|
|
@app.route("/api/plans/<pid>", methods=["DELETE"])
|
|
def api_plan_delete(pid: str):
|
|
ids = list_plan_ids()
|
|
if pid not in ids: return jsonify({"error": "not found"}), 404
|
|
if len(ids) <= 1: return jsonify({"error": "letzte Phase kann nicht gelöscht werden"}), 400
|
|
atomic_json_write(BACKUPS_DIR / f"deleted-{pid}-{utc_now().replace(':','')}.json", {k:v for k,v in read_plan(pid).items() if k != 'id'})
|
|
plan_path(pid).unlink()
|
|
if get_active() is None: set_active(list_plan_ids()[0])
|
|
return jsonify({"ok": True, "active": get_active()})
|
|
|
|
@app.route("/api/plans/<pid>/export")
|
|
def api_plan_export(pid: str):
|
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
|
return send_file(io.BytesIO(plan_path(pid).read_bytes()), mimetype="application/json", as_attachment=True, download_name=f"{pid}.json")
|
|
|
|
@app.route("/api/plans/import", methods=["POST"])
|
|
def api_plan_import():
|
|
raw = request.files["file"].read() if "file" in request.files else request.data
|
|
if not raw: return jsonify({"error": "keine Datei"}), 400
|
|
try: obj = json.loads(raw.decode("utf-8"))
|
|
except Exception as exc: return jsonify({"error": f"ungültiges JSON: {exc}"}), 400
|
|
if isinstance(obj, dict) and "config" in obj:
|
|
cfg = obj.get("config", {}); name = str(obj.get("name") or "Importierte Phase")
|
|
else:
|
|
cfg = obj if isinstance(obj, dict) else {}; name = str((cfg.get("meta") or {}).get("title") or "Importierte Phase")
|
|
pid = unique_id(slugify(name)); wrapper = create_wrapper(pid, name, cfg); write_plan(pid, wrapper, backup=False); set_active(pid)
|
|
return jsonify(public_wrapper(wrapper))
|
|
|
|
@app.route("/api/plans/<pid>/proposals")
|
|
def api_plan_proposals(pid: str):
|
|
raw = {}
|
|
path = _proposal_file(pid)
|
|
if path.exists():
|
|
try: raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception: raw = {}
|
|
return jsonify(raw if isinstance(raw, dict) else {})
|
|
|
|
@app.route("/api/plans/<pid>/proposals/<proposal_id>", methods=["POST"])
|
|
def api_plan_proposal_status(pid: str, proposal_id: str):
|
|
path = _proposal_file(pid); body = request.get_json(silent=True) or {}; status = str(body.get("status") or "reviewed")
|
|
if status not in {"open", "accepted", "rejected", "reviewed"}:
|
|
return jsonify({"error": "ungültiger Vorschlagsstatus"}), 400
|
|
raw = {}
|
|
if path.exists():
|
|
try: raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception: raw = {}
|
|
proposals = raw.get("proposals") if isinstance(raw.get("proposals"), list) else []
|
|
for proposal in proposals:
|
|
if str(proposal.get("id")) == proposal_id:
|
|
proposal["status"] = status; proposal["reviewed_at"] = utc_now()
|
|
raw["proposals"] = proposals; raw["updated_at"] = utc_now(); atomic_json_write(path, raw)
|
|
return jsonify({"ok": True, "proposals": proposals})
|
|
|
|
# Backward-compatible active plan endpoints
|
|
@app.route("/api/config", methods=["GET"])
|
|
def api_get_config():
|
|
pid = get_active(); return jsonify(public_wrapper(read_plan(pid)) if pid else {"config": default_for("training")})
|
|
|
|
@app.route("/api/config", methods=["POST"])
|
|
def api_save_config():
|
|
pid = get_active()
|
|
if not pid: return jsonify({"error": "kein aktiver Plan"}), 400
|
|
return api_plan_save(pid)
|
|
|
|
@app.route("/api/default")
|
|
def api_default(): return jsonify(default_for(request.args.get("kind", "training")))
|
|
|
|
@app.route("/api/reset", methods=["POST"])
|
|
def api_reset():
|
|
pid = get_active(); wrapper = read_plan(pid); kind = "recipe" if wrapper["draft"].get("type") == "recipe" else "training"
|
|
wrapper["draft"] = default_for(kind); wrapper["revision"] += 1; saved = write_plan(pid, wrapper)
|
|
return jsonify(public_wrapper(saved))
|
|
|
|
@app.route("/api/pdf", methods=["POST"])
|
|
def api_pdf():
|
|
body = request.get_json(force=True) or {}; cfg = body.get("config") if isinstance(body.get("config"), dict) else body
|
|
if cfg.get("type") != "recipe": cfg = migrate_training_config(cfg)
|
|
try: pdf_bytes = builder_for(cfg).build_pdf(cfg)
|
|
except Exception as exc: return jsonify({"error": str(exc)}), 400
|
|
name = cfg.get("meta", {}).get("output_name", "Trainingsplan.pdf")
|
|
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=name)
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8080")), debug=False)
|