- Introduced the `viz_bundle` block type to the report profile schema, allowing for the inclusion of bundled visualizations in PDF reports. - Updated the `build_structured_report_pdf` function to handle `VizBundleBlock` and append its content to the report. - Enhanced the report catalog API to include details for the new `viz_bundle` block type. - Added configuration editors for various visualization bundles in the frontend settings page. - Updated tests to validate the new `viz_bundle` functionality and ensure proper handling of report profiles. - Bumped application version to reflect these enhancements.
127 lines
4.3 KiB
Python
127 lines
4.3 KiB
Python
"""
|
|
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)
|
|
|