feat(dokumente,identity): DOC-003 Versionierung + QR-Code-Etiketten
CI / backend-tests (push) Failing after 2m15s
CI / frontend-build (push) Successful in 28s

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:
2026-09-08 11:03:46 +02:00
co-authored by Claude Sonnet 5
parent d5d8c72d9a
commit 977d81ddcd
14 changed files with 543 additions and 54 deletions
+79 -2
View File
@@ -44,7 +44,7 @@ def _upload_pfad() -> Path:
return pfad
async def speichere_dokument(
async def _pruefe_und_speichere(
db: AsyncSession,
*,
entitaet_typ: str,
@@ -55,6 +55,7 @@ async def speichere_dokument(
dokumenttyp: DokumentTyp,
beschreibung: str | None,
hochgeladen_von: int,
vorgaenger_id: uuid.UUID | None,
) -> Dokument:
if mime_type not in ERLAUBTE_MIME_TYPES:
raise DateityperlaubtError(mime_type)
@@ -92,12 +93,81 @@ async def speichere_dokument(
beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von,
hochgeladen_am=datetime.now(timezone.utc),
vorgaenger_id=vorgaenger_id,
)
db.add(dokument)
await db.flush()
return dokument
async def speichere_dokument(
db: AsyncSession,
*,
entitaet_typ: str,
entitaet_id: str,
dateiname: str,
mime_type: str,
inhalt: bytes,
dokumenttyp: DokumentTyp,
beschreibung: str | None,
hochgeladen_von: int,
) -> Dokument:
return await _pruefe_und_speichere(
db,
entitaet_typ=entitaet_typ,
entitaet_id=entitaet_id,
dateiname=dateiname,
mime_type=mime_type,
inhalt=inhalt,
dokumenttyp=dokumenttyp,
beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von,
vorgaenger_id=None,
)
async def ersetze_dokument(
db: AsyncSession,
*,
altes_dokument: Dokument,
dateiname: str,
mime_type: str,
inhalt: bytes,
beschreibung: str | None,
hochgeladen_von: int,
) -> Dokument:
"""DOC-003: neue Version anlegen statt zu überschreiben - altes_dokument
bleibt unverändert als Historieneintrag erhalten, entitaet_typ/entitaet_id/
dokumenttyp werden von ihm übernommen (nicht neu wählbar, es ist dieselbe
Sache in neuer Fassung)."""
return await _pruefe_und_speichere(
db,
entitaet_typ=altes_dokument.entitaet_typ,
entitaet_id=altes_dokument.entitaet_id,
dateiname=dateiname,
mime_type=mime_type,
inhalt=inhalt,
dokumenttyp=altes_dokument.dokumenttyp,
beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von,
vorgaenger_id=altes_dokument.id,
)
async def versionshistorie(db: AsyncSession, dokument: Dokument) -> list[Dokument]:
"""Kette rückwärts über vorgaenger_id, neueste zuerst (inkl. übergebenem
Dokument selbst als erstem Eintrag)."""
kette = [dokument]
aktuelles = dokument
while aktuelles.vorgaenger_id is not None:
vorgaenger = await db.get(Dokument, aktuelles.vorgaenger_id)
if vorgaenger is None:
break
kette.append(vorgaenger)
aktuelles = vorgaenger
return kette
async def liste_fuer_entitaet(
db: AsyncSession,
*,
@@ -106,7 +176,14 @@ async def liste_fuer_entitaet(
dokumenttyp: DokumentTyp | None = None,
darf_eingeschraenkte_sehen: bool = True,
) -> list[Dokument]:
bedingungen = [Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id]
# DOC-003: nur die jeweils aktuelle Version je Dokument zeigen - "aktuell"
# heißt, kein anderes Dokument hat vorgaenger_id auf dieses gesetzt.
ersetzte_ids = select(Dokument.vorgaenger_id).where(Dokument.vorgaenger_id.isnot(None))
bedingungen = [
Dokument.entitaet_typ == entitaet_typ,
Dokument.entitaet_id == entitaet_id,
Dokument.id.notin_(ersetzte_ids),
]
if dokumenttyp is not None:
bedingungen.append(Dokument.dokumenttyp == dokumenttyp)
if not darf_eingeschraenkte_sehen:
+42
View File
@@ -3,6 +3,9 @@ import io
from barcode import Code128
from barcode.writer import ImageWriter
from PIL import Image
from reportlab.graphics import renderPDF
from reportlab.graphics.barcode import qr
from reportlab.graphics.shapes import Drawing
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
@@ -55,3 +58,42 @@ def generiere_code128_label_pdf(code: str, klartext_zusatz: str | None = None) -
c.showPage()
c.save()
return pdf_puffer.getvalue()
_QR_ZIELGROESSE = 25 * mm
_QR_MINDESTBREITE = 30 * mm
def generiere_qr_label_pdf(code: str, klartext_zusatz: str | None = None) -> bytes:
"""Alternative zum Code128-Label (Nutzer-Wunsch): QR-Code statt 1D-Barcode -
robuster bei teilbeschädigtem Etikett (Fehlerkorrektur), von praktisch jeder
Handy-Kamera nativ lesbar, kompakter am Objekt anbringbar. Gleicher
Klartext-Code als Inhalt wie beim Code128-Label, damit beide Varianten
denselben Scan-Lookup (/objekte/code/{code}) treffen."""
qr_code = qr.QrCodeWidget(code)
qr_code.barWidth = _QR_ZIELGROESSE
qr_code.barHeight = _QR_ZIELGROESSE
bounds = qr_code.getBounds()
qr_breite_pt = bounds[2] - bounds[0]
qr_hoehe_pt = bounds[3] - bounds[1]
zeichnung = Drawing(qr_breite_pt, qr_hoehe_pt, transform=[1, 0, 0, 1, -bounds[0], -bounds[1]])
zeichnung.add(qr_code)
label_breite = max(qr_breite_pt + 2 * _RAND, _QR_MINDESTBREITE)
zusatz_hoehe = 14 if klartext_zusatz else 0
label_hoehe = qr_hoehe_pt + zusatz_hoehe + 2 * _RAND
pdf_puffer = io.BytesIO()
c = canvas.Canvas(pdf_puffer, pagesize=(label_breite, label_hoehe))
zeichnung_x = (label_breite - qr_breite_pt) / 2
zeichnung_y = label_hoehe - qr_hoehe_pt - _RAND
renderPDF.draw(zeichnung, c, zeichnung_x, zeichnung_y)
if klartext_zusatz:
c.setFont("Helvetica", 9)
c.drawCentredString(label_breite / 2, _RAND - 2, klartext_zusatz)
c.showPage()
c.save()
return pdf_puffer.getvalue()