diff --git a/validation-suite/backend/mercury/Dockerfile b/validation-suite/backend/mercury/Dockerfile index 21a01c5c..317e63e1 100644 --- a/validation-suite/backend/mercury/Dockerfile +++ b/validation-suite/backend/mercury/Dockerfile @@ -7,15 +7,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONPATH=/app RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev \ + && apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libglib2.0-0 libffi-dev shared-mime-info \ && rm -rf /var/lib/apt/lists/* COPY pyproject.toml . -RUN pip install --no-cache-dir . +RUN pip install --no-cache-dir ".[dev]" COPY alembic.ini . COPY alembic ./alembic COPY app ./app +COPY tests ./tests +COPY docs ./docs COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh @@ -23,4 +25,3 @@ RUN chmod +x /docker-entrypoint.sh EXPOSE 8000 ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "2"] - diff --git a/validation-suite/backend/mercury/alembic/versions/202607110003_report_templates_helios_imports.py b/validation-suite/backend/mercury/alembic/versions/202607110003_report_templates_helios_imports.py new file mode 100644 index 00000000..14c495b7 --- /dev/null +++ b/validation-suite/backend/mercury/alembic/versions/202607110003_report_templates_helios_imports.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "202607110003" +down_revision = "202607110002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "report_templates", + sa.Column("template_key", sa.String(length=120), nullable=False), + sa.Column("name", sa.String(length=180), nullable=False), + sa.Column("version", sa.String(length=40), nullable=False), + sa.Column("validation_type", sa.String(length=120), nullable=False), + sa.Column("reference_path", sa.String(length=500), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_report_templates")), + sa.UniqueConstraint("template_key", name=op.f("uq_report_templates_template_key")), + ) + op.create_index(op.f("ix_report_templates_active"), "report_templates", ["active"], unique=False) + op.create_index(op.f("ix_report_templates_template_key"), "report_templates", ["template_key"], unique=False) + op.create_index(op.f("ix_report_templates_validation_type"), "report_templates", ["validation_type"], unique=False) + + op.create_table( + "text_blocks", + sa.Column("template_id", sa.String(), nullable=False), + sa.Column("block_key", sa.String(length=160), nullable=False), + sa.Column("title", sa.String(length=240), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("order_index", sa.Integer(), nullable=False), + sa.Column("version", sa.String(length=40), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_text_blocks_template_id_report_templates")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_text_blocks")), + ) + op.create_index(op.f("ix_text_blocks_active"), "text_blocks", ["active"], unique=False) + op.create_index(op.f("ix_text_blocks_block_key"), "text_blocks", ["block_key"], unique=False) + op.create_index(op.f("ix_text_blocks_template_id"), "text_blocks", ["template_id"], unique=False) + + op.create_table( + "report_sections", + sa.Column("template_id", sa.String(), nullable=False), + sa.Column("section_key", sa.String(length=160), nullable=False), + sa.Column("number", sa.String(length=40), nullable=True), + sa.Column("title", sa.String(length=240), nullable=False), + sa.Column("order_index", sa.Integer(), nullable=False), + sa.Column("page_break_before", sa.Boolean(), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_report_sections_template_id_report_templates")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_report_sections")), + ) + op.create_index(op.f("ix_report_sections_active"), "report_sections", ["active"], unique=False) + op.create_index(op.f("ix_report_sections_section_key"), "report_sections", ["section_key"], unique=False) + op.create_index(op.f("ix_report_sections_template_id"), "report_sections", ["template_id"], unique=False) + + op.create_table( + "checklist_templates", + sa.Column("template_id", sa.String(), nullable=False), + sa.Column("checklist_key", sa.String(length=160), nullable=False), + sa.Column("title", sa.String(length=240), nullable=False), + sa.Column("columns", sa.JSON(), nullable=False), + sa.Column("items", sa.JSON(), nullable=False), + sa.Column("order_index", sa.Integer(), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_checklist_templates_template_id_report_templates")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_checklist_templates")), + ) + op.create_index(op.f("ix_checklist_templates_active"), "checklist_templates", ["active"], unique=False) + op.create_index(op.f("ix_checklist_templates_checklist_key"), "checklist_templates", ["checklist_key"], unique=False) + op.create_index(op.f("ix_checklist_templates_template_id"), "checklist_templates", ["template_id"], unique=False) + + op.create_table( + "measurement_imports", + sa.Column("validation_id", sa.String(), nullable=False), + sa.Column("import_type", sa.String(length=60), nullable=False), + sa.Column("original_filename", sa.String(length=255), nullable=False), + sa.Column("storage_path", sa.String(length=500), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("parser_version", sa.String(length=40), nullable=False), + sa.Column("status", sa.String(length=60), nullable=False), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["validation_id"], ["validations.id"], name=op.f("fk_measurement_imports_validation_id_validations")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_measurement_imports")), + ) + op.create_index(op.f("ix_measurement_imports_import_type"), "measurement_imports", ["import_type"], unique=False) + op.create_index(op.f("ix_measurement_imports_sha256"), "measurement_imports", ["sha256"], unique=False) + op.create_index(op.f("ix_measurement_imports_status"), "measurement_imports", ["status"], unique=False) + op.create_index(op.f("ix_measurement_imports_validation_id"), "measurement_imports", ["validation_id"], unique=False) + + op.create_table( + "measurement_import_values", + sa.Column("import_id", sa.String(), nullable=False), + sa.Column("test_run", sa.String(length=120), nullable=False), + sa.Column("field_name", sa.String(length=120), nullable=False), + sa.Column("raw_value", sa.Text(), nullable=True), + sa.Column("normalized_value", sa.String(length=180), nullable=True), + sa.Column("unit", sa.String(length=40), nullable=True), + sa.Column("source_page", sa.Integer(), nullable=True), + sa.Column("source_text", sa.Text(), nullable=True), + sa.Column("confidence", sa.Integer(), nullable=False), + sa.Column("confirmed", sa.Boolean(), nullable=False), + sa.Column("corrected_value", sa.String(length=180), nullable=True), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["import_id"], ["measurement_imports.id"], name=op.f("fk_measurement_import_values_import_id_measurement_imports")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_measurement_import_values")), + ) + op.create_index(op.f("ix_measurement_import_values_confirmed"), "measurement_import_values", ["confirmed"], unique=False) + op.create_index(op.f("ix_measurement_import_values_field_name"), "measurement_import_values", ["field_name"], unique=False) + op.create_index(op.f("ix_measurement_import_values_import_id"), "measurement_import_values", ["import_id"], unique=False) + op.create_index(op.f("ix_measurement_import_values_test_run"), "measurement_import_values", ["test_run"], unique=False) + + +def downgrade() -> None: + op.drop_table("measurement_import_values") + op.drop_table("measurement_imports") + op.drop_table("checklist_templates") + op.drop_table("report_sections") + op.drop_table("text_blocks") + op.drop_table("report_templates") diff --git a/validation-suite/backend/mercury/app/api/v1/domain.py b/validation-suite/backend/mercury/app/api/v1/domain.py index 7a9d70c1..19f04f89 100644 --- a/validation-suite/backend/mercury/app/api/v1/domain.py +++ b/validation-suite/backend/mercury/app/api/v1/domain.py @@ -3,9 +3,12 @@ from __future__ import annotations from typing import Any import json +import logging +import shutil +import uuid from pathlib import Path -from fastapi import APIRouter, Depends, Query, Response, UploadFile +from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError @@ -19,6 +22,7 @@ from app.models.device import Device from app.models.equipment import Equipment from app.models.location import Location from app.models.validation import Validation +from app.modules.helios.service import HeliosImportService from app.modules.orion.service import OrionReportService from app.schemas.common import PaginatedResponse from app.schemas.domain import ( @@ -37,6 +41,8 @@ from app.schemas.domain import ( LocationCreate, LocationRead, LocationUpdate, + MeasurementImportConfirmRequest, + MeasurementImportPreviewRead, ValidationCreate, ValidationImportPreview, ValidationImportRequest, @@ -49,6 +55,25 @@ from app.services.domain_service import CrudService, DomainServices from app.services.validation_workflow import ValidationWorkflowService router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)]) +logger = logging.getLogger(__name__) + + +@router.get("/report-templates/default/checklists") +def default_report_checklists(session: Session = Depends(get_session)) -> list[dict]: + from app.modules.orion.template_service import ReportTemplateService + + bundle = ReportTemplateService(session).ensure_default_template() + session.commit() + return [ + { + "checklist_key": item.checklist_key, + "title": item.title, + "columns": item.columns, + "items": item.items, + "order_index": item.order_index, + } + for item in bundle.checklists + ] @router.get("/dashboard") @@ -60,12 +85,47 @@ def dashboard(session: Session = Depends(get_session)) -> dict[str, int]: "contacts": session.scalar(select(func.count()).select_from(Contact)) or 0, "devices": session.scalar(select(func.count()).select_from(Device)) or 0, "equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0, + "equipment_green": session.scalar( + select(func.count()).select_from(Equipment).where(Equipment.status == "green") + ) + or 0, + "equipment_yellow": session.scalar( + select(func.count()).select_from(Equipment).where(Equipment.status == "yellow") + ) + or 0, + "equipment_red": session.scalar( + select(func.count()).select_from(Equipment).where(Equipment.status == "red") + ) + or 0, "validations": session.scalar(select(func.count()).select_from(Validation)) or 0, - "validation_drafts": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "ENTWURF")) or 0, - "validation_ready": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "BEREIT_ZUR_PRUEFUNG")) or 0, - "validation_in_review": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "IN_PRUEFUNG")) or 0, - "validation_approved": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "FREIGEGEBEN")) or 0, - "validation_overdue": session.scalar(select(func.count()).select_from(Validation).where(Validation.next_validation_on < today)) or 0, + "validation_drafts": session.scalar( + select(func.count()).select_from(Validation).where(Validation.status == "ENTWURF") + ) + or 0, + "validation_ready": session.scalar( + select(func.count()) + .select_from(Validation) + .where(Validation.status == "BEREIT_ZUR_PRUEFUNG") + ) + or 0, + "validation_in_review": session.scalar( + select(func.count()).select_from(Validation).where(Validation.status == "IN_PRUEFUNG") + ) + or 0, + "validation_approved": session.scalar( + select(func.count()).select_from(Validation).where(Validation.status == "FREIGEGEBEN") + ) + or 0, + "validation_completed": session.scalar( + select(func.count()).select_from(Validation).where(Validation.status == "ABGESCHLOSSEN") + ) + or 0, + "validation_overdue": session.scalar( + select(func.count()) + .select_from(Validation) + .where(Validation.next_validation_on < today) + ) + or 0, } @@ -89,7 +149,9 @@ def commit_create(session: Session, service: CrudService, payload): session.rollback() from fastapi import HTTPException - raise HTTPException(status_code=409, detail="Datensatz verletzt Datenbankbeziehungen.") from exc + raise HTTPException( + status_code=409, detail="Datensatz verletzt Datenbankbeziehungen." + ) from exc def commit_update(session: Session, service: CrudService, item_id: str, payload): @@ -104,7 +166,9 @@ def commit_update(session: Session, service: CrudService, item_id: str, payload) session.rollback() from fastapi import HTTPException - raise HTTPException(status_code=409, detail="Datensatz verletzt Datenbankbeziehungen.") from exc + raise HTTPException( + status_code=409, detail="Datensatz verletzt Datenbankbeziehungen." + ) from exc def commit_delete(session: Session, service: CrudService, item_id: str) -> Response: @@ -204,7 +268,9 @@ def create_equipment(payload: EquipmentCreate, session: Session = Depends(get_se @router.put("/equipment/{item_id}", response_model=EquipmentRead) -def update_equipment(item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session)): +def update_equipment( + item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session) +): return commit_update(session, DomainServices(session).equipment, item_id, payload) @@ -271,7 +337,9 @@ def create_validation(payload: ValidationCreate, session: Session = Depends(get_ @router.put("/validations/{item_id}", response_model=ValidationRead) -def update_validation(item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session)): +def update_validation( + item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session) +): return commit_update(session, DomainServices(session).validations, item_id, payload) @@ -336,6 +404,96 @@ def cancel_validation(item_id: str, session: Session = Depends(get_session)): return item +@router.post("/validations/{item_id}/attachments") +def upload_validation_attachment( + item_id: str, + category: str = Form(...), + description: str = Form(default=""), + order: int = Form(default=0), + file: UploadFile = File(...), + session: Session = Depends(get_session), +): + item = DomainServices(session).validations.repository.get(item_id) + if item is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="Resource not found") + if item.status in {"FREIGEGEBEN", "ABGESCHLOSSEN"}: + from fastapi import HTTPException + + raise HTTPException(status_code=409, detail="Freigegebene Berichte sind schreibgeschuetzt.") + + original_name = Path(file.filename or "anlage").name + suffix = Path(original_name).suffix + stored_name = f"{uuid.uuid4().hex}{suffix}" + upload_dir = Path("/app/uploads/validations") / item_id + upload_dir.mkdir(parents=True, exist_ok=True) + storage_path = upload_dir / stored_name + with storage_path.open("wb") as target: + shutil.copyfileobj(file.file, target) + + attachment = { + "category": category, + "filename": original_name, + "content_type": file.content_type, + "description": description, + "order": order, + "storage_path": str(storage_path), + "url": f"/uploads/validations/{item_id}/{stored_name}", + } + current = list(item.attachments or []) + current.append(attachment) + item.attachments = current + session.commit() + return attachment + + +@router.post( + "/validations/{item_id}/measurement-imports/winlog-pdf", + response_model=MeasurementImportPreviewRead, +) +async def upload_winlog_pdf_import( + item_id: str, + file: UploadFile = File(...), + attachment_only: bool = Form(default=False), + session: Session = Depends(get_session), +): + item = DomainServices(session).validations.repository.get(item_id) + if item is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="Resource not found") + preview = HeliosImportService(session).save_winlog_pdf( + item_id, + file.filename or "winlog.pdf", + await file.read(), + attachment_only=attachment_only, + ) + session.commit() + return preview + + +@router.post( + "/measurement-imports/{import_id}/confirm", + response_model=MeasurementImportPreviewRead, +) +def confirm_measurement_import( + import_id: str, + payload: MeasurementImportConfirmRequest, + session: Session = Depends(get_session), +): + try: + preview = HeliosImportService(session).confirm_values( + import_id, [item.model_dump() for item in payload.values] + ) + except ValueError as exc: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail=str(exc)) from exc + session.commit() + return preview + + @router.get("/validations/{item_id}/export.json") def export_validation_json(item_id: str, session: Session = Depends(get_session)): item = DomainServices(session).validations.repository.get(item_id) @@ -356,8 +514,12 @@ async def preview_validation_csv(file: UploadFile, session: Session = Depends(ge @router.post("/validations/import/json", response_model=ValidationImportSummary) -def import_validation_json(payload: ValidationImportRequest, session: Session = Depends(get_session)): - summary = ValidationWorkflowService(session).import_rows(payload.rows, payload.duplicate_strategy) +def import_validation_json( + payload: ValidationImportRequest, session: Session = Depends(get_session) +): + summary = ValidationWorkflowService(session).import_rows( + payload.rows, payload.duplicate_strategy + ) session.commit() return summary @@ -369,7 +531,16 @@ def validation_report_preview(item_id: str, session: Session = Depends(get_sessi @router.get("/validations/{item_id}/report.pdf") def validation_report_pdf(item_id: str, session: Session = Depends(get_session)): - report_path = OrionReportService(session, Path("/app/reports")).render_pdf(item_id) + try: + report_path = OrionReportService(session, Path("/app/reports")).render_pdf(item_id) + except Exception as exc: + logger.exception("PDF generation failed for validation %s", item_id) + from fastapi import HTTPException + + raise HTTPException( + status_code=502, + detail="Der PDF-Bericht konnte nicht erzeugt werden. Die HTML-Vorschau ist weiterhin verfuegbar.", + ) from exc return FileResponse( report_path, media_type="application/pdf", diff --git a/validation-suite/backend/mercury/app/core/config.py b/validation-suite/backend/mercury/app/core/config.py index c7aee90a..7b7c4c14 100644 --- a/validation-suite/backend/mercury/app/core/config.py +++ b/validation-suite/backend/mercury/app/core/config.py @@ -17,6 +17,7 @@ class Settings(BaseSettings): jwt_algorithm: str = "HS256" access_token_minutes: int = 60 * 8 cors_origins: list[str] = ["http://localhost:3000"] + public_base_url: str = Field(default="http://localhost:8000", alias="PUBLIC_BASE_URL") admin_email: str = Field(default="admin@schubamed.de", alias="ADMIN_EMAIL") admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD") diff --git a/validation-suite/backend/mercury/app/main.py b/validation-suite/backend/mercury/app/main.py index 079487a0..f3eca3e6 100644 --- a/validation-suite/backend/mercury/app/main.py +++ b/validation-suite/backend/mercury/app/main.py @@ -1,10 +1,15 @@ from __future__ import annotations +from pathlib import Path + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from app.api.v1.router import api_router from app.core.config import settings +from app.db.session import SessionLocal +from app.modules.orion.template_service import ReportTemplateService app = FastAPI(title=settings.app_name, version="0.1.0") app.add_middleware( @@ -14,10 +19,18 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +Path("/app/uploads").mkdir(parents=True, exist_ok=True) +app.mount("/uploads", StaticFiles(directory="/app/uploads"), name="uploads") app.include_router(api_router) +@app.on_event("startup") +def load_reference_templates() -> None: + with SessionLocal() as session: + ReportTemplateService(session).ensure_default_template() + session.commit() + + @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} - diff --git a/validation-suite/backend/mercury/app/models/__init__.py b/validation-suite/backend/mercury/app/models/__init__.py index 80d95a36..887988c2 100644 --- a/validation-suite/backend/mercury/app/models/__init__.py +++ b/validation-suite/backend/mercury/app/models/__init__.py @@ -5,6 +5,14 @@ from app.models.document import Document from app.models.equipment import Equipment from app.models.location import Location from app.models.program import Program +from app.models.report_template import ( + ChecklistTemplate, + MeasurementImport, + MeasurementImportValue, + ReportSection, + ReportTemplate, + TextBlock, +) from app.models.user import User from app.models.validation import Validation @@ -16,6 +24,12 @@ __all__ = [ "Equipment", "Location", "Program", + "ChecklistTemplate", + "MeasurementImport", + "MeasurementImportValue", + "ReportSection", + "ReportTemplate", + "TextBlock", "User", "Validation", ] diff --git a/validation-suite/backend/mercury/app/models/report_template.py b/validation-suite/backend/mercury/app/models/report_template.py new file mode 100644 index 00000000..0b3b3e43 --- /dev/null +++ b/validation-suite/backend/mercury/app/models/report_template.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import enum + +from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin, UUIDMixin + + +class ReportTemplate(Base, UUIDMixin, TimestampMixin): + __tablename__ = "report_templates" + + template_key: Mapped[str] = mapped_column(String(120), unique=True, index=True) + name: Mapped[str] = mapped_column(String(180)) + version: Mapped[str] = mapped_column(String(40)) + validation_type: Mapped[str] = mapped_column(String(120), index=True) + reference_path: Mapped[str] = mapped_column(String(500)) + active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + + +class TextBlock(Base, UUIDMixin, TimestampMixin): + __tablename__ = "text_blocks" + + template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True) + block_key: Mapped[str] = mapped_column(String(160), index=True) + title: Mapped[str] = mapped_column(String(240)) + content: Mapped[str] = mapped_column(Text) + order_index: Mapped[int] = mapped_column(Integer, default=0) + version: Mapped[str] = mapped_column(String(40), default="1.0") + active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + + +class ReportSection(Base, UUIDMixin, TimestampMixin): + __tablename__ = "report_sections" + + template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True) + section_key: Mapped[str] = mapped_column(String(160), index=True) + number: Mapped[str | None] = mapped_column(String(40)) + title: Mapped[str] = mapped_column(String(240)) + order_index: Mapped[int] = mapped_column(Integer, default=0) + page_break_before: Mapped[bool] = mapped_column(Boolean, default=False) + active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + + +class ChecklistTemplate(Base, UUIDMixin, TimestampMixin): + __tablename__ = "checklist_templates" + + template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True) + checklist_key: Mapped[str] = mapped_column(String(160), index=True) + title: Mapped[str] = mapped_column(String(240)) + columns: Mapped[list[str]] = mapped_column(JSON, default=list) + items: Mapped[list[dict]] = mapped_column(JSON, default=list) + order_index: Mapped[int] = mapped_column(Integer, default=0) + active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + + +class MeasurementImportStatus(str, enum.Enum): + uploaded = "HOCHGELADEN" + analyzing = "ANALYSE_LAEUFT" + preview_ready = "VORSCHAU_BEREIT" + confirmed = "BESTAETIGT" + error = "FEHLER" + attachment_only = "NUR_ANLAGE" + + +class MeasurementImportType(str, enum.Enum): + winlog_csv = "WINLOG_CSV" + winlog_pdf = "WINLOG_PDF" + winlog_attachment_only = "WINLOG_ATTACHMENT_ONLY" + + +class MeasurementImport(Base, UUIDMixin, TimestampMixin): + __tablename__ = "measurement_imports" + + validation_id: Mapped[str] = mapped_column(ForeignKey("validations.id"), index=True) + import_type: Mapped[str] = mapped_column(String(60), index=True) + original_filename: Mapped[str] = mapped_column(String(255)) + storage_path: Mapped[str] = mapped_column(String(500)) + sha256: Mapped[str] = mapped_column(String(64), index=True) + parser_version: Mapped[str] = mapped_column(String(40)) + status: Mapped[str] = mapped_column(String(60), index=True) + + +class MeasurementImportValue(Base, UUIDMixin, TimestampMixin): + __tablename__ = "measurement_import_values" + + import_id: Mapped[str] = mapped_column(ForeignKey("measurement_imports.id"), index=True) + test_run: Mapped[str] = mapped_column(String(120), index=True) + field_name: Mapped[str] = mapped_column(String(120), index=True) + raw_value: Mapped[str | None] = mapped_column(Text) + normalized_value: Mapped[str | None] = mapped_column(String(180)) + unit: Mapped[str | None] = mapped_column(String(40)) + source_page: Mapped[int | None] = mapped_column(Integer) + source_text: Mapped[str | None] = mapped_column(Text) + confidence: Mapped[int] = mapped_column(Integer, default=0) + confirmed: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + corrected_value: Mapped[str | None] = mapped_column(String(180)) diff --git a/validation-suite/backend/mercury/app/modules/helios/service.py b/validation-suite/backend/mercury/app/modules/helios/service.py index 4a2c93b1..a2d18e11 100644 --- a/validation-suite/backend/mercury/app/modules/helios/service.py +++ b/validation-suite/backend/mercury/app/modules/helios/service.py @@ -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), + ] diff --git a/validation-suite/backend/mercury/app/modules/orion/assets.py b/validation-suite/backend/mercury/app/modules/orion/assets.py new file mode 100644 index 00000000..42e2d6bf --- /dev/null +++ b/validation-suite/backend/mercury/app/modules/orion/assets.py @@ -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() diff --git a/validation-suite/backend/mercury/app/modules/orion/assets/schubamed-logo.svg b/validation-suite/backend/mercury/app/modules/orion/assets/schubamed-logo.svg new file mode 100644 index 00000000..a48e21d7 --- /dev/null +++ b/validation-suite/backend/mercury/app/modules/orion/assets/schubamed-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/validation-suite/backend/mercury/app/modules/orion/components/chapters.py b/validation-suite/backend/mercury/app/modules/orion/components/chapters.py index 41724f63..131a4fea 100644 --- a/validation-suite/backend/mercury/app/modules/orion/components/chapters.py +++ b/validation-suite/backend/mercury/app/modules/orion/components/chapters.py @@ -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 ( - "
" - "
Neutraler Logo-Platzhalter · Validation Suite
" - "

Pruefbericht zur Validierung

" - "

Funktions- und Leistungsqualifikation Klein-Sterilisator

" - f"

{text(context.customer.name)}

" + '
' + '
' + '
SCHUBAMED
Validation Suite
Medizintechnik und Validierung
' + f'' + "
" + "

PRUEFBERICHT ZUR VALIDIERUNG

" + '

Funktions- und Leistungsqualifikation Klein-Sterilisator

' + f'

{text(context.customer.name)}

' f"{rows}" - "
Unterschrift technische Validierung
Unterschrift Auftraggeber
" + '
Unterschrift technische Validierung
Unterschrift Auftraggeber
' "
" ) @@ -46,11 +81,11 @@ class TocComponent(ReportComponent): def render(self, context: ReportContext) -> str: links = "".join( - f"
  • {text(component.title)}
  • " + f'
  • {text(component.title)}
  • ' for component in self.components if component.anchor not in {"cover", "toc"} ) - return section(self.anchor, self.title, f"
      {links}
    ") + return section(self.anchor, self.title, f'
      {links}
    ') 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"

    {paragraph(render_template_text(block.content, context) if block else 'nicht erfasst')}

    " + 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"

    {text(self.body)}

    ") + 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"

    {paragraph(content)}

    ") + return section(self.anchor, self.title, f"

    {paragraph(self.body)}

    ") 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 += "

    Standort

    " + 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 = "

    Dokumentation

    " + self._render_items(documentation) - body += "

    Leistung

    " + self._render_items(performance) + body = "" + for checklist in context.checklist_templates: + body += f"

    {text(checklist.title)}

    " + body += self._render_items(checklist.items) + if not body: + documentation = context.validation.documentation_checklist or [] + performance = context.validation.performance_checklist or [] + body = "

    Dokumentation

    " + self._render_items(documentation) + body += "

    Leistung

    " + 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 += "

    Winlog-Dateien

    " + table(["Bereich", "Datei", "Typ"], winlog_rows) + body += "

    Messdaten noch nicht bestätigt.

    " 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( + '
    ' + f'{text(caption)}' + f"
    Abbildung {index}: {text(caption)}
    " + "
    " + ) + body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows) + if figures: + body += '
    ' + "".join(figures) + "
    " + 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 diff --git a/validation-suite/backend/mercury/app/modules/orion/context.py b/validation-suite/backend/mercury/app/modules/orion/context.py index 0c78a49c..6214b5d7 100644 --- a/validation-suite/backend/mercury/app/modules/orion/context.py +++ b/validation-suite/backend/mercury/app/modules/orion/context.py @@ -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, ) - diff --git a/validation-suite/backend/mercury/app/modules/orion/service.py b/validation-suite/backend/mercury/app/modules/orion/service.py index 05fcdc75..929792cd 100644 --- a/validation-suite/backend/mercury/app/modules/orion/service.py +++ b/validation-suite/backend/mercury/app/modules/orion/service.py @@ -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) diff --git a/validation-suite/backend/mercury/app/modules/orion/template_service.py b/validation-suite/backend/mercury/app/modules/orion/template_service.py new file mode 100644 index 00000000..31eea7c2 --- /dev/null +++ b/validation-suite/backend/mercury/app/modules/orion/template_service.py @@ -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)) diff --git a/validation-suite/backend/mercury/app/modules/orion/templates/report.css b/validation-suite/backend/mercury/app/modules/orion/templates/report.css index b74117f8..5144b649 100644 --- a/validation-suite/backend/mercury/app/modules/orion/templates/report.css +++ b/validation-suite/backend/mercury/app/modules/orion/templates/report.css @@ -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; + } } diff --git a/validation-suite/backend/mercury/app/modules/orion/templates/report.py b/validation-suite/backend/mercury/app/modules/orion/templates/report.py index f28c9aca..a850608e 100644 --- a/validation-suite/backend/mercury/app/modules/orion/templates/report.py +++ b/validation-suite/backend/mercury/app/modules/orion/templates/report.py @@ -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""" +
    +
    + SCHUBAMED +
    + SCHUBAMED® + Aufbereitung mit System +
    +
    +
    +
    Berichtsnummer
    {report_number}
    +
    Version
    {version}
    +
    Datum
    {report_date}
    +
    +
    + + """ + + 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""" @@ -20,9 +49,9 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
    -
    +
    + {render_report_chrome(context, logo_uri)} {chapter_markup}
    """ - diff --git a/validation-suite/backend/mercury/app/schemas/domain.py b/validation-suite/backend/mercury/app/schemas/domain.py index 76e940e1..b91191d7 100644 --- a/validation-suite/backend/mercury/app/schemas/domain.py +++ b/validation-suite/backend/mercury/app/schemas/domain.py @@ -206,3 +206,38 @@ class ValidationImportSummary(ORMModel): skipped: int failed: int errors: list[str] = Field(default_factory=list) + + +class MeasurementImportValueRead(ORMModel): + id: str + test_run: str + field_name: str + raw_value: str | None = None + normalized_value: str | None = None + unit: str | None = None + source_page: int | None = None + source_text: str | None = None + confidence: int + confirmed: bool + corrected_value: str | None = None + + +class MeasurementImportPreviewRead(ORMModel): + id: str + validation_id: str + import_type: str + original_filename: str + sha256: str + parser_version: str + status: str + values: list[MeasurementImportValueRead] + + +class MeasurementImportValueConfirm(ORMModel): + id: str + confirmed: bool = False + corrected_value: str | None = None + + +class MeasurementImportConfirmRequest(ORMModel): + values: list[MeasurementImportValueConfirm] diff --git a/validation-suite/backend/mercury/docs/reference/README.md b/validation-suite/backend/mercury/docs/reference/README.md new file mode 100644 index 00000000..f3e710c5 --- /dev/null +++ b/validation-suite/backend/mercury/docs/reference/README.md @@ -0,0 +1,15 @@ +# Referenzdateien + +Die Dateien in diesem Verzeichnis sind fachliche Referenzen und keine Laufzeitdaten. Sie duerfen nicht durch Benutzeruploads ueberschrieben werden. + +| Dateiname | Berichtstyp | Version | Status | Verwendungszweck | Orion-Vorlage | +| --- | --- | --- | --- | --- | --- | +| `reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx` | Erstvalidierung Klein-Sterilisator | 1.0 | gueltig | Fachliche Referenz fuer Kapitelstruktur, Standardtexte, Tabellen, Bild- und Anlagenbereiche | `small_steam_sterilizer_initial_validation` | +| `logos/schubamed-logo.svg` | Firmenlogo | 1.0 | gueltig | Offizielles Logo fuer Atlas und Orion | alle aktiven Berichtsvorlagen | + +## Struktur + +- `reports/`: fachliche Berichtsvorlagen als Referenzdokumente +- `winlog/`: Winlog-Referenzdateien fuer Parser- und Importtests +- `logos/`: offizielle Markenassets +- `images/`: fachliche Bildreferenzen fuer Berichtslayouts diff --git a/validation-suite/backend/mercury/docs/reference/images/.gitkeep b/validation-suite/backend/mercury/docs/reference/images/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/validation-suite/backend/mercury/docs/reference/images/.gitkeep @@ -0,0 +1 @@ + diff --git a/validation-suite/backend/mercury/docs/reference/logos/schubamed-logo.svg b/validation-suite/backend/mercury/docs/reference/logos/schubamed-logo.svg new file mode 100644 index 00000000..a48e21d7 --- /dev/null +++ b/validation-suite/backend/mercury/docs/reference/logos/schubamed-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/validation-suite/backend/mercury/docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx b/validation-suite/backend/mercury/docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx new file mode 100644 index 00000000..bb22591d Binary files /dev/null and b/validation-suite/backend/mercury/docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx differ diff --git a/validation-suite/backend/mercury/docs/reference/winlog/.gitkeep b/validation-suite/backend/mercury/docs/reference/winlog/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/validation-suite/backend/mercury/docs/reference/winlog/.gitkeep @@ -0,0 +1 @@ + diff --git a/validation-suite/backend/mercury/pyproject.toml b/validation-suite/backend/mercury/pyproject.toml index 88f0b0d7..7f64d6a7 100644 --- a/validation-suite/backend/mercury/pyproject.toml +++ b/validation-suite/backend/mercury/pyproject.toml @@ -14,9 +14,11 @@ dependencies = [ "python-jose[cryptography]==3.5.0", "python-multipart==0.0.20", "python-dateutil==2.9.0.post0", + "pydyf==0.11.0", + "pypdf==6.4.1", "sqlalchemy==2.0.41", "uvicorn[standard]==0.35.0", - "weasyprint==62.3" + "weasyprint==69.0" ] [project.optional-dependencies] diff --git a/validation-suite/backend/mercury/tests/test_validation_workflow.py b/validation-suite/backend/mercury/tests/test_validation_workflow.py index b669af81..2537ac57 100644 --- a/validation-suite/backend/mercury/tests/test_validation_workflow.py +++ b/validation-suite/backend/mercury/tests/test_validation_workflow.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import date +from pathlib import Path from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -14,6 +15,9 @@ from app.models.validation import Validation, ValidationStatus from app.services.validation_workflow import ValidationWorkflowService from app.schemas.domain import ValidationCreate from app.modules.orion.service import OrionReportService +from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri +from app.modules.orion.template_service import ReportTemplateService +from app.modules.helios.service import HeliosImportService def session() -> Session: @@ -219,7 +223,217 @@ def test_orion_renders_reference_main_chapters(tmp_path): html = OrionReportService(db, tmp_path).render_html(validation.id) - assert "1. Funktionsqualifikation" in html - assert "2.2 Pruefmittel" in html - assert "4. Ergebnisse der Validierung" in html - assert "9. Kalibrierzertifikate" in html + assert "1 Funktionsqualifikation" in html + assert "2.2 Prüfmittel" in html + assert "4 Ergebnisse der Validierung" in html + assert "9. Werkskalibrierzertifikate Sensoren" in html + + +def test_orion_renders_uploaded_images_as_real_images(tmp_path): + db = session() + customer, location, device = seed(db) + validation = valid_validation(customer, location, device) + validation.attachments = [ + { + "category": "Beladung", + "filename": "beladung.png", + "content_type": "image/png", + "description": "Beladungsmuster Testlauf 1", + "order": 1, + "url": "/uploads/validations/example/beladung.png", + } + ] + db.add(validation) + db.flush() + + html = OrionReportService(db, tmp_path).render_html(validation.id) + + assert '

    Orion PDF Test

    ").write_pdf() + + assert pdf_bytes.startswith(b"%PDF") + assert len(pdf_bytes) > 1024 + + +def test_orion_renders_real_pdf_with_logo(tmp_path): + db = session() + customer, location, device = seed(db) + validation = valid_validation(customer, location, device) + db.add(validation) + db.flush() + + assert SCHUBAMED_LOGO_PATH.exists() + html = OrionReportService(db, tmp_path).render_html(validation.id) + pdf_path = OrionReportService(db, tmp_path).render_pdf(validation.id) + + assert schubamed_logo_uri() in html + assert "Neutraler Logo-Platzhalter" not in html + assert pdf_path.read_bytes().startswith(b"%PDF") + assert pdf_path.stat().st_size > 1024 + + +def test_orion_header_footer_layout_markers_are_rendered(tmp_path): + db = session() + customer, location, device = seed(db) + validation = valid_validation(customer, location, device) + validation.report_number = "SV-2026-00005" + db.add(validation) + db.flush() + + html = OrionReportService(db, tmp_path).render_html(validation.id) + + assert 'class="report-header"' in html + assert 'class="report-header-brand"' in html + assert "SCHUBAMED®" in html + assert "Aufbereitung mit System" in html + assert "SV-2026-00005" in html + assert "Version" in html + assert "Datum" in html + assert 'class="report-footer"' in html + assert "Validation Suite" in html + assert "Seite" in html + assert "page-count" in html + + +def test_orion_layout_css_reserves_header_footer_space(): + css = Path("app/modules/orion/templates/report.css").read_text(encoding="utf-8") + + assert "margin: 34mm 18mm 24mm 18mm" in css + assert "position: running(report-header)" in css + assert "position: running(report-footer)" in css + assert "grid-template-columns: 1fr 54mm" in css + assert "height: 24mm" in css + assert "height: 20mm" in css + assert "font-size: 22pt" in css + assert "counter(pages)" in css + + +def test_reference_template_textblocks_and_checklists_are_loaded(): + db = session() + bundle = ReportTemplateService(db).ensure_default_template() + + assert bundle.template.template_key == "small_steam_sterilizer_initial_validation" + assert ReportTemplateService(db).reference_path.exists() + assert {"summary", "bq_goal", "legal", "performance"} <= set(bundle.text_blocks) + assert len(bundle.checklists) >= 6 + assert any(item.title == "Beschreibung Sterilisator" for item in bundle.checklists) + + +def test_orion_contains_required_reference_sections_and_three_runs(tmp_path): + db = session() + customer, location, device = seed(db) + validation = valid_validation(customer, location, device) + db.add(validation) + db.flush() + + html = OrionReportService(db, tmp_path).render_html(validation.id) + + for title in [ + "Zusammenfassendes Ergebnis der Validierung", + "1.1 Anlass und Ziel der Prüfung", + "1.2 Gesetzliche Grundlagen", + "3.2 Standardbeladung – Testlauf 1", + "3.3 Standardbeladung – Testlauf 2", + "3.4 Standardbeladung – Testlauf 3", + ]: + assert title in html + assert "Neutraler Logo-Platzhalter" not in html + assert "report-header-brand" in html + + +def test_orion_appends_pdf_attachments(tmp_path): + from pypdf import PdfReader, PdfWriter + + db = session() + customer, location, device = seed(db) + validation = valid_validation(customer, location, device) + attachment_path = tmp_path / "anlage.pdf" + writer = PdfWriter() + writer.add_blank_page(width=200, height=200) + with attachment_path.open("wb") as handle: + writer.write(handle) + validation.attachments = [ + { + "category": "Kalibrierschein", + "filename": "anlage.pdf", + "content_type": "application/pdf", + "description": "Anlage", + "order": 1, + "storage_path": str(attachment_path), + } + ] + db.add(validation) + db.flush() + + pdf_path = OrionReportService(db, tmp_path).render_pdf(validation.id) + + assert len(PdfReader(str(pdf_path)).pages) >= 2 + + +def test_winlog_pdf_import_preview_and_confirmation(tmp_path): + from weasyprint import HTML + + db = session() + customer, location, device = seed(db) + validation = valid_validation(customer, location, device) + db.add(validation) + db.flush() + pdf_bytes = HTML( + string="

    Vakuumtest

    Programm: Vakuum

    Leckrate: 0,1

    Ergebnis: bestanden

    " + ).write_pdf() + + preview = HeliosImportService(db, tmp_path).save_winlog_pdf( + validation.id, "winlog.pdf", pdf_bytes + ) + + assert preview["status"] == "VORSCHAU_BEREIT" + assert preview["sha256"] + assert any(value["field_name"] == "leak_rate" for value in preview["values"]) + first = preview["values"][0] + confirmed = HeliosImportService(db, tmp_path).confirm_values( + preview["id"], [{"id": first["id"], "confirmed": True, "corrected_value": "korrigiert"}] + ) + assert confirmed["status"] == "BESTAETIGT" + + +def test_unreadable_winlog_pdf_does_not_crash(tmp_path): + db = session() + customer, location, device = seed(db) + validation = valid_validation(customer, location, device) + db.add(validation) + db.flush() + + preview = HeliosImportService(db, tmp_path).save_winlog_pdf( + validation.id, "broken.pdf", b"not a pdf" + ) + + assert preview["status"] in {"FEHLER", "NUR_ANLAGE"} + assert preview["values"] == [] + + +def test_confirmed_measurements_appear_in_orion_report(tmp_path): + from weasyprint import HTML + + db = session() + customer, location, device = seed(db) + validation = valid_validation(customer, location, device) + db.add(validation) + db.flush() + pdf_bytes = HTML(string="

    Testlauf 1

    Leckrate: 0,2

    ").write_pdf() + preview = HeliosImportService(db, tmp_path).save_winlog_pdf(validation.id, "winlog.pdf", pdf_bytes) + leak_rate = next(value for value in preview["values"] if value["field_name"] == "leak_rate") + HeliosImportService(db, tmp_path).confirm_values( + preview["id"], [{"id": leak_rate["id"], "confirmed": True}] + ) + + html = OrionReportService(db, tmp_path).render_html(validation.id) + + assert "leak_rate" in html + assert "0,2" in html diff --git a/validation-suite/docker-compose.yml b/validation-suite/docker-compose.yml index 28ed2ff8..5accf211 100644 --- a/validation-suite/docker-compose.yml +++ b/validation-suite/docker-compose.yml @@ -24,6 +24,7 @@ services: environment: DATABASE_URL: postgresql+psycopg://validation:validation123@postgres:5432/validation_suite JWT_SECRET: validation-suite-local-jwt-secret + PUBLIC_BASE_URL: http://localhost:8000 ADMIN_EMAIL: admin@schubamed.de ADMIN_PASSWORD: ValidationSuite!2026 ports: diff --git a/validation-suite/docs/reference/README.md b/validation-suite/docs/reference/README.md new file mode 100644 index 00000000..f3e710c5 --- /dev/null +++ b/validation-suite/docs/reference/README.md @@ -0,0 +1,15 @@ +# Referenzdateien + +Die Dateien in diesem Verzeichnis sind fachliche Referenzen und keine Laufzeitdaten. Sie duerfen nicht durch Benutzeruploads ueberschrieben werden. + +| Dateiname | Berichtstyp | Version | Status | Verwendungszweck | Orion-Vorlage | +| --- | --- | --- | --- | --- | --- | +| `reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx` | Erstvalidierung Klein-Sterilisator | 1.0 | gueltig | Fachliche Referenz fuer Kapitelstruktur, Standardtexte, Tabellen, Bild- und Anlagenbereiche | `small_steam_sterilizer_initial_validation` | +| `logos/schubamed-logo.svg` | Firmenlogo | 1.0 | gueltig | Offizielles Logo fuer Atlas und Orion | alle aktiven Berichtsvorlagen | + +## Struktur + +- `reports/`: fachliche Berichtsvorlagen als Referenzdokumente +- `winlog/`: Winlog-Referenzdateien fuer Parser- und Importtests +- `logos/`: offizielle Markenassets +- `images/`: fachliche Bildreferenzen fuer Berichtslayouts diff --git a/validation-suite/docs/reference/images/.gitkeep b/validation-suite/docs/reference/images/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/validation-suite/docs/reference/images/.gitkeep @@ -0,0 +1 @@ + diff --git a/validation-suite/docs/reference/logos/schubamed-logo.svg b/validation-suite/docs/reference/logos/schubamed-logo.svg new file mode 100644 index 00000000..a48e21d7 --- /dev/null +++ b/validation-suite/docs/reference/logos/schubamed-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/validation-suite/docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx b/validation-suite/docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx new file mode 100644 index 00000000..bb22591d Binary files /dev/null and b/validation-suite/docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx differ diff --git a/validation-suite/docs/reference/winlog/.gitkeep b/validation-suite/docs/reference/winlog/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/validation-suite/docs/reference/winlog/.gitkeep @@ -0,0 +1 @@ + diff --git a/validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx b/validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx index d020bdd3..e1961695 100644 --- a/validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx +++ b/validation-suite/frontend/atlas/app/(app)/dashboard/page.tsx @@ -1,20 +1,37 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine } from "lucide-react"; +import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine, Gauge, Plus, Stethoscope, TestTube2, Users } from "lucide-react"; import Link from "next/link"; import { useAuth } from "@/components/auth"; import { apiGet } from "@/lib/api"; +import { BrandLogo } from "@/components/brand/brand-logo"; type DashboardData = { + customers: number; + devices: number; + equipment: number; + validations: number; validation_drafts: number; validation_ready: number; validation_in_review: number; validation_approved: number; + validation_completed: number; validation_overdue: number; + equipment_green: number; + equipment_yellow: number; + equipment_red: number; }; -const cards = [ +const primaryCards = [ + { label: "Neue Validierung", value: "Starten", href: "/validations/new", icon: Plus }, + { label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList }, + { label: "Kunden", key: "customers", href: "/customers", icon: Users }, + { label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope }, + { label: "Pruefmittel", key: "equipment", href: "/equipment", icon: TestTube2 } +] as const; + +const workflowCards = [ { label: "Entwuerfe", key: "validation_drafts", href: "/validations?status=ENTWURF", icon: FilePenLine }, { label: "Bereit zur Pruefung", key: "validation_ready", href: "/validations?status=BEREIT_ZUR_PRUEFUNG", icon: ClipboardList }, { label: "In Pruefung", key: "validation_in_review", href: "/validations?status=IN_PRUEFUNG", icon: Clock }, @@ -32,27 +49,63 @@ export default function DashboardPage() { return (
    -
    -

    Dashboard

    +
    +
    + +
    +

    Benutzer: angemeldet

    +

    Speicherstatus: synchronisiert

    +
    +
    +

    Dashboard

    Validierungsworkflow und faellige Revalidierungen.

    - {cards.map((item) => { + {primaryCards.map((item) => { const Icon = item.icon; + const value = "key" in item ? query.data?.[item.key] ?? 0 : item.value; return ( - +

    {item.label}

    -

    {query.data?.[item.key] ?? 0}

    +

    {value}

    ); })}
    -
    -

    Zuletzt bearbeitete Validierungen

    -

    Die Validierungsverwaltung bietet Suche, Filter, Sortierung, Vorschau, Export und Workflow-Aktionen.

    +
    + {workflowCards.map((item) => { + const Icon = item.icon; + return ( + +
    +

    {item.label}

    + +
    +

    {query.data?.[item.key] ?? 0}

    + + ); + })} +
    +
    + +

    Zuletzt bearbeitet

    +

    Aktuelle Validierungen nach Bearbeitungsdatum oeffnen.

    + + +

    Ueberfaellige Revalidierungen

    +

    {query.data?.validation_overdue ?? 0}

    + + +

    Kalibrierstatus

    +

    Gruen {query.data?.equipment_green ?? 0} · Gelb {query.data?.equipment_yellow ?? 0} · Rot {query.data?.equipment_red ?? 0}

    + + +

    Letzte Berichte

    +

    {query.data?.validation_completed ?? 0}

    +
    ); diff --git a/validation-suite/frontend/atlas/app/(app)/validations/[id]/preview/page.tsx b/validation-suite/frontend/atlas/app/(app)/validations/[id]/preview/page.tsx index 5930fb1d..32deccd3 100644 --- a/validation-suite/frontend/atlas/app/(app)/validations/[id]/preview/page.tsx +++ b/validation-suite/frontend/atlas/app/(app)/validations/[id]/preview/page.tsx @@ -12,6 +12,7 @@ export default function ValidationPreviewPage() { const params = useParams<{ id: string }>(); const [html, setHtml] = useState(""); const [message, setMessage] = useState("Vorschau wird geladen."); + const [toast, setToast] = useState(""); useEffect(() => { if (!token || !params.id) return; @@ -33,6 +34,11 @@ export default function ValidationPreviewPage() { const response = await fetch(`${API_BASE}/validations/${params.id}/report.pdf`, { headers: { Authorization: `Bearer ${token}` } }); + if (!response.ok) { + const body = await response.json().catch(() => null); + setToast(body?.detail ?? "PDF konnte nicht erzeugt werden."); + return; + } const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -54,6 +60,7 @@ export default function ValidationPreviewPage() { + {toast &&
    {toast}
    } {message ?
    {message}
    :