Update
This commit is contained in:
+476
-262
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user