feat(repairs): add repair documents
This commit is contained in:
parent
884a20e043
commit
6e7e75f864
21 changed files with 1222 additions and 6 deletions
|
|
@ -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")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
225
backend/hermes/app/services/repair_document_service.py
Normal file
225
backend/hermes/app/services/repair_document_service.py
Normal 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")
|
||||
|
|
@ -87,6 +87,7 @@ class LocalDiskStorageProvider(StorageProvider):
|
|||
"customers",
|
||||
"projects",
|
||||
"tickets",
|
||||
"repairs",
|
||||
"imports",
|
||||
"temp",
|
||||
]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue