""" 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", "body_history_viz", "nutrition_history_viz", "activity_overview", "kpi_board", "quick_capture", "trend_kcal_weight", "nutrition_detail_charts", "recovery_charts_panel", }) _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({ "show_weight", "show_resting_hr", "show_hrv", "show_vo2_max", }) _KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"}) _KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$") _BODY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ "show_goals_strip", "show_intro_blurb", "show_layer_meta", "show_kpis", "show_weight_chart", "show_body_fat_chart", "show_proportion_chart", "show_circumference_index_chart", "show_circumference_lines_chart", }) _BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "chart_days": 30, "show_goals_strip": False, "show_intro_blurb": False, "show_layer_meta": False, "show_kpis": True, "kpi_detail": "compact", "show_weight_chart": True, "show_body_fat_chart": False, "show_proportion_chart": False, "show_circumference_index_chart": False, "show_circumference_lines_chart": False, } _NUTRITION_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ "show_goals_strip", "show_intro_blurb", "show_kpis", "show_kcal_vs_weight", "show_calorie_balance_chart", "show_protein_lean_chart", "show_heuristics", "show_macro_daily_bars", "show_macro_distribution_pair", "show_energy_protein_charts", }) _NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "chart_days": 30, "show_goals_strip": False, "show_intro_blurb": False, "show_kpis": True, "kpi_detail": "compact", "show_kcal_vs_weight": True, "show_calorie_balance_chart": False, "show_protein_lean_chart": False, "show_heuristics": False, "show_macro_daily_bars": True, "show_macro_distribution_pair": True, "show_energy_protein_charts": False, } 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: raw = {} 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 widget_id not in WIDGETS_ALLOWING_CONFIG: if raw: raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") return {} if not raw: if widget_id == "body_history_viz": return _validate_body_history_viz_config({}) if widget_id == "nutrition_history_viz": return _validate_nutrition_history_viz_config({}) return {} if widget_id == "body_overview": return _validate_chart_days_only(raw, label="body_overview") if widget_id == "body_history_viz": return _validate_body_history_viz_config(raw) if widget_id == "nutrition_history_viz": return _validate_nutrition_history_viz_config(raw) 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 == "quick_capture": return _validate_quick_capture_config(raw) if widget_id == "trend_kcal_weight": return _validate_chart_days_only(raw, label="trend_kcal_weight") if widget_id == "nutrition_detail_charts": return _validate_chart_days_only(raw, label="nutrition_detail_charts") if widget_id == "recovery_charts_panel": return _validate_chart_days_only(raw, label="recovery_charts_panel") raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") def _validate_quick_capture_config(raw: dict[str, Any]) -> dict[str, Any]: label = "quick_capture" unknown = set(raw) - _QUICK_CAPTURE_KEYS if unknown: raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") out: dict[str, bool] = {} for k in _QUICK_CAPTURE_KEYS: if k not in raw: continue v = raw[k] if not isinstance(v, bool): raise ValueError(f"{label}: {k} muss boolean sein") out[k] = v merged = {k: True for k in _QUICK_CAPTURE_KEYS} merged.update(out) if not any(merged.values()): raise ValueError(f"{label}: mindestens ein Bereich muss sichtbar sein (show_*)") return out 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_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: label = "body_history_viz" allowed = _BODY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"}) unknown = set(raw) - allowed if unknown: raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") out: dict[str, Any] = dict(_BODY_HISTORY_VIZ_DEFAULTS) for k in _BODY_HISTORY_VIZ_BOOL_KEYS: if k not in raw: continue v = raw[k] if not isinstance(v, bool): raise ValueError(f"{label}: {k} muss boolean sein") out[k] = v if "kpi_detail" in raw: kd = raw["kpi_detail"] if kd not in ("compact", "full"): raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein") out["kpi_detail"] = kd if "chart_days" in raw: 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") out["chart_days"] = v if not out["show_kpis"] and not any( out[k] for k in ( "show_weight_chart", "show_body_fat_chart", "show_proportion_chart", "show_circumference_index_chart", "show_circumference_lines_chart", ) ): raise ValueError(f"{label}: mindestens KPIs oder ein Chart muss sichtbar sein") return out def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: label = "nutrition_history_viz" allowed = _NUTRITION_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"}) unknown = set(raw) - allowed if unknown: raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") out: dict[str, Any] = dict(_NUTRITION_HISTORY_VIZ_DEFAULTS) for k in _NUTRITION_HISTORY_VIZ_BOOL_KEYS: if k not in raw: continue v = raw[k] if not isinstance(v, bool): raise ValueError(f"{label}: {k} muss boolean sein") out[k] = v if "kpi_detail" in raw: kd = raw["kpi_detail"] if kd not in ("compact", "full"): raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein") out["kpi_detail"] = kd if "chart_days" in raw: 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") out["chart_days"] = v if not out["show_kpis"] and not any( out[k] for k in ( "show_kcal_vs_weight", "show_calorie_balance_chart", "show_protein_lean_chart", "show_heuristics", "show_macro_daily_bars", "show_macro_distribution_pair", "show_energy_protein_charts", ) ): raise ValueError(f"{label}: mindestens KPIs oder ein Chart-Bereich muss sichtbar sein") return out 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}