This commit is contained in:
2026-09-01 14:57:28 +02:00
parent 56b1f4263b
commit e97ffe1701
21 changed files with 2028 additions and 25528 deletions
+476 -262
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Trainingsplan- und Rezepteditor mit Entwurf/Veröffentlichung und Konfliktschutz."""
"""Backend fuer einen schlanken Trainingsplan-Editor."""
from __future__ import annotations
import io
@@ -8,42 +8,29 @@ import os
import re
from copy import deepcopy
from pathlib import Path
from typing import Any
from typing import Any, Callable
from flask import Flask, Response, jsonify, request, send_file
from flask import Flask, jsonify, request, send_file
import plan_builder
import recipe_builder
from schema_contract import migrate_training_config, validate_training_config, stable_id
from schema_contract import default_config, normalize_training_config, stable_id, validate_training_config
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"
text = str(name or "").strip().lower()
text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
return re.sub(r"[^a-z0-9]+", "-", text).strip("-") or "trainingsplan"
def plan_path(pid: str) -> Path:
@@ -53,26 +40,29 @@ def plan_path(pid: str) -> Path:
def unique_id(base: str) -> str:
stem, index = base, 2
while plan_path(stem).exists():
stem = f"{base}-{index}"; index += 1
stem = f"{base}-{index}"
index += 1
return stem
def _payload() -> dict[str, Any]:
body = request.get_json(silent=True)
return body if isinstance(body, dict) else {}
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)
published_source = raw.get("config") if isinstance(raw.get("config"), dict) else raw
published = normalize_training_config(published_source, plan_id=str(raw.get("plan_id") or pid))
draft_source = raw.get("draft") if isinstance(raw.get("draft"), dict) else published
draft = normalize_training_config(draft_source, 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)),
"plan_id": str(raw.get("plan_id") or published.get("plan_id") or pid),
"name": name,
"revision": revision,
"published_revision": published_revision,
@@ -96,25 +86,41 @@ def write_plan(pid: str, wrapper: dict[str, Any], *, backup: bool = True) -> dic
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)
def create_wrapper(pid: str, name: str, cfg: dict[str, Any] | None = None) -> dict[str, Any]:
plan = normalize_training_config(cfg or default_config(), plan_id=pid)
plan["plan_id"] = pid
plan["meta"]["title"] = str(name or plan["meta"].get("title") or "Neuer Trainingsplan")
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),
"id": pid,
"plan_id": pid,
"name": plan["meta"]["title"],
"revision": 1,
"published_revision": 1,
"created_at": now,
"updated_at": now,
"published_at": now,
"config": deepcopy(plan),
"draft": deepcopy(plan),
}
def list_plan_ids() -> list[str]:
return sorted(path.stem for path in PLANS_DIR.glob("*.json"))
ids = []
for path in PLANS_DIR.glob("*.json"):
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
raw = {}
source = raw.get("config") if isinstance(raw, dict) and isinstance(raw.get("config"), dict) else raw
if isinstance(source, dict) and source.get("type") == "recipe":
continue
ids.append(path.stem)
return sorted(ids)
def plan_name(pid: str) -> str:
try: return read_plan(pid)["name"]
except Exception: return pid
def editable_plan_exists(pid: str) -> bool:
return pid in list_plan_ids()
def get_active() -> str | None:
@@ -128,323 +134,531 @@ def set_active(pid: str) -> None:
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
try:
raw = json.loads(LEGACY_CONFIG.read_text(encoding="utf-8")) if LEGACY_CONFIG.exists() else default_config()
except Exception:
raw = default_config()
write_plan("trainingsplan", create_wrapper("trainingsplan", "Trainingsplan", raw), backup=False)
if get_active() is None and list_plan_ids():
set_active(list_plan_ids()[0])
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"],
"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 _save_draft(pid: str, wrapper: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
wrapper["draft"] = normalize_training_config(config, plan_id=wrapper["plan_id"])
wrapper["draft"]["meta"]["title"] = str(wrapper["name"])
wrapper["revision"] += 1
return write_plan(pid, wrapper)
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 _mutate_draft(pid: str, mutator: Callable[[dict[str, Any]], Any]) -> tuple[dict[str, Any], Any]:
if not editable_plan_exists(pid):
raise FileNotFoundError(pid)
wrapper = read_plan(pid)
draft = deepcopy(wrapper["draft"])
result = mutator(draft)
if isinstance(result, str) and result in {"day", "exercise", "item", "used"}:
return wrapper, result
saved = _save_draft(pid, wrapper, draft)
return saved, result
def _proposal_file(pid: str) -> Path:
return TRACKER_PROPOSALS_DIR / f"{pid}.json"
def _find_by_id(items: list[dict[str, Any]], item_id: str) -> dict[str, Any] | None:
return next((item for item in items if str(item.get("id")) == item_id), None)
def _replace_list_item(items: list[dict[str, Any]], item_id: str, replacement: dict[str, Any]) -> bool:
for index, item in enumerate(items):
if str(item.get("id")) == item_id:
items[index] = replacement
return True
return False
def _delete_list_item(items: list[dict[str, Any]], item_id: str) -> bool:
before = len(items)
items[:] = [item for item in items if str(item.get("id")) != item_id]
return len(items) != before
ensure_seed()
@app.route("/")
def index():
return Response((STATIC_DIR / "index.html").read_text(encoding="utf-8"), mimetype="text/html")
return jsonify({"name": "Trainingsplan-Backend", "status": "ok"})
@app.route("/api/plans", methods=["GET"])
def api_plans():
active = get_active(); 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
plan = read_plan(pid)
plans.append({
"id": pid,
"name": plan["name"],
"active": pid == active,
"revision": plan["revision"],
"published_revision": plan["published_revision"],
"has_unpublished_changes": plan["draft"] != plan["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))
body = _payload()
name = str(body.get("name") or "Neuer Trainingsplan").strip()
source = body.get("from")
cfg = deepcopy(read_plan(str(source))["draft"]) if source and editable_plan_exists(str(source)) else default_config()
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)), 201
@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
if not editable_plan_exists(pid):
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)))
if not editable_plan_exists(pid):
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 {}
if not editable_plan_exists(pid):
return jsonify({"error": "not found"}), 404
body = _payload()
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)
saved = _save_draft(pid, wrapper, config)
validation = validate_training_config(saved["draft"])
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)
if not editable_plan_exists(pid):
return jsonify({"error": "not found"}), 404
body = _payload()
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"])
validation = validate_training_config(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"]}})
return jsonify({"ok": True, **public_wrapper(saved, draft=False), "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)
if not editable_plan_exists(pid):
return jsonify({"error": "not found"}), 404
name = str(_payload().get("name") or "").strip()
if not name:
return jsonify({"error": "name required"}), 400
wrapper = read_plan(pid)
wrapper["name"] = name
wrapper["draft"]["meta"]["title"] = name
wrapper["config"]["meta"]["title"] = 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'})
if pid not in ids:
return jsonify({"error": "not found"}), 404
if len(ids) <= 1:
return jsonify({"error": "letzter Plan 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])
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
if not editable_plan_exists(pid):
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))
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
cfg = obj.get("config", {}) if isinstance(obj, dict) and isinstance(obj.get("config"), dict) else (obj if isinstance(obj, dict) else {})
name = str(obj.get("name") or (cfg.get("meta") or {}).get("title") or "Importierter Trainingsplan")
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)), 201
@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})
@app.route("/api/plans/<pid>/exercises", methods=["GET"])
def api_exercises_list(pid: str):
if not editable_plan_exists(pid):
return jsonify({"error": "not found"}), 404
return jsonify({"exercises": read_plan(pid)["draft"].get("exercises", [])})
@app.route("/api/plans/<pid>/exercises", methods=["POST"])
def api_exercise_create(pid: str):
body = _payload()
name = str(body.get("name") or "").strip()
if not name:
return jsonify({"error": "name required"}), 400
def mutate(cfg: dict[str, Any]) -> dict[str, Any]:
exercise = {
"id": stable_id("exercise", cfg.get("plan_id"), len(cfg.get("exercises", [])), name),
"name": name,
}
for key in ("cue", "description", "equipment", "notes"):
value = str(body.get(key) or "").strip()
if value:
exercise[key] = value
tags = body.get("tags")
if isinstance(tags, list):
exercise["tags"] = [str(tag).strip() for tag in tags if str(tag).strip()]
cfg.setdefault("exercises", []).append(exercise)
return exercise
try:
saved, exercise = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True, "exercise": exercise, "revision": saved["revision"]}), 201
@app.route("/api/plans/<pid>/exercises/<exercise_id>", methods=["PATCH"])
def api_exercise_update(pid: str, exercise_id: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any] | None:
exercises = cfg.setdefault("exercises", [])
exercise = _find_by_id(exercises, exercise_id)
if not exercise:
return None
updated = deepcopy(exercise)
for key in ("name", "cue", "description", "equipment", "notes"):
if key in body:
value = str(body.get(key) or "").strip()
if value:
updated[key] = value
else:
updated.pop(key, None)
if isinstance(body.get("tags"), list):
updated["tags"] = [str(tag).strip() for tag in body["tags"] if str(tag).strip()]
_replace_list_item(exercises, exercise_id, updated)
return updated
try:
saved, exercise = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if exercise is None:
return jsonify({"error": "exercise not found"}), 404
return jsonify({"ok": True, "exercise": exercise, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/exercises/<exercise_id>", methods=["DELETE"])
def api_exercise_delete(pid: str, exercise_id: str):
force = request.args.get("force") in {"1", "true", "yes"}
def mutate(cfg: dict[str, Any]) -> bool | str:
used = any(item.get("exercise_id") == exercise_id for day in cfg.get("days", []) for item in day.get("exercises", []))
if used and not force:
return "used"
removed = _delete_list_item(cfg.setdefault("exercises", []), exercise_id)
if force:
for day in cfg.get("days", []):
day["exercises"] = [item for item in day.get("exercises", []) if item.get("exercise_id") != exercise_id]
return removed
try:
saved, result = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if result == "used":
return jsonify({"error": "exercise is used by training days", "code": "exercise_in_use"}), 409
if not result:
return jsonify({"error": "exercise not found"}), 404
return jsonify({"ok": True, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/days", methods=["GET"])
def api_days_list(pid: str):
if not editable_plan_exists(pid):
return jsonify({"error": "not found"}), 404
return jsonify({"days": read_plan(pid)["draft"].get("days", [])})
@app.route("/api/plans/<pid>/days", methods=["POST"])
def api_day_create(pid: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any]:
days = cfg.setdefault("days", [])
next_num = max([int(day.get("num") or 0) for day in days], default=0) + 1
num = int(body.get("num") or next_num)
name = str(body.get("name") or body.get("focus") or f"Tag {num}").strip()
day = {
"id": stable_id("day", cfg.get("plan_id"), num, name),
"num": num,
"name": name,
"warmup": {"items": [str(item).strip() for item in body.get("warmup", []) if str(item).strip()]} if isinstance(body.get("warmup"), list) else {"items": []},
"cooldown": {"items": [str(item).strip() for item in body.get("cooldown", []) if str(item).strip()]} if isinstance(body.get("cooldown"), list) else {"items": []},
"exercises": [],
}
days.append(day)
return day
try:
saved, day = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True, "day": day, "revision": saved["revision"]}), 201
@app.route("/api/plans/<pid>/days/<day_id>", methods=["PATCH"])
def api_day_update(pid: str, day_id: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any] | None:
days = cfg.setdefault("days", [])
day = _find_by_id(days, day_id)
if not day:
return None
updated = deepcopy(day)
for key in ("name", "focus", "notes"):
if key in body:
value = str(body.get(key) or "").strip()
if value:
updated[key] = value
else:
updated.pop(key, None)
if "num" in body:
updated["num"] = int(body.get("num") or updated.get("num") or 1)
for key in ("warmup", "cooldown"):
if isinstance(body.get(key), list):
updated[key] = {"items": [str(item).strip() for item in body[key] if str(item).strip()]}
elif isinstance(body.get(key), dict):
updated[key] = body[key]
_replace_list_item(days, day_id, updated)
return updated
try:
saved, day = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if day is None:
return jsonify({"error": "day not found"}), 404
return jsonify({"ok": True, "day": day, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/days/<day_id>", methods=["DELETE"])
def api_day_delete(pid: str, day_id: str):
def mutate(cfg: dict[str, Any]) -> bool:
return _delete_list_item(cfg.setdefault("days", []), day_id)
try:
saved, removed = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if not removed:
return jsonify({"error": "day not found"}), 404
return jsonify({"ok": True, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/days/<day_id>/exercises", methods=["POST"])
def api_day_exercise_add(pid: str, day_id: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any] | str:
day = _find_by_id(cfg.setdefault("days", []), day_id)
if not day:
return "day"
exercise_id = str(body.get("exercise_id") or "").strip()
if not exercise_id and str(body.get("name") or "").strip():
exercise = {
"id": stable_id("exercise", cfg.get("plan_id"), len(cfg.get("exercises", [])), body.get("name")),
"name": str(body.get("name")).strip(),
}
cfg.setdefault("exercises", []).append(exercise)
exercise_id = exercise["id"]
if not _find_by_id(cfg.get("exercises", []), exercise_id):
return "exercise"
item = {
"id": stable_id("day-exercise", day_id, exercise_id, len(day.get("exercises", []))),
"exercise_id": exercise_id,
}
for key in ("sets", "duration_seconds"):
if body.get(key):
item[key] = int(body[key])
for key in ("reps", "tempo", "rest", "notes"):
value = str(body.get(key) or "").strip()
if value:
item[key] = value
day.setdefault("exercises", []).append(item)
return item
try:
saved, item = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if item == "day":
return jsonify({"error": "day not found"}), 404
if item == "exercise":
return jsonify({"error": "exercise not found"}), 404
return jsonify({"ok": True, "day_exercise": item, "revision": saved["revision"]}), 201
@app.route("/api/plans/<pid>/days/<day_id>/exercises/<item_id>", methods=["PATCH"])
def api_day_exercise_update(pid: str, day_id: str, item_id: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any] | str:
day = _find_by_id(cfg.setdefault("days", []), day_id)
if not day:
return "day"
item = _find_by_id(day.setdefault("exercises", []), item_id)
if not item:
return "item"
updated = deepcopy(item)
if "exercise_id" in body:
exercise_id = str(body.get("exercise_id") or "").strip()
if not _find_by_id(cfg.get("exercises", []), exercise_id):
return "exercise"
updated["exercise_id"] = exercise_id
for key in ("sets", "duration_seconds"):
if key in body:
value = int(body.get(key) or 0)
if value > 0:
updated[key] = value
else:
updated.pop(key, None)
for key in ("reps", "tempo", "rest", "notes"):
if key in body:
value = str(body.get(key) or "").strip()
if value:
updated[key] = value
else:
updated.pop(key, None)
_replace_list_item(day["exercises"], item_id, updated)
return updated
try:
saved, item = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if item == "day":
return jsonify({"error": "day not found"}), 404
if item == "item":
return jsonify({"error": "day exercise not found"}), 404
if item == "exercise":
return jsonify({"error": "exercise not found"}), 404
return jsonify({"ok": True, "day_exercise": item, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/days/<day_id>/exercises/<item_id>", methods=["DELETE"])
def api_day_exercise_delete(pid: str, day_id: str, item_id: str):
def mutate(cfg: dict[str, Any]) -> bool | str:
day = _find_by_id(cfg.setdefault("days", []), day_id)
if not day:
return "day"
return _delete_list_item(day.setdefault("exercises", []), item_id)
try:
saved, result = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if result == "day":
return jsonify({"error": "day not found"}), 404
if not result:
return jsonify({"error": "day exercise not found"}), 404
return jsonify({"ok": True, "revision": saved["revision"]})
# 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")})
pid = get_active()
return jsonify(public_wrapper(read_plan(pid)) if pid else {"config": default_config()})
@app.route("/api/config", methods=["POST"])
def api_save_config():
pid = get_active()
if not pid: return jsonify({"error": "kein aktiver Plan"}), 400
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")))
def api_default():
return jsonify(default_config())
@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)
pid = get_active()
if not pid:
return jsonify({"error": "kein aktiver Plan"}), 400
wrapper = read_plan(pid)
wrapper["draft"] = normalize_training_config(default_config(), plan_id=wrapper["plan_id"])
wrapper["draft"]["meta"]["title"] = wrapper["name"]
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)