feat(repairs): add repair documents

This commit is contained in:
Schubert Ferenc 2026-07-05 00:34:57 +02:00
parent 884a20e043
commit 6e7e75f864
21 changed files with 1222 additions and 6 deletions

View file

@ -0,0 +1,58 @@
"""extend repair documents
Revision ID: a1c5f9e2d430
Revises: f2b8d4e6a910
Create Date: 2026-07-05 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "a1c5f9e2d430"
down_revision: Union[str, Sequence[str], None] = "f2b8d4e6a910"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("repair_documents", sa.Column("original_filename", sa.String(length=255), server_default="", nullable=False))
op.add_column("repair_documents", sa.Column("stored_filename", sa.String(length=255), server_default="", nullable=False))
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), server_default="", nullable=False)
op.add_column("repair_documents", sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False))
op.add_column("repair_documents", sa.Column("size_bytes", sa.BigInteger(), server_default="0", nullable=False))
op.add_column("repair_documents", sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False))
op.add_column("repair_documents", sa.Column("visibility", sa.String(length=40), server_default="internal", nullable=False))
op.add_column("repair_documents", sa.Column("note", sa.Text(), nullable=True))
op.add_column("repair_documents", sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True))
op.add_column("repair_documents", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
op.create_foreign_key(
op.f("fk_repair_documents_uploaded_by_user_id_users"),
"repair_documents",
"users",
["uploaded_by_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(op.f("ix_repair_documents_checksum_sha256"), "repair_documents", ["checksum_sha256"], unique=False)
op.create_index(op.f("ix_repair_documents_uploaded_by_user_id"), "repair_documents", ["uploaded_by_user_id"], unique=False)
op.create_index(op.f("ix_repair_documents_visibility"), "repair_documents", ["visibility"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_repair_documents_visibility"), table_name="repair_documents")
op.drop_index(op.f("ix_repair_documents_uploaded_by_user_id"), table_name="repair_documents")
op.drop_index(op.f("ix_repair_documents_checksum_sha256"), table_name="repair_documents")
op.drop_constraint(op.f("fk_repair_documents_uploaded_by_user_id_users"), "repair_documents", type_="foreignkey")
op.drop_column("repair_documents", "updated_at")
op.drop_column("repair_documents", "uploaded_by_user_id")
op.drop_column("repair_documents", "note")
op.drop_column("repair_documents", "visibility")
op.drop_column("repair_documents", "checksum_sha256")
op.drop_column("repair_documents", "size_bytes")
op.drop_column("repair_documents", "mime_type")
op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), nullable=True, server_default=None)
op.drop_column("repair_documents", "stored_filename")
op.drop_column("repair_documents", "original_filename")

View file

@ -70,6 +70,7 @@ def get_dashboard_summary(
MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)),
MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)),
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)),
]
if "system_settings.manage" in permissions:

View file

@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from starlette.requests import Request
from typing import cast
@ -11,6 +12,10 @@ from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import (
RepairCreate,
RepairDocumentResponse,
RepairDocumentType,
RepairDocumentUpdate,
RepairDocumentVisibility,
RepairIntakePayload,
RepairIntakeResponse,
RepairNotificationEventResponse,
@ -26,6 +31,7 @@ from app.schemas.repair import (
RepairUpdate,
)
from app.services.repair_notification_service import RepairNotificationService
from app.services.repair_document_service import RepairDocumentService
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.repair_service import RepairService
@ -130,6 +136,117 @@ def get_repair_history(
return RepairRepository.get_history(db, repair_id)
@router.get("/repairs/{repair_id}/documents", response_model=list[RepairDocumentResponse])
def list_repair_documents(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
return RepairRepository.list_documents(db, repair_id)
@router.post("/repairs/{repair_id}/documents/upload", response_model=RepairDocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_repair_document(
repair_id: int,
request: Request,
file: UploadFile = File(...),
title: str = Form(..., min_length=1, max_length=255),
document_type: RepairDocumentType = Form("other"),
visibility: RepairDocumentVisibility = Form("internal"),
note: str | None = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
normalized_title = title.strip()
normalized_note = note.strip() if note else None
if not normalized_title:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Titel ist erforderlich")
return await RepairDocumentService.upload_document(
db,
db_repair,
file=file,
title=normalized_title,
document_type=document_type,
visibility=visibility,
note=normalized_note,
actor=current_user,
request=request,
)
@router.get("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
def get_repair_document(
repair_id: int,
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return document
@router.put("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse)
def update_repair_document(
repair_id: int,
document_id: int,
payload: RepairDocumentUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return RepairDocumentService.update_document(db, db_repair, document, payload, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/documents/{document_id}/download")
def download_repair_document(
repair_id: int,
document_id: int,
disposition: str = Query(default="inline", pattern="^(inline|attachment)$"),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
file_path = RepairDocumentService.open_document_file(document)
return FileResponse(
file_path,
media_type=document.mime_type or "application/octet-stream",
headers={
"Content-Disposition": RepairDocumentService.content_disposition(
document,
mode="attachment" if disposition == "attachment" else "inline",
),
},
)
@router.delete("/repairs/{repair_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_repair_document(
repair_id: int,
document_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
RepairDocumentService.delete_document(db, db_repair, document, actor=current_user, request=request)
return None
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
def get_public_link(
repair_id: int,

View file

@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
@ -41,6 +41,7 @@ class Repair(Base):
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
documents: Mapped[list["RepairDocument"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
class RepairStatusHistory(Base):
@ -89,10 +90,33 @@ class RepairDocument(Base):
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
title: Mapped[str] = mapped_column(String(255))
document_type: Mapped[str] = mapped_column(String(80), index=True)
original_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
stored_filename: Mapped[str] = mapped_column(String(255), default="", server_default="")
storage_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
visibility: Mapped[str] = mapped_column(String(40), default="internal", server_default="internal", index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
repair: Mapped[Repair] = relationship(back_populates="documents")
uploaded_by = relationship("User", lazy="joined")
@property
def uploaded_by_username(self) -> str:
return self.uploaded_by.username if self.uploaded_by is not None else ""
@property
def uploaded_by_display_name(self) -> str:
if self.uploaded_by is None:
return ""
display_name = f"{self.uploaded_by.first_name} {self.uploaded_by.last_name}".strip()
return display_name or self.uploaded_by.username
class RepairPublicAccessToken(Base):

View file

@ -3,8 +3,8 @@ from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate
class RepairRepository:
@ -362,6 +362,77 @@ class RepairRepository:
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
) or 0
@staticmethod
def list_documents(db: Session, repair_id: int) -> list[RepairDocument]:
return list(
db.scalars(
select(RepairDocument)
.where(RepairDocument.repair_id == repair_id)
.order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc())
)
)
@staticmethod
def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None:
return db.scalar(
select(RepairDocument)
.where(RepairDocument.repair_id == repair_id)
.where(RepairDocument.id == document_id)
)
@staticmethod
def create_document(
db: Session,
*,
repair_id: int,
title: str,
document_type: str,
original_filename: str,
stored_filename: str,
storage_path: str,
mime_type: str,
size_bytes: int,
checksum_sha256: str,
visibility: str,
note: str | None,
uploaded_by_user_id: int | None,
) -> RepairDocument:
document = RepairDocument(
repair_id=repair_id,
title=title,
document_type=document_type,
original_filename=original_filename,
stored_filename=stored_filename,
storage_path=storage_path,
mime_type=mime_type,
size_bytes=size_bytes,
checksum_sha256=checksum_sha256,
visibility=visibility,
note=note,
uploaded_by_user_id=uploaded_by_user_id,
)
db.add(document)
db.commit()
db.refresh(document)
return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document
@staticmethod
def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument:
for key, value in payload.model_dump().items():
setattr(document, key, value)
db.commit()
db.refresh(document)
return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document
@staticmethod
def delete_document(db: Session, document: RepairDocument) -> None:
db.delete(document)
db.commit()
@staticmethod
def count_documents(db: Session) -> int:
return db.scalar(select(func.count(RepairDocument.id))) or 0
@staticmethod
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
data = payload.model_dump()

View file

@ -19,6 +19,16 @@ RepairStatus = Literal[
]
RepairPriority = Literal["low", "normal", "high", "urgent"]
RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"]
RepairDocumentType = Literal[
"device_photo",
"fault_photo",
"measurement",
"estimate",
"repair_report",
"shipping",
"other",
]
RepairDocumentVisibility = Literal["internal", "customer"]
def normalize_text(value: object) -> str:
@ -222,3 +232,39 @@ class RepairNotificationEventResponse(BaseModel):
class RepairNotificationOverviewResponse(BaseModel):
templates: list[RepairNotificationTemplateResponse]
events: list[RepairNotificationEventResponse]
class RepairDocumentBase(BaseModel):
title: str = Field(min_length=1, max_length=255)
document_type: RepairDocumentType = "other"
visibility: RepairDocumentVisibility = "internal"
note: str | None = None
@field_validator("title", "note", mode="before")
@classmethod
def normalize_document_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class RepairDocumentUpdate(RepairDocumentBase):
pass
class RepairDocumentResponse(RepairDocumentBase):
id: int
repair_id: int
original_filename: str
stored_filename: str
storage_path: str
mime_type: str
size_bytes: int
checksum_sha256: str
uploaded_by_user_id: int | None
uploaded_by_username: str = ""
uploaded_by_display_name: str = ""
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View file

@ -169,6 +169,9 @@ def action_title(action: str) -> str:
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
"repairs.status_mail.sent": "Statusmail versendet",
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
"repairs.documents.upload": "Reparaturdokument hochgeladen",
"repairs.documents.update": "Reparaturdokument geändert",
"repairs.documents.delete": "Reparaturdokument gelöscht",
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",

View file

@ -0,0 +1,225 @@
import mimetypes
from pathlib import Path
from typing import Literal
from urllib.parse import quote
from fastapi import HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.repair import Repair, RepairDocument
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairDocumentUpdate
from app.services.audit_service import write_audit_log
from app.storage import get_storage_service
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
ALLOWED_REPAIR_DOCUMENT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
ALLOWED_REPAIR_DOCUMENT_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
}
DOCUMENT_TYPE_LABELS = {
"device_photo": "Gerätefoto",
"fault_photo": "Fehlerbild",
"measurement": "Messbild",
"estimate": "Kostenvoranschlag",
"repair_report": "Reparaturbericht",
"shipping": "Versandbeleg",
"other": "Dokument",
}
def storage_validation_error(exc: StorageValidationError) -> HTTPException:
detail = str(exc) or "Ungültige Datei"
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST
return HTTPException(status_code=status_code, detail=detail)
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _document_label(document: RepairDocument) -> str:
return f"{document.title} · {document.original_filename}"
def _audit_document_data(document: RepairDocument) -> dict:
return {
"id": document.id,
"repair_id": document.repair_id,
"title": document.title,
"document_type": document.document_type,
"original_filename": document.original_filename,
"mime_type": document.mime_type,
"size_bytes": document.size_bytes,
"visibility": document.visibility,
"uploaded_by_user_id": document.uploaded_by_user_id,
"created_at": document.created_at,
"updated_at": document.updated_at,
}
class RepairDocumentService:
@staticmethod
async def upload_document(
db: Session,
repair: Repair,
*,
file: UploadFile,
title: str,
document_type: str,
visibility: str,
note: str | None,
actor: User,
request: Request,
) -> RepairDocument:
storage_service = get_storage_service()
max_bytes = storage_service.max_upload_mb * 1024 * 1024
content = await file.read(max_bytes + 1)
original_filename = file.filename or ""
mime_type = RepairDocumentService._resolve_mime_type(original_filename, file.content_type)
RepairDocumentService._validate_repair_document_type(original_filename, mime_type)
try:
metadata = storage_service.save_file(
namespace=f"repairs/{repair.id}/documents",
content=content,
original_filename=original_filename,
mime_type=mime_type,
)
except StorageValidationError as exc:
raise storage_validation_error(exc) from exc
try:
document = RepairRepository.create_document(
db,
repair_id=repair.id,
title=title,
document_type=document_type,
original_filename=metadata.original_filename,
stored_filename=metadata.stored_filename,
storage_path=metadata.storage_key,
mime_type=metadata.mime_type,
size_bytes=metadata.size,
checksum_sha256=metadata.checksum_sha256,
visibility=visibility,
note=note,
uploaded_by_user_id=actor.id,
)
except Exception:
storage_service.delete_file(metadata.storage_key)
raise
write_audit_log(
db,
action="repairs.documents.upload",
entity_type="repair_documents",
entity_id=document.id,
entity_label=_document_label(document),
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"document_type": document.document_type,
"visibility": document.visibility,
"mime_type": document.mime_type,
"size_bytes": document.size_bytes,
},
)
return document
@staticmethod
def update_document(
db: Session,
repair: Repair,
document: RepairDocument,
payload: RepairDocumentUpdate,
*,
actor: User,
request: Request,
) -> RepairDocument:
before_data = _audit_document_data(document)
updated = RepairRepository.update_document(db, document, payload)
write_audit_log(
db,
action="repairs.documents.update",
entity_type="repair_documents",
entity_id=updated.id,
entity_label=_document_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_document_data(updated),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
return updated
@staticmethod
def delete_document(
db: Session,
repair: Repair,
document: RepairDocument,
*,
actor: User,
request: Request,
) -> None:
before_data = _audit_document_data(document)
storage_path = document.storage_path
label = _document_label(document)
try:
get_storage_service().delete_file(storage_path)
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
RepairRepository.delete_document(db, document)
write_audit_log(
db,
action="repairs.documents.delete",
entity_type="repair_documents",
entity_id=document.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
@staticmethod
def open_document_file(document: RepairDocument) -> Path:
if not document.storage_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden")
try:
return get_storage_service().open_file(document.storage_path)
except StorageFileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
@staticmethod
def content_disposition(document: RepairDocument, *, mode: Literal["inline", "attachment"]) -> str:
filename = document.original_filename or document.stored_filename or "reparatur-dokument"
safe_filename = filename.replace('"', "")
encoded_filename = quote(filename)
return f'{mode}; filename="{safe_filename}"; filename*=UTF-8\'\'{encoded_filename}'
@staticmethod
def _resolve_mime_type(original_filename: str, content_type: str | None) -> str:
if content_type and content_type != "application/octet-stream":
return content_type
return mimetypes.guess_type(original_filename)[0] or "application/octet-stream"
@staticmethod
def _validate_repair_document_type(original_filename: str, mime_type: str) -> None:
extension = Path(original_filename or "").suffix.lower()
if extension not in ALLOWED_REPAIR_DOCUMENT_EXTENSIONS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt")
if mime_type not in ALLOWED_REPAIR_DOCUMENT_MIME_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt")

View file

@ -87,6 +87,7 @@ class LocalDiskStorageProvider(StorageProvider):
"customers",
"projects",
"tickets",
"repairs",
"imports",
"temp",
]: