Compare commits

..

No commits in common. "main" and "v2.7.2" have entirely different histories.
main ... v2.7.2

28904 changed files with 136 additions and 4363497 deletions

View file

@ -1,4 +0,0 @@
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_NAME=atlas_access_token
AUTH_COOKIE_SAMESITE=lax
MERCURY_INTERNAL_URL=http://mercury-api:8000

View file

@ -6,7 +6,3 @@ JWT_SECRET=replace-this-secret
ADMIN_EMAIL=admin@schubamed.de ADMIN_EMAIL=admin@schubamed.de
ADMIN_PASSWORD=ValidationSuite!2026 ADMIN_PASSWORD=ValidationSuite!2026
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1 NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1
AUTH_COOKIE_NAME=atlas_access_token
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=lax
MERCURY_INTERNAL_URL=http://mercury-api:8000

View file

@ -7,7 +7,7 @@ from sqlalchemy import engine_from_config, pool
from app.core.config import settings from app.core.config import settings
from app.db.base import Base from app.db.base import Base
from app.models import contact, customer, device, document, equipment, location, program, report_settings, user, validation from app.models import contact, customer, device, document, equipment, location, program, user, validation
config = context.config config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)
@ -45,3 +45,4 @@ if context.is_offline_mode():
run_migrations_offline() run_migrations_offline()
else: else:
run_migrations_online() run_migrations_online()

View file

@ -1,36 +0,0 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110004"
down_revision = "202607110003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("first_name", sa.String(length=80), nullable=True))
op.add_column("users", sa.Column("last_name", sa.String(length=80), nullable=True))
op.add_column("users", sa.Column("must_change_password", sa.Boolean(), nullable=False, server_default=sa.true()))
op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("password_changed_at", sa.DateTime(timezone=True), nullable=True))
op.execute(
sa.text(
"UPDATE users SET first_name = COALESCE(first_name, split_part(full_name, ' ', 1)), "
"last_name = COALESCE(last_name, NULLIF(substr(full_name, length(split_part(full_name, ' ', 1)) + 2), ''))"
)
)
op.execute(sa.text("UPDATE users SET first_name = COALESCE(first_name, 'Validation'), last_name = COALESCE(last_name, 'Admin')"))
op.alter_column("users", "first_name", nullable=False)
op.alter_column("users", "last_name", nullable=False)
op.alter_column("users", "must_change_password", server_default=None)
def downgrade() -> None:
op.drop_column("users", "password_changed_at")
op.drop_column("users", "last_login_at")
op.drop_column("users", "must_change_password")
op.drop_column("users", "last_name")
op.drop_column("users", "first_name")

View file

@ -1,21 +0,0 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110005"
down_revision = "202607110004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("contacts", sa.Column("notes", sa.String(length=500), nullable=True))
op.add_column("devices", sa.Column("notes", sa.Text(), nullable=True))
op.add_column("equipment", sa.Column("notes", sa.String(length=500), nullable=True))
def downgrade() -> None:
op.drop_column("equipment", "notes")
op.drop_column("devices", "notes")
op.drop_column("contacts", "notes")

View file

@ -1,108 +0,0 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110006"
down_revision = "202607110005"
branch_labels = None
depends_on = None
ALLOWED_ROLES = ("admin", "pruefer", "mitarbeiter", "leser")
def _other_columns_use_userrole(connection) -> bool:
result = connection.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_type t ON t.oid = a.atttypid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE t.typname = 'userrole'
AND NOT (n.nspname = 'public' AND c.relname = 'users' AND a.attname = 'role')
AND a.attnum > 0
AND NOT a.attisdropped
)
"""
)
)
return bool(result.scalar())
def _normalize_roles(connection) -> None:
connection.execute(
sa.text(
"""
UPDATE users
SET role = CASE upper(role::text)
WHEN 'ADMIN' THEN 'admin'
WHEN 'PRUEFER' THEN 'pruefer'
WHEN 'EMPLOYEE' THEN 'mitarbeiter'
WHEN 'MITARBEITER' THEN 'mitarbeiter'
WHEN 'AUDITOR' THEN 'leser'
WHEN 'LESER' THEN 'leser'
ELSE role::text
END
"""
)
)
invalid_roles = connection.execute(
sa.text(
"""
SELECT DISTINCT role::text
FROM users
WHERE role::text NOT IN ('admin', 'pruefer', 'mitarbeiter', 'leser')
"""
),
).scalars().all()
if invalid_roles:
raise RuntimeError(f"Unknown user roles encountered during migration: {', '.join(sorted(invalid_roles))}")
def upgrade() -> None:
connection = op.get_bind()
op.alter_column(
"users",
"role",
existing_type=sa.Enum(name="userrole"),
type_=sa.String(length=50),
existing_nullable=False,
existing_server_default=None,
postgresql_using="role::text",
nullable=False,
)
_normalize_roles(connection)
op.execute(sa.text("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'mitarbeiter'"))
if not _other_columns_use_userrole(connection):
op.execute(sa.text("DROP TYPE IF EXISTS userrole"))
def downgrade() -> None:
connection = op.get_bind()
op.execute(
sa.text(
"CREATE TYPE userrole AS ENUM ('admin', 'pruefer', 'mitarbeiter', 'leser')"
)
)
invalid_roles = connection.execute(
sa.text(
"""
SELECT DISTINCT role
FROM users
WHERE role NOT IN ('admin', 'pruefer', 'mitarbeiter', 'leser')
"""
),
).scalars().all()
if invalid_roles:
raise RuntimeError(f"Cannot downgrade user roles with invalid values: {', '.join(sorted(invalid_roles))}")
op.alter_column(
"users",
"role",
existing_type=sa.String(length=50),
type_=sa.Enum("admin", "pruefer", "mitarbeiter", "leser", name="userrole"),
nullable=False,
)
op.execute(sa.text("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'mitarbeiter'::userrole"))

View file

@ -1,25 +0,0 @@
"""add deleted_at to users for soft delete
Revision ID: 202607110007
Revises: 202607110006
Create Date: 2026-07-11 00:07:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110007"
down_revision = "202607110006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("users", "deleted_at")

View file

@ -1,66 +0,0 @@
"""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")

View file

@ -1,59 +0,0 @@
"""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")

View file

@ -1,38 +0,0 @@
"""customer external reference
Revision ID: 202607120003
Revises: 202607120002
Create Date: 2026-07-12 14:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607120003"
down_revision = "202607120002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("customers", sa.Column("source_system", sa.String(length=80), nullable=True))
op.add_column("customers", sa.Column("external_id", sa.String(length=120), nullable=True))
op.create_index(op.f("ix_customers_source_system"), "customers", ["source_system"], unique=False)
op.create_index(op.f("ix_customers_external_id"), "customers", ["external_id"], unique=False)
op.create_index(
"ix_customers_source_system_external_id",
"customers",
["source_system", "external_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_customers_source_system_external_id", table_name="customers")
op.drop_index(op.f("ix_customers_external_id"), table_name="customers")
op.drop_index(op.f("ix_customers_source_system"), table_name="customers")
op.drop_column("customers", "external_id")
op.drop_column("customers", "source_system")

View file

@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.db.session import get_session from app.db.session import get_session
from app.models.user import User, UserRole from app.models.user import User
bearer = HTTPBearer() bearer = HTTPBearer()
@ -30,17 +30,3 @@ def current_user(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
return user return user
def require_role(*roles: UserRole):
def dependency(user: User = Depends(current_user)) -> User:
if user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return user
return dependency
def current_admin(user: User = Depends(current_user)) -> User:
if user.role != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return user

View file

@ -1,53 +1,24 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.dependencies import current_user from app.api.dependencies import current_user
from app.db.session import get_session from app.db.session import get_session
from app.models.user import User from app.models.user import User
from app.core.security import hash_password, verify_password from app.schemas.auth import LoginRequest, TokenResponse, UserRead
from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserRead
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.core.config import settings
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse: def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse:
token, user = AuthService(session).login(payload.email, payload.password) token = AuthService(session).login(payload.email, payload.password)
return TokenResponse( return TokenResponse(access_token=token)
access_token=token,
expires_in=settings.access_token_minutes * 60,
user=user,
)
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
def me(user: User = Depends(current_user)) -> User: def me(user: User = Depends(current_user)) -> User:
return user return user
@router.post("/change-password")
def change_password(
payload: ChangePasswordRequest,
user: User = Depends(current_user),
session: Session = Depends(get_session),
) -> dict[str, str]:
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status_code=422, detail="Das aktuelle Passwort ist falsch.")
if payload.new_password != payload.new_password_confirmation:
raise HTTPException(status_code=422, detail="Die neuen Passwoerter stimmen nicht ueberein.")
if len(payload.new_password) < 12:
raise HTTPException(status_code=422, detail="Das neue Passwort muss mindestens 12 Zeichen haben.")
if verify_password(payload.new_password, user.password_hash):
raise HTTPException(status_code=422, detail="Das neue Passwort darf nicht identisch zum alten sein.")
user.password_hash = hash_password(payload.new_password)
user.must_change_password = False
user.password_changed_at = datetime.now(UTC)
session.commit()
return {"status": "ok"}

View file

@ -6,24 +6,21 @@ import json
import logging import logging
import shutil import shutil
import uuid import uuid
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.dependencies import current_admin, current_user from app.api.dependencies import current_user
from app.db.session import get_session from app.db.session import get_session
from app.models.contact import Contact from app.models.contact import Contact
from app.models.customer import Customer from app.models.customer import Customer
from app.models.device import Device from app.models.device import Device
from app.models.equipment import Equipment from app.models.equipment import Equipment
from app.models.location import Location from app.models.location import Location
from app.models.user import User, UserRole
from app.models.validation import Validation from app.models.validation import Validation
from app.modules.helios.service import HeliosImportService from app.modules.helios.service import HeliosImportService
from app.modules.orion.service import OrionReportService from app.modules.orion.service import OrionReportService
@ -53,20 +50,8 @@ from app.schemas.domain import (
ValidationRead, ValidationRead,
ValidationReview, ValidationReview,
ValidationUpdate, ValidationUpdate,
QuickStartCreateRequest,
QuickStartCustomerData,
QuickStartValidationSummary,
OrionReportSettingsRead,
OrionReportSettingsUpdate,
UserCreate,
UserRead,
UserUpdate,
UserPasswordResetRequest,
) )
from app.services.domain_service import CrudService, DomainServices from app.services.domain_service import CrudService, DomainServices
from app.services.customer_import import CustomerImportService, parse_json_object
from app.services.quickstart_service import QuickStartService
from app.services.report_settings import OrionReportSettingsService
from app.services.validation_workflow import ValidationWorkflowService from app.services.validation_workflow import ValidationWorkflowService
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)]) router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
@ -91,71 +76,9 @@ 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") @router.get("/dashboard")
def dashboard(session: Session = Depends(get_session)) -> dict[str, int]: def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
today = func.current_date() today = func.current_date()
result_groups = {
"validation_result_open": ["OFFEN", "offen", None, ""],
"validation_result_passed": ["BESTANDEN", "bestanden"],
"validation_result_conditional": ["BESTANDEN_MIT_AUFLAGEN", "bestanden_mit_auflagen", "mit_auflagen", "bestanden mit Auflagen"],
"validation_result_failed": ["NICHT_BESTANDEN", "nicht_bestanden", "nicht bestanden"],
}
result_counts = {
key: session.scalar(select(func.count()).select_from(Validation).where(Validation.result.in_(values)))
or 0
for key, values in result_groups.items()
if None not in values
}
result_counts["validation_result_open"] = (
session.scalar(
select(func.count())
.select_from(Validation)
.where((Validation.result.in_(["OFFEN", "offen", ""])) | (Validation.result.is_(None)))
)
or 0
)
return { return {
"customers": session.scalar(select(func.count()).select_from(Customer)) or 0, "customers": session.scalar(select(func.count()).select_from(Customer)) or 0,
"locations": session.scalar(select(func.count()).select_from(Location)) or 0, "locations": session.scalar(select(func.count()).select_from(Location)) or 0,
@ -203,7 +126,6 @@ def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
.where(Validation.next_validation_on < today) .where(Validation.next_validation_on < today)
) )
or 0, or 0,
**result_counts,
} }
@ -215,51 +137,6 @@ def paging(
return {"page": page, "page_size": page_size, "search": search} return {"page": page, "page_size": page_size, "search": search}
@router.post("/customer-imports/preview")
async def preview_customer_import(
file: UploadFile,
mapping: str | None = Form(default=None),
session: Session = Depends(get_session),
_: User = Depends(current_admin),
):
try:
content = await file.read()
parsed_mapping = parse_json_object(mapping) if mapping else None
return CustomerImportService(session).preview(file.filename or "import", content, parsed_mapping)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except UnicodeDecodeError as exc:
raise HTTPException(status_code=422, detail="Die CSV-Datei muss UTF-8 kodiert sein.") from exc
@router.post("/customer-imports/confirm")
async def confirm_customer_import(
file: UploadFile,
mapping: str = Form(...),
row_actions: str | None = Form(default=None),
ignore_empty_values: bool = Form(default=True),
session: Session = Depends(get_session),
user: User = Depends(current_admin),
):
try:
content = await file.read()
result = CustomerImportService(session).confirm(
file.filename or "import",
content,
parse_json_object(mapping),
parse_json_object(row_actions),
ignore_empty_values,
user,
)
return result
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=422, detail=str(exc)) from exc
except IntegrityError as exc:
session.rollback()
raise HTTPException(status_code=409, detail="Der Kundenimport konnte nicht vollständig gespeichert werden.") from exc
def commit_create(session: Session, service: CrudService, payload): def commit_create(session: Session, service: CrudService, payload):
try: try:
item = service.create(payload.model_dump()) item = service.create(payload.model_dump())
@ -320,14 +197,6 @@ def delete_customer(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).customers, item_id) return commit_delete(session, DomainServices(session).customers, item_id)
@router.get("/customers/{item_id}/quickstart-data", response_model=QuickStartCustomerData)
def customer_quickstart_data(item_id: str, session: Session = Depends(get_session)):
try:
return QuickStartService(session).customer_data(item_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/locations", response_model=PaginatedResponse[LocationRead]) @router.get("/locations", response_model=PaginatedResponse[LocationRead])
def list_locations(params: dict = Depends(paging), session: Session = Depends(get_session)): def list_locations(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).locations.list(**params) return DomainServices(session).locations.list(**params)
@ -462,35 +331,11 @@ def get_validation(item_id: str, session: Session = Depends(get_session)):
return item return item
@router.get("/devices/{item_id}/last-validation", response_model=QuickStartValidationSummary | None)
def last_device_validation(item_id: str, session: Session = Depends(get_session)):
try:
return QuickStartService(session).device_last_validation(item_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/validations", response_model=ValidationRead, status_code=201) @router.post("/validations", response_model=ValidationRead, status_code=201)
def create_validation(payload: ValidationCreate, session: Session = Depends(get_session)): def create_validation(payload: ValidationCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).validations, payload) return commit_create(session, DomainServices(session).validations, payload)
@router.post("/validations/quick-start", response_model=ValidationRead, status_code=201)
def create_quick_start_validation(
payload: QuickStartCreateRequest,
session: Session = Depends(get_session),
me: User = Depends(current_user),
):
try:
validation = QuickStartService(session).create_validation(payload, me)
session.commit()
session.refresh(validation)
return validation
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.put("/validations/{item_id}", response_model=ValidationRead) @router.put("/validations/{item_id}", response_model=ValidationRead)
def update_validation( def update_validation(
item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session) item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session)
@ -701,192 +546,3 @@ def validation_report_pdf(item_id: str, session: Session = Depends(get_session))
media_type="application/pdf", media_type="application/pdf",
filename=report_path.name, filename=report_path.name,
) )
def _user_payload(user: User) -> dict:
return {
"id": user.id,
"created_at": user.created_at,
"updated_at": user.updated_at,
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"role": UserRole(user.role),
"is_active": user.is_active,
"must_change_password": user.must_change_password,
"last_login_at": user.last_login_at,
"password_changed_at": user.password_changed_at,
}
@router.get("/users", response_model=PaginatedResponse[UserRead])
def list_users(
session: Session = Depends(get_session),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str | None = Query(default=None),
role: str | None = Query(default=None),
active: bool | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_order: str = Query(default="desc"),
_: User = Depends(current_admin),
):
query = select(User)
query = query.where(User.deleted_at.is_(None))
if search:
term = f"%{search.strip()}%"
query = query.where(
(User.first_name.ilike(term))
| (User.last_name.ilike(term))
| (User.email.ilike(term))
)
if role:
query = query.where(User.role == role)
if active is not None:
query = query.where(User.is_active.is_(active))
sort_columns = {
"name": User.last_name,
"email": User.email,
"role": User.role,
"is_active": User.is_active,
"last_login_at": User.last_login_at,
"password_changed_at": User.password_changed_at,
"created_at": User.created_at,
"updated_at": User.updated_at,
}
sort_column = sort_columns.get(sort_by, User.created_at)
query = query.order_by(sort_column.asc() if sort_order.lower() == "asc" else sort_column.desc())
total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
pages = max((total + page_size - 1) // page_size, 1)
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages}
@router.post("/users", status_code=201)
def create_user(payload: UserCreate, session: Session = Depends(get_session), _: User = Depends(current_admin)):
from app.core.security import hash_password
import secrets
from app.models.user import User
normalized_email = payload.email.strip().lower()
logger.debug("create_user request email=%s normalized_email=%s", payload.email, normalized_email)
existing = session.scalar(select(User).where(func.lower(User.email) == normalized_email))
logger.debug("create_user select result=%s count=%s", bool(existing), 1 if existing else 0)
if existing is not None:
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.")
temporary_password = payload.temporary_password or secrets.token_urlsafe(12)
try:
user = User(
email=normalized_email,
first_name=payload.first_name,
last_name=payload.last_name,
role=payload.role.value,
password_hash=hash_password(temporary_password),
is_active=payload.is_active,
must_change_password=payload.must_change_password,
)
logger.debug("create_user insert email=%s", normalized_email)
session.add(user)
session.commit()
logger.debug("create_user commit ok email=%s id=%s", normalized_email, user.id)
session.refresh(user)
logger.debug("create_user response email=%s status=201", normalized_email)
return JSONResponse(
content={"temporary_password": temporary_password, "user": jsonable_encoder(user)},
status_code=201,
)
except IntegrityError as exc:
session.rollback()
logger.exception("create_user integrity error email=%s", normalized_email)
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.") from exc
except ProgrammingError as exc:
session.rollback()
raise HTTPException(status_code=422, detail="Die Benutzerrolle ist ungültig.") from exc
@router.get("/users/{item_id}", response_model=UserRead)
def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found")
return user
@router.put("/users/{item_id}", response_model=UserRead)
def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id and not payload.is_active:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
active_admins = session.scalar(
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1 and (not payload.is_active or payload.role != UserRole.ADMIN.value):
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert oder herabgestuft werden.")
try:
normalized_email = payload.email.strip().lower()
logger.debug("update_user request id=%s email=%s normalized_email=%s", item_id, payload.email, normalized_email)
existing = session.scalar(
select(User).where(func.lower(User.email) == normalized_email, User.id != user.id)
)
logger.debug("update_user select result=%s count=%s", bool(existing), 1 if existing else 0)
if existing is not None:
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.")
user.first_name = payload.first_name
user.last_name = payload.last_name
user.email = normalized_email
user.role = payload.role.value
user.is_active = payload.is_active
user.must_change_password = payload.must_change_password
logger.debug("update_user commit start id=%s email=%s", user.id, normalized_email)
session.commit()
logger.debug("update_user commit ok id=%s email=%s", user.id, normalized_email)
session.refresh(user)
return user
except IntegrityError as exc:
session.rollback()
logger.exception("update_user integrity error id=%s email=%s", item_id, payload.email)
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.") from exc
except ProgrammingError as exc:
session.rollback()
raise HTTPException(status_code=422, detail="Die Benutzerrolle ist ungültig.") from exc
@router.delete("/users/{item_id}", status_code=204)
def delete_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id:
raise HTTPException(status_code=409, detail="Das eigene Benutzerkonto kann nicht geloescht werden.")
active_admins = session.scalar(
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1:
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht geloescht werden.")
referenced_validations = session.scalar(
select(func.count()).select_from(Validation).where(Validation.examiner_id == user.id)
) or 0
if referenced_validations == 0:
session.delete(user)
session.commit()
return Response(status_code=204)
user.is_active = False
user.deleted_at = datetime.now(UTC)
session.commit()
return Response(status_code=204)
@router.post("/users/{item_id}/reset-password")
def reset_user_password(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
import secrets
from app.core.security import hash_password
user = session.get(User, item_id)
if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found")
temporary_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(temporary_password)
user.must_change_password = True
session.commit()
return JSONResponse(content={"temporary_password": temporary_password})

View file

@ -1,138 +0,0 @@
from __future__ import annotations
import argparse
import json
import sys
from getpass import getpass
from pathlib import Path
from app.db.session import SessionLocal
from app.models.user import User
from app.services.demo_data import DemoDataService
from app.services.reference_masterdata import ReferenceMasterdataImportService
def cmd_import_reference_masterdata(args: argparse.Namespace) -> int:
with SessionLocal() as session:
service = ReferenceMasterdataImportService(session)
result = service.import_reference_docx(
Path(args.reference),
dry_run=args.dry_run,
update_existing=args.update_existing,
create_validation=args.create_validation,
)
payload = result.as_dict()
if args.json_summary:
print(json.dumps(payload, ensure_ascii=False, default=str))
else:
for label in ["created", "updated", "unchanged", "conflicts", "errors"]:
print(f"{label}: {len(payload[label])}")
for item in payload[label]:
print(f" - {item}")
if result.validation_id:
print(f"validation_id: {result.validation_id}")
return 0
def cmd_reset_password(args: argparse.Namespace) -> int:
from app.core.security import hash_password
from sqlalchemy import select
with SessionLocal() as session:
user = session.scalar(select(User).where(User.email == args.email.lower()))
if user is None:
raise SystemExit(f"User not found: {args.email}")
first = getpass("New password: ")
second = getpass("Repeat password: ")
if first != second:
raise SystemExit("Passwords do not match")
user.password_hash = hash_password(first)
session.commit()
print("Password updated")
return 0
def cmd_create_admin(_: argparse.Namespace) -> int:
from app.core.security import hash_password
from app.models.user import UserRole
from sqlalchemy import select
email = input("E-Mail: ").strip().lower()
first_name = input("Vorname: ").strip()
last_name = input("Nachname: ").strip()
password = getpass("Passwort: ")
password_confirmation = getpass("Passwort bestätigen: ")
if password != password_confirmation:
raise SystemExit("Passwords do not match")
with SessionLocal() as session:
existing = session.scalar(select(User).where(User.email == email))
if existing is not None:
raise SystemExit("User already exists")
session.add(
User(
email=email,
first_name=first_name,
last_name=last_name,
role=UserRole.ADMIN.value,
password_hash=hash_password(password),
is_active=True,
must_change_password=False,
)
)
session.commit()
print("Admin user created")
return 0
def cmd_demo_data(args: argparse.Namespace) -> int:
with SessionLocal() as session:
summary = DemoDataService(session).run(
customers=args.customers,
devices=args.devices,
validations=args.validations,
reports=args.reports,
images=args.images,
reset=args.reset,
)
print(json.dumps(summary, ensure_ascii=False))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="python -m app.cli")
subparsers = parser.add_subparsers(dest="command", required=True)
import_parser = subparsers.add_parser("import-reference-masterdata")
import_parser.add_argument("--reference", required=True)
import_parser.add_argument("--dry-run", action="store_true")
import_parser.add_argument("--update-existing", action="store_true")
import_parser.add_argument("--create-validation", action="store_true")
import_parser.add_argument("--json-summary", action="store_true")
import_parser.set_defaults(func=cmd_import_reference_masterdata)
reset_parser = subparsers.add_parser("reset-password")
reset_parser.add_argument("--email", required=True)
reset_parser.set_defaults(func=cmd_reset_password)
admin_parser = subparsers.add_parser("create-admin")
admin_parser.set_defaults(func=cmd_create_admin)
demo_parser = subparsers.add_parser("demo-data")
demo_parser.add_argument("--customers", type=int, default=10)
demo_parser.add_argument("--devices", type=int, default=10)
demo_parser.add_argument("--validations", type=int, default=20)
demo_parser.add_argument("--reports", action="store_true")
demo_parser.add_argument("--images", action="store_true")
demo_parser.add_argument("--reset", action="store_true")
demo_parser.set_defaults(func=cmd_demo_data)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -18,5 +18,6 @@ def verify_password(password: str, password_hash: str) -> bool:
def create_access_token(subject: str, role: str) -> str: def create_access_token(subject: str, role: str) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes) expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes)
payload = {"sub": subject, "role": role, "iss": settings.app_name, "iat": datetime.now(UTC), "type": "access", "exp": expires_at} payload = {"sub": subject, "role": role, "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)

View file

@ -16,12 +16,10 @@ def seed_admin() -> None:
session.add( session.add(
User( User(
email=settings.admin_email.lower(), email=settings.admin_email.lower(),
first_name="Validation Suite", full_name="Validation Suite Administrator",
last_name="Administrator", role=UserRole.admin,
role=UserRole.ADMIN.value,
password_hash=hash_password(settings.admin_password), password_hash=hash_password(settings.admin_password),
is_active=True, is_active=True,
must_change_password=False,
) )
) )
session.commit() session.commit()
@ -29,3 +27,4 @@ def seed_admin() -> None:
if __name__ == "__main__": if __name__ == "__main__":
seed_admin() seed_admin()

View file

@ -13,7 +13,6 @@ from app.models.report_template import (
ReportTemplate, ReportTemplate,
TextBlock, TextBlock,
) )
from app.models.report_settings import OrionReportSettings
from app.models.user import User from app.models.user import User
from app.models.validation import Validation from app.models.validation import Validation
@ -30,7 +29,6 @@ __all__ = [
"MeasurementImportValue", "MeasurementImportValue",
"ReportSection", "ReportSection",
"ReportTemplate", "ReportTemplate",
"OrionReportSettings",
"TextBlock", "TextBlock",
"User", "User",
"Validation", "Validation",

View file

@ -14,6 +14,6 @@ class Contact(Base, UUIDMixin, TimestampMixin):
function: Mapped[str | None] = mapped_column(String(120)) function: Mapped[str | None] = mapped_column(String(120))
email: Mapped[str | None] = mapped_column(String(255)) email: Mapped[str | None] = mapped_column(String(255))
phone: Mapped[str | None] = mapped_column(String(80)) phone: Mapped[str | None] = mapped_column(String(80))
notes: Mapped[str | None] = mapped_column(String(500))
customer: Mapped["Customer"] = relationship(back_populates="contacts") customer: Mapped["Customer"] = relationship(back_populates="contacts")

View file

@ -17,8 +17,6 @@ class Customer(Base, UUIDMixin, TimestampMixin):
__tablename__ = "customers" __tablename__ = "customers"
customer_type: Mapped[CustomerType] = mapped_column(Enum(CustomerType)) customer_type: Mapped[CustomerType] = mapped_column(Enum(CustomerType))
source_system: Mapped[str | None] = mapped_column(String(80), index=True)
external_id: Mapped[str | None] = mapped_column(String(120), index=True)
name: Mapped[str] = mapped_column(String(220), index=True) name: Mapped[str] = mapped_column(String(220), index=True)
street: Mapped[str | None] = mapped_column(String(220)) street: Mapped[str | None] = mapped_column(String(220))
postal_code: Mapped[str | None] = mapped_column(String(20)) postal_code: Mapped[str | None] = mapped_column(String(20))
@ -31,3 +29,4 @@ class Customer(Base, UUIDMixin, TimestampMixin):
contacts: Mapped[list["Contact"]] = relationship(back_populates="customer", cascade="all, delete-orphan") contacts: Mapped[list["Contact"]] = relationship(back_populates="customer", cascade="all, delete-orphan")
locations: Mapped[list["Location"]] = relationship(back_populates="customer", cascade="all, delete-orphan") locations: Mapped[list["Location"]] = relationship(back_populates="customer", cascade="all, delete-orphan")

View file

@ -24,6 +24,6 @@ class Device(Base, UUIDMixin, TimestampMixin):
water_treatment: Mapped[str | None] = mapped_column(String(220)) water_treatment: Mapped[str | None] = mapped_column(String(220))
documentation: Mapped[str | None] = mapped_column(Text) documentation: Mapped[str | None] = mapped_column(Text)
supplier: Mapped[str | None] = mapped_column(String(180)) supplier: Mapped[str | None] = mapped_column(String(180))
notes: Mapped[str | None] = mapped_column(Text)
location: Mapped["Location | None"] = relationship(back_populates="devices") location: Mapped["Location | None"] = relationship(back_populates="devices")

View file

@ -32,4 +32,4 @@ class Equipment(Base, UUIDMixin, TimestampMixin):
calibration_due_on: Mapped[date | None] = mapped_column(Date) calibration_due_on: Mapped[date | None] = mapped_column(Date)
certificate_document_id: Mapped[str | None] = mapped_column(String(80)) certificate_document_id: Mapped[str | None] = mapped_column(String(80))
status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green) status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green)
notes: Mapped[str | None] = mapped_column(String(500))

View file

@ -1,78 +0,0 @@
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)

View file

@ -1,40 +1,25 @@
from __future__ import annotations from __future__ import annotations
import enum import enum
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String from sqlalchemy import Boolean, Enum, String
from sqlalchemy.orm import Mapped, mapped_column, validates from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin from app.db.base import Base, TimestampMixin, UUIDMixin
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):
ADMIN = "admin" admin = "admin"
PRUEFER = "pruefer" employee = "employee"
MITARBEITER = "mitarbeiter" auditor = "auditor"
LESER = "leser"
class User(Base, UUIDMixin, TimestampMixin): class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = "users" __tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True, index=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
full_name: Mapped[str] = mapped_column(String(160), nullable=False) full_name: Mapped[str] = mapped_column(String(160))
first_name: Mapped[str] = mapped_column(String(80)) role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.employee)
last_name: Mapped[str] = mapped_column(String(80))
role: Mapped[str] = mapped_column(String(50), nullable=False, default=UserRole.MITARBEITER.value)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@validates("first_name", "last_name")
def _sync_full_name(self, key: str, value: str) -> str:
first_name = value if key == "first_name" else getattr(self, "first_name", None)
last_name = value if key == "last_name" else getattr(self, "last_name", None)
if first_name is not None and last_name is not None:
self.full_name = f"{first_name} {last_name}".strip()
return value

View file

@ -8,7 +8,6 @@ from app.modules.orion.assets import schubamed_logo_uri
from app.modules.orion.components.base import ReportComponent from app.modules.orion.components.base import ReportComponent
from app.modules.orion.context import ReportContext from app.modules.orion.context import ReportContext
from app.modules.orion.html import definition_list, paragraph, section, table, text, yes_no from app.modules.orion.html import definition_list, paragraph, section, table, text, yes_no
from app.modules.orion.result import validation_result_box, validation_result_cover_sentence, validation_result_label
def render_template_text(content: str, context: ReportContext) -> str: def render_template_text(content: str, context: ReportContext) -> str:
@ -64,6 +63,7 @@ class CoverComponent(ReportComponent):
("Pruefdatum", validation.performed_on), ("Pruefdatum", validation.performed_on),
("Pruefungsort", validation.test_location), ("Pruefungsort", validation.test_location),
("Pruefer", validation.examiner_name), ("Pruefer", validation.examiner_name),
("Gesamtergebnis", validation.result),
("Status", validation.status), ("Status", validation.status),
( (
"Ansprechpartner", "Ansprechpartner",
@ -83,33 +83,10 @@ class CoverComponent(ReportComponent):
'<p class="cover-subtitle">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>' '<p class="cover-subtitle">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>'
f'<p class="cover-subtitle">{text(context.customer.name)}</p>' f'<p class="cover-subtitle">{text(context.customer.name)}</p>'
f"{rows}" f"{rows}"
'<div class="cover-closing">' '<div class="signature-grid"><div>Unterschrift technische Validierung</div><div>Unterschrift Auftraggeber</div></div>'
f"{self._cover_result_text(context)}"
f"{self._signature_blocks(context)}"
"</div>"
"</section>" "</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): class TocComponent(ReportComponent):
anchor = "toc" anchor = "toc"
@ -144,8 +121,7 @@ class SummaryComponent(ReportComponent):
def render(self, context: ReportContext) -> str: def render(self, context: ReportContext) -> str:
validation = context.validation validation = context.validation
block = context.text_blocks.get("summary") block = context.text_blocks.get("summary")
body = validation_result_box(validation.result, compact=True) body = f"<p>{paragraph(render_template_text(block.content, context) if block else 'nicht erfasst')}</p>"
body += f"<p>{paragraph(render_template_text(block.content, context) if block else 'nicht erfasst')}</p>"
body += definition_list( body += definition_list(
[ [
("Kunde", context.customer.name), ("Kunde", context.customer.name),
@ -159,7 +135,7 @@ class SummaryComponent(ReportComponent):
), ),
), ),
("Pruefmittel", len(context.equipment)), ("Pruefmittel", len(context.equipment)),
("Ergebnis", validation_result_label(validation.result)), ("Ergebnis", validation.result),
("Mitwirkende Personen", validation.participants), ("Mitwirkende Personen", validation.participants),
( (
"Hinweis auf naechste Leistungsbeurteilung", "Hinweis auf naechste Leistungsbeurteilung",
@ -190,10 +166,6 @@ class StaticTextComponent(ReportComponent):
return f"{self.number} {self.title}" if self.number else self.title return f"{self.number} {self.title}" if self.number else self.title
def render(self, context: ReportContext) -> str: def render(self, context: ReportContext) -> str:
if self.anchor == "results":
body = validation_result_box(context.validation.result, compact=True)
body += f"<p>{paragraph(self.body)}</p>"
return section(self.anchor, self.title, body, self.bookmark_label)
if self.block_key and self.block_key in context.text_blocks: if self.block_key and self.block_key in context.text_blocks:
content = render_template_text(context.text_blocks[self.block_key].content, context) content = render_template_text(context.text_blocks[self.block_key].content, context)
return section(self.anchor, self.title, f"<p>{paragraph(content)}</p>", self.bookmark_label) return section(self.anchor, self.title, f"<p>{paragraph(content)}</p>", self.bookmark_label)
@ -519,7 +491,7 @@ class AttachmentComponent(NumberedComponent):
figures.append( figures.append(
'<figure class="report-figure">' '<figure class="report-figure">'
f'<img class="report-image" src="{text(src)}" alt="{text(caption)}">' f'<img class="report-image" src="{text(src)}" alt="{text(caption)}">'
f'<figcaption class="image-caption">Abbildung {index}: {text(caption)}</figcaption>' f"<figcaption>Abbildung {index}: {text(caption)}</figcaption>"
"</figure>" "</figure>"
) )
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows) body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)

View file

@ -18,7 +18,6 @@ from app.models.report_template import (
ReportSection, ReportSection,
TextBlock, TextBlock,
) )
from app.models.report_settings import OrionReportSettings
from app.models.location import Location from app.models.location import Location
from app.models.validation import Validation from app.models.validation import Validation
from app.modules.orion.template_service import ReportTemplateService from app.modules.orion.template_service import ReportTemplateService
@ -37,7 +36,6 @@ class ReportContext:
text_blocks: dict[str, TextBlock] text_blocks: dict[str, TextBlock]
checklist_templates: list[ChecklistTemplate] checklist_templates: list[ChecklistTemplate]
confirmed_measurements: list[MeasurementImportValue] confirmed_measurements: list[MeasurementImportValue]
report_settings: OrionReportSettings
class OrionContextBuilder: class OrionContextBuilder:
@ -65,9 +63,6 @@ class OrionContextBuilder:
) )
) )
template_bundle = ReportTemplateService(self.session).ensure_default_template() 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( confirmed_measurements = list(
self.session.scalars( self.session.scalars(
select(MeasurementImportValue) select(MeasurementImportValue)
@ -92,5 +87,4 @@ class OrionContextBuilder:
text_blocks=template_bundle.text_blocks, text_blocks=template_bundle.text_blocks,
checklist_templates=template_bundle.checklists, checklist_templates=template_bundle.checklists,
confirmed_measurements=confirmed_measurements, confirmed_measurements=confirmed_measurements,
report_settings=report_settings,
) )

View file

@ -1,77 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from app.modules.orion.html import text
@dataclass(frozen=True)
class ResultPresentation:
key: str
label: str
css_class: str
color: str
RESULT_PRESENTATIONS: dict[str, ResultPresentation] = {
"BESTANDEN": ResultPresentation("BESTANDEN", "Bestanden", "result-box--passed", "#245C36"),
"BESTANDEN_MIT_AUFLAGEN": ResultPresentation(
"BESTANDEN_MIT_AUFLAGEN",
"Bestanden mit Auflagen",
"result-box--conditional",
"#7A5A00",
),
"NICHT_BESTANDEN": ResultPresentation(
"NICHT_BESTANDEN",
"Nicht bestanden",
"result-box--failed",
"#7A1F26",
),
"OFFEN": ResultPresentation("OFFEN", "Noch nicht bewertet", "result-box--open", "#2E3B40"),
}
RESULT_ALIASES = {
"": "OFFEN",
"OFFEN": "OFFEN",
"BESTANDEN": "BESTANDEN",
"BESTANDEN_MIT_AUFLAGEN": "BESTANDEN_MIT_AUFLAGEN",
"NICHT_BESTANDEN": "NICHT_BESTANDEN",
"offen": "OFFEN",
"bestanden": "BESTANDEN",
"bestanden_mit_auflagen": "BESTANDEN_MIT_AUFLAGEN",
"mit_auflagen": "BESTANDEN_MIT_AUFLAGEN",
"bestanden mit Auflagen": "BESTANDEN_MIT_AUFLAGEN",
"nicht_bestanden": "NICHT_BESTANDEN",
"nicht bestanden": "NICHT_BESTANDEN",
}
def validation_result_presentation(value: str | None) -> ResultPresentation:
key = RESULT_ALIASES.get(str(value or "").strip(), "OFFEN")
return RESULT_PRESENTATIONS[key]
def validation_result_label(value: str | None) -> str:
return validation_result_presentation(value).label
def validation_result_cover_sentence(value: str | None) -> str:
presentation = validation_result_presentation(value)
sentences = {
"BESTANDEN": "Die technische Validierung wurde erfolgreich bestanden.",
"BESTANDEN_MIT_AUFLAGEN": "Die technische Validierung wurde mit Auflagen bestanden.",
"NICHT_BESTANDEN": "Die technische Validierung wurde nicht bestanden.",
"OFFEN": "Die technische Validierung wurde noch nicht bewertet.",
}
return sentences[presentation.key]
def validation_result_box(value: str | None, *, compact: bool = False) -> str:
presentation = validation_result_presentation(value)
size_class = "result-box--compact" if compact else "result-box--cover"
return (
f'<div class="result-box {size_class} {presentation.css_class}">'
'<div class="result-box__label">VALIDIERUNGSERGEBNIS</div>'
f'<div class="result-box__value">{text(presentation.label.upper() if not compact else presentation.label)}</div>'
"</div>"
)

View file

@ -2,8 +2,6 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import logging import logging
from datetime import UTC, date, datetime
from types import SimpleNamespace
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -27,17 +25,8 @@ from app.modules.orion.components import (
TocComponent, TocComponent,
) )
from app.modules.orion.context import OrionContextBuilder, ReportContext 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.templates.report import render_document
from app.modules.orion.template_service import REPORT_SECTIONS 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__) logger = logging.getLogger(__name__)
@ -63,178 +52,10 @@ class OrionReportService:
self._append_pdf_attachments(context, output_path) self._append_pdf_attachments(context, output_path)
return 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]: def _components(self) -> list[ReportComponent]:
chapters = [self._component_for_section(item) for item in REPORT_SECTIONS] chapters = [self._component_for_section(item) for item in REPORT_SECTIONS]
return [CoverComponent(), SummaryComponent(), TocComponent(REPORT_SECTIONS), CustomerComponent(), *chapters] 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: def _component_for_section(self, item: dict) -> ReportComponent:
component = item.get("component") component = item.get("component")
key = str(item["key"]) key = str(item["key"])

View file

@ -14,30 +14,6 @@
@bottom-left { content: element(report-footer); } @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; box-sizing: border-box;
} }
@ -159,6 +135,7 @@ body {
.cover-page { .cover-page {
page: cover; page: cover;
min-height: 245mm;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
@ -169,28 +146,12 @@ body {
page: report; 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 { .cover-top {
align-items: flex-start; align-items: flex-start;
display: grid; display: grid;
gap: 14mm; gap: 18mm;
grid-template-columns: 1fr 55mm; grid-template-columns: 1fr 55mm;
margin-bottom: 8mm; margin-bottom: 32mm;
} }
.cover-logo { .cover-logo {
@ -199,18 +160,10 @@ body {
width: auto; width: auto;
} }
.logo-size-small .cover-logo {
height: 12mm;
}
.logo-size-large .cover-logo {
height: 22mm;
}
.company-address { .company-address {
color: #6B7C85; color: #6B7C85;
font-size: 8.5pt; font-size: 9pt;
line-height: 1.35; line-height: 1.5;
text-align: right; text-align: right;
} }
@ -225,172 +178,28 @@ body {
h1 { h1 {
color: #2E3B40; color: #2E3B40;
font-size: 28pt; font-size: 34pt;
line-height: 1.05; line-height: 1.05;
margin: 0 0 3mm 0; margin: 0 0 8mm 0;
} }
.cover-subtitle { .cover-subtitle {
color: #4F6A74; color: #4F6A74;
font-size: 12.5pt;
margin: 0 0 3mm 0;
}
.cover-page .definition-row {
grid-template-columns: 36mm 1fr;
padding: .65mm 0;
}
.cover-page .definition-list {
font-size: 9.4pt;
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;
page-break-inside: avoid;
}
.cover-result-text {
border-top: .25mm solid #DCE3E3;
break-inside: avoid;
margin: 2mm 0 0 0;
page-break-inside: avoid;
padding-top: 2.2mm;
}
.cover-result-text h2 {
border: 0;
color: #4F6A74;
font-size: 10pt;
letter-spacing: .04em;
margin: 0 0 1mm 0;
padding: 0;
text-transform: uppercase;
}
.cover-result-text p {
color: #2E3B40;
font-size: 10.5pt;
font-weight: 700;
margin: 0;
}
.result-box {
border: .65mm solid #8A9498;
border-radius: 1.5mm;
break-inside: avoid;
margin: 4mm 0;
padding: 3mm 6mm;
page-break-inside: avoid;
text-align: center;
width: 100%;
}
.result-box__label {
color: #4F5E63;
font-size: 8.4pt;
font-weight: 700;
letter-spacing: .06em;
margin-bottom: 1.2mm;
}
.result-box__value {
font-weight: 800;
line-height: 1.15;
}
.result-box--cover {
margin: 0 0 2.5mm 0;
padding: 2.8mm 6mm;
}
.result-box--cover .result-box__value {
font-size: 18.5pt;
text-transform: uppercase;
}
.result-box--compact {
margin: 0 0 6mm 0;
padding: 5mm 6mm;
}
.result-box--compact .result-box__value {
font-size: 16pt; font-size: 16pt;
} margin: 0 0 18mm 0;
.result-box--passed {
background: #EAF5EE;
border-color: #6FA37D;
color: #245C36;
}
.result-box--conditional {
background: #FFF7DD;
border-color: #C9A64A;
color: #7A5A00;
}
.result-box--failed {
background: #FCEBEC;
border-color: #C96A70;
color: #7A1F26;
}
.result-box--open {
background: #F1F3F3;
border-color: #9AA6AA;
color: #2E3B40;
}
.signature-block {
break-inside: avoid;
color: #6B7C85;
page-break-inside: avoid;
width: 82mm;
} }
.signature-grid { .signature-grid {
break-inside: avoid; display: grid;
display: flex; grid-template-columns: 1fr 1fr;
gap: 14mm; gap: 16mm;
margin-top: 9mm; margin-top: 18mm;
page-break-inside: avoid;
} }
.signature-line { .signature-grid div {
border-top: 1px solid #6B7C85; border-top: 1px solid #6B7C85;
height: 0; color: #6B7C85;
margin-bottom: 1.5mm; padding-top: 3mm;
width: 82mm;
}
.signature-label {
font-size: 8.5pt;
} }
.chapter { .chapter {
@ -398,46 +207,6 @@ h1 {
margin-top: 9mm; 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 { h2 {
border-bottom: .25mm solid #DCE3E3; border-bottom: .25mm solid #DCE3E3;
color: #2E3B40; color: #2E3B40;
@ -518,19 +287,6 @@ dd {
font-size: 8.5pt; 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 { .toc-list {
counter-reset: toc; counter-reset: toc;
list-style: none; list-style: none;
@ -572,26 +328,6 @@ dd {
margin-top: 8mm; 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 { .report-figure {
break-inside: avoid; break-inside: avoid;
margin: 0; margin: 0;
@ -605,64 +341,6 @@ dd {
width: 100%; 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 { figcaption {
color: #6B7C85; color: #6B7C85;
font-size: 8.5pt; font-size: 8.5pt;

View file

@ -8,14 +8,10 @@ from app.modules.orion.html import text
def render_report_chrome(context: ReportContext, logo_uri: str) -> str: def render_report_chrome(context: ReportContext, logo_uri: str) -> str:
settings = context.report_settings
report_number = text(context.validation.report_number) report_number = text(context.validation.report_number)
version = text(context.validation.version) version = text(context.validation.version)
report_date = text(context.validation.updated_at) report_date = text(context.validation.updated_at)
header = "" return f"""
footer = ""
if settings.show_header:
header = f"""
<header class="report-header" aria-label="Berichtskopf"> <header class="report-header" aria-label="Berichtskopf">
<div class="report-header-brand"> <div class="report-header-brand">
<img src="{logo_uri}" alt="SCHUBAMED"> <img src="{logo_uri}" alt="SCHUBAMED">
@ -30,16 +26,12 @@ def render_report_chrome(context: ReportContext, logo_uri: str) -> str:
<div><dt>Datum</dt><dd>{report_date}</dd></div> <div><dt>Datum</dt><dd>{report_date}</dd></div>
</dl> </dl>
</header> </header>
"""
if settings.show_footer:
footer = f"""
<footer class="report-footer" aria-label="Berichtsfuß"> <footer class="report-footer" aria-label="Berichtsfuß">
<span>Validation Suite</span> <span>Validation Suite</span>
<span>Seite <span class="page-number"></span> von <span class="page-count"></span></span> <span>Seite <span class="page-number"></span> von <span class="page-count"></span></span>
<span>Version {version}</span> <span>Version {version}</span>
</footer> </footer>
""" """
return header + footer
def render_document(context: ReportContext, chapters: list[str]) -> str: def render_document(context: ReportContext, chapters: list[str]) -> str:
@ -48,24 +40,6 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
title = f"Validierungsbericht {context.validation.report_number}" title = f"Validierungsbericht {context.validation.report_number}"
cover_markup = chapters[0] if chapters else "" cover_markup = chapters[0] if chapters else ""
content_markup = "\n".join(chapters[1:]) 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> return f"""<!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
@ -74,7 +48,7 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
<title>{text(title)}</title> <title>{text(title)}</title>
<style>{css}</style> <style>{css}</style>
</head> </head>
<body class="{body_classes}"> <body>
<article class="report-document"> <article class="report-document">
{cover_markup} {cover_markup}
<section class="report-content"> <section class="report-content">

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import or_, select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.customer import Customer from app.models.customer import Customer
@ -18,28 +18,12 @@ class UserRepository(Repository[User]):
model = User model = User
def by_email(self, email: str) -> User | None: def by_email(self, email: str) -> User | None:
return self.session.scalar(select(User).where(User.email == email.strip().lower())) return self.session.scalar(select(User).where(User.email == email.lower()))
class CustomerRepository(Repository[Customer]): class CustomerRepository(Repository[Customer]):
model = Customer model = Customer
search_columns = ("name", "city", "email", "phone")
def _search_statement(self, search: str | None = None):
statement = select(Customer)
if search:
term = f"%{search.strip()}%"
statement = statement.where(
or_(
Customer.name.ilike(term),
Customer.external_id.ilike(term),
Customer.city.ilike(term),
Customer.postal_code.ilike(term),
Customer.email.ilike(term),
Customer.phone.ilike(term),
Customer.contacts.any(Contact.full_name.ilike(term)),
)
)
return statement
class LocationRepository(Repository[Location]): class LocationRepository(Repository[Location]):

View file

@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from app.models.user import UserRole from app.models.user import UserRole
@ -16,22 +14,11 @@ class LoginRequest(BaseModel):
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
expires_in: int
user: UserRead
class UserRead(EntityRead): class UserRead(EntityRead):
email: EmailStr email: EmailStr
first_name: str full_name: str
last_name: str
role: UserRole role: UserRole
is_active: bool is_active: bool
must_change_password: bool
last_login_at: datetime | None = None
password_changed_at: datetime | None = None
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
new_password_confirmation: str

View file

@ -23,4 +23,3 @@ class PaginatedResponse(BaseModel, Generic[T]):
total: int total: int
page: int page: int
page_size: int page_size: int
pages: int

View file

@ -1,33 +1,18 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from datetime import datetime
from typing import Literal
from uuid import UUID from uuid import UUID
from pydantic import ConfigDict, EmailStr, Field, field_validator from pydantic import EmailStr, Field, field_validator
from app.models.customer import CustomerType from app.models.customer import CustomerType
from app.models.equipment import EquipmentKind, EquipmentStatus 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.models.validation import ValidationStatus
from app.schemas.common import EntityRead, ORMModel from app.schemas.common import EntityRead, ORMModel
class CustomerCreate(ORMModel): class CustomerCreate(ORMModel):
customer_type: CustomerType customer_type: CustomerType
source_system: str | None = None
external_id: str | None = None
name: str name: str
street: str | None = None street: str | None = None
postal_code: str | None = None postal_code: str | None = None
@ -104,32 +89,6 @@ class DeviceUpdate(DeviceCreate):
pass 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): class EquipmentCreate(ORMModel):
kind: EquipmentKind kind: EquipmentKind
manufacturer: str | None = None manufacturer: str | None = None
@ -282,64 +241,3 @@ class MeasurementImportValueConfirm(ORMModel):
class MeasurementImportConfirmRequest(ORMModel): class MeasurementImportConfirmRequest(ORMModel):
values: list[MeasurementImportValueConfirm] values: list[MeasurementImportValueConfirm]
class UserBase(ORMModel):
first_name: str
last_name: str
email: EmailStr
role: UserRole
is_active: bool = True
must_change_password: bool = True
class UserCreate(UserBase):
temporary_password: str
class UserUpdate(UserBase):
pass
class UserRead(UserBase, EntityRead):
last_login_at: datetime | None = None
password_changed_at: datetime | None = None
class UserPasswordResetRequest(ORMModel):
temporary_password: str | None = None
class QuickStartValidationSummary(ORMModel):
id: str
device_id: str
report_number: str
performed_on: date | None = None
result: str | None = None
next_validation_on: date | None = None
status: str
validation_type: str | None = None
equipment_ids: list[str] = Field(default_factory=list)
class QuickStartCustomerData(ORMModel):
customer: CustomerRead
locations: list[LocationRead] = Field(default_factory=list)
contacts: list[ContactRead] = Field(default_factory=list)
devices: list[DeviceRead] = Field(default_factory=list)
validations: list[QuickStartValidationSummary] = Field(default_factory=list)
class QuickStartCreateRequest(ORMModel):
customer_id: UUID
location_id: UUID | None = None
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

View file

@ -1,13 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import create_access_token, verify_password from app.core.security import create_access_token, verify_password
from app.models.user import User
from app.repositories.domain import UserRepository from app.repositories.domain import UserRepository
@ -15,16 +11,13 @@ class AuthService:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
self.users = UserRepository(session) self.users = UserRepository(session)
def login(self, email: str, password: str) -> tuple[str, User]: def login(self, email: str, password: str) -> str:
user = self.users.by_email(email) user = self.users.by_email(email)
if user is None or not verify_password(password, user.password_hash): if user is None or not user.is_active or not verify_password(password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials", detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not user.is_active or user.deleted_at is not None: return create_access_token(user.id, user.role.value)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
user.last_login_at = datetime.now(UTC)
self.users.session.commit()
return create_access_token(user.id, user.role), user

View file

@ -1,505 +0,0 @@
from __future__ import annotations
import csv
import io
import json
import re
import zipfile
from dataclasses import dataclass
from html import unescape
from typing import Any
from xml.etree import ElementTree
from pydantic import EmailStr, TypeAdapter, ValidationError
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from app.models.contact import Contact
from app.models.customer import Customer, CustomerType
from app.models.location import Location
from app.models.user import User
MAX_IMPORT_BYTES = 5 * 1024 * 1024
SOURCE_SYSTEM = "desk4"
EMAIL_ADAPTER = TypeAdapter(EmailStr)
TARGET_FIELDS: dict[str, str] = {
"ignore": "Nicht importieren",
"customer_number": "Kundennummer",
"customer_name": "Firmenname",
"customer_addition": "Zusatz",
"customer_street": "Straße",
"customer_house_number": "Hausnummer",
"customer_postal_code": "Postleitzahl",
"customer_city": "Ort",
"customer_country": "Land",
"customer_phone": "Telefon",
"customer_mobile": "Mobiltelefon",
"customer_email": "E-Mail",
"customer_website": "Website",
"customer_vat_id": "Umsatzsteuer-ID",
"customer_notes": "Interne Notiz",
"contact_salutation": "Anrede",
"contact_first_name": "Vorname",
"contact_last_name": "Nachname",
"contact_full_name": "Ansprechpartner",
"contact_function": "Funktion",
"contact_phone": "Telefon Ansprechpartner",
"contact_mobile": "Mobil Ansprechpartner",
"contact_email": "E-Mail Ansprechpartner",
"location_name": "Standortbezeichnung",
"location_street": "Straße Standort",
"location_house_number": "Hausnummer Standort",
"location_postal_code": "PLZ Standort",
"location_city": "Ort Standort",
"location_country": "Land Standort",
}
ALIASES: dict[str, list[str]] = {
"customer_number": ["kundennr", "kunden nr", "kunden-nr", "kunden nummer", "kundennummer", "debitor", "debitorennummer", "nummer"],
"customer_name": ["firma", "firmenname", "name", "kunde", "kundenname", "unternehmen"],
"customer_addition": ["zusatz", "name 2", "firmenzusatz"],
"customer_street": ["strasse", "straße", "anschrift", "adresse", "kunde strasse"],
"customer_house_number": ["hausnummer", "hausnr", "nr"],
"customer_postal_code": ["plz", "postleitzahl", "zip"],
"customer_city": ["ort", "stadt"],
"customer_country": ["land"],
"customer_phone": ["telefon", "tel", "festnetz"],
"customer_mobile": ["mobiltelefon", "mobil", "handy"],
"customer_email": ["e-mail", "email", "mail"],
"customer_website": ["web", "website", "homepage"],
"customer_vat_id": ["ust id", "ustid", "umsatzsteuer", "vat"],
"customer_notes": ["notiz", "bemerkung", "interne notiz"],
"contact_salutation": ["anrede"],
"contact_first_name": ["vorname"],
"contact_last_name": ["nachname", "name ansprechpartner"],
"contact_full_name": ["ansprechpartner", "kontakt", "kontaktperson"],
"contact_function": ["funktion", "position"],
"contact_phone": ["telefon ansprechpartner", "kontakt telefon"],
"contact_mobile": ["mobil ansprechpartner", "kontakt mobil"],
"contact_email": ["email ansprechpartner", "e-mail ansprechpartner", "kontakt email"],
"location_name": ["standort", "standortbezeichnung", "filiale"],
"location_street": ["standort strasse", "standort straße", "liefer strasse", "lieferadresse"],
"location_house_number": ["standort hausnummer"],
"location_postal_code": ["standort plz", "liefer plz"],
"location_city": ["standort ort", "liefer ort"],
"location_country": ["standort land"],
}
@dataclass(frozen=True)
class ParsedImportFile:
headers: list[str]
rows: list[dict[str, str]]
warnings: list[str]
def normalize_header(value: str) -> str:
value = value.strip().lower().replace("ß", "ss")
value = re.sub(r"[_./:;]+", " ", value)
value = re.sub(r"\s+", " ", value)
return value
def clean(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def join_parts(*values: str) -> str:
return " ".join(item for item in (clean(value) for value in values) if item)
class CustomerImportService:
def __init__(self, session: Session) -> None:
self.session = session
def parse_file(self, filename: str, content: bytes) -> ParsedImportFile:
if not content:
raise ValueError("Die Importdatei ist leer.")
if len(content) > MAX_IMPORT_BYTES:
raise ValueError("Die Importdatei ist zu groß.")
suffix = filename.lower().rsplit(".", 1)[-1] if "." in filename else ""
if suffix == "csv":
return self._parse_csv(content)
if suffix == "xlsx":
return self._parse_xlsx(content)
raise ValueError("Nur CSV- und XLSX-Dateien werden unterstützt.")
def suggest_mapping(self, headers: list[str]) -> dict[str, str]:
mapping: dict[str, str] = {}
used: set[str] = set()
for header in headers:
normalized = normalize_header(header)
target = "ignore"
for field, aliases in ALIASES.items():
if field in used:
continue
if normalized in aliases or any(alias in normalized for alias in aliases):
target = field
used.add(field)
break
mapping[header] = target
return mapping
def preview(self, filename: str, content: bytes, mapping: dict[str, str] | None = None) -> dict:
parsed = self.parse_file(filename, content)
final_mapping = mapping or self.suggest_mapping(parsed.headers)
rows = [self._preview_row(index, row, final_mapping) for index, row in enumerate(parsed.rows, start=2)]
summary = self._summary(rows)
return {
"columns": parsed.headers,
"target_fields": TARGET_FIELDS,
"mapping": final_mapping,
"rows": rows,
"summary": summary,
"warnings": parsed.warnings,
}
def confirm(
self,
filename: str,
content: bytes,
mapping: dict[str, str],
row_actions: dict[str, str],
ignore_empty_values: bool,
user: User,
) -> dict:
preview = self.preview(filename, content, mapping)
results = []
counters = {
"total": len(preview["rows"]),
"created_customers": 0,
"updated_customers": 0,
"skipped_rows": 0,
"error_rows": 0,
"created_locations": 0,
"created_contacts": 0,
}
for row in preview["rows"]:
action = row_actions.get(str(row["row_number"]), row["action"])
if row["action"] == "ERROR":
action = "ERROR"
if action not in {"NEU_ANLEGEN", "BESTEHENDEN_AKTUALISIEREN", "UEBERSPRINGEN", "ERROR"}:
action = row["action"]
try:
result = self._apply_row(row, action, ignore_empty_values)
for key in counters:
counters[key] += result.get(key, 0)
results.append(
{
"row_number": row["row_number"],
"customer_name": row["recognized"]["customer"].get("name"),
"action": action,
"result": result["result"],
"message": result["message"],
}
)
except Exception as exc:
self.session.rollback()
counters["error_rows"] += 1
results.append(
{
"row_number": row["row_number"],
"customer_name": row["recognized"]["customer"].get("name"),
"action": action,
"result": "FEHLER",
"message": str(exc),
}
)
self.session.commit()
return {"summary": counters, "rows": results, "executed_by_user_id": user.id}
def _parse_csv(self, content: bytes) -> ParsedImportFile:
text = content.decode("utf-8-sig")
sample = text[:4096]
delimiter = ";"
try:
delimiter = csv.Sniffer().sniff(sample, delimiters=";,").delimiter
except csv.Error:
delimiter = ";" if sample.count(";") >= sample.count(",") else ","
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
headers = [clean(item) for item in (reader.fieldnames or []) if clean(item)]
if not headers:
raise ValueError("Die Importdatei enthält keine Spaltenüberschriften.")
rows = [{header: clean(row.get(header)) for header in headers} for row in reader]
rows = [row for row in rows if any(row.values())]
if not rows:
raise ValueError("Die Importdatei enthält keine Datenzeilen.")
return ParsedImportFile(headers=headers, rows=rows, warnings=[])
def _parse_xlsx(self, content: bytes) -> ParsedImportFile:
if not content.startswith(b"PK"):
raise ValueError("Die XLSX-Datei ist ungültig.")
try:
archive = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise ValueError("Die XLSX-Datei ist ungültig.") from exc
names = set(archive.namelist())
if any(name.endswith("vbaProject.bin") for name in names):
raise ValueError("Makro-Dateien werden nicht unterstützt.")
workbook = ElementTree.fromstring(archive.read("xl/workbook.xml"))
ns = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main", "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships"}
sheets = workbook.findall(".//m:sheet", ns)
warnings = []
if len(sheets) > 1:
warnings.append("Die XLSX-Datei enthält mehrere Tabellenblätter. Es wurde das erste Tabellenblatt verwendet.")
sheet_path = "xl/worksheets/sheet1.xml"
shared = self._xlsx_shared_strings(archive)
sheet = ElementTree.fromstring(archive.read(sheet_path))
matrix: list[list[str]] = []
for row in sheet.findall(".//m:sheetData/m:row", ns):
values: dict[int, str] = {}
for cell in row.findall("m:c", ns):
ref = cell.attrib.get("r", "A1")
col = self._column_index(ref)
value = self._xlsx_cell_value(cell, shared, ns)
values[col] = value
if values:
matrix.append([values.get(index, "") for index in range(max(values) + 1)])
if not matrix:
raise ValueError("Die XLSX-Datei enthält keine Daten.")
headers = [clean(value) for value in matrix[0]]
headers = [value for value in headers if value]
if not headers:
raise ValueError("Die XLSX-Datei enthält keine Spaltenüberschriften.")
rows = []
for raw in matrix[1:]:
row = {header: clean(raw[index] if index < len(raw) else "") for index, header in enumerate(headers)}
if any(row.values()):
rows.append(row)
if not rows:
raise ValueError("Die XLSX-Datei enthält keine Datenzeilen.")
return ParsedImportFile(headers=headers, rows=rows, warnings=warnings)
def _xlsx_shared_strings(self, archive: zipfile.ZipFile) -> list[str]:
if "xl/sharedStrings.xml" not in archive.namelist():
return []
ns = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml"))
values = []
for item in root.findall(".//m:si", ns):
values.append(unescape("".join(node.text or "" for node in item.findall(".//m:t", ns))))
return values
def _xlsx_cell_value(self, cell: ElementTree.Element, shared: list[str], ns: dict[str, str]) -> str:
cell_type = cell.attrib.get("t")
value_node = cell.find("m:v", ns)
if value_node is None:
inline = cell.find(".//m:t", ns)
return clean(inline.text if inline is not None else "")
value = clean(value_node.text)
if cell_type == "s" and value.isdigit() and int(value) < len(shared):
return shared[int(value)]
return value
def _column_index(self, ref: str) -> int:
letters = re.sub(r"[^A-Z]", "", ref.upper())
index = 0
for char in letters:
index = index * 26 + (ord(char) - 64)
return max(index - 1, 0)
def _mapped_values(self, row: dict[str, str], mapping: dict[str, str]) -> dict[str, str]:
values = {field: "" for field in TARGET_FIELDS if field != "ignore"}
for column, target in mapping.items():
if target in values and not values[target]:
values[target] = clean(row.get(column))
return values
def _preview_row(self, row_number: int, row: dict[str, str], mapping: dict[str, str]) -> dict:
values = self._mapped_values(row, mapping)
recognized = self._recognized(values)
errors = self._validate_values(values)
matches = self._find_matches(recognized["customer"])
action = "NEU_ANLEGEN"
message = "Neuer Kunde"
if errors:
action = "ERROR"
message = "Pflichtfeld oder Eingabe ungültig"
elif matches["external_id"]:
action = "BESTEHENDEN_AKTUALISIEREN"
message = "Bestehender Kunde über Kundennummer gefunden"
elif matches["name_postal_code"] or matches["name_city"] or matches["email"]:
action = "UEBERSPRINGEN"
message = "Mögliche Dublette gefunden"
return {
"row_number": row_number,
"source": row,
"recognized": recognized,
"errors": errors,
"action": action,
"message": message,
"matches": {key: self._customer_ref(value) for key, value in matches.items() if value},
"allowed_actions": self._allowed_actions(action, bool(matches["external_id"])),
}
def _recognized(self, values: dict[str, str]) -> dict:
customer_notes = "\n".join(
item
for item in [
values["customer_notes"],
f"Zusatz: {values['customer_addition']}" if values["customer_addition"] else "",
f"Land: {values['customer_country']}" if values["customer_country"] else "",
f"Mobil: {values['customer_mobile']}" if values["customer_mobile"] else "",
f"Website: {values['customer_website']}" if values["customer_website"] else "",
f"USt-ID: {values['customer_vat_id']}" if values["customer_vat_id"] else "",
]
if item
)
first_last = join_parts(values["contact_first_name"], values["contact_last_name"])
full_name = values["contact_full_name"] or first_last
return {
"customer": {
"source_system": SOURCE_SYSTEM if values["customer_number"] else None,
"external_id": values["customer_number"] or None,
"name": values["customer_name"],
"street": join_parts(values["customer_street"], values["customer_house_number"]),
"postal_code": values["customer_postal_code"],
"city": values["customer_city"],
"phone": values["customer_phone"] or values["customer_mobile"],
"email": values["customer_email"],
"notes": customer_notes,
},
"location": {
"name": values["location_name"] or "Hauptstandort",
"street": join_parts(values["location_street"], values["location_house_number"]) or join_parts(values["customer_street"], values["customer_house_number"]),
"postal_code": values["location_postal_code"] or values["customer_postal_code"],
"city": values["location_city"] or values["customer_city"],
},
"contact": {
"full_name": full_name,
"function": values["contact_function"],
"email": values["contact_email"],
"phone": values["contact_phone"] or values["contact_mobile"],
"notes": values["contact_salutation"],
},
}
def _validate_values(self, values: dict[str, str]) -> list[str]:
errors = []
if not values["customer_number"] and not values["customer_name"]:
errors.append("Kundennummer oder Firmenname fehlt.")
for label, value in [("Kunden-E-Mail", values["customer_email"]), ("Ansprechpartner-E-Mail", values["contact_email"])]:
if value:
try:
EMAIL_ADAPTER.validate_python(value)
except ValidationError:
errors.append(f"{label} ist ungültig.")
return errors
def _find_matches(self, customer: dict) -> dict[str, Customer | None]:
external = None
if customer.get("external_id"):
external = self.session.scalar(
select(Customer).where(
Customer.source_system == SOURCE_SYSTEM,
Customer.external_id == customer["external_id"],
)
)
name = customer.get("name")
postal_code = customer.get("postal_code")
city = customer.get("city")
email = customer.get("email")
return {
"external_id": external,
"name_postal_code": self.session.scalar(select(Customer).where(func.lower(Customer.name) == name.lower(), Customer.postal_code == postal_code)) if name and postal_code else None,
"name_city": self.session.scalar(select(Customer).where(func.lower(Customer.name) == name.lower(), func.lower(Customer.city) == city.lower())) if name and city else None,
"email": self.session.scalar(select(Customer).where(func.lower(Customer.email) == email.lower())) if email else None,
}
def _allowed_actions(self, action: str, has_external_match: bool) -> list[str]:
if action == "ERROR":
return ["ERROR"]
if has_external_match:
return ["BESTEHENDEN_AKTUALISIEREN", "UEBERSPRINGEN"]
return ["NEU_ANLEGEN", "UEBERSPRINGEN"]
def _customer_ref(self, customer: Customer) -> dict:
return {"id": customer.id, "name": customer.name, "external_id": customer.external_id, "postal_code": customer.postal_code, "city": customer.city}
def _apply_row(self, row: dict, action: str, ignore_empty_values: bool) -> dict:
if action in {"UEBERSPRINGEN", "ERROR"}:
return {"skipped_rows": 1 if action == "UEBERSPRINGEN" else 0, "error_rows": 1 if action == "ERROR" else 0, "result": "ÜBERSPRUNGEN" if action == "UEBERSPRINGEN" else "FEHLER", "message": row["message"]}
customer_data = row["recognized"]["customer"]
matches = self._find_matches(customer_data)
customer = matches["external_id"]
created_customer = False
if action == "BESTEHENDEN_AKTUALISIEREN":
if customer is None:
raise ValueError("Aktualisierung ist nur mit eindeutiger Kundennummer möglich.")
self._update_customer(customer, customer_data, ignore_empty_values)
else:
if customer is not None:
raise ValueError("Kunde mit dieser Kundennummer existiert bereits.")
customer = Customer(customer_type=CustomerType.practice, name=customer_data["name"] or customer_data["external_id"])
self._update_customer(customer, customer_data, False)
self.session.add(customer)
self.session.flush()
created_customer = True
created_location = self._ensure_location(customer, row["recognized"]["location"])
created_contact = self._ensure_contact(customer, row["recognized"]["contact"])
self.session.flush()
return {
"created_customers": 1 if created_customer else 0,
"updated_customers": 0 if created_customer else 1,
"created_locations": 1 if created_location else 0,
"created_contacts": 1 if created_contact else 0,
"result": "ERFOLGREICH",
"message": "Kunde angelegt" if created_customer else "Kunde aktualisiert",
}
def _update_customer(self, customer: Customer, data: dict, ignore_empty_values: bool) -> None:
for key in ["source_system", "external_id", "name", "street", "postal_code", "city", "phone", "email", "notes"]:
value = data.get(key)
if ignore_empty_values and value in (None, ""):
continue
setattr(customer, key, value or None)
def _ensure_location(self, customer: Customer, data: dict) -> bool:
if not any(data.get(key) for key in ["name", "street", "postal_code", "city"]):
return False
existing = self.session.scalar(
select(Location).where(
Location.customer_id == customer.id,
func.lower(Location.name) == data["name"].lower(),
)
)
if existing:
return False
self.session.add(Location(customer_id=customer.id, **{key: value or None for key, value in data.items()}))
return True
def _ensure_contact(self, customer: Customer, data: dict) -> bool:
if not data.get("full_name"):
return False
existing = self.session.scalar(
select(Contact).where(
Contact.customer_id == customer.id,
func.lower(Contact.full_name) == data["full_name"].lower(),
)
)
if existing:
return False
self.session.add(Contact(customer_id=customer.id, **{key: value or None for key, value in data.items()}))
return True
def _summary(self, rows: list[dict]) -> dict:
return {
"total": len(rows),
"new": sum(1 for row in rows if row["action"] == "NEU_ANLEGEN"),
"update": sum(1 for row in rows if row["action"] == "BESTEHENDEN_AKTUALISIEREN"),
"duplicates": sum(1 for row in rows if row["action"] == "UEBERSPRINGEN" and row["matches"]),
"errors": sum(1 for row in rows if row["action"] == "ERROR"),
"skip": sum(1 for row in rows if row["action"] == "UEBERSPRINGEN"),
}
def parse_json_object(value: str | None, fallback: dict | None = None) -> dict:
if not value:
return fallback or {}
parsed = json.loads(value)
if not isinstance(parsed, dict):
raise ValueError("JSON-Daten müssen ein Objekt sein.")
return parsed

View file

@ -1,757 +0,0 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import shutil
from typing import Any
from dateutil.relativedelta import relativedelta
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.contact import Contact
from app.models.customer import Customer, CustomerType
from app.models.device import Device
from app.models.equipment import Equipment, EquipmentKind, EquipmentStatus
from app.models.location import Location
from app.models.validation import Validation, ValidationStatus
from app.modules.orion.service import OrionReportService
from app.modules.orion.template_service import ReportTemplateService
from app.services.validation_workflow import ValidationWorkflowService
DEMO_TAG = "DEMO_DATA"
DEMO_REPORT_PREFIX = "DEMO-VAL"
DEMO_DEVICE_PREFIX = "DEMO-DEV"
DEMO_EQUIPMENT_PREFIX = "DEMO-EQ"
DEMO_UPLOAD_ROOT = Path("/app/uploads/demo-data")
DEMO_REPORT_ROOT = Path("/app/reports")
@dataclass(frozen=True)
class DemoCustomerTemplate:
name: str
specialty: str
street: str
postal_code: str
city: str
phone: str
email: str
hygiene_officer: str
quality_manager: str
operator: str
locations: list[dict[str, Any]]
contacts: list[dict[str, Any]]
device: dict[str, Any]
CUSTOMER_TEMPLATES: list[DemoCustomerTemplate] = [
DemoCustomerTemplate(
name="Zahnzentrum Alster",
specialty="Zahnarzt",
street="Alsterufer 18",
postal_code="20354",
city="Hamburg",
phone="040 5550010",
email="kontakt@zahnzentrum-alster.schubamed.de",
hygiene_officer="Dr. Jana Peters",
quality_manager="M. Kruse",
operator="Dr. Jana Peters",
locations=[
{"name": "Hauptpraxis", "street": "Alsterufer 18", "postal_code": "20354", "city": "Hamburg", "room": "Steri 1"},
{"name": "OP-Bereich", "street": "Alsterufer 18", "postal_code": "20354", "city": "Hamburg", "room": "OP 2"},
],
contacts=[
{"full_name": "Dr. Jana Peters", "function": "Betreiberin", "email": "jana.peters@schubamed.de", "phone": "040 5550011"},
{"full_name": "M. Kruse", "function": "QM-Beauftragte", "email": "qm.zahnzentrum.alster@schubamed.de", "phone": "040 5550012"},
],
device={
"manufacturer": "Euronda SpA",
"model": "E10.7",
"serial_number": "SN-DEMO-001",
"year_built": 2023,
"commissioned_on": date(2023, 6, 14),
"chamber_volume_liters": 24,
"steam_generation": "Eigendampferzeugung",
"water_treatment": "Vollentsalzung",
"documentation": "CF-Karte und PDF-Protokoll",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="Urologie Zentrum Elbe",
specialty="Urologie",
street="Mönckebergstr. 11",
postal_code="20095",
city="Hamburg",
phone="040 5550020",
email="kontakt@urologie-elbe.schubamed.de",
hygiene_officer="Dr. Felix Brandt",
quality_manager="S. Lange",
operator="Dr. Felix Brandt",
locations=[
{"name": "Praxis Mitte", "street": "Mönckebergstr. 11", "postal_code": "20095", "city": "Hamburg", "room": "Steri A"},
],
contacts=[
{"full_name": "Dr. Felix Brandt", "function": "Betreiber", "email": "felix.brandt@schubamed.de", "phone": "040 5550021"},
],
device={
"manufacturer": "Euronda SpA",
"model": "E9",
"serial_number": "SN-DEMO-002",
"year_built": 2022,
"commissioned_on": date(2022, 11, 2),
"chamber_volume_liters": 18,
"steam_generation": "Generator integriert",
"water_treatment": "Wasserkonditionierung",
"documentation": "Digitale Dokumentation",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="Orthopädie am Park",
specialty="Orthopädie",
street="Königsallee 44",
postal_code="40212",
city="Düsseldorf",
phone="0211 5550030",
email="kontakt@orthopaedie-park.schubamed.de",
hygiene_officer="Dr. Lea Sommer",
quality_manager="A. Hoffmann",
operator="Dr. Lea Sommer",
locations=[
{"name": "Ambulanz", "street": "Königsallee 44", "postal_code": "40212", "city": "Düsseldorf", "room": "Steri 1"},
{"name": "Behandlungszentrum", "street": "Kaiserswerther Str. 99", "postal_code": "40474", "city": "Düsseldorf", "room": "Steri 2"},
],
contacts=[
{"full_name": "Dr. Lea Sommer", "function": "Betreiberin", "email": "lea.sommer@schubamed.de", "phone": "0211 5550031"},
{"full_name": "A. Hoffmann", "function": "Hygienebeauftragte", "email": "hygiene.orthopaedie@schubamed.de", "phone": "0211 5550032"},
],
device={
"manufacturer": "MELAG",
"model": "Vacuklav 41 B+",
"serial_number": "SN-DEMO-003",
"year_built": 2021,
"commissioned_on": date(2021, 9, 8),
"chamber_volume_liters": 22,
"steam_generation": "Dampferzeuger intern",
"water_treatment": "VE-Wasser",
"documentation": "CF-Karte, USB-Export",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="Dermatologie Rhein",
specialty="Dermatologie",
street="Breite Str. 8",
postal_code="50667",
city="Köln",
phone="0221 5550040",
email="kontakt@dermatologie-rhein.schubamed.de",
hygiene_officer="Dr. Sophie Keller",
quality_manager="D. Neumann",
operator="Dr. Sophie Keller",
locations=[
{"name": "Hautpraxis", "street": "Breite Str. 8", "postal_code": "50667", "city": "Köln", "room": "Steri"},
],
contacts=[
{"full_name": "Dr. Sophie Keller", "function": "Betreiberin", "email": "sophie.keller@schubamed.de", "phone": "0221 5550041"},
{"full_name": "D. Neumann", "function": "QM", "email": "qm.dermatologie.rhein@schubamed.de", "phone": "0221 5550042"},
],
device={
"manufacturer": "MELAG",
"model": "Vacuklav 44 B+",
"serial_number": "SN-DEMO-004",
"year_built": 2020,
"commissioned_on": date(2020, 4, 16),
"chamber_volume_liters": 24,
"steam_generation": "Eigendampferzeugung",
"water_treatment": "Osmose",
"documentation": "Digitale Dokumentation und Ausdruck",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="HNO Zentrum Forum",
specialty="HNO",
street="Theatinerstr. 19",
postal_code="80333",
city="München",
phone="089 5550050",
email="kontakt@hno-forum.schubamed.de",
hygiene_officer="Dr. Tom Berger",
quality_manager="R. Wagner",
operator="Dr. Tom Berger",
locations=[
{"name": "Forum Praxis", "street": "Theatinerstr. 19", "postal_code": "80333", "city": "München", "room": "Steri 1"},
],
contacts=[
{"full_name": "Dr. Tom Berger", "function": "Betreiber", "email": "tom.berger@schubamed.de", "phone": "089 5550051"},
],
device={
"manufacturer": "MELAG",
"model": "PrimeLine",
"serial_number": "SN-DEMO-005",
"year_built": 2024,
"commissioned_on": date(2024, 3, 1),
"chamber_volume_liters": 29,
"steam_generation": "Generator integriert",
"water_treatment": "VE-Wasser",
"documentation": "USB-Export",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="Gynäkologie Marienhof",
specialty="Gynäkologie",
street="Marienplatz 3",
postal_code="86150",
city="Augsburg",
phone="0821 5550060",
email="kontakt@gyn-marienhof.schubamed.de",
hygiene_officer="Dr. Miriam Wolf",
quality_manager="K. Braun",
operator="Dr. Miriam Wolf",
locations=[
{"name": "Frauenpraxis", "street": "Marienplatz 3", "postal_code": "86150", "city": "Augsburg", "room": "Steri"},
{"name": "Ambulanz Süd", "street": "Bürgermeister-Fischer-Str. 12", "postal_code": "86150", "city": "Augsburg", "room": "Steri 2"},
],
contacts=[
{"full_name": "Dr. Miriam Wolf", "function": "Betreiberin", "email": "miriam.wolf@schubamed.de", "phone": "0821 5550061"},
],
device={
"manufacturer": "MELAG",
"model": "ProLine",
"serial_number": "SN-DEMO-006",
"year_built": 2019,
"commissioned_on": date(2019, 8, 23),
"chamber_volume_liters": 18,
"steam_generation": "Kompaktgenerator",
"water_treatment": "VE-Wasser",
"documentation": "CF-Card",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="Chirurgie Ost",
specialty="Chirurgie",
street="Lindenstr. 7",
postal_code="04109",
city="Leipzig",
phone="0341 5550070",
email="kontakt@chirurgie-ost.schubamed.de",
hygiene_officer="Dr. Paul Richter",
quality_manager="F. Scholz",
operator="Dr. Paul Richter",
locations=[
{"name": "OP-Zentrum", "street": "Lindenstr. 7", "postal_code": "04109", "city": "Leipzig", "room": "Steri OP"},
],
contacts=[
{"full_name": "Dr. Paul Richter", "function": "Betreiber", "email": "paul.richter@schubamed.de", "phone": "0341 5550071"},
{"full_name": "F. Scholz", "function": "Hygiene", "email": "hygiene.chirurgie.ost@schubamed.de", "phone": "0341 5550072"},
],
device={
"manufacturer": "W&H",
"model": "Lara XL",
"serial_number": "SN-DEMO-007",
"year_built": 2023,
"commissioned_on": date(2023, 2, 15),
"chamber_volume_liters": 17,
"steam_generation": "Dampferzeuger intern",
"water_treatment": "Mikrofiltration",
"documentation": "USB und Ausdruck",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="Allgemeinmedizin West",
specialty="Allgemeinmedizin",
street="Bergstr. 25",
postal_code="70173",
city="Stuttgart",
phone="0711 5550080",
email="kontakt@allgemeinmedizin-west.schubamed.de",
hygiene_officer="Dr. Nina Beck",
quality_manager="J. Hartmann",
operator="Dr. Nina Beck",
locations=[
{"name": "Hausarztpraxis", "street": "Bergstr. 25", "postal_code": "70173", "city": "Stuttgart", "room": "Steri"},
],
contacts=[
{"full_name": "Dr. Nina Beck", "function": "Betreiberin", "email": "nina.beck@schubamed.de", "phone": "0711 5550081"},
],
device={
"manufacturer": "W&H",
"model": "Lisa",
"serial_number": "SN-DEMO-008",
"year_built": 2022,
"commissioned_on": date(2022, 7, 12),
"chamber_volume_liters": 22,
"steam_generation": "Eigendampferzeugung",
"water_treatment": "VE-Wasser",
"documentation": "Digital und Papier",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="Augenärzte am Ring",
specialty="Augenarzt",
street="Ringstr. 52",
postal_code="50672",
city="Köln",
phone="0221 5550090",
email="kontakt@augenaerzte-ring.schubamed.de",
hygiene_officer="Dr. Karin Otto",
quality_manager="M. Franke",
operator="Dr. Karin Otto",
locations=[
{"name": "Augenzentrum", "street": "Ringstr. 52", "postal_code": "50672", "city": "Köln", "room": "Steri 1"},
{"name": "Laserzentrum", "street": "Ringstr. 54", "postal_code": "50672", "city": "Köln", "room": "Steri 2"},
],
contacts=[
{"full_name": "Dr. Karin Otto", "function": "Betreiberin", "email": "karin.otto@schubamed.de", "phone": "0221 5550091"},
{"full_name": "M. Franke", "function": "QM", "email": "qm.augen@schubamed.de", "phone": "0221 5550092"},
],
device={
"manufacturer": "Mocom",
"model": "B Classic",
"serial_number": "SN-DEMO-009",
"year_built": 2021,
"commissioned_on": date(2021, 5, 20),
"chamber_volume_liters": 12,
"steam_generation": "Kompaktgenerator",
"water_treatment": "Vollentsalzung",
"documentation": "USB-Export",
"supplier": "schubamed Medizintechnik",
},
),
DemoCustomerTemplate(
name="MVZ Medikon",
specialty="MVZ",
street="Forum 9",
postal_code="90402",
city="Nürnberg",
phone="0911 5550100",
email="kontakt@mvz-medikon.schubamed.de",
hygiene_officer="Dr. Lara Stein",
quality_manager="C. Meier",
operator="Dr. Lara Stein",
locations=[
{"name": "Hauptstandort", "street": "Forum 9", "postal_code": "90402", "city": "Nürnberg", "room": "Steri A"},
{"name": "Nebenstandort", "street": "Forum 11", "postal_code": "90402", "city": "Nürnberg", "room": "Steri B"},
],
contacts=[
{"full_name": "Dr. Lara Stein", "function": "Betreiberin", "email": "lara.stein@schubamed.de", "phone": "0911 5550101"},
{"full_name": "C. Meier", "function": "Hygiene", "email": "hygiene.mvz@schubamed.de", "phone": "0911 5550102"},
],
device={
"manufacturer": "Kronos",
"model": "B23",
"serial_number": "SN-DEMO-010",
"year_built": 2024,
"commissioned_on": date(2024, 1, 11),
"chamber_volume_liters": 23,
"steam_generation": "Eigendampferzeugung",
"water_treatment": "VE-Wasser",
"documentation": "Hybrid",
"supplier": "schubamed Medizintechnik",
},
),
]
EQUIPMENT_TEMPLATES: list[dict[str, Any]] = [
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "EBRO",
"model": "EBI 11",
"serial_number": "DEMO-EBI11-001",
"calibrated_on": date(2025, 1, 17),
"calibration_due_on": date(2026, 1, 17),
"status": EquipmentStatus.green,
"notes": "Demo-Temperaturlogger",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "EBRO",
"model": "EBI 11",
"serial_number": "DEMO-EBI11-002",
"calibrated_on": date(2025, 2, 2),
"calibration_due_on": date(2026, 2, 2),
"status": EquipmentStatus.green,
"notes": "Demo-Temperaturlogger",
},
{
"kind": EquipmentKind.pressure_logger,
"manufacturer": "EBRO",
"model": "Drucklogger",
"serial_number": "DEMO-PR-001",
"calibrated_on": date(2025, 1, 21),
"calibration_due_on": date(2026, 1, 21),
"status": EquipmentStatus.green,
"notes": "Demo-Drucklogger",
},
{
"kind": EquipmentKind.sensor,
"manufacturer": "Mettler Toledo",
"model": "Waage",
"serial_number": "DEMO-SCALE-001",
"calibrated_on": date(2024, 12, 12),
"calibration_due_on": date(2025, 12, 12),
"status": EquipmentStatus.yellow,
"notes": "Demo-Waage",
},
{
"kind": EquipmentKind.sensor,
"manufacturer": "Mettler Toledo",
"model": "Leitwertmessgerät",
"serial_number": "DEMO-COND-001",
"calibrated_on": date(2025, 3, 4),
"calibration_due_on": date(2026, 3, 4),
"status": EquipmentStatus.green,
"notes": "Demo-Leitwertmessgerät",
},
{
"kind": EquipmentKind.sensor,
"manufacturer": "Trotec",
"model": "Raumklimamessgerät",
"serial_number": "DEMO-CLIMATE-001",
"calibrated_on": date(2024, 11, 1),
"calibration_due_on": date(2025, 11, 1),
"status": EquipmentStatus.red,
"notes": "Demo-Raumklimamessgerät",
},
]
class DemoDataService:
def __init__(self, session: Session, upload_root: Path | None = None, report_root: Path | None = None) -> None:
self.session = session
self.upload_root = upload_root or DEMO_UPLOAD_ROOT.parent
self.report_root = report_root or DEMO_REPORT_ROOT
self.demo_upload_root = self.upload_root / "demo-data"
def run(
self,
*,
customers: int = 10,
devices: int = 10,
validations: int = 20,
reports: bool = False,
images: bool = False,
reset: bool = False,
) -> dict[str, int]:
if reset:
self.reset()
created_customers = self.ensure_customers(max(customers, 10))
created_equipment = self.ensure_equipment()
created_devices = self.ensure_devices(max(devices, 10), created_customers)
created_validations = self.ensure_validations(max(validations, 20), created_customers, created_devices, images=images)
self.session.commit()
if reports:
self._generate_reports(created_validations)
return {
"customers": len(created_customers),
"devices": len(created_devices),
"validations": len(created_validations),
"equipment": len(created_equipment),
}
def reset(self) -> None:
demo_validation_ids = self.session.scalars(
select(Validation.id).where(
Validation.report_number.like(f"{DEMO_REPORT_PREFIX}%")
| Validation.notes.like(f"%{DEMO_TAG}%")
)
).all()
if demo_validation_ids:
self.session.execute(delete(Validation).where(Validation.id.in_(demo_validation_ids)))
self.session.execute(
delete(Equipment).where(Equipment.serial_number.like("DEMO-%") | Equipment.notes.like(f"%{DEMO_TAG}%"))
)
self.session.execute(
delete(Device).where(Device.serial_number.like("SN-DEMO-%") | Device.notes.like(f"%{DEMO_TAG}%"))
)
self.session.execute(
delete(Contact).where(
Contact.email.like("demo-%@schubamed.de") | Contact.notes.like(f"%{DEMO_TAG}%")
)
)
self.session.execute(
delete(Customer).where(
Customer.email.like("demo-%@schubamed.de") | Customer.notes.like(f"%{DEMO_TAG}%")
)
)
self.session.commit()
if self.demo_upload_root.exists():
shutil.rmtree(self.demo_upload_root)
self.report_root.mkdir(parents=True, exist_ok=True)
for pdf in self.report_root.glob(f"{DEMO_REPORT_PREFIX}*.pdf"):
pdf.unlink()
def ensure_customers(self, count: int) -> list[Customer]:
ensured: list[Customer] = []
for index in range(count):
template = CUSTOMER_TEMPLATES[index % len(CUSTOMER_TEMPLATES)]
occurrence = index // len(CUSTOMER_TEMPLATES)
customer = self._ensure_customer(template, occurrence, index)
ensured.append(customer)
return ensured
def ensure_equipment(self) -> list[Equipment]:
items: list[Equipment] = []
for template in EQUIPMENT_TEMPLATES:
equipment = self.session.scalar(
select(Equipment).where(Equipment.serial_number == template["serial_number"])
)
if equipment is None:
equipment = Equipment(**template)
equipment.notes = f"{template['notes']} {DEMO_TAG}"
self.session.add(equipment)
self.session.flush()
items.append(equipment)
return items
def ensure_devices(self, count: int, customers: list[Customer]) -> list[Device]:
devices: list[Device] = []
all_locations = {customer.id: list(self.session.scalars(select(Location).where(Location.customer_id == customer.id))) for customer in customers}
for index in range(count):
customer = customers[index % len(customers)]
template = CUSTOMER_TEMPLATES[index % len(CUSTOMER_TEMPLATES)].device
occurrence = index // len(CUSTOMER_TEMPLATES)
serial_number = template["serial_number"] if occurrence == 0 else f"{template['serial_number']}-{occurrence + 1}"
device = self.session.scalar(select(Device).where(Device.serial_number == serial_number))
if device is None:
location_candidates = all_locations.get(customer.id, [])
location_id = location_candidates[0].id if location_candidates else None
device_payload = deepcopy(template)
device_payload["serial_number"] = serial_number
device_payload["customer_id"] = customer.id
device_payload["location_id"] = location_id
device = Device(**device_payload)
device.notes = f"{DEMO_TAG} {customer.name}"
self.session.add(device)
self.session.flush()
devices.append(device)
return devices
def ensure_validations(
self,
count: int,
customers: list[Customer],
devices: list[Device],
*,
images: bool = False,
) -> list[Validation]:
validations: list[Validation] = []
report_number_index = 1
for index in range(count):
template = CUSTOMER_TEMPLATES[index % len(CUSTOMER_TEMPLATES)]
customer = customers[index % len(customers)]
device = devices[index % len(devices)]
locations = list(self.session.scalars(select(Location).where(Location.customer_id == customer.id)))
contacts = list(self.session.scalars(select(Contact).where(Contact.customer_id == customer.id)))
location = locations[index % len(locations)] if locations else None
contact = contacts[index % len(contacts)] if contacts else None
report_number = f"{DEMO_REPORT_PREFIX}-{report_number_index:03d}"
report_number_index += 1
validation = self.session.scalar(select(Validation).where(Validation.report_number == report_number))
if validation is None:
validation = Validation(
report_number=report_number,
customer_id=customer.id,
location_id=location.id if location else None,
contact_id=contact.id if contact else None,
device_id=device.id,
validation_type="Erstvalidierung" if index % 2 == 0 else "Revalidierung",
performed_on=date.today() - relativedelta(months=6 + index),
examiner_name="Dr. Lara Stein",
operator_name=template.operator,
participants=f"{customer.name} - Team",
status=[
ValidationStatus.draft.value,
ValidationStatus.approved.value,
ValidationStatus.completed.value,
][index % 3],
result="bestanden" if index % 3 else "bestanden mit Auflagen",
revalidation_interval_months=24,
equipment_ids=[equipment.id for equipment in self.session.scalars(select(Equipment).limit(3))],
environment_conditions={
"room_temperature": 22 + (index % 3),
"humidity": 48 + (index % 5),
"test_time": f"{8 + (index % 3)}:30",
"checks": [
{"text": "Raumbedingungen stabil", "value": "yes", "comment": ""},
{"text": "Aufstellort frei zugänglich", "value": "yes", "comment": ""},
],
},
documentation_checklist=self._checklist("documentation_control", index),
performance_checklist=self._checklist("sterilizer_description", index),
programs=[
{"name": "Vakuumtest", "selected": True, "custom": False},
{"name": "Bowie-Dick / Leerkammerprofil", "selected": index % 2 == 0, "custom": False},
{"name": "134 C hohl verpackt", "selected": True, "custom": False},
],
loading_patterns=self._loading_patterns(index),
measurement_data=self._measurement_data(index),
drying={"start_weight": 12.5, "end_weight": 12.1, "difference": 0.4, "assessment": "in Ordnung", "comment": ""},
recommendations=[
{"number": 1, "text": "Routinekontrolle dokumentieren", "deadline": "6 Monate", "status": "offen"}
] if index % 4 == 0 else [],
attachments=self._attachments_for_validation(index, images=images),
notes=DEMO_TAG,
)
self.session.add(validation)
self.session.flush()
ValidationWorkflowService(self.session).apply_revalidation_date(validation)
validations.append(validation)
return validations
def _ensure_customer(self, template: DemoCustomerTemplate, occurrence: int, index: int) -> Customer:
email = template.email if occurrence == 0 else f"demo-{index + 1:02d}@schubamed.de"
customer = self.session.scalar(select(Customer).where(Customer.email == email))
if customer is None:
customer = Customer(
customer_type=CustomerType.practice,
name=template.name if occurrence == 0 else f"{template.name} {occurrence + 1}",
street=template.street,
postal_code=template.postal_code,
city=template.city,
phone=template.phone,
email=email,
hygiene_officer=template.hygiene_officer,
quality_manager=template.quality_manager,
notes=f"{DEMO_TAG} {template.specialty}",
)
self.session.add(customer)
self.session.flush()
for loc_index, location_payload in enumerate(template.locations, start=1):
location = self.session.scalar(
select(Location).where(
Location.customer_id == customer.id,
Location.name == location_payload["name"],
)
)
if location is None:
self.session.add(Location(customer_id=customer.id, **location_payload))
for contact_payload in template.contacts:
contact = self.session.scalar(
select(Contact).where(
Contact.customer_id == customer.id,
Contact.full_name == contact_payload["full_name"],
)
)
if contact is None:
self.session.add(
Contact(
customer_id=customer.id,
full_name=contact_payload["full_name"],
function=contact_payload["function"],
email=contact_payload["email"],
phone=contact_payload["phone"],
notes=DEMO_TAG,
)
)
self.session.flush()
return customer
def _checklist(self, key: str, index: int) -> list[dict[str, Any]]:
bundle = ReportTemplateService(self.session).ensure_default_template()
template = next((item for item in bundle.checklists if item.checklist_key == key), None)
if template is None:
return []
values = ["yes", "yes", "na"] if index % 2 == 0 else ["yes", "na", "yes"]
return [
{
"number": item["number"],
"text": item["text"],
"value": values[(item["number"] - 1) % len(values)],
"comment": "",
}
for item in template.items
]
def _loading_patterns(self, index: int) -> list[dict[str, Any]]:
return [
{
"run": 1,
"pattern": "Standard",
"description": f"Demo-Beladung {index + 1}",
"images": [],
},
{
"run": 2,
"pattern": "Standard",
"description": "",
"images": [],
},
{
"run": 3,
"pattern": "Standard",
"description": "",
"images": [],
},
]
def _measurement_data(self, index: int) -> list[dict[str, Any]]:
base_temperature = 132 + (index % 3)
return [
{
"test_run": "Vakuumtest",
"start_time": "08:00",
"end_time": "08:08",
"duration": "00:08",
"leak_rate": "0.4",
"result": "bestanden",
},
{
"test_run": "Testlauf 1",
"start_time": "09:00",
"end_time": "09:31",
"duration": "00:31",
"minimum_temperature": base_temperature,
"maximum_temperature": base_temperature + 2,
"temperature_band": "2.0",
"holding_time": "3:00",
"pressure": "2.1",
"result": "bestanden",
},
]
def _attachments_for_validation(self, index: int, *, images: bool) -> list[dict[str, Any]]:
if not images or index >= 5:
return []
image_dir = self.demo_upload_root / "images"
image_dir.mkdir(parents=True, exist_ok=True)
image_path = image_dir / f"demo-figure-{index + 1}.svg"
if not image_path.exists():
image_path.write_text(
"\n".join(
[
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800">',
'<rect width="1200" height="800" fill="#f6f7f2"/>',
'<rect x="60" y="60" width="1080" height="680" rx="18" fill="#ffffff" stroke="#bfc5bd" stroke-width="3"/>',
'<text x="100" y="150" font-family="Arial, sans-serif" font-size="44" fill="#2f3b35">SCHUBAMED Demo-Bild</text>',
f'<text x="100" y="230" font-family="Arial, sans-serif" font-size="32" fill="#4d5b54">Validierung {index + 1}</text>',
"</svg>",
]
),
encoding="utf-8",
)
return [
{
"category": "Beladung",
"filename": image_path.name,
"content_type": "image/svg+xml",
"description": f"Demo-Abbildung {index + 1}",
"order": 1,
"storage_path": str(image_path),
}
]
def _generate_reports(self, validations: list[Validation]) -> None:
self.report_root.mkdir(parents=True, exist_ok=True)
ReportTemplateService(self.session).ensure_default_template()
for validation in validations:
OrionReportService(self.session, self.report_root).render_pdf(validation.id)

View file

@ -12,7 +12,6 @@ from app.models.customer import Customer
from app.models.device import Device from app.models.device import Device
from app.models.equipment import Equipment from app.models.equipment import Equipment
from app.models.location import Location from app.models.location import Location
from app.models.user import User, UserRole
from app.models.validation import Validation from app.models.validation import Validation
from app.repositories.base import Repository from app.repositories.base import Repository
from app.repositories.domain import ( from app.repositories.domain import (
@ -21,7 +20,6 @@ from app.repositories.domain import (
DeviceRepository, DeviceRepository,
EquipmentRepository, EquipmentRepository,
LocationRepository, LocationRepository,
UserRepository,
ValidationRepository, ValidationRepository,
) )
@ -36,13 +34,11 @@ class CrudService:
safe_page = max(page, 1) safe_page = max(page, 1)
safe_page_size = min(max(page_size, 1), 100) safe_page_size = min(max(page_size, 1), 100)
offset = (safe_page - 1) * safe_page_size offset = (safe_page - 1) * safe_page_size
total = self.repository.count(search)
return { return {
"items": self.repository.list(safe_page_size, offset, search), "items": self.repository.list(safe_page_size, offset, search),
"total": total, "total": self.repository.count(search),
"page": safe_page, "page": safe_page,
"page_size": safe_page_size, "page_size": safe_page_size,
"pages": max((total + safe_page_size - 1) // safe_page_size, 1),
} }
def create(self, data: dict) -> ModelT: def create(self, data: dict) -> ModelT:
@ -82,7 +78,6 @@ class CrudService:
class DomainServices: class DomainServices:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
self.users: CrudService[User] = CrudService(UserRepository(session))
self.customers: CrudService[Customer] = CrudService(CustomerRepository(session)) self.customers: CrudService[Customer] = CrudService(CustomerRepository(session))
self.locations: CrudService[Location] = CrudService(LocationRepository(session)) self.locations: CrudService[Location] = CrudService(LocationRepository(session))
self.contacts: CrudService[Contact] = CrudService(ContactRepository(session)) self.contacts: CrudService[Contact] = CrudService(ContactRepository(session))

View file

@ -1,268 +0,0 @@
from __future__ import annotations
from copy import deepcopy
from datetime import date
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment, EquipmentKind
from app.models.location import Location
from app.models.user import User
from app.models.validation import Validation, ValidationStatus
from app.modules.orion.template_service import ReportTemplateService
from app.schemas.domain import QuickStartCreateRequest, QuickStartCustomerData, QuickStartValidationSummary
from app.services.validation_workflow import ValidationWorkflowService
class QuickStartService:
def __init__(self, session: Session) -> None:
self.session = session
def customer_data(self, customer_id: str) -> QuickStartCustomerData:
customer = self._customer(customer_id)
locations = list(
self.session.scalars(
select(Location).where(Location.customer_id == customer.id).order_by(Location.name.asc())
)
)
contacts = list(
self.session.scalars(
select(Contact).where(Contact.customer_id == customer.id).order_by(Contact.full_name.asc())
)
)
devices = list(
self.session.scalars(
select(Device).where(Device.customer_id == customer.id).order_by(Device.serial_number.asc())
)
)
validations = self._validation_summaries([device.id for device in devices])
return QuickStartCustomerData(
customer=customer,
locations=locations,
contacts=contacts,
devices=devices,
validations=validations,
)
def device_last_validation(self, device_id: str) -> QuickStartValidationSummary | None:
return self._last_validation(device_id)
def create_validation(self, payload: QuickStartCreateRequest, examiner: User) -> Validation:
customer = self._customer(str(payload.customer_id))
devices = list(
self.session.scalars(
select(Device).where(Device.customer_id == customer.id).order_by(Device.serial_number.asc())
)
)
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())
)
)
contacts = list(
self.session.scalars(
select(Contact).where(Contact.customer_id == customer.id).order_by(Contact.full_name.asc())
)
)
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,
contact_id=contact.id if contact else None,
device_id=device.id,
validation_type=payload.validation_type,
performed_on=date.today(),
examiner_name=examiner.full_name,
examiner_id=examiner.id,
operator_name=customer.quality_manager or customer.hygiene_officer or examiner.full_name,
status=ValidationStatus.draft.value,
result="offen",
revalidation_interval_months=base.revalidation_interval_months if base else 24,
next_validation_manually_overridden=False,
version=1 if base is None else base.version + 1,
previous_validation_id=base.id if base else None,
equipment_ids=list(base.equipment_ids) if base and base.equipment_ids else self._default_equipment_ids(),
environment_conditions=deepcopy(base.environment_conditions) if base else {},
documentation_checklist=deepcopy(base.documentation_checklist) if base else self._template_checklist(bundle, "documentation_checklist"),
performance_checklist=deepcopy(base.performance_checklist) if base else self._template_checklist(bundle, "performance_checklist"),
programs=deepcopy(base.programs) if base else self._default_programs(),
loading_patterns=deepcopy(base.loading_patterns) if base else self._default_loading_patterns(),
measurement_data=[],
drying={},
recommendations=deepcopy(base.recommendations) if base else [],
attachments=[],
)
self.session.add(validation)
self.session.flush()
ValidationWorkflowService(self.session).apply_revalidation_date(validation)
return validation
def _customer(self, customer_id: str) -> Customer:
customer = self.session.get(Customer, customer_id)
if customer is None:
raise ValueError("Kunde nicht gefunden.")
return customer
def _device(self, device_id: str) -> Device:
device = self.session.get(Device, device_id)
if device is None:
raise ValueError("Gerät nicht gefunden.")
return device
def _location(self, location_id: str) -> Location:
location = self.session.get(Location, location_id)
if location is None:
raise ValueError("Standort nicht gefunden.")
return location
def _contact(self, contact_id: str) -> Contact:
contact = self.session.get(Contact, contact_id)
if contact is None:
raise ValueError("Ansprechpartner nicht gefunden.")
return contact
def _single_or_default(self, items):
if len(items) == 1:
return items[0]
return None
def _last_validation(self, device_id: str) -> QuickStartValidationSummary | None:
validation = self.session.scalar(
select(Validation)
.where(
and_(
Validation.device_id == device_id,
Validation.status.in_([ValidationStatus.approved.value, ValidationStatus.completed.value]),
)
)
.order_by(Validation.performed_on.desc().nulls_last(), Validation.updated_at.desc())
)
if validation is None:
return None
return self._summary(validation)
def _validation_summaries(self, device_ids: list[str]) -> list[QuickStartValidationSummary]:
if not device_ids:
return []
validations = list(
self.session.scalars(
select(Validation)
.where(Validation.device_id.in_(device_ids))
.order_by(Validation.performed_on.desc().nulls_last(), Validation.updated_at.desc())
.limit(10)
)
)
return [self._summary(item) for item in validations]
def _summary(self, validation: Validation) -> QuickStartValidationSummary:
return QuickStartValidationSummary(
id=validation.id,
device_id=validation.device_id,
report_number=validation.report_number,
performed_on=validation.performed_on,
result=validation.result,
next_validation_on=validation.next_validation_on,
status=validation.status,
validation_type=validation.validation_type,
equipment_ids=list(validation.equipment_ids or []),
)
def _base_validation(self, device_id: str, validation_type: str) -> Validation | None:
if validation_type not in {"Revalidierung", "Leistungsbeurteilung"}:
return None
return self.session.scalar(
select(Validation)
.where(
and_(
Validation.device_id == device_id,
Validation.status.in_([ValidationStatus.approved.value, ValidationStatus.completed.value]),
)
)
.order_by(Validation.performed_on.desc().nulls_last(), Validation.updated_at.desc())
)
def _next_report_number(self) -> str:
total = self.session.scalar(select(func.count()).select_from(Validation)) or 0
return f"VAL-{total + 1:05d}"
def _default_equipment_ids(self) -> list[str]:
equipment = list(
self.session.scalars(
select(Equipment).where(Equipment.kind == EquipmentKind.temperature_logger).order_by(Equipment.serial_number.asc())
)
)
equipment.extend(
list(
self.session.scalars(
select(Equipment).where(Equipment.kind == EquipmentKind.pressure_logger).order_by(Equipment.serial_number.asc())
)
)
)
selected: list[str] = []
temp_count = 0
pressure_count = 0
for item in equipment:
if item.kind == EquipmentKind.temperature_logger and temp_count < 5:
selected.append(item.id)
temp_count += 1
elif item.kind == EquipmentKind.pressure_logger and pressure_count < 1:
selected.append(item.id)
pressure_count += 1
return selected
def _template_checklist(self, bundle, key: str) -> list[dict]:
checklist_key = {
"documentation_checklist": "documentation_control",
"performance_checklist": "sterilizer_description",
}.get(key, key)
template = next((item for item in bundle.checklists if item.checklist_key == checklist_key), None)
if template is None:
return []
return [
{
"template_key": template.checklist_key,
"template_title": template.title,
"text": item,
"value": "na",
"comment": "",
}
for item in template.items
]
def _default_programs(self) -> list[dict]:
return [
{"name": "Vakuumtest", "selected": True, "custom": False},
{"name": "Bowie-Dick / Leerkammerprofil", "selected": True, "custom": False},
{"name": "134 C hohl verpackt", "selected": True, "custom": False},
]
def _default_loading_patterns(self) -> list[dict]:
return [
{"run": 1, "pattern": "Standardbeladung", "description": "", "images": []},
{"run": 2, "pattern": "Standardbeladung", "description": "", "images": []},
{"run": 3, "pattern": "Standardbeladung", "description": "", "images": []},
]

View file

@ -1,380 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
import re
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.contact import Contact
from app.models.customer import Customer, CustomerType
from app.models.device import Device
from app.models.equipment import Equipment, EquipmentKind, EquipmentStatus
from app.models.location import Location
from app.models.validation import Validation, ValidationStatus
@dataclass
class ImportResult:
created: list[str] = field(default_factory=list)
updated: list[str] = field(default_factory=list)
unchanged: list[str] = field(default_factory=list)
conflicts: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
validation_id: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"created": self.created,
"updated": self.updated,
"unchanged": self.unchanged,
"conflicts": self.conflicts,
"errors": self.errors,
"validation_id": self.validation_id,
}
class ReferenceMasterdataImportService:
def __init__(self, session: Session) -> None:
self.session = session
def import_reference_docx(
self,
reference_path: Path,
*,
dry_run: bool = False,
update_existing: bool = False,
create_validation: bool = False,
) -> ImportResult:
if not reference_path.exists():
raise FileNotFoundError(reference_path)
result = ImportResult()
payload = self._payload()
customer = self._upsert_customer(payload["customer"], result, dry_run, update_existing)
location = self._upsert_location(customer, payload["location"], result, dry_run, update_existing)
self._upsert_contacts(customer, payload["contacts"], result, dry_run, update_existing)
device = self._upsert_device(customer, location, payload["device"], result, dry_run, update_existing)
self._upsert_equipment(payload["equipment"], result, dry_run, update_existing)
if create_validation:
validation = self._create_validation(customer, location, device, result, dry_run)
result.validation_id = validation.id if validation is not None else None
if not dry_run:
self.session.commit()
return result
def _payload(self) -> dict[str, Any]:
return {
"customer": {
"name": "Urologische Praxis Dr. Durmaz",
"customer_type": CustomerType.practice,
"specialty": "Urologie",
"display_name": "Urologie Dr. Durmaz",
},
"location": {
"name": "Praxis Nürnberg",
"street": "Wölckernstr. 5",
"postal_code": "90459",
"city": "Nürnberg",
"country": "Deutschland",
},
"contacts": [
{
"full_name": "Dr. Durmaz",
"function": "Verantwortlicher Betreiber",
"notes": "Arzt; QM-Mitverantwortlicher",
},
{
"full_name": "Frau Bal",
"function": "QM-Beauftragte",
"notes": "Hygienebeauftragte; Sachkunde A / Fachkenntnisse Aufbereitung und Freigabe von Medizinprodukten",
},
],
"device": {
"manufacturer": "Euronda SpA",
"model": "E10.7",
"device_type": "Dampf-Kleinsterilisator Klasse B",
"serial_number": "EXN250688",
"year_built": 2025,
"commissioned_on": date(2025, 12, 5),
"chamber_volume_liters": 23,
"steam_generation": "Eigendampferzeugung",
"water_treatment": "Wasserversorgung über Aquafilter Euronda",
"documentation": "interne CF-Card; Protokollausgabe am PC/Rechner möglich",
"supplier": "schubamed-Medizintechnik, 92421 Schwandorf",
"notes": "Sterilisationsverfahren: Konditionierung mit Wasserdampf; Konditionierung teilweise oberhalb und unterhalb des Umgebungsdruckes; Chargendokumentation über interne CF-Card.",
},
"equipment": [
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15102807",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T235; Messbereich 0 °C bis +150 °C",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15211066",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15102538",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15211067",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C",
},
{
"kind": EquipmentKind.temperature_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "15125738",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Bezeichnung T240; Quellenkonflikt: Referenz nennt auch 1525738, mehrfach belegt ist 15125738.",
},
{
"kind": EquipmentKind.pressure_logger,
"manufacturer": "Ebro",
"model": "EBI 11",
"serial_number": "P111",
"calibrated_on": date(2025, 1, 17),
"status": EquipmentStatus.green,
"notes": "Drucklogger",
},
],
}
def _normalize(self, value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", value.lower())
def _upsert_customer(self, payload: dict[str, Any], result: ImportResult, dry_run: bool, update_existing: bool) -> Customer:
target_name = self._normalize(payload["name"])
customer = next(
(item for item in self.session.scalars(select(Customer)) if self._normalize(item.name) == target_name),
None,
)
if customer is None:
customer = Customer(customer_type=payload["customer_type"], name=payload["name"])
customer.notes = f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}"
if not dry_run:
self.session.add(customer)
self.session.flush()
result.created.append("customer")
return customer
if update_existing:
changed = False
if customer.notes != f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}":
customer.notes = f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}"
changed = True
if customer.customer_type != payload["customer_type"]:
customer.customer_type = payload["customer_type"]
changed = True
if changed:
result.updated.append("customer")
else:
result.unchanged.append("customer")
else:
result.unchanged.append("customer")
return customer
def _upsert_location(self, customer: Customer, payload: dict[str, Any], result: ImportResult, dry_run: bool, update_existing: bool) -> Location:
location = next(
(
item
for item in self.session.scalars(select(Location).where(Location.customer_id == customer.id))
if item.street == payload["street"]
and item.postal_code == payload["postal_code"]
and item.city == payload["city"]
),
None,
)
if location is None:
location = Location(
customer_id=customer.id,
name=payload["name"],
street=payload["street"],
postal_code=payload["postal_code"],
city=payload["city"],
)
if not dry_run:
self.session.add(location)
self.session.flush()
result.created.append("location")
return location
if update_existing:
location.name = payload["name"]
result.updated.append("location")
else:
result.unchanged.append("location")
return location
def _upsert_contacts(
self,
customer: Customer,
contacts: list[dict[str, Any]],
result: ImportResult,
dry_run: bool,
update_existing: bool,
) -> None:
for payload in contacts:
contact = self.session.scalar(
select(Contact).where(
Contact.customer_id == customer.id,
Contact.full_name == payload["full_name"],
)
)
notes = payload["notes"]
if contact is None:
contact = Contact(
customer_id=customer.id,
full_name=payload["full_name"],
function=payload["function"],
notes=notes,
)
if not dry_run:
self.session.add(contact)
self.session.flush()
result.created.append(f"contact:{payload['full_name']}")
continue
if update_existing:
contact.function = payload["function"]
contact.notes = notes
result.updated.append(f"contact:{payload['full_name']}")
else:
result.unchanged.append(f"contact:{payload['full_name']}")
def _upsert_device(
self,
customer: Customer,
location: Location,
payload: dict[str, Any],
result: ImportResult,
dry_run: bool,
update_existing: bool,
) -> Device:
device = self.session.scalar(select(Device).where(Device.serial_number == payload["serial_number"]))
if device is None:
device = Device(
customer_id=customer.id,
location_id=location.id,
manufacturer=payload["manufacturer"],
model=payload["model"],
device_type=payload["device_type"],
serial_number=payload["serial_number"],
year_built=payload["year_built"],
commissioned_on=payload["commissioned_on"],
chamber_volume_liters=payload["chamber_volume_liters"],
steam_generation=payload["steam_generation"],
water_treatment=payload["water_treatment"],
documentation=payload["documentation"],
supplier=payload["supplier"],
notes=payload["notes"],
)
if not dry_run:
self.session.add(device)
self.session.flush()
result.created.append("device")
return device
if update_existing:
for key, value in payload.items():
if hasattr(device, key):
setattr(device, key, value)
result.updated.append("device")
else:
result.unchanged.append("device")
return device
def _upsert_equipment(
self,
items: list[dict[str, Any]],
result: ImportResult,
dry_run: bool,
update_existing: bool,
) -> None:
for payload in items:
equipment = self.session.scalar(
select(Equipment).where(Equipment.serial_number == payload["serial_number"])
)
if equipment is None:
equipment = Equipment(
kind=payload["kind"],
manufacturer=payload["manufacturer"],
model=payload["model"],
serial_number=payload["serial_number"],
calibrated_on=payload["calibrated_on"],
status=payload["status"],
notes=payload["notes"],
)
if not dry_run:
self.session.add(equipment)
self.session.flush()
result.created.append(f"equipment:{payload['serial_number']}")
continue
if update_existing:
equipment.kind = payload["kind"]
equipment.manufacturer = payload["manufacturer"]
equipment.model = payload["model"]
equipment.calibrated_on = payload["calibrated_on"]
equipment.status = payload["status"]
equipment.notes = payload["notes"]
result.updated.append(f"equipment:{payload['serial_number']}")
else:
result.unchanged.append(f"equipment:{payload['serial_number']}")
def _create_validation(
self,
customer: Customer,
location: Location,
device: Device,
result: ImportResult,
dry_run: bool,
) -> Validation | None:
existing = self.session.scalar(
select(Validation).where(
Validation.customer_id == customer.id,
Validation.device_id == device.id,
Validation.validation_type == "Erstvalidierung",
)
)
if existing is not None:
result.unchanged.append("validation")
return existing
validation = Validation(
report_number="REF-IMPORT-1",
customer_id=customer.id,
location_id=location.id,
device_id=device.id,
validation_type="Erstvalidierung",
status=ValidationStatus.draft.value,
examiner_name="nicht erfasst",
)
if not dry_run:
self.session.add(validation)
self.session.flush()
result.created.append("validation")
return validation

View file

@ -1,77 +0,0 @@
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

View file

@ -7,7 +7,7 @@ from datetime import date, datetime
from uuid import uuid4 from uuid import uuid4
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.contact import Contact from app.models.contact import Contact
@ -206,21 +206,9 @@ class ValidationWorkflowService:
Validation.result.ilike(term), Validation.result.ilike(term),
) )
) )
for key in ["status", "customer_id", "device_id", "validation_type"]: for key in ["status", "customer_id", "device_id", "validation_type", "result"]:
if filters.get(key): if filters.get(key):
conditions.append(getattr(Validation, key) == filters[key]) conditions.append(getattr(Validation, key) == filters[key])
if filters.get("result"):
result_aliases = {
"OFFEN": ["OFFEN", "offen", ""],
"BESTANDEN": ["BESTANDEN", "bestanden"],
"BESTANDEN_MIT_AUFLAGEN": ["BESTANDEN_MIT_AUFLAGEN", "bestanden_mit_auflagen", "mit_auflagen", "bestanden mit Auflagen"],
"NICHT_BESTANDEN": ["NICHT_BESTANDEN", "nicht_bestanden", "nicht bestanden"],
}
result_values = result_aliases.get(filters["result"], [filters["result"]])
if filters["result"] == "OFFEN":
conditions.append(or_(Validation.result.in_(result_values), Validation.result.is_(None)))
else:
conditions.append(Validation.result.in_(result_values))
if filters.get("date_from"): if filters.get("date_from"):
conditions.append(Validation.performed_on >= filters["date_from"]) conditions.append(Validation.performed_on >= filters["date_from"])
if filters.get("date_to"): if filters.get("date_to"):
@ -236,17 +224,9 @@ class ValidationWorkflowService:
statement = statement.order_by(sort_column.asc()) statement = statement.order_by(sort_column.asc())
else: else:
statement = statement.order_by(sort_column.desc()) statement = statement.order_by(sort_column.desc())
total = self.session.scalar( total = len(list(self.session.scalars(count_statement)))
select(func.count()).select_from(count_statement.order_by(None).subquery())
) or 0
items = list(self.session.scalars(statement.offset((page - 1) * page_size).limit(page_size))) items = list(self.session.scalars(statement.offset((page - 1) * page_size).limit(page_size)))
return { return {"items": items, "total": total, "page": page, "page_size": page_size}
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": max((total + page_size - 1) // page_size, 1),
}
def revalidation_status(self, validation: Validation) -> str: def revalidation_status(self, validation: Validation) -> str:
if not validation.next_validation_on: if not validation.next_validation_on:

View file

@ -2,4 +2,6 @@
set -eu set -eu
alembic upgrade head alembic upgrade head
python -m app.db.seed
exec "$@" exec "$@"

View file

@ -1,99 +0,0 @@
from __future__ import annotations
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.contact import Contact
from app.models.customer import Customer, CustomerType
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.validation import Validation
from app import cli as app_cli
from app.services.demo_data import DemoDataService
def session() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
return Session(engine)
def test_demo_data_cli_parser_accepts_expected_options():
args = app_cli.build_parser().parse_args(
[
"demo-data",
"--customers",
"10",
"--devices",
"10",
"--validations",
"20",
"--reports",
"--images",
]
)
assert args.command == "demo-data"
assert args.customers == 10
assert args.devices == 10
assert args.validations == 20
assert args.reports is True
assert args.images is True
def test_demo_data_service_is_idempotent_and_creates_realistic_data(tmp_path):
db = session()
service = DemoDataService(db, upload_root=tmp_path / "uploads", report_root=tmp_path / "reports")
summary_first = service.run(customers=10, devices=10, validations=20, images=True)
assert summary_first["customers"] == 10
assert summary_first["devices"] == 10
assert summary_first["validations"] == 20
assert db.scalar(select(func.count()).select_from(Customer)) == 10
assert db.scalar(select(func.count()).select_from(Device)) == 10
assert db.scalar(select(func.count()).select_from(Equipment)) >= 6
assert db.scalar(select(func.count()).select_from(Validation)) == 20
assert db.scalar(select(func.count()).select_from(Contact)) >= 10
assert list((tmp_path / "uploads").rglob("*.svg"))
summary_second = service.run(customers=10, devices=10, validations=20, images=True)
assert summary_second["customers"] == 10
assert db.scalar(select(func.count()).select_from(Customer)) == 10
assert db.scalar(select(func.count()).select_from(Device)) == 10
assert db.scalar(select(func.count()).select_from(Validation)) == 20
def test_demo_data_reset_removes_only_demo_rows(tmp_path):
db = session()
real_customer = Customer(
customer_type=CustomerType.practice,
name="Reale Praxis",
email="real@example.com",
)
db.add(real_customer)
db.flush()
service = DemoDataService(db, upload_root=tmp_path / "uploads", report_root=tmp_path / "reports")
service.run(customers=10, devices=10, validations=20, images=True)
service.reset()
assert db.scalar(select(Customer).where(Customer.email == "real@example.com")) is not None
assert db.scalar(select(Customer).where(Customer.notes.like("%DEMO_DATA%"))) is None
assert db.scalar(select(Device).where(Device.notes.like("%DEMO_DATA%"))) is None
assert db.scalar(select(Validation).where(Validation.notes.like("%DEMO_DATA%"))) is None
def test_demo_data_cli_command_uses_sessionlocal(monkeypatch):
db = session()
monkeypatch.setattr(app_cli, "SessionLocal", lambda: db)
args = app_cli.build_parser().parse_args(["demo-data", "--customers", "10", "--devices", "10", "--validations", "20"])
exit_code = app_cli.cmd_demo_data(args)
assert exit_code == 0
assert db.scalar(select(func.count()).select_from(Customer)) == 10
assert db.scalar(select(func.count()).select_from(Device)) == 10
assert db.scalar(select(func.count()).select_from(Validation)) == 20

View file

@ -40,10 +40,7 @@ services:
depends_on: depends_on:
- mercury-api - mercury-api
environment: environment:
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-atlas_access_token} NEXT_PUBLIC_API_BASE_URL: http://localhost:8000/api/v1
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false}
AUTH_COOKIE_SAMESITE: ${AUTH_COOKIE_SAMESITE:-lax}
MERCURY_INTERNAL_URL: ${MERCURY_INTERNAL_URL:-http://mercury-api:8000}
ports: ports:
- "3000:3000" - "3000:3000"

View file

@ -1 +0,0 @@
MO_FrTIQMTyIgUY4at_vj

View file

@ -1,31 +0,0 @@
{
"/(app)/contacts/page": "/contacts",
"/(app)/customers/import/page": "/customers/import",
"/(app)/customers/page": "/customers",
"/(app)/dashboard/page": "/dashboard",
"/(app)/devices/page": "/devices",
"/(app)/documents/page": "/documents",
"/(app)/equipment/page": "/equipment",
"/(app)/locations/page": "/locations",
"/(app)/profile/security/page": "/profile/security",
"/(app)/settings/report-layout/page": "/settings/report-layout",
"/(app)/users/page": "/users",
"/(app)/validations/[id]/edit/page": "/validations/[id]/edit",
"/(app)/validations/[id]/preview/page": "/validations/[id]/preview",
"/(app)/validations/new/page": "/validations/new",
"/(app)/validations/page": "/validations",
"/(app)/validations/quick-start/page": "/validations/quick-start",
"/(auth)/login/page": "/login",
"/_global-error/page": "/_global-error",
"/_not-found/page": "/_not-found",
"/api/customer-imports/confirm/route": "/api/customer-imports/confirm",
"/api/customer-imports/preview/route": "/api/customer-imports/preview",
"/api/login/route": "/api/login",
"/api/logout/route": "/api/logout",
"/api/me/route": "/api/me",
"/api/report-settings/preview/route": "/api/report-settings/preview",
"/api/report-settings/route": "/api/report-settings",
"/api/v1/[...path]/route": "/api/v1/[...path]",
"/icon.svg/route": "/icon.svg",
"/page": "/"
}

View file

@ -1,22 +0,0 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [
"static/chunks/0cz1d0mv5g_q7.js"
],
"lowPriorityFiles": [
"static/MO_FrTIQMTyIgUY4at_vj/_buildManifest.js",
"static/MO_FrTIQMTyIgUY4at_vj/_ssgManifest.js",
"static/MO_FrTIQMTyIgUY4at_vj/_clientMiddlewareManifest.js"
],
"rootMainFiles": [
"static/chunks/2zjueh7t2vecu.js",
"static/chunks/30wdrt2uam-rs.js",
"static/chunks/0n-zjr76qg7uq.js",
"static/chunks/0iec5q4ack_04.js",
"static/chunks/27jktro2p5rq9.js",
"static/chunks/turbopack-06glzjf65-whj.js"
]
}

View file

@ -1,6 +0,0 @@
var R=require("./chunks/[turbopack]_runtime.js")("425d580d18f26225.js")
R.c("chunks/[turbopack-node]_transforms_postcss_ts_13hmb-_._.js")
R.c("chunks/[root-of-the-server]__1audplt._.js")
R.m("[turbopack-node]/child_process/globals.ts [postcss] (ecmascript)")
R.m("[turbopack-node]/child_process/evaluate.ts/evaluate.js { INNER => \"[turbopack-node]/transforms/postcss.ts { CONFIG => \\\"[project]/postcss.config.js_.loader.mjs [postcss] (ecmascript)\\\" } [postcss] (ecmascript)\", RUNTIME => \"[turbopack-node]/child_process/evaluate.ts [postcss] (ecmascript)\" } [postcss] (ecmascript)")
module.exports=R.m("[turbopack-node]/child_process/evaluate.ts/evaluate.js { INNER => \"[turbopack-node]/transforms/postcss.ts { CONFIG => \\\"[project]/postcss.config.js_.loader.mjs [postcss] (ecmascript)\\\" } [postcss] (ecmascript)\", RUNTIME => \"[turbopack-node]/child_process/evaluate.ts [postcss] (ecmascript)\" } [postcss] (ecmascript)").exports

View file

@ -1,5 +0,0 @@
{
"version": 3,
"sources": [],
"sections": []
}

View file

@ -1,233 +0,0 @@
module.exports = [
"[externals]/path [external] (path, cjs)", ((__turbopack_context__, module, exports) => {
const mod = __turbopack_context__.x("path", () => require("path"));
module.exports = mod;
}),
"[externals]/url [external] (url, cjs)", ((__turbopack_context__, module, exports) => {
const mod = __turbopack_context__.x("url", () => require("url"));
module.exports = mod;
}),
"[externals]/fs [external] (fs, cjs)", ((__turbopack_context__, module, exports) => {
const mod = __turbopack_context__.x("fs", () => require("fs"));
module.exports = mod;
}),
"[externals]/node:url [external] (node:url, cjs)", ((__turbopack_context__, module, exports) => {
const mod = __turbopack_context__.x("node:url", () => require("node:url"));
module.exports = mod;
}),
"[externals]/node:path [external] (node:path, cjs)", ((__turbopack_context__, module, exports) => {
const mod = __turbopack_context__.x("node:path", () => require("node:path"));
module.exports = mod;
}),
"[project]/postcss.config.js_.loader.mjs [postcss] (ecmascript)", ((__turbopack_context__) => {
"use strict";
return __turbopack_context__.a(async (__turbopack_handle_async_dependencies__, __turbopack_async_result__) => { try {
__turbopack_context__.s([
"default",
()=>__TURBOPACK__default__export__
]);
var __TURBOPACK__imported__module__$5b$externals$5d2f$node$3a$url__$5b$external$5d$__$28$node$3a$url$2c$__cjs$29$__ = __turbopack_context__.i("[externals]/node:url [external] (node:url, cjs)");
var __TURBOPACK__imported__module__$5b$externals$5d2f$node$3a$path__$5b$external$5d$__$28$node$3a$path$2c$__cjs$29$__ = __turbopack_context__.i("[externals]/node:path [external] (node:path, cjs)");
;
;
const configPath = __TURBOPACK__imported__module__$5b$externals$5d2f$node$3a$path__$5b$external$5d$__$28$node$3a$path$2c$__cjs$29$__["default"].join(process.cwd(), "./postcss.config.js");
// Absolute paths don't work with ESM imports on Windows:
// https://github.com/nodejs/node/issues/31710
// convert it to a file:// URL, which works on all platforms
const configUrl = (0, __TURBOPACK__imported__module__$5b$externals$5d2f$node$3a$url__$5b$external$5d$__$28$node$3a$url$2c$__cjs$29$__["pathToFileURL"])(configPath).toString();
const mod = await __turbopack_context__.y(configUrl);
const __TURBOPACK__default__export__ = mod.default ?? mod;
__turbopack_async_result__();
} catch(e) { __turbopack_async_result__(e); } }, true);}),
"[turbopack-node]/transforms/transforms.ts [postcss] (ecmascript)", ((__turbopack_context__) => {
"use strict";
/**
* Shared utilities for our 2 transform implementations.
*/ __turbopack_context__.s([
"fromPath",
()=>fromPath,
"getReadEnvVariables",
()=>getReadEnvVariables,
"toPath",
()=>toPath
]);
var __TURBOPACK__imported__module__$5b$externals$5d2f$path__$5b$external$5d$__$28$path$2c$__cjs$29$__ = __turbopack_context__.i("[externals]/path [external] (path, cjs)");
;
const contextDir = process.cwd();
const toPath = (file)=>{
const relPath = (0, __TURBOPACK__imported__module__$5b$externals$5d2f$path__$5b$external$5d$__$28$path$2c$__cjs$29$__["relative"])(contextDir, file);
if ((0, __TURBOPACK__imported__module__$5b$externals$5d2f$path__$5b$external$5d$__$28$path$2c$__cjs$29$__["isAbsolute"])(relPath)) {
throw new Error(`Cannot depend on path (${file}) outside of root directory (${contextDir})`);
}
return __TURBOPACK__imported__module__$5b$externals$5d2f$path__$5b$external$5d$__$28$path$2c$__cjs$29$__["sep"] !== '/' ? relPath.replaceAll(__TURBOPACK__imported__module__$5b$externals$5d2f$path__$5b$external$5d$__$28$path$2c$__cjs$29$__["sep"], '/') : relPath;
};
const fromPath = (path)=>{
return (0, __TURBOPACK__imported__module__$5b$externals$5d2f$path__$5b$external$5d$__$28$path$2c$__cjs$29$__["join"])(/* turbopackIgnore: true */ contextDir, __TURBOPACK__imported__module__$5b$externals$5d2f$path__$5b$external$5d$__$28$path$2c$__cjs$29$__["sep"] !== '/' ? path.replaceAll('/', __TURBOPACK__imported__module__$5b$externals$5d2f$path__$5b$external$5d$__$28$path$2c$__cjs$29$__["sep"]) : path);
};
// Patch process.env to track which env vars are read
const originalEnv = process.env;
const readEnvVars = new Set();
process.env = new Proxy(originalEnv, {
get (target, prop) {
if (typeof prop === 'string') {
// We register the env var as dependency on the
// current transform and all future transforms
// since the env var might be cached in module scope
// and influence them all
readEnvVars.add(prop);
}
return Reflect.get(target, prop);
},
set (target, prop, value) {
return Reflect.set(target, prop, value);
}
});
function getReadEnvVariables() {
return Array.from(readEnvVars);
}
}),
"[turbopack-node]/transforms/postcss.ts { CONFIG => \"[project]/postcss.config.js_.loader.mjs [postcss] (ecmascript)\" } [postcss] (ecmascript)", ((__turbopack_context__) => {
"use strict";
return __turbopack_context__.a(async (__turbopack_handle_async_dependencies__, __turbopack_async_result__) => { try {
__turbopack_context__.s([
"default",
()=>transform,
"init",
()=>init
]);
// @ts-ignore
var __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$postcss$2f$lib$2f$postcss$2e$mjs__$5b$postcss$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/node_modules/postcss/lib/postcss.mjs [postcss] (ecmascript)");
// @ts-ignore
var __TURBOPACK__imported__module__$5b$project$5d2f$postcss$2e$config$2e$js_$2e$loader$2e$mjs__$5b$postcss$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/postcss.config.js_.loader.mjs [postcss] (ecmascript)");
var __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$transforms$2f$transforms$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[turbopack-node]/transforms/transforms.ts [postcss] (ecmascript)");
var __turbopack_async_dependencies__ = __turbopack_handle_async_dependencies__([
__TURBOPACK__imported__module__$5b$project$5d2f$postcss$2e$config$2e$js_$2e$loader$2e$mjs__$5b$postcss$5d$__$28$ecmascript$29$__
]);
[__TURBOPACK__imported__module__$5b$project$5d2f$postcss$2e$config$2e$js_$2e$loader$2e$mjs__$5b$postcss$5d$__$28$ecmascript$29$__] = __turbopack_async_dependencies__.then ? (await __turbopack_async_dependencies__)() : __turbopack_async_dependencies__;
;
;
;
let processor;
const init = async (ipc)=>{
let config = __TURBOPACK__imported__module__$5b$project$5d2f$postcss$2e$config$2e$js_$2e$loader$2e$mjs__$5b$postcss$5d$__$28$ecmascript$29$__["default"];
if (typeof config === 'function') {
config = await config({
env: 'development'
});
}
if (typeof config === 'undefined') {
throw new Error('PostCSS config is undefined (make sure to export an function or object from config file)');
}
let plugins;
if (Array.isArray(config.plugins)) {
plugins = config.plugins.map((plugin)=>{
if (Array.isArray(plugin)) {
return plugin;
} else if (typeof plugin === 'string') {
return [
plugin,
{}
];
} else {
return plugin;
}
});
} else if (typeof config.plugins === 'object') {
plugins = Object.entries(config.plugins).filter(([, options])=>options);
} else {
plugins = [];
}
const loadedPlugins = plugins.map((plugin)=>{
if (Array.isArray(plugin)) {
const [arg, options] = plugin;
let pluginFactory = arg;
if (typeof pluginFactory === 'string') {
pluginFactory = require(/* turbopackIgnore: true */ pluginFactory);
}
if (pluginFactory.default) {
pluginFactory = pluginFactory.default;
}
return pluginFactory(options);
}
return plugin;
});
processor = (0, __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f$postcss$2f$lib$2f$postcss$2e$mjs__$5b$postcss$5d$__$28$ecmascript$29$__["default"])(loadedPlugins);
};
async function transform(ipc, cssContent, name, sourceMap) {
const { css, map, messages } = await processor.process(cssContent, {
from: name,
to: name,
map: sourceMap ? {
inline: false,
annotation: false
} : undefined
});
const assets = [];
const filePaths = [];
const buildFilePaths = [];
const directories = [];
for (const msg of messages){
switch(msg.type){
case 'asset':
assets.push({
file: msg.file,
content: msg.content,
sourceMap: !sourceMap ? undefined : typeof msg.sourceMap === 'string' ? msg.sourceMap : JSON.stringify(msg.sourceMap)
});
break;
case 'dependency':
case 'missing-dependency':
filePaths.push((0, __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$transforms$2f$transforms$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__["toPath"])(msg.file));
break;
case 'build-dependency':
buildFilePaths.push((0, __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$transforms$2f$transforms$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__["toPath"])(msg.file));
break;
case 'dir-dependency':
directories.push([
(0, __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$transforms$2f$transforms$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__["toPath"])(msg.dir),
msg.glob
]);
break;
case 'context-dependency':
directories.push([
(0, __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$transforms$2f$transforms$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__["toPath"])(msg.dir),
'**'
]);
break;
default:
break;
}
}
ipc.sendInfo({
type: 'dependencies',
filePaths,
directories,
buildFilePaths,
envVariables: (0, __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$transforms$2f$transforms$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__["getReadEnvVariables"])()
});
return {
css,
map: sourceMap ? JSON.stringify(map) : undefined,
assets
};
}
__turbopack_async_result__();
} catch(e) { __turbopack_async_result__(e); } }, false);}),
];
//# sourceMappingURL=%5Broot-of-the-server%5D__0x0qtct._.js.map

View file

@ -1,500 +0,0 @@
module.exports = [
"[turbopack-node]/child_process/globals.ts [postcss] (ecmascript)", ((__turbopack_context__, module, exports) => {
// @ts-ignore
process.turbopack = {};
}),
"[externals]/node:net [external] (node:net, cjs)", ((__turbopack_context__, module, exports) => {
const mod = __turbopack_context__.x("node:net", () => require("node:net"));
module.exports = mod;
}),
"[externals]/node:stream [external] (node:stream, cjs)", ((__turbopack_context__, module, exports) => {
const mod = __turbopack_context__.x("node:stream", () => require("node:stream"));
module.exports = mod;
}),
"[turbopack-node]/compiled/stacktrace-parser/index.js [postcss] (ecmascript)", ((__turbopack_context__) => {
"use strict";
__turbopack_context__.s([
"parse",
()=>parse
]);
if (typeof __nccwpck_require__ !== "undefined") __nccwpck_require__.ab = ("TURBOPACK compile-time value", "/ROOT/compiled/stacktrace-parser") + "/";
var n = "<unknown>";
function parse(e) {
var r = e.split("\n");
return r.reduce(function(e, r) {
var n = parseChrome(r) || parseWinjs(r) || parseGecko(r) || parseNode(r) || parseJSC(r);
if (n) {
e.push(n);
}
return e;
}, []);
}
var a = /^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|\/|[a-z]:\\|\\\\).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i;
var l = /\((\S*)(?::(\d+))(?::(\d+))\)/;
function parseChrome(e) {
var r = a.exec(e);
if (!r) {
return null;
}
var u = r[2] && r[2].indexOf("native") === 0;
var t = r[2] && r[2].indexOf("eval") === 0;
var i = l.exec(r[2]);
if (t && i != null) {
r[2] = i[1];
r[3] = i[2];
r[4] = i[3];
}
return {
file: !u ? r[2] : null,
methodName: r[1] || n,
arguments: u ? [
r[2]
] : [],
lineNumber: r[3] ? +r[3] : null,
column: r[4] ? +r[4] : null
};
}
var u = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i;
function parseWinjs(e) {
var r = u.exec(e);
if (!r) {
return null;
}
return {
file: r[2],
methodName: r[1] || n,
arguments: [],
lineNumber: +r[3],
column: r[4] ? +r[4] : null
};
}
var t = /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|\[native).*?|[^@]*bundle)(?::(\d+))?(?::(\d+))?\s*$/i;
var i = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i;
function parseGecko(e) {
var r = t.exec(e);
if (!r) {
return null;
}
var a = r[3] && r[3].indexOf(" > eval") > -1;
var l = i.exec(r[3]);
if (a && l != null) {
r[3] = l[1];
r[4] = l[2];
r[5] = null;
}
return {
file: r[3],
methodName: r[1] || n,
arguments: r[2] ? r[2].split(",") : [],
lineNumber: r[4] ? +r[4] : null,
column: r[5] ? +r[5] : null
};
}
var s = /^\s*(?:([^@]*)(?:\((.*?)\))?@)?(\S.*?):(\d+)(?::(\d+))?\s*$/i;
function parseJSC(e) {
var r = s.exec(e);
if (!r) {
return null;
}
return {
file: r[3],
methodName: r[1] || n,
arguments: [],
lineNumber: +r[4],
column: r[5] ? +r[5] : null
};
}
var o = /^\s*at (?:((?:\[object object\])?[^\\/]+(?: \[as \S+\])?) )?\(?(.*?):(\d+)(?::(\d+))?\)?\s*$/i;
function parseNode(e) {
var r = o.exec(e);
if (!r) {
return null;
}
return {
file: r[2],
methodName: r[1] || n,
arguments: [],
lineNumber: +r[3],
column: r[4] ? +r[4] : null
};
}
}),
"[turbopack-node]/error.ts [postcss] (ecmascript)", ((__turbopack_context__) => {
"use strict";
__turbopack_context__.s([
"default",
()=>isError,
"getProperError",
()=>getProperError,
"structuredError",
()=>structuredError
]);
var __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$compiled$2f$stacktrace$2d$parser$2f$index$2e$js__$5b$postcss$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[turbopack-node]/compiled/stacktrace-parser/index.js [postcss] (ecmascript)");
;
function isError(err) {
return typeof err === 'object' && err !== null && 'name' in err && 'message' in err;
}
function getProperError(err) {
if (isError(err)) {
return err;
}
if ("TURBOPACK compile-time falsy", 0) //TURBOPACK unreachable
;
return new Error(isPlainObject(err) ? JSON.stringify(err) : err + '');
}
function getObjectClassLabel(value) {
return Object.prototype.toString.call(value);
}
function isPlainObject(value) {
if (getObjectClassLabel(value) !== '[object Object]') {
return false;
}
const prototype = Object.getPrototypeOf(value);
/**
* this used to be previously:
*
* `return prototype === null || prototype === Object.prototype`
*
* but Edge Runtime expose Object from vm, being that kind of type-checking wrongly fail.
*
* It was changed to the current implementation since it's resilient to serialization.
*/ return prototype === null || prototype.hasOwnProperty('isPrototypeOf');
}
function structuredError(e) {
e = getProperError(e);
return {
name: e.name,
message: e.message,
stack: typeof e.stack === 'string' ? (0, __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$compiled$2f$stacktrace$2d$parser$2f$index$2e$js__$5b$postcss$5d$__$28$ecmascript$29$__["parse"])(e.stack) : [],
cause: e.cause ? structuredError(getProperError(e.cause)) : undefined
};
}
}),
"[turbopack-node]/child_process/index.ts [postcss] (ecmascript)", ((__turbopack_context__) => {
"use strict";
__turbopack_context__.s([
"IPC",
()=>IPC
]);
var __TURBOPACK__imported__module__$5b$externals$5d2f$node$3a$net__$5b$external$5d$__$28$node$3a$net$2c$__cjs$29$__ = __turbopack_context__.i("[externals]/node:net [external] (node:net, cjs)");
var __TURBOPACK__imported__module__$5b$externals$5d2f$node$3a$stream__$5b$external$5d$__$28$node$3a$stream$2c$__cjs$29$__ = __turbopack_context__.i("[externals]/node:stream [external] (node:stream, cjs)");
var __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$error$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[turbopack-node]/error.ts [postcss] (ecmascript)");
;
;
;
function createIpc(port) {
const socket = (0, __TURBOPACK__imported__module__$5b$externals$5d2f$node$3a$net__$5b$external$5d$__$28$node$3a$net$2c$__cjs$29$__["createConnection"])({
port,
host: '127.0.0.1'
});
/**
* A writable stream that writes to the socket.
* We don't write directly to the socket because we need to
* handle backpressure and wait for the socket to be drained
* before writing more data.
*/ const socketWritable = new __TURBOPACK__imported__module__$5b$externals$5d2f$node$3a$stream__$5b$external$5d$__$28$node$3a$stream$2c$__cjs$29$__["Writable"]({
write (chunk, _enc, cb) {
if (socket.write(chunk)) {
cb();
} else {
socket.once('drain', cb);
}
},
final (cb) {
socket.end(cb);
}
});
const packetQueue = [];
const recvPromiseResolveQueue = [];
function pushPacket(packet) {
const recvPromiseResolve = recvPromiseResolveQueue.shift();
if (recvPromiseResolve != null) {
recvPromiseResolve(JSON.parse(packet.toString('utf8')));
} else {
packetQueue.push(packet);
}
}
let state = {
type: 'waiting'
};
let buffer = Buffer.alloc(0);
socket.once('connect', ()=>{
socket.setNoDelay(true);
socket.on('data', (chunk)=>{
buffer = Buffer.concat([
buffer,
chunk
]);
loop: while(true){
switch(state.type){
case 'waiting':
{
if (buffer.length >= 4) {
const length = buffer.readUInt32BE(0);
buffer = buffer.subarray(4);
state = {
type: 'packet',
length
};
} else {
break loop;
}
break;
}
case 'packet':
{
if (buffer.length >= state.length) {
const packet = buffer.subarray(0, state.length);
buffer = buffer.subarray(state.length);
state = {
type: 'waiting'
};
pushPacket(packet);
} else {
break loop;
}
break;
}
default:
invariant(state, (state)=>`Unknown state type: ${state?.type}`);
}
}
});
});
// When the socket is closed, this process is no longer needed.
// This might happen e. g. when parent process is killed or
// node.js pool is garbage collected.
socket.once('close', ()=>{
process.exit(0);
});
// TODO(lukesandberg): some of the messages being sent are very large and contain lots
// of redundant information. Consider adding gzip compression to our stream.
function doSend(message) {
return new Promise((resolve, reject)=>{
// Reserve 4 bytes for our length prefix, we will over-write after encoding.
const packet = Buffer.from('0000' + message, 'utf8');
packet.writeUInt32BE(packet.length - 4, 0);
socketWritable.write(packet, (err)=>{
process.stderr.write(`TURBOPACK_OUTPUT_D\n`);
process.stdout.write(`TURBOPACK_OUTPUT_D\n`);
if (err != null) {
reject(err);
} else {
resolve();
}
});
});
}
function send(message) {
return doSend(JSON.stringify(message));
}
function sendReady() {
return doSend('');
}
return {
async recv () {
const packet = packetQueue.shift();
if (packet != null) {
return JSON.parse(packet.toString('utf8'));
}
const result = await new Promise((resolve)=>{
recvPromiseResolveQueue.push((result)=>{
resolve(result);
});
});
return result;
},
send (message) {
return send(message);
},
sendReady,
async sendError (error) {
let failed = false;
try {
await send({
type: 'error',
...(0, __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$error$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__["structuredError"])(error)
});
} catch (err) {
// There's nothing we can do about errors that happen after this point, we can't tell anyone
// about them.
console.error('failed to send error back to rust:', err);
failed = true;
}
await new Promise((res)=>socket.end(()=>res()));
process.exit(failed ? 1 : 0);
}
};
}
const PORT = process.argv[2];
const IPC = createIpc(parseInt(PORT, 10));
process.on('uncaughtException', (err)=>{
IPC.sendError(err);
});
process.on('unhandledRejection', (reason)=>{
IPC.sendError(reason instanceof Error ? reason : new Error(String(reason)));
});
const improveConsole = (name, stream, addStack)=>{
// @ts-ignore
const original = console[name];
// @ts-ignore
const stdio = process[stream];
// @ts-ignore
console[name] = (...args)=>{
stdio.write(`TURBOPACK_OUTPUT_B\n`);
original(...args);
if (addStack) {
const stack = new Error().stack?.replace(/^.+\n.+\n/, '') + '\n';
stdio.write('TURBOPACK_OUTPUT_S\n');
stdio.write(stack);
}
stdio.write('TURBOPACK_OUTPUT_E\n');
};
};
improveConsole('error', 'stderr', true);
improveConsole('warn', 'stderr', true);
improveConsole('count', 'stdout', true);
improveConsole('trace', 'stderr', false);
improveConsole('log', 'stdout', true);
improveConsole('group', 'stdout', true);
improveConsole('groupCollapsed', 'stdout', true);
improveConsole('table', 'stdout', true);
improveConsole('debug', 'stdout', true);
improveConsole('info', 'stdout', true);
improveConsole('dir', 'stdout', true);
improveConsole('dirxml', 'stdout', true);
improveConsole('timeEnd', 'stdout', true);
improveConsole('timeLog', 'stdout', true);
improveConsole('timeStamp', 'stdout', true);
improveConsole('assert', 'stderr', true);
/**
* Utility function to ensure all variants of an enum are handled.
*/ function invariant(never, computeMessage) {
throw new Error(`Invariant: ${computeMessage(never)}`);
}
}),
"[turbopack-node]/child_process/evaluate.ts [postcss] (ecmascript)", ((__turbopack_context__) => {
"use strict";
__turbopack_context__.s([
"run",
()=>run
]);
var __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$child_process$2f$index$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[turbopack-node]/child_process/index.ts [postcss] (ecmascript)");
;
const ipc = __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$child_process$2f$index$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__["IPC"];
const queue = [];
const run = async (moduleFactory)=>{
let nextId = 1;
const requests = new Map();
const internalIpc = {
sendInfo: (message)=>ipc.send({
type: 'info',
data: message
}),
sendRequest: (message)=>{
const id = nextId++;
let resolve, reject;
const promise = new Promise((res, rej)=>{
resolve = res;
reject = rej;
});
requests.set(id, {
resolve,
reject
});
return ipc.send({
type: 'request',
id,
data: message
}).then(()=>promise);
},
sendError: (error)=>{
return ipc.sendError(error);
}
};
// Initialize module and send ready message
let getValue;
try {
const module = await moduleFactory();
if (typeof module.init === 'function') {
await module.init();
}
getValue = module.default;
await ipc.sendReady();
} catch (err) {
await ipc.sendReady();
await ipc.sendError(err);
}
// Queue handling
let isRunning = false;
const run = async ()=>{
while(queue.length > 0){
const args = queue.shift();
try {
const value = await getValue(internalIpc, ...args);
await ipc.send({
type: 'end',
data: value === undefined ? undefined : JSON.stringify(value, null, 2),
duration: 0
});
} catch (e) {
await ipc.sendError(e);
}
}
isRunning = false;
};
// Communication handling
while(true){
const msg = await ipc.recv();
switch(msg.type){
case 'evaluate':
{
queue.push(msg.args);
if (!isRunning) {
isRunning = true;
run();
}
break;
}
case 'result':
{
const request = requests.get(msg.id);
if (request) {
requests.delete(msg.id);
if (msg.error) {
request.reject(new Error(msg.error));
} else {
request.resolve(msg.data);
}
}
break;
}
default:
{
console.error('unexpected message type', msg.type);
process.exit(1);
}
}
}
};
}),
"[turbopack-node]/child_process/evaluate.ts/evaluate.js { INNER => \"[turbopack-node]/transforms/postcss.ts { CONFIG => \\\"[project]/postcss.config.js_.loader.mjs [postcss] (ecmascript)\\\" } [postcss] (ecmascript)\", RUNTIME => \"[turbopack-node]/child_process/evaluate.ts [postcss] (ecmascript)\" } [postcss] (ecmascript)", ((__turbopack_context__) => {
"use strict";
__turbopack_context__.s([]);
var __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$child_process$2f$evaluate$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[turbopack-node]/child_process/evaluate.ts [postcss] (ecmascript)");
;
(0, __TURBOPACK__imported__module__$5b$turbopack$2d$node$5d2f$child_process$2f$evaluate$2e$ts__$5b$postcss$5d$__$28$ecmascript$29$__["run"])(()=>__turbopack_context__.A('[turbopack-node]/transforms/postcss.ts { CONFIG => "[project]/postcss.config.js_.loader.mjs [postcss] (ecmascript)" } [postcss] (ecmascript, async loader)'));
}),
];
//# sourceMappingURL=%5Broot-of-the-server%5D__1audplt._.js.map

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more