feat(orion): extend report layout settings to version 1.1
This commit is contained in:
parent
12e1761a5b
commit
613ffdc8b7
591 changed files with 3601 additions and 999 deletions
|
|
@ -7,7 +7,7 @@ from sqlalchemy import engine_from_config, pool
|
|||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.models import contact, customer, device, document, equipment, location, program, user, validation
|
||||
from app.models import contact, customer, device, document, equipment, location, program, report_settings, user, validation
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
|
@ -45,4 +45,3 @@ if context.is_offline_mode():
|
|||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
"""orion report settings
|
||||
|
||||
Revision ID: 202607120001
|
||||
Revises: 202607110007
|
||||
Create Date: 2026-07-12 12:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "202607120001"
|
||||
down_revision = "202607110007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"orion_report_settings",
|
||||
sa.Column("scope", sa.String(length=40), nullable=False),
|
||||
sa.Column("layout_profile", sa.String(length=40), nullable=False),
|
||||
sa.Column("show_cover_result_text", sa.Boolean(), nullable=False),
|
||||
sa.Column("signature_mode", sa.String(length=60), nullable=False),
|
||||
sa.Column("image_size", sa.String(length=40), nullable=False),
|
||||
sa.Column("page_break_before_main_chapters", sa.Boolean(), nullable=False),
|
||||
sa.Column("updated_by_user_id", sa.String(), nullable=True),
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name=op.f("fk_orion_report_settings_updated_by_user_id_users")),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_orion_report_settings")),
|
||||
sa.UniqueConstraint("scope", name=op.f("uq_orion_report_settings_scope")),
|
||||
)
|
||||
op.create_index(op.f("ix_orion_report_settings_scope"), "orion_report_settings", ["scope"], unique=True)
|
||||
op.create_index(op.f("ix_orion_report_settings_updated_by_user_id"), "orion_report_settings", ["updated_by_user_id"], unique=False)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO orion_report_settings (
|
||||
id,
|
||||
scope,
|
||||
layout_profile,
|
||||
show_cover_result_text,
|
||||
signature_mode,
|
||||
image_size,
|
||||
page_break_before_main_chapters
|
||||
)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000000001',
|
||||
'GLOBAL',
|
||||
'STANDARD',
|
||||
true,
|
||||
'TECHNICAL_ONLY',
|
||||
'MEDIUM',
|
||||
true
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_orion_report_settings_updated_by_user_id"), table_name="orion_report_settings")
|
||||
op.drop_index(op.f("ix_orion_report_settings_scope"), table_name="orion_report_settings")
|
||||
op.drop_table("orion_report_settings")
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""orion report settings v1.1
|
||||
|
||||
Revision ID: 202607120002
|
||||
Revises: 202607120001
|
||||
Create Date: 2026-07-12 13:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "202607120002"
|
||||
down_revision = "202607120001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("orion_report_settings", sa.Column("page_margin", sa.String(length=40), nullable=False, server_default="STANDARD"))
|
||||
op.add_column("orion_report_settings", sa.Column("section_spacing", sa.String(length=40), nullable=False, server_default="STANDARD"))
|
||||
op.add_column("orion_report_settings", sa.Column("table_layout", sa.String(length=40), nullable=False, server_default="STANDARD"))
|
||||
op.add_column("orion_report_settings", sa.Column("table_font_size", sa.String(length=40), nullable=False, server_default="STANDARD"))
|
||||
op.add_column("orion_report_settings", sa.Column("image_position", sa.String(length=40), nullable=False, server_default="SIDE_BY_SIDE"))
|
||||
op.add_column("orion_report_settings", sa.Column("max_images_per_page", sa.Integer(), nullable=False, server_default="2"))
|
||||
op.add_column("orion_report_settings", sa.Column("show_image_captions", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||
op.add_column("orion_report_settings", sa.Column("show_header", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||
op.add_column("orion_report_settings", sa.Column("show_footer", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||
op.add_column("orion_report_settings", sa.Column("logo_size", sa.String(length=40), nullable=False, server_default="MEDIUM"))
|
||||
op.add_column("orion_report_settings", sa.Column("compact_cover", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
for column in [
|
||||
"page_margin",
|
||||
"section_spacing",
|
||||
"table_layout",
|
||||
"table_font_size",
|
||||
"image_position",
|
||||
"max_images_per_page",
|
||||
"show_image_captions",
|
||||
"show_header",
|
||||
"show_footer",
|
||||
"logo_size",
|
||||
"compact_cover",
|
||||
]:
|
||||
op.alter_column("orion_report_settings", column, server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orion_report_settings", "compact_cover")
|
||||
op.drop_column("orion_report_settings", "logo_size")
|
||||
op.drop_column("orion_report_settings", "show_footer")
|
||||
op.drop_column("orion_report_settings", "show_header")
|
||||
op.drop_column("orion_report_settings", "show_image_captions")
|
||||
op.drop_column("orion_report_settings", "max_images_per_page")
|
||||
op.drop_column("orion_report_settings", "image_position")
|
||||
op.drop_column("orion_report_settings", "table_font_size")
|
||||
op.drop_column("orion_report_settings", "table_layout")
|
||||
op.drop_column("orion_report_settings", "section_spacing")
|
||||
op.drop_column("orion_report_settings", "page_margin")
|
||||
Binary file not shown.
|
|
@ -56,6 +56,8 @@ from app.schemas.domain import (
|
|||
QuickStartCreateRequest,
|
||||
QuickStartCustomerData,
|
||||
QuickStartValidationSummary,
|
||||
OrionReportSettingsRead,
|
||||
OrionReportSettingsUpdate,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
|
|
@ -63,6 +65,7 @@ from app.schemas.domain import (
|
|||
)
|
||||
from app.services.domain_service import CrudService, DomainServices
|
||||
from app.services.quickstart_service import QuickStartService
|
||||
from app.services.report_settings import OrionReportSettingsService
|
||||
from app.services.validation_workflow import ValidationWorkflowService
|
||||
|
||||
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
|
||||
|
|
@ -87,6 +90,48 @@ def default_report_checklists(session: Session = Depends(get_session)) -> list[d
|
|||
]
|
||||
|
||||
|
||||
@router.get("/report-settings", response_model=OrionReportSettingsRead)
|
||||
def get_report_settings(
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(current_admin),
|
||||
):
|
||||
return OrionReportSettingsService(session).get_global()
|
||||
|
||||
|
||||
@router.put("/report-settings", response_model=OrionReportSettingsRead)
|
||||
def update_report_settings(
|
||||
payload: OrionReportSettingsUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(current_admin),
|
||||
):
|
||||
try:
|
||||
settings = OrionReportSettingsService(session).update_global(payload, user)
|
||||
session.commit()
|
||||
session.refresh(settings)
|
||||
return settings
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Berichtseinstellungen konnten nicht gespeichert werden.") from exc
|
||||
|
||||
|
||||
@router.post("/report-settings/preview")
|
||||
def preview_report_settings(
|
||||
payload: OrionReportSettingsUpdate | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
_: User = Depends(current_admin),
|
||||
):
|
||||
try:
|
||||
pdf = OrionReportService(session, Path("/app/reports")).render_settings_preview_pdf(payload)
|
||||
except Exception as exc:
|
||||
logger.exception("Orion settings preview generation failed")
|
||||
raise HTTPException(status_code=502, detail="Die PDF-Vorschau konnte nicht erzeugt werden.") from exc
|
||||
return Response(
|
||||
content=pdf,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": 'attachment; filename="orion-layout-preview.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
|
||||
today = func.current_date()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from app.models.report_template import (
|
|||
ReportTemplate,
|
||||
TextBlock,
|
||||
)
|
||||
from app.models.report_settings import OrionReportSettings
|
||||
from app.models.user import User
|
||||
from app.models.validation import Validation
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ __all__ = [
|
|||
"MeasurementImportValue",
|
||||
"ReportSection",
|
||||
"ReportTemplate",
|
||||
"OrionReportSettings",
|
||||
"TextBlock",
|
||||
"User",
|
||||
"Validation",
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class OrionReportSettingsScope(str, enum.Enum):
|
||||
global_ = "GLOBAL"
|
||||
|
||||
|
||||
class OrionLayoutProfile(str, enum.Enum):
|
||||
compact = "COMPACT"
|
||||
standard = "STANDARD"
|
||||
|
||||
|
||||
class OrionSignatureMode(str, enum.Enum):
|
||||
technical_only = "TECHNICAL_ONLY"
|
||||
technical_and_client = "TECHNICAL_AND_CLIENT"
|
||||
|
||||
|
||||
class OrionImageSize(str, enum.Enum):
|
||||
small = "SMALL"
|
||||
medium = "MEDIUM"
|
||||
large = "LARGE"
|
||||
|
||||
|
||||
class OrionPageMargin(str, enum.Enum):
|
||||
narrow = "NARROW"
|
||||
standard = "STANDARD"
|
||||
wide = "WIDE"
|
||||
|
||||
|
||||
class OrionSpacing(str, enum.Enum):
|
||||
compact = "COMPACT"
|
||||
standard = "STANDARD"
|
||||
|
||||
|
||||
class OrionTableFontSize(str, enum.Enum):
|
||||
small = "SMALL"
|
||||
standard = "STANDARD"
|
||||
|
||||
|
||||
class OrionImagePosition(str, enum.Enum):
|
||||
stacked = "STACKED"
|
||||
side_by_side = "SIDE_BY_SIDE"
|
||||
|
||||
|
||||
class OrionLogoSize(str, enum.Enum):
|
||||
small = "SMALL"
|
||||
medium = "MEDIUM"
|
||||
large = "LARGE"
|
||||
|
||||
|
||||
class OrionReportSettings(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "orion_report_settings"
|
||||
|
||||
scope: Mapped[str] = mapped_column(String(40), nullable=False, unique=True, index=True)
|
||||
layout_profile: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionLayoutProfile.standard.value)
|
||||
show_cover_result_text: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
signature_mode: Mapped[str] = mapped_column(String(60), nullable=False, default=OrionSignatureMode.technical_only.value)
|
||||
image_size: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionImageSize.medium.value)
|
||||
page_break_before_main_chapters: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
page_margin: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionPageMargin.standard.value)
|
||||
section_spacing: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionSpacing.standard.value)
|
||||
table_layout: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionSpacing.standard.value)
|
||||
table_font_size: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionTableFontSize.standard.value)
|
||||
image_position: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionImagePosition.side_by_side.value)
|
||||
max_images_per_page: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
|
||||
show_image_captions: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
show_header: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
show_footer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
logo_size: Mapped[str] = mapped_column(String(40), nullable=False, default=OrionLogoSize.medium.value)
|
||||
compact_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -84,15 +84,32 @@ class CoverComponent(ReportComponent):
|
|||
f'<p class="cover-subtitle">{text(context.customer.name)}</p>'
|
||||
f"{rows}"
|
||||
'<div class="cover-closing">'
|
||||
'<div class="cover-result-text">'
|
||||
"<h2>Ergebnis der Validierung</h2>"
|
||||
f"<p>{text(validation_result_cover_sentence(validation.result))}</p>"
|
||||
"</div>"
|
||||
'<div class="signature-block"><div class="signature-line"></div><div class="signature-label">Unterschrift technische Validierung</div></div>'
|
||||
f"{self._cover_result_text(context)}"
|
||||
f"{self._signature_blocks(context)}"
|
||||
"</div>"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
def _cover_result_text(self, context: ReportContext) -> str:
|
||||
if not context.report_settings.show_cover_result_text:
|
||||
return ""
|
||||
return (
|
||||
'<div class="cover-result-text">'
|
||||
"<h2>Ergebnis der Validierung</h2>"
|
||||
f"<p>{text(validation_result_cover_sentence(context.validation.result))}</p>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
def _signature_blocks(self, context: ReportContext) -> str:
|
||||
blocks = [
|
||||
'<div class="signature-block"><div class="signature-line"></div><div class="signature-label">Unterschrift technische Validierung</div></div>'
|
||||
]
|
||||
if context.report_settings.signature_mode == "TECHNICAL_AND_CLIENT":
|
||||
blocks.append(
|
||||
'<div class="signature-block"><div class="signature-line"></div><div class="signature-label">Unterschrift Auftraggeber</div></div>'
|
||||
)
|
||||
return '<div class="signature-grid">' + "".join(blocks) + "</div>"
|
||||
|
||||
|
||||
class TocComponent(ReportComponent):
|
||||
anchor = "toc"
|
||||
|
|
@ -502,7 +519,7 @@ class AttachmentComponent(NumberedComponent):
|
|||
figures.append(
|
||||
'<figure class="report-figure">'
|
||||
f'<img class="report-image" src="{text(src)}" alt="{text(caption)}">'
|
||||
f"<figcaption>Abbildung {index}: {text(caption)}</figcaption>"
|
||||
f'<figcaption class="image-caption">Abbildung {index}: {text(caption)}</figcaption>'
|
||||
"</figure>"
|
||||
)
|
||||
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from app.models.report_template import (
|
|||
ReportSection,
|
||||
TextBlock,
|
||||
)
|
||||
from app.models.report_settings import OrionReportSettings
|
||||
from app.models.location import Location
|
||||
from app.models.validation import Validation
|
||||
from app.modules.orion.template_service import ReportTemplateService
|
||||
|
|
@ -36,6 +37,7 @@ class ReportContext:
|
|||
text_blocks: dict[str, TextBlock]
|
||||
checklist_templates: list[ChecklistTemplate]
|
||||
confirmed_measurements: list[MeasurementImportValue]
|
||||
report_settings: OrionReportSettings
|
||||
|
||||
|
||||
class OrionContextBuilder:
|
||||
|
|
@ -63,6 +65,9 @@ class OrionContextBuilder:
|
|||
)
|
||||
)
|
||||
template_bundle = ReportTemplateService(self.session).ensure_default_template()
|
||||
from app.services.report_settings import OrionReportSettingsService
|
||||
|
||||
report_settings = OrionReportSettingsService(self.session).get_global()
|
||||
confirmed_measurements = list(
|
||||
self.session.scalars(
|
||||
select(MeasurementImportValue)
|
||||
|
|
@ -87,4 +92,5 @@ class OrionContextBuilder:
|
|||
text_blocks=template_bundle.text_blocks,
|
||||
checklist_templates=template_bundle.checklists,
|
||||
confirmed_measurements=confirmed_measurements,
|
||||
report_settings=report_settings,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
import logging
|
||||
from datetime import UTC, date, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -25,8 +27,17 @@ from app.modules.orion.components import (
|
|||
TocComponent,
|
||||
)
|
||||
from app.modules.orion.context import OrionContextBuilder, ReportContext
|
||||
from app.modules.orion.html import section, table, text
|
||||
from app.modules.orion.templates.report import render_document
|
||||
from app.modules.orion.template_service import REPORT_SECTIONS
|
||||
from app.models.report_settings import (
|
||||
OrionImageSize,
|
||||
OrionLayoutProfile,
|
||||
OrionReportSettings,
|
||||
OrionReportSettingsScope,
|
||||
OrionSignatureMode,
|
||||
)
|
||||
from app.schemas.domain import OrionReportSettingsUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -52,10 +63,178 @@ class OrionReportService:
|
|||
self._append_pdf_attachments(context, output_path)
|
||||
return output_path
|
||||
|
||||
def render_settings_preview_pdf(self, settings_override: OrionReportSettingsUpdate | None = None) -> bytes:
|
||||
from weasyprint import HTML
|
||||
|
||||
context = self._preview_context(settings_override)
|
||||
preview_section = section(
|
||||
"layout-preview",
|
||||
"Vorschau Hauptkapitel",
|
||||
table(
|
||||
["Bereich", "Darstellung"],
|
||||
[
|
||||
["Layoutprofil", context.report_settings.layout_profile],
|
||||
["Seitenraender", context.report_settings.page_margin],
|
||||
["Abschnittsabstaende", context.report_settings.section_spacing],
|
||||
["Tabellenlayout", context.report_settings.table_layout],
|
||||
["Tabellenschrift", context.report_settings.table_font_size],
|
||||
["Bildgroesse", context.report_settings.image_size],
|
||||
["Bildposition", context.report_settings.image_position],
|
||||
["Bilder pro Seite", context.report_settings.max_images_per_page],
|
||||
["Kopfzeile", "Ein" if context.report_settings.show_header else "Aus"],
|
||||
["Fusszeile", "Ein" if context.report_settings.show_footer else "Aus"],
|
||||
["Logo", context.report_settings.logo_size],
|
||||
["Kapitelumbruch", "Ein" if context.report_settings.page_break_before_main_chapters else "Aus"],
|
||||
],
|
||||
)
|
||||
+ (
|
||||
'<div class="figure-grid">'
|
||||
+ "".join(
|
||||
'<figure class="report-figure">'
|
||||
f'<img class="report-image" src="{text((ORION_ASSET_DIR / "schubamed-logo.svg").resolve().as_uri())}" alt="Beispielbild {index}">'
|
||||
f'<figcaption class="image-caption">Abbildung {index}: Beispielbild fuer die Layoutvorschau</figcaption>'
|
||||
"</figure>"
|
||||
for index in range(1, 5)
|
||||
)
|
||||
+ "</div>"
|
||||
),
|
||||
"1 Vorschau Hauptkapitel",
|
||||
)
|
||||
chapters = [
|
||||
CoverComponent().render(context),
|
||||
SummaryComponent().render(context),
|
||||
TocComponent([{"key": "layout-preview", "number": "1", "title": "Vorschau Hauptkapitel", "toc": True}]).render(context),
|
||||
CustomerComponent().render(context),
|
||||
preview_section,
|
||||
StaticTextComponent("results", "Ergebnisse der Validierung", "Die spaetere Ergebnisbox bleibt Bestandteil des Abschlussbereichs.", number="2").render(context),
|
||||
]
|
||||
html = render_document(context, chapters)
|
||||
return HTML(string=html, base_url=str(ORION_ASSET_DIR)).write_pdf()
|
||||
|
||||
def _components(self) -> list[ReportComponent]:
|
||||
chapters = [self._component_for_section(item) for item in REPORT_SECTIONS]
|
||||
return [CoverComponent(), SummaryComponent(), TocComponent(REPORT_SECTIONS), CustomerComponent(), *chapters]
|
||||
|
||||
def _preview_context(self, settings_override: OrionReportSettingsUpdate | None) -> ReportContext:
|
||||
if settings_override is None:
|
||||
report_settings = self._settings_clone()
|
||||
else:
|
||||
report_settings = OrionReportSettings(
|
||||
scope=OrionReportSettingsScope.global_.value,
|
||||
layout_profile=settings_override.layout_profile.value,
|
||||
show_cover_result_text=settings_override.show_cover_result_text,
|
||||
signature_mode=settings_override.signature_mode.value,
|
||||
image_size=settings_override.image_size.value,
|
||||
page_break_before_main_chapters=settings_override.page_break_before_main_chapters,
|
||||
page_margin=settings_override.page_margin.value,
|
||||
section_spacing=settings_override.section_spacing.value,
|
||||
table_layout=settings_override.table_layout.value,
|
||||
table_font_size=settings_override.table_font_size.value,
|
||||
image_position=settings_override.image_position.value,
|
||||
max_images_per_page=settings_override.max_images_per_page,
|
||||
show_image_captions=settings_override.show_image_captions,
|
||||
show_header=settings_override.show_header,
|
||||
show_footer=settings_override.show_footer,
|
||||
logo_size=settings_override.logo_size.value,
|
||||
compact_cover=settings_override.compact_cover,
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
validation = SimpleNamespace(
|
||||
id="preview-validation",
|
||||
report_number="ORION-LAYOUT-PREVIEW",
|
||||
validation_type="Layoutvorschau",
|
||||
project="Administrative Orion-Vorschau",
|
||||
performed_on=date.today(),
|
||||
test_location="Schubamed Preview",
|
||||
examiner_name="Technische Validierung",
|
||||
status="ENTWURF",
|
||||
result="BESTANDEN_MIT_AUFLAGEN",
|
||||
participants="Preview Team",
|
||||
operator_name="Preview Betreiber",
|
||||
next_validation_on=date(date.today().year + 2, date.today().month, date.today().day),
|
||||
version=1,
|
||||
updated_at=now,
|
||||
equipment_ids=[],
|
||||
environment_conditions={},
|
||||
documentation_checklist=[],
|
||||
performance_checklist=[],
|
||||
programs=[],
|
||||
loading_patterns=[],
|
||||
measurement_data=[],
|
||||
drying={},
|
||||
recommendations=[],
|
||||
attachments=[],
|
||||
)
|
||||
customer = SimpleNamespace(
|
||||
name="Demo-Praxis Orion",
|
||||
customer_type=SimpleNamespace(value="practice"),
|
||||
street="Musterstrasse 12",
|
||||
postal_code="12345",
|
||||
city="Berlin",
|
||||
phone="+49 30 123456",
|
||||
email="preview@schubamed.de",
|
||||
hygiene_officer="H. Muster",
|
||||
quality_manager="Q. Beispiel",
|
||||
)
|
||||
location = SimpleNamespace(name="Aufbereitungsraum", street="Musterstrasse 12", postal_code="12345", city="Berlin", room="AEMP")
|
||||
contact = SimpleNamespace(full_name="Dr. Petra Beispiel", function="Praxisleitung", email="kontakt@example.de", phone="+49 30 123456")
|
||||
device = SimpleNamespace(
|
||||
manufacturer="MELAG",
|
||||
model="Vacuklav 44 B+",
|
||||
serial_number="PREVIEW-12345",
|
||||
device_type="Klein-Sterilisator",
|
||||
year_built=2024,
|
||||
commissioned_on=date(2024, 5, 15),
|
||||
chamber_volume_liters=22,
|
||||
steam_generation="integriert",
|
||||
water_treatment="VE-Wasser",
|
||||
documentation="vollstaendig",
|
||||
supplier="Schubamed",
|
||||
)
|
||||
return ReportContext(
|
||||
validation=validation,
|
||||
customer=customer,
|
||||
location=location,
|
||||
contact=contact,
|
||||
device=device,
|
||||
equipment=[],
|
||||
generated_dir=self.generated_dir,
|
||||
report_sections=[],
|
||||
text_blocks={
|
||||
"summary": SimpleNamespace(
|
||||
content="Diese Vorschau zeigt die aktuell gewaehlten Orion-Berichtseinstellungen ohne Produktivdaten zu veraendern."
|
||||
)
|
||||
},
|
||||
checklist_templates=[],
|
||||
confirmed_measurements=[],
|
||||
report_settings=report_settings,
|
||||
)
|
||||
|
||||
def _settings_clone(self) -> OrionReportSettings:
|
||||
from app.services.report_settings import OrionReportSettingsService
|
||||
|
||||
saved = OrionReportSettingsService(self.session).get_global()
|
||||
return OrionReportSettings(
|
||||
scope=saved.scope,
|
||||
layout_profile=saved.layout_profile,
|
||||
show_cover_result_text=saved.show_cover_result_text,
|
||||
signature_mode=saved.signature_mode,
|
||||
image_size=saved.image_size,
|
||||
page_break_before_main_chapters=saved.page_break_before_main_chapters,
|
||||
page_margin=saved.page_margin,
|
||||
section_spacing=saved.section_spacing,
|
||||
table_layout=saved.table_layout,
|
||||
table_font_size=saved.table_font_size,
|
||||
image_position=saved.image_position,
|
||||
max_images_per_page=saved.max_images_per_page,
|
||||
show_image_captions=saved.show_image_captions,
|
||||
show_header=saved.show_header,
|
||||
show_footer=saved.show_footer,
|
||||
logo_size=saved.logo_size,
|
||||
compact_cover=saved.compact_cover,
|
||||
updated_by_user_id=saved.updated_by_user_id,
|
||||
)
|
||||
|
||||
def _component_for_section(self, item: dict) -> ReportComponent:
|
||||
component = item.get("component")
|
||||
key = str(item["key"])
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -14,6 +14,30 @@
|
|||
@bottom-left { content: element(report-footer); }
|
||||
}
|
||||
|
||||
@page cover-narrow {
|
||||
margin: 16mm 14mm 16mm 14mm;
|
||||
@top-left { content: ""; }
|
||||
@bottom-left { content: ""; }
|
||||
}
|
||||
|
||||
@page cover-wide {
|
||||
margin: 24mm 22mm 24mm 22mm;
|
||||
@top-left { content: ""; }
|
||||
@bottom-left { content: ""; }
|
||||
}
|
||||
|
||||
@page report-narrow {
|
||||
margin: 30mm 14mm 20mm 14mm;
|
||||
@top-left { content: element(report-header); }
|
||||
@bottom-left { content: element(report-footer); }
|
||||
}
|
||||
|
||||
@page report-wide {
|
||||
margin: 38mm 22mm 28mm 22mm;
|
||||
@top-left { content: element(report-header); }
|
||||
@bottom-left { content: element(report-footer); }
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
|
@ -145,6 +169,22 @@ body {
|
|||
page: report;
|
||||
}
|
||||
|
||||
.page-margin-narrow .cover-page {
|
||||
page: cover-narrow;
|
||||
}
|
||||
|
||||
.page-margin-wide .cover-page {
|
||||
page: cover-wide;
|
||||
}
|
||||
|
||||
.page-margin-narrow .report-content {
|
||||
page: report-narrow;
|
||||
}
|
||||
|
||||
.page-margin-wide .report-content {
|
||||
page: report-wide;
|
||||
}
|
||||
|
||||
.cover-top {
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
|
|
@ -159,6 +199,14 @@ body {
|
|||
width: auto;
|
||||
}
|
||||
|
||||
.logo-size-small .cover-logo {
|
||||
height: 12mm;
|
||||
}
|
||||
|
||||
.logo-size-large .cover-logo {
|
||||
height: 22mm;
|
||||
}
|
||||
|
||||
.company-address {
|
||||
color: #6B7C85;
|
||||
font-size: 8.5pt;
|
||||
|
|
@ -198,6 +246,29 @@ h1 {
|
|||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.cover-compact .cover-top {
|
||||
margin-bottom: 5mm;
|
||||
}
|
||||
|
||||
.cover-compact h1 {
|
||||
font-size: 24pt;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
.cover-compact .cover-subtitle {
|
||||
font-size: 11.2pt;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
.cover-compact .cover-page .definition-row {
|
||||
padding: .35mm 0;
|
||||
}
|
||||
|
||||
.cover-compact .cover-page .definition-list {
|
||||
font-size: 8.8pt;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.cover-closing {
|
||||
break-inside: avoid;
|
||||
margin-top: 1.5mm;
|
||||
|
|
@ -299,11 +370,18 @@ h1 {
|
|||
.signature-block {
|
||||
break-inside: avoid;
|
||||
color: #6B7C85;
|
||||
margin-top: 9mm;
|
||||
page-break-inside: avoid;
|
||||
width: 82mm;
|
||||
}
|
||||
|
||||
.signature-grid {
|
||||
break-inside: avoid;
|
||||
display: flex;
|
||||
gap: 14mm;
|
||||
margin-top: 9mm;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.signature-line {
|
||||
border-top: 1px solid #6B7C85;
|
||||
height: 0;
|
||||
|
|
@ -320,6 +398,46 @@ h1 {
|
|||
margin-top: 9mm;
|
||||
}
|
||||
|
||||
.main-chapter-breaks .report-content .chapter {
|
||||
break-before: page;
|
||||
}
|
||||
|
||||
.main-chapter-breaks .report-content .chapter:first-of-type {
|
||||
break-before: auto;
|
||||
}
|
||||
|
||||
.main-chapter-flow .report-content .chapter {
|
||||
break-before: auto;
|
||||
}
|
||||
|
||||
.section-spacing-compact h2 {
|
||||
margin-bottom: 4mm;
|
||||
padding-bottom: 2.5mm;
|
||||
}
|
||||
|
||||
.section-spacing-compact h3 {
|
||||
margin: 5mm 0 2mm 0;
|
||||
}
|
||||
|
||||
.section-spacing-compact p {
|
||||
margin-top: 2mm;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
.section-spacing-compact .chapter,
|
||||
.section-spacing-compact .chapter + .chapter {
|
||||
margin-top: 6mm;
|
||||
}
|
||||
|
||||
.section-spacing-compact .data-table {
|
||||
margin-top: 2.5mm;
|
||||
}
|
||||
|
||||
.section-spacing-compact .figure-grid {
|
||||
gap: 5mm;
|
||||
margin-top: 5mm;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: .25mm solid #DCE3E3;
|
||||
color: #2E3B40;
|
||||
|
|
@ -400,6 +518,19 @@ dd {
|
|||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.table-layout-compact .data-table {
|
||||
margin-top: 2.5mm;
|
||||
}
|
||||
|
||||
.table-layout-compact .data-table th,
|
||||
.table-layout-compact .data-table td {
|
||||
padding: 1.5mm;
|
||||
}
|
||||
|
||||
.table-font-small .data-table {
|
||||
font-size: 8.4pt;
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
counter-reset: toc;
|
||||
list-style: none;
|
||||
|
|
@ -441,6 +572,26 @@ dd {
|
|||
margin-top: 8mm;
|
||||
}
|
||||
|
||||
.image-position-stacked .figure-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.image-position-side-by-side .figure-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.image-page-max-1 .report-figure {
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.image-page-max-2 .report-figure:nth-child(2n) {
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.image-page-max-4 .report-figure:nth-child(4n) {
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.report-figure {
|
||||
break-inside: avoid;
|
||||
margin: 0;
|
||||
|
|
@ -454,6 +605,64 @@ dd {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.image-size-small .report-image {
|
||||
max-height: 55mm;
|
||||
}
|
||||
|
||||
.image-size-medium .report-image {
|
||||
max-height: 90mm;
|
||||
}
|
||||
|
||||
.image-size-large .report-image {
|
||||
max-height: 125mm;
|
||||
}
|
||||
|
||||
.image-captions-off .image-caption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-size-small .report-header-brand img {
|
||||
height: 14mm;
|
||||
}
|
||||
|
||||
.logo-size-large .report-header-brand img {
|
||||
height: 24mm;
|
||||
}
|
||||
|
||||
.layout-compact html,
|
||||
.layout-compact {
|
||||
line-height: 1.32;
|
||||
}
|
||||
|
||||
.layout-compact h2 {
|
||||
font-size: 18pt;
|
||||
margin-bottom: 4mm;
|
||||
padding-bottom: 2.5mm;
|
||||
}
|
||||
|
||||
.layout-compact h3 {
|
||||
margin: 5mm 0 2mm 0;
|
||||
}
|
||||
|
||||
.layout-compact .chapter,
|
||||
.layout-compact .chapter + .chapter {
|
||||
margin-top: 6mm;
|
||||
}
|
||||
|
||||
.layout-compact .definition-row {
|
||||
padding: 1.4mm 0;
|
||||
}
|
||||
|
||||
.layout-compact .data-table th,
|
||||
.layout-compact .data-table td {
|
||||
padding: 1.6mm;
|
||||
}
|
||||
|
||||
.layout-compact .result-box {
|
||||
margin: 2.5mm 0;
|
||||
padding: 2.4mm 5mm;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: #6B7C85;
|
||||
font-size: 8.5pt;
|
||||
|
|
|
|||
|
|
@ -8,30 +8,38 @@ from app.modules.orion.html import text
|
|||
|
||||
|
||||
def render_report_chrome(context: ReportContext, logo_uri: str) -> str:
|
||||
settings = context.report_settings
|
||||
report_number = text(context.validation.report_number)
|
||||
version = text(context.validation.version)
|
||||
report_date = text(context.validation.updated_at)
|
||||
return f"""
|
||||
<header class="report-header" aria-label="Berichtskopf">
|
||||
<div class="report-header-brand">
|
||||
<img src="{logo_uri}" alt="SCHUBAMED">
|
||||
<div>
|
||||
<strong>SCHUBAMED®</strong>
|
||||
<span>Aufbereitung mit System</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="report-header-meta">
|
||||
<div><dt>Berichtsnummer</dt><dd>{report_number}</dd></div>
|
||||
<div><dt>Version</dt><dd>{version}</dd></div>
|
||||
<div><dt>Datum</dt><dd>{report_date}</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
<footer class="report-footer" aria-label="Berichtsfuß">
|
||||
<span>Validation Suite</span>
|
||||
<span>Seite <span class="page-number"></span> von <span class="page-count"></span></span>
|
||||
<span>Version {version}</span>
|
||||
</footer>
|
||||
"""
|
||||
header = ""
|
||||
footer = ""
|
||||
if settings.show_header:
|
||||
header = f"""
|
||||
<header class="report-header" aria-label="Berichtskopf">
|
||||
<div class="report-header-brand">
|
||||
<img src="{logo_uri}" alt="SCHUBAMED">
|
||||
<div>
|
||||
<strong>SCHUBAMED®</strong>
|
||||
<span>Aufbereitung mit System</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="report-header-meta">
|
||||
<div><dt>Berichtsnummer</dt><dd>{report_number}</dd></div>
|
||||
<div><dt>Version</dt><dd>{version}</dd></div>
|
||||
<div><dt>Datum</dt><dd>{report_date}</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
"""
|
||||
if settings.show_footer:
|
||||
footer = f"""
|
||||
<footer class="report-footer" aria-label="Berichtsfuß">
|
||||
<span>Validation Suite</span>
|
||||
<span>Seite <span class="page-number"></span> von <span class="page-count"></span></span>
|
||||
<span>Version {version}</span>
|
||||
</footer>
|
||||
"""
|
||||
return header + footer
|
||||
|
||||
|
||||
def render_document(context: ReportContext, chapters: list[str]) -> str:
|
||||
|
|
@ -40,6 +48,24 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
|
|||
title = f"Validierungsbericht {context.validation.report_number}"
|
||||
cover_markup = chapters[0] if chapters else ""
|
||||
content_markup = "\n".join(chapters[1:])
|
||||
settings = context.report_settings
|
||||
body_classes = " ".join(
|
||||
[
|
||||
"orion-report",
|
||||
f"layout-{settings.layout_profile.lower()}",
|
||||
f"page-margin-{settings.page_margin.lower()}",
|
||||
f"section-spacing-{settings.section_spacing.lower()}",
|
||||
f"table-layout-{settings.table_layout.lower()}",
|
||||
f"table-font-{settings.table_font_size.lower()}",
|
||||
f"image-size-{settings.image_size.lower()}",
|
||||
f"image-position-{settings.image_position.lower().replace('_', '-')}",
|
||||
f"image-page-max-{settings.max_images_per_page}",
|
||||
f"logo-size-{settings.logo_size.lower()}",
|
||||
"image-captions-on" if settings.show_image_captions else "image-captions-off",
|
||||
"cover-compact" if settings.compact_cover else "cover-standard",
|
||||
"main-chapter-breaks" if settings.page_break_before_main_chapters else "main-chapter-flow",
|
||||
]
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
|
|
@ -48,7 +74,7 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
|
|||
<title>{text(title)}</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="{body_classes}">
|
||||
<article class="report-document">
|
||||
{cover_markup}
|
||||
<section class="report-content">
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,12 +2,23 @@ from __future__ import annotations
|
|||
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import EmailStr, Field, field_validator
|
||||
from pydantic import ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
from app.models.customer import CustomerType
|
||||
from app.models.equipment import EquipmentKind, EquipmentStatus
|
||||
from app.models.report_settings import (
|
||||
OrionImagePosition,
|
||||
OrionImageSize,
|
||||
OrionLayoutProfile,
|
||||
OrionLogoSize,
|
||||
OrionPageMargin,
|
||||
OrionSignatureMode,
|
||||
OrionSpacing,
|
||||
OrionTableFontSize,
|
||||
)
|
||||
from app.models.user import UserRole
|
||||
from app.models.validation import ValidationStatus
|
||||
from app.schemas.common import EntityRead, ORMModel
|
||||
|
|
@ -91,6 +102,32 @@ class DeviceUpdate(DeviceCreate):
|
|||
pass
|
||||
|
||||
|
||||
class OrionReportSettingsUpdate(ORMModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
layout_profile: OrionLayoutProfile
|
||||
show_cover_result_text: bool
|
||||
signature_mode: OrionSignatureMode
|
||||
image_size: OrionImageSize
|
||||
page_break_before_main_chapters: bool
|
||||
page_margin: OrionPageMargin
|
||||
section_spacing: OrionSpacing
|
||||
table_layout: OrionSpacing
|
||||
table_font_size: OrionTableFontSize
|
||||
image_position: OrionImagePosition
|
||||
max_images_per_page: Literal[1, 2, 4]
|
||||
show_image_captions: bool
|
||||
show_header: bool
|
||||
show_footer: bool
|
||||
logo_size: OrionLogoSize
|
||||
compact_cover: bool
|
||||
|
||||
|
||||
class OrionReportSettingsRead(OrionReportSettingsUpdate, EntityRead):
|
||||
scope: str
|
||||
updated_by_user_id: str | None = None
|
||||
|
||||
|
||||
class EquipmentCreate(ORMModel):
|
||||
kind: EquipmentKind
|
||||
manufacturer: str | None = None
|
||||
|
|
@ -297,3 +334,10 @@ class QuickStartCreateRequest(ORMModel):
|
|||
contact_id: UUID | None = None
|
||||
device_id: UUID | None = None
|
||||
validation_type: str
|
||||
|
||||
@field_validator("location_id", "contact_id", "device_id", mode="before")
|
||||
@classmethod
|
||||
def empty_string_to_none(cls, value):
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -61,6 +61,8 @@ class QuickStartService:
|
|||
device = self._device(str(payload.device_id)) if payload.device_id else self._single_or_default(devices)
|
||||
if device is None:
|
||||
raise ValueError("Kein Gerät verfügbar.")
|
||||
if device.customer_id != customer.id:
|
||||
raise ValueError("Gerät gehört nicht zum ausgewählten Kunden.")
|
||||
locations = list(
|
||||
self.session.scalars(
|
||||
select(Location).where(Location.customer_id == customer.id).order_by(Location.name.asc())
|
||||
|
|
@ -73,13 +75,23 @@ class QuickStartService:
|
|||
)
|
||||
location = self._location(str(payload.location_id)) if payload.location_id else self._single_or_default(locations)
|
||||
contact = self._contact(str(payload.contact_id)) if payload.contact_id else self._single_or_default(contacts)
|
||||
if location is None:
|
||||
if locations:
|
||||
raise ValueError("Bitte Standort auswählen.")
|
||||
raise ValueError("Kein Standort verfügbar.")
|
||||
if location.customer_id != customer.id:
|
||||
raise ValueError("Standort gehört nicht zum ausgewählten Kunden.")
|
||||
if device.location_id and device.location_id != location.id:
|
||||
raise ValueError("Gerät gehört nicht zum gewählten Standort.")
|
||||
if contact and contact.customer_id != customer.id:
|
||||
raise ValueError("Ansprechpartner gehört nicht zum ausgewählten Kunden.")
|
||||
|
||||
base = self._base_validation(device.id, payload.validation_type)
|
||||
bundle = ReportTemplateService(self.session).ensure_default_template()
|
||||
validation = Validation(
|
||||
report_number=self._next_report_number(),
|
||||
customer_id=customer.id,
|
||||
location_id=location.id if location else (locations[0].id if locations else None),
|
||||
location_id=location.id,
|
||||
contact_id=contact.id if contact else None,
|
||||
device_id=device.id,
|
||||
validation_type=payload.validation_type,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.report_settings import (
|
||||
OrionImagePosition,
|
||||
OrionImageSize,
|
||||
OrionLayoutProfile,
|
||||
OrionLogoSize,
|
||||
OrionPageMargin,
|
||||
OrionReportSettings,
|
||||
OrionReportSettingsScope,
|
||||
OrionSignatureMode,
|
||||
OrionSpacing,
|
||||
OrionTableFontSize,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.domain import OrionReportSettingsUpdate
|
||||
|
||||
|
||||
class OrionReportSettingsService:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def get_global(self) -> OrionReportSettings:
|
||||
settings = self.session.scalar(
|
||||
select(OrionReportSettings).where(
|
||||
OrionReportSettings.scope == OrionReportSettingsScope.global_.value
|
||||
)
|
||||
)
|
||||
if settings is not None:
|
||||
return settings
|
||||
settings = OrionReportSettings(
|
||||
scope=OrionReportSettingsScope.global_.value,
|
||||
layout_profile=OrionLayoutProfile.standard.value,
|
||||
show_cover_result_text=True,
|
||||
signature_mode=OrionSignatureMode.technical_only.value,
|
||||
image_size=OrionImageSize.medium.value,
|
||||
page_break_before_main_chapters=True,
|
||||
page_margin=OrionPageMargin.standard.value,
|
||||
section_spacing=OrionSpacing.standard.value,
|
||||
table_layout=OrionSpacing.standard.value,
|
||||
table_font_size=OrionTableFontSize.standard.value,
|
||||
image_position=OrionImagePosition.side_by_side.value,
|
||||
max_images_per_page=2,
|
||||
show_image_captions=True,
|
||||
show_header=True,
|
||||
show_footer=True,
|
||||
logo_size=OrionLogoSize.medium.value,
|
||||
compact_cover=False,
|
||||
)
|
||||
self.session.add(settings)
|
||||
self.session.flush()
|
||||
return settings
|
||||
|
||||
def update_global(self, payload: OrionReportSettingsUpdate, user: User) -> OrionReportSettings:
|
||||
settings = self.get_global()
|
||||
settings.layout_profile = payload.layout_profile.value
|
||||
settings.show_cover_result_text = payload.show_cover_result_text
|
||||
settings.signature_mode = payload.signature_mode.value
|
||||
settings.image_size = payload.image_size.value
|
||||
settings.page_break_before_main_chapters = payload.page_break_before_main_chapters
|
||||
settings.page_margin = payload.page_margin.value
|
||||
settings.section_spacing = payload.section_spacing.value
|
||||
settings.table_layout = payload.table_layout.value
|
||||
settings.table_font_size = payload.table_font_size.value
|
||||
settings.image_position = payload.image_position.value
|
||||
settings.max_images_per_page = payload.max_images_per_page
|
||||
settings.show_image_captions = payload.show_image_captions
|
||||
settings.show_header = payload.show_header
|
||||
settings.show_footer = payload.show_footer
|
||||
settings.logo_size = payload.logo_size.value
|
||||
settings.compact_cover = payload.compact_cover
|
||||
settings.updated_by_user_id = user.id
|
||||
self.session.flush()
|
||||
return settings
|
||||
Binary file not shown.
|
|
@ -25,11 +25,13 @@ from app.api.v1.auth import login as auth_login
|
|||
from app.api.v1 import domain as domain_api
|
||||
from app.schemas.auth import LoginRequest
|
||||
from app.schemas.domain import QuickStartCreateRequest, UserCreate, UserUpdate, ValidationCreate
|
||||
from app.schemas.domain import OrionReportSettingsUpdate
|
||||
from app.modules.orion.service import OrionReportService
|
||||
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
|
||||
from app.modules.orion.result import validation_result_box, validation_result_presentation
|
||||
from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
|
||||
from app.modules.helios.service import HeliosImportService
|
||||
from app.services.report_settings import OrionReportSettingsService
|
||||
|
||||
|
||||
def session() -> Session:
|
||||
|
|
@ -785,6 +787,120 @@ def test_quickstart_auto_selects_single_customer_masterdata():
|
|||
assert created.device_id == device.id
|
||||
|
||||
|
||||
def test_quickstart_requires_location_when_customer_has_multiple_locations():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
db.add(Location(customer_id=customer.id, name="Zweiter Standort"))
|
||||
admin = User(
|
||||
email="multi-location-admin@schubamed.de",
|
||||
first_name="Quickstart",
|
||||
last_name="Admin",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
with pytest.raises(ValueError, match="Bitte Standort auswählen"):
|
||||
QuickStartService(db).create_validation(
|
||||
QuickStartCreateRequest(
|
||||
customer_id=customer.id,
|
||||
device_id=device.id,
|
||||
validation_type="Erstvalidierung",
|
||||
),
|
||||
examiner=admin,
|
||||
)
|
||||
|
||||
created = QuickStartService(db).create_validation(
|
||||
QuickStartCreateRequest(
|
||||
customer_id=customer.id,
|
||||
location_id=location.id,
|
||||
device_id=device.id,
|
||||
validation_type="Erstvalidierung",
|
||||
),
|
||||
examiner=admin,
|
||||
)
|
||||
assert created.location_id == location.id
|
||||
|
||||
|
||||
def test_quickstart_rejects_foreign_location_and_contact_ids():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
other_customer = Customer(customer_type=CustomerType.practice, name="Andere Praxis")
|
||||
db.add(other_customer)
|
||||
db.flush()
|
||||
other_location = Location(customer_id=other_customer.id, name="Fremder Standort")
|
||||
other_contact = Contact(customer_id=other_customer.id, full_name="Fremder Kontakt")
|
||||
admin = User(
|
||||
email="foreign-masterdata-admin@schubamed.de",
|
||||
first_name="Quickstart",
|
||||
last_name="Admin",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add_all([other_location, other_contact, admin])
|
||||
db.flush()
|
||||
|
||||
with pytest.raises(ValueError, match="Standort gehört nicht"):
|
||||
QuickStartService(db).create_validation(
|
||||
QuickStartCreateRequest(
|
||||
customer_id=customer.id,
|
||||
location_id=other_location.id,
|
||||
device_id=device.id,
|
||||
validation_type="Erstvalidierung",
|
||||
),
|
||||
examiner=admin,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Ansprechpartner gehört nicht"):
|
||||
QuickStartService(db).create_validation(
|
||||
QuickStartCreateRequest(
|
||||
customer_id=customer.id,
|
||||
location_id=location.id,
|
||||
contact_id=other_contact.id,
|
||||
device_id=device.id,
|
||||
validation_type="Erstvalidierung",
|
||||
),
|
||||
examiner=admin,
|
||||
)
|
||||
|
||||
|
||||
def test_quickstart_allows_multiple_contacts_without_required_selection():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
db.add_all([
|
||||
Contact(customer_id=customer.id, full_name="Kontakt A"),
|
||||
Contact(customer_id=customer.id, full_name="Kontakt B"),
|
||||
])
|
||||
admin = User(
|
||||
email="optional-contact-admin@schubamed.de",
|
||||
first_name="Quickstart",
|
||||
last_name="Admin",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
created = QuickStartService(db).create_validation(
|
||||
QuickStartCreateRequest(
|
||||
customer_id=customer.id,
|
||||
location_id=location.id,
|
||||
device_id=device.id,
|
||||
validation_type="Erstvalidierung",
|
||||
),
|
||||
examiner=admin,
|
||||
)
|
||||
|
||||
assert created.contact_id is None
|
||||
|
||||
|
||||
def test_quickstart_device_last_validation_returns_latest_completed_validation():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
|
|
@ -1257,3 +1373,194 @@ def test_confirmed_measurements_appear_in_orion_report(tmp_path):
|
|||
|
||||
assert "leak_rate" in html
|
||||
assert "0,2" in html
|
||||
|
||||
|
||||
def seed_admin(db: Session) -> User:
|
||||
user = User(
|
||||
email="admin@schubamed.de",
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def report_settings_payload(**overrides) -> OrionReportSettingsUpdate:
|
||||
values = {
|
||||
"layout_profile": "STANDARD",
|
||||
"show_cover_result_text": True,
|
||||
"signature_mode": "TECHNICAL_ONLY",
|
||||
"image_size": "MEDIUM",
|
||||
"page_break_before_main_chapters": True,
|
||||
"page_margin": "STANDARD",
|
||||
"section_spacing": "STANDARD",
|
||||
"table_layout": "STANDARD",
|
||||
"table_font_size": "STANDARD",
|
||||
"image_position": "SIDE_BY_SIDE",
|
||||
"max_images_per_page": 2,
|
||||
"show_image_captions": True,
|
||||
"show_header": True,
|
||||
"show_footer": True,
|
||||
"logo_size": "MEDIUM",
|
||||
"compact_cover": False,
|
||||
}
|
||||
values.update(overrides)
|
||||
return OrionReportSettingsUpdate(**values)
|
||||
|
||||
|
||||
def test_report_settings_defaults_and_update_persist():
|
||||
db = session()
|
||||
admin = seed_admin(db)
|
||||
|
||||
settings = OrionReportSettingsService(db).get_global()
|
||||
|
||||
assert settings.scope == "GLOBAL"
|
||||
assert settings.layout_profile == "STANDARD"
|
||||
assert settings.show_cover_result_text is True
|
||||
assert settings.signature_mode == "TECHNICAL_ONLY"
|
||||
assert settings.image_size == "MEDIUM"
|
||||
assert settings.page_break_before_main_chapters is True
|
||||
assert settings.page_margin == "STANDARD"
|
||||
assert settings.section_spacing == "STANDARD"
|
||||
assert settings.table_layout == "STANDARD"
|
||||
assert settings.table_font_size == "STANDARD"
|
||||
assert settings.image_position == "SIDE_BY_SIDE"
|
||||
assert settings.max_images_per_page == 2
|
||||
assert settings.show_image_captions is True
|
||||
assert settings.show_header is True
|
||||
assert settings.show_footer is True
|
||||
assert settings.logo_size == "MEDIUM"
|
||||
assert settings.compact_cover is False
|
||||
|
||||
updated = OrionReportSettingsService(db).update_global(
|
||||
report_settings_payload(
|
||||
layout_profile="COMPACT",
|
||||
show_cover_result_text=False,
|
||||
signature_mode="TECHNICAL_AND_CLIENT",
|
||||
image_size="LARGE",
|
||||
page_break_before_main_chapters=False,
|
||||
page_margin="WIDE",
|
||||
section_spacing="COMPACT",
|
||||
table_layout="COMPACT",
|
||||
table_font_size="SMALL",
|
||||
image_position="STACKED",
|
||||
max_images_per_page=4,
|
||||
show_image_captions=False,
|
||||
show_header=False,
|
||||
show_footer=False,
|
||||
logo_size="LARGE",
|
||||
compact_cover=True,
|
||||
),
|
||||
admin,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert updated.layout_profile == "COMPACT"
|
||||
assert updated.show_cover_result_text is False
|
||||
assert updated.signature_mode == "TECHNICAL_AND_CLIENT"
|
||||
assert updated.image_size == "LARGE"
|
||||
assert updated.page_break_before_main_chapters is False
|
||||
assert updated.page_margin == "WIDE"
|
||||
assert updated.section_spacing == "COMPACT"
|
||||
assert updated.table_layout == "COMPACT"
|
||||
assert updated.table_font_size == "SMALL"
|
||||
assert updated.image_position == "STACKED"
|
||||
assert updated.max_images_per_page == 4
|
||||
assert updated.show_image_captions is False
|
||||
assert updated.show_header is False
|
||||
assert updated.show_footer is False
|
||||
assert updated.logo_size == "LARGE"
|
||||
assert updated.compact_cover is True
|
||||
assert updated.updated_by_user_id == admin.id
|
||||
|
||||
|
||||
def test_report_settings_invalid_enum_is_rejected():
|
||||
with pytest.raises(Exception):
|
||||
report_settings_payload(
|
||||
layout_profile="WILD",
|
||||
)
|
||||
|
||||
|
||||
def test_orion_settings_preview_pdf_contains_pdf_bytes(tmp_path):
|
||||
db = session()
|
||||
seed_admin(db)
|
||||
|
||||
pdf = OrionReportService(db, tmp_path).render_settings_preview_pdf(
|
||||
report_settings_payload(
|
||||
layout_profile="COMPACT",
|
||||
show_cover_result_text=True,
|
||||
signature_mode="TECHNICAL_AND_CLIENT",
|
||||
image_size="SMALL",
|
||||
page_break_before_main_chapters=False,
|
||||
page_margin="NARROW",
|
||||
section_spacing="COMPACT",
|
||||
table_layout="COMPACT",
|
||||
table_font_size="SMALL",
|
||||
image_position="STACKED",
|
||||
max_images_per_page=1,
|
||||
show_image_captions=False,
|
||||
show_header=False,
|
||||
show_footer=False,
|
||||
logo_size="SMALL",
|
||||
compact_cover=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert pdf.startswith(b"%PDF")
|
||||
assert len(pdf) > 1024
|
||||
|
||||
|
||||
def test_orion_report_settings_affect_html_without_removing_summary_result(tmp_path):
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
validation = valid_validation(customer, location, device)
|
||||
validation.result = "BESTANDEN"
|
||||
db.add(validation)
|
||||
admin = seed_admin(db)
|
||||
OrionReportSettingsService(db).update_global(
|
||||
report_settings_payload(
|
||||
layout_profile="COMPACT",
|
||||
show_cover_result_text=False,
|
||||
signature_mode="TECHNICAL_AND_CLIENT",
|
||||
image_size="SMALL",
|
||||
page_break_before_main_chapters=False,
|
||||
page_margin="NARROW",
|
||||
section_spacing="COMPACT",
|
||||
table_layout="COMPACT",
|
||||
table_font_size="SMALL",
|
||||
image_position="STACKED",
|
||||
max_images_per_page=1,
|
||||
show_image_captions=False,
|
||||
show_header=False,
|
||||
show_footer=False,
|
||||
logo_size="SMALL",
|
||||
compact_cover=True,
|
||||
),
|
||||
admin,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
||||
|
||||
assert "layout-compact" in html
|
||||
assert "page-margin-narrow" in html
|
||||
assert "section-spacing-compact" in html
|
||||
assert "table-layout-compact" in html
|
||||
assert "table-font-small" in html
|
||||
assert "image-size-small" in html
|
||||
assert "image-position-stacked" in html
|
||||
assert "image-page-max-1" in html
|
||||
assert "image-captions-off" in html
|
||||
assert "logo-size-small" in html
|
||||
assert "cover-compact" in html
|
||||
assert "main-chapter-flow" in html
|
||||
assert "Ergebnis der Validierung" not in html.split('<section class="report-content">')[0]
|
||||
assert "Unterschrift Auftraggeber" in html
|
||||
assert '<header class="report-header"' not in html
|
||||
assert '<footer class="report-footer"' not in html
|
||||
assert html.count("result-box") >= 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue