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,9 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.report_template import (
|
||||
MeasurementImport,
|
||||
MeasurementImportStatus,
|
||||
MeasurementImportType,
|
||||
MeasurementImportValue,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeasurementSeries:
|
||||
|
|
@ -12,8 +24,181 @@ class MeasurementSeries:
|
|||
|
||||
|
||||
class HeliosImportService:
|
||||
parser_version = "helios-winlog-pdf-1.0"
|
||||
|
||||
def __init__(self, session: Session | None = None, upload_root: Path | None = None) -> None:
|
||||
self.session = session
|
||||
self.upload_root = upload_root or Path("/app/uploads")
|
||||
|
||||
def import_csv(self, path: Path) -> MeasurementSeries:
|
||||
with path.open(newline="", encoding="utf-8-sig") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
return MeasurementSeries(headers=reader.fieldnames or [], rows=list(reader))
|
||||
|
||||
def save_winlog_pdf(
|
||||
self, validation_id: str, filename: str, content: bytes, attachment_only: bool = False
|
||||
) -> dict:
|
||||
if self.session is None:
|
||||
raise RuntimeError("A database session is required for Winlog imports")
|
||||
safe_name = Path(filename or "winlog.pdf").name
|
||||
target_dir = self.upload_root / "validations" / validation_id / "winlog"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
storage_path = target_dir / safe_name
|
||||
storage_path.write_bytes(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
import_row = MeasurementImport(
|
||||
validation_id=validation_id,
|
||||
import_type=(
|
||||
MeasurementImportType.winlog_attachment_only.value
|
||||
if attachment_only
|
||||
else MeasurementImportType.winlog_pdf.value
|
||||
),
|
||||
original_filename=safe_name,
|
||||
storage_path=str(storage_path),
|
||||
sha256=digest,
|
||||
parser_version=self.parser_version,
|
||||
status=(
|
||||
MeasurementImportStatus.attachment_only.value
|
||||
if attachment_only
|
||||
else MeasurementImportStatus.uploaded.value
|
||||
),
|
||||
)
|
||||
self.session.add(import_row)
|
||||
self.session.flush()
|
||||
values: list[MeasurementImportValue] = []
|
||||
if not attachment_only:
|
||||
values = self._extract_pdf_values(import_row, storage_path)
|
||||
import_row.status = (
|
||||
MeasurementImportStatus.preview_ready.value
|
||||
if values
|
||||
else MeasurementImportStatus.attachment_only.value
|
||||
)
|
||||
import_row.import_type = (
|
||||
MeasurementImportType.winlog_pdf.value
|
||||
if values
|
||||
else MeasurementImportType.winlog_attachment_only.value
|
||||
)
|
||||
self.session.flush()
|
||||
return self.preview(import_row.id)
|
||||
|
||||
def preview(self, import_id: str) -> dict:
|
||||
if self.session is None:
|
||||
raise RuntimeError("A database session is required for Winlog imports")
|
||||
import_row = self.session.get(MeasurementImport, import_id)
|
||||
if import_row is None:
|
||||
raise ValueError("Measurement import not found")
|
||||
values = list(
|
||||
self.session.scalars(
|
||||
select(MeasurementImportValue).where(MeasurementImportValue.import_id == import_id)
|
||||
)
|
||||
)
|
||||
return {
|
||||
"id": import_row.id,
|
||||
"validation_id": import_row.validation_id,
|
||||
"import_type": import_row.import_type,
|
||||
"original_filename": import_row.original_filename,
|
||||
"sha256": import_row.sha256,
|
||||
"parser_version": import_row.parser_version,
|
||||
"status": import_row.status,
|
||||
"values": [
|
||||
{
|
||||
"id": value.id,
|
||||
"test_run": value.test_run,
|
||||
"field_name": value.field_name,
|
||||
"raw_value": value.raw_value,
|
||||
"normalized_value": value.normalized_value,
|
||||
"unit": value.unit,
|
||||
"source_page": value.source_page,
|
||||
"source_text": value.source_text,
|
||||
"confidence": value.confidence,
|
||||
"confirmed": value.confirmed,
|
||||
"corrected_value": value.corrected_value,
|
||||
}
|
||||
for value in values
|
||||
],
|
||||
}
|
||||
|
||||
def confirm_values(self, import_id: str, values: list[dict]) -> dict:
|
||||
if self.session is None:
|
||||
raise RuntimeError("A database session is required for Winlog imports")
|
||||
import_row = self.session.get(MeasurementImport, import_id)
|
||||
if import_row is None:
|
||||
raise ValueError("Measurement import not found")
|
||||
by_id = {
|
||||
value.id: value
|
||||
for value in self.session.scalars(
|
||||
select(MeasurementImportValue).where(MeasurementImportValue.import_id == import_id)
|
||||
)
|
||||
}
|
||||
for payload in values:
|
||||
item = by_id.get(payload.get("id"))
|
||||
if item is None:
|
||||
continue
|
||||
item.confirmed = bool(payload.get("confirmed"))
|
||||
item.corrected_value = payload.get("corrected_value") or item.corrected_value
|
||||
import_row.status = MeasurementImportStatus.confirmed.value
|
||||
self.session.flush()
|
||||
return self.preview(import_id)
|
||||
|
||||
def _extract_pdf_values(self, import_row: MeasurementImport, path: Path) -> list[MeasurementImportValue]:
|
||||
from pypdf import PdfReader
|
||||
|
||||
values: list[MeasurementImportValue] = []
|
||||
try:
|
||||
reader = PdfReader(str(path))
|
||||
pages = [page.extract_text() or "" for page in reader.pages]
|
||||
except Exception:
|
||||
import_row.status = MeasurementImportStatus.error.value
|
||||
return []
|
||||
for page_index, page_text in enumerate(pages, start=1):
|
||||
if not page_text.strip():
|
||||
continue
|
||||
test_run = self._detect_test_run(page_text)
|
||||
for field_name, pattern, unit in self._patterns():
|
||||
match = re.search(pattern, page_text, flags=re.IGNORECASE)
|
||||
if not match:
|
||||
continue
|
||||
raw_value = match.group(1).strip()
|
||||
value = MeasurementImportValue(
|
||||
import_id=import_row.id,
|
||||
test_run=test_run,
|
||||
field_name=field_name,
|
||||
raw_value=raw_value,
|
||||
normalized_value=raw_value,
|
||||
unit=unit,
|
||||
source_page=page_index,
|
||||
source_text=match.group(0)[:500],
|
||||
confidence=80,
|
||||
confirmed=False,
|
||||
corrected_value=None,
|
||||
)
|
||||
self.session.add(value)
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
def _detect_test_run(self, text: str) -> str:
|
||||
lower = text.lower()
|
||||
if "vakuum" in lower:
|
||||
return "Vakuumtest"
|
||||
if "bowie" in lower or "leerkammer" in lower:
|
||||
return "Bowie-Dick / Leerkammerprofil"
|
||||
for index in (1, 2, 3):
|
||||
if f"testlauf {index}" in lower or f"test {index}" in lower:
|
||||
return f"Testlauf {index}"
|
||||
return "nicht zugeordnet"
|
||||
|
||||
def _patterns(self) -> list[tuple[str, str, str | None]]:
|
||||
return [
|
||||
("program_name", r"Programm(?:name)?[:\s]+([^\n]+)", None),
|
||||
("batch_number", r"Charge(?:nnummer)?[:\s]+([^\n]+)", None),
|
||||
("start_time", r"Start(?:zeit)?[:\s]+([0-9:.\-\s]+)", None),
|
||||
("end_time", r"(?:Ende|Endzeit)[:\s]+([0-9:.\-\s]+)", None),
|
||||
("duration", r"Dauer[:\s]+([0-9:.\-\s]+)", None),
|
||||
("min_temperature", r"Min(?:dest)?temperatur[:\s]+([0-9,.]+)", "°C"),
|
||||
("max_temperature", r"Max(?:imal|\.)?temperatur[:\s]+([0-9,.]+)", "°C"),
|
||||
("temperature_band", r"Temperaturband[:\s]+([0-9,.]+)", "K"),
|
||||
("holding_time", r"Haltezeit[:\s]+([0-9:.\-\s]+)", None),
|
||||
("pressure", r"Druck[:\s]+([0-9,.-]+)", "bar"),
|
||||
("leak_rate", r"Leckrate[:\s]+([0-9,.-]+)", "mbar/min"),
|
||||
("result", r"Ergebnis[:\s]+([^\n]+)", None),
|
||||
]
|
||||
|
|
|
|||
12
validation-suite/backend/mercury/app/modules/orion/assets.py
Normal file
12
validation-suite/backend/mercury/app/modules/orion/assets.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ORION_ASSET_DIR = Path(__file__).resolve().parent / "assets"
|
||||
SCHUBAMED_LOGO_PATH = ORION_ASSET_DIR / "schubamed-logo.svg"
|
||||
|
||||
|
||||
def schubamed_logo_uri() -> str:
|
||||
if not SCHUBAMED_LOGO_PATH.exists():
|
||||
raise FileNotFoundError(f"Required Orion logo asset is missing: {SCHUBAMED_LOGO_PATH}")
|
||||
return SCHUBAMED_LOGO_PATH.resolve().as_uri()
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 640 480">
|
||||
<path d="M476,95 L483,94 L486,97 L486,99 L484,101 L477,102 Z" fill="#F60B2F"/>
|
||||
<path d="M473,92 L473,112 L476,112 L477,104 L480,104 L485,112 L489,112 L485,106 L489,101 L488,93 Z" fill="#F60B2F"/>
|
||||
<path d="M476,83 L487,84 L496,92 L498,97 L498,107 L496,111 L485,120 L475,120 L470,118 L464,112 L461,106 L461,98 L464,91 L469,86 Z" fill="#F60B2F"/>
|
||||
<path d="M329,84 L328,399 L329,401 L338,401 L354,399 L358,396 L358,119 L363,118 L385,127 L391,133 L392,386 L403,381 L417,372 L421,365 L422,158 L432,170 L444,193 L450,211 L454,230 L454,250 L448,281 L441,297 L440,303 L460,316 L466,315 L478,284 L483,255 L483,232 L480,210 L470,178 L454,150 L431,124 L407,106 L378,92 L350,85 Z" fill="#F60B2F"/>
|
||||
<path d="M308,83 L283,86 L256,94 L235,104 L211,121 L193,139 L178,160 L167,182 L159,208 L159,216 L162,219 L278,285 L281,289 L280,367 L269,364 L250,355 L229,340 L211,320 L203,308 L194,289 L190,285 L186,283 L163,283 L162,288 L168,305 L184,334 L195,348 L209,362 L236,381 L268,395 L298,401 L310,400 L310,271 L303,265 L201,207 L192,200 L207,170 L217,157 L229,145 L255,127 L280,117 L281,200 L284,202 L307,202 L310,200 L310,84 Z" fill="#F60B2F"/>
|
||||
<path d="M473,80 L467,83 L461,89 L458,95 L458,108 L463,117 L468,121 L476,124 L487,123 L493,120 L498,115 L501,109 L501,94 L497,87 L491,82 L486,80 Z" fill="#F60B2F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,8 +11,16 @@ from app.models.contact import Contact
|
|||
from app.models.customer import Customer
|
||||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.report_template import (
|
||||
ChecklistTemplate,
|
||||
MeasurementImport,
|
||||
MeasurementImportValue,
|
||||
ReportSection,
|
||||
TextBlock,
|
||||
)
|
||||
from app.models.location import Location
|
||||
from app.models.validation import Validation
|
||||
from app.modules.orion.template_service import ReportTemplateService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -24,6 +32,10 @@ class ReportContext:
|
|||
device: Device | None
|
||||
equipment: list[Equipment]
|
||||
generated_dir: Path
|
||||
report_sections: list[ReportSection]
|
||||
text_blocks: dict[str, TextBlock]
|
||||
checklist_templates: list[ChecklistTemplate]
|
||||
confirmed_measurements: list[MeasurementImportValue]
|
||||
|
||||
|
||||
class OrionContextBuilder:
|
||||
|
|
@ -50,6 +62,17 @@ class OrionContextBuilder:
|
|||
select(Equipment).where(Equipment.id.in_(validation.equipment_ids))
|
||||
)
|
||||
)
|
||||
template_bundle = ReportTemplateService(self.session).ensure_default_template()
|
||||
confirmed_measurements = list(
|
||||
self.session.scalars(
|
||||
select(MeasurementImportValue)
|
||||
.join_from(MeasurementImportValue, MeasurementImport)
|
||||
.where(
|
||||
MeasurementImport.validation_id == validation.id,
|
||||
MeasurementImportValue.confirmed.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
self.generated_dir.mkdir(parents=True, exist_ok=True)
|
||||
return ReportContext(
|
||||
|
|
@ -60,5 +83,8 @@ class OrionContextBuilder:
|
|||
device=device,
|
||||
equipment=equipment,
|
||||
generated_dir=self.generated_dir,
|
||||
report_sections=template_bundle.sections,
|
||||
text_blocks=template_bundle.text_blocks,
|
||||
checklist_templates=template_bundle.checklists,
|
||||
confirmed_measurements=confirmed_measurements,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.orion.assets import ORION_ASSET_DIR
|
||||
from app.modules.orion.components import (
|
||||
AttachmentComponent,
|
||||
ChecklistComponent,
|
||||
|
|
@ -25,6 +27,8 @@ from app.modules.orion.components import (
|
|||
from app.modules.orion.context import OrionContextBuilder, ReportContext
|
||||
from app.modules.orion.templates.report import render_document
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrionReportService:
|
||||
def __init__(self, session: Session, generated_dir: Path | None = None) -> None:
|
||||
|
|
@ -43,34 +47,82 @@ class OrionReportService:
|
|||
context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
|
||||
output_path = context.generated_dir / f"{context.validation.report_number}.pdf"
|
||||
html = self.render_html(validation_id)
|
||||
HTML(string=html, base_url=str(context.generated_dir)).write_pdf(output_path)
|
||||
HTML(string=html, base_url=str(ORION_ASSET_DIR)).write_pdf(output_path)
|
||||
self._append_pdf_attachments(context, output_path)
|
||||
return output_path
|
||||
|
||||
def _components(self) -> list[ReportComponent]:
|
||||
chapters: list[ReportComponent] = [
|
||||
StaticTextComponent("bq", "1. Funktionsqualifikation (BQ)", "Anlass, Ziel und gesetzliche Grundlagen werden anhand der erfassten Validierungsdaten bewertet."),
|
||||
StaticTextComponent("bq-goal", "1.1 Anlass und Ziel der Pruefung"),
|
||||
StaticTextComponent("legal", "1.2 Gesetzliche Grundlagen"),
|
||||
StaticTextComponent("bq", "1 Funktionsqualifikation (BQ)", "nicht erfasst"),
|
||||
StaticTextComponent("bq-goal", "1.1 Anlass und Ziel der Prüfung", block_key="bq_goal"),
|
||||
StaticTextComponent("legal", "1.2 Gesetzliche Grundlagen", block_key="legal"),
|
||||
DeviceComponent(),
|
||||
StaticTextComponent("performance", "1.4 Leistungsueberpruefung"),
|
||||
StaticTextComponent("performance", "1.4 Leistungsüberprüfung", block_key="performance"),
|
||||
ChecklistComponent(),
|
||||
StaticTextComponent("work-instructions", "Arbeitsanweisungen"),
|
||||
StaticTextComponent("documentation", "1.6 Dokumentation / Kontrolle"),
|
||||
StaticTextComponent("work-instructions", "1.7 Arbeitsanweisungen"),
|
||||
EnvironmentComponent(),
|
||||
StaticTextComponent("batch-control", "1.9 Chargenkontrolle"),
|
||||
ProgramComponent(),
|
||||
LoadingComponent(),
|
||||
StaticTextComponent("reference-load", "1.12 Referenzbeladung"),
|
||||
StaticTextComponent("reference-load", "1.12 Referenzbeladung Sterilisator"),
|
||||
StaticTextComponent("equipment", "2 Eingesetzte Prüfmittel"),
|
||||
EquipmentComponent(),
|
||||
StaticTextComponent("equipment-thermo", "2.2 Pruefmittel zur thermoelektrischen Untersuchung"),
|
||||
StaticTextComponent("configuration", "3. Pruefkonfiguration"),
|
||||
StaticTextComponent("equipment-thermo", "2.2 Prüfmittel zur thermoelektrischen Untersuchung"),
|
||||
StaticTextComponent("configuration", "2.3 Prüfkonfiguration"),
|
||||
StaticTextComponent("lq", "3 Leistungsqualifikation (LQ)"),
|
||||
StaticTextComponent("thermo-tests", "3.1 Leistungsbeurteilung – Thermoelektrische Prüfungen"),
|
||||
MeasurementComponent(),
|
||||
StaticTextComponent("results", "4. Ergebnisse der Validierung"),
|
||||
StaticTextComponent("run-1", "3.2 Standardbeladung – Testlauf 1"),
|
||||
StaticTextComponent("test-1", "3.2.1 Test 1"),
|
||||
StaticTextComponent("run-2", "3.3 Standardbeladung – Testlauf 2"),
|
||||
StaticTextComponent("test-2", "3.3.1 Test 2"),
|
||||
StaticTextComponent("run-3", "3.4 Standardbeladung – Testlauf 3"),
|
||||
StaticTextComponent("test-3", "3.4.1 Test 3"),
|
||||
StaticTextComponent("results", "4 Ergebnisse der Validierung"),
|
||||
StaticTextComponent("results-vacuum", "4.1 Vakuumtest"),
|
||||
StaticTextComponent("results-runs", "4.1.2 Testläufe 1 bis 3"),
|
||||
DryingComponent(),
|
||||
RecommendationComponent(),
|
||||
StaticTextComponent("appendix", "5 Anhang"),
|
||||
AttachmentComponent(),
|
||||
StaticTextComponent("cycles", "6. Programmablaeufe / Zyklen"),
|
||||
StaticTextComponent("risk", "7. Risikoeinstufung und Abschlussgespraech"),
|
||||
StaticTextComponent("winlog", "5.1 Winlog-Auswertungen"),
|
||||
StaticTextComponent("release-docs", "5.2 Freigabedokumentation / Routineprüfungen"),
|
||||
StaticTextComponent("indicators", "5.3 Nachweis der umgeschlagenen Indikatoren"),
|
||||
StaticTextComponent("cycles", "6 Programmabläufe / Zyklen"),
|
||||
StaticTextComponent("practice-certificates", "6.1 Zertifikate Praxis"),
|
||||
StaticTextComponent("risk", "7 Risikoeinstufung nach RKI"),
|
||||
StaticTextComponent("closing-talk", "7.1 Abschlussgespräch"),
|
||||
StaticTextComponent("certificates", "8. Zertifikate"),
|
||||
StaticTextComponent("calibration-certificates", "9. Kalibrierzertifikate"),
|
||||
StaticTextComponent("calibration-certificates", "9. Werkskalibrierzertifikate Sensoren"),
|
||||
]
|
||||
return [CoverComponent(), SummaryComponent(), TocComponent(chapters), CustomerComponent(), *chapters]
|
||||
|
||||
def _append_pdf_attachments(self, context: ReportContext, output_path: Path) -> None:
|
||||
pdf_paths = []
|
||||
for item in context.validation.attachments or []:
|
||||
filename = str(item.get("filename") or "")
|
||||
content_type = str(item.get("content_type") or "")
|
||||
if not filename.lower().endswith(".pdf") and content_type != "application/pdf":
|
||||
continue
|
||||
storage_path = item.get("storage_path")
|
||||
if storage_path and Path(str(storage_path)).exists():
|
||||
pdf_paths.append(Path(str(storage_path)))
|
||||
if not pdf_paths:
|
||||
return
|
||||
try:
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
writer = PdfWriter()
|
||||
for page in PdfReader(str(output_path)).pages:
|
||||
writer.add_page(page)
|
||||
for pdf_path in pdf_paths:
|
||||
try:
|
||||
for page in PdfReader(str(pdf_path)).pages:
|
||||
writer.add_page(page)
|
||||
except Exception:
|
||||
logger.exception("Could not append attachment PDF %s", pdf_path)
|
||||
with output_path.open("wb") as handle:
|
||||
writer.write(handle)
|
||||
except Exception:
|
||||
logger.exception("Could not merge Orion PDF attachments for validation %s", context.validation.id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,354 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.report_template import ChecklistTemplate, ReportSection, ReportTemplate, TextBlock
|
||||
from app.modules.orion.html import text
|
||||
|
||||
TEMPLATE_KEY = "small_steam_sterilizer_initial_validation"
|
||||
TEMPLATE_NAME = "Erstvalidierung Klein-Sterilisator"
|
||||
TEMPLATE_VERSION = "1.0"
|
||||
REFERENCE_PATH = "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx"
|
||||
|
||||
SECTION_DEFINITIONS: list[tuple[str, str | None, str, bool]] = [
|
||||
("bq", "1", "Funktionsqualifikation (BQ)", True),
|
||||
("bq_goal", "1.1", "Anlass und Ziel der Prüfung", False),
|
||||
("legal", "1.2", "Gesetzliche Grundlagen", False),
|
||||
("device", "1.3", "Angaben zum Gerät", False),
|
||||
("performance", "1.4", "Leistungsüberprüfung", False),
|
||||
("performance_checklist", "1.5", "Checkliste Leistungsanforderung", False),
|
||||
("documentation", "1.6", "Dokumentation / Kontrolle", False),
|
||||
("work_instructions", "1.7", "Arbeitsanweisungen", False),
|
||||
("environment", "1.8", "Umgebungsbedingungen", False),
|
||||
("batch_control", "1.9", "Chargenkontrolle", False),
|
||||
("programs", "1.10", "Beschreibung der verwendeten Programme", False),
|
||||
("loading", "1.11", "Beladungsbeschreibungen", False),
|
||||
("reference_load", "1.12", "Referenzbeladung Sterilisator", False),
|
||||
("equipment", "2", "Eingesetzte Prüfmittel", True),
|
||||
("measurement_devices", "2.1", "Beschreibung der Messgeräte", False),
|
||||
("thermo_equipment", "2.2", "Prüfmittel zur thermoelektrischen Untersuchung", False),
|
||||
("configuration", "2.3", "Prüfkonfiguration", False),
|
||||
("lq", "3", "Leistungsqualifikation (LQ)", True),
|
||||
("thermo_tests", "3.1", "Leistungsbeurteilung – Thermoelektrische Prüfungen", False),
|
||||
("vacuum_test", "3.1.1", "Vakuumtest", False),
|
||||
("run_1", "3.2", "Standardbeladung – Testlauf 1", False),
|
||||
("test_1", "3.2.1", "Test 1", False),
|
||||
("run_2", "3.3", "Standardbeladung – Testlauf 2", False),
|
||||
("test_2", "3.3.1", "Test 2", False),
|
||||
("run_3", "3.4", "Standardbeladung – Testlauf 3", False),
|
||||
("test_3", "3.4.1", "Test 3", False),
|
||||
("results", "4", "Ergebnisse der Validierung", True),
|
||||
("results_vacuum", "4.1", "Vakuumtest", False),
|
||||
("results_runs", "4.1.2", "Testläufe 1 bis 3", False),
|
||||
("drying", None, "Nachweis der Trocknungseigenschaften", False),
|
||||
("recommendations", "4.2", "Empfehlungen und Auflagen", False),
|
||||
("attachments", "5", "Anhang", True),
|
||||
("winlog", "5.1", "Winlog-Auswertungen", False),
|
||||
("release_docs", "5.2", "Freigabedokumentation / Routineprüfungen", False),
|
||||
("indicators", "5.3", "Nachweis der umgeschlagenen Indikatoren", False),
|
||||
("cycles", "6", "Programmabläufe / Zyklen", True),
|
||||
("practice_certificates", "6.1", "Zertifikate Praxis", False),
|
||||
("risk", "7", "Risikoeinstufung nach RKI", True),
|
||||
("closing_talk", "7.1", "Abschlussgespräch", False),
|
||||
("certificates", "8", "Zertifikate", True),
|
||||
("calibration_certificates", "9", "Werkskalibrierzertifikate Sensoren", True),
|
||||
]
|
||||
|
||||
TEXT_BLOCK_HEADINGS = {
|
||||
"summary": "Zusammenfassendes Ergebnis der Validierung",
|
||||
"bq_goal": "Anlass und Ziel der Prüfung",
|
||||
"legal": "Gesetzliche Grundlagen",
|
||||
"performance": "Leistungsüberprüfung",
|
||||
}
|
||||
|
||||
CHECKLIST_DEFINITIONS = [
|
||||
(
|
||||
"sterilizer_description",
|
||||
"Beschreibung Sterilisator",
|
||||
["Beschreibung", "Ja", "Nein", "Nicht anwendbar", "Kommentar"],
|
||||
[
|
||||
"Trennung von Controlling und Monitoring Schaltkreisen",
|
||||
"Temperaturüberwachung Sterilisationsprozess",
|
||||
"Drucküberwachung Sterilisationsprozess",
|
||||
"Wasserzulauf - Ablauf / manuelle Überwachung",
|
||||
"Automatische Aufzeichnung der Sterilisationsergebnisse",
|
||||
"Überwachung Wasserqualität",
|
||||
"Separater Netzanschluss",
|
||||
"Sichtkontrolle: Kessel, Sensoren, Türbereich",
|
||||
],
|
||||
),
|
||||
(
|
||||
"documentation_control",
|
||||
"Dokumentation / Kontrolle",
|
||||
["Vorliegende Dokumente / Beschreibungen", "vorhanden", "eingesehen", "Kommentar"],
|
||||
[
|
||||
"Bedienungshandbuch / Installationsprotokoll",
|
||||
"Wartungshandbuch",
|
||||
"Arbeitsanweisungen / Checklisten / reale Beladungsmuster",
|
||||
"Schulungsnachweise Mitarbeiter zur Aufbereitung",
|
||||
"Risikoeinstufung der Medizinprodukte nach RKI",
|
||||
"Protokoll mit Freigabe der Sterilisation durch Mitarbeiter",
|
||||
"Chargenkontrolle / Indikator für jede Charge",
|
||||
"Chargendokumentation der letzten 6 Wochen",
|
||||
"Dokumentation / Bilder der schwierigsten repräsentativen Beladung",
|
||||
],
|
||||
),
|
||||
(
|
||||
"work_instructions",
|
||||
"Arbeitsanweisungen",
|
||||
["Arbeitsanweisung", "vorhanden", "eingesehen", "Prüfintervall", "Kommentar"],
|
||||
[
|
||||
"Aufbereitung von Medizinprodukten",
|
||||
"Beladung Sterilisator",
|
||||
"Routinekontrollen",
|
||||
"Freigabe von Chargen",
|
||||
"Umgang mit Abweichungen",
|
||||
],
|
||||
),
|
||||
(
|
||||
"environment_conditions",
|
||||
"Umgebungsbedingungen",
|
||||
["Prüfpunkt", "Ja", "Nein", "Nicht anwendbar", "Kommentar"],
|
||||
["Raumbedingungen stabil", "Aufstellort frei zugänglich", "Medienversorgung verfügbar"],
|
||||
),
|
||||
(
|
||||
"batch_documentation",
|
||||
"Chargendokumentation",
|
||||
["Prüfpunkt", "vorhanden", "eingesehen", "Kommentar"],
|
||||
["Vakuumtest", "Testlauf 1", "Testlauf 2", "Testlauf 3", "Freigabedokumentation"],
|
||||
),
|
||||
(
|
||||
"batch_control",
|
||||
"Chargenkontrolle",
|
||||
["Prüfpunkt", "Ja", "Nein", "Prüfkörper", "Kommentar"],
|
||||
["Bowie-Dick / Leerkammerprofil", "Helix-Test", "Indikatoren umgeschlagen"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemplateBundle:
|
||||
template: ReportTemplate
|
||||
sections: list[ReportSection]
|
||||
text_blocks: dict[str, TextBlock]
|
||||
checklists: list[ChecklistTemplate]
|
||||
|
||||
|
||||
class ReportTemplateService:
|
||||
def __init__(self, session: Session, project_root: Path | None = None) -> None:
|
||||
self.session = session
|
||||
self.project_root = project_root or self._discover_project_root()
|
||||
|
||||
@property
|
||||
def reference_path(self) -> Path:
|
||||
return self.project_root / REFERENCE_PATH
|
||||
|
||||
def _discover_project_root(self) -> Path:
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (parent / REFERENCE_PATH).exists():
|
||||
return parent
|
||||
return Path("/app")
|
||||
|
||||
def ensure_default_template(self) -> TemplateBundle:
|
||||
if not self.reference_path.exists():
|
||||
raise FileNotFoundError(f"Required reference report is missing: {self.reference_path}")
|
||||
template = self.session.scalar(
|
||||
select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
|
||||
)
|
||||
if template is None:
|
||||
template = ReportTemplate(
|
||||
template_key=TEMPLATE_KEY,
|
||||
name=TEMPLATE_NAME,
|
||||
version=TEMPLATE_VERSION,
|
||||
validation_type="Erstvalidierung",
|
||||
reference_path=REFERENCE_PATH,
|
||||
active=True,
|
||||
)
|
||||
self.session.add(template)
|
||||
try:
|
||||
self.session.flush()
|
||||
except IntegrityError:
|
||||
self.session.rollback()
|
||||
template = self.session.scalar(
|
||||
select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
|
||||
)
|
||||
if template is None:
|
||||
raise
|
||||
if not self._has_children(template):
|
||||
self._create_children(template)
|
||||
self.session.flush()
|
||||
return self.load_bundle()
|
||||
|
||||
def load_bundle(self) -> TemplateBundle:
|
||||
template = self.session.scalar(
|
||||
select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
|
||||
)
|
||||
if template is None:
|
||||
return self.ensure_default_template()
|
||||
sections = list(
|
||||
self.session.scalars(
|
||||
select(ReportSection)
|
||||
.where(ReportSection.template_id == template.id, ReportSection.active.is_(True))
|
||||
.order_by(ReportSection.order_index)
|
||||
)
|
||||
)
|
||||
text_blocks = {
|
||||
block.block_key: block
|
||||
for block in self.session.scalars(
|
||||
select(TextBlock)
|
||||
.where(TextBlock.template_id == template.id, TextBlock.active.is_(True))
|
||||
.order_by(TextBlock.order_index)
|
||||
)
|
||||
}
|
||||
checklists = list(
|
||||
self.session.scalars(
|
||||
select(ChecklistTemplate)
|
||||
.where(ChecklistTemplate.template_id == template.id, ChecklistTemplate.active.is_(True))
|
||||
.order_by(ChecklistTemplate.order_index)
|
||||
)
|
||||
)
|
||||
return TemplateBundle(template=template, sections=sections, text_blocks=text_blocks, checklists=checklists)
|
||||
|
||||
def render_block(self, block_key: str, context: Any) -> str:
|
||||
bundle = self.load_bundle()
|
||||
block = bundle.text_blocks.get(block_key)
|
||||
if block is None:
|
||||
return "nicht erfasst"
|
||||
return self.render_text(block.content, context)
|
||||
|
||||
def render_text(self, content: str, context: Any) -> 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
|
||||
|
||||
def _has_children(self, template: ReportTemplate) -> bool:
|
||||
return bool(
|
||||
self.session.scalar(
|
||||
select(TextBlock.id).where(TextBlock.template_id == template.id).limit(1)
|
||||
)
|
||||
)
|
||||
|
||||
def _create_children(self, template: ReportTemplate) -> None:
|
||||
paragraphs = self._read_docx_paragraphs()
|
||||
blocks = self._extract_blocks(paragraphs)
|
||||
for index, (section_key, number, title, page_break) in enumerate(SECTION_DEFINITIONS, start=1):
|
||||
self.session.add(
|
||||
ReportSection(
|
||||
template_id=template.id,
|
||||
section_key=section_key,
|
||||
number=number,
|
||||
title=title,
|
||||
order_index=index,
|
||||
page_break_before=page_break,
|
||||
active=True,
|
||||
)
|
||||
)
|
||||
for index, (block_key, title) in enumerate(TEXT_BLOCK_HEADINGS.items(), start=1):
|
||||
self.session.add(
|
||||
TextBlock(
|
||||
template_id=template.id,
|
||||
block_key=block_key,
|
||||
title=title,
|
||||
content=self._sanitize_reference_text(blocks.get(block_key, "nicht erfasst")),
|
||||
order_index=index,
|
||||
version=TEMPLATE_VERSION,
|
||||
active=True,
|
||||
)
|
||||
)
|
||||
for index, (key, title, columns, items) in enumerate(CHECKLIST_DEFINITIONS, start=1):
|
||||
self.session.add(
|
||||
ChecklistTemplate(
|
||||
template_id=template.id,
|
||||
checklist_key=key,
|
||||
title=title,
|
||||
columns=columns,
|
||||
items=[
|
||||
{"number": item_index + 1, "text": item, "value": "na", "comment": ""}
|
||||
for item_index, item in enumerate(items)
|
||||
],
|
||||
order_index=index,
|
||||
active=True,
|
||||
)
|
||||
)
|
||||
|
||||
def _read_docx_paragraphs(self) -> list[str]:
|
||||
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
||||
with ZipFile(self.reference_path) as archive:
|
||||
root = ET.fromstring(archive.read("word/document.xml"))
|
||||
paragraphs: list[str] = []
|
||||
for paragraph in root.findall(".//w:p", ns):
|
||||
line = "".join(
|
||||
node.text or "" for node in paragraph.findall(".//w:t", ns)
|
||||
).strip()
|
||||
if line:
|
||||
paragraphs.append(line)
|
||||
return paragraphs
|
||||
|
||||
def _extract_blocks(self, paragraphs: list[str]) -> dict[str, str]:
|
||||
blocks: dict[str, str] = {}
|
||||
heading_by_normalized = {self._normalize(value): key for key, value in TEXT_BLOCK_HEADINGS.items()}
|
||||
active_key: str | None = None
|
||||
buffer: list[str] = []
|
||||
for paragraph in paragraphs:
|
||||
normalized = self._normalize(paragraph)
|
||||
if normalized in heading_by_normalized:
|
||||
if active_key:
|
||||
blocks[active_key] = "\n\n".join(buffer).strip()
|
||||
active_key = heading_by_normalized[normalized]
|
||||
buffer = []
|
||||
continue
|
||||
if active_key and self._looks_like_next_section(paragraph):
|
||||
blocks[active_key] = "\n\n".join(buffer).strip()
|
||||
active_key = None
|
||||
buffer = []
|
||||
continue
|
||||
if active_key:
|
||||
buffer.append(paragraph)
|
||||
if active_key:
|
||||
blocks[active_key] = "\n\n".join(buffer).strip()
|
||||
return blocks
|
||||
|
||||
def _sanitize_reference_text(self, content: str) -> str:
|
||||
replacements = {
|
||||
"Euronda E10.7": "{{ device.manufacturer }} {{ device.model }}",
|
||||
"Euronda / E10.7": "{{ device.manufacturer }} / {{ device.model }}",
|
||||
"EXN250688": "{{ device.serial_number }}",
|
||||
"Dr.Durmaz": "{{ customer.name }}",
|
||||
"Nürnberg": "{{ location.city }}",
|
||||
"05.12.2025": "{{ validation.performed_on }}",
|
||||
"November 2027": "{{ validation.next_validation_on }}",
|
||||
"bestanden": "{{ validation.result }}",
|
||||
}
|
||||
sanitized = content
|
||||
for old, new in replacements.items():
|
||||
sanitized = sanitized.replace(old, new)
|
||||
return sanitized or "nicht erfasst"
|
||||
|
||||
def _normalize(self, value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", value.lower())
|
||||
|
||||
def _looks_like_next_section(self, value: str) -> bool:
|
||||
if value in {"Inhaltsverzeichnis", "1.3 Angaben zum Gerät"}:
|
||||
return True
|
||||
return bool(re.match(r"^\d+(\.\d+)*\s", value))
|
||||
|
|
@ -1,33 +1,14 @@
|
|||
@page {
|
||||
size: A4;
|
||||
margin: 24mm 16mm 22mm 16mm;
|
||||
@top-left {
|
||||
content: "Validation Suite";
|
||||
color: #4F6A74;
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
@top-right {
|
||||
content: string(report-number);
|
||||
color: #6B7C85;
|
||||
font-size: 9pt;
|
||||
}
|
||||
@bottom-left {
|
||||
content: "Schubamed";
|
||||
color: #6B7C85;
|
||||
font-size: 8pt;
|
||||
}
|
||||
@bottom-right {
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
color: #6B7C85;
|
||||
font-size: 8pt;
|
||||
}
|
||||
margin: 34mm 18mm 24mm 18mm;
|
||||
@top-left { content: element(report-header); }
|
||||
@bottom-left { content: element(report-footer); }
|
||||
}
|
||||
|
||||
@page:first {
|
||||
margin: 20mm 16mm 18mm 16mm;
|
||||
margin: 20mm 18mm 20mm 18mm;
|
||||
@top-left { content: ""; }
|
||||
@top-right { content: ""; }
|
||||
@bottom-left { content: ""; }
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -46,17 +27,137 @@ body {
|
|||
}
|
||||
|
||||
.report-meta {
|
||||
string-set: report-number attr(data-report-number);
|
||||
string-set: report-number attr(data-report-number), report-version attr(data-report-version), report-date attr(data-report-date);
|
||||
}
|
||||
|
||||
.report-header {
|
||||
align-items: center;
|
||||
border-bottom: .25mm solid #DCE3E3;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 54mm;
|
||||
height: 24mm;
|
||||
left: 0;
|
||||
padding-bottom: 3mm;
|
||||
position: running(report-header);
|
||||
top: 0;
|
||||
width: 174mm;
|
||||
}
|
||||
|
||||
.report-header-brand {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4mm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-header-brand img {
|
||||
display: block;
|
||||
height: 20mm;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.report-header-brand strong {
|
||||
color: #2E3B40;
|
||||
display: block;
|
||||
font-size: 15pt;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.report-header-brand span {
|
||||
color: #6B7C85;
|
||||
display: block;
|
||||
font-size: 9pt;
|
||||
line-height: 1.35;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
|
||||
.report-header-meta {
|
||||
color: #4F5E63;
|
||||
display: grid;
|
||||
gap: 1.2mm;
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.report-header-meta div,
|
||||
.report-footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.report-header-meta dt {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.report-header-meta dd {
|
||||
font-size: 9pt;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-header-meta div:first-child dd {
|
||||
color: #2E3B40;
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-footer {
|
||||
align-items: center;
|
||||
border-top: .25mm solid #DCE3E3;
|
||||
color: #6B7C85;
|
||||
font-size: 8pt;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
height: 10mm;
|
||||
padding-top: 2.5mm;
|
||||
position: running(report-footer);
|
||||
width: 174mm;
|
||||
}
|
||||
|
||||
.report-footer span:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-footer span:nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.page-number::before {
|
||||
content: counter(page);
|
||||
}
|
||||
|
||||
.page-count::before {
|
||||
content: counter(pages);
|
||||
}
|
||||
|
||||
.cover-page {
|
||||
min-height: 245mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.cover-top {
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
gap: 18mm;
|
||||
grid-template-columns: 1fr 55mm;
|
||||
margin-bottom: 32mm;
|
||||
}
|
||||
|
||||
.cover-logo {
|
||||
height: 16mm;
|
||||
justify-self: end;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.company-address {
|
||||
color: #6B7C85;
|
||||
font-size: 9pt;
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cover-kicker {
|
||||
color: #6C8A96;
|
||||
font-size: 11pt;
|
||||
|
|
@ -93,17 +194,29 @@ h1 {
|
|||
}
|
||||
|
||||
.chapter {
|
||||
break-before: page;
|
||||
break-before: auto;
|
||||
margin-top: 9mm;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: 1px solid #E6EAEA;
|
||||
border-bottom: .25mm solid #DCE3E3;
|
||||
color: #2E3B40;
|
||||
font-size: 18pt;
|
||||
margin: 0 0 8mm 0;
|
||||
font-size: 22pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 7mm 0;
|
||||
padding-bottom: 4mm;
|
||||
}
|
||||
|
||||
.chapter + .chapter {
|
||||
margin-top: 12mm;
|
||||
}
|
||||
|
||||
.chapter > p:only-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #4F6A74;
|
||||
font-size: 12pt;
|
||||
|
|
@ -176,6 +289,32 @@ dd {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.figure-grid {
|
||||
display: grid;
|
||||
gap: 8mm;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 8mm;
|
||||
}
|
||||
|
||||
.report-figure {
|
||||
break-inside: avoid;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-image {
|
||||
border: 1px solid #E6EAEA;
|
||||
display: block;
|
||||
max-height: 90mm;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: #6B7C85;
|
||||
font-size: 8.5pt;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
@media screen {
|
||||
body {
|
||||
background: #F7F8F8;
|
||||
|
|
@ -190,6 +329,14 @@ dd {
|
|||
padding: 48px;
|
||||
}
|
||||
|
||||
.report-header,
|
||||
.report-footer {
|
||||
left: auto;
|
||||
margin: 0 auto 32px auto;
|
||||
position: static;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cover-page {
|
||||
min-height: auto;
|
||||
}
|
||||
|
|
@ -198,4 +345,12 @@ dd {
|
|||
break-before: auto;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.report-footer {
|
||||
margin: 48px auto 0 auto;
|
||||
}
|
||||
|
||||
.figure-grid {
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,41 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from app.modules.orion.assets import schubamed_logo_uri
|
||||
from app.modules.orion.context import ReportContext
|
||||
from app.modules.orion.html import text
|
||||
|
||||
|
||||
def render_report_chrome(context: ReportContext, logo_uri: str) -> str:
|
||||
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>
|
||||
"""
|
||||
|
||||
|
||||
def render_document(context: ReportContext, chapters: list[str]) -> str:
|
||||
css = (Path(__file__).resolve().parent / "report.css").read_text(encoding="utf-8")
|
||||
logo_uri = schubamed_logo_uri()
|
||||
title = f"Validierungsbericht {context.validation.report_number}"
|
||||
chapter_markup = "\n".join(chapters)
|
||||
return f"""<!doctype html>
|
||||
|
|
@ -20,9 +49,9 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
|
|||
</head>
|
||||
<body>
|
||||
<article class="report-document">
|
||||
<div class="report-meta" data-report-number="{text(context.validation.report_number)}"></div>
|
||||
<div class="report-meta" data-report-number="{text(context.validation.report_number)}" data-report-version="{text(context.validation.version)}" data-report-date="{text(context.validation.updated_at)}"></div>
|
||||
{render_report_chrome(context, logo_uri)}
|
||||
{chapter_markup}
|
||||
</article>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue