292 lines
13 KiB
Python
292 lines
13 KiB
Python
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
|
|
|
|
from app.modules.orion.assets import ORION_ASSET_DIR
|
|
from app.modules.orion.components import (
|
|
AttachmentComponent,
|
|
ChecklistComponent,
|
|
CoverComponent,
|
|
CustomerComponent,
|
|
DeviceComponent,
|
|
DryingComponent,
|
|
EnvironmentComponent,
|
|
EquipmentComponent,
|
|
LoadingComponent,
|
|
MeasurementComponent,
|
|
ProgramComponent,
|
|
RecommendationComponent,
|
|
ReportComponent,
|
|
StaticTextComponent,
|
|
SummaryComponent,
|
|
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__)
|
|
|
|
|
|
class OrionReportService:
|
|
def __init__(self, session: Session, generated_dir: Path | None = None) -> None:
|
|
self.session = session
|
|
self.generated_dir = generated_dir or Path("/app/reports")
|
|
|
|
def render_html(self, validation_id: str) -> str:
|
|
context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
|
|
components = self._components()
|
|
chapters = [component.render(context) for component in components]
|
|
return render_document(context, chapters)
|
|
|
|
def render_pdf(self, validation_id: str) -> Path:
|
|
from weasyprint import HTML
|
|
|
|
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(ORION_ASSET_DIR)).write_pdf(output_path)
|
|
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"])
|
|
title = str(item["title"])
|
|
number = item.get("number")
|
|
if component == "device":
|
|
return DeviceComponent(key, title, number)
|
|
if component == "checklist":
|
|
return ChecklistComponent(key, title, number)
|
|
if component == "environment":
|
|
return EnvironmentComponent(key, title, number)
|
|
if component == "programs":
|
|
return ProgramComponent(key, title, number)
|
|
if component == "loading":
|
|
return LoadingComponent(key, title, number)
|
|
if component == "equipment":
|
|
return EquipmentComponent(key, title, number)
|
|
if component == "measurements":
|
|
return MeasurementComponent(key, title, number)
|
|
if component == "drying":
|
|
return DryingComponent(key, title, number)
|
|
if component == "recommendations":
|
|
return RecommendationComponent(key, title, number)
|
|
if component == "attachments":
|
|
return AttachmentComponent(key, title, number)
|
|
return StaticTextComponent(key, title, block_key=item.get("block_key"), number=number)
|
|
|
|
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)
|