# -*- 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/", 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//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/", 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//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//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//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/", 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//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//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//proposals/", 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)