Compare commits
No commits in common. "main" and "v2.2.0" have entirely different histories.
28941 changed files with 500 additions and 4368154 deletions
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONPATH=/app
|
PYTHONPATH=/app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libglib2.0-0 libffi-dev shared-mime-info \
|
&& apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir ".[dev]"
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic ./alembic
|
COPY alembic ./alembic
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY tests ./tests
|
|
||||||
COPY docs ./docs
|
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
@ -25,3 +23,4 @@ RUN chmod +x /docker-entrypoint.sh
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "2"]
|
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "2"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "202607110001"
|
|
||||||
down_revision = "202607100002"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute("ALTER TABLE validations ALTER COLUMN status TYPE varchar(40) USING status::text")
|
|
||||||
op.execute(
|
|
||||||
"""
|
|
||||||
UPDATE validations
|
|
||||||
SET status = CASE status
|
|
||||||
WHEN 'draft' THEN 'ENTWURF'
|
|
||||||
WHEN 'ready_for_report' THEN 'BEREIT_ZUR_PRUEFUNG'
|
|
||||||
WHEN 'in_progress' THEN 'IN_PRUEFUNG'
|
|
||||||
WHEN 'completed' THEN 'ABGESCHLOSSEN'
|
|
||||||
ELSE status
|
|
||||||
END
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
op.execute("ALTER TABLE validations ALTER COLUMN status SET DEFAULT 'ENTWURF'")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute(
|
|
||||||
"""
|
|
||||||
UPDATE validations
|
|
||||||
SET status = CASE status
|
|
||||||
WHEN 'ENTWURF' THEN 'draft'
|
|
||||||
WHEN 'BEREIT_ZUR_PRUEFUNG' THEN 'ready_for_report'
|
|
||||||
WHEN 'IN_PRUEFUNG' THEN 'in_progress'
|
|
||||||
WHEN 'FREIGEGEBEN' THEN 'ready_for_report'
|
|
||||||
WHEN 'ABGESCHLOSSEN' THEN 'completed'
|
|
||||||
WHEN 'STORNIERT' THEN 'completed'
|
|
||||||
ELSE status
|
|
||||||
END
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "202607110002"
|
|
||||||
down_revision = "202607110001"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column("validations", sa.Column("revalidation_interval_months", sa.Integer(), nullable=False, server_default="24"))
|
|
||||||
op.add_column("validations", sa.Column("next_validation_manually_overridden", sa.Boolean(), nullable=False, server_default=sa.false()))
|
|
||||||
op.add_column("validations", sa.Column("version", sa.Integer(), nullable=False, server_default="1"))
|
|
||||||
op.add_column("validations", sa.Column("previous_validation_id", sa.String(), nullable=True))
|
|
||||||
op.create_foreign_key("fk_validations_previous_validation_id_validations", "validations", "validations", ["previous_validation_id"], ["id"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_constraint("fk_validations_previous_validation_id_validations", "validations", type_="foreignkey")
|
|
||||||
op.drop_column("validations", "previous_validation_id")
|
|
||||||
op.drop_column("validations", "version")
|
|
||||||
op.drop_column("validations", "next_validation_manually_overridden")
|
|
||||||
op.drop_column("validations", "revalidation_interval_months")
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "202607110003"
|
|
||||||
down_revision = "202607110002"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"report_templates",
|
|
||||||
sa.Column("template_key", sa.String(length=120), nullable=False),
|
|
||||||
sa.Column("name", sa.String(length=180), nullable=False),
|
|
||||||
sa.Column("version", sa.String(length=40), nullable=False),
|
|
||||||
sa.Column("validation_type", sa.String(length=120), nullable=False),
|
|
||||||
sa.Column("reference_path", sa.String(length=500), nullable=False),
|
|
||||||
sa.Column("active", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("id", sa.String(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_report_templates")),
|
|
||||||
sa.UniqueConstraint("template_key", name=op.f("uq_report_templates_template_key")),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_report_templates_active"), "report_templates", ["active"], unique=False)
|
|
||||||
op.create_index(op.f("ix_report_templates_template_key"), "report_templates", ["template_key"], unique=False)
|
|
||||||
op.create_index(op.f("ix_report_templates_validation_type"), "report_templates", ["validation_type"], unique=False)
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"text_blocks",
|
|
||||||
sa.Column("template_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("block_key", sa.String(length=160), nullable=False),
|
|
||||||
sa.Column("title", sa.String(length=240), nullable=False),
|
|
||||||
sa.Column("content", sa.Text(), nullable=False),
|
|
||||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("version", sa.String(length=40), nullable=False),
|
|
||||||
sa.Column("active", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("id", sa.String(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_text_blocks_template_id_report_templates")),
|
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_text_blocks")),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_text_blocks_active"), "text_blocks", ["active"], unique=False)
|
|
||||||
op.create_index(op.f("ix_text_blocks_block_key"), "text_blocks", ["block_key"], unique=False)
|
|
||||||
op.create_index(op.f("ix_text_blocks_template_id"), "text_blocks", ["template_id"], unique=False)
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"report_sections",
|
|
||||||
sa.Column("template_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("section_key", sa.String(length=160), nullable=False),
|
|
||||||
sa.Column("number", sa.String(length=40), nullable=True),
|
|
||||||
sa.Column("title", sa.String(length=240), nullable=False),
|
|
||||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("page_break_before", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("active", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("id", sa.String(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_report_sections_template_id_report_templates")),
|
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_report_sections")),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_report_sections_active"), "report_sections", ["active"], unique=False)
|
|
||||||
op.create_index(op.f("ix_report_sections_section_key"), "report_sections", ["section_key"], unique=False)
|
|
||||||
op.create_index(op.f("ix_report_sections_template_id"), "report_sections", ["template_id"], unique=False)
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"checklist_templates",
|
|
||||||
sa.Column("template_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("checklist_key", sa.String(length=160), nullable=False),
|
|
||||||
sa.Column("title", sa.String(length=240), nullable=False),
|
|
||||||
sa.Column("columns", sa.JSON(), nullable=False),
|
|
||||||
sa.Column("items", sa.JSON(), nullable=False),
|
|
||||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("active", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("id", sa.String(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_checklist_templates_template_id_report_templates")),
|
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_checklist_templates")),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_checklist_templates_active"), "checklist_templates", ["active"], unique=False)
|
|
||||||
op.create_index(op.f("ix_checklist_templates_checklist_key"), "checklist_templates", ["checklist_key"], unique=False)
|
|
||||||
op.create_index(op.f("ix_checklist_templates_template_id"), "checklist_templates", ["template_id"], unique=False)
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"measurement_imports",
|
|
||||||
sa.Column("validation_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("import_type", sa.String(length=60), nullable=False),
|
|
||||||
sa.Column("original_filename", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("storage_path", sa.String(length=500), nullable=False),
|
|
||||||
sa.Column("sha256", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("parser_version", sa.String(length=40), nullable=False),
|
|
||||||
sa.Column("status", sa.String(length=60), nullable=False),
|
|
||||||
sa.Column("id", sa.String(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["validation_id"], ["validations.id"], name=op.f("fk_measurement_imports_validation_id_validations")),
|
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_measurement_imports")),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_measurement_imports_import_type"), "measurement_imports", ["import_type"], unique=False)
|
|
||||||
op.create_index(op.f("ix_measurement_imports_sha256"), "measurement_imports", ["sha256"], unique=False)
|
|
||||||
op.create_index(op.f("ix_measurement_imports_status"), "measurement_imports", ["status"], unique=False)
|
|
||||||
op.create_index(op.f("ix_measurement_imports_validation_id"), "measurement_imports", ["validation_id"], unique=False)
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"measurement_import_values",
|
|
||||||
sa.Column("import_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("test_run", sa.String(length=120), nullable=False),
|
|
||||||
sa.Column("field_name", sa.String(length=120), nullable=False),
|
|
||||||
sa.Column("raw_value", sa.Text(), nullable=True),
|
|
||||||
sa.Column("normalized_value", sa.String(length=180), nullable=True),
|
|
||||||
sa.Column("unit", sa.String(length=40), nullable=True),
|
|
||||||
sa.Column("source_page", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("source_text", sa.Text(), nullable=True),
|
|
||||||
sa.Column("confidence", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("confirmed", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("corrected_value", sa.String(length=180), nullable=True),
|
|
||||||
sa.Column("id", sa.String(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["import_id"], ["measurement_imports.id"], name=op.f("fk_measurement_import_values_import_id_measurement_imports")),
|
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_measurement_import_values")),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_measurement_import_values_confirmed"), "measurement_import_values", ["confirmed"], unique=False)
|
|
||||||
op.create_index(op.f("ix_measurement_import_values_field_name"), "measurement_import_values", ["field_name"], unique=False)
|
|
||||||
op.create_index(op.f("ix_measurement_import_values_import_id"), "measurement_import_values", ["import_id"], unique=False)
|
|
||||||
op.create_index(op.f("ix_measurement_import_values_test_run"), "measurement_import_values", ["test_run"], unique=False)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table("measurement_import_values")
|
|
||||||
op.drop_table("measurement_imports")
|
|
||||||
op.drop_table("checklist_templates")
|
|
||||||
op.drop_table("report_sections")
|
|
||||||
op.drop_table("text_blocks")
|
|
||||||
op.drop_table("report_templates")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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"))
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"}
|
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,18 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import json
|
from fastapi import APIRouter, Depends, Query, Response
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import uuid
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
|
||||||
from fastapi.encoders import jsonable_encoder
|
|
||||||
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.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.orion.service import OrionReportService
|
|
||||||
from app.schemas.common import PaginatedResponse
|
from app.schemas.common import PaginatedResponse
|
||||||
from app.schemas.domain import (
|
from app.schemas.domain import (
|
||||||
ContactCreate,
|
ContactCreate,
|
||||||
|
|
@ -44,166 +31,24 @@ from app.schemas.domain import (
|
||||||
LocationCreate,
|
LocationCreate,
|
||||||
LocationRead,
|
LocationRead,
|
||||||
LocationUpdate,
|
LocationUpdate,
|
||||||
MeasurementImportConfirmRequest,
|
|
||||||
MeasurementImportPreviewRead,
|
|
||||||
ValidationCreate,
|
ValidationCreate,
|
||||||
ValidationImportPreview,
|
|
||||||
ValidationImportRequest,
|
|
||||||
ValidationImportSummary,
|
|
||||||
ValidationRead,
|
ValidationRead,
|
||||||
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
|
|
||||||
|
|
||||||
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
|
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/report-templates/default/checklists")
|
|
||||||
def default_report_checklists(session: Session = Depends(get_session)) -> list[dict]:
|
|
||||||
from app.modules.orion.template_service import ReportTemplateService
|
|
||||||
|
|
||||||
bundle = ReportTemplateService(session).ensure_default_template()
|
|
||||||
session.commit()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"checklist_key": item.checklist_key,
|
|
||||||
"title": item.title,
|
|
||||||
"columns": item.columns,
|
|
||||||
"items": item.items,
|
|
||||||
"order_index": item.order_index,
|
|
||||||
}
|
|
||||||
for item in bundle.checklists
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/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()
|
|
||||||
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,
|
||||||
"contacts": session.scalar(select(func.count()).select_from(Contact)) or 0,
|
"contacts": session.scalar(select(func.count()).select_from(Contact)) or 0,
|
||||||
"devices": session.scalar(select(func.count()).select_from(Device)) or 0,
|
"devices": session.scalar(select(func.count()).select_from(Device)) or 0,
|
||||||
"equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0,
|
"equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0,
|
||||||
"equipment_green": session.scalar(
|
|
||||||
select(func.count()).select_from(Equipment).where(Equipment.status == "green")
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
"equipment_yellow": session.scalar(
|
|
||||||
select(func.count()).select_from(Equipment).where(Equipment.status == "yellow")
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
"equipment_red": session.scalar(
|
|
||||||
select(func.count()).select_from(Equipment).where(Equipment.status == "red")
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
"validations": session.scalar(select(func.count()).select_from(Validation)) or 0,
|
"validations": session.scalar(select(func.count()).select_from(Validation)) or 0,
|
||||||
"validation_drafts": session.scalar(
|
|
||||||
select(func.count()).select_from(Validation).where(Validation.status == "ENTWURF")
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
"validation_ready": session.scalar(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Validation)
|
|
||||||
.where(Validation.status == "BEREIT_ZUR_PRUEFUNG")
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
"validation_in_review": session.scalar(
|
|
||||||
select(func.count()).select_from(Validation).where(Validation.status == "IN_PRUEFUNG")
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
"validation_approved": session.scalar(
|
|
||||||
select(func.count()).select_from(Validation).where(Validation.status == "FREIGEGEBEN")
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
"validation_completed": session.scalar(
|
|
||||||
select(func.count()).select_from(Validation).where(Validation.status == "ABGESCHLOSSEN")
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
"validation_overdue": session.scalar(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Validation)
|
|
||||||
.where(Validation.next_validation_on < today)
|
|
||||||
)
|
|
||||||
or 0,
|
|
||||||
**result_counts,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -215,83 +60,18 @@ 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:
|
|
||||||
item = service.create(payload.model_dump())
|
item = service.create(payload.model_dump())
|
||||||
if isinstance(item, Validation):
|
|
||||||
ValidationWorkflowService(session).apply_revalidation_date(item)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(item)
|
session.refresh(item)
|
||||||
return item
|
return item
|
||||||
except IntegrityError as exc:
|
|
||||||
session.rollback()
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409, detail="Datensatz verletzt Datenbankbeziehungen."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def commit_update(session: Session, service: CrudService, item_id: str, payload):
|
def commit_update(session: Session, service: CrudService, item_id: str, payload):
|
||||||
try:
|
|
||||||
item = service.update(item_id, payload.model_dump())
|
item = service.update(item_id, payload.model_dump())
|
||||||
if isinstance(item, Validation):
|
|
||||||
ValidationWorkflowService(session).apply_revalidation_date(item)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(item)
|
session.refresh(item)
|
||||||
return item
|
return item
|
||||||
except IntegrityError as exc:
|
|
||||||
session.rollback()
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409, detail="Datensatz verletzt Datenbankbeziehungen."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def commit_delete(session: Session, service: CrudService, item_id: str) -> Response:
|
def commit_delete(session: Session, service: CrudService, item_id: str) -> Response:
|
||||||
|
|
@ -320,14 +100,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)
|
||||||
|
|
@ -399,9 +171,7 @@ def create_equipment(payload: EquipmentCreate, session: Session = Depends(get_se
|
||||||
|
|
||||||
|
|
||||||
@router.put("/equipment/{item_id}", response_model=EquipmentRead)
|
@router.put("/equipment/{item_id}", response_model=EquipmentRead)
|
||||||
def update_equipment(
|
def update_equipment(item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session)):
|
||||||
item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
return commit_update(session, DomainServices(session).equipment, item_id, payload)
|
return commit_update(session, DomainServices(session).equipment, item_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -411,39 +181,8 @@ def delete_equipment(item_id: str, session: Session = Depends(get_session)):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validations", response_model=PaginatedResponse[ValidationRead])
|
@router.get("/validations", response_model=PaginatedResponse[ValidationRead])
|
||||||
def list_validations(
|
def list_validations(params: dict = Depends(paging), session: Session = Depends(get_session)):
|
||||||
search: str | None = Query(default=None, max_length=120),
|
return DomainServices(session).validations.list(**params)
|
||||||
page: int = Query(default=1, ge=1),
|
|
||||||
page_size: int = Query(default=20, ge=1, le=100),
|
|
||||||
sort_by: str = Query(default="updated_at"),
|
|
||||||
sort_order: str = Query(default="desc", pattern="^(asc|desc)$"),
|
|
||||||
status: str | None = None,
|
|
||||||
customer_id: str | None = None,
|
|
||||||
device_id: str | None = None,
|
|
||||||
validation_type: str | None = None,
|
|
||||||
result: str | None = None,
|
|
||||||
date_from: str | None = None,
|
|
||||||
date_to: str | None = None,
|
|
||||||
overdue_only: bool = False,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
):
|
|
||||||
return ValidationWorkflowService(session).query_validations(
|
|
||||||
search=search,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_order=sort_order,
|
|
||||||
filters={
|
|
||||||
"status": status,
|
|
||||||
"customer_id": customer_id,
|
|
||||||
"device_id": device_id,
|
|
||||||
"validation_type": validation_type,
|
|
||||||
"result": result,
|
|
||||||
"date_from": date_from,
|
|
||||||
"date_to": date_to,
|
|
||||||
"overdue_only": overdue_only,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validations/next-report-number")
|
@router.get("/validations/next-report-number")
|
||||||
|
|
@ -462,431 +201,16 @@ 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)
|
|
||||||
):
|
|
||||||
return commit_update(session, DomainServices(session).validations, item_id, payload)
|
return commit_update(session, DomainServices(session).validations, item_id, payload)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/validations/{item_id}", status_code=204)
|
@router.delete("/validations/{item_id}", status_code=204)
|
||||||
def delete_validation(item_id: str, session: Session = Depends(get_session)):
|
def delete_validation(item_id: str, session: Session = Depends(get_session)):
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
|
||||||
if item and item.status != "ENTWURF":
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=409, detail="Only draft validations can be deleted")
|
|
||||||
return commit_delete(session, DomainServices(session).validations, item_id)
|
return commit_delete(session, DomainServices(session).validations, item_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validations/{item_id}/review", response_model=ValidationReview)
|
|
||||||
def review_validation(item_id: str, session: Session = Depends(get_session)):
|
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
|
||||||
if item is None:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
|
||||||
review = ValidationWorkflowService(session).mark_ready_for_review(item)
|
|
||||||
session.commit()
|
|
||||||
return review
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validations/{item_id}/duplicate", response_model=ValidationRead, status_code=201)
|
|
||||||
def duplicate_validation(item_id: str, session: Session = Depends(get_session)):
|
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
|
||||||
if item is None:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
|
||||||
clone = ValidationWorkflowService(session).duplicate(item)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(clone)
|
|
||||||
return clone
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validations/{item_id}/new-version", response_model=ValidationRead, status_code=201)
|
|
||||||
def new_validation_version(item_id: str, session: Session = Depends(get_session)):
|
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
|
||||||
if item is None:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
|
||||||
clone = ValidationWorkflowService(session).create_new_version(item)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(clone)
|
|
||||||
return clone
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validations/{item_id}/cancel", response_model=ValidationRead)
|
|
||||||
def cancel_validation(item_id: str, session: Session = Depends(get_session)):
|
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
|
||||||
if item is None:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
|
||||||
item.status = "STORNIERT"
|
|
||||||
session.commit()
|
|
||||||
session.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validations/{item_id}/attachments")
|
|
||||||
def upload_validation_attachment(
|
|
||||||
item_id: str,
|
|
||||||
category: str = Form(...),
|
|
||||||
description: str = Form(default=""),
|
|
||||||
order: int = Form(default=0),
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
):
|
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
|
||||||
if item is None:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
|
||||||
if item.status in {"FREIGEGEBEN", "ABGESCHLOSSEN"}:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=409, detail="Freigegebene Berichte sind schreibgeschuetzt.")
|
|
||||||
|
|
||||||
original_name = Path(file.filename or "anlage").name
|
|
||||||
suffix = Path(original_name).suffix
|
|
||||||
stored_name = f"{uuid.uuid4().hex}{suffix}"
|
|
||||||
upload_dir = Path("/app/uploads/validations") / item_id
|
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
storage_path = upload_dir / stored_name
|
|
||||||
with storage_path.open("wb") as target:
|
|
||||||
shutil.copyfileobj(file.file, target)
|
|
||||||
|
|
||||||
attachment = {
|
|
||||||
"category": category,
|
|
||||||
"filename": original_name,
|
|
||||||
"content_type": file.content_type,
|
|
||||||
"description": description,
|
|
||||||
"order": order,
|
|
||||||
"storage_path": str(storage_path),
|
|
||||||
"url": f"/uploads/validations/{item_id}/{stored_name}",
|
|
||||||
}
|
|
||||||
current = list(item.attachments or [])
|
|
||||||
current.append(attachment)
|
|
||||||
item.attachments = current
|
|
||||||
session.commit()
|
|
||||||
return attachment
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/validations/{item_id}/measurement-imports/winlog-pdf",
|
|
||||||
response_model=MeasurementImportPreviewRead,
|
|
||||||
)
|
|
||||||
async def upload_winlog_pdf_import(
|
|
||||||
item_id: str,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
attachment_only: bool = Form(default=False),
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
):
|
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
|
||||||
if item is None:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
|
||||||
preview = HeliosImportService(session).save_winlog_pdf(
|
|
||||||
item_id,
|
|
||||||
file.filename or "winlog.pdf",
|
|
||||||
await file.read(),
|
|
||||||
attachment_only=attachment_only,
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
return preview
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/measurement-imports/{import_id}/confirm",
|
|
||||||
response_model=MeasurementImportPreviewRead,
|
|
||||||
)
|
|
||||||
def confirm_measurement_import(
|
|
||||||
import_id: str,
|
|
||||||
payload: MeasurementImportConfirmRequest,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
preview = HeliosImportService(session).confirm_values(
|
|
||||||
import_id, [item.model_dump() for item in payload.values]
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
||||||
session.commit()
|
|
||||||
return preview
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validations/{item_id}/export.json")
|
|
||||||
def export_validation_json(item_id: str, session: Session = Depends(get_session)):
|
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
|
||||||
if item is None:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
|
||||||
data = ValidationWorkflowService(session).export_json(item)
|
|
||||||
return JSONResponse(
|
|
||||||
content=json.loads(json.dumps(data, default=str)),
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{item.report_number}.json"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validations/import/csv-preview", response_model=ValidationImportPreview)
|
|
||||||
async def preview_validation_csv(file: UploadFile, session: Session = Depends(get_session)):
|
|
||||||
return ValidationWorkflowService(session).preview_csv(await file.read())
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validations/import/json", response_model=ValidationImportSummary)
|
|
||||||
def import_validation_json(
|
|
||||||
payload: ValidationImportRequest, session: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
summary = ValidationWorkflowService(session).import_rows(
|
|
||||||
payload.rows, payload.duplicate_strategy
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validations/{item_id}/report.html", response_class=HTMLResponse)
|
|
||||||
def validation_report_preview(item_id: str, session: Session = Depends(get_session)):
|
|
||||||
return OrionReportService(session, Path("/app/reports")).render_html(item_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validations/{item_id}/report.pdf")
|
|
||||||
def validation_report_pdf(item_id: str, session: Session = Depends(get_session)):
|
|
||||||
try:
|
|
||||||
report_path = OrionReportService(session, Path("/app/reports")).render_pdf(item_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("PDF generation failed for validation %s", item_id)
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Der PDF-Bericht konnte nicht erzeugt werden. Die HTML-Vorschau ist weiterhin verfuegbar.",
|
|
||||||
) from exc
|
|
||||||
return FileResponse(
|
|
||||||
report_path,
|
|
||||||
media_type="application/pdf",
|
|
||||||
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})
|
|
||||||
|
|
|
||||||
|
|
@ -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:]))
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -17,7 +17,6 @@ class Settings(BaseSettings):
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_minutes: int = 60 * 8
|
access_token_minutes: int = 60 * 8
|
||||||
cors_origins: list[str] = ["http://localhost:3000"]
|
cors_origins: list[str] = ["http://localhost:3000"]
|
||||||
public_base_url: str = Field(default="http://localhost:8000", alias="PUBLIC_BASE_URL")
|
|
||||||
admin_email: str = Field(default="admin@schubamed.de", alias="ADMIN_EMAIL")
|
admin_email: str = Field(default="admin@schubamed.de", alias="ADMIN_EMAIL")
|
||||||
admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD")
|
admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import SessionLocal
|
|
||||||
from app.modules.orion.template_service import ReportTemplateService
|
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -19,18 +14,10 @@ app.add_middleware(
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
Path("/app/uploads").mkdir(parents=True, exist_ok=True)
|
|
||||||
app.mount("/uploads", StaticFiles(directory="/app/uploads"), name="uploads")
|
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
def load_reference_templates() -> None:
|
|
||||||
with SessionLocal() as session:
|
|
||||||
ReportTemplateService(session).ensure_default_template()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,6 @@ from app.models.document import Document
|
||||||
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.program import Program
|
from app.models.program import Program
|
||||||
from app.models.report_template import (
|
|
||||||
ChecklistTemplate,
|
|
||||||
MeasurementImport,
|
|
||||||
MeasurementImportValue,
|
|
||||||
ReportSection,
|
|
||||||
ReportTemplate,
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -25,13 +16,6 @@ __all__ = [
|
||||||
"Equipment",
|
"Equipment",
|
||||||
"Location",
|
"Location",
|
||||||
"Program",
|
"Program",
|
||||||
"ChecklistTemplate",
|
|
||||||
"MeasurementImport",
|
|
||||||
"MeasurementImportValue",
|
|
||||||
"ReportSection",
|
|
||||||
"ReportTemplate",
|
|
||||||
"OrionReportSettings",
|
|
||||||
"TextBlock",
|
|
||||||
"User",
|
"User",
|
||||||
"Validation",
|
"Validation",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
|
||||||
|
|
||||||
|
|
||||||
class ReportTemplate(Base, UUIDMixin, TimestampMixin):
|
|
||||||
__tablename__ = "report_templates"
|
|
||||||
|
|
||||||
template_key: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(180))
|
|
||||||
version: Mapped[str] = mapped_column(String(40))
|
|
||||||
validation_type: Mapped[str] = mapped_column(String(120), index=True)
|
|
||||||
reference_path: Mapped[str] = mapped_column(String(500))
|
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TextBlock(Base, UUIDMixin, TimestampMixin):
|
|
||||||
__tablename__ = "text_blocks"
|
|
||||||
|
|
||||||
template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True)
|
|
||||||
block_key: Mapped[str] = mapped_column(String(160), index=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(240))
|
|
||||||
content: Mapped[str] = mapped_column(Text)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
version: Mapped[str] = mapped_column(String(40), default="1.0")
|
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ReportSection(Base, UUIDMixin, TimestampMixin):
|
|
||||||
__tablename__ = "report_sections"
|
|
||||||
|
|
||||||
template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True)
|
|
||||||
section_key: Mapped[str] = mapped_column(String(160), index=True)
|
|
||||||
number: Mapped[str | None] = mapped_column(String(40))
|
|
||||||
title: Mapped[str] = mapped_column(String(240))
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
page_break_before: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ChecklistTemplate(Base, UUIDMixin, TimestampMixin):
|
|
||||||
__tablename__ = "checklist_templates"
|
|
||||||
|
|
||||||
template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True)
|
|
||||||
checklist_key: Mapped[str] = mapped_column(String(160), index=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(240))
|
|
||||||
columns: Mapped[list[str]] = mapped_column(JSON, default=list)
|
|
||||||
items: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementImportStatus(str, enum.Enum):
|
|
||||||
uploaded = "HOCHGELADEN"
|
|
||||||
analyzing = "ANALYSE_LAEUFT"
|
|
||||||
preview_ready = "VORSCHAU_BEREIT"
|
|
||||||
confirmed = "BESTAETIGT"
|
|
||||||
error = "FEHLER"
|
|
||||||
attachment_only = "NUR_ANLAGE"
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementImportType(str, enum.Enum):
|
|
||||||
winlog_csv = "WINLOG_CSV"
|
|
||||||
winlog_pdf = "WINLOG_PDF"
|
|
||||||
winlog_attachment_only = "WINLOG_ATTACHMENT_ONLY"
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementImport(Base, UUIDMixin, TimestampMixin):
|
|
||||||
__tablename__ = "measurement_imports"
|
|
||||||
|
|
||||||
validation_id: Mapped[str] = mapped_column(ForeignKey("validations.id"), index=True)
|
|
||||||
import_type: Mapped[str] = mapped_column(String(60), index=True)
|
|
||||||
original_filename: Mapped[str] = mapped_column(String(255))
|
|
||||||
storage_path: Mapped[str] = mapped_column(String(500))
|
|
||||||
sha256: Mapped[str] = mapped_column(String(64), index=True)
|
|
||||||
parser_version: Mapped[str] = mapped_column(String(40))
|
|
||||||
status: Mapped[str] = mapped_column(String(60), index=True)
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementImportValue(Base, UUIDMixin, TimestampMixin):
|
|
||||||
__tablename__ = "measurement_import_values"
|
|
||||||
|
|
||||||
import_id: Mapped[str] = mapped_column(ForeignKey("measurement_imports.id"), index=True)
|
|
||||||
test_run: Mapped[str] = mapped_column(String(120), index=True)
|
|
||||||
field_name: Mapped[str] = mapped_column(String(120), index=True)
|
|
||||||
raw_value: Mapped[str | None] = mapped_column(Text)
|
|
||||||
normalized_value: Mapped[str | None] = mapped_column(String(180))
|
|
||||||
unit: Mapped[str | None] = mapped_column(String(40))
|
|
||||||
source_page: Mapped[int | None] = mapped_column(Integer)
|
|
||||||
source_text: Mapped[str | None] = mapped_column(Text)
|
|
||||||
confidence: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
|
||||||
corrected_value: Mapped[str | None] = mapped_column(String(180))
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,17 @@ from __future__ import annotations
|
||||||
import enum
|
import enum
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Date, ForeignKey, Integer, JSON, String, Text
|
from sqlalchemy import Date, Enum, ForeignKey, JSON, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
class ValidationStatus(str, enum.Enum):
|
class ValidationStatus(str, enum.Enum):
|
||||||
draft = "ENTWURF"
|
draft = "draft"
|
||||||
ready_for_review = "BEREIT_ZUR_PRUEFUNG"
|
in_progress = "in_progress"
|
||||||
in_review = "IN_PRUEFUNG"
|
ready_for_report = "ready_for_report"
|
||||||
approved = "FREIGEGEBEN"
|
completed = "completed"
|
||||||
completed = "ABGESCHLOSSEN"
|
|
||||||
cancelled = "STORNIERT"
|
|
||||||
|
|
||||||
|
|
||||||
class Validation(Base, UUIDMixin, TimestampMixin):
|
class Validation(Base, UUIDMixin, TimestampMixin):
|
||||||
|
|
@ -24,7 +22,7 @@ class Validation(Base, UUIDMixin, TimestampMixin):
|
||||||
report_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
|
report_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
|
||||||
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
|
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
|
||||||
location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
|
location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
|
||||||
contact_id: Mapped[str | None] = mapped_column(ForeignKey("contacts.id"), index=True, nullable=True)
|
contact_id: Mapped[str | None] = mapped_column(ForeignKey("contacts.id"), index=True)
|
||||||
device_id: Mapped[str | None] = mapped_column(ForeignKey("devices.id"), index=True)
|
device_id: Mapped[str | None] = mapped_column(ForeignKey("devices.id"), index=True)
|
||||||
validation_type: Mapped[str] = mapped_column(String(120))
|
validation_type: Mapped[str] = mapped_column(String(120))
|
||||||
project: Mapped[str | None] = mapped_column(String(180))
|
project: Mapped[str | None] = mapped_column(String(180))
|
||||||
|
|
@ -35,12 +33,8 @@ class Validation(Base, UUIDMixin, TimestampMixin):
|
||||||
scheduled_on: Mapped[date | None] = mapped_column(Date)
|
scheduled_on: Mapped[date | None] = mapped_column(Date)
|
||||||
performed_on: Mapped[date | None] = mapped_column(Date)
|
performed_on: Mapped[date | None] = mapped_column(Date)
|
||||||
next_validation_on: Mapped[date | None] = mapped_column(Date)
|
next_validation_on: Mapped[date | None] = mapped_column(Date)
|
||||||
revalidation_interval_months: Mapped[int] = mapped_column(Integer, default=24)
|
examiner_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"))
|
||||||
next_validation_manually_overridden: Mapped[bool] = mapped_column(Boolean, default=False)
|
status: Mapped[ValidationStatus] = mapped_column(Enum(ValidationStatus), default=ValidationStatus.draft)
|
||||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
|
||||||
previous_validation_id: Mapped[str | None] = mapped_column(ForeignKey("validations.id"), nullable=True)
|
|
||||||
examiner_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
|
||||||
status: Mapped[str] = mapped_column(String(40), default=ValidationStatus.draft.value)
|
|
||||||
result: Mapped[str | None] = mapped_column(String(120))
|
result: Mapped[str | None] = mapped_column(String(120))
|
||||||
notes: Mapped[str | None] = mapped_column(Text)
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
equipment_ids: Mapped[list[str]] = mapped_column(JSON, default=list)
|
equipment_ids: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,21 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.report_template import (
|
|
||||||
MeasurementImport,
|
|
||||||
MeasurementImportStatus,
|
|
||||||
MeasurementImportType,
|
|
||||||
MeasurementImportValue,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MeasurementSeries:
|
class MeasurementSeries:
|
||||||
|
|
@ -24,181 +12,8 @@ class MeasurementSeries:
|
||||||
|
|
||||||
|
|
||||||
class HeliosImportService:
|
class HeliosImportService:
|
||||||
parser_version = "helios-winlog-pdf-1.0"
|
|
||||||
|
|
||||||
def __init__(self, session: Session | None = None, upload_root: Path | None = None) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.upload_root = upload_root or Path("/app/uploads")
|
|
||||||
|
|
||||||
def import_csv(self, path: Path) -> MeasurementSeries:
|
def import_csv(self, path: Path) -> MeasurementSeries:
|
||||||
with path.open(newline="", encoding="utf-8-sig") as handle:
|
with path.open(newline="", encoding="utf-8-sig") as handle:
|
||||||
reader = csv.DictReader(handle)
|
reader = csv.DictReader(handle)
|
||||||
return MeasurementSeries(headers=reader.fieldnames or [], rows=list(reader))
|
return MeasurementSeries(headers=reader.fieldnames or [], rows=list(reader))
|
||||||
|
|
||||||
def save_winlog_pdf(
|
|
||||||
self, validation_id: str, filename: str, content: bytes, attachment_only: bool = False
|
|
||||||
) -> dict:
|
|
||||||
if self.session is None:
|
|
||||||
raise RuntimeError("A database session is required for Winlog imports")
|
|
||||||
safe_name = Path(filename or "winlog.pdf").name
|
|
||||||
target_dir = self.upload_root / "validations" / validation_id / "winlog"
|
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
storage_path = target_dir / safe_name
|
|
||||||
storage_path.write_bytes(content)
|
|
||||||
digest = hashlib.sha256(content).hexdigest()
|
|
||||||
import_row = MeasurementImport(
|
|
||||||
validation_id=validation_id,
|
|
||||||
import_type=(
|
|
||||||
MeasurementImportType.winlog_attachment_only.value
|
|
||||||
if attachment_only
|
|
||||||
else MeasurementImportType.winlog_pdf.value
|
|
||||||
),
|
|
||||||
original_filename=safe_name,
|
|
||||||
storage_path=str(storage_path),
|
|
||||||
sha256=digest,
|
|
||||||
parser_version=self.parser_version,
|
|
||||||
status=(
|
|
||||||
MeasurementImportStatus.attachment_only.value
|
|
||||||
if attachment_only
|
|
||||||
else MeasurementImportStatus.uploaded.value
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.session.add(import_row)
|
|
||||||
self.session.flush()
|
|
||||||
values: list[MeasurementImportValue] = []
|
|
||||||
if not attachment_only:
|
|
||||||
values = self._extract_pdf_values(import_row, storage_path)
|
|
||||||
import_row.status = (
|
|
||||||
MeasurementImportStatus.preview_ready.value
|
|
||||||
if values
|
|
||||||
else MeasurementImportStatus.attachment_only.value
|
|
||||||
)
|
|
||||||
import_row.import_type = (
|
|
||||||
MeasurementImportType.winlog_pdf.value
|
|
||||||
if values
|
|
||||||
else MeasurementImportType.winlog_attachment_only.value
|
|
||||||
)
|
|
||||||
self.session.flush()
|
|
||||||
return self.preview(import_row.id)
|
|
||||||
|
|
||||||
def preview(self, import_id: str) -> dict:
|
|
||||||
if self.session is None:
|
|
||||||
raise RuntimeError("A database session is required for Winlog imports")
|
|
||||||
import_row = self.session.get(MeasurementImport, import_id)
|
|
||||||
if import_row is None:
|
|
||||||
raise ValueError("Measurement import not found")
|
|
||||||
values = list(
|
|
||||||
self.session.scalars(
|
|
||||||
select(MeasurementImportValue).where(MeasurementImportValue.import_id == import_id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"id": import_row.id,
|
|
||||||
"validation_id": import_row.validation_id,
|
|
||||||
"import_type": import_row.import_type,
|
|
||||||
"original_filename": import_row.original_filename,
|
|
||||||
"sha256": import_row.sha256,
|
|
||||||
"parser_version": import_row.parser_version,
|
|
||||||
"status": import_row.status,
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"id": value.id,
|
|
||||||
"test_run": value.test_run,
|
|
||||||
"field_name": value.field_name,
|
|
||||||
"raw_value": value.raw_value,
|
|
||||||
"normalized_value": value.normalized_value,
|
|
||||||
"unit": value.unit,
|
|
||||||
"source_page": value.source_page,
|
|
||||||
"source_text": value.source_text,
|
|
||||||
"confidence": value.confidence,
|
|
||||||
"confirmed": value.confirmed,
|
|
||||||
"corrected_value": value.corrected_value,
|
|
||||||
}
|
|
||||||
for value in values
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def confirm_values(self, import_id: str, values: list[dict]) -> dict:
|
|
||||||
if self.session is None:
|
|
||||||
raise RuntimeError("A database session is required for Winlog imports")
|
|
||||||
import_row = self.session.get(MeasurementImport, import_id)
|
|
||||||
if import_row is None:
|
|
||||||
raise ValueError("Measurement import not found")
|
|
||||||
by_id = {
|
|
||||||
value.id: value
|
|
||||||
for value in self.session.scalars(
|
|
||||||
select(MeasurementImportValue).where(MeasurementImportValue.import_id == import_id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for payload in values:
|
|
||||||
item = by_id.get(payload.get("id"))
|
|
||||||
if item is None:
|
|
||||||
continue
|
|
||||||
item.confirmed = bool(payload.get("confirmed"))
|
|
||||||
item.corrected_value = payload.get("corrected_value") or item.corrected_value
|
|
||||||
import_row.status = MeasurementImportStatus.confirmed.value
|
|
||||||
self.session.flush()
|
|
||||||
return self.preview(import_id)
|
|
||||||
|
|
||||||
def _extract_pdf_values(self, import_row: MeasurementImport, path: Path) -> list[MeasurementImportValue]:
|
|
||||||
from pypdf import PdfReader
|
|
||||||
|
|
||||||
values: list[MeasurementImportValue] = []
|
|
||||||
try:
|
|
||||||
reader = PdfReader(str(path))
|
|
||||||
pages = [page.extract_text() or "" for page in reader.pages]
|
|
||||||
except Exception:
|
|
||||||
import_row.status = MeasurementImportStatus.error.value
|
|
||||||
return []
|
|
||||||
for page_index, page_text in enumerate(pages, start=1):
|
|
||||||
if not page_text.strip():
|
|
||||||
continue
|
|
||||||
test_run = self._detect_test_run(page_text)
|
|
||||||
for field_name, pattern, unit in self._patterns():
|
|
||||||
match = re.search(pattern, page_text, flags=re.IGNORECASE)
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
raw_value = match.group(1).strip()
|
|
||||||
value = MeasurementImportValue(
|
|
||||||
import_id=import_row.id,
|
|
||||||
test_run=test_run,
|
|
||||||
field_name=field_name,
|
|
||||||
raw_value=raw_value,
|
|
||||||
normalized_value=raw_value,
|
|
||||||
unit=unit,
|
|
||||||
source_page=page_index,
|
|
||||||
source_text=match.group(0)[:500],
|
|
||||||
confidence=80,
|
|
||||||
confirmed=False,
|
|
||||||
corrected_value=None,
|
|
||||||
)
|
|
||||||
self.session.add(value)
|
|
||||||
values.append(value)
|
|
||||||
return values
|
|
||||||
|
|
||||||
def _detect_test_run(self, text: str) -> str:
|
|
||||||
lower = text.lower()
|
|
||||||
if "vakuum" in lower:
|
|
||||||
return "Vakuumtest"
|
|
||||||
if "bowie" in lower or "leerkammer" in lower:
|
|
||||||
return "Bowie-Dick / Leerkammerprofil"
|
|
||||||
for index in (1, 2, 3):
|
|
||||||
if f"testlauf {index}" in lower or f"test {index}" in lower:
|
|
||||||
return f"Testlauf {index}"
|
|
||||||
return "nicht zugeordnet"
|
|
||||||
|
|
||||||
def _patterns(self) -> list[tuple[str, str, str | None]]:
|
|
||||||
return [
|
|
||||||
("program_name", r"Programm(?:name)?[:\s]+([^\n]+)", None),
|
|
||||||
("batch_number", r"Charge(?:nnummer)?[:\s]+([^\n]+)", None),
|
|
||||||
("start_time", r"Start(?:zeit)?[:\s]+([0-9:.\-\s]+)", None),
|
|
||||||
("end_time", r"(?:Ende|Endzeit)[:\s]+([0-9:.\-\s]+)", None),
|
|
||||||
("duration", r"Dauer[:\s]+([0-9:.\-\s]+)", None),
|
|
||||||
("min_temperature", r"Min(?:dest)?temperatur[:\s]+([0-9,.]+)", "°C"),
|
|
||||||
("max_temperature", r"Max(?:imal|\.)?temperatur[:\s]+([0-9,.]+)", "°C"),
|
|
||||||
("temperature_band", r"Temperaturband[:\s]+([0-9,.]+)", "K"),
|
|
||||||
("holding_time", r"Haltezeit[:\s]+([0-9:.\-\s]+)", None),
|
|
||||||
("pressure", r"Druck[:\s]+([0-9,.-]+)", "bar"),
|
|
||||||
("leak_rate", r"Leckrate[:\s]+([0-9,.-]+)", "mbar/min"),
|
|
||||||
("result", r"Ergebnis[:\s]+([^\n]+)", None),
|
|
||||||
]
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,12 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ORION_ASSET_DIR = Path(__file__).resolve().parent / "assets"
|
|
||||||
SCHUBAMED_LOGO_PATH = ORION_ASSET_DIR / "schubamed-logo.svg"
|
|
||||||
|
|
||||||
|
|
||||||
def schubamed_logo_uri() -> str:
|
|
||||||
if not SCHUBAMED_LOGO_PATH.exists():
|
|
||||||
raise FileNotFoundError(f"Required Orion logo asset is missing: {SCHUBAMED_LOGO_PATH}")
|
|
||||||
return SCHUBAMED_LOGO_PATH.resolve().as_uri()
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?xml version="1.0" ?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 640 480">
|
|
||||||
<path d="M476,95 L483,94 L486,97 L486,99 L484,101 L477,102 Z" fill="#F60B2F"/>
|
|
||||||
<path d="M473,92 L473,112 L476,112 L477,104 L480,104 L485,112 L489,112 L485,106 L489,101 L488,93 Z" fill="#F60B2F"/>
|
|
||||||
<path d="M476,83 L487,84 L496,92 L498,97 L498,107 L496,111 L485,120 L475,120 L470,118 L464,112 L461,106 L461,98 L464,91 L469,86 Z" fill="#F60B2F"/>
|
|
||||||
<path d="M329,84 L328,399 L329,401 L338,401 L354,399 L358,396 L358,119 L363,118 L385,127 L391,133 L392,386 L403,381 L417,372 L421,365 L422,158 L432,170 L444,193 L450,211 L454,230 L454,250 L448,281 L441,297 L440,303 L460,316 L466,315 L478,284 L483,255 L483,232 L480,210 L470,178 L454,150 L431,124 L407,106 L378,92 L350,85 Z" fill="#F60B2F"/>
|
|
||||||
<path d="M308,83 L283,86 L256,94 L235,104 L211,121 L193,139 L178,160 L167,182 L159,208 L159,216 L162,219 L278,285 L281,289 L280,367 L269,364 L250,355 L229,340 L211,320 L203,308 L194,289 L190,285 L186,283 L163,283 L162,288 L168,305 L184,334 L195,348 L209,362 L236,381 L268,395 L298,401 L310,400 L310,271 L303,265 L201,207 L192,200 L207,170 L217,157 L229,145 L255,127 L280,117 L281,200 L284,202 L307,202 L310,200 L310,84 Z" fill="#F60B2F"/>
|
|
||||||
<path d="M473,80 L467,83 L461,89 L458,95 L458,108 L463,117 L468,121 L476,124 L487,123 L493,120 L498,115 L501,109 L501,94 L497,87 L491,82 L486,80 Z" fill="#F60B2F"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,37 +0,0 @@
|
||||||
from app.modules.orion.components.base import ReportComponent
|
|
||||||
from app.modules.orion.components.chapters import (
|
|
||||||
AttachmentComponent,
|
|
||||||
ChecklistComponent,
|
|
||||||
CoverComponent,
|
|
||||||
CustomerComponent,
|
|
||||||
DeviceComponent,
|
|
||||||
DryingComponent,
|
|
||||||
EnvironmentComponent,
|
|
||||||
EquipmentComponent,
|
|
||||||
LoadingComponent,
|
|
||||||
MeasurementComponent,
|
|
||||||
ProgramComponent,
|
|
||||||
RecommendationComponent,
|
|
||||||
StaticTextComponent,
|
|
||||||
SummaryComponent,
|
|
||||||
TocComponent,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AttachmentComponent",
|
|
||||||
"ChecklistComponent",
|
|
||||||
"CoverComponent",
|
|
||||||
"CustomerComponent",
|
|
||||||
"DeviceComponent",
|
|
||||||
"DryingComponent",
|
|
||||||
"EnvironmentComponent",
|
|
||||||
"EquipmentComponent",
|
|
||||||
"LoadingComponent",
|
|
||||||
"MeasurementComponent",
|
|
||||||
"ProgramComponent",
|
|
||||||
"RecommendationComponent",
|
|
||||||
"StaticTextComponent",
|
|
||||||
"ReportComponent",
|
|
||||||
"SummaryComponent",
|
|
||||||
"TocComponent",
|
|
||||||
]
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,15 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
from app.modules.orion.context import ReportContext
|
|
||||||
|
|
||||||
|
|
||||||
class ReportComponent(ABC):
|
|
||||||
anchor: str
|
|
||||||
title: str
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
@ -1,551 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.modules.orion.assets import schubamed_logo_uri
|
|
||||||
from app.modules.orion.components.base import ReportComponent
|
|
||||||
from app.modules.orion.context import ReportContext
|
|
||||||
from app.modules.orion.html import definition_list, paragraph, section, table, text, yes_no
|
|
||||||
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:
|
|
||||||
values = {
|
|
||||||
"device.manufacturer": context.device.manufacturer if context.device else None,
|
|
||||||
"device.model": context.device.model if context.device else None,
|
|
||||||
"device.serial_number": context.device.serial_number if context.device else None,
|
|
||||||
"customer.name": context.customer.name,
|
|
||||||
"location.city": context.location.city if context.location else None,
|
|
||||||
"validation.performed_on": context.validation.performed_on,
|
|
||||||
"validation.next_validation_on": context.validation.next_validation_on,
|
|
||||||
"validation.result": context.validation.result,
|
|
||||||
}
|
|
||||||
rendered = content
|
|
||||||
for key, value in values.items():
|
|
||||||
rendered = rendered.replace("{{ " + key + " }}", text(value))
|
|
||||||
return rendered
|
|
||||||
|
|
||||||
|
|
||||||
class NumberedComponent(ReportComponent):
|
|
||||||
anchor = ""
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
def __init__(self, anchor: str | None = None, title: str | None = None, number: str | None = None) -> None:
|
|
||||||
if anchor is not None:
|
|
||||||
self.anchor = anchor
|
|
||||||
if title is not None:
|
|
||||||
self.title = title
|
|
||||||
self.number = number
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bookmark_label(self) -> str:
|
|
||||||
return f"{self.number} {self.title}" if self.number else self.title
|
|
||||||
|
|
||||||
|
|
||||||
class CoverComponent(ReportComponent):
|
|
||||||
anchor = "cover"
|
|
||||||
title = "Deckblatt"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
validation = context.validation
|
|
||||||
rows = definition_list(
|
|
||||||
[
|
|
||||||
("Hersteller", context.device.manufacturer if context.device else "nicht erfasst"),
|
|
||||||
("Geraet", context.device.model if context.device else "nicht erfasst"),
|
|
||||||
(
|
|
||||||
"Seriennummer",
|
|
||||||
context.device.serial_number if context.device else "nicht erfasst",
|
|
||||||
),
|
|
||||||
("Berichtsnummer", validation.report_number),
|
|
||||||
("Validierungsart", validation.validation_type),
|
|
||||||
("Projekt", validation.project),
|
|
||||||
("Pruefdatum", validation.performed_on),
|
|
||||||
("Pruefungsort", validation.test_location),
|
|
||||||
("Pruefer", validation.examiner_name),
|
|
||||||
("Status", validation.status),
|
|
||||||
(
|
|
||||||
"Ansprechpartner",
|
|
||||||
context.contact.full_name if context.contact else "nicht erfasst",
|
|
||||||
),
|
|
||||||
("Mitwirkende Personen", validation.participants or "nicht erfasst"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
logo_uri = schubamed_logo_uri()
|
|
||||||
return (
|
|
||||||
'<section class="cover-page" id="cover">'
|
|
||||||
'<div class="cover-top">'
|
|
||||||
'<div class="company-address">SCHUBAMED<br>Validation Suite<br>Medizintechnik und Validierung</div>'
|
|
||||||
f'<img class="cover-logo" src="{logo_uri}" alt="SCHUBAMED Validation Suite">'
|
|
||||||
"</div>"
|
|
||||||
"<h1>PRÜFBERICHT ZUR VALIDIERUNG</h1>"
|
|
||||||
'<p class="cover-subtitle">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>'
|
|
||||||
f'<p class="cover-subtitle">{text(context.customer.name)}</p>'
|
|
||||||
f"{rows}"
|
|
||||||
'<div class="cover-closing">'
|
|
||||||
f"{self._cover_result_text(context)}"
|
|
||||||
f"{self._signature_blocks(context)}"
|
|
||||||
"</div>"
|
|
||||||
"</section>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _cover_result_text(self, context: ReportContext) -> str:
|
|
||||||
if not context.report_settings.show_cover_result_text:
|
|
||||||
return ""
|
|
||||||
return (
|
|
||||||
'<div class="cover-result-text">'
|
|
||||||
"<h2>Ergebnis der Validierung</h2>"
|
|
||||||
f"<p>{text(validation_result_cover_sentence(context.validation.result))}</p>"
|
|
||||||
"</div>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _signature_blocks(self, context: ReportContext) -> str:
|
|
||||||
blocks = [
|
|
||||||
'<div class="signature-block"><div class="signature-line"></div><div class="signature-label">Unterschrift technische Validierung</div></div>'
|
|
||||||
]
|
|
||||||
if context.report_settings.signature_mode == "TECHNICAL_AND_CLIENT":
|
|
||||||
blocks.append(
|
|
||||||
'<div class="signature-block"><div class="signature-line"></div><div class="signature-label">Unterschrift Auftraggeber</div></div>'
|
|
||||||
)
|
|
||||||
return '<div class="signature-grid">' + "".join(blocks) + "</div>"
|
|
||||||
|
|
||||||
|
|
||||||
class TocComponent(ReportComponent):
|
|
||||||
anchor = "toc"
|
|
||||||
title = "Inhaltsverzeichnis"
|
|
||||||
|
|
||||||
def __init__(self, sections: list[dict]) -> None:
|
|
||||||
self.sections = sections
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
links = "".join(
|
|
||||||
self._toc_item(item)
|
|
||||||
for item in self.sections
|
|
||||||
if item.get("toc")
|
|
||||||
)
|
|
||||||
return section(self.anchor, self.title, f'<ol class="toc-list">{links}</ol>')
|
|
||||||
|
|
||||||
def _toc_item(self, item: dict) -> str:
|
|
||||||
number = item.get("number")
|
|
||||||
title = item.get("title")
|
|
||||||
label = f"{number} {title}" if number else str(title)
|
|
||||||
depth = str(number).count(".") + 1 if number else 2
|
|
||||||
return (
|
|
||||||
f'<li class="toc-level-{depth}"><a href="#{text(item.get("key"))}">'
|
|
||||||
f'<span class="toc-label">{text(label)}</span></a></li>'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SummaryComponent(ReportComponent):
|
|
||||||
anchor = "summary"
|
|
||||||
title = "Zusammenfassendes Ergebnis der Validierung"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
validation = context.validation
|
|
||||||
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 += definition_list(
|
|
||||||
[
|
|
||||||
("Kunde", context.customer.name),
|
|
||||||
("Standort", context.location.name if context.location else None),
|
|
||||||
(
|
|
||||||
"Geraet",
|
|
||||||
(
|
|
||||||
f"{context.device.manufacturer} {context.device.model}"
|
|
||||||
if context.device
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("Pruefmittel", len(context.equipment)),
|
|
||||||
("Ergebnis", validation_result_label(validation.result)),
|
|
||||||
("Mitwirkende Personen", validation.participants),
|
|
||||||
(
|
|
||||||
"Hinweis auf naechste Leistungsbeurteilung",
|
|
||||||
validation.next_validation_on or "nicht erfasst",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return section(self.anchor, self.title, body)
|
|
||||||
|
|
||||||
|
|
||||||
class StaticTextComponent(ReportComponent):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
anchor: str,
|
|
||||||
title: str,
|
|
||||||
body: str = "nicht erfasst",
|
|
||||||
block_key: str | None = None,
|
|
||||||
number: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.anchor = anchor
|
|
||||||
self.title = title
|
|
||||||
self.body = body
|
|
||||||
self.block_key = block_key
|
|
||||||
self.number = number
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bookmark_label(self) -> str:
|
|
||||||
return f"{self.number} {self.title}" if self.number else self.title
|
|
||||||
|
|
||||||
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:
|
|
||||||
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(self.body)}</p>", self.bookmark_label)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerComponent(ReportComponent):
|
|
||||||
anchor = "customer"
|
|
||||||
title = "Kunde, Standort und Ansprechpartner"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
customer = context.customer
|
|
||||||
location = context.location
|
|
||||||
contact = context.contact
|
|
||||||
body = "<h3>Kunde</h3>" + definition_list(
|
|
||||||
[
|
|
||||||
("Name", customer.name),
|
|
||||||
("Typ", customer.customer_type.value),
|
|
||||||
(
|
|
||||||
"Adresse",
|
|
||||||
" ".join(filter(None, [customer.street, customer.postal_code, customer.city])),
|
|
||||||
),
|
|
||||||
("Telefon", customer.phone),
|
|
||||||
("Mail", customer.email),
|
|
||||||
("Betreiber", context.validation.operator_name),
|
|
||||||
("QM", customer.quality_manager),
|
|
||||||
("Hygienebeauftragter", customer.hygiene_officer),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if location:
|
|
||||||
body += "<h3>Standort</h3>" + definition_list(
|
|
||||||
[
|
|
||||||
("Name", location.name),
|
|
||||||
(
|
|
||||||
"Adresse",
|
|
||||||
" ".join(
|
|
||||||
filter(None, [location.street, location.postal_code, location.city])
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("Raum", location.room),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if contact:
|
|
||||||
body += "<h3>Ansprechpartner</h3>" + definition_list(
|
|
||||||
[
|
|
||||||
("Name", contact.full_name),
|
|
||||||
("Funktion", contact.function),
|
|
||||||
("Mail", contact.email),
|
|
||||||
("Telefon", contact.phone),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return section(self.anchor, self.title, body)
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceComponent(NumberedComponent):
|
|
||||||
anchor = "device"
|
|
||||||
title = "Geraet"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
device = context.device
|
|
||||||
if device is None:
|
|
||||||
return section(self.anchor, self.title, "<p>nicht erfasst</p>", self.bookmark_label)
|
|
||||||
body = definition_list(
|
|
||||||
[
|
|
||||||
("Hersteller", device.manufacturer),
|
|
||||||
("Modell", device.model),
|
|
||||||
("Typ", device.device_type),
|
|
||||||
("Seriennummer", device.serial_number),
|
|
||||||
("Baujahr", device.year_built),
|
|
||||||
("Inbetriebnahme", device.commissioned_on),
|
|
||||||
(
|
|
||||||
"Kammervolumen",
|
|
||||||
(
|
|
||||||
f"{device.chamber_volume_liters} Liter"
|
|
||||||
if device.chamber_volume_liters
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("Dampferzeugung", device.steam_generation),
|
|
||||||
("Wasseraufbereitung", device.water_treatment),
|
|
||||||
("Dokumentation", device.documentation),
|
|
||||||
("Lieferant", device.supplier),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return section(self.anchor, self.title, body, self.bookmark_label)
|
|
||||||
|
|
||||||
|
|
||||||
class EquipmentComponent(NumberedComponent):
|
|
||||||
anchor = "equipment"
|
|
||||||
title = "Pruefmittel"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
rows = [
|
|
||||||
[
|
|
||||||
item.kind.value,
|
|
||||||
item.manufacturer,
|
|
||||||
item.model,
|
|
||||||
item.serial_number,
|
|
||||||
item.calibrated_on,
|
|
||||||
item.calibration_due_on,
|
|
||||||
item.status.value,
|
|
||||||
]
|
|
||||||
for item in context.equipment
|
|
||||||
]
|
|
||||||
return section(
|
|
||||||
self.anchor,
|
|
||||||
self.title,
|
|
||||||
table(
|
|
||||||
[
|
|
||||||
"Art",
|
|
||||||
"Hersteller",
|
|
||||||
"Modell",
|
|
||||||
"Seriennummer",
|
|
||||||
"Kalibriert",
|
|
||||||
"Gueltig bis",
|
|
||||||
"Status",
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
),
|
|
||||||
self.bookmark_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EnvironmentComponent(NumberedComponent):
|
|
||||||
anchor = "environment"
|
|
||||||
title = "Umgebungsbedingungen"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
data = context.validation.environment_conditions or {}
|
|
||||||
body = definition_list(
|
|
||||||
[
|
|
||||||
("Raumtemperatur", data.get("room_temperature")),
|
|
||||||
("relative Luftfeuchtigkeit", data.get("humidity")),
|
|
||||||
("Pruefzeit", data.get("test_time")),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
rows = [
|
|
||||||
[item.get("text"), yes_no(item.get("value")), item.get("comment")]
|
|
||||||
for item in data.get("checks", [])
|
|
||||||
]
|
|
||||||
if rows:
|
|
||||||
body += table(["Pruefpunkt", "Bewertung", "Kommentar"], rows)
|
|
||||||
return section(self.anchor, self.title, body, self.bookmark_label)
|
|
||||||
|
|
||||||
|
|
||||||
class ChecklistComponent(NumberedComponent):
|
|
||||||
anchor = "checklists"
|
|
||||||
title = "Dokumentations- und Leistungschecklisten"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
body = ""
|
|
||||||
for checklist in context.checklist_templates:
|
|
||||||
body += f"<h3>{text(checklist.title)}</h3>"
|
|
||||||
body += self._render_items(checklist.items)
|
|
||||||
if not body:
|
|
||||||
documentation = context.validation.documentation_checklist or []
|
|
||||||
performance = context.validation.performance_checklist or []
|
|
||||||
body = "<h3>Dokumentation</h3>" + self._render_items(documentation)
|
|
||||||
body += "<h3>Leistung</h3>" + self._render_items(performance)
|
|
||||||
return section(self.anchor, self.title, body, self.bookmark_label)
|
|
||||||
|
|
||||||
def _render_items(self, items: list[dict]) -> str:
|
|
||||||
rows = [
|
|
||||||
[item.get("number"), item.get("text"), yes_no(item.get("value")), item.get("comment")]
|
|
||||||
for item in items
|
|
||||||
]
|
|
||||||
return table(["Nr.", "Pruefpunkt", "Bewertung", "Kommentar"], rows)
|
|
||||||
|
|
||||||
|
|
||||||
class ProgramComponent(NumberedComponent):
|
|
||||||
anchor = "programs"
|
|
||||||
title = "Programme"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
programs = [item for item in (context.validation.programs or []) if item.get("selected")]
|
|
||||||
rows = [
|
|
||||||
[index + 1, item.get("name"), "Eigenes Programm" if item.get("custom") else "Standard"]
|
|
||||||
for index, item in enumerate(programs)
|
|
||||||
]
|
|
||||||
return section(self.anchor, self.title, table(["Nr.", "Programm", "Typ"], rows), self.bookmark_label)
|
|
||||||
|
|
||||||
|
|
||||||
class LoadingComponent(NumberedComponent):
|
|
||||||
anchor = "loading"
|
|
||||||
title = "Beladungsmuster"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
rows = [
|
|
||||||
[
|
|
||||||
item.get("run"),
|
|
||||||
item.get("pattern"),
|
|
||||||
item.get("description"),
|
|
||||||
len(item.get("images", [])),
|
|
||||||
]
|
|
||||||
for item in context.validation.loading_patterns or []
|
|
||||||
]
|
|
||||||
return section(
|
|
||||||
self.anchor,
|
|
||||||
self.title,
|
|
||||||
table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows),
|
|
||||||
self.bookmark_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementComponent(NumberedComponent):
|
|
||||||
anchor = "measurements"
|
|
||||||
title = "Messdaten"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
if context.confirmed_measurements:
|
|
||||||
rows = [
|
|
||||||
[
|
|
||||||
item.test_run,
|
|
||||||
item.field_name,
|
|
||||||
item.corrected_value or item.normalized_value or item.raw_value,
|
|
||||||
item.unit,
|
|
||||||
item.source_page,
|
|
||||||
f"{item.confidence} %",
|
|
||||||
]
|
|
||||||
for item in context.confirmed_measurements
|
|
||||||
]
|
|
||||||
return section(
|
|
||||||
self.anchor,
|
|
||||||
self.title,
|
|
||||||
table(["Testlauf", "Messwert", "Wert", "Einheit", "Quelle", "Sicherheit"], rows, "compact"),
|
|
||||||
self.bookmark_label,
|
|
||||||
)
|
|
||||||
rows = [
|
|
||||||
[
|
|
||||||
item.get("name"),
|
|
||||||
item.get("start_time"),
|
|
||||||
item.get("end_time"),
|
|
||||||
item.get("duration"),
|
|
||||||
item.get("leak_rate"),
|
|
||||||
item.get("min_temperature"),
|
|
||||||
item.get("max_temperature"),
|
|
||||||
item.get("temperature_band"),
|
|
||||||
item.get("holding_time"),
|
|
||||||
item.get("pressure"),
|
|
||||||
item.get("result"),
|
|
||||||
]
|
|
||||||
for item in context.validation.measurement_data or []
|
|
||||||
]
|
|
||||||
body = table(
|
|
||||||
[
|
|
||||||
"Bereich",
|
|
||||||
"Start",
|
|
||||||
"Ende",
|
|
||||||
"Dauer",
|
|
||||||
"Leckrate",
|
|
||||||
"Min. Temp.",
|
|
||||||
"Max. Temp.",
|
|
||||||
"Band",
|
|
||||||
"Haltezeit",
|
|
||||||
"Druck",
|
|
||||||
"Ergebnis",
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
"compact",
|
|
||||||
)
|
|
||||||
winlog_rows = []
|
|
||||||
for item in context.validation.measurement_data or []:
|
|
||||||
for imported in item.get("imports", []):
|
|
||||||
winlog_rows.append(
|
|
||||||
[item.get("name"), imported.get("filename"), imported.get("content_type")]
|
|
||||||
)
|
|
||||||
if winlog_rows:
|
|
||||||
body += "<h3>Winlog-Dateien</h3>" + table(["Bereich", "Datei", "Typ"], winlog_rows)
|
|
||||||
body += "<p>Messdaten noch nicht bestätigt.</p>"
|
|
||||||
return section(self.anchor, self.title, body, self.bookmark_label)
|
|
||||||
|
|
||||||
|
|
||||||
class DryingComponent(NumberedComponent):
|
|
||||||
anchor = "drying"
|
|
||||||
title = "Trocknung"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
data = context.validation.drying or {}
|
|
||||||
body = definition_list(
|
|
||||||
[
|
|
||||||
("Startgewicht", data.get("start_weight")),
|
|
||||||
("Endgewicht", data.get("end_weight")),
|
|
||||||
("Differenz", data.get("difference")),
|
|
||||||
("Bewertung", data.get("rating")),
|
|
||||||
("Bemerkung", data.get("comment")),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return section(self.anchor, self.title, body, self.bookmark_label)
|
|
||||||
|
|
||||||
|
|
||||||
class RecommendationComponent(NumberedComponent):
|
|
||||||
anchor = "recommendations"
|
|
||||||
title = "Empfehlungen und Auflagen"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
rows = [
|
|
||||||
[item.get("number"), item.get("text"), item.get("deadline"), item.get("status")]
|
|
||||||
for item in context.validation.recommendations or []
|
|
||||||
]
|
|
||||||
return section(self.anchor, self.title, table(["Nr.", "Text", "Frist", "Status"], rows), self.bookmark_label)
|
|
||||||
|
|
||||||
|
|
||||||
class AttachmentComponent(NumberedComponent):
|
|
||||||
anchor = "attachments"
|
|
||||||
title = "Bilder und Anlagen"
|
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
|
||||||
attachments = sorted(
|
|
||||||
context.validation.attachments or [], key=lambda row: row.get("order") or 0
|
|
||||||
)
|
|
||||||
rows = [
|
|
||||||
[item.get("order"), item.get("category"), item.get("filename"), item.get("description")]
|
|
||||||
for item in attachments
|
|
||||||
]
|
|
||||||
figures = []
|
|
||||||
for index, item in enumerate(attachments, start=1):
|
|
||||||
src = self._image_source(item)
|
|
||||||
if not src:
|
|
||||||
continue
|
|
||||||
caption = (
|
|
||||||
item.get("description") or item.get("filename") or item.get("category") or "Anlage"
|
|
||||||
)
|
|
||||||
figures.append(
|
|
||||||
'<figure class="report-figure">'
|
|
||||||
f'<img class="report-image" src="{text(src)}" alt="{text(caption)}">'
|
|
||||||
f'<figcaption class="image-caption">Abbildung {index}: {text(caption)}</figcaption>'
|
|
||||||
"</figure>"
|
|
||||||
)
|
|
||||||
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)
|
|
||||||
if figures:
|
|
||||||
body += '<div class="figure-grid">' + "".join(figures) + "</div>"
|
|
||||||
return section(self.anchor, self.title, body, self.bookmark_label)
|
|
||||||
|
|
||||||
def _image_source(self, item: dict) -> str | None:
|
|
||||||
content_type = str(item.get("content_type") or "")
|
|
||||||
filename = str(item.get("filename") or "")
|
|
||||||
if not content_type.startswith("image/") and Path(filename).suffix.lower() not in {
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".png",
|
|
||||||
".webp",
|
|
||||||
".svg",
|
|
||||||
}:
|
|
||||||
return None
|
|
||||||
url = item.get("url")
|
|
||||||
if isinstance(url, str) and url:
|
|
||||||
if url.startswith(("http://", "https://")):
|
|
||||||
return url
|
|
||||||
return f"{settings.public_base_url.rstrip('/')}{quote(url, safe='/:._-')}"
|
|
||||||
storage_path = item.get("storage_path")
|
|
||||||
if storage_path:
|
|
||||||
path = Path(str(storage_path))
|
|
||||||
if path.exists():
|
|
||||||
return path.resolve().as_uri()
|
|
||||||
return None
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import 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
|
|
||||||
from app.models.report_template import (
|
|
||||||
ChecklistTemplate,
|
|
||||||
MeasurementImport,
|
|
||||||
MeasurementImportValue,
|
|
||||||
ReportSection,
|
|
||||||
TextBlock,
|
|
||||||
)
|
|
||||||
from app.models.report_settings import OrionReportSettings
|
|
||||||
from app.models.location import Location
|
|
||||||
from app.models.validation import Validation
|
|
||||||
from app.modules.orion.template_service import ReportTemplateService
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ReportContext:
|
|
||||||
validation: Validation
|
|
||||||
customer: Customer
|
|
||||||
location: Location | None
|
|
||||||
contact: Contact | None
|
|
||||||
device: Device | None
|
|
||||||
equipment: list[Equipment]
|
|
||||||
generated_dir: Path
|
|
||||||
report_sections: list[ReportSection]
|
|
||||||
text_blocks: dict[str, TextBlock]
|
|
||||||
checklist_templates: list[ChecklistTemplate]
|
|
||||||
confirmed_measurements: list[MeasurementImportValue]
|
|
||||||
report_settings: OrionReportSettings
|
|
||||||
|
|
||||||
|
|
||||||
class OrionContextBuilder:
|
|
||||||
def __init__(self, session: Session, generated_dir: Path) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.generated_dir = generated_dir
|
|
||||||
|
|
||||||
def build(self, validation_id: str) -> ReportContext:
|
|
||||||
validation = self.session.get(Validation, validation_id)
|
|
||||||
if validation is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Validation not found")
|
|
||||||
|
|
||||||
customer = self.session.get(Customer, validation.customer_id)
|
|
||||||
if customer is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Validation has no customer")
|
|
||||||
|
|
||||||
location = self.session.get(Location, validation.location_id) if validation.location_id else None
|
|
||||||
contact = self.session.get(Contact, validation.contact_id) if validation.contact_id else None
|
|
||||||
device = self.session.get(Device, validation.device_id) if validation.device_id else None
|
|
||||||
equipment = []
|
|
||||||
if validation.equipment_ids:
|
|
||||||
equipment = list(
|
|
||||||
self.session.scalars(
|
|
||||||
select(Equipment).where(Equipment.id.in_(validation.equipment_ids))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
template_bundle = ReportTemplateService(self.session).ensure_default_template()
|
|
||||||
from app.services.report_settings import OrionReportSettingsService
|
|
||||||
|
|
||||||
report_settings = OrionReportSettingsService(self.session).get_global()
|
|
||||||
confirmed_measurements = list(
|
|
||||||
self.session.scalars(
|
|
||||||
select(MeasurementImportValue)
|
|
||||||
.join_from(MeasurementImportValue, MeasurementImport)
|
|
||||||
.where(
|
|
||||||
MeasurementImport.validation_id == validation.id,
|
|
||||||
MeasurementImportValue.confirmed.is_(True),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.generated_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return ReportContext(
|
|
||||||
validation=validation,
|
|
||||||
customer=customer,
|
|
||||||
location=location,
|
|
||||||
contact=contact,
|
|
||||||
device=device,
|
|
||||||
equipment=equipment,
|
|
||||||
generated_dir=self.generated_dir,
|
|
||||||
report_sections=template_bundle.sections,
|
|
||||||
text_blocks=template_bundle.text_blocks,
|
|
||||||
checklist_templates=template_bundle.checklists,
|
|
||||||
confirmed_measurements=confirmed_measurements,
|
|
||||||
report_settings=report_settings,
|
|
||||||
)
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
|
||||||
from html import escape
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def text(value: Any) -> str:
|
|
||||||
if value is None or value == "":
|
|
||||||
return "nicht erfasst"
|
|
||||||
if isinstance(value, (date, datetime)):
|
|
||||||
return value.strftime("%d.%m.%Y")
|
|
||||||
return escape(str(value))
|
|
||||||
|
|
||||||
|
|
||||||
def paragraph(value: Any) -> str:
|
|
||||||
content = text(value)
|
|
||||||
return content.replace("\n", "<br>")
|
|
||||||
|
|
||||||
|
|
||||||
def yes_no(value: Any) -> str:
|
|
||||||
labels = {"yes": "Ja", "no": "Nein", "na": "Nicht zutreffend", True: "Ja", False: "Nein"}
|
|
||||||
return text(labels.get(value, value))
|
|
||||||
|
|
||||||
|
|
||||||
def definition_list(rows: list[tuple[str, Any]]) -> str:
|
|
||||||
items = "".join(
|
|
||||||
f"<div class=\"definition-row\"><dt>{text(label)}</dt><dd>{paragraph(value)}</dd></div>"
|
|
||||||
for label, value in rows
|
|
||||||
if value not in (None, "", [])
|
|
||||||
)
|
|
||||||
return f"<dl class=\"definition-list\">{items}</dl>"
|
|
||||||
|
|
||||||
|
|
||||||
def table(headers: list[str], rows: list[list[Any]], css_class: str = "") -> str:
|
|
||||||
head = "".join(f"<th>{text(header)}</th>" for header in headers)
|
|
||||||
body = "".join(
|
|
||||||
"<tr>" + "".join(f"<td>{paragraph(cell)}</td>" for cell in row) + "</tr>"
|
|
||||||
for row in rows
|
|
||||||
)
|
|
||||||
return f"<table class=\"data-table {css_class}\"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
|
|
||||||
|
|
||||||
|
|
||||||
def section(chapter_id: str, title: str, body: str, bookmark_label: str | None = None) -> str:
|
|
||||||
bookmark_attr = (
|
|
||||||
f' data-bookmark-label="{text(bookmark_label)}"' if bookmark_label is not None else ""
|
|
||||||
)
|
|
||||||
heading = bookmark_label or title
|
|
||||||
return (
|
|
||||||
f'<section class="chapter" id="{text(chapter_id)}">'
|
|
||||||
f'<h2 class="chapter-title"{bookmark_attr}>{text(heading)}</h2>{body}</section>'
|
|
||||||
)
|
|
||||||
|
|
@ -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>"
|
|
||||||
)
|
|
||||||
|
|
@ -1,292 +1,30 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import logging
|
|
||||||
from datetime import UTC, date, datetime
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from weasyprint import HTML
|
||||||
|
|
||||||
from app.modules.orion.assets import ORION_ASSET_DIR
|
|
||||||
from app.modules.orion.components import (
|
|
||||||
AttachmentComponent,
|
|
||||||
ChecklistComponent,
|
|
||||||
CoverComponent,
|
|
||||||
CustomerComponent,
|
|
||||||
DeviceComponent,
|
|
||||||
DryingComponent,
|
|
||||||
EnvironmentComponent,
|
|
||||||
EquipmentComponent,
|
|
||||||
LoadingComponent,
|
|
||||||
MeasurementComponent,
|
|
||||||
ProgramComponent,
|
|
||||||
RecommendationComponent,
|
|
||||||
ReportComponent,
|
|
||||||
StaticTextComponent,
|
|
||||||
SummaryComponent,
|
|
||||||
TocComponent,
|
|
||||||
)
|
|
||||||
from app.modules.orion.context import OrionContextBuilder, ReportContext
|
|
||||||
from app.modules.orion.html import section, table, text
|
|
||||||
from app.modules.orion.templates.report import render_document
|
|
||||||
from app.modules.orion.template_service import REPORT_SECTIONS
|
|
||||||
from app.models.report_settings import (
|
|
||||||
OrionImageSize,
|
|
||||||
OrionLayoutProfile,
|
|
||||||
OrionReportSettings,
|
|
||||||
OrionReportSettingsScope,
|
|
||||||
OrionSignatureMode,
|
|
||||||
)
|
|
||||||
from app.schemas.domain import OrionReportSettingsUpdate
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class OrionReportService:
|
class OrionReportService:
|
||||||
def __init__(self, session: Session, generated_dir: Path | None = None) -> None:
|
chapters = [
|
||||||
self.session = session
|
"Deckblatt",
|
||||||
self.generated_dir = generated_dir or Path("/app/reports")
|
"Inhaltsverzeichnis",
|
||||||
|
"Zusammenfassung",
|
||||||
|
"Gerät",
|
||||||
|
"Kunde",
|
||||||
|
"Normen",
|
||||||
|
"Prüfmittel",
|
||||||
|
"Programme",
|
||||||
|
"Beladung",
|
||||||
|
"Messungen",
|
||||||
|
"Diagramme",
|
||||||
|
"Empfehlungen",
|
||||||
|
"Anlagen",
|
||||||
|
]
|
||||||
|
|
||||||
def render_html(self, validation_id: str) -> str:
|
def render_pdf(self, title: str, output_path: Path) -> Path:
|
||||||
context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
|
chapter_markup = "".join(f"<section><h2>{chapter}</h2></section>" for chapter in self.chapters)
|
||||||
components = self._components()
|
html = f"<html><body><h1>{title}</h1>{chapter_markup}</body></html>"
|
||||||
chapters = [component.render(context) for component in components]
|
HTML(string=html).write_pdf(output_path)
|
||||||
return render_document(context, chapters)
|
|
||||||
|
|
||||||
def render_pdf(self, validation_id: str) -> Path:
|
|
||||||
from weasyprint import HTML
|
|
||||||
|
|
||||||
context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
|
|
||||||
output_path = context.generated_dir / f"{context.validation.report_number}.pdf"
|
|
||||||
html = self.render_html(validation_id)
|
|
||||||
HTML(string=html, base_url=str(ORION_ASSET_DIR)).write_pdf(output_path)
|
|
||||||
self._append_pdf_attachments(context, output_path)
|
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
def render_settings_preview_pdf(self, settings_override: OrionReportSettingsUpdate | None = None) -> bytes:
|
|
||||||
from weasyprint import HTML
|
|
||||||
|
|
||||||
context = self._preview_context(settings_override)
|
|
||||||
preview_section = section(
|
|
||||||
"layout-preview",
|
|
||||||
"Vorschau Hauptkapitel",
|
|
||||||
table(
|
|
||||||
["Bereich", "Darstellung"],
|
|
||||||
[
|
|
||||||
["Layoutprofil", context.report_settings.layout_profile],
|
|
||||||
["Seitenraender", context.report_settings.page_margin],
|
|
||||||
["Abschnittsabstaende", context.report_settings.section_spacing],
|
|
||||||
["Tabellenlayout", context.report_settings.table_layout],
|
|
||||||
["Tabellenschrift", context.report_settings.table_font_size],
|
|
||||||
["Bildgroesse", context.report_settings.image_size],
|
|
||||||
["Bildposition", context.report_settings.image_position],
|
|
||||||
["Bilder pro Seite", context.report_settings.max_images_per_page],
|
|
||||||
["Kopfzeile", "Ein" if context.report_settings.show_header else "Aus"],
|
|
||||||
["Fusszeile", "Ein" if context.report_settings.show_footer else "Aus"],
|
|
||||||
["Logo", context.report_settings.logo_size],
|
|
||||||
["Kapitelumbruch", "Ein" if context.report_settings.page_break_before_main_chapters else "Aus"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
+ (
|
|
||||||
'<div class="figure-grid">'
|
|
||||||
+ "".join(
|
|
||||||
'<figure class="report-figure">'
|
|
||||||
f'<img class="report-image" src="{text((ORION_ASSET_DIR / "schubamed-logo.svg").resolve().as_uri())}" alt="Beispielbild {index}">'
|
|
||||||
f'<figcaption class="image-caption">Abbildung {index}: Beispielbild fuer die Layoutvorschau</figcaption>'
|
|
||||||
"</figure>"
|
|
||||||
for index in range(1, 5)
|
|
||||||
)
|
|
||||||
+ "</div>"
|
|
||||||
),
|
|
||||||
"1 Vorschau Hauptkapitel",
|
|
||||||
)
|
|
||||||
chapters = [
|
|
||||||
CoverComponent().render(context),
|
|
||||||
SummaryComponent().render(context),
|
|
||||||
TocComponent([{"key": "layout-preview", "number": "1", "title": "Vorschau Hauptkapitel", "toc": True}]).render(context),
|
|
||||||
CustomerComponent().render(context),
|
|
||||||
preview_section,
|
|
||||||
StaticTextComponent("results", "Ergebnisse der Validierung", "Die spaetere Ergebnisbox bleibt Bestandteil des Abschlussbereichs.", number="2").render(context),
|
|
||||||
]
|
|
||||||
html = render_document(context, chapters)
|
|
||||||
return HTML(string=html, base_url=str(ORION_ASSET_DIR)).write_pdf()
|
|
||||||
|
|
||||||
def _components(self) -> list[ReportComponent]:
|
|
||||||
chapters = [self._component_for_section(item) for item in REPORT_SECTIONS]
|
|
||||||
return [CoverComponent(), SummaryComponent(), TocComponent(REPORT_SECTIONS), CustomerComponent(), *chapters]
|
|
||||||
|
|
||||||
def _preview_context(self, settings_override: OrionReportSettingsUpdate | None) -> ReportContext:
|
|
||||||
if settings_override is None:
|
|
||||||
report_settings = self._settings_clone()
|
|
||||||
else:
|
|
||||||
report_settings = OrionReportSettings(
|
|
||||||
scope=OrionReportSettingsScope.global_.value,
|
|
||||||
layout_profile=settings_override.layout_profile.value,
|
|
||||||
show_cover_result_text=settings_override.show_cover_result_text,
|
|
||||||
signature_mode=settings_override.signature_mode.value,
|
|
||||||
image_size=settings_override.image_size.value,
|
|
||||||
page_break_before_main_chapters=settings_override.page_break_before_main_chapters,
|
|
||||||
page_margin=settings_override.page_margin.value,
|
|
||||||
section_spacing=settings_override.section_spacing.value,
|
|
||||||
table_layout=settings_override.table_layout.value,
|
|
||||||
table_font_size=settings_override.table_font_size.value,
|
|
||||||
image_position=settings_override.image_position.value,
|
|
||||||
max_images_per_page=settings_override.max_images_per_page,
|
|
||||||
show_image_captions=settings_override.show_image_captions,
|
|
||||||
show_header=settings_override.show_header,
|
|
||||||
show_footer=settings_override.show_footer,
|
|
||||||
logo_size=settings_override.logo_size.value,
|
|
||||||
compact_cover=settings_override.compact_cover,
|
|
||||||
)
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
validation = SimpleNamespace(
|
|
||||||
id="preview-validation",
|
|
||||||
report_number="ORION-LAYOUT-PREVIEW",
|
|
||||||
validation_type="Layoutvorschau",
|
|
||||||
project="Administrative Orion-Vorschau",
|
|
||||||
performed_on=date.today(),
|
|
||||||
test_location="Schubamed Preview",
|
|
||||||
examiner_name="Technische Validierung",
|
|
||||||
status="ENTWURF",
|
|
||||||
result="BESTANDEN_MIT_AUFLAGEN",
|
|
||||||
participants="Preview Team",
|
|
||||||
operator_name="Preview Betreiber",
|
|
||||||
next_validation_on=date(date.today().year + 2, date.today().month, date.today().day),
|
|
||||||
version=1,
|
|
||||||
updated_at=now,
|
|
||||||
equipment_ids=[],
|
|
||||||
environment_conditions={},
|
|
||||||
documentation_checklist=[],
|
|
||||||
performance_checklist=[],
|
|
||||||
programs=[],
|
|
||||||
loading_patterns=[],
|
|
||||||
measurement_data=[],
|
|
||||||
drying={},
|
|
||||||
recommendations=[],
|
|
||||||
attachments=[],
|
|
||||||
)
|
|
||||||
customer = SimpleNamespace(
|
|
||||||
name="Demo-Praxis Orion",
|
|
||||||
customer_type=SimpleNamespace(value="practice"),
|
|
||||||
street="Musterstrasse 12",
|
|
||||||
postal_code="12345",
|
|
||||||
city="Berlin",
|
|
||||||
phone="+49 30 123456",
|
|
||||||
email="preview@schubamed.de",
|
|
||||||
hygiene_officer="H. Muster",
|
|
||||||
quality_manager="Q. Beispiel",
|
|
||||||
)
|
|
||||||
location = SimpleNamespace(name="Aufbereitungsraum", street="Musterstrasse 12", postal_code="12345", city="Berlin", room="AEMP")
|
|
||||||
contact = SimpleNamespace(full_name="Dr. Petra Beispiel", function="Praxisleitung", email="kontakt@example.de", phone="+49 30 123456")
|
|
||||||
device = SimpleNamespace(
|
|
||||||
manufacturer="MELAG",
|
|
||||||
model="Vacuklav 44 B+",
|
|
||||||
serial_number="PREVIEW-12345",
|
|
||||||
device_type="Klein-Sterilisator",
|
|
||||||
year_built=2024,
|
|
||||||
commissioned_on=date(2024, 5, 15),
|
|
||||||
chamber_volume_liters=22,
|
|
||||||
steam_generation="integriert",
|
|
||||||
water_treatment="VE-Wasser",
|
|
||||||
documentation="vollstaendig",
|
|
||||||
supplier="Schubamed",
|
|
||||||
)
|
|
||||||
return ReportContext(
|
|
||||||
validation=validation,
|
|
||||||
customer=customer,
|
|
||||||
location=location,
|
|
||||||
contact=contact,
|
|
||||||
device=device,
|
|
||||||
equipment=[],
|
|
||||||
generated_dir=self.generated_dir,
|
|
||||||
report_sections=[],
|
|
||||||
text_blocks={
|
|
||||||
"summary": SimpleNamespace(
|
|
||||||
content="Diese Vorschau zeigt die aktuell gewaehlten Orion-Berichtseinstellungen ohne Produktivdaten zu veraendern."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
checklist_templates=[],
|
|
||||||
confirmed_measurements=[],
|
|
||||||
report_settings=report_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _settings_clone(self) -> OrionReportSettings:
|
|
||||||
from app.services.report_settings import OrionReportSettingsService
|
|
||||||
|
|
||||||
saved = OrionReportSettingsService(self.session).get_global()
|
|
||||||
return OrionReportSettings(
|
|
||||||
scope=saved.scope,
|
|
||||||
layout_profile=saved.layout_profile,
|
|
||||||
show_cover_result_text=saved.show_cover_result_text,
|
|
||||||
signature_mode=saved.signature_mode,
|
|
||||||
image_size=saved.image_size,
|
|
||||||
page_break_before_main_chapters=saved.page_break_before_main_chapters,
|
|
||||||
page_margin=saved.page_margin,
|
|
||||||
section_spacing=saved.section_spacing,
|
|
||||||
table_layout=saved.table_layout,
|
|
||||||
table_font_size=saved.table_font_size,
|
|
||||||
image_position=saved.image_position,
|
|
||||||
max_images_per_page=saved.max_images_per_page,
|
|
||||||
show_image_captions=saved.show_image_captions,
|
|
||||||
show_header=saved.show_header,
|
|
||||||
show_footer=saved.show_footer,
|
|
||||||
logo_size=saved.logo_size,
|
|
||||||
compact_cover=saved.compact_cover,
|
|
||||||
updated_by_user_id=saved.updated_by_user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _component_for_section(self, item: dict) -> ReportComponent:
|
|
||||||
component = item.get("component")
|
|
||||||
key = str(item["key"])
|
|
||||||
title = str(item["title"])
|
|
||||||
number = item.get("number")
|
|
||||||
if component == "device":
|
|
||||||
return DeviceComponent(key, title, number)
|
|
||||||
if component == "checklist":
|
|
||||||
return ChecklistComponent(key, title, number)
|
|
||||||
if component == "environment":
|
|
||||||
return EnvironmentComponent(key, title, number)
|
|
||||||
if component == "programs":
|
|
||||||
return ProgramComponent(key, title, number)
|
|
||||||
if component == "loading":
|
|
||||||
return LoadingComponent(key, title, number)
|
|
||||||
if component == "equipment":
|
|
||||||
return EquipmentComponent(key, title, number)
|
|
||||||
if component == "measurements":
|
|
||||||
return MeasurementComponent(key, title, number)
|
|
||||||
if component == "drying":
|
|
||||||
return DryingComponent(key, title, number)
|
|
||||||
if component == "recommendations":
|
|
||||||
return RecommendationComponent(key, title, number)
|
|
||||||
if component == "attachments":
|
|
||||||
return AttachmentComponent(key, title, number)
|
|
||||||
return StaticTextComponent(key, title, block_key=item.get("block_key"), number=number)
|
|
||||||
|
|
||||||
def _append_pdf_attachments(self, context: ReportContext, output_path: Path) -> None:
|
|
||||||
pdf_paths = []
|
|
||||||
for item in context.validation.attachments or []:
|
|
||||||
filename = str(item.get("filename") or "")
|
|
||||||
content_type = str(item.get("content_type") or "")
|
|
||||||
if not filename.lower().endswith(".pdf") and content_type != "application/pdf":
|
|
||||||
continue
|
|
||||||
storage_path = item.get("storage_path")
|
|
||||||
if storage_path and Path(str(storage_path)).exists():
|
|
||||||
pdf_paths.append(Path(str(storage_path)))
|
|
||||||
if not pdf_paths:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
from pypdf import PdfReader, PdfWriter
|
|
||||||
|
|
||||||
writer = PdfWriter()
|
|
||||||
for page in PdfReader(str(output_path)).pages:
|
|
||||||
writer.add_page(page)
|
|
||||||
for pdf_path in pdf_paths:
|
|
||||||
try:
|
|
||||||
for page in PdfReader(str(pdf_path)).pages:
|
|
||||||
writer.add_page(page)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Could not append attachment PDF %s", pdf_path)
|
|
||||||
with output_path.open("wb") as handle:
|
|
||||||
writer.write(handle)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Could not merge Orion PDF attachments for validation %s", context.validation.id)
|
|
||||||
|
|
|
||||||
|
|
@ -1,356 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from zipfile import ZipFile
|
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.report_template import ChecklistTemplate, ReportSection, ReportTemplate, TextBlock
|
|
||||||
from app.modules.orion.html import text
|
|
||||||
|
|
||||||
TEMPLATE_KEY = "small_steam_sterilizer_initial_validation"
|
|
||||||
TEMPLATE_NAME = "Erstvalidierung Klein-Sterilisator"
|
|
||||||
TEMPLATE_VERSION = "1.0"
|
|
||||||
REFERENCE_PATH = "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx"
|
|
||||||
|
|
||||||
REPORT_SECTIONS: list[dict[str, Any]] = [
|
|
||||||
{"key": "functional_qualification", "number": "1", "title": "Funktionsqualifikation (BQ)", "toc": True, "component": "static", "block_key": None},
|
|
||||||
{"key": "purpose", "number": "1.1", "title": "Anlass und Ziel der Prüfung", "toc": True, "component": "static", "block_key": "bq_goal"},
|
|
||||||
{"key": "legal", "number": "1.2", "title": "Gesetzliche Grundlagen", "toc": True, "component": "static", "block_key": "legal"},
|
|
||||||
{"key": "device", "number": "1.3", "title": "Angaben zum Gerät", "toc": True, "component": "device"},
|
|
||||||
{"key": "performance", "number": "1.4", "title": "Leistungsüberprüfung", "toc": True, "component": "static", "block_key": "performance"},
|
|
||||||
{"key": "performance_checklist", "number": "1.5", "title": "Checkliste Leistungsanforderung", "toc": True, "component": "checklist"},
|
|
||||||
{"key": "documentation", "number": "1.6", "title": "Dokumentation / Kontrolle", "toc": True, "component": "static"},
|
|
||||||
{"key": "work_instructions", "number": "1.7", "title": "Arbeitsanweisungen", "toc": True, "component": "static"},
|
|
||||||
{"key": "environment", "number": "1.8", "title": "Umgebungsbedingungen", "toc": True, "component": "environment"},
|
|
||||||
{"key": "batch_control", "number": "1.9", "title": "Chargenkontrolle", "toc": True, "component": "static"},
|
|
||||||
{"key": "programs", "number": "1.10", "title": "Beschreibung der verwendeten Programme", "toc": True, "component": "programs"},
|
|
||||||
{"key": "loading", "number": "1.11", "title": "Beladungsbeschreibungen", "toc": True, "component": "loading"},
|
|
||||||
{"key": "reference_load", "number": "1.12", "title": "Referenzbeladung Sterilisator bei Validierung", "toc": True, "component": "static"},
|
|
||||||
{"key": "equipment_root", "number": "2", "title": "Eingesetzte Prüfmittel", "toc": True, "component": "static"},
|
|
||||||
{"key": "measurement_devices", "number": "2.1", "title": "Beschreibung der Messgeräte", "toc": True, "component": "equipment"},
|
|
||||||
{"key": "thermo_equipment", "number": "2.2", "title": "Prüfmittel zur thermoelektrischen Untersuchung", "toc": True, "component": "static"},
|
|
||||||
{"key": "configuration", "number": "2.3", "title": "Prüfkonfiguration", "toc": True, "component": "static"},
|
|
||||||
{"key": "lq", "number": "3", "title": "Leistungsqualifikation (LQ)", "toc": True, "component": "static"},
|
|
||||||
{"key": "thermo_tests", "number": "3.1", "title": "Leistungsbeurteilung – Thermoelektrische Prüfungen", "toc": True, "component": "static"},
|
|
||||||
{"key": "vacuum_test", "number": "3.1.1", "title": "Vakuumtest", "toc": True, "component": "measurements"},
|
|
||||||
{"key": "run_1", "number": "3.2", "title": "Standardbeladung (1. Durchlauf)", "toc": True, "component": "static"},
|
|
||||||
{"key": "test_1", "number": "3.2.1", "title": "Test 1", "toc": True, "component": "static"},
|
|
||||||
{"key": "run_2", "number": "3.3", "title": "Standardbeladung (2. Durchlauf)", "toc": True, "component": "static"},
|
|
||||||
{"key": "test_2", "number": "3.3.1", "title": "Test 2", "toc": True, "component": "static"},
|
|
||||||
{"key": "run_3", "number": "3.4", "title": "Standardbeladung (3. Durchlauf)", "toc": True, "component": "static"},
|
|
||||||
{"key": "test_3", "number": "3.4.1", "title": "Test 3", "toc": True, "component": "static"},
|
|
||||||
{"key": "results", "number": "4", "title": "Ergebnisse der Validierung", "toc": True, "component": "static"},
|
|
||||||
{"key": "results_vacuum", "number": "4.1", "title": "Vakuumtest", "toc": True, "component": "static"},
|
|
||||||
{"key": "results_runs", "number": "4.1.2", "title": "Testläufe 1 bis 3 Programm 134 °C B", "toc": True, "component": "static"},
|
|
||||||
{"key": "drying", "number": None, "title": "Nachweis der Trocknungseigenschaften Testläufe 1 bis 3", "toc": True, "component": "drying"},
|
|
||||||
{"key": "recommendations", "number": "4.2", "title": "Empfehlungen und Auflagen", "toc": True, "component": "recommendations"},
|
|
||||||
{"key": "appendix", "number": "5", "title": "Anhang", "toc": True, "component": "static"},
|
|
||||||
{"key": "winlog", "number": "5.1", "title": "Vakuumtest / Winlog-Auswertungen", "toc": True, "component": "attachments"},
|
|
||||||
{"key": "bd_empty_chamber", "number": None, "title": "Testlauf Leerkammerprofil / Bowie-Dick", "toc": True, "component": "static"},
|
|
||||||
{"key": "batch_release_docs", "number": None, "title": "Chargen- und Freigabedokumentation", "toc": True, "component": "static"},
|
|
||||||
{"key": "release_docs", "number": "5.2", "title": "Freigabedokumentation / Routineprüfungen", "toc": True, "component": "static"},
|
|
||||||
{"key": "indicators", "number": "5.3", "title": "Nachweis der umgeschlagenen Indikatoren", "toc": True, "component": "static"},
|
|
||||||
{"key": "cycles", "number": "6", "title": "Programmabläufe / Zyklen des Sterilisators", "toc": True, "component": "static"},
|
|
||||||
{"key": "practice_certificates", "number": "6.1", "title": "Zertifikate Praxis", "toc": True, "component": "static"},
|
|
||||||
{"key": "risk", "number": "7", "title": "Risikoeinstufung der Praxis nach RKI", "toc": True, "component": "static"},
|
|
||||||
{"key": "closing_talk", "number": "7.1", "title": "Abschlussgespräch zur Validierung", "toc": True, "component": "static"},
|
|
||||||
{"key": "certificates", "number": "8", "title": "Zertifikate", "toc": True, "component": "static"},
|
|
||||||
{"key": "calibration_certificates", "number": "9", "title": "Werkskalibrierzertifikate Sensoren", "toc": True, "component": "static"},
|
|
||||||
]
|
|
||||||
|
|
||||||
TEXT_BLOCK_HEADINGS = {
|
|
||||||
"summary": "Zusammenfassendes Ergebnis der Validierung",
|
|
||||||
"bq_goal": "Anlass und Ziel der Prüfung",
|
|
||||||
"legal": "Gesetzliche Grundlagen",
|
|
||||||
"performance": "Leistungsüberprüfung",
|
|
||||||
}
|
|
||||||
|
|
||||||
CHECKLIST_DEFINITIONS = [
|
|
||||||
(
|
|
||||||
"sterilizer_description",
|
|
||||||
"Beschreibung Sterilisator",
|
|
||||||
["Beschreibung", "Ja", "Nein", "Nicht anwendbar", "Kommentar"],
|
|
||||||
[
|
|
||||||
"Trennung von Controlling und Monitoring Schaltkreisen",
|
|
||||||
"Temperaturüberwachung Sterilisationsprozess",
|
|
||||||
"Drucküberwachung Sterilisationsprozess",
|
|
||||||
"Wasserzulauf - Ablauf / manuelle Überwachung",
|
|
||||||
"Automatische Aufzeichnung der Sterilisationsergebnisse",
|
|
||||||
"Überwachung Wasserqualität",
|
|
||||||
"Separater Netzanschluss",
|
|
||||||
"Sichtkontrolle: Kessel, Sensoren, Türbereich",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"documentation_control",
|
|
||||||
"Dokumentation / Kontrolle",
|
|
||||||
["Vorliegende Dokumente / Beschreibungen", "vorhanden", "eingesehen", "Kommentar"],
|
|
||||||
[
|
|
||||||
"Bedienungshandbuch / Installationsprotokoll",
|
|
||||||
"Wartungshandbuch",
|
|
||||||
"Arbeitsanweisungen / Checklisten / reale Beladungsmuster",
|
|
||||||
"Schulungsnachweise Mitarbeiter zur Aufbereitung",
|
|
||||||
"Risikoeinstufung der Medizinprodukte nach RKI",
|
|
||||||
"Protokoll mit Freigabe der Sterilisation durch Mitarbeiter",
|
|
||||||
"Chargenkontrolle / Indikator für jede Charge",
|
|
||||||
"Chargendokumentation der letzten 6 Wochen",
|
|
||||||
"Dokumentation / Bilder der schwierigsten repräsentativen Beladung",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"work_instructions",
|
|
||||||
"Arbeitsanweisungen",
|
|
||||||
["Arbeitsanweisung", "vorhanden", "eingesehen", "Prüfintervall", "Kommentar"],
|
|
||||||
[
|
|
||||||
"Aufbereitung von Medizinprodukten",
|
|
||||||
"Beladung Sterilisator",
|
|
||||||
"Routinekontrollen",
|
|
||||||
"Freigabe von Chargen",
|
|
||||||
"Umgang mit Abweichungen",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"environment_conditions",
|
|
||||||
"Umgebungsbedingungen",
|
|
||||||
["Prüfpunkt", "Ja", "Nein", "Nicht anwendbar", "Kommentar"],
|
|
||||||
["Raumbedingungen stabil", "Aufstellort frei zugänglich", "Medienversorgung verfügbar"],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"batch_documentation",
|
|
||||||
"Chargendokumentation",
|
|
||||||
["Prüfpunkt", "vorhanden", "eingesehen", "Kommentar"],
|
|
||||||
["Vakuumtest", "Testlauf 1", "Testlauf 2", "Testlauf 3", "Freigabedokumentation"],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"batch_control",
|
|
||||||
"Chargenkontrolle",
|
|
||||||
["Prüfpunkt", "Ja", "Nein", "Prüfkörper", "Kommentar"],
|
|
||||||
["Bowie-Dick / Leerkammerprofil", "Helix-Test", "Indikatoren umgeschlagen"],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TemplateBundle:
|
|
||||||
template: ReportTemplate
|
|
||||||
sections: list[ReportSection]
|
|
||||||
text_blocks: dict[str, TextBlock]
|
|
||||||
checklists: list[ChecklistTemplate]
|
|
||||||
|
|
||||||
|
|
||||||
class ReportTemplateService:
|
|
||||||
def __init__(self, session: Session, project_root: Path | None = None) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.project_root = project_root or self._discover_project_root()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def reference_path(self) -> Path:
|
|
||||||
return self.project_root / REFERENCE_PATH
|
|
||||||
|
|
||||||
def _discover_project_root(self) -> Path:
|
|
||||||
current = Path(__file__).resolve()
|
|
||||||
for parent in current.parents:
|
|
||||||
if (parent / REFERENCE_PATH).exists():
|
|
||||||
return parent
|
|
||||||
return Path("/app")
|
|
||||||
|
|
||||||
def ensure_default_template(self) -> TemplateBundle:
|
|
||||||
if not self.reference_path.exists():
|
|
||||||
raise FileNotFoundError(f"Required reference report is missing: {self.reference_path}")
|
|
||||||
template = self.session.scalar(
|
|
||||||
select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
|
|
||||||
)
|
|
||||||
if template is None:
|
|
||||||
template = ReportTemplate(
|
|
||||||
template_key=TEMPLATE_KEY,
|
|
||||||
name=TEMPLATE_NAME,
|
|
||||||
version=TEMPLATE_VERSION,
|
|
||||||
validation_type="Erstvalidierung",
|
|
||||||
reference_path=REFERENCE_PATH,
|
|
||||||
active=True,
|
|
||||||
)
|
|
||||||
self.session.add(template)
|
|
||||||
try:
|
|
||||||
self.session.flush()
|
|
||||||
except IntegrityError:
|
|
||||||
self.session.rollback()
|
|
||||||
template = self.session.scalar(
|
|
||||||
select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
|
|
||||||
)
|
|
||||||
if template is None:
|
|
||||||
raise
|
|
||||||
if not self._has_children(template):
|
|
||||||
self._create_children(template)
|
|
||||||
self.session.flush()
|
|
||||||
return self.load_bundle()
|
|
||||||
|
|
||||||
def load_bundle(self) -> TemplateBundle:
|
|
||||||
template = self.session.scalar(
|
|
||||||
select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
|
|
||||||
)
|
|
||||||
if template is None:
|
|
||||||
return self.ensure_default_template()
|
|
||||||
sections = list(
|
|
||||||
self.session.scalars(
|
|
||||||
select(ReportSection)
|
|
||||||
.where(ReportSection.template_id == template.id, ReportSection.active.is_(True))
|
|
||||||
.order_by(ReportSection.order_index)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
text_blocks = {
|
|
||||||
block.block_key: block
|
|
||||||
for block in self.session.scalars(
|
|
||||||
select(TextBlock)
|
|
||||||
.where(TextBlock.template_id == template.id, TextBlock.active.is_(True))
|
|
||||||
.order_by(TextBlock.order_index)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
checklists = list(
|
|
||||||
self.session.scalars(
|
|
||||||
select(ChecklistTemplate)
|
|
||||||
.where(ChecklistTemplate.template_id == template.id, ChecklistTemplate.active.is_(True))
|
|
||||||
.order_by(ChecklistTemplate.order_index)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return TemplateBundle(template=template, sections=sections, text_blocks=text_blocks, checklists=checklists)
|
|
||||||
|
|
||||||
def render_block(self, block_key: str, context: Any) -> str:
|
|
||||||
bundle = self.load_bundle()
|
|
||||||
block = bundle.text_blocks.get(block_key)
|
|
||||||
if block is None:
|
|
||||||
return "nicht erfasst"
|
|
||||||
return self.render_text(block.content, context)
|
|
||||||
|
|
||||||
def render_text(self, content: str, context: Any) -> str:
|
|
||||||
values = {
|
|
||||||
"device.manufacturer": context.device.manufacturer if context.device else None,
|
|
||||||
"device.model": context.device.model if context.device else None,
|
|
||||||
"device.serial_number": context.device.serial_number if context.device else None,
|
|
||||||
"customer.name": context.customer.name,
|
|
||||||
"location.city": context.location.city if context.location else None,
|
|
||||||
"validation.performed_on": context.validation.performed_on,
|
|
||||||
"validation.next_validation_on": context.validation.next_validation_on,
|
|
||||||
"validation.result": context.validation.result,
|
|
||||||
}
|
|
||||||
rendered = content
|
|
||||||
for key, value in values.items():
|
|
||||||
rendered = rendered.replace("{{ " + key + " }}", text(value))
|
|
||||||
return rendered
|
|
||||||
|
|
||||||
def _has_children(self, template: ReportTemplate) -> bool:
|
|
||||||
return bool(
|
|
||||||
self.session.scalar(
|
|
||||||
select(TextBlock.id).where(TextBlock.template_id == template.id).limit(1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_children(self, template: ReportTemplate) -> None:
|
|
||||||
paragraphs = self._read_docx_paragraphs()
|
|
||||||
blocks = self._extract_blocks(paragraphs)
|
|
||||||
for index, section in enumerate(REPORT_SECTIONS, start=1):
|
|
||||||
self.session.add(
|
|
||||||
ReportSection(
|
|
||||||
template_id=template.id,
|
|
||||||
section_key=str(section["key"]),
|
|
||||||
number=section["number"],
|
|
||||||
title=str(section["title"]),
|
|
||||||
order_index=index,
|
|
||||||
page_break_before=section["number"] in {"1", "2", "3", "4", "5", "6", "7", "8", "9"},
|
|
||||||
active=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for index, (block_key, title) in enumerate(TEXT_BLOCK_HEADINGS.items(), start=1):
|
|
||||||
self.session.add(
|
|
||||||
TextBlock(
|
|
||||||
template_id=template.id,
|
|
||||||
block_key=block_key,
|
|
||||||
title=title,
|
|
||||||
content=self._sanitize_reference_text(blocks.get(block_key, "nicht erfasst")),
|
|
||||||
order_index=index,
|
|
||||||
version=TEMPLATE_VERSION,
|
|
||||||
active=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for index, (key, title, columns, items) in enumerate(CHECKLIST_DEFINITIONS, start=1):
|
|
||||||
self.session.add(
|
|
||||||
ChecklistTemplate(
|
|
||||||
template_id=template.id,
|
|
||||||
checklist_key=key,
|
|
||||||
title=title,
|
|
||||||
columns=columns,
|
|
||||||
items=[
|
|
||||||
{"number": item_index + 1, "text": item, "value": "na", "comment": ""}
|
|
||||||
for item_index, item in enumerate(items)
|
|
||||||
],
|
|
||||||
order_index=index,
|
|
||||||
active=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _read_docx_paragraphs(self) -> list[str]:
|
|
||||||
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
|
||||||
with ZipFile(self.reference_path) as archive:
|
|
||||||
root = ET.fromstring(archive.read("word/document.xml"))
|
|
||||||
paragraphs: list[str] = []
|
|
||||||
for paragraph in root.findall(".//w:p", ns):
|
|
||||||
line = "".join(
|
|
||||||
node.text or "" for node in paragraph.findall(".//w:t", ns)
|
|
||||||
).strip()
|
|
||||||
if line:
|
|
||||||
paragraphs.append(line)
|
|
||||||
return paragraphs
|
|
||||||
|
|
||||||
def _extract_blocks(self, paragraphs: list[str]) -> dict[str, str]:
|
|
||||||
blocks: dict[str, str] = {}
|
|
||||||
heading_by_normalized = {self._normalize(value): key for key, value in TEXT_BLOCK_HEADINGS.items()}
|
|
||||||
active_key: str | None = None
|
|
||||||
buffer: list[str] = []
|
|
||||||
for paragraph in paragraphs:
|
|
||||||
normalized = self._normalize(paragraph)
|
|
||||||
if normalized in heading_by_normalized:
|
|
||||||
if active_key:
|
|
||||||
blocks[active_key] = "\n\n".join(buffer).strip()
|
|
||||||
active_key = heading_by_normalized[normalized]
|
|
||||||
buffer = []
|
|
||||||
continue
|
|
||||||
if active_key and self._looks_like_next_section(paragraph):
|
|
||||||
blocks[active_key] = "\n\n".join(buffer).strip()
|
|
||||||
active_key = None
|
|
||||||
buffer = []
|
|
||||||
continue
|
|
||||||
if active_key:
|
|
||||||
buffer.append(paragraph)
|
|
||||||
if active_key:
|
|
||||||
blocks[active_key] = "\n\n".join(buffer).strip()
|
|
||||||
return blocks
|
|
||||||
|
|
||||||
def _sanitize_reference_text(self, content: str) -> str:
|
|
||||||
replacements = {
|
|
||||||
"Euronda E10.7": "{{ device.manufacturer }} {{ device.model }}",
|
|
||||||
"Euronda / E10.7": "{{ device.manufacturer }} / {{ device.model }}",
|
|
||||||
"EXN250688": "{{ device.serial_number }}",
|
|
||||||
"Dr.Durmaz": "{{ customer.name }}",
|
|
||||||
"Nürnberg": "{{ location.city }}",
|
|
||||||
"05.12.2025": "{{ validation.performed_on }}",
|
|
||||||
"November 2027": "{{ validation.next_validation_on }}",
|
|
||||||
"bestanden": "{{ validation.result }}",
|
|
||||||
}
|
|
||||||
sanitized = content
|
|
||||||
for old, new in replacements.items():
|
|
||||||
sanitized = sanitized.replace(old, new)
|
|
||||||
return sanitized or "nicht erfasst"
|
|
||||||
|
|
||||||
def _normalize(self, value: str) -> str:
|
|
||||||
return re.sub(r"[^a-z0-9]+", "", value.lower())
|
|
||||||
|
|
||||||
def _looks_like_next_section(self, value: str) -> bool:
|
|
||||||
if value in {"Inhaltsverzeichnis", "1.3 Angaben zum Gerät"}:
|
|
||||||
return True
|
|
||||||
return bool(re.match(r"^\d+(\.\d+)*\s", value))
|
|
||||||
Binary file not shown.
|
|
@ -1,710 +0,0 @@
|
||||||
@page {
|
|
||||||
size: A4;
|
|
||||||
}
|
|
||||||
|
|
||||||
@page cover {
|
|
||||||
margin: 20mm 18mm 20mm 18mm;
|
|
||||||
@top-left { content: ""; }
|
|
||||||
@bottom-left { content: ""; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@page report {
|
|
||||||
margin: 34mm 18mm 24mm 18mm;
|
|
||||||
@top-left { content: element(report-header); }
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
color: #2E3B40;
|
|
||||||
font-family: Inter, Arial, sans-serif;
|
|
||||||
font-size: 10.5pt;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-meta {
|
|
||||||
display: none;
|
|
||||||
string-set: report-number attr(data-report-number), report-version attr(data-report-version), report-date attr(data-report-date);
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header {
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: .25mm solid #DCE3E3;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 54mm;
|
|
||||||
height: 24mm;
|
|
||||||
left: 0;
|
|
||||||
padding-bottom: 3mm;
|
|
||||||
position: running(report-header);
|
|
||||||
top: 0;
|
|
||||||
width: 174mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-brand {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
gap: 4mm;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-brand img {
|
|
||||||
display: block;
|
|
||||||
height: 20mm;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-brand strong {
|
|
||||||
color: #2E3B40;
|
|
||||||
display: block;
|
|
||||||
font-size: 15pt;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 1.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-brand span {
|
|
||||||
color: #6B7C85;
|
|
||||||
display: block;
|
|
||||||
font-size: 9pt;
|
|
||||||
line-height: 1.35;
|
|
||||||
margin-top: 1mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-meta {
|
|
||||||
color: #4F5E63;
|
|
||||||
display: grid;
|
|
||||||
gap: 1.2mm;
|
|
||||||
margin: 0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-meta div,
|
|
||||||
.report-footer {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-meta dt {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-meta dd {
|
|
||||||
font-size: 9pt;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header-meta div:first-child dd {
|
|
||||||
color: #2E3B40;
|
|
||||||
font-size: 10pt;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-footer {
|
|
||||||
align-items: center;
|
|
||||||
border-top: .25mm solid #DCE3E3;
|
|
||||||
color: #6B7C85;
|
|
||||||
font-size: 8pt;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
height: 10mm;
|
|
||||||
padding-top: 2.5mm;
|
|
||||||
position: running(report-footer);
|
|
||||||
width: 174mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-footer span:nth-child(2) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-footer span:nth-child(3) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-number::before {
|
|
||||||
content: counter(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-count::before {
|
|
||||||
content: counter(pages);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-page {
|
|
||||||
page: cover;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
break-after: page;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-content {
|
|
||||||
page: report;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-margin-narrow .cover-page {
|
|
||||||
page: cover-narrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-margin-wide .cover-page {
|
|
||||||
page: cover-wide;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-margin-narrow .report-content {
|
|
||||||
page: report-narrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-margin-wide .report-content {
|
|
||||||
page: report-wide;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-top {
|
|
||||||
align-items: flex-start;
|
|
||||||
display: grid;
|
|
||||||
gap: 14mm;
|
|
||||||
grid-template-columns: 1fr 55mm;
|
|
||||||
margin-bottom: 8mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-logo {
|
|
||||||
height: 16mm;
|
|
||||||
justify-self: end;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-size-small .cover-logo {
|
|
||||||
height: 12mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-size-large .cover-logo {
|
|
||||||
height: 22mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-address {
|
|
||||||
color: #6B7C85;
|
|
||||||
font-size: 8.5pt;
|
|
||||||
line-height: 1.35;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-kicker {
|
|
||||||
color: #6C8A96;
|
|
||||||
font-size: 11pt;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: .08em;
|
|
||||||
margin-bottom: 14mm;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #2E3B40;
|
|
||||||
font-size: 28pt;
|
|
||||||
line-height: 1.05;
|
|
||||||
margin: 0 0 3mm 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-subtitle {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
break-inside: avoid;
|
|
||||||
display: flex;
|
|
||||||
gap: 14mm;
|
|
||||||
margin-top: 9mm;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-line {
|
|
||||||
border-top: 1px solid #6B7C85;
|
|
||||||
height: 0;
|
|
||||||
margin-bottom: 1.5mm;
|
|
||||||
width: 82mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-label {
|
|
||||||
font-size: 8.5pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter {
|
|
||||||
break-before: auto;
|
|
||||||
margin-top: 9mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-chapter-breaks .report-content .chapter {
|
|
||||||
break-before: page;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-chapter-breaks .report-content .chapter:first-of-type {
|
|
||||||
break-before: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-chapter-flow .report-content .chapter {
|
|
||||||
break-before: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-spacing-compact h2 {
|
|
||||||
margin-bottom: 4mm;
|
|
||||||
padding-bottom: 2.5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-spacing-compact h3 {
|
|
||||||
margin: 5mm 0 2mm 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-spacing-compact p {
|
|
||||||
margin-top: 2mm;
|
|
||||||
margin-bottom: 2mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-spacing-compact .chapter,
|
|
||||||
.section-spacing-compact .chapter + .chapter {
|
|
||||||
margin-top: 6mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-spacing-compact .data-table {
|
|
||||||
margin-top: 2.5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-spacing-compact .figure-grid {
|
|
||||||
gap: 5mm;
|
|
||||||
margin-top: 5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
border-bottom: .25mm solid #DCE3E3;
|
|
||||||
color: #2E3B40;
|
|
||||||
font-size: 22pt;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 1.15;
|
|
||||||
margin: 0 0 7mm 0;
|
|
||||||
padding-bottom: 4mm;
|
|
||||||
bookmark-level: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2[data-bookmark-label] {
|
|
||||||
bookmark-label: attr(data-bookmark-label);
|
|
||||||
bookmark-level: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter + .chapter {
|
|
||||||
margin-top: 12mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter > p:only-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
color: #4F6A74;
|
|
||||||
font-size: 12pt;
|
|
||||||
margin: 8mm 0 3mm 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.definition-list {
|
|
||||||
display: block;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.definition-row {
|
|
||||||
border-bottom: 1px solid #E6EAEA;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 42mm 1fr;
|
|
||||||
gap: 6mm;
|
|
||||||
padding: 2.5mm 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: #6B7C85;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 4mm;
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th {
|
|
||||||
background: #F7F8F8;
|
|
||||||
color: #4F6A74;
|
|
||||||
font-size: 8.5pt;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th,
|
|
||||||
.data-table td {
|
|
||||||
border: 1px solid #E6EAEA;
|
|
||||||
padding: 2.4mm;
|
|
||||||
vertical-align: top;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table.compact {
|
|
||||||
font-size: 8.5pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-layout-compact .data-table {
|
|
||||||
margin-top: 2.5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-layout-compact .data-table th,
|
|
||||||
.table-layout-compact .data-table td {
|
|
||||||
padding: 1.5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-font-small .data-table {
|
|
||||||
font-size: 8.4pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-list {
|
|
||||||
counter-reset: toc;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-list li {
|
|
||||||
border-bottom: 1px solid #E6EAEA;
|
|
||||||
padding: 3mm 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-list a {
|
|
||||||
color: #2E3B40;
|
|
||||||
display: grid;
|
|
||||||
gap: 4mm;
|
|
||||||
grid-template-columns: 1fr 14mm;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-level-2 {
|
|
||||||
padding-left: 6mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-level-3,
|
|
||||||
.toc-level-4 {
|
|
||||||
padding-left: 12mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc-list a::after {
|
|
||||||
content: target-counter(attr(href), page);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.figure-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 8mm;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
margin-top: 8mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-position-stacked .figure-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-position-side-by-side .figure-grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-page-max-1 .report-figure {
|
|
||||||
break-after: page;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-page-max-2 .report-figure:nth-child(2n) {
|
|
||||||
break-after: page;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-page-max-4 .report-figure:nth-child(4n) {
|
|
||||||
break-after: page;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-figure {
|
|
||||||
break-inside: avoid;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-image {
|
|
||||||
border: 1px solid #E6EAEA;
|
|
||||||
display: block;
|
|
||||||
max-height: 90mm;
|
|
||||||
object-fit: contain;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-size-small .report-image {
|
|
||||||
max-height: 55mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-size-medium .report-image {
|
|
||||||
max-height: 90mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-size-large .report-image {
|
|
||||||
max-height: 125mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-captions-off .image-caption {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-size-small .report-header-brand img {
|
|
||||||
height: 14mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-size-large .report-header-brand img {
|
|
||||||
height: 24mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-compact html,
|
|
||||||
.layout-compact {
|
|
||||||
line-height: 1.32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-compact h2 {
|
|
||||||
font-size: 18pt;
|
|
||||||
margin-bottom: 4mm;
|
|
||||||
padding-bottom: 2.5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-compact h3 {
|
|
||||||
margin: 5mm 0 2mm 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-compact .chapter,
|
|
||||||
.layout-compact .chapter + .chapter {
|
|
||||||
margin-top: 6mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-compact .definition-row {
|
|
||||||
padding: 1.4mm 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-compact .data-table th,
|
|
||||||
.layout-compact .data-table td {
|
|
||||||
padding: 1.6mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-compact .result-box {
|
|
||||||
margin: 2.5mm 0;
|
|
||||||
padding: 2.4mm 5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
figcaption {
|
|
||||||
color: #6B7C85;
|
|
||||||
font-size: 8.5pt;
|
|
||||||
margin-top: 2mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen {
|
|
||||||
body {
|
|
||||||
background: #F7F8F8;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-document {
|
|
||||||
background: #FFFFFF;
|
|
||||||
box-shadow: 0 14px 40px rgba(46, 59, 64, 0.08);
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 960px;
|
|
||||||
padding: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-header,
|
|
||||||
.report-footer {
|
|
||||||
left: auto;
|
|
||||||
margin: 0 auto 32px auto;
|
|
||||||
position: static;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-page {
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter {
|
|
||||||
break-before: auto;
|
|
||||||
margin-top: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-footer {
|
|
||||||
margin: 48px auto 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.figure-grid {
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.modules.orion.assets import schubamed_logo_uri
|
|
||||||
from app.modules.orion.context import ReportContext
|
|
||||||
from app.modules.orion.html import text
|
|
||||||
|
|
||||||
|
|
||||||
def render_report_chrome(context: ReportContext, logo_uri: str) -> str:
|
|
||||||
settings = context.report_settings
|
|
||||||
report_number = text(context.validation.report_number)
|
|
||||||
version = text(context.validation.version)
|
|
||||||
report_date = text(context.validation.updated_at)
|
|
||||||
header = ""
|
|
||||||
footer = ""
|
|
||||||
if settings.show_header:
|
|
||||||
header = f"""
|
|
||||||
<header class="report-header" aria-label="Berichtskopf">
|
|
||||||
<div class="report-header-brand">
|
|
||||||
<img src="{logo_uri}" alt="SCHUBAMED">
|
|
||||||
<div>
|
|
||||||
<strong>SCHUBAMED®</strong>
|
|
||||||
<span>Aufbereitung mit System</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<dl class="report-header-meta">
|
|
||||||
<div><dt>Berichtsnummer</dt><dd>{report_number}</dd></div>
|
|
||||||
<div><dt>Version</dt><dd>{version}</dd></div>
|
|
||||||
<div><dt>Datum</dt><dd>{report_date}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</header>
|
|
||||||
"""
|
|
||||||
if settings.show_footer:
|
|
||||||
footer = f"""
|
|
||||||
<footer class="report-footer" aria-label="Berichtsfuß">
|
|
||||||
<span>Validation Suite</span>
|
|
||||||
<span>Seite <span class="page-number"></span> von <span class="page-count"></span></span>
|
|
||||||
<span>Version {version}</span>
|
|
||||||
</footer>
|
|
||||||
"""
|
|
||||||
return header + footer
|
|
||||||
|
|
||||||
|
|
||||||
def render_document(context: ReportContext, chapters: list[str]) -> str:
|
|
||||||
css = (Path(__file__).resolve().parent / "report.css").read_text(encoding="utf-8")
|
|
||||||
logo_uri = schubamed_logo_uri()
|
|
||||||
title = f"Validierungsbericht {context.validation.report_number}"
|
|
||||||
cover_markup = chapters[0] if chapters else ""
|
|
||||||
content_markup = "\n".join(chapters[1:])
|
|
||||||
settings = context.report_settings
|
|
||||||
body_classes = " ".join(
|
|
||||||
[
|
|
||||||
"orion-report",
|
|
||||||
f"layout-{settings.layout_profile.lower()}",
|
|
||||||
f"page-margin-{settings.page_margin.lower()}",
|
|
||||||
f"section-spacing-{settings.section_spacing.lower()}",
|
|
||||||
f"table-layout-{settings.table_layout.lower()}",
|
|
||||||
f"table-font-{settings.table_font_size.lower()}",
|
|
||||||
f"image-size-{settings.image_size.lower()}",
|
|
||||||
f"image-position-{settings.image_position.lower().replace('_', '-')}",
|
|
||||||
f"image-page-max-{settings.max_images_per_page}",
|
|
||||||
f"logo-size-{settings.logo_size.lower()}",
|
|
||||||
"image-captions-on" if settings.show_image_captions else "image-captions-off",
|
|
||||||
"cover-compact" if settings.compact_cover else "cover-standard",
|
|
||||||
"main-chapter-breaks" if settings.page_break_before_main_chapters else "main-chapter-flow",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return f"""<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{text(title)}</title>
|
|
||||||
<style>{css}</style>
|
|
||||||
</head>
|
|
||||||
<body class="{body_classes}">
|
|
||||||
<article class="report-document">
|
|
||||||
{cover_markup}
|
|
||||||
<section class="report-content">
|
|
||||||
<div class="report-meta" data-report-number="{text(context.validation.report_number)}" data-report-version="{text(context.validation.version)}" data-report-date="{text(context.validation.updated_at)}"></div>
|
|
||||||
{render_report_chrome(context, logo_uri)}
|
|
||||||
{content_markup}
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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]):
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,17 @@
|
||||||
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 pydantic import ConfigDict, EmailStr, Field, field_validator
|
from pydantic import EmailStr, Field
|
||||||
|
|
||||||
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 +88,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
|
||||||
|
|
@ -150,12 +108,12 @@ class EquipmentUpdate(EquipmentCreate):
|
||||||
|
|
||||||
|
|
||||||
class ValidationCreate(ORMModel):
|
class ValidationCreate(ORMModel):
|
||||||
report_number: str | None = None
|
report_number: str
|
||||||
customer_id: UUID | None = None
|
customer_id: str
|
||||||
location_id: UUID | None = None
|
location_id: str | None = None
|
||||||
contact_id: UUID | None = None
|
contact_id: str | None = None
|
||||||
device_id: UUID | None = None
|
device_id: str | None = None
|
||||||
validation_type: str | None = None
|
validation_type: str
|
||||||
project: str | None = None
|
project: str | None = None
|
||||||
test_location: str | None = None
|
test_location: str | None = None
|
||||||
examiner_name: str | None = None
|
examiner_name: str | None = None
|
||||||
|
|
@ -164,12 +122,8 @@ class ValidationCreate(ORMModel):
|
||||||
scheduled_on: date | None = None
|
scheduled_on: date | None = None
|
||||||
performed_on: date | None = None
|
performed_on: date | None = None
|
||||||
next_validation_on: date | None = None
|
next_validation_on: date | None = None
|
||||||
revalidation_interval_months: int = 24
|
examiner_id: str | None = None
|
||||||
next_validation_manually_overridden: bool = False
|
status: ValidationStatus = ValidationStatus.draft
|
||||||
version: int = 1
|
|
||||||
previous_validation_id: UUID | None = None
|
|
||||||
examiner_id: UUID | None = None
|
|
||||||
status: ValidationStatus | str = ValidationStatus.draft
|
|
||||||
result: str | None = None
|
result: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
equipment_ids: list[str] = Field(default_factory=list)
|
equipment_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
@ -183,21 +137,6 @@ class ValidationCreate(ORMModel):
|
||||||
recommendations: list[dict] = Field(default_factory=list)
|
recommendations: list[dict] = Field(default_factory=list)
|
||||||
attachments: list[dict] = Field(default_factory=list)
|
attachments: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
@field_validator(
|
|
||||||
"customer_id",
|
|
||||||
"location_id",
|
|
||||||
"contact_id",
|
|
||||||
"device_id",
|
|
||||||
"examiner_id",
|
|
||||||
"previous_validation_id",
|
|
||||||
mode="before",
|
|
||||||
)
|
|
||||||
@classmethod
|
|
||||||
def empty_string_to_none(cls, value):
|
|
||||||
if value == "":
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationRead(ValidationCreate, EntityRead):
|
class ValidationRead(ValidationCreate, EntityRead):
|
||||||
pass
|
pass
|
||||||
|
|
@ -205,141 +144,3 @@ class ValidationRead(ValidationCreate, EntityRead):
|
||||||
|
|
||||||
class ValidationUpdate(ValidationCreate):
|
class ValidationUpdate(ValidationCreate):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ValidationIssue(ORMModel):
|
|
||||||
field: str
|
|
||||||
message: str
|
|
||||||
section: str
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationReview(ORMModel):
|
|
||||||
status: str
|
|
||||||
errors: list[ValidationIssue]
|
|
||||||
warnings: list[ValidationIssue]
|
|
||||||
complete_sections: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationImportPreviewRow(ORMModel):
|
|
||||||
row_number: int
|
|
||||||
data: dict
|
|
||||||
errors: list[str]
|
|
||||||
duplicate: bool
|
|
||||||
resolved_customer_id: str | None = None
|
|
||||||
resolved_location_id: str | None = None
|
|
||||||
resolved_device_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationImportPreview(ORMModel):
|
|
||||||
rows: list[ValidationImportPreviewRow]
|
|
||||||
valid_rows: int
|
|
||||||
invalid_rows: int
|
|
||||||
duplicates: int
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationImportRequest(ORMModel):
|
|
||||||
rows: list[dict]
|
|
||||||
duplicate_strategy: str = "skip"
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationImportSummary(ORMModel):
|
|
||||||
successful: int
|
|
||||||
skipped: int
|
|
||||||
failed: int
|
|
||||||
errors: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementImportValueRead(ORMModel):
|
|
||||||
id: str
|
|
||||||
test_run: str
|
|
||||||
field_name: str
|
|
||||||
raw_value: str | None = None
|
|
||||||
normalized_value: str | None = None
|
|
||||||
unit: str | None = None
|
|
||||||
source_page: int | None = None
|
|
||||||
source_text: str | None = None
|
|
||||||
confidence: int
|
|
||||||
confirmed: bool
|
|
||||||
corrected_value: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementImportPreviewRead(ORMModel):
|
|
||||||
id: str
|
|
||||||
validation_id: str
|
|
||||||
import_type: str
|
|
||||||
original_filename: str
|
|
||||||
sha256: str
|
|
||||||
parser_version: str
|
|
||||||
status: str
|
|
||||||
values: list[MeasurementImportValueRead]
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementImportValueConfirm(ORMModel):
|
|
||||||
id: str
|
|
||||||
confirmed: bool = False
|
|
||||||
corrected_value: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MeasurementImportConfirmRequest(ORMModel):
|
|
||||||
values: list[MeasurementImportValueConfirm]
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -12,7 +11,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 +19,6 @@ from app.repositories.domain import (
|
||||||
DeviceRepository,
|
DeviceRepository,
|
||||||
EquipmentRepository,
|
EquipmentRepository,
|
||||||
LocationRepository,
|
LocationRepository,
|
||||||
UserRepository,
|
|
||||||
ValidationRepository,
|
ValidationRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -36,43 +33,24 @@ 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:
|
||||||
data = self._normalize(data)
|
|
||||||
return self.repository.add(self.repository.model(**data))
|
return self.repository.add(self.repository.model(**data))
|
||||||
|
|
||||||
def update(self, item_id: str, data: dict) -> ModelT:
|
def update(self, item_id: str, data: dict) -> ModelT:
|
||||||
data = self._normalize(data)
|
|
||||||
item = self.repository.get(item_id)
|
item = self.repository.get(item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
|
||||||
if isinstance(item, Validation) and item.status in {"FREIGEGEBEN", "ABGESCHLOSSEN"}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Freigegebene oder abgeschlossene Validierungen sind schreibgeschuetzt")
|
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
setattr(item, key, value)
|
setattr(item, key, value)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
def _normalize(self, data: dict) -> dict:
|
|
||||||
normalized = {}
|
|
||||||
for key, value in data.items():
|
|
||||||
if key.endswith("_id") and value == "":
|
|
||||||
normalized[key] = None
|
|
||||||
elif isinstance(value, UUID):
|
|
||||||
normalized[key] = str(value)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
normalized[key] = [str(item) if isinstance(item, UUID) else item for item in value]
|
|
||||||
else:
|
|
||||||
normalized[key] = value
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
def delete(self, item_id: str) -> None:
|
def delete(self, item_id: str) -> None:
|
||||||
item = self.repository.get(item_id)
|
item = self.repository.get(item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
|
|
@ -82,7 +60,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))
|
||||||
|
|
|
||||||
|
|
@ -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": []},
|
|
||||||
]
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,364 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import date, datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
from sqlalchemy import and_, func, or_, 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
|
|
||||||
from app.models.location import Location
|
|
||||||
from app.models.validation import Validation, ValidationStatus
|
|
||||||
|
|
||||||
REQUIRED_FIELDS = {
|
|
||||||
"report_number": ("Allgemeine Angaben", "Berichtsnummer"),
|
|
||||||
"validation_type": ("Allgemeine Angaben", "Validierungsart"),
|
|
||||||
"performed_on": ("Allgemeine Angaben", "Pruefdatum"),
|
|
||||||
"customer_id": ("Kunde und Standort", "Kunde"),
|
|
||||||
"location_id": ("Kunde und Standort", "Standort"),
|
|
||||||
"device_id": ("Geraet", "Geraet"),
|
|
||||||
"examiner_name": ("Allgemeine Angaben", "Pruefer"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ReviewIssue:
|
|
||||||
field: str
|
|
||||||
message: str
|
|
||||||
section: str
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, str]:
|
|
||||||
return {"field": self.field, "message": self.message, "section": self.section}
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationWorkflowService:
|
|
||||||
def __init__(self, session: Session) -> None:
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
def review(self, validation: Validation) -> dict:
|
|
||||||
errors = self._required_errors(validation) + self._reference_errors(validation)
|
|
||||||
warnings = self._warnings(validation)
|
|
||||||
complete_sections = self._complete_sections(validation, errors, warnings)
|
|
||||||
return {
|
|
||||||
"status": validation.status,
|
|
||||||
"errors": [issue.as_dict() for issue in errors],
|
|
||||||
"warnings": [issue.as_dict() for issue in warnings],
|
|
||||||
"complete_sections": complete_sections,
|
|
||||||
}
|
|
||||||
|
|
||||||
def mark_ready_for_review(self, validation: Validation) -> dict:
|
|
||||||
review = self.review(validation)
|
|
||||||
validation.status = (
|
|
||||||
ValidationStatus.ready_for_review.value
|
|
||||||
if not review["errors"]
|
|
||||||
else ValidationStatus.draft.value
|
|
||||||
)
|
|
||||||
self.session.flush()
|
|
||||||
review["status"] = validation.status
|
|
||||||
return review
|
|
||||||
|
|
||||||
def apply_revalidation_date(self, validation: Validation) -> None:
|
|
||||||
if validation.performed_on and not validation.next_validation_manually_overridden:
|
|
||||||
validation.next_validation_on = validation.performed_on + relativedelta(
|
|
||||||
months=validation.revalidation_interval_months or 24
|
|
||||||
)
|
|
||||||
|
|
||||||
def duplicate(self, validation: Validation) -> Validation:
|
|
||||||
clone = Validation(
|
|
||||||
report_number=f"{validation.report_number}-KOPIE-{str(uuid4())[:8]}",
|
|
||||||
customer_id=validation.customer_id,
|
|
||||||
location_id=validation.location_id,
|
|
||||||
contact_id=validation.contact_id,
|
|
||||||
device_id=validation.device_id,
|
|
||||||
validation_type=validation.validation_type,
|
|
||||||
project=validation.project,
|
|
||||||
test_location=validation.test_location,
|
|
||||||
examiner_name=validation.examiner_name,
|
|
||||||
participants=validation.participants,
|
|
||||||
operator_name=validation.operator_name,
|
|
||||||
scheduled_on=validation.scheduled_on,
|
|
||||||
performed_on=validation.performed_on,
|
|
||||||
next_validation_on=validation.next_validation_on,
|
|
||||||
revalidation_interval_months=validation.revalidation_interval_months,
|
|
||||||
next_validation_manually_overridden=validation.next_validation_manually_overridden,
|
|
||||||
version=validation.version + 1,
|
|
||||||
previous_validation_id=validation.id,
|
|
||||||
examiner_id=validation.examiner_id,
|
|
||||||
status=ValidationStatus.draft.value,
|
|
||||||
result=validation.result,
|
|
||||||
notes=validation.notes,
|
|
||||||
equipment_ids=validation.equipment_ids,
|
|
||||||
environment_conditions=validation.environment_conditions,
|
|
||||||
documentation_checklist=validation.documentation_checklist,
|
|
||||||
performance_checklist=validation.performance_checklist,
|
|
||||||
programs=validation.programs,
|
|
||||||
loading_patterns=validation.loading_patterns,
|
|
||||||
measurement_data=validation.measurement_data,
|
|
||||||
drying=validation.drying,
|
|
||||||
recommendations=validation.recommendations,
|
|
||||||
attachments=validation.attachments,
|
|
||||||
)
|
|
||||||
self.session.add(clone)
|
|
||||||
self.session.flush()
|
|
||||||
return clone
|
|
||||||
|
|
||||||
def create_new_version(self, validation: Validation) -> Validation:
|
|
||||||
clone = self.duplicate(validation)
|
|
||||||
clone.report_number = f"{validation.report_number}-V{clone.version}"
|
|
||||||
return clone
|
|
||||||
|
|
||||||
def export_json(self, validation: Validation) -> dict:
|
|
||||||
return {
|
|
||||||
column.name: getattr(validation, column.name)
|
|
||||||
for column in Validation.__table__.columns
|
|
||||||
if column.name not in {"created_at", "updated_at"}
|
|
||||||
}
|
|
||||||
|
|
||||||
def preview_csv(self, content: bytes) -> dict:
|
|
||||||
rows = []
|
|
||||||
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
|
|
||||||
for index, row in enumerate(reader, start=2):
|
|
||||||
rows.append(self._preview_import_row(index, row))
|
|
||||||
return {
|
|
||||||
"rows": rows,
|
|
||||||
"valid_rows": sum(1 for row in rows if not row["errors"]),
|
|
||||||
"invalid_rows": sum(1 for row in rows if row["errors"]),
|
|
||||||
"duplicates": sum(1 for row in rows if row["duplicate"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
def import_rows(self, rows: list[dict], duplicate_strategy: str) -> dict:
|
|
||||||
summary = {"successful": 0, "skipped": 0, "failed": 0, "errors": []}
|
|
||||||
for index, row in enumerate(rows, start=1):
|
|
||||||
preview = self._preview_import_row(index, row)
|
|
||||||
if preview["errors"]:
|
|
||||||
summary["failed"] += 1
|
|
||||||
summary["errors"].append(f"Zeile {index}: {', '.join(preview['errors'])}")
|
|
||||||
continue
|
|
||||||
existing = self.session.scalar(
|
|
||||||
select(Validation).where(Validation.report_number == row.get("report_number"))
|
|
||||||
)
|
|
||||||
if existing and duplicate_strategy == "skip":
|
|
||||||
summary["skipped"] += 1
|
|
||||||
continue
|
|
||||||
target = existing if existing and duplicate_strategy == "update" else Validation()
|
|
||||||
target.report_number = (
|
|
||||||
f"{row.get('report_number')}-IMPORT-{str(uuid4())[:8]}"
|
|
||||||
if existing and duplicate_strategy == "copy"
|
|
||||||
else row.get("report_number")
|
|
||||||
)
|
|
||||||
target.validation_type = row.get("validation_type")
|
|
||||||
target.performed_on = self._parse_date(row.get("test_date") or row.get("performed_on"))
|
|
||||||
target.customer_id = preview["resolved_customer_id"]
|
|
||||||
target.location_id = preview["resolved_location_id"]
|
|
||||||
target.contact_id = row.get("contact_id")
|
|
||||||
target.device_id = preview["resolved_device_id"]
|
|
||||||
target.examiner_name = row.get("examiner") or row.get("examiner_name")
|
|
||||||
target.project = row.get("project")
|
|
||||||
target.test_location = row.get("test_location")
|
|
||||||
target.participants = row.get("participants")
|
|
||||||
target.operator_name = row.get("operator_name")
|
|
||||||
target.next_validation_on = self._parse_date(row.get("next_validation_on"))
|
|
||||||
target.equipment_ids = row.get("equipment_ids") or []
|
|
||||||
target.environment_conditions = row.get("environment_conditions") or {}
|
|
||||||
target.documentation_checklist = row.get("documentation_checklist") or []
|
|
||||||
target.performance_checklist = row.get("performance_checklist") or []
|
|
||||||
target.programs = row.get("programs") or []
|
|
||||||
target.loading_patterns = row.get("loading_patterns") or []
|
|
||||||
target.measurement_data = row.get("measurement_data") or []
|
|
||||||
target.drying = row.get("drying") or {}
|
|
||||||
target.recommendations = row.get("recommendations") or []
|
|
||||||
target.attachments = row.get("attachments") or []
|
|
||||||
target.status = row.get("status") or ValidationStatus.draft.value
|
|
||||||
target.result = row.get("result")
|
|
||||||
target.notes = row.get("notes")
|
|
||||||
if target.id is None:
|
|
||||||
self.session.add(target)
|
|
||||||
summary["successful"] += 1
|
|
||||||
self.session.flush()
|
|
||||||
return summary
|
|
||||||
|
|
||||||
def query_validations(
|
|
||||||
self,
|
|
||||||
search: str | None,
|
|
||||||
page: int,
|
|
||||||
page_size: int,
|
|
||||||
sort_by: str,
|
|
||||||
sort_order: str,
|
|
||||||
filters: dict,
|
|
||||||
) -> dict:
|
|
||||||
statement = select(Validation)
|
|
||||||
count_statement = select(Validation)
|
|
||||||
conditions = []
|
|
||||||
if search:
|
|
||||||
term = f"%{search}%"
|
|
||||||
conditions.append(
|
|
||||||
or_(
|
|
||||||
Validation.report_number.ilike(term),
|
|
||||||
Validation.validation_type.ilike(term),
|
|
||||||
Validation.examiner_name.ilike(term),
|
|
||||||
Validation.result.ilike(term),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for key in ["status", "customer_id", "device_id", "validation_type"]:
|
|
||||||
if filters.get(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"):
|
|
||||||
conditions.append(Validation.performed_on >= filters["date_from"])
|
|
||||||
if filters.get("date_to"):
|
|
||||||
conditions.append(Validation.performed_on <= filters["date_to"])
|
|
||||||
if filters.get("overdue_only"):
|
|
||||||
conditions.append(Validation.next_validation_on < date.today())
|
|
||||||
conditions.append(Validation.status != ValidationStatus.cancelled.value)
|
|
||||||
if conditions:
|
|
||||||
statement = statement.where(and_(*conditions))
|
|
||||||
count_statement = count_statement.where(and_(*conditions))
|
|
||||||
sort_column = getattr(Validation, sort_by, Validation.updated_at)
|
|
||||||
if sort_order == "asc":
|
|
||||||
statement = statement.order_by(sort_column.asc())
|
|
||||||
else:
|
|
||||||
statement = statement.order_by(sort_column.desc())
|
|
||||||
total = self.session.scalar(
|
|
||||||
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)))
|
|
||||||
return {
|
|
||||||
"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:
|
|
||||||
if not validation.next_validation_on:
|
|
||||||
return "nicht_erfasst"
|
|
||||||
delta = (validation.next_validation_on - date.today()).days
|
|
||||||
if delta < 0:
|
|
||||||
return "ueberfaellig"
|
|
||||||
if delta <= 30:
|
|
||||||
return "faellig_30"
|
|
||||||
if delta <= 90:
|
|
||||||
return "faellig_90"
|
|
||||||
return "faellig_spaeter"
|
|
||||||
|
|
||||||
def _required_errors(self, validation: Validation) -> list[ReviewIssue]:
|
|
||||||
issues = []
|
|
||||||
for field, (section, label) in REQUIRED_FIELDS.items():
|
|
||||||
if not getattr(validation, field):
|
|
||||||
issues.append(ReviewIssue(field, f"{label} fehlt.", section))
|
|
||||||
return issues
|
|
||||||
|
|
||||||
def _reference_errors(self, validation: Validation) -> list[ReviewIssue]:
|
|
||||||
issues = []
|
|
||||||
customer = self.session.get(Customer, validation.customer_id) if validation.customer_id else None
|
|
||||||
location = self.session.get(Location, validation.location_id) if validation.location_id else None
|
|
||||||
device = self.session.get(Device, validation.device_id) if validation.device_id else None
|
|
||||||
if validation.customer_id and customer is None:
|
|
||||||
issues.append(ReviewIssue("customer_id", "Kunde existiert nicht.", "Kunde und Standort"))
|
|
||||||
if validation.location_id and location is None:
|
|
||||||
issues.append(ReviewIssue("location_id", "Standort existiert nicht.", "Kunde und Standort"))
|
|
||||||
if validation.device_id and device is None:
|
|
||||||
issues.append(ReviewIssue("device_id", "Geraet existiert nicht.", "Geraet"))
|
|
||||||
if location and device and device.location_id != location.id:
|
|
||||||
issues.append(ReviewIssue("device_id", "Geraet gehoert nicht zum gewaehlten Standort.", "Geraet"))
|
|
||||||
return issues
|
|
||||||
|
|
||||||
def _warnings(self, validation: Validation) -> list[ReviewIssue]:
|
|
||||||
warnings = []
|
|
||||||
equipment = []
|
|
||||||
if validation.equipment_ids:
|
|
||||||
equipment = list(self.session.scalars(select(Equipment).where(Equipment.id.in_(validation.equipment_ids))))
|
|
||||||
if not validation.equipment_ids:
|
|
||||||
warnings.append(ReviewIssue("equipment_ids", "Keine Pruefmittel ausgewaehlt.", "Pruefmittel"))
|
|
||||||
for item in equipment:
|
|
||||||
if item.calibration_due_on and item.calibration_due_on < date.today():
|
|
||||||
warnings.append(ReviewIssue("equipment_ids", f"Pruefmittel {item.serial_number} ist abgelaufen.", "Pruefmittel"))
|
|
||||||
if not any(item.get("selected") for item in validation.programs or []):
|
|
||||||
warnings.append(ReviewIssue("programs", "Keine Programme ausgewaehlt.", "Programme"))
|
|
||||||
if not validation.measurement_data:
|
|
||||||
warnings.append(ReviewIssue("measurement_data", "Keine Messdaten vorhanden.", "Messdaten"))
|
|
||||||
if not validation.attachments:
|
|
||||||
warnings.append(ReviewIssue("attachments", "Keine Bilder oder Anlagen vorhanden.", "Bilder und Anlagen"))
|
|
||||||
if not validation.recommendations:
|
|
||||||
warnings.append(ReviewIssue("recommendations", "Keine Empfehlungen oder Auflagen erfasst.", "Empfehlungen"))
|
|
||||||
winlog_imported = any(item.get("imports") for item in validation.measurement_data or [])
|
|
||||||
if not winlog_imported:
|
|
||||||
warnings.append(ReviewIssue("measurement_data", "Winlog-Datei noch nicht importiert.", "Messdaten"))
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
def _complete_sections(
|
|
||||||
self, validation: Validation, errors: list[ReviewIssue], warnings: list[ReviewIssue]
|
|
||||||
) -> list[str]:
|
|
||||||
blocked = {issue.section for issue in [*errors, *warnings]}
|
|
||||||
sections = [
|
|
||||||
"Allgemeine Angaben",
|
|
||||||
"Kunde und Standort",
|
|
||||||
"Geraet",
|
|
||||||
"Pruefmittel",
|
|
||||||
"Programme",
|
|
||||||
"Messdaten",
|
|
||||||
"Bilder und Anlagen",
|
|
||||||
"Empfehlungen",
|
|
||||||
]
|
|
||||||
return [section for section in sections if section not in blocked]
|
|
||||||
|
|
||||||
def _preview_import_row(self, row_number: int, row: dict) -> dict:
|
|
||||||
errors = []
|
|
||||||
customer = self.session.get(Customer, row.get("customer_id")) if row.get("customer_id") else self.session.scalar(select(Customer).where(Customer.name == row.get("customer_reference")))
|
|
||||||
location = self.session.get(Location, row.get("location_id")) if row.get("location_id") else self.session.scalar(select(Location).where(Location.name == row.get("location_reference")))
|
|
||||||
device = self.session.get(Device, row.get("device_id")) if row.get("device_id") else self.session.scalar(select(Device).where(Device.serial_number == row.get("device_serial_number")))
|
|
||||||
duplicate = bool(
|
|
||||||
row.get("report_number")
|
|
||||||
and self.session.scalar(select(Validation).where(Validation.report_number == row.get("report_number")))
|
|
||||||
)
|
|
||||||
required = [
|
|
||||||
("report_number", row.get("report_number")),
|
|
||||||
("validation_type", row.get("validation_type")),
|
|
||||||
("test_date", row.get("test_date") or row.get("performed_on")),
|
|
||||||
("customer_reference", row.get("customer_reference") or row.get("customer_id")),
|
|
||||||
("location_reference", row.get("location_reference") or row.get("location_id")),
|
|
||||||
("device_serial_number", row.get("device_serial_number") or row.get("device_id")),
|
|
||||||
("examiner", row.get("examiner") or row.get("examiner_name")),
|
|
||||||
]
|
|
||||||
for field, value in required:
|
|
||||||
if not value:
|
|
||||||
errors.append(f"{field} fehlt")
|
|
||||||
if row.get("customer_reference") and not customer:
|
|
||||||
errors.append("Kunde nicht gefunden")
|
|
||||||
if row.get("location_reference") and not location:
|
|
||||||
errors.append("Standort nicht gefunden")
|
|
||||||
if row.get("device_serial_number") and not device:
|
|
||||||
errors.append("Geraet nicht gefunden")
|
|
||||||
return {
|
|
||||||
"row_number": row_number,
|
|
||||||
"data": row,
|
|
||||||
"errors": errors,
|
|
||||||
"duplicate": duplicate,
|
|
||||||
"resolved_customer_id": customer.id if customer else None,
|
|
||||||
"resolved_location_id": location.id if location else None,
|
|
||||||
"resolved_device_id": device.id if device else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _parse_date(self, value: str | None) -> date | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue