- Introduced the `report_export` widget to the dashboard, allowing users to generate structured PDF reports. - Updated widget configuration to include `report_export` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `report_export` entry. - Implemented API endpoints for managing report profiles and generating PDFs. - Added frontend components for configuring and displaying report settings. - Updated tests to ensure proper validation and functionality of the new report generation features. - Bumped application version to reflect the addition of the new widget and related functionalities.
578 lines
20 KiB
Python
578 lines
20 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",
|
||
"body_history_viz",
|
||
"nutrition_history_viz",
|
||
"fitness_history_viz",
|
||
"recovery_history_viz",
|
||
"history_overview_viz",
|
||
"activity_overview",
|
||
"kpi_board",
|
||
"quick_capture",
|
||
"trend_kcal_weight",
|
||
"nutrition_detail_charts",
|
||
"recovery_charts_panel",
|
||
"report_export",
|
||
})
|
||
|
||
_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,
|
||
}
|
||
|
||
_FITNESS_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||
"show_layer_meta",
|
||
"show_kpis",
|
||
"show_progress_insights",
|
||
"show_chart_training_volume",
|
||
"show_chart_training_type_distribution",
|
||
"show_chart_quality_sessions",
|
||
"show_chart_load_monitoring",
|
||
})
|
||
|
||
_FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
||
"chart_days": 30,
|
||
"show_layer_meta": False,
|
||
"show_kpis": True,
|
||
"kpi_detail": "compact",
|
||
"show_progress_insights": False,
|
||
"show_chart_training_volume": True,
|
||
"show_chart_training_type_distribution": True,
|
||
"show_chart_quality_sessions": False,
|
||
"show_chart_load_monitoring": False,
|
||
}
|
||
|
||
_RECOVERY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||
"show_layer_meta",
|
||
"show_kpis",
|
||
"show_progress_insights",
|
||
"show_sleep_section_heading",
|
||
"show_chart_recovery_score",
|
||
"show_chart_sleep_quality",
|
||
"show_chart_sleep_debt",
|
||
"show_heart_section_heading",
|
||
"show_heart_context_card",
|
||
"show_chart_hrv_rhr",
|
||
"show_vitals_extra_heading",
|
||
"show_vitals_extra_trends",
|
||
})
|
||
|
||
_RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
||
"chart_days": 30,
|
||
"show_layer_meta": False,
|
||
"show_kpis": True,
|
||
"kpi_detail": "compact",
|
||
"show_progress_insights": False,
|
||
"show_sleep_section_heading": True,
|
||
"show_chart_recovery_score": True,
|
||
"show_chart_sleep_quality": True,
|
||
"show_chart_sleep_debt": False,
|
||
"show_heart_section_heading": True,
|
||
"show_heart_context_card": False,
|
||
"show_chart_hrv_rhr": True,
|
||
"show_vitals_extra_heading": False,
|
||
"show_vitals_extra_trends": False,
|
||
}
|
||
|
||
_HISTORY_OVERVIEW_VIZ_SECTION_KEYS: frozenset[str] = frozenset({
|
||
"show_section_body",
|
||
"show_section_nutrition",
|
||
"show_section_fitness",
|
||
"show_section_recovery",
|
||
})
|
||
|
||
_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||
"show_confidence_banner",
|
||
"show_intro_blurb",
|
||
*_HISTORY_OVERVIEW_VIZ_SECTION_KEYS,
|
||
"show_correlation_c1_c3",
|
||
"show_drivers_c4",
|
||
})
|
||
|
||
_HISTORY_OVERVIEW_VIZ_DEFAULTS: dict[str, Any] = {
|
||
"chart_days": 30,
|
||
"show_confidence_banner": True,
|
||
"show_intro_blurb": True,
|
||
"show_section_body": True,
|
||
"show_section_nutrition": True,
|
||
"show_section_fitness": True,
|
||
"show_section_recovery": True,
|
||
"show_correlation_c1_c3": True,
|
||
"show_drivers_c4": True,
|
||
}
|
||
|
||
|
||
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({})
|
||
if widget_id == "fitness_history_viz":
|
||
return _validate_fitness_history_viz_config({})
|
||
if widget_id == "recovery_history_viz":
|
||
return _validate_recovery_history_viz_config({})
|
||
if widget_id == "history_overview_viz":
|
||
return _validate_history_overview_viz_config({})
|
||
if widget_id == "report_export":
|
||
return _validate_report_export_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 == "fitness_history_viz":
|
||
return _validate_fitness_history_viz_config(raw)
|
||
if widget_id == "recovery_history_viz":
|
||
return _validate_recovery_history_viz_config(raw)
|
||
if widget_id == "history_overview_viz":
|
||
return _validate_history_overview_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")
|
||
if widget_id == "report_export":
|
||
return _validate_report_export_config(raw)
|
||
|
||
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_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||
label = "fitness_history_viz"
|
||
allowed = _FITNESS_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(_FITNESS_HISTORY_VIZ_DEFAULTS)
|
||
for k in _FITNESS_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 out["show_progress_insights"] and not any(
|
||
out[k]
|
||
for k in (
|
||
"show_chart_training_volume",
|
||
"show_chart_training_type_distribution",
|
||
"show_chart_quality_sessions",
|
||
"show_chart_load_monitoring",
|
||
)
|
||
):
|
||
raise ValueError(f"{label}: mindestens KPIs, Einschätzungen oder ein Chart muss sichtbar sein")
|
||
return out
|
||
|
||
|
||
def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||
label = "recovery_history_viz"
|
||
allowed = _RECOVERY_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(_RECOVERY_HISTORY_VIZ_DEFAULTS)
|
||
for k in _RECOVERY_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 out["show_progress_insights"] and not out["show_heart_context_card"] and not out[
|
||
"show_vitals_extra_trends"
|
||
] and not any(
|
||
out[k]
|
||
for k in (
|
||
"show_chart_recovery_score",
|
||
"show_chart_sleep_quality",
|
||
"show_chart_sleep_debt",
|
||
"show_chart_hrv_rhr",
|
||
)
|
||
):
|
||
raise ValueError(f"{label}: mindestens KPIs, Überblick, Kontextkarte, Extra-Vitals oder ein Chart muss sichtbar sein")
|
||
return out
|
||
|
||
|
||
def _migrate_history_overview_viz_raw(raw: dict[str, Any]) -> dict[str, Any]:
|
||
"""Alt: show_area_summaries → vier show_section_* (nur wo keine expliziten Section-Keys gesetzt)."""
|
||
r = dict(raw)
|
||
if "show_area_summaries" not in r:
|
||
return r
|
||
leg = r.pop("show_area_summaries")
|
||
if not isinstance(leg, bool):
|
||
raise ValueError("history_overview_viz: show_area_summaries muss boolean sein (veraltet — nutze show_section_*)")
|
||
for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS:
|
||
if k not in r:
|
||
r[k] = leg
|
||
return r
|
||
|
||
|
||
def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||
label = "history_overview_viz"
|
||
raw_m = _migrate_history_overview_viz_raw(raw)
|
||
allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"})
|
||
unknown = set(raw_m) - allowed
|
||
if unknown:
|
||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||
out: dict[str, Any] = dict(_HISTORY_OVERVIEW_VIZ_DEFAULTS)
|
||
for k in _HISTORY_OVERVIEW_VIZ_BOOL_KEYS:
|
||
if k not in raw_m:
|
||
continue
|
||
v = raw_m[k]
|
||
if not isinstance(v, bool):
|
||
raise ValueError(f"{label}: {k} muss boolean sein")
|
||
out[k] = v
|
||
if "chart_days" in raw_m:
|
||
v = _parse_chart_days(raw_m["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
|
||
has_section = any(out[k] for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS)
|
||
has_other = any(
|
||
out[k]
|
||
for k in (
|
||
"show_confidence_banner",
|
||
"show_correlation_c1_c3",
|
||
"show_drivers_c4",
|
||
)
|
||
)
|
||
if not has_section and not has_other:
|
||
raise ValueError(
|
||
f"{label}: mindestens eine Bereichs-Kachel, das Datenlage-Banner, Lag-Korrelationen (C1–C3) oder Treiber (C4) 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}
|
||
|
||
|
||
def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||
label = "report_export"
|
||
allowed = frozenset({"document_title", "subtitle", "capture_scale"})
|
||
unknown = set(raw) - allowed
|
||
if unknown:
|
||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||
out: dict[str, Any] = {"capture_scale": 2}
|
||
if "document_title" in raw:
|
||
t = raw["document_title"]
|
||
if t is not None and not isinstance(t, str):
|
||
raise ValueError(f"{label}: document_title muss Text sein")
|
||
s = (t or "").strip()
|
||
if len(s) > 120:
|
||
raise ValueError(f"{label}: document_title max. 120 Zeichen")
|
||
if s:
|
||
out["document_title"] = s
|
||
if "subtitle" in raw:
|
||
t = raw["subtitle"]
|
||
if t is not None and not isinstance(t, str):
|
||
raise ValueError(f"{label}: subtitle muss Text sein")
|
||
s = (t or "").strip()
|
||
if len(s) > 240:
|
||
raise ValueError(f"{label}: subtitle max. 240 Zeichen")
|
||
if s:
|
||
out["subtitle"] = s
|
||
if "capture_scale" in raw:
|
||
v = raw["capture_scale"]
|
||
if isinstance(v, bool) or isinstance(v, float):
|
||
if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9:
|
||
v = int(round(v))
|
||
else:
|
||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
||
if not isinstance(v, int):
|
||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
||
if v < 1 or v > 3:
|
||
raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen")
|
||
out["capture_scale"] = v
|
||
return out
|
||
|
||
|