feat(dokumente,identity): DOC-003 Versionierung + QR-Code-Etiketten
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
This commit is contained in:
@@ -14,9 +14,11 @@ from app.services.dokument import (
|
||||
DateizugrossError,
|
||||
DokumentDuplikatError,
|
||||
dateipfad,
|
||||
ersetze_dokument,
|
||||
liste_fuer_entitaet,
|
||||
loesche_dokument,
|
||||
speichere_dokument,
|
||||
versionshistorie,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -40,6 +42,13 @@ def _darf_eingeschraenkte_dokumente_sehen(benutzer: Benutzer) -> bool:
|
||||
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,
|
||||
@@ -108,16 +117,72 @@ async def lade_dokument_herunter(
|
||||
dokument = await db.get(Dokument, dokument_id)
|
||||
if dokument is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
|
||||
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")
|
||||
_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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
@@ -14,7 +15,9 @@ from app.models.objektposition import Objektposition
|
||||
from app.models.stammdaten import Material, Objekttyp
|
||||
from app.models.vorlage import Beladungsvorlage
|
||||
from app.services import historie as historie_service
|
||||
from app.services.label import generiere_code128_label_pdf
|
||||
from app.services.label import generiere_code128_label_pdf, generiere_qr_label_pdf
|
||||
|
||||
LabelFormat = Literal["code128", "qr"]
|
||||
from app.schemas.objekt import (
|
||||
FahrzeugdetailsRead,
|
||||
FahrzeugdetailsUpsert,
|
||||
@@ -157,16 +160,26 @@ async def hole_objekt(
|
||||
return _objekt_read(objekt, letzte_map.get(objekt.id))
|
||||
|
||||
|
||||
def _erzeuge_label_pdf(code: str, klartext_zusatz: str | None, format: LabelFormat) -> bytes:
|
||||
if format == "qr":
|
||||
return generiere_qr_label_pdf(code, klartext_zusatz)
|
||||
return generiere_code128_label_pdf(code, klartext_zusatz)
|
||||
|
||||
|
||||
@router.get("/objekte/{objekt_id}/label.pdf")
|
||||
async def objekt_label(
|
||||
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||
objekt_id: int,
|
||||
format: LabelFormat = "code128",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""Druckfertiges Code128-Label (Karte 10): Code + Objektname als Klartext-
|
||||
Fallback. Vektor-PDF, skaliert verlustfrei auf jede Labelgröße beim Druck."""
|
||||
"""Druckfertiges Etikett (Karte 10, QR-Variante Nutzer-Nachtrag): Code128
|
||||
(Standard) oder QR-Code + Objektname als Klartext-Fallback. Vektor-PDF,
|
||||
skaliert verlustfrei auf jede Labelgröße beim Druck."""
|
||||
objekt = await db.get(Objekt, objekt_id)
|
||||
if objekt is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
|
||||
pdf = generiere_code128_label_pdf(objekt.code, objekt.name)
|
||||
pdf = _erzeuge_label_pdf(objekt.code, objekt.name, format)
|
||||
return Response(
|
||||
content=pdf,
|
||||
media_type="application/pdf",
|
||||
@@ -176,7 +189,10 @@ async def objekt_label(
|
||||
|
||||
@router.get("/objektpositionen/{position_id}/label.pdf")
|
||||
async def objektposition_label(
|
||||
position_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||
position_id: uuid.UUID,
|
||||
format: LabelFormat = "code128",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""Geräte-Etikett (Karte 10): eigener Code je Exemplar, Klartext-Zusatz ist
|
||||
der Materialname (Objektname wäre bei mehreren Geräten am selben Objekt
|
||||
@@ -185,7 +201,7 @@ async def objektposition_label(
|
||||
if position is None or position.code is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Geräte-Code vergeben")
|
||||
material = await db.get(Material, position.material_id)
|
||||
pdf = generiere_code128_label_pdf(position.code, material.name if material else None)
|
||||
pdf = _erzeuge_label_pdf(position.code, material.name if material else None, format)
|
||||
return Response(
|
||||
content=pdf,
|
||||
media_type="application/pdf",
|
||||
|
||||
Reference in New Issue
Block a user