""" Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layout). Block-Typen: - section: Überschrift - 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 Literal, Union from pydantic import BaseModel, Field, model_validator ALLOWED_CHART_IDS: frozenset[str] = frozenset( { "weight_trend", "energy_balance", "macro_distribution", "training_volume", "training_type_distribution", } ) _MAX_BLOCKS = 24 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 ReportProfilePayload(BaseModel): version: Literal[1] = 1 document_title: str = Field(default="", max_length=120) blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock]] @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="Körpergewicht"), ChartBlock(chart_id="weight_trend", window_days=90), SectionBlock(title="Energiebilanz"), ChartBlock(chart_id="energy_balance", window_days=28), SectionBlock(title="Trainingsvolumen"), ChartBlock(chart_id="training_volume", window_days=84), ], ) 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)