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:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user