- 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.
93 lines
2.9 KiB
Python
93 lines
2.9 KiB
Python
"""
|
|
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)
|
|
|