chore: initial import
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user