feat(orion): extend report layout settings to version 1.1

This commit is contained in:
Schubert Ferenc 2026-07-12 14:56:15 +02:00
parent 12e1761a5b
commit 613ffdc8b7
591 changed files with 3601 additions and 999 deletions

View file

@ -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()

View file

@ -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",

View file

@ -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)

View file

@ -84,15 +84,32 @@ class CoverComponent(ReportComponent):
f'<p class="cover-subtitle">{text(context.customer.name)}</p>'
f"{rows}"
'<div class="cover-closing">'
'<div class="cover-result-text">'
"<h2>Ergebnis der Validierung</h2>"
f"<p>{text(validation_result_cover_sentence(validation.result))}</p>"
"</div>"
'<div class="signature-block"><div class="signature-line"></div><div class="signature-label">Unterschrift technische Validierung</div></div>'
f"{self._cover_result_text(context)}"
f"{self._signature_blocks(context)}"
"</div>"
"</section>"
)
def _cover_result_text(self, context: ReportContext) -> str:
if not context.report_settings.show_cover_result_text:
return ""
return (
'<div class="cover-result-text">'
"<h2>Ergebnis der Validierung</h2>"
f"<p>{text(validation_result_cover_sentence(context.validation.result))}</p>"
"</div>"
)
def _signature_blocks(self, context: ReportContext) -> str:
blocks = [
'<div class="signature-block"><div class="signature-line"></div><div class="signature-label">Unterschrift technische Validierung</div></div>'
]
if context.report_settings.signature_mode == "TECHNICAL_AND_CLIENT":
blocks.append(
'<div class="signature-block"><div class="signature-line"></div><div class="signature-label">Unterschrift Auftraggeber</div></div>'
)
return '<div class="signature-grid">' + "".join(blocks) + "</div>"
class TocComponent(ReportComponent):
anchor = "toc"
@ -502,7 +519,7 @@ class AttachmentComponent(NumberedComponent):
figures.append(
'<figure class="report-figure">'
f'<img class="report-image" src="{text(src)}" alt="{text(caption)}">'
f"<figcaption>Abbildung {index}: {text(caption)}</figcaption>"
f'<figcaption class="image-caption">Abbildung {index}: {text(caption)}</figcaption>'
"</figure>"
)
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)

View file

@ -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,
)

View file

@ -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"],
],
)
+ (
'<div class="figure-grid">'
+ "".join(
'<figure class="report-figure">'
f'<img class="report-image" src="{text((ORION_ASSET_DIR / "schubamed-logo.svg").resolve().as_uri())}" alt="Beispielbild {index}">'
f'<figcaption class="image-caption">Abbildung {index}: Beispielbild fuer die Layoutvorschau</figcaption>'
"</figure>"
for index in range(1, 5)
)
+ "</div>"
),
"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"])

View file

@ -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;

View file

@ -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 class="report-header" aria-label="Berichtskopf">
<div class="report-header-brand">
<img src="{logo_uri}" alt="SCHUBAMED">
<div>
<strong>SCHUBAMED®</strong>
<span>Aufbereitung mit System</span>
</div>
</div>
<dl class="report-header-meta">
<div><dt>Berichtsnummer</dt><dd>{report_number}</dd></div>
<div><dt>Version</dt><dd>{version}</dd></div>
<div><dt>Datum</dt><dd>{report_date}</dd></div>
</dl>
</header>
<footer class="report-footer" aria-label="Berichtsfuß">
<span>Validation Suite</span>
<span>Seite <span class="page-number"></span> von <span class="page-count"></span></span>
<span>Version {version}</span>
</footer>
"""
header = ""
footer = ""
if settings.show_header:
header = f"""
<header class="report-header" aria-label="Berichtskopf">
<div class="report-header-brand">
<img src="{logo_uri}" alt="SCHUBAMED">
<div>
<strong>SCHUBAMED®</strong>
<span>Aufbereitung mit System</span>
</div>
</div>
<dl class="report-header-meta">
<div><dt>Berichtsnummer</dt><dd>{report_number}</dd></div>
<div><dt>Version</dt><dd>{version}</dd></div>
<div><dt>Datum</dt><dd>{report_date}</dd></div>
</dl>
</header>
"""
if settings.show_footer:
footer = f"""
<footer class="report-footer" aria-label="Berichtsfuß">
<span>Validation Suite</span>
<span>Seite <span class="page-number"></span> von <span class="page-count"></span></span>
<span>Version {version}</span>
</footer>
"""
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"""<!doctype html>
<html lang="de">
<head>
@ -48,7 +74,7 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
<title>{text(title)}</title>
<style>{css}</style>
</head>
<body>
<body class="{body_classes}">
<article class="report-document">
{cover_markup}
<section class="report-content">

View file

@ -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

View file

@ -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,

View file

@ -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