665 lines
25 KiB
Python
665 lines
25 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Backend fuer einen schlanken Trainingsplan-Editor."""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from flask import Flask, jsonify, request, send_file
|
|
|
|
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"
|
|
ACTIVE_FILE = DATA_DIR / "active.txt"
|
|
LEGACY_CONFIG = DATA_DIR / "config.json"
|
|
BACKUPS_DIR = DATA_DIR / "backups"
|
|
PLANS_DIR.mkdir(parents=True, exist_ok=True)
|
|
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
app = Flask(__name__, static_folder=None)
|
|
|
|
|
|
def slugify(name: Any) -> str:
|
|
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:
|
|
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 _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_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 pid),
|
|
"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] | 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": 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]:
|
|
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 editable_plan_exists(pid: str) -> bool:
|
|
return pid in list_plan_ids()
|
|
|
|
|
|
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 ensure_seed() -> None:
|
|
if not list_plan_ids():
|
|
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"],
|
|
"has_unpublished_changes": wrapper["draft"] != wrapper["config"],
|
|
"config": deepcopy(wrapper["draft"] if draft else wrapper["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 _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 _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 jsonify({"name": "Trainingsplan-Backend", "status": "ok"})
|
|
|
|
|
|
@app.route("/api/plans", methods=["GET"])
|
|
def api_plans():
|
|
active = get_active()
|
|
plans = []
|
|
for pid in list_plan_ids():
|
|
try:
|
|
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 = _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 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 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 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
|
|
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>/publish", methods=["POST"])
|
|
def api_plan_publish(pid: str):
|
|
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 = validate_training_config(wrapper["draft"])
|
|
if validation["errors"]:
|
|
return jsonify({"error": "Der Plan enthält Validierungsfehler.", "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}}), 400
|
|
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, draft=False), "validation": {"errors": [], "warnings": validation["warnings"]}})
|
|
|
|
|
|
@app.route("/api/plans/<pid>/rename", methods=["POST"])
|
|
def api_plan_rename(pid: str):
|
|
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": "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])
|
|
return jsonify({"ok": True, "active": get_active()})
|
|
|
|
|
|
@app.route("/api/plans/<pid>/export")
|
|
def api_plan_export(pid: str):
|
|
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
|
|
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>/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"]})
|
|
|
|
|
|
@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_config()})
|
|
|
|
|
|
@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_config())
|
|
|
|
|
|
@app.route("/api/reset", methods=["POST"])
|
|
def api_reset():
|
|
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))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8080")), debug=False)
|