- Updated the dashboard layout schema to include new widgets: DashboardGreeting, QuickWeightToday, BodyStatStrip, StatusPills, ProfileGoalsProgress, TrendKcalWeight, NutritionActivitySummary, RecoverySleepRest, and TrainingTypeDistribution. - Improved widget configuration validation to support new features, including chart days for trend and distribution widgets. - Refactored the default lab layout to align with the updated widget catalog and ensure proper default activation. - Bumped app_dashboard version to 1.6.0 to reflect the addition of new widgets and configuration enhancements.
145 lines
5.0 KiB
Python
145 lines
5.0 KiB
Python
"""
|
|
Pro-Widget-Konfiguration im Dashboard-Layout (v1).
|
|
|
|
Nur ausgewählte Widget-IDs dürfen nicht-leere config haben; bekannte Keys werden validiert.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import re
|
|
from typing import Any
|
|
|
|
MAX_WIDGET_CONFIG_JSON_BYTES = 3072
|
|
|
|
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
|
"body_overview",
|
|
"activity_overview",
|
|
"kpi_board",
|
|
"trend_kcal_weight",
|
|
"training_type_distribution",
|
|
})
|
|
|
|
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
|
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
|
|
|
|
|
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
|
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
|
|
|
|
|
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
|
if raw is None:
|
|
return {}
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
|
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
|
|
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
|
|
if not raw:
|
|
return {}
|
|
|
|
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
|
|
|
if widget_id == "body_overview":
|
|
return _validate_chart_days_only(raw, label="body_overview")
|
|
if widget_id == "activity_overview":
|
|
return _validate_chart_days_only(raw, label="activity_overview")
|
|
if widget_id == "kpi_board":
|
|
return _validate_kpi_board_config(raw)
|
|
if widget_id == "trend_kcal_weight":
|
|
return _validate_chart_days_only(raw, label="trend_kcal_weight")
|
|
if widget_id == "training_type_distribution":
|
|
return _validate_distribution_days_only(raw)
|
|
|
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
|
|
|
|
|
def _kpi_tile_id_valid(tid: str) -> bool:
|
|
if tid in _KPI_TILE_FIXED:
|
|
return True
|
|
return bool(_KPI_REF_TILE_RE.match(tid))
|
|
|
|
|
|
def _normalize_kpi_tile_entry(item: Any) -> str:
|
|
if isinstance(item, str):
|
|
tid = item.strip()
|
|
elif isinstance(item, dict) and "id" in item:
|
|
tid = str(item["id"]).strip()
|
|
else:
|
|
raise ValueError("kpi_board: jedes tiles-Element braucht eine id (String oder Objekt mit id)")
|
|
if not tid:
|
|
raise ValueError("kpi_board: leere Kachel-id")
|
|
if not _kpi_tile_id_valid(tid):
|
|
raise ValueError(f"kpi_board: ungültige Kachel-id {tid!r} (z. B. body_fat, avg_kcal, ref:typ_key)")
|
|
return tid
|
|
|
|
|
|
def _validate_kpi_board_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
if not raw:
|
|
return {}
|
|
# Legacy nur chart_days → entfallen, automatische Kachelwahl
|
|
if set(raw.keys()) <= frozenset({"chart_days"}):
|
|
return {}
|
|
allowed = frozenset({"tiles"})
|
|
unknown = set(raw) - allowed
|
|
if unknown:
|
|
raise ValueError(f"kpi_board: unbekannte config-Felder: {sorted(unknown)}")
|
|
tiles_raw = raw.get("tiles")
|
|
if tiles_raw is None:
|
|
return {}
|
|
if not isinstance(tiles_raw, list):
|
|
raise ValueError("kpi_board: tiles muss eine Liste sein")
|
|
if len(tiles_raw) > 9:
|
|
raise ValueError("kpi_board: maximal 9 Kacheln")
|
|
seen: set[str] = set()
|
|
out: list[dict[str, str]] = []
|
|
for item in tiles_raw:
|
|
tid = _normalize_kpi_tile_entry(item)
|
|
if tid in seen:
|
|
raise ValueError(f"kpi_board: doppelte Kachel-id {tid}")
|
|
seen.add(tid)
|
|
out.append({"id": tid})
|
|
return {"tiles": out}
|
|
|
|
|
|
def _parse_chart_days(v: Any, label: str) -> int:
|
|
if isinstance(v, bool):
|
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
|
if isinstance(v, float):
|
|
if not math.isfinite(v):
|
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
|
if abs(v - round(v)) > 1e-9:
|
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
|
return int(round(v))
|
|
if isinstance(v, int):
|
|
return v
|
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
|
|
|
|
|
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
|
allowed = frozenset({"chart_days"})
|
|
unknown = set(raw) - allowed
|
|
if unknown:
|
|
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
if "chart_days" not in raw:
|
|
return {}
|
|
v = _parse_chart_days(raw["chart_days"], label)
|
|
if v < 7 or v > 90:
|
|
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
return {"chart_days": v}
|
|
|
|
|
|
def _validate_distribution_days_only(raw: dict[str, Any]) -> dict[str, Any]:
|
|
label = "training_type_distribution"
|
|
allowed = frozenset({"distribution_days"})
|
|
unknown = set(raw) - allowed
|
|
if unknown:
|
|
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
if "distribution_days" not in raw:
|
|
return {}
|
|
v = _parse_chart_days(raw["distribution_days"], label)
|
|
if v < 7 or v > 120:
|
|
raise ValueError(f"{label}: distribution_days muss zwischen 7 und 120 liegen")
|
|
return {"distribution_days": v}
|