feat(dokument): Dokumente-Modul (Roadmap Phase 5)
Polymorphe Datei-Anhänge (PDF/JPEG/PNG/WebP, Whitelist statt Blacklist -
owasp-Grundsatz) an beliebige Ressource, gleiches entitaet_typ/entitaet_id-
Muster wie Mangel/Historie. Lokale Ablage (settings.upload_dir, kein Cloud-
Zwang, Self-Hosting-Anforderung), server-generierter Dateiname verhindert
Path-Traversal/Namenskollisionen.
POST /dokumente (multipart), GET /dokumente (Filter Pflicht: entitaet_typ +
entitaet_id), GET /dokumente/{id}/download, DELETE /dokumente/{id}. Hochladen:
alle Mitarbeiter+, Löschen: Materialverantwortliche+Leitung+Admin.
Frontend: wiederverwendbares DokumentePanel (Upload/Liste/Download/Löschen),
eingebunden in ObjektSection (je Objekt) und MangelListePage (Fotos zu
Mängeln) - weitere Ressourcen (Geräteinstanz, Fahrzeugdetails, Benutzer)
können denselben Baustein später einfach wiederverwenden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models.auth import RolleTyp
|
||||
from app.models.dokument import Dokument
|
||||
from app.schemas.dokument import DokumentRead, EntitaetTyp
|
||||
from app.services.dokument import (
|
||||
DateityperlaubtError,
|
||||
DateizugrossError,
|
||||
dateipfad,
|
||||
liste_fuer_entitaet,
|
||||
loesche_dokument,
|
||||
speichere_dokument,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_mitarbeiter_plus = require_roles(
|
||||
RolleTyp.mitarbeiter,
|
||||
RolleTyp.materialverantwortlicher,
|
||||
RolleTyp.leitungsverantwortlicher,
|
||||
RolleTyp.administration,
|
||||
)
|
||||
_materialverantwortliche = require_roles(
|
||||
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dokumente", response_model=list[DokumentRead])
|
||||
async def liste_dokumente(
|
||||
entitaet_typ: EntitaetTyp,
|
||||
entitaet_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
) -> list[Dokument]:
|
||||
return await liste_fuer_entitaet(db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id)
|
||||
|
||||
|
||||
@router.post("/dokumente", response_model=DokumentRead, status_code=status.HTTP_201_CREATED)
|
||||
async def lade_dokument_hoch(
|
||||
entitaet_typ: EntitaetTyp = Form(...),
|
||||
entitaet_id: str = Form(...),
|
||||
beschreibung: str | None = Form(None),
|
||||
datei: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(_mitarbeiter_plus),
|
||||
) -> Dokument:
|
||||
inhalt = await datei.read()
|
||||
try:
|
||||
return await speichere_dokument(
|
||||
db,
|
||||
entitaet_typ=entitaet_typ,
|
||||
entitaet_id=entitaet_id,
|
||||
dateiname=datei.filename or "unbenannt",
|
||||
mime_type=datei.content_type or "application/octet-stream",
|
||||
inhalt=inhalt,
|
||||
beschreibung=beschreibung,
|
||||
hochgeladen_von=current_user.id,
|
||||
)
|
||||
except DateityperlaubtError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail=f"Dateityp nicht erlaubt: {exc}"
|
||||
) from exc
|
||||
except DateizugrossError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Datei zu groß"
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/dokumente/{dokument_id}/download")
|
||||
async def lade_dokument_herunter(
|
||||
dokument_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||
) -> FileResponse:
|
||||
dokument = await db.get(Dokument, dokument_id)
|
||||
if dokument is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||
pfad = dateipfad(dokument)
|
||||
if not pfad.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht mehr vorhanden")
|
||||
return FileResponse(pfad, media_type=dokument.mime_type, filename=dokument.dateiname)
|
||||
|
||||
|
||||
@router.delete("/dokumente/{dokument_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def entferne_dokument(
|
||||
dokument_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
|
||||
) -> None:
|
||||
dokument = await db.get(Dokument, dokument_id)
|
||||
if dokument is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||
await loesche_dokument(db, dokument=dokument)
|
||||
Reference in New Issue
Block a user