style(orion): align report header footer and page layout
This commit is contained in:
parent
302e542fda
commit
503b343070
40 changed files with 2010 additions and 148 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue