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"], ], ) + ( '
' + "".join( '
' f'Beispielbild {index}' f'
Abbildung {index}: Beispielbild fuer die Layoutvorschau
' "
" for index in range(1, 5) ) + "
" ), "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)