diff --git a/validation-suite/backend/mercury/alembic/env.py b/validation-suite/backend/mercury/alembic/env.py
index c26172eb..8b69c10e 100644
--- a/validation-suite/backend/mercury/alembic/env.py
+++ b/validation-suite/backend/mercury/alembic/env.py
@@ -7,7 +7,7 @@ from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base
-from app.models import contact, customer, device, document, equipment, location, program, user, validation
+from app.models import contact, customer, device, document, equipment, location, program, report_settings, user, validation
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
@@ -45,4 +45,3 @@ if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-
diff --git a/validation-suite/backend/mercury/alembic/versions/202607120001_orion_report_settings.py b/validation-suite/backend/mercury/alembic/versions/202607120001_orion_report_settings.py
new file mode 100644
index 00000000..52665d4e
--- /dev/null
+++ b/validation-suite/backend/mercury/alembic/versions/202607120001_orion_report_settings.py
@@ -0,0 +1,66 @@
+"""orion report settings
+
+Revision ID: 202607120001
+Revises: 202607110007
+Create Date: 2026-07-12 12:00:00.000000
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "202607120001"
+down_revision = "202607110007"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "orion_report_settings",
+ sa.Column("scope", sa.String(length=40), nullable=False),
+ sa.Column("layout_profile", sa.String(length=40), nullable=False),
+ sa.Column("show_cover_result_text", sa.Boolean(), nullable=False),
+ sa.Column("signature_mode", sa.String(length=60), nullable=False),
+ sa.Column("image_size", sa.String(length=40), nullable=False),
+ sa.Column("page_break_before_main_chapters", sa.Boolean(), nullable=False),
+ sa.Column("updated_by_user_id", sa.String(), nullable=True),
+ sa.Column("id", sa.String(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name=op.f("fk_orion_report_settings_updated_by_user_id_users")),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_orion_report_settings")),
+ sa.UniqueConstraint("scope", name=op.f("uq_orion_report_settings_scope")),
+ )
+ op.create_index(op.f("ix_orion_report_settings_scope"), "orion_report_settings", ["scope"], unique=True)
+ op.create_index(op.f("ix_orion_report_settings_updated_by_user_id"), "orion_report_settings", ["updated_by_user_id"], unique=False)
+ op.execute(
+ """
+ INSERT INTO orion_report_settings (
+ id,
+ scope,
+ layout_profile,
+ show_cover_result_text,
+ signature_mode,
+ image_size,
+ page_break_before_main_chapters
+ )
+ VALUES (
+ '00000000-0000-4000-8000-000000000001',
+ 'GLOBAL',
+ 'STANDARD',
+ true,
+ 'TECHNICAL_ONLY',
+ 'MEDIUM',
+ true
+ )
+ """
+ )
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_orion_report_settings_updated_by_user_id"), table_name="orion_report_settings")
+ op.drop_index(op.f("ix_orion_report_settings_scope"), table_name="orion_report_settings")
+ op.drop_table("orion_report_settings")
diff --git a/validation-suite/backend/mercury/alembic/versions/202607120002_orion_report_settings_v11.py b/validation-suite/backend/mercury/alembic/versions/202607120002_orion_report_settings_v11.py
new file mode 100644
index 00000000..c6bfd361
--- /dev/null
+++ b/validation-suite/backend/mercury/alembic/versions/202607120002_orion_report_settings_v11.py
@@ -0,0 +1,59 @@
+"""orion report settings v1.1
+
+Revision ID: 202607120002
+Revises: 202607120001
+Create Date: 2026-07-12 13:00:00.000000
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "202607120002"
+down_revision = "202607120001"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("orion_report_settings", sa.Column("page_margin", sa.String(length=40), nullable=False, server_default="STANDARD"))
+ op.add_column("orion_report_settings", sa.Column("section_spacing", sa.String(length=40), nullable=False, server_default="STANDARD"))
+ op.add_column("orion_report_settings", sa.Column("table_layout", sa.String(length=40), nullable=False, server_default="STANDARD"))
+ op.add_column("orion_report_settings", sa.Column("table_font_size", sa.String(length=40), nullable=False, server_default="STANDARD"))
+ op.add_column("orion_report_settings", sa.Column("image_position", sa.String(length=40), nullable=False, server_default="SIDE_BY_SIDE"))
+ op.add_column("orion_report_settings", sa.Column("max_images_per_page", sa.Integer(), nullable=False, server_default="2"))
+ op.add_column("orion_report_settings", sa.Column("show_image_captions", sa.Boolean(), nullable=False, server_default=sa.true()))
+ op.add_column("orion_report_settings", sa.Column("show_header", sa.Boolean(), nullable=False, server_default=sa.true()))
+ op.add_column("orion_report_settings", sa.Column("show_footer", sa.Boolean(), nullable=False, server_default=sa.true()))
+ op.add_column("orion_report_settings", sa.Column("logo_size", sa.String(length=40), nullable=False, server_default="MEDIUM"))
+ op.add_column("orion_report_settings", sa.Column("compact_cover", sa.Boolean(), nullable=False, server_default=sa.false()))
+ for column in [
+ "page_margin",
+ "section_spacing",
+ "table_layout",
+ "table_font_size",
+ "image_position",
+ "max_images_per_page",
+ "show_image_captions",
+ "show_header",
+ "show_footer",
+ "logo_size",
+ "compact_cover",
+ ]:
+ op.alter_column("orion_report_settings", column, server_default=None)
+
+
+def downgrade() -> None:
+ op.drop_column("orion_report_settings", "compact_cover")
+ op.drop_column("orion_report_settings", "logo_size")
+ op.drop_column("orion_report_settings", "show_footer")
+ op.drop_column("orion_report_settings", "show_header")
+ op.drop_column("orion_report_settings", "show_image_captions")
+ op.drop_column("orion_report_settings", "max_images_per_page")
+ op.drop_column("orion_report_settings", "image_position")
+ op.drop_column("orion_report_settings", "table_font_size")
+ op.drop_column("orion_report_settings", "table_layout")
+ op.drop_column("orion_report_settings", "section_spacing")
+ op.drop_column("orion_report_settings", "page_margin")
diff --git a/validation-suite/backend/mercury/app/api/v1/__pycache__/domain.cpython-313.pyc b/validation-suite/backend/mercury/app/api/v1/__pycache__/domain.cpython-313.pyc
index d8f2daa5..cf791a22 100644
Binary files a/validation-suite/backend/mercury/app/api/v1/__pycache__/domain.cpython-313.pyc and b/validation-suite/backend/mercury/app/api/v1/__pycache__/domain.cpython-313.pyc differ
diff --git a/validation-suite/backend/mercury/app/api/v1/domain.py b/validation-suite/backend/mercury/app/api/v1/domain.py
index c9d1e8ff..fb49324d 100644
--- a/validation-suite/backend/mercury/app/api/v1/domain.py
+++ b/validation-suite/backend/mercury/app/api/v1/domain.py
@@ -56,6 +56,8 @@ from app.schemas.domain import (
QuickStartCreateRequest,
QuickStartCustomerData,
QuickStartValidationSummary,
+ OrionReportSettingsRead,
+ OrionReportSettingsUpdate,
UserCreate,
UserRead,
UserUpdate,
@@ -63,6 +65,7 @@ from app.schemas.domain import (
)
from app.services.domain_service import CrudService, DomainServices
from app.services.quickstart_service import QuickStartService
+from app.services.report_settings import OrionReportSettingsService
from app.services.validation_workflow import ValidationWorkflowService
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
@@ -87,6 +90,48 @@ def default_report_checklists(session: Session = Depends(get_session)) -> list[d
]
+@router.get("/report-settings", response_model=OrionReportSettingsRead)
+def get_report_settings(
+ session: Session = Depends(get_session),
+ _: User = Depends(current_admin),
+):
+ return OrionReportSettingsService(session).get_global()
+
+
+@router.put("/report-settings", response_model=OrionReportSettingsRead)
+def update_report_settings(
+ payload: OrionReportSettingsUpdate,
+ session: Session = Depends(get_session),
+ user: User = Depends(current_admin),
+):
+ try:
+ settings = OrionReportSettingsService(session).update_global(payload, user)
+ session.commit()
+ session.refresh(settings)
+ return settings
+ except IntegrityError as exc:
+ session.rollback()
+ raise HTTPException(status_code=409, detail="Berichtseinstellungen konnten nicht gespeichert werden.") from exc
+
+
+@router.post("/report-settings/preview")
+def preview_report_settings(
+ payload: OrionReportSettingsUpdate | None = None,
+ session: Session = Depends(get_session),
+ _: User = Depends(current_admin),
+):
+ try:
+ pdf = OrionReportService(session, Path("/app/reports")).render_settings_preview_pdf(payload)
+ except Exception as exc:
+ logger.exception("Orion settings preview generation failed")
+ raise HTTPException(status_code=502, detail="Die PDF-Vorschau konnte nicht erzeugt werden.") from exc
+ return Response(
+ content=pdf,
+ media_type="application/pdf",
+ headers={"Content-Disposition": 'attachment; filename="orion-layout-preview.pdf"'},
+ )
+
+
@router.get("/dashboard")
def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
today = func.current_date()
diff --git a/validation-suite/backend/mercury/app/models/__init__.py b/validation-suite/backend/mercury/app/models/__init__.py
index 887988c2..5b2189a3 100644
--- a/validation-suite/backend/mercury/app/models/__init__.py
+++ b/validation-suite/backend/mercury/app/models/__init__.py
@@ -13,6 +13,7 @@ from app.models.report_template import (
ReportTemplate,
TextBlock,
)
+from app.models.report_settings import OrionReportSettings
from app.models.user import User
from app.models.validation import Validation
@@ -29,6 +30,7 @@ __all__ = [
"MeasurementImportValue",
"ReportSection",
"ReportTemplate",
+ "OrionReportSettings",
"TextBlock",
"User",
"Validation",
diff --git a/validation-suite/backend/mercury/app/models/__pycache__/__init__.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/__init__.cpython-313.pyc
index a882f213..e6e2aa9e 100644
Binary files a/validation-suite/backend/mercury/app/models/__pycache__/__init__.cpython-313.pyc and b/validation-suite/backend/mercury/app/models/__pycache__/__init__.cpython-313.pyc differ
diff --git a/validation-suite/backend/mercury/app/models/report_settings.py b/validation-suite/backend/mercury/app/models/report_settings.py
new file mode 100644
index 00000000..fddea181
--- /dev/null
+++ b/validation-suite/backend/mercury/app/models/report_settings.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import enum
+
+from sqlalchemy import Boolean, ForeignKey, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.db.base import Base, TimestampMixin, UUIDMixin
+
+
+class OrionReportSettingsScope(str, enum.Enum):
+ global_ = "GLOBAL"
+
+
+class OrionLayoutProfile(str, enum.Enum):
+ compact = "COMPACT"
+ standard = "STANDARD"
+
+
+class OrionSignatureMode(str, enum.Enum):
+ technical_only = "TECHNICAL_ONLY"
+ technical_and_client = "TECHNICAL_AND_CLIENT"
+
+
+class OrionImageSize(str, enum.Enum):
+ small = "SMALL"
+ medium = "MEDIUM"
+ large = "LARGE"
+
+
+class OrionPageMargin(str, enum.Enum):
+ narrow = "NARROW"
+ standard = "STANDARD"
+ wide = "WIDE"
+
+
+class OrionSpacing(str, enum.Enum):
+ compact = "COMPACT"
+ standard = "STANDARD"
+
+
+class OrionTableFontSize(str, enum.Enum):
+ small = "SMALL"
+ standard = "STANDARD"
+
+
+class OrionImagePosition(str, enum.Enum):
+ stacked = "STACKED"
+ side_by_side = "SIDE_BY_SIDE"
+
+
+class OrionLogoSize(str, enum.Enum):
+ small = "SMALL"
+ medium = "MEDIUM"
+ large = "LARGE"
+
+
+class OrionReportSettings(Base, UUIDMixin, TimestampMixin):
+ __tablename__ = "orion_report_settings"
+
+ scope: Mapped[str] = mapped_column(String(40), nullable=False, unique=True, index=True)
+ layout_profile: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionLayoutProfile.standard.value)
+ show_cover_result_text: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ signature_mode: Mapped[str] = mapped_column(String(60), nullable=False, default=OrionSignatureMode.technical_only.value)
+ image_size: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionImageSize.medium.value)
+ page_break_before_main_chapters: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ page_margin: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionPageMargin.standard.value)
+ section_spacing: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionSpacing.standard.value)
+ table_layout: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionSpacing.standard.value)
+ table_font_size: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionTableFontSize.standard.value)
+ image_position: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionImagePosition.side_by_side.value)
+ max_images_per_page: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
+ show_image_captions: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ show_header: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ show_footer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ logo_size: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionLogoSize.medium.value)
+ compact_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
diff --git a/validation-suite/backend/mercury/app/modules/orion/__pycache__/context.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/__pycache__/context.cpython-313.pyc
index ff5671a6..a6f6ccec 100644
Binary files a/validation-suite/backend/mercury/app/modules/orion/__pycache__/context.cpython-313.pyc and b/validation-suite/backend/mercury/app/modules/orion/__pycache__/context.cpython-313.pyc differ
diff --git a/validation-suite/backend/mercury/app/modules/orion/__pycache__/service.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/__pycache__/service.cpython-313.pyc
index 6789a28c..8a87ff7a 100644
Binary files a/validation-suite/backend/mercury/app/modules/orion/__pycache__/service.cpython-313.pyc and b/validation-suite/backend/mercury/app/modules/orion/__pycache__/service.cpython-313.pyc differ
diff --git a/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/chapters.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/chapters.cpython-313.pyc
index 79bd7695..f0f9034a 100644
Binary files a/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/chapters.cpython-313.pyc and b/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/chapters.cpython-313.pyc differ
diff --git a/validation-suite/backend/mercury/app/modules/orion/components/chapters.py b/validation-suite/backend/mercury/app/modules/orion/components/chapters.py
index f1f44e79..c113494c 100644
--- a/validation-suite/backend/mercury/app/modules/orion/components/chapters.py
+++ b/validation-suite/backend/mercury/app/modules/orion/components/chapters.py
@@ -84,15 +84,32 @@ class CoverComponent(ReportComponent):
f'
{text(context.customer.name)}
'
f"{rows}"
''
- '
'
- "
Ergebnis der Validierung
"
- f"
{text(validation_result_cover_sentence(validation.result))}
"
- "
"
- '
Unterschrift technische Validierung
'
+ f"{self._cover_result_text(context)}"
+ f"{self._signature_blocks(context)}"
"
"
""
)
+ def _cover_result_text(self, context: ReportContext) -> str:
+ if not context.report_settings.show_cover_result_text:
+ return ""
+ return (
+ ''
+ "
Ergebnis der Validierung
"
+ f"
{text(validation_result_cover_sentence(context.validation.result))}
"
+ "
"
+ )
+
+ def _signature_blocks(self, context: ReportContext) -> str:
+ blocks = [
+ 'Unterschrift technische Validierung
'
+ ]
+ if context.report_settings.signature_mode == "TECHNICAL_AND_CLIENT":
+ blocks.append(
+ 'Unterschrift Auftraggeber
'
+ )
+ return '' + "".join(blocks) + "
"
+
class TocComponent(ReportComponent):
anchor = "toc"
@@ -502,7 +519,7 @@ class AttachmentComponent(NumberedComponent):
figures.append(
''
f'
'
- f"Abbildung {index}: {text(caption)}"
+ f'Abbildung {index}: {text(caption)}'
""
)
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)
diff --git a/validation-suite/backend/mercury/app/modules/orion/context.py b/validation-suite/backend/mercury/app/modules/orion/context.py
index 6214b5d7..f1c66b6a 100644
--- a/validation-suite/backend/mercury/app/modules/orion/context.py
+++ b/validation-suite/backend/mercury/app/modules/orion/context.py
@@ -18,6 +18,7 @@ from app.models.report_template import (
ReportSection,
TextBlock,
)
+from app.models.report_settings import OrionReportSettings
from app.models.location import Location
from app.models.validation import Validation
from app.modules.orion.template_service import ReportTemplateService
@@ -36,6 +37,7 @@ class ReportContext:
text_blocks: dict[str, TextBlock]
checklist_templates: list[ChecklistTemplate]
confirmed_measurements: list[MeasurementImportValue]
+ report_settings: OrionReportSettings
class OrionContextBuilder:
@@ -63,6 +65,9 @@ class OrionContextBuilder:
)
)
template_bundle = ReportTemplateService(self.session).ensure_default_template()
+ from app.services.report_settings import OrionReportSettingsService
+
+ report_settings = OrionReportSettingsService(self.session).get_global()
confirmed_measurements = list(
self.session.scalars(
select(MeasurementImportValue)
@@ -87,4 +92,5 @@ class OrionContextBuilder:
text_blocks=template_bundle.text_blocks,
checklist_templates=template_bundle.checklists,
confirmed_measurements=confirmed_measurements,
+ report_settings=report_settings,
)
diff --git a/validation-suite/backend/mercury/app/modules/orion/service.py b/validation-suite/backend/mercury/app/modules/orion/service.py
index 8b9720e2..2ceec75d 100644
--- a/validation-suite/backend/mercury/app/modules/orion/service.py
+++ b/validation-suite/backend/mercury/app/modules/orion/service.py
@@ -2,6 +2,8 @@ from __future__ import annotations
from pathlib import Path
import logging
+from datetime import UTC, date, datetime
+from types import SimpleNamespace
from sqlalchemy.orm import Session
@@ -25,8 +27,17 @@ from app.modules.orion.components import (
TocComponent,
)
from app.modules.orion.context import OrionContextBuilder, ReportContext
+from app.modules.orion.html import section, table, text
from app.modules.orion.templates.report import render_document
from app.modules.orion.template_service import REPORT_SECTIONS
+from app.models.report_settings import (
+ OrionImageSize,
+ OrionLayoutProfile,
+ OrionReportSettings,
+ OrionReportSettingsScope,
+ OrionSignatureMode,
+)
+from app.schemas.domain import OrionReportSettingsUpdate
logger = logging.getLogger(__name__)
@@ -52,10 +63,178 @@ class OrionReportService:
self._append_pdf_attachments(context, output_path)
return output_path
+ def render_settings_preview_pdf(self, settings_override: OrionReportSettingsUpdate | None = None) -> bytes:
+ from weasyprint import HTML
+
+ context = self._preview_context(settings_override)
+ preview_section = section(
+ "layout-preview",
+ "Vorschau Hauptkapitel",
+ table(
+ ["Bereich", "Darstellung"],
+ [
+ ["Layoutprofil", context.report_settings.layout_profile],
+ ["Seitenraender", context.report_settings.page_margin],
+ ["Abschnittsabstaende", context.report_settings.section_spacing],
+ ["Tabellenlayout", context.report_settings.table_layout],
+ ["Tabellenschrift", context.report_settings.table_font_size],
+ ["Bildgroesse", context.report_settings.image_size],
+ ["Bildposition", context.report_settings.image_position],
+ ["Bilder pro Seite", context.report_settings.max_images_per_page],
+ ["Kopfzeile", "Ein" if context.report_settings.show_header else "Aus"],
+ ["Fusszeile", "Ein" if context.report_settings.show_footer else "Aus"],
+ ["Logo", context.report_settings.logo_size],
+ ["Kapitelumbruch", "Ein" if context.report_settings.page_break_before_main_chapters else "Aus"],
+ ],
+ )
+ + (
+ '"
+ ),
+ "1 Vorschau Hauptkapitel",
+ )
+ chapters = [
+ CoverComponent().render(context),
+ SummaryComponent().render(context),
+ TocComponent([{"key": "layout-preview", "number": "1", "title": "Vorschau Hauptkapitel", "toc": True}]).render(context),
+ CustomerComponent().render(context),
+ preview_section,
+ StaticTextComponent("results", "Ergebnisse der Validierung", "Die spaetere Ergebnisbox bleibt Bestandteil des Abschlussbereichs.", number="2").render(context),
+ ]
+ html = render_document(context, chapters)
+ return HTML(string=html, base_url=str(ORION_ASSET_DIR)).write_pdf()
+
def _components(self) -> list[ReportComponent]:
chapters = [self._component_for_section(item) for item in REPORT_SECTIONS]
return [CoverComponent(), SummaryComponent(), TocComponent(REPORT_SECTIONS), CustomerComponent(), *chapters]
+ def _preview_context(self, settings_override: OrionReportSettingsUpdate | None) -> ReportContext:
+ if settings_override is None:
+ report_settings = self._settings_clone()
+ else:
+ report_settings = OrionReportSettings(
+ scope=OrionReportSettingsScope.global_.value,
+ layout_profile=settings_override.layout_profile.value,
+ show_cover_result_text=settings_override.show_cover_result_text,
+ signature_mode=settings_override.signature_mode.value,
+ image_size=settings_override.image_size.value,
+ page_break_before_main_chapters=settings_override.page_break_before_main_chapters,
+ page_margin=settings_override.page_margin.value,
+ section_spacing=settings_override.section_spacing.value,
+ table_layout=settings_override.table_layout.value,
+ table_font_size=settings_override.table_font_size.value,
+ image_position=settings_override.image_position.value,
+ max_images_per_page=settings_override.max_images_per_page,
+ show_image_captions=settings_override.show_image_captions,
+ show_header=settings_override.show_header,
+ show_footer=settings_override.show_footer,
+ logo_size=settings_override.logo_size.value,
+ compact_cover=settings_override.compact_cover,
+ )
+ now = datetime.now(UTC)
+ validation = SimpleNamespace(
+ id="preview-validation",
+ report_number="ORION-LAYOUT-PREVIEW",
+ validation_type="Layoutvorschau",
+ project="Administrative Orion-Vorschau",
+ performed_on=date.today(),
+ test_location="Schubamed Preview",
+ examiner_name="Technische Validierung",
+ status="ENTWURF",
+ result="BESTANDEN_MIT_AUFLAGEN",
+ participants="Preview Team",
+ operator_name="Preview Betreiber",
+ next_validation_on=date(date.today().year + 2, date.today().month, date.today().day),
+ version=1,
+ updated_at=now,
+ equipment_ids=[],
+ environment_conditions={},
+ documentation_checklist=[],
+ performance_checklist=[],
+ programs=[],
+ loading_patterns=[],
+ measurement_data=[],
+ drying={},
+ recommendations=[],
+ attachments=[],
+ )
+ customer = SimpleNamespace(
+ name="Demo-Praxis Orion",
+ customer_type=SimpleNamespace(value="practice"),
+ street="Musterstrasse 12",
+ postal_code="12345",
+ city="Berlin",
+ phone="+49 30 123456",
+ email="preview@schubamed.de",
+ hygiene_officer="H. Muster",
+ quality_manager="Q. Beispiel",
+ )
+ location = SimpleNamespace(name="Aufbereitungsraum", street="Musterstrasse 12", postal_code="12345", city="Berlin", room="AEMP")
+ contact = SimpleNamespace(full_name="Dr. Petra Beispiel", function="Praxisleitung", email="kontakt@example.de", phone="+49 30 123456")
+ device = SimpleNamespace(
+ manufacturer="MELAG",
+ model="Vacuklav 44 B+",
+ serial_number="PREVIEW-12345",
+ device_type="Klein-Sterilisator",
+ year_built=2024,
+ commissioned_on=date(2024, 5, 15),
+ chamber_volume_liters=22,
+ steam_generation="integriert",
+ water_treatment="VE-Wasser",
+ documentation="vollstaendig",
+ supplier="Schubamed",
+ )
+ return ReportContext(
+ validation=validation,
+ customer=customer,
+ location=location,
+ contact=contact,
+ device=device,
+ equipment=[],
+ generated_dir=self.generated_dir,
+ report_sections=[],
+ text_blocks={
+ "summary": SimpleNamespace(
+ content="Diese Vorschau zeigt die aktuell gewaehlten Orion-Berichtseinstellungen ohne Produktivdaten zu veraendern."
+ )
+ },
+ checklist_templates=[],
+ confirmed_measurements=[],
+ report_settings=report_settings,
+ )
+
+ def _settings_clone(self) -> OrionReportSettings:
+ from app.services.report_settings import OrionReportSettingsService
+
+ saved = OrionReportSettingsService(self.session).get_global()
+ return OrionReportSettings(
+ scope=saved.scope,
+ layout_profile=saved.layout_profile,
+ show_cover_result_text=saved.show_cover_result_text,
+ signature_mode=saved.signature_mode,
+ image_size=saved.image_size,
+ page_break_before_main_chapters=saved.page_break_before_main_chapters,
+ page_margin=saved.page_margin,
+ section_spacing=saved.section_spacing,
+ table_layout=saved.table_layout,
+ table_font_size=saved.table_font_size,
+ image_position=saved.image_position,
+ max_images_per_page=saved.max_images_per_page,
+ show_image_captions=saved.show_image_captions,
+ show_header=saved.show_header,
+ show_footer=saved.show_footer,
+ logo_size=saved.logo_size,
+ compact_cover=saved.compact_cover,
+ updated_by_user_id=saved.updated_by_user_id,
+ )
+
def _component_for_section(self, item: dict) -> ReportComponent:
component = item.get("component")
key = str(item["key"])
diff --git a/validation-suite/backend/mercury/app/modules/orion/templates/__pycache__/report.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/templates/__pycache__/report.cpython-313.pyc
index b83517c0..80224c0e 100644
Binary files a/validation-suite/backend/mercury/app/modules/orion/templates/__pycache__/report.cpython-313.pyc and b/validation-suite/backend/mercury/app/modules/orion/templates/__pycache__/report.cpython-313.pyc differ
diff --git a/validation-suite/backend/mercury/app/modules/orion/templates/report.css b/validation-suite/backend/mercury/app/modules/orion/templates/report.css
index 993a8904..4f7554eb 100644
--- a/validation-suite/backend/mercury/app/modules/orion/templates/report.css
+++ b/validation-suite/backend/mercury/app/modules/orion/templates/report.css
@@ -14,6 +14,30 @@
@bottom-left { content: element(report-footer); }
}
+@page cover-narrow {
+ margin: 16mm 14mm 16mm 14mm;
+ @top-left { content: ""; }
+ @bottom-left { content: ""; }
+}
+
+@page cover-wide {
+ margin: 24mm 22mm 24mm 22mm;
+ @top-left { content: ""; }
+ @bottom-left { content: ""; }
+}
+
+@page report-narrow {
+ margin: 30mm 14mm 20mm 14mm;
+ @top-left { content: element(report-header); }
+ @bottom-left { content: element(report-footer); }
+}
+
+@page report-wide {
+ margin: 38mm 22mm 28mm 22mm;
+ @top-left { content: element(report-header); }
+ @bottom-left { content: element(report-footer); }
+}
+
* {
box-sizing: border-box;
}
@@ -145,6 +169,22 @@ body {
page: report;
}
+.page-margin-narrow .cover-page {
+ page: cover-narrow;
+}
+
+.page-margin-wide .cover-page {
+ page: cover-wide;
+}
+
+.page-margin-narrow .report-content {
+ page: report-narrow;
+}
+
+.page-margin-wide .report-content {
+ page: report-wide;
+}
+
.cover-top {
align-items: flex-start;
display: grid;
@@ -159,6 +199,14 @@ body {
width: auto;
}
+.logo-size-small .cover-logo {
+ height: 12mm;
+}
+
+.logo-size-large .cover-logo {
+ height: 22mm;
+}
+
.company-address {
color: #6B7C85;
font-size: 8.5pt;
@@ -198,6 +246,29 @@ h1 {
line-height: 1.25;
}
+.cover-compact .cover-top {
+ margin-bottom: 5mm;
+}
+
+.cover-compact h1 {
+ font-size: 24pt;
+ margin-bottom: 2mm;
+}
+
+.cover-compact .cover-subtitle {
+ font-size: 11.2pt;
+ margin-bottom: 2mm;
+}
+
+.cover-compact .cover-page .definition-row {
+ padding: .35mm 0;
+}
+
+.cover-compact .cover-page .definition-list {
+ font-size: 8.8pt;
+ line-height: 1.18;
+}
+
.cover-closing {
break-inside: avoid;
margin-top: 1.5mm;
@@ -299,11 +370,18 @@ h1 {
.signature-block {
break-inside: avoid;
color: #6B7C85;
- margin-top: 9mm;
page-break-inside: avoid;
width: 82mm;
}
+.signature-grid {
+ break-inside: avoid;
+ display: flex;
+ gap: 14mm;
+ margin-top: 9mm;
+ page-break-inside: avoid;
+}
+
.signature-line {
border-top: 1px solid #6B7C85;
height: 0;
@@ -320,6 +398,46 @@ h1 {
margin-top: 9mm;
}
+.main-chapter-breaks .report-content .chapter {
+ break-before: page;
+}
+
+.main-chapter-breaks .report-content .chapter:first-of-type {
+ break-before: auto;
+}
+
+.main-chapter-flow .report-content .chapter {
+ break-before: auto;
+}
+
+.section-spacing-compact h2 {
+ margin-bottom: 4mm;
+ padding-bottom: 2.5mm;
+}
+
+.section-spacing-compact h3 {
+ margin: 5mm 0 2mm 0;
+}
+
+.section-spacing-compact p {
+ margin-top: 2mm;
+ margin-bottom: 2mm;
+}
+
+.section-spacing-compact .chapter,
+.section-spacing-compact .chapter + .chapter {
+ margin-top: 6mm;
+}
+
+.section-spacing-compact .data-table {
+ margin-top: 2.5mm;
+}
+
+.section-spacing-compact .figure-grid {
+ gap: 5mm;
+ margin-top: 5mm;
+}
+
h2 {
border-bottom: .25mm solid #DCE3E3;
color: #2E3B40;
@@ -400,6 +518,19 @@ dd {
font-size: 8.5pt;
}
+.table-layout-compact .data-table {
+ margin-top: 2.5mm;
+}
+
+.table-layout-compact .data-table th,
+.table-layout-compact .data-table td {
+ padding: 1.5mm;
+}
+
+.table-font-small .data-table {
+ font-size: 8.4pt;
+}
+
.toc-list {
counter-reset: toc;
list-style: none;
@@ -441,6 +572,26 @@ dd {
margin-top: 8mm;
}
+.image-position-stacked .figure-grid {
+ grid-template-columns: 1fr;
+}
+
+.image-position-side-by-side .figure-grid {
+ grid-template-columns: 1fr 1fr;
+}
+
+.image-page-max-1 .report-figure {
+ break-after: page;
+}
+
+.image-page-max-2 .report-figure:nth-child(2n) {
+ break-after: page;
+}
+
+.image-page-max-4 .report-figure:nth-child(4n) {
+ break-after: page;
+}
+
.report-figure {
break-inside: avoid;
margin: 0;
@@ -454,6 +605,64 @@ dd {
width: 100%;
}
+.image-size-small .report-image {
+ max-height: 55mm;
+}
+
+.image-size-medium .report-image {
+ max-height: 90mm;
+}
+
+.image-size-large .report-image {
+ max-height: 125mm;
+}
+
+.image-captions-off .image-caption {
+ display: none;
+}
+
+.logo-size-small .report-header-brand img {
+ height: 14mm;
+}
+
+.logo-size-large .report-header-brand img {
+ height: 24mm;
+}
+
+.layout-compact html,
+.layout-compact {
+ line-height: 1.32;
+}
+
+.layout-compact h2 {
+ font-size: 18pt;
+ margin-bottom: 4mm;
+ padding-bottom: 2.5mm;
+}
+
+.layout-compact h3 {
+ margin: 5mm 0 2mm 0;
+}
+
+.layout-compact .chapter,
+.layout-compact .chapter + .chapter {
+ margin-top: 6mm;
+}
+
+.layout-compact .definition-row {
+ padding: 1.4mm 0;
+}
+
+.layout-compact .data-table th,
+.layout-compact .data-table td {
+ padding: 1.6mm;
+}
+
+.layout-compact .result-box {
+ margin: 2.5mm 0;
+ padding: 2.4mm 5mm;
+}
+
figcaption {
color: #6B7C85;
font-size: 8.5pt;
diff --git a/validation-suite/backend/mercury/app/modules/orion/templates/report.py b/validation-suite/backend/mercury/app/modules/orion/templates/report.py
index f0c95373..3e682257 100644
--- a/validation-suite/backend/mercury/app/modules/orion/templates/report.py
+++ b/validation-suite/backend/mercury/app/modules/orion/templates/report.py
@@ -8,30 +8,38 @@ from app.modules.orion.html import text
def render_report_chrome(context: ReportContext, logo_uri: str) -> str:
+ settings = context.report_settings
report_number = text(context.validation.report_number)
version = text(context.validation.version)
report_date = text(context.validation.updated_at)
- return f"""
-
-
- """
+ header = ""
+ footer = ""
+ if settings.show_header:
+ header = f"""
+
+ """
+ if settings.show_footer:
+ footer = f"""
+
+ """
+ return header + footer
def render_document(context: ReportContext, chapters: list[str]) -> str:
@@ -40,6 +48,24 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
title = f"Validierungsbericht {context.validation.report_number}"
cover_markup = chapters[0] if chapters else ""
content_markup = "\n".join(chapters[1:])
+ settings = context.report_settings
+ body_classes = " ".join(
+ [
+ "orion-report",
+ f"layout-{settings.layout_profile.lower()}",
+ f"page-margin-{settings.page_margin.lower()}",
+ f"section-spacing-{settings.section_spacing.lower()}",
+ f"table-layout-{settings.table_layout.lower()}",
+ f"table-font-{settings.table_font_size.lower()}",
+ f"image-size-{settings.image_size.lower()}",
+ f"image-position-{settings.image_position.lower().replace('_', '-')}",
+ f"image-page-max-{settings.max_images_per_page}",
+ f"logo-size-{settings.logo_size.lower()}",
+ "image-captions-on" if settings.show_image_captions else "image-captions-off",
+ "cover-compact" if settings.compact_cover else "cover-standard",
+ "main-chapter-breaks" if settings.page_break_before_main_chapters else "main-chapter-flow",
+ ]
+ )
return f"""
@@ -48,7 +74,7 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
{text(title)}
-
+
{cover_markup}
diff --git a/validation-suite/backend/mercury/app/schemas/__pycache__/domain.cpython-313.pyc b/validation-suite/backend/mercury/app/schemas/__pycache__/domain.cpython-313.pyc
index eb36daa6..d8aa28fd 100644
Binary files a/validation-suite/backend/mercury/app/schemas/__pycache__/domain.cpython-313.pyc and b/validation-suite/backend/mercury/app/schemas/__pycache__/domain.cpython-313.pyc differ
diff --git a/validation-suite/backend/mercury/app/schemas/domain.py b/validation-suite/backend/mercury/app/schemas/domain.py
index a37061e5..51f4d8ba 100644
--- a/validation-suite/backend/mercury/app/schemas/domain.py
+++ b/validation-suite/backend/mercury/app/schemas/domain.py
@@ -2,12 +2,23 @@ from __future__ import annotations
from datetime import date
from datetime import datetime
+from typing import Literal
from uuid import UUID
-from pydantic import EmailStr, Field, field_validator
+from pydantic import ConfigDict, EmailStr, Field, field_validator
from app.models.customer import CustomerType
from app.models.equipment import EquipmentKind, EquipmentStatus
+from app.models.report_settings import (
+ OrionImagePosition,
+ OrionImageSize,
+ OrionLayoutProfile,
+ OrionLogoSize,
+ OrionPageMargin,
+ OrionSignatureMode,
+ OrionSpacing,
+ OrionTableFontSize,
+)
from app.models.user import UserRole
from app.models.validation import ValidationStatus
from app.schemas.common import EntityRead, ORMModel
@@ -91,6 +102,32 @@ class DeviceUpdate(DeviceCreate):
pass
+class OrionReportSettingsUpdate(ORMModel):
+ model_config = ConfigDict(extra="forbid")
+
+ layout_profile: OrionLayoutProfile
+ show_cover_result_text: bool
+ signature_mode: OrionSignatureMode
+ image_size: OrionImageSize
+ page_break_before_main_chapters: bool
+ page_margin: OrionPageMargin
+ section_spacing: OrionSpacing
+ table_layout: OrionSpacing
+ table_font_size: OrionTableFontSize
+ image_position: OrionImagePosition
+ max_images_per_page: Literal[1, 2, 4]
+ show_image_captions: bool
+ show_header: bool
+ show_footer: bool
+ logo_size: OrionLogoSize
+ compact_cover: bool
+
+
+class OrionReportSettingsRead(OrionReportSettingsUpdate, EntityRead):
+ scope: str
+ updated_by_user_id: str | None = None
+
+
class EquipmentCreate(ORMModel):
kind: EquipmentKind
manufacturer: str | None = None
@@ -297,3 +334,10 @@ class QuickStartCreateRequest(ORMModel):
contact_id: UUID | None = None
device_id: UUID | None = None
validation_type: str
+
+ @field_validator("location_id", "contact_id", "device_id", mode="before")
+ @classmethod
+ def empty_string_to_none(cls, value):
+ if value == "":
+ return None
+ return value
diff --git a/validation-suite/backend/mercury/app/services/__pycache__/quickstart_service.cpython-313.pyc b/validation-suite/backend/mercury/app/services/__pycache__/quickstart_service.cpython-313.pyc
index 67eb04a0..a942a569 100644
Binary files a/validation-suite/backend/mercury/app/services/__pycache__/quickstart_service.cpython-313.pyc and b/validation-suite/backend/mercury/app/services/__pycache__/quickstart_service.cpython-313.pyc differ
diff --git a/validation-suite/backend/mercury/app/services/quickstart_service.py b/validation-suite/backend/mercury/app/services/quickstart_service.py
index d3e58988..fe4f543b 100644
--- a/validation-suite/backend/mercury/app/services/quickstart_service.py
+++ b/validation-suite/backend/mercury/app/services/quickstart_service.py
@@ -61,6 +61,8 @@ class QuickStartService:
device = self._device(str(payload.device_id)) if payload.device_id else self._single_or_default(devices)
if device is None:
raise ValueError("Kein Gerät verfügbar.")
+ if device.customer_id != customer.id:
+ raise ValueError("Gerät gehört nicht zum ausgewählten Kunden.")
locations = list(
self.session.scalars(
select(Location).where(Location.customer_id == customer.id).order_by(Location.name.asc())
@@ -73,13 +75,23 @@ class QuickStartService:
)
location = self._location(str(payload.location_id)) if payload.location_id else self._single_or_default(locations)
contact = self._contact(str(payload.contact_id)) if payload.contact_id else self._single_or_default(contacts)
+ if location is None:
+ if locations:
+ raise ValueError("Bitte Standort auswählen.")
+ raise ValueError("Kein Standort verfügbar.")
+ if location.customer_id != customer.id:
+ raise ValueError("Standort gehört nicht zum ausgewählten Kunden.")
+ if device.location_id and device.location_id != location.id:
+ raise ValueError("Gerät gehört nicht zum gewählten Standort.")
+ if contact and contact.customer_id != customer.id:
+ raise ValueError("Ansprechpartner gehört nicht zum ausgewählten Kunden.")
base = self._base_validation(device.id, payload.validation_type)
bundle = ReportTemplateService(self.session).ensure_default_template()
validation = Validation(
report_number=self._next_report_number(),
customer_id=customer.id,
- location_id=location.id if location else (locations[0].id if locations else None),
+ location_id=location.id,
contact_id=contact.id if contact else None,
device_id=device.id,
validation_type=payload.validation_type,
diff --git a/validation-suite/backend/mercury/app/services/report_settings.py b/validation-suite/backend/mercury/app/services/report_settings.py
new file mode 100644
index 00000000..acd6017f
--- /dev/null
+++ b/validation-suite/backend/mercury/app/services/report_settings.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.models.report_settings import (
+ OrionImagePosition,
+ OrionImageSize,
+ OrionLayoutProfile,
+ OrionLogoSize,
+ OrionPageMargin,
+ OrionReportSettings,
+ OrionReportSettingsScope,
+ OrionSignatureMode,
+ OrionSpacing,
+ OrionTableFontSize,
+)
+from app.models.user import User
+from app.schemas.domain import OrionReportSettingsUpdate
+
+
+class OrionReportSettingsService:
+ def __init__(self, session: Session) -> None:
+ self.session = session
+
+ def get_global(self) -> OrionReportSettings:
+ settings = self.session.scalar(
+ select(OrionReportSettings).where(
+ OrionReportSettings.scope == OrionReportSettingsScope.global_.value
+ )
+ )
+ if settings is not None:
+ return settings
+ settings = OrionReportSettings(
+ scope=OrionReportSettingsScope.global_.value,
+ layout_profile=OrionLayoutProfile.standard.value,
+ show_cover_result_text=True,
+ signature_mode=OrionSignatureMode.technical_only.value,
+ image_size=OrionImageSize.medium.value,
+ page_break_before_main_chapters=True,
+ page_margin=OrionPageMargin.standard.value,
+ section_spacing=OrionSpacing.standard.value,
+ table_layout=OrionSpacing.standard.value,
+ table_font_size=OrionTableFontSize.standard.value,
+ image_position=OrionImagePosition.side_by_side.value,
+ max_images_per_page=2,
+ show_image_captions=True,
+ show_header=True,
+ show_footer=True,
+ logo_size=OrionLogoSize.medium.value,
+ compact_cover=False,
+ )
+ self.session.add(settings)
+ self.session.flush()
+ return settings
+
+ def update_global(self, payload: OrionReportSettingsUpdate, user: User) -> OrionReportSettings:
+ settings = self.get_global()
+ settings.layout_profile = payload.layout_profile.value
+ settings.show_cover_result_text = payload.show_cover_result_text
+ settings.signature_mode = payload.signature_mode.value
+ settings.image_size = payload.image_size.value
+ settings.page_break_before_main_chapters = payload.page_break_before_main_chapters
+ settings.page_margin = payload.page_margin.value
+ settings.section_spacing = payload.section_spacing.value
+ settings.table_layout = payload.table_layout.value
+ settings.table_font_size = payload.table_font_size.value
+ settings.image_position = payload.image_position.value
+ settings.max_images_per_page = payload.max_images_per_page
+ settings.show_image_captions = payload.show_image_captions
+ settings.show_header = payload.show_header
+ settings.show_footer = payload.show_footer
+ settings.logo_size = payload.logo_size.value
+ settings.compact_cover = payload.compact_cover
+ settings.updated_by_user_id = user.id
+ self.session.flush()
+ return settings
diff --git a/validation-suite/backend/mercury/tests/__pycache__/test_validation_workflow.cpython-313-pytest-8.3.4.pyc b/validation-suite/backend/mercury/tests/__pycache__/test_validation_workflow.cpython-313-pytest-8.3.4.pyc
index 5d4aabfa..cdf6a815 100644
Binary files a/validation-suite/backend/mercury/tests/__pycache__/test_validation_workflow.cpython-313-pytest-8.3.4.pyc and b/validation-suite/backend/mercury/tests/__pycache__/test_validation_workflow.cpython-313-pytest-8.3.4.pyc differ
diff --git a/validation-suite/backend/mercury/tests/test_validation_workflow.py b/validation-suite/backend/mercury/tests/test_validation_workflow.py
index b5a7fc5c..d68491eb 100644
--- a/validation-suite/backend/mercury/tests/test_validation_workflow.py
+++ b/validation-suite/backend/mercury/tests/test_validation_workflow.py
@@ -25,11 +25,13 @@ from app.api.v1.auth import login as auth_login
from app.api.v1 import domain as domain_api
from app.schemas.auth import LoginRequest
from app.schemas.domain import QuickStartCreateRequest, UserCreate, UserUpdate, ValidationCreate
+from app.schemas.domain import OrionReportSettingsUpdate
from app.modules.orion.service import OrionReportService
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
from app.modules.orion.result import validation_result_box, validation_result_presentation
from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
from app.modules.helios.service import HeliosImportService
+from app.services.report_settings import OrionReportSettingsService
def session() -> Session:
@@ -785,6 +787,120 @@ def test_quickstart_auto_selects_single_customer_masterdata():
assert created.device_id == device.id
+def test_quickstart_requires_location_when_customer_has_multiple_locations():
+ db = session()
+ customer, location, device = seed(db)
+ db.add(Location(customer_id=customer.id, name="Zweiter Standort"))
+ admin = User(
+ email="multi-location-admin@schubamed.de",
+ first_name="Quickstart",
+ last_name="Admin",
+ role=UserRole.ADMIN.value,
+ password_hash="hash",
+ is_active=True,
+ must_change_password=False,
+ )
+ db.add(admin)
+ db.flush()
+
+ with pytest.raises(ValueError, match="Bitte Standort auswählen"):
+ QuickStartService(db).create_validation(
+ QuickStartCreateRequest(
+ customer_id=customer.id,
+ device_id=device.id,
+ validation_type="Erstvalidierung",
+ ),
+ examiner=admin,
+ )
+
+ created = QuickStartService(db).create_validation(
+ QuickStartCreateRequest(
+ customer_id=customer.id,
+ location_id=location.id,
+ device_id=device.id,
+ validation_type="Erstvalidierung",
+ ),
+ examiner=admin,
+ )
+ assert created.location_id == location.id
+
+
+def test_quickstart_rejects_foreign_location_and_contact_ids():
+ db = session()
+ customer, location, device = seed(db)
+ other_customer = Customer(customer_type=CustomerType.practice, name="Andere Praxis")
+ db.add(other_customer)
+ db.flush()
+ other_location = Location(customer_id=other_customer.id, name="Fremder Standort")
+ other_contact = Contact(customer_id=other_customer.id, full_name="Fremder Kontakt")
+ admin = User(
+ email="foreign-masterdata-admin@schubamed.de",
+ first_name="Quickstart",
+ last_name="Admin",
+ role=UserRole.ADMIN.value,
+ password_hash="hash",
+ is_active=True,
+ must_change_password=False,
+ )
+ db.add_all([other_location, other_contact, admin])
+ db.flush()
+
+ with pytest.raises(ValueError, match="Standort gehört nicht"):
+ QuickStartService(db).create_validation(
+ QuickStartCreateRequest(
+ customer_id=customer.id,
+ location_id=other_location.id,
+ device_id=device.id,
+ validation_type="Erstvalidierung",
+ ),
+ examiner=admin,
+ )
+
+ with pytest.raises(ValueError, match="Ansprechpartner gehört nicht"):
+ QuickStartService(db).create_validation(
+ QuickStartCreateRequest(
+ customer_id=customer.id,
+ location_id=location.id,
+ contact_id=other_contact.id,
+ device_id=device.id,
+ validation_type="Erstvalidierung",
+ ),
+ examiner=admin,
+ )
+
+
+def test_quickstart_allows_multiple_contacts_without_required_selection():
+ db = session()
+ customer, location, device = seed(db)
+ db.add_all([
+ Contact(customer_id=customer.id, full_name="Kontakt A"),
+ Contact(customer_id=customer.id, full_name="Kontakt B"),
+ ])
+ admin = User(
+ email="optional-contact-admin@schubamed.de",
+ first_name="Quickstart",
+ last_name="Admin",
+ role=UserRole.ADMIN.value,
+ password_hash="hash",
+ is_active=True,
+ must_change_password=False,
+ )
+ db.add(admin)
+ db.flush()
+
+ created = QuickStartService(db).create_validation(
+ QuickStartCreateRequest(
+ customer_id=customer.id,
+ location_id=location.id,
+ device_id=device.id,
+ validation_type="Erstvalidierung",
+ ),
+ examiner=admin,
+ )
+
+ assert created.contact_id is None
+
+
def test_quickstart_device_last_validation_returns_latest_completed_validation():
db = session()
customer, location, device = seed(db)
@@ -1257,3 +1373,194 @@ def test_confirmed_measurements_appear_in_orion_report(tmp_path):
assert "leak_rate" in html
assert "0,2" in html
+
+
+def seed_admin(db: Session) -> User:
+ user = User(
+ email="admin@schubamed.de",
+ first_name="Admin",
+ last_name="User",
+ role=UserRole.ADMIN.value,
+ password_hash="hash",
+ is_active=True,
+ must_change_password=False,
+ )
+ db.add(user)
+ db.flush()
+ return user
+
+
+def report_settings_payload(**overrides) -> OrionReportSettingsUpdate:
+ values = {
+ "layout_profile": "STANDARD",
+ "show_cover_result_text": True,
+ "signature_mode": "TECHNICAL_ONLY",
+ "image_size": "MEDIUM",
+ "page_break_before_main_chapters": True,
+ "page_margin": "STANDARD",
+ "section_spacing": "STANDARD",
+ "table_layout": "STANDARD",
+ "table_font_size": "STANDARD",
+ "image_position": "SIDE_BY_SIDE",
+ "max_images_per_page": 2,
+ "show_image_captions": True,
+ "show_header": True,
+ "show_footer": True,
+ "logo_size": "MEDIUM",
+ "compact_cover": False,
+ }
+ values.update(overrides)
+ return OrionReportSettingsUpdate(**values)
+
+
+def test_report_settings_defaults_and_update_persist():
+ db = session()
+ admin = seed_admin(db)
+
+ settings = OrionReportSettingsService(db).get_global()
+
+ assert settings.scope == "GLOBAL"
+ assert settings.layout_profile == "STANDARD"
+ assert settings.show_cover_result_text is True
+ assert settings.signature_mode == "TECHNICAL_ONLY"
+ assert settings.image_size == "MEDIUM"
+ assert settings.page_break_before_main_chapters is True
+ assert settings.page_margin == "STANDARD"
+ assert settings.section_spacing == "STANDARD"
+ assert settings.table_layout == "STANDARD"
+ assert settings.table_font_size == "STANDARD"
+ assert settings.image_position == "SIDE_BY_SIDE"
+ assert settings.max_images_per_page == 2
+ assert settings.show_image_captions is True
+ assert settings.show_header is True
+ assert settings.show_footer is True
+ assert settings.logo_size == "MEDIUM"
+ assert settings.compact_cover is False
+
+ updated = OrionReportSettingsService(db).update_global(
+ report_settings_payload(
+ layout_profile="COMPACT",
+ show_cover_result_text=False,
+ signature_mode="TECHNICAL_AND_CLIENT",
+ image_size="LARGE",
+ page_break_before_main_chapters=False,
+ page_margin="WIDE",
+ section_spacing="COMPACT",
+ table_layout="COMPACT",
+ table_font_size="SMALL",
+ image_position="STACKED",
+ max_images_per_page=4,
+ show_image_captions=False,
+ show_header=False,
+ show_footer=False,
+ logo_size="LARGE",
+ compact_cover=True,
+ ),
+ admin,
+ )
+ db.commit()
+
+ assert updated.layout_profile == "COMPACT"
+ assert updated.show_cover_result_text is False
+ assert updated.signature_mode == "TECHNICAL_AND_CLIENT"
+ assert updated.image_size == "LARGE"
+ assert updated.page_break_before_main_chapters is False
+ assert updated.page_margin == "WIDE"
+ assert updated.section_spacing == "COMPACT"
+ assert updated.table_layout == "COMPACT"
+ assert updated.table_font_size == "SMALL"
+ assert updated.image_position == "STACKED"
+ assert updated.max_images_per_page == 4
+ assert updated.show_image_captions is False
+ assert updated.show_header is False
+ assert updated.show_footer is False
+ assert updated.logo_size == "LARGE"
+ assert updated.compact_cover is True
+ assert updated.updated_by_user_id == admin.id
+
+
+def test_report_settings_invalid_enum_is_rejected():
+ with pytest.raises(Exception):
+ report_settings_payload(
+ layout_profile="WILD",
+ )
+
+
+def test_orion_settings_preview_pdf_contains_pdf_bytes(tmp_path):
+ db = session()
+ seed_admin(db)
+
+ pdf = OrionReportService(db, tmp_path).render_settings_preview_pdf(
+ report_settings_payload(
+ layout_profile="COMPACT",
+ show_cover_result_text=True,
+ signature_mode="TECHNICAL_AND_CLIENT",
+ image_size="SMALL",
+ page_break_before_main_chapters=False,
+ page_margin="NARROW",
+ section_spacing="COMPACT",
+ table_layout="COMPACT",
+ table_font_size="SMALL",
+ image_position="STACKED",
+ max_images_per_page=1,
+ show_image_captions=False,
+ show_header=False,
+ show_footer=False,
+ logo_size="SMALL",
+ compact_cover=True,
+ )
+ )
+
+ assert pdf.startswith(b"%PDF")
+ assert len(pdf) > 1024
+
+
+def test_orion_report_settings_affect_html_without_removing_summary_result(tmp_path):
+ db = session()
+ customer, location, device = seed(db)
+ validation = valid_validation(customer, location, device)
+ validation.result = "BESTANDEN"
+ db.add(validation)
+ admin = seed_admin(db)
+ OrionReportSettingsService(db).update_global(
+ report_settings_payload(
+ layout_profile="COMPACT",
+ show_cover_result_text=False,
+ signature_mode="TECHNICAL_AND_CLIENT",
+ image_size="SMALL",
+ page_break_before_main_chapters=False,
+ page_margin="NARROW",
+ section_spacing="COMPACT",
+ table_layout="COMPACT",
+ table_font_size="SMALL",
+ image_position="STACKED",
+ max_images_per_page=1,
+ show_image_captions=False,
+ show_header=False,
+ show_footer=False,
+ logo_size="SMALL",
+ compact_cover=True,
+ ),
+ admin,
+ )
+ db.commit()
+
+ html = OrionReportService(db, tmp_path).render_html(validation.id)
+
+ assert "layout-compact" in html
+ assert "page-margin-narrow" in html
+ assert "section-spacing-compact" in html
+ assert "table-layout-compact" in html
+ assert "table-font-small" in html
+ assert "image-size-small" in html
+ assert "image-position-stacked" in html
+ assert "image-page-max-1" in html
+ assert "image-captions-off" in html
+ assert "logo-size-small" in html
+ assert "cover-compact" in html
+ assert "main-chapter-flow" in html
+ assert "Ergebnis der Validierung" not in html.split('')[0]
+ assert "Unterschrift Auftraggeber" in html
+ assert '