Files
patrickandClaude Sonnet 5 ae09433c11
CI / backend-tests (push) Successful in 2m16s
CI / frontend-build (push) Successful in 27s
feat(dokumente): DOC-004 Original-vs-Kopie-Kennzeichnung
Neues Feld ist_original (Boolean, Migration 0029) - explizit beim Upload
und beim Ersetzen (DOC-003) setzbar, kein Default in eine Richtung erraten.
Wird beim Ersetzen bewusst NICHT vom Vorgänger übernommen (neue Version
könnte ein nachgereichtes Original sein, das eine bisherige Kopie ersetzt,
oder umgekehrt). Frontend: Checkbox "Original (nicht nur Kopie/Scan)" im
Upload-Formular, "Original"-Badge in der Dokumentenliste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-08 12:16:25 +02:00

198 lines
7.6 KiB
Python

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 Benutzer, RolleTyp
from app.models.dokument import EINGESCHRAENKTE_DOKUMENTTYPEN, Dokument, DokumentTyp
from app.schemas.dokument import DokumentRead, EntitaetTyp
from app.services.dokument import (
DateityperlaubtError,
DateizugrossError,
DokumentDuplikatError,
dateipfad,
ersetze_dokument,
liste_fuer_entitaet,
loesche_dokument,
speichere_dokument,
versionshistorie,
)
router = APIRouter()
_mitarbeiter_plus = require_roles(
RolleTyp.mitarbeiter,
RolleTyp.materialverantwortlicher,
RolleTyp.leitungsverantwortlicher,
RolleTyp.administration,
)
_materialverantwortliche = require_roles(
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher
)
_VERANTWORTLICHE_ROLLEN = {RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher}
def _darf_eingeschraenkte_dokumente_sehen(benutzer: Benutzer) -> bool:
"""DOC-005: Rechnungen etc. nur für Materialverantwortliche+ - siehe
EINGESCHRAENKTE_DOKUMENTTYPEN."""
return bool({RolleTyp(r) for r in benutzer.rollen_namen} & _VERANTWORTLICHE_ROLLEN)
def _pruefe_eingeschraenkten_zugriff(dokument: Dokument, current_user: Benutzer) -> None:
if dokument.dokumenttyp in EINGESCHRAENKTE_DOKUMENTTYPEN and not _darf_eingeschraenkte_dokumente_sehen(
current_user
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Für diesen Dokumenttyp fehlt die Berechtigung")
@router.get("/dokumente", response_model=list[DokumentRead])
async def liste_dokumente(
entitaet_typ: EntitaetTyp,
entitaet_id: str,
dokumenttyp: DokumentTyp | None = None,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(get_current_user),
) -> list[Dokument]:
return await liste_fuer_entitaet(
db,
entitaet_typ=entitaet_typ,
entitaet_id=entitaet_id,
dokumenttyp=dokumenttyp,
darf_eingeschraenkte_sehen=_darf_eingeschraenkte_dokumente_sehen(current_user),
)
@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(...),
dokumenttyp: DokumentTyp = Form(...),
beschreibung: str | None = Form(None),
ist_original: bool = Form(False),
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,
dokumenttyp=dokumenttyp,
beschreibung=beschreibung,
hochgeladen_von=current_user.id,
ist_original=ist_original,
)
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
except DokumentDuplikatError as exc:
bestehendes = exc.bestehendes_dokument
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Diese Datei wurde bereits am {bestehendes.hochgeladen_am:%d.%m.%Y %H:%M} "
f"als „{bestehendes.dateiname}“ hochgeladen."
),
) from exc
@router.get("/dokumente/{dokument_id}/download")
async def lade_dokument_herunter(
dokument_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = 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")
_pruefe_eingeschraenkten_zugriff(dokument, current_user)
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.post("/dokumente/{dokument_id}/ersetzen", response_model=DokumentRead, status_code=status.HTTP_201_CREATED)
async def dokument_ersetzen(
dokument_id: uuid.UUID,
beschreibung: str | None = Form(None),
ist_original: bool = Form(False),
datei: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user=Depends(_mitarbeiter_plus),
) -> Dokument:
"""DOC-003: neue Version anlegen statt zu überschreiben - entitaet_typ/
entitaet_id/dokumenttyp werden vom Vorgänger übernommen."""
altes_dokument = await db.get(Dokument, dokument_id)
if altes_dokument is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
_pruefe_eingeschraenkten_zugriff(altes_dokument, current_user)
inhalt = await datei.read()
try:
return await ersetze_dokument(
db,
altes_dokument=altes_dokument,
dateiname=datei.filename or "unbenannt",
mime_type=datei.content_type or "application/octet-stream",
inhalt=inhalt,
beschreibung=beschreibung,
hochgeladen_von=current_user.id,
ist_original=ist_original,
)
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
except DokumentDuplikatError as exc:
bestehendes = exc.bestehendes_dokument
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Diese Datei wurde bereits am {bestehendes.hochgeladen_am:%d.%m.%Y %H:%M} "
f"als „{bestehendes.dateiname}“ hochgeladen."
),
) from exc
@router.get("/dokumente/{dokument_id}/versionen", response_model=list[DokumentRead])
async def dokument_versionen(
dokument_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(get_current_user),
) -> list[Dokument]:
"""DOC-003: komplette Versionskette (neueste zuerst, inkl. übergebenem
Dokument selbst)."""
dokument = await db.get(Dokument, dokument_id)
if dokument is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
_pruefe_eingeschraenkten_zugriff(dokument, current_user)
return await versionshistorie(db, dokument)
@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)