feat(repairs): add repair documents
This commit is contained in:
parent
884a20e043
commit
6e7e75f864
21 changed files with 1222 additions and 6 deletions
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue