Files
T
2026-07-24 21:37:03 +02:00

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)