Dokument-Versionierung: vorgaenger_id verkettet Versionen (Migration 0027),
POST /dokumente/{id}/ersetzen legt neue Version an statt zu überschreiben,
alte Fassung bleibt als Historie erhalten. GET /dokumente/{id}/versionen
liefert die Kette (neueste zuerst), respektiert DOC-005-Zugriffsrechte.
Liste zeigt nur die jeweils aktuelle Version. Löschen der aktuellen Version
gibt automatisch den Vorgänger als neue "aktuelle" frei. Frontend:
"Ersetzen"-Button + aufklappbare Versionshistorie im DokumentePanel.
QR-Code-Etiketten: bisher nur Code128-Barcode möglich. generiere_qr_label_pdf
(reportlab-eigenes QR-Widget, keine neue Abhängigkeit) als Alternative,
?format=qr|code128 an /objekte/{id}/label.pdf und /objektpositionen/{id}/
label.pdf. AktePage bekommt "Etikett (Barcode)"/"Etikett (QR)"-Buttons
(vorher gab es dafür noch gar keinen UI-Zugang im Web-Frontend).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
194 lines
7.5 KiB
Python
194 lines
7.5 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),
|
|
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,
|
|
)
|
|
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),
|
|
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,
|
|
)
|
|
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)
|