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
+1 -1
View File
@@ -216,7 +216,7 @@ vergeben statt geraten.
| READY-005 | Readiness | Regel-Konfiguration-UI | P2 | M | MEDIUM | READY-003 | ⬜ (keine UI zur Regel-Konfiguration gefunden, Regeln sind Code) | | READY-005 | Readiness | Regel-Konfiguration-UI | P2 | M | MEDIUM | READY-003 | ⬜ (keine UI zur Regel-Konfiguration gefunden, Regeln sind Code) |
| DOC-001 | Documents | Dokument-Grundgerüst (Upload) | P1 | M | MEDIUM | FILE-001 | ✅ (`dokument.py` Model+Endpunkt, inkl. SHA-256-Duplikaterkennung, Migration 0024/0016 referenzieren DOC-001) | | DOC-001 | Documents | Dokument-Grundgerüst (Upload) | P1 | M | MEDIUM | FILE-001 | ✅ (`dokument.py` Model+Endpunkt, inkl. SHA-256-Duplikaterkennung, Migration 0024/0016 referenzieren DOC-001) |
| DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | ✅ (`DokumentTyp`-Enum, Migration 0026, Filter beim Listen, Auswahl beim Upload) | | DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | ✅ (`DokumentTyp`-Enum, Migration 0026, Filter beim Listen, Auswahl beim Upload) |
| DOC-003 | Documents | Versionierung | P2 | M | MEDIUM | DOC-001 | (kein Versionsfeld/-verweis im Dokument-Modell) | | DOC-003 | Documents | Versionierung | P2 | M | MEDIUM | DOC-001 | (`vorgaenger_id`-Kette, Migration 0027, `/ersetzen`+`/versionen`-Endpunkte, alte Version bleibt erhalten) |
| DOC-004 | Documents | Original-vs-Kopie-Kennzeichnung | P2 | S | LOW | DOC-001 | ⬜ (kein entsprechendes Feld gefunden) | | DOC-004 | Documents | Original-vs-Kopie-Kennzeichnung | P2 | S | LOW | DOC-001 | ⬜ (kein entsprechendes Feld gefunden) |
| DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ✅ (nach Dokumenttyp: Rechnungen nur für Materialverantwortliche+, per `EINGESCHRAENKTE_DOKUMENTTYPEN`) | | DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ✅ (nach Dokumenttyp: Rechnungen nur für Materialverantwortliche+, per `EINGESCHRAENKTE_DOKUMENTTYPEN`) |
| MOBILE-001 | Mobile | PWA-Grundgerüst | P1 | M | MEDIUM | FOUND-005 | ✅ (Vite-PWA-Build erzeugt `manifest.webmanifest`/`sw.js`, `vite-plugin-pwa` in package.json) | | MOBILE-001 | Mobile | PWA-Grundgerüst | P1 | M | MEDIUM | FOUND-005 | ✅ (Vite-PWA-Build erzeugt `manifest.webmanifest`/`sw.js`, `vite-plugin-pwa` in package.json) |
@@ -0,0 +1,26 @@
"""DOC-003: Dokument-Versionierung - vorgaenger_id verkettet Versionen.
Revision ID: 0027_dokument_versionierung
Revises: 0026_dokumenttyp
Create Date: 2026-09-08
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0027_dokument_versionierung"
down_revision: Union[str, None] = "0026_dokumenttyp"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE dokument ADD COLUMN vorgaenger_id UUID REFERENCES dokument(id)"
)
op.execute("CREATE INDEX ix_dokument_vorgaenger_id ON dokument (vorgaenger_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_dokument_vorgaenger_id")
op.execute("ALTER TABLE dokument DROP COLUMN vorgaenger_id")
+69 -4
View File
@@ -14,9 +14,11 @@ from app.services.dokument import (
DateizugrossError, DateizugrossError,
DokumentDuplikatError, DokumentDuplikatError,
dateipfad, dateipfad,
ersetze_dokument,
liste_fuer_entitaet, liste_fuer_entitaet,
loesche_dokument, loesche_dokument,
speichere_dokument, speichere_dokument,
versionshistorie,
) )
router = APIRouter() 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) 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]) @router.get("/dokumente", response_model=list[DokumentRead])
async def liste_dokumente( async def liste_dokumente(
entitaet_typ: EntitaetTyp, entitaet_typ: EntitaetTyp,
@@ -108,16 +117,72 @@ async def lade_dokument_herunter(
dokument = await db.get(Dokument, dokument_id) dokument = await db.get(Dokument, dokument_id)
if dokument is None: if dokument is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") 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( _pruefe_eingeschraenkten_zugriff(dokument, current_user)
current_user
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Für diesen Dokumenttyp fehlt die Berechtigung")
pfad = dateipfad(dokument) pfad = dateipfad(dokument)
if not pfad.exists(): if not pfad.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht mehr vorhanden") 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) 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) @router.delete("/dokumente/{dokument_id}", status_code=status.HTTP_204_NO_CONTENT)
async def entferne_dokument( async def entferne_dokument(
dokument_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche) dokument_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
+23 -7
View File
@@ -1,4 +1,5 @@
import uuid import uuid
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select from sqlalchemy import select
@@ -14,7 +15,9 @@ from app.models.objektposition import Objektposition
from app.models.stammdaten import Material, Objekttyp from app.models.stammdaten import Material, Objekttyp
from app.models.vorlage import Beladungsvorlage from app.models.vorlage import Beladungsvorlage
from app.services import historie as historie_service 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 ( from app.schemas.objekt import (
FahrzeugdetailsRead, FahrzeugdetailsRead,
FahrzeugdetailsUpsert, FahrzeugdetailsUpsert,
@@ -157,16 +160,26 @@ async def hole_objekt(
return _objekt_read(objekt, letzte_map.get(objekt.id)) 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") @router.get("/objekte/{objekt_id}/label.pdf")
async def objekt_label( 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: ) -> Response:
"""Druckfertiges Code128-Label (Karte 10): Code + Objektname als Klartext- """Druckfertiges Etikett (Karte 10, QR-Variante Nutzer-Nachtrag): Code128
Fallback. Vektor-PDF, skaliert verlustfrei auf jede Labelgröße beim Druck.""" (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) objekt = await db.get(Objekt, objekt_id)
if objekt is None: if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") 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( return Response(
content=pdf, content=pdf,
media_type="application/pdf", media_type="application/pdf",
@@ -176,7 +189,10 @@ async def objekt_label(
@router.get("/objektpositionen/{position_id}/label.pdf") @router.get("/objektpositionen/{position_id}/label.pdf")
async def objektposition_label( 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: ) -> Response:
"""Geräte-Etikett (Karte 10): eigener Code je Exemplar, Klartext-Zusatz ist """Geräte-Etikett (Karte 10): eigener Code je Exemplar, Klartext-Zusatz ist
der Materialname (Objektname wäre bei mehreren Geräten am selben Objekt 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: if position is None or position.code is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Geräte-Code vergeben") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Geräte-Code vergeben")
material = await db.get(Material, position.material_id) 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( return Response(
content=pdf, content=pdf,
media_type="application/pdf", media_type="application/pdf",
+6
View File
@@ -61,3 +61,9 @@ class Dokument(Base):
beschreibung: Mapped[str | None] = mapped_column(String) beschreibung: Mapped[str | None] = mapped_column(String)
hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
# DOC-003: Versionierung - zeigt auf das Dokument, das diese Version ersetzt.
# Alte Version bleibt als eigene Zeile erhalten (kein Überschreiben/Löschen),
# "aktuell" ist jedes Dokument, auf das kein anderes per vorgaenger_id zeigt.
vorgaenger_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("dokument.id"), nullable=True
)
+1
View File
@@ -26,3 +26,4 @@ class DokumentRead(BaseModel):
beschreibung: str | None beschreibung: str | None
hochgeladen_von: int hochgeladen_von: int
hochgeladen_am: datetime hochgeladen_am: datetime
vorgaenger_id: uuid.UUID | None
+79 -2
View File
@@ -44,7 +44,7 @@ def _upload_pfad() -> Path:
return pfad return pfad
async def speichere_dokument( async def _pruefe_und_speichere(
db: AsyncSession, db: AsyncSession,
*, *,
entitaet_typ: str, entitaet_typ: str,
@@ -55,6 +55,7 @@ async def speichere_dokument(
dokumenttyp: DokumentTyp, dokumenttyp: DokumentTyp,
beschreibung: str | None, beschreibung: str | None,
hochgeladen_von: int, hochgeladen_von: int,
vorgaenger_id: uuid.UUID | None,
) -> Dokument: ) -> Dokument:
if mime_type not in ERLAUBTE_MIME_TYPES: if mime_type not in ERLAUBTE_MIME_TYPES:
raise DateityperlaubtError(mime_type) raise DateityperlaubtError(mime_type)
@@ -92,12 +93,81 @@ async def speichere_dokument(
beschreibung=beschreibung, beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von, hochgeladen_von=hochgeladen_von,
hochgeladen_am=datetime.now(timezone.utc), hochgeladen_am=datetime.now(timezone.utc),
vorgaenger_id=vorgaenger_id,
) )
db.add(dokument) db.add(dokument)
await db.flush() await db.flush()
return dokument 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( async def liste_fuer_entitaet(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -106,7 +176,14 @@ async def liste_fuer_entitaet(
dokumenttyp: DokumentTyp | None = None, dokumenttyp: DokumentTyp | None = None,
darf_eingeschraenkte_sehen: bool = True, darf_eingeschraenkte_sehen: bool = True,
) -> list[Dokument]: ) -> 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: if dokumenttyp is not None:
bedingungen.append(Dokument.dokumenttyp == dokumenttyp) bedingungen.append(Dokument.dokumenttyp == dokumenttyp)
if not darf_eingeschraenkte_sehen: if not darf_eingeschraenkte_sehen:
+42
View File
@@ -3,6 +3,9 @@ import io
from barcode import Code128 from barcode import Code128
from barcode.writer import ImageWriter from barcode.writer import ImageWriter
from PIL import Image 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.units import mm
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
@@ -55,3 +58,42 @@ def generiere_code128_label_pdf(code: str, klartext_zusatz: str | None = None) -
c.showPage() c.showPage()
c.save() c.save()
return pdf_puffer.getvalue() 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()
+101
View File
@@ -259,3 +259,104 @@ async def test_mitarbeiter_kann_rechnung_nicht_direkt_herunterladen(
f"/api/v1/dokumente/{dokument_id}/download", headers=auth_header(verantwortlicher_token) f"/api/v1/dokumente/{dokument_id}/download", headers=auth_header(verantwortlicher_token)
) )
assert erlaubt.status_code == 200 assert erlaubt.status_code == 200
@pytest.mark.asyncio
async def test_dokument_ersetzen_legt_neue_version_an_alte_bleibt_erhalten(
client, objekt_mit_position, mitarbeiter_user
):
"""DOC-003: alte Version bleibt in der Versionshistorie, Liste zeigt nur
die neue als aktuell."""
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
v1 = await client.post(
"/api/v1/dokumente",
data={
"entitaet_typ": "objekt",
"entitaet_id": str(objekt.id),
"dokumenttyp": "pruefprotokoll",
"beschreibung": "Erste Fassung",
},
files={"datei": ("protokoll_v1.pdf", b"%PDF-1.4 v1", "application/pdf")},
headers=auth_header(token),
)
v1_id = v1.json()["id"]
v2 = await client.post(
f"/api/v1/dokumente/{v1_id}/ersetzen",
data={"beschreibung": "Zweite Fassung"},
files={"datei": ("protokoll_v2.pdf", b"%PDF-1.4 v2", "application/pdf")},
headers=auth_header(token),
)
assert v2.status_code == 201
v2_body = v2.json()
assert v2_body["dateiname"] == "protokoll_v2.pdf"
assert v2_body["dokumenttyp"] == "pruefprotokoll"
assert v2_body["vorgaenger_id"] == v1_id
liste = await client.get(
f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token)
)
body = liste.json()
assert len(body) == 1
assert body[0]["dateiname"] == "protokoll_v2.pdf"
historie = await client.get(f"/api/v1/dokumente/{v2_body['id']}/versionen", headers=auth_header(token))
dateinamen = [d["dateiname"] for d in historie.json()]
assert dateinamen == ["protokoll_v2.pdf", "protokoll_v1.pdf"]
alte_datei_noch_da = await client.get(f"/api/v1/dokumente/{v1_id}/download", headers=auth_header(token))
assert alte_datei_noch_da.status_code == 200
assert alte_datei_noch_da.content == b"%PDF-1.4 v1"
@pytest.mark.asyncio
async def test_loeschen_der_aktuellen_version_gibt_vorgaenger_frei(client, objekt_mit_position, mitarbeiter_user):
"""Löscht man die aktuelle Version, wird die vorherige wieder als aktuell
gelistet (nichts zeigt mehr per vorgaenger_id auf sie)."""
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
v1 = await client.post(
"/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
files={"datei": ("v1.pdf", b"%PDF-1.4 v1", "application/pdf")},
headers=auth_header(token),
)
v1_id = v1.json()["id"]
v2 = await client.post(
f"/api/v1/dokumente/{v1_id}/ersetzen",
data={},
files={"datei": ("v2.pdf", b"%PDF-1.4 v2", "application/pdf")},
headers=auth_header(token),
)
v2_id = v2.json()["id"]
await client.delete(f"/api/v1/dokumente/{v2_id}", headers=auth_header(token))
liste = await client.get(
f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token)
)
body = liste.json()
assert len(body) == 1
assert body[0]["id"] == v1_id
@pytest.mark.asyncio
async def test_versionshistorie_respektiert_zugriffsrechte(
client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user
):
objekt, _material = objekt_mit_position
verantwortlicher_token = await login(client, "materialverantwortlicher1")
v1 = await client.post(
"/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "rechnung"},
files={"datei": ("rechnung_v1.pdf", b"%PDF-1.4 v1", "application/pdf")},
headers=auth_header(verantwortlicher_token),
)
v1_id = v1.json()["id"]
mitarbeiter_token = await login(client, "mitarbeiter1")
verweigert = await client.get(f"/api/v1/dokumente/{v1_id}/versionen", headers=auth_header(mitarbeiter_token))
assert verweigert.status_code == 403
+7
View File
@@ -309,6 +309,13 @@ async def test_objekt_label_pdf(client, db_session, objekttyp_factory, standort_
assert response.headers["content-type"] == "application/pdf" assert response.headers["content-type"] == "application/pdf"
assert response.content.startswith(b"%PDF") assert response.content.startswith(b"%PDF")
qr_response = await client.get(
f"/api/v1/objekte/{objekt_id}/label.pdf?format=qr", headers=auth_header(token)
)
assert qr_response.status_code == 200
assert qr_response.headers["content-type"] == "application/pdf"
assert qr_response.content.startswith(b"%PDF")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_objektposition_label_pdf_ohne_code_ist_404( async def test_objektposition_label_pdf_ohne_code_ist_404(
+35 -2
View File
@@ -88,6 +88,31 @@ export async function ladeDokumentHoch(felder: {
return response.json(); return response.json();
} }
// DOC-003: neue Version eines bestehenden Dokuments - entitaet_typ/entitaet_id/
// dokumenttyp übernimmt das Backend vom Vorgänger, hier nicht nötig.
export async function ladeDokumentErsetzen(felder: {
dokumentId: string;
beschreibung?: string;
datei: File;
}): Promise<unknown> {
const formData = new FormData();
if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung);
formData.append("datei", felder.datei);
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const response = await fetch(`${BASE_URL}/dokumente/${felder.dokumentId}/ersetzen`, {
method: "POST",
headers,
body: formData,
});
if (!response.ok) {
throw new ApiError(response.status, await response.json().catch(() => null));
}
return response.json();
}
// Download braucht den Auth-Header (kein einfacher <a href>-Link möglich, da // Download braucht den Auth-Header (kein einfacher <a href>-Link möglich, da
// der Server ohne Bearer-Token 401 liefert) - Blob laden und über einen // der Server ohne Bearer-Token 401 liefert) - Blob laden und über einen
// temporären Objekt-Link im Browser "herunterladen" lassen. // temporären Objekt-Link im Browser "herunterladen" lassen.
@@ -120,13 +145,13 @@ export async function ladeDokumentHerunter(dokumentId: string, dateiname: string
// erst danach nachgeladen. Kein "noopener", weil sonst keine Referenz zum // erst danach nachgeladen. Kein "noopener", weil sonst keine Referenz zum
// nachträglichen Setzen von location.href zurückkäme; unkritisch, da das // nachträglichen Setzen von location.href zurückkäme; unkritisch, da das
// Ziel eine selbst erzeugte same-origin blob:-URL ist, keine fremde Seite. // Ziel eine selbst erzeugte same-origin blob:-URL ist, keine fremde Seite.
export async function ladeDokumentAnsehen(dokumentId: string): Promise<void> { async function ladeUndOeffneInNeuemTab(pfad: string): Promise<void> {
const tab = window.open("", "_blank"); const tab = window.open("", "_blank");
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`; if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers }); const response = await fetch(`${BASE_URL}${pfad}`, { headers });
if (!response.ok) { if (!response.ok) {
throw new ApiError(response.status, null); throw new ApiError(response.status, null);
} }
@@ -144,6 +169,14 @@ export async function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
} }
} }
export function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
return ladeUndOeffneInNeuemTab(`/dokumente/${dokumentId}/download`);
}
export function ladeObjektEtikett(objektId: number, format: "code128" | "qr"): Promise<void> {
return ladeUndOeffneInNeuemTab(`/objekte/${objektId}/label.pdf?format=${format}`);
}
export async function login(username: string, password: string): Promise<string> { export async function login(username: string, password: string): Promise<string> {
const body = new URLSearchParams({ username, password }); const body = new URLSearchParams({ username, password });
const response = await fetch(`${BASE_URL}/auth/login`, { const response = await fetch(`${BASE_URL}/auth/login`, {
+1
View File
@@ -144,6 +144,7 @@ export interface Dokument {
beschreibung: string | null; beschreibung: string | null;
hochgeladen_von: number; hochgeladen_von: number;
hochgeladen_am: string; hochgeladen_am: string;
vorgaenger_id: string | null;
} }
export interface Lagerbewegung { export interface Lagerbewegung {
+108 -9
View File
@@ -1,6 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client"; import {
ApiError,
apiRequest,
ladeDokumentAnsehen,
ladeDokumentErsetzen,
ladeDokumentHerunter,
ladeDokumentHoch,
} from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import type { Dokument, DokumentTyp } from "../api/types"; import type { Dokument, DokumentTyp } from "../api/types";
@@ -27,6 +34,10 @@ function formatGroesse(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
function formatZeitpunkt(iso: string): string {
return new Date(iso).toLocaleString("de-DE");
}
// Dokumente-Modul (Roadmap Phase 5): wiederverwendbares Panel für beliebige // Dokumente-Modul (Roadmap Phase 5): wiederverwendbares Panel für beliebige
// Ressourcen (Objekt, Mangel, Fahrzeugdetails, ...) - gleiche entitaet_typ/ // Ressourcen (Objekt, Mangel, Fahrzeugdetails, ...) - gleiche entitaet_typ/
// entitaet_id-Adressierung wie das Backend. // entitaet_id-Adressierung wie das Backend.
@@ -40,6 +51,8 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges"); const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges");
const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">(""); const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">("");
const [wirdHochgeladen, setWirdHochgeladen] = useState(false); const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
const [versionenOffen, setVersionenOffen] = useState<Record<string, Dokument[]>>({});
const ersetzenInputs = useRef(new Map<string, HTMLInputElement>());
async function laden() { async function laden() {
setLaedt(true); setLaedt(true);
@@ -82,17 +95,52 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
} }
} }
async function ansehen(d: Dokument) { async function ersetzen(dokumentId: string, e: { target: HTMLInputElement }) {
const datei = e.target.files?.[0];
if (!datei) return;
try { try {
await ladeDokumentAnsehen(d.id); await ladeDokumentErsetzen({ dokumentId, datei });
e.target.value = "";
delete versionenOffen[dokumentId];
await laden();
} catch (fehler) {
if (fehler instanceof ApiError && fehler.status === 409) {
const detail = (fehler.detail as { detail?: string } | null)?.detail;
onFehler(detail ?? "Diese Datei wurde hier bereits hochgeladen.");
} else {
onFehler("Neue Version konnte nicht angelegt werden.");
}
}
}
async function versionenUmschalten(d: Dokument) {
if (versionenOffen[d.id]) {
setVersionenOffen((bisherig) => {
const neu = { ...bisherig };
delete neu[d.id];
return neu;
});
return;
}
try {
const historie = await apiRequest<Dokument[]>(`/dokumente/${d.id}/versionen`);
setVersionenOffen((bisherig) => ({ ...bisherig, [d.id]: historie.slice(1) }));
} catch {
onFehler("Versionshistorie konnte nicht geladen werden.");
}
}
async function ansehen(dokumentId: string) {
try {
await ladeDokumentAnsehen(dokumentId);
} catch { } catch {
onFehler("Datei konnte nicht geöffnet werden."); onFehler("Datei konnte nicht geöffnet werden.");
} }
} }
async function herunterladen(d: Dokument) { async function herunterladen(dokumentId: string, dateiname: string) {
try { try {
await ladeDokumentHerunter(d.id, d.dateiname); await ladeDokumentHerunter(dokumentId, dateiname);
} catch { } catch {
onFehler("Download fehlgeschlagen."); onFehler("Download fehlgeschlagen.");
} }
@@ -151,13 +199,14 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
{!laedt && dokumente.length > 0 && ( {!laedt && dokumente.length > 0 && (
<ul className="card-list"> <ul className="card-list">
{dokumente.map((d) => ( {dokumente.map((d) => (
<li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}> <li key={d.id} style={{ padding: "0.3rem 0" }}>
<div className="row-between">
<span> <span>
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}> <span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
{DOKUMENTTYP_LABEL[d.dokumenttyp]} {DOKUMENTTYP_LABEL[d.dokumenttyp]}
</span> </span>
<button <button
onClick={() => ansehen(d)} onClick={() => ansehen(d.id)}
title="In neuem Tab ansehen" title="In neuem Tab ansehen"
style={{ style={{
background: "none", background: "none",
@@ -174,15 +223,65 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
<span className="text-muted"> ({formatGroesse(d.groesse_bytes)})</span> <span className="text-muted"> ({formatGroesse(d.groesse_bytes)})</span>
</span> </span>
<span className="row" style={{ gap: "0.4rem" }}> <span className="row" style={{ gap: "0.4rem" }}>
<button className="btn btn-secondary" onClick={() => herunterladen(d)}> <button className="btn btn-secondary" onClick={() => herunterladen(d.id, d.dateiname)}>
Herunterladen Herunterladen
</button> </button>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp"
style={{ display: "none" }}
ref={(el) => {
if (el) ersetzenInputs.current.set(d.id, el);
else ersetzenInputs.current.delete(d.id);
}}
onChange={(e) => ersetzen(d.id, e)}
/>
<button
className="btn btn-secondary"
onClick={() => ersetzenInputs.current.get(d.id)?.click()}
title="Neue Version dieses Dokuments hochladen - alte Fassung bleibt in der Historie"
>
Ersetzen
</button>
{d.vorgaenger_id !== null || versionenOffen[d.id] ? (
<button className="btn btn-secondary" onClick={() => versionenUmschalten(d)}>
{versionenOffen[d.id] ? "Versionen ausblenden" : "Versionen"}
</button>
) : null}
{darfLoeschen && ( {darfLoeschen && (
<button className="btn btn-secondary" onClick={() => loeschen(d)}> <button className="btn btn-secondary" onClick={() => loeschen(d)}>
Löschen Löschen
</button> </button>
)} )}
</span> </span>
</div>
{versionenOffen[d.id] && versionenOffen[d.id].length > 0 && (
<ul className="card-list" style={{ marginLeft: "1.5rem", marginTop: "0.3rem" }}>
{versionenOffen[d.id].map((alt) => (
<li key={alt.id} className="row-between text-muted" style={{ fontSize: "0.85rem" }}>
<span>
<button
onClick={() => ansehen(alt.id)}
style={{
background: "none",
border: "none",
padding: 0,
color: "inherit",
textDecoration: "underline",
cursor: "pointer",
}}
>
{alt.dateiname}
</button>{" "}
· {formatZeitpunkt(alt.hochgeladen_am)}
</span>
<button className="btn btn-secondary" onClick={() => herunterladen(alt.id, alt.dateiname)}>
Herunterladen
</button>
</li>
))}
</ul>
)}
</li> </li>
))} ))}
</ul> </ul>
+16 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { apiRequest } from "../api/client"; import { apiRequest, ladeObjektEtikett } from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import type { Akte } from "../api/types"; import type { Akte } from "../api/types";
import { DokumentePanel } from "../components/DokumentePanel"; import { DokumentePanel } from "../components/DokumentePanel";
@@ -41,6 +41,15 @@ export function AktePage() {
useEffect(laden, [objektId]); useEffect(laden, [objektId]);
async function etikettDrucken(format: "code128" | "qr") {
if (!objektId) return;
try {
await ladeObjektEtikett(Number(objektId), format);
} catch {
setFehler("Etikett konnte nicht erstellt werden.");
}
}
async function pruefungDurchgefuehrt(geraetId: string) { async function pruefungDurchgefuehrt(geraetId: string) {
try { try {
await apiRequest(`/geraete/${geraetId}`, { await apiRequest(`/geraete/${geraetId}`, {
@@ -90,6 +99,12 @@ export function AktePage() {
<Link to={`/maengel?objekt_id=${objekt.id}`} className="btn btn-secondary"> <Link to={`/maengel?objekt_id=${objekt.id}`} className="btn btn-secondary">
Mangel melden Mangel melden
</Link> </Link>
<button className="btn btn-secondary" onClick={() => etikettDrucken("code128")}>
Etikett (Barcode)
</button>
<button className="btn btn-secondary" onClick={() => etikettDrucken("qr")}>
Etikett (QR)
</button>
</div> </div>
<div className="akte-stamm card"> <div className="akte-stamm card">