""" Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layout). Block-Typen: - section: Überschrift - viz_bundle: Layer-2b-Ver bundles (KPIs, Text, Charts) — gleiche Config wie Dashboard - chart: diagramm via report_chart_fetch (chart_id + window_days) - ai_insight: optional insight_id (UUID), sonst Platzhalter für spätere Auswahl """ from __future__ import annotations from typing import Any, Literal, Union from pydantic import BaseModel, Field, model_validator from dashboard_widget_config import validate_widget_entry_config ALLOWED_CHART_IDS: frozenset[str] = frozenset( { "weight_trend", "energy_balance", "macro_distribution", "training_volume", "training_type_distribution", } ) _MAX_BLOCKS = 32 ALLOWED_VIZ_BUNDLE_IDS: frozenset[str] = frozenset( { "body_history_viz", "nutrition_history_viz", "fitness_history_viz", "recovery_history_viz", "history_overview_viz", } ) class SectionBlock(BaseModel): type: Literal["section"] = "section" title: str = Field(min_length=1, max_length=200) class ChartBlock(BaseModel): type: Literal["chart"] = "chart" chart_id: str = Field(min_length=1, max_length=64) window_days: int = Field(default=28, ge=7, le=365) @model_validator(mode="after") def _chart_known(self) -> ChartBlock: if self.chart_id not in ALLOWED_CHART_IDS: raise ValueError(f"Unbekanntes chart_id: {self.chart_id!r} (erlaubt: {sorted(ALLOWED_CHART_IDS)})") return self class AiInsightBlock(BaseModel): type: Literal["ai_insight"] = "ai_insight" title: str = Field(default="", max_length=200) insight_id: str | None = Field(default=None, max_length=48) class VizBundleBlock(BaseModel): """Gleiche Layer-2b-Bundles wie im Dashboard; config wie validate_widget_entry_config.""" type: Literal["viz_bundle"] = "viz_bundle" bundle_id: str = Field(min_length=1, max_length=64) config: dict[str, Any] = Field(default_factory=dict) @model_validator(mode="after") def _bundle_config(self) -> VizBundleBlock: if self.bundle_id not in ALLOWED_VIZ_BUNDLE_IDS: raise ValueError( f"Unbekanntes bundle_id: {self.bundle_id!r} (erlaubt: {sorted(ALLOWED_VIZ_BUNDLE_IDS)})" ) self.config = validate_widget_entry_config(self.bundle_id, self.config) return self class ReportProfilePayload(BaseModel): version: Literal[1] = 1 document_title: str = Field(default="", max_length=120) blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock, VizBundleBlock]] @model_validator(mode="after") def _blocks_limit(self) -> ReportProfilePayload: if len(self.blocks) > _MAX_BLOCKS: raise ValueError(f"Maximal {_MAX_BLOCKS} Blöcke erlaubt") if not self.blocks: raise ValueError("Mindestens ein Block erforderlich") return self def to_stored_dict(self) -> dict: return { "version": self.version, "document_title": self.document_title, "blocks": [b.model_dump(mode="json") for b in self.blocks], } def default_report_profile_dict() -> dict: """Standard-Bericht beim ersten Zugriff (ohne DB-Zeile).""" p = ReportProfilePayload( document_title="", blocks=[ SectionBlock(title="Verlauf — Körper"), VizBundleBlock(bundle_id="body_history_viz", config={"chart_days": 90}), SectionBlock(title="Verlauf — Ernährung"), VizBundleBlock(bundle_id="nutrition_history_viz", config={"chart_days": 90}), SectionBlock(title="Verlauf — Fitness"), VizBundleBlock(bundle_id="fitness_history_viz", config={"chart_days": 90}), SectionBlock(title="Verlauf — Erholung"), VizBundleBlock(bundle_id="recovery_history_viz", config={"chart_days": 90}), SectionBlock(title="Gesamtübersicht"), VizBundleBlock(bundle_id="history_overview_viz", config={"chart_days": 90}), ], ) return p.to_stored_dict() def parse_report_profile(raw: dict | None) -> ReportProfilePayload: if raw is None or raw == {}: return ReportProfilePayload.model_validate(default_report_profile_dict()) return ReportProfilePayload.model_validate(raw)