style(orion): align report header footer and page layout

This commit is contained in:
Schubert Ferenc 2026-07-11 12:19:10 +02:00
parent 302e542fda
commit 503b343070
40 changed files with 2010 additions and 148 deletions

View file

@ -1,10 +1,32 @@
from __future__ import annotations
from pathlib import Path
from urllib.parse import quote
from app.core.config import settings
from app.modules.orion.assets import schubamed_logo_uri
from app.modules.orion.components.base import ReportComponent
from app.modules.orion.context import ReportContext
from app.modules.orion.html import definition_list, paragraph, section, table, text, yes_no
def render_template_text(content: str, context: ReportContext) -> str:
values = {
"device.manufacturer": context.device.manufacturer if context.device else None,
"device.model": context.device.model if context.device else None,
"device.serial_number": context.device.serial_number if context.device else None,
"customer.name": context.customer.name,
"location.city": context.location.city if context.location else None,
"validation.performed_on": context.validation.performed_on,
"validation.next_validation_on": context.validation.next_validation_on,
"validation.result": context.validation.result,
}
rendered = content
for key, value in values.items():
rendered = rendered.replace("{{ " + key + " }}", text(value))
return rendered
class CoverComponent(ReportComponent):
anchor = "cover"
title = "Deckblatt"
@ -13,6 +35,12 @@ class CoverComponent(ReportComponent):
validation = context.validation
rows = definition_list(
[
("Hersteller", context.device.manufacturer if context.device else "nicht erfasst"),
("Geraet", context.device.model if context.device else "nicht erfasst"),
(
"Seriennummer",
context.device.serial_number if context.device else "nicht erfasst",
),
("Berichtsnummer", validation.report_number),
("Validierungsart", validation.validation_type),
("Projekt", validation.project),
@ -21,18 +49,25 @@ class CoverComponent(ReportComponent):
("Pruefer", validation.examiner_name),
("Gesamtergebnis", validation.result),
("Status", validation.status),
("Ansprechpartner", context.contact.full_name if context.contact else "nicht erfasst"),
(
"Ansprechpartner",
context.contact.full_name if context.contact else "nicht erfasst",
),
("Mitwirkende Personen", validation.participants or "nicht erfasst"),
]
)
logo_uri = schubamed_logo_uri()
return (
"<section class=\"cover-page\" id=\"cover\">"
"<div class=\"cover-kicker\">Neutraler Logo-Platzhalter · Validation Suite</div>"
"<h1>Pruefbericht zur Validierung</h1>"
"<p class=\"cover-subtitle\">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>"
f"<p class=\"cover-subtitle\">{text(context.customer.name)}</p>"
'<section class="cover-page" id="cover">'
'<div class="cover-top">'
'<div class="company-address">SCHUBAMED<br>Validation Suite<br>Medizintechnik und Validierung</div>'
f'<img class="cover-logo" src="{logo_uri}" alt="SCHUBAMED Validation Suite">'
"</div>"
"<h1>PRUEFBERICHT ZUR VALIDIERUNG</h1>"
'<p class="cover-subtitle">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>'
f'<p class="cover-subtitle">{text(context.customer.name)}</p>'
f"{rows}"
"<div class=\"signature-grid\"><div>Unterschrift technische Validierung</div><div>Unterschrift Auftraggeber</div></div>"
'<div class="signature-grid"><div>Unterschrift technische Validierung</div><div>Unterschrift Auftraggeber</div></div>'
"</section>"
)
@ -46,11 +81,11 @@ class TocComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
links = "".join(
f"<li><a href=\"#{component.anchor}\">{text(component.title)}</a></li>"
f'<li><a href="#{component.anchor}">{text(component.title)}</a></li>'
for component in self.components
if component.anchor not in {"cover", "toc"}
)
return section(self.anchor, self.title, f"<ol class=\"toc-list\">{links}</ol>")
return section(self.anchor, self.title, f'<ol class="toc-list">{links}</ol>')
class SummaryComponent(ReportComponent):
@ -59,28 +94,44 @@ class SummaryComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
validation = context.validation
body = definition_list(
block = context.text_blocks.get("summary")
body = f"<p>{paragraph(render_template_text(block.content, context) if block else 'nicht erfasst')}</p>"
body += definition_list(
[
("Kunde", context.customer.name),
("Standort", context.location.name if context.location else None),
("Geraet", f"{context.device.manufacturer} {context.device.model}" if context.device else None),
(
"Geraet",
(
f"{context.device.manufacturer} {context.device.model}"
if context.device
else None
),
),
("Pruefmittel", len(context.equipment)),
("Ergebnis", validation.result),
("Mitwirkende Personen", validation.participants),
("Hinweis auf naechste Leistungsbeurteilung", validation.next_validation_on or "nicht erfasst"),
(
"Hinweis auf naechste Leistungsbeurteilung",
validation.next_validation_on or "nicht erfasst",
),
]
)
return section(self.anchor, self.title, body)
class StaticTextComponent(ReportComponent):
def __init__(self, anchor: str, title: str, body: str = "nicht erfasst") -> None:
def __init__(self, anchor: str, title: str, body: str = "nicht erfasst", block_key: str | None = None) -> None:
self.anchor = anchor
self.title = title
self.body = body
self.block_key = block_key
def render(self, context: ReportContext) -> str:
return section(self.anchor, self.title, f"<p>{text(self.body)}</p>")
if self.block_key and self.block_key in context.text_blocks:
content = render_template_text(context.text_blocks[self.block_key].content, context)
return section(self.anchor, self.title, f"<p>{paragraph(content)}</p>")
return section(self.anchor, self.title, f"<p>{paragraph(self.body)}</p>")
class CustomerComponent(ReportComponent):
@ -95,7 +146,10 @@ class CustomerComponent(ReportComponent):
[
("Name", customer.name),
("Typ", customer.customer_type.value),
("Adresse", " ".join(filter(None, [customer.street, customer.postal_code, customer.city]))),
(
"Adresse",
" ".join(filter(None, [customer.street, customer.postal_code, customer.city])),
),
("Telefon", customer.phone),
("Mail", customer.email),
("Betreiber", context.validation.operator_name),
@ -107,7 +161,12 @@ class CustomerComponent(ReportComponent):
body += "<h3>Standort</h3>" + definition_list(
[
("Name", location.name),
("Adresse", " ".join(filter(None, [location.street, location.postal_code, location.city]))),
(
"Adresse",
" ".join(
filter(None, [location.street, location.postal_code, location.city])
),
),
("Raum", location.room),
]
)
@ -139,7 +198,14 @@ class DeviceComponent(ReportComponent):
("Seriennummer", device.serial_number),
("Baujahr", device.year_built),
("Inbetriebnahme", device.commissioned_on),
("Kammervolumen", f"{device.chamber_volume_liters} Liter" if device.chamber_volume_liters else None),
(
"Kammervolumen",
(
f"{device.chamber_volume_liters} Liter"
if device.chamber_volume_liters
else None
),
),
("Dampferzeugung", device.steam_generation),
("Wasseraufbereitung", device.water_treatment),
("Dokumentation", device.documentation),
@ -170,7 +236,15 @@ class EquipmentComponent(ReportComponent):
self.anchor,
self.title,
table(
["Art", "Hersteller", "Modell", "Seriennummer", "Kalibriert", "Gueltig bis", "Status"],
[
"Art",
"Hersteller",
"Modell",
"Seriennummer",
"Kalibriert",
"Gueltig bis",
"Status",
],
rows,
),
)
@ -189,7 +263,10 @@ class EnvironmentComponent(ReportComponent):
("Pruefzeit", data.get("test_time")),
]
)
rows = [[item.get("text"), yes_no(item.get("value")), item.get("comment")] for item in data.get("checks", [])]
rows = [
[item.get("text"), yes_no(item.get("value")), item.get("comment")]
for item in data.get("checks", [])
]
if rows:
body += table(["Pruefpunkt", "Bewertung", "Kommentar"], rows)
return section(self.anchor, self.title, body)
@ -200,10 +277,15 @@ class ChecklistComponent(ReportComponent):
title = "Dokumentations- und Leistungschecklisten"
def render(self, context: ReportContext) -> str:
documentation = context.validation.documentation_checklist or []
performance = context.validation.performance_checklist or []
body = "<h3>Dokumentation</h3>" + self._render_items(documentation)
body += "<h3>Leistung</h3>" + self._render_items(performance)
body = ""
for checklist in context.checklist_templates:
body += f"<h3>{text(checklist.title)}</h3>"
body += self._render_items(checklist.items)
if not body:
documentation = context.validation.documentation_checklist or []
performance = context.validation.performance_checklist or []
body = "<h3>Dokumentation</h3>" + self._render_items(documentation)
body += "<h3>Leistung</h3>" + self._render_items(performance)
return section(self.anchor, self.title, body)
def _render_items(self, items: list[dict]) -> str:
@ -220,7 +302,10 @@ class ProgramComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
programs = [item for item in (context.validation.programs or []) if item.get("selected")]
rows = [[index + 1, item.get("name"), "Eigenes Programm" if item.get("custom") else "Standard"] for index, item in enumerate(programs)]
rows = [
[index + 1, item.get("name"), "Eigenes Programm" if item.get("custom") else "Standard"]
for index, item in enumerate(programs)
]
return section(self.anchor, self.title, table(["Nr.", "Programm", "Typ"], rows))
@ -230,10 +315,19 @@ class LoadingComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
rows = [
[item.get("run"), item.get("pattern"), item.get("description"), len(item.get("images", []))]
[
item.get("run"),
item.get("pattern"),
item.get("description"),
len(item.get("images", [])),
]
for item in context.validation.loading_patterns or []
]
return section(self.anchor, self.title, table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows))
return section(
self.anchor,
self.title,
table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows),
)
class MeasurementComponent(ReportComponent):
@ -241,6 +335,23 @@ class MeasurementComponent(ReportComponent):
title = "Messdaten"
def render(self, context: ReportContext) -> str:
if context.confirmed_measurements:
rows = [
[
item.test_run,
item.field_name,
item.corrected_value or item.normalized_value or item.raw_value,
item.unit,
item.source_page,
f"{item.confidence} %",
]
for item in context.confirmed_measurements
]
return section(
self.anchor,
self.title,
table(["Testlauf", "Messwert", "Wert", "Einheit", "Quelle", "Sicherheit"], rows, "compact"),
)
rows = [
[
item.get("name"),
@ -277,9 +388,12 @@ class MeasurementComponent(ReportComponent):
winlog_rows = []
for item in context.validation.measurement_data or []:
for imported in item.get("imports", []):
winlog_rows.append([item.get("name"), imported.get("filename"), imported.get("content_type")])
winlog_rows.append(
[item.get("name"), imported.get("filename"), imported.get("content_type")]
)
if winlog_rows:
body += "<h3>Winlog-Dateien</h3>" + table(["Bereich", "Datei", "Typ"], winlog_rows)
body += "<p>Messdaten noch nicht bestätigt.</p>"
return section(self.anchor, self.title, body)
@ -318,8 +432,51 @@ class AttachmentComponent(ReportComponent):
title = "Bilder und Anlagen"
def render(self, context: ReportContext) -> str:
attachments = sorted(
context.validation.attachments or [], key=lambda row: row.get("order") or 0
)
rows = [
[item.get("order"), item.get("category"), item.get("filename"), item.get("description")]
for item in sorted(context.validation.attachments or [], key=lambda row: row.get("order") or 0)
for item in attachments
]
return section(self.anchor, self.title, table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows))
figures = []
for index, item in enumerate(attachments, start=1):
src = self._image_source(item)
if not src:
continue
caption = (
item.get("description") or item.get("filename") or item.get("category") or "Anlage"
)
figures.append(
'<figure class="report-figure">'
f'<img class="report-image" src="{text(src)}" alt="{text(caption)}">'
f"<figcaption>Abbildung {index}: {text(caption)}</figcaption>"
"</figure>"
)
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)
if figures:
body += '<div class="figure-grid">' + "".join(figures) + "</div>"
return section(self.anchor, self.title, body)
def _image_source(self, item: dict) -> str | None:
content_type = str(item.get("content_type") or "")
filename = str(item.get("filename") or "")
if not content_type.startswith("image/") and Path(filename).suffix.lower() not in {
".jpg",
".jpeg",
".png",
".webp",
".svg",
}:
return None
url = item.get("url")
if isinstance(url, str) and url:
if url.startswith(("http://", "https://")):
return url
return f"{settings.public_base_url.rstrip('/')}{quote(url, safe='/:._-')}"
storage_path = item.get("storage_path")
if storage_path:
path = Path(str(storage_path))
if path.exists():
return path.resolve().as_uri()
return None