diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index f26d103..c7e93bd 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -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) | | 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-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-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) | diff --git a/backend/alembic/versions/0027_dokument_versionierung.py b/backend/alembic/versions/0027_dokument_versionierung.py new file mode 100644 index 0000000..38381b1 --- /dev/null +++ b/backend/alembic/versions/0027_dokument_versionierung.py @@ -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") diff --git a/backend/app/api/v1/endpoints/dokument.py b/backend/app/api/v1/endpoints/dokument.py index d37033f..96b35e5 100644 --- a/backend/app/api/v1/endpoints/dokument.py +++ b/backend/app/api/v1/endpoints/dokument.py @@ -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) diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index 439a8cb..54bf251 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -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", diff --git a/backend/app/models/dokument.py b/backend/app/models/dokument.py index f73029d..c7aa389 100644 --- a/backend/app/models/dokument.py +++ b/backend/app/models/dokument.py @@ -61,3 +61,9 @@ class Dokument(Base): beschreibung: Mapped[str | None] = mapped_column(String) hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), 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 + ) diff --git a/backend/app/schemas/dokument.py b/backend/app/schemas/dokument.py index 57bc5c6..1352e59 100644 --- a/backend/app/schemas/dokument.py +++ b/backend/app/schemas/dokument.py @@ -26,3 +26,4 @@ class DokumentRead(BaseModel): beschreibung: str | None hochgeladen_von: int hochgeladen_am: datetime + vorgaenger_id: uuid.UUID | None diff --git a/backend/app/services/dokument.py b/backend/app/services/dokument.py index 4bd072a..4b2adae 100644 --- a/backend/app/services/dokument.py +++ b/backend/app/services/dokument.py @@ -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: diff --git a/backend/app/services/label.py b/backend/app/services/label.py index e4c5965..ba06edd 100644 --- a/backend/app/services/label.py +++ b/backend/app/services/label.py @@ -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() diff --git a/backend/tests/test_dokument.py b/backend/tests/test_dokument.py index d47781c..53d5453 100644 --- a/backend/tests/test_dokument.py +++ b/backend/tests/test_dokument.py @@ -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) ) 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 diff --git a/backend/tests/test_objekte.py b/backend/tests/test_objekte.py index 7b9327a..e5702fc 100644 --- a/backend/tests/test_objekte.py +++ b/backend/tests/test_objekte.py @@ -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.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 async def test_objektposition_label_pdf_ohne_code_ist_404( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3029594..da69758 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -88,6 +88,31 @@ export async function ladeDokumentHoch(felder: { 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 { + const formData = new FormData(); + if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung); + formData.append("datei", felder.datei); + + const headers: Record = {}; + 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 -Link möglich, da // der Server ohne Bearer-Token 401 liefert) - Blob laden und über einen // 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 // nachträglichen Setzen von location.href zurückkäme; unkritisch, da das // Ziel eine selbst erzeugte same-origin blob:-URL ist, keine fremde Seite. -export async function ladeDokumentAnsehen(dokumentId: string): Promise { +async function ladeUndOeffneInNeuemTab(pfad: string): Promise { const tab = window.open("", "_blank"); try { const headers: Record = {}; 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) { throw new ApiError(response.status, null); } @@ -144,6 +169,14 @@ export async function ladeDokumentAnsehen(dokumentId: string): Promise { } } +export function ladeDokumentAnsehen(dokumentId: string): Promise { + return ladeUndOeffneInNeuemTab(`/dokumente/${dokumentId}/download`); +} + +export function ladeObjektEtikett(objektId: number, format: "code128" | "qr"): Promise { + return ladeUndOeffneInNeuemTab(`/objekte/${objektId}/label.pdf?format=${format}`); +} + export async function login(username: string, password: string): Promise { const body = new URLSearchParams({ username, password }); const response = await fetch(`${BASE_URL}/auth/login`, { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d89f7af..b20d013 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -144,6 +144,7 @@ export interface Dokument { beschreibung: string | null; hochgeladen_von: number; hochgeladen_am: string; + vorgaenger_id: string | null; } export interface Lagerbewegung { diff --git a/frontend/src/components/DokumentePanel.tsx b/frontend/src/components/DokumentePanel.tsx index b9d792b..6a0c13c 100644 --- a/frontend/src/components/DokumentePanel.tsx +++ b/frontend/src/components/DokumentePanel.tsx @@ -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 type { Dokument, DokumentTyp } from "../api/types"; @@ -27,6 +34,10 @@ function formatGroesse(bytes: number): string { 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 // Ressourcen (Objekt, Mangel, Fahrzeugdetails, ...) - gleiche entitaet_typ/ // entitaet_id-Adressierung wie das Backend. @@ -40,6 +51,8 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { const [dokumenttyp, setDokumenttyp] = useState("sonstiges"); const [filterTyp, setFilterTyp] = useState(""); const [wirdHochgeladen, setWirdHochgeladen] = useState(false); + const [versionenOffen, setVersionenOffen] = useState>({}); + const ersetzenInputs = useRef(new Map()); async function laden() { 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 { - 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(`/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 { onFehler("Datei konnte nicht geöffnet werden."); } } - async function herunterladen(d: Dokument) { + async function herunterladen(dokumentId: string, dateiname: string) { try { - await ladeDokumentHerunter(d.id, d.dateiname); + await ladeDokumentHerunter(dokumentId, dateiname); } catch { onFehler("Download fehlgeschlagen."); } @@ -151,38 +199,89 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { {!laedt && dokumente.length > 0 && (
    {dokumente.map((d) => ( -
  • - - - {DOKUMENTTYP_LABEL[d.dokumenttyp]} - - - {d.beschreibung && – {d.beschreibung}} - ({formatGroesse(d.groesse_bytes)}) - - - - {darfLoeschen && ( - - )} - + {d.beschreibung && – {d.beschreibung}} + ({formatGroesse(d.groesse_bytes)}) + + + + { + if (el) ersetzenInputs.current.set(d.id, el); + else ersetzenInputs.current.delete(d.id); + }} + onChange={(e) => ersetzen(d.id, e)} + /> + + {d.vorgaenger_id !== null || versionenOffen[d.id] ? ( + + ) : null} + {darfLoeschen && ( + + )} + + + {versionenOffen[d.id] && versionenOffen[d.id].length > 0 && ( +
      + {versionenOffen[d.id].map((alt) => ( +
    • + + {" "} + · {formatZeitpunkt(alt.hochgeladen_am)} + + +
    • + ))} +
    + )}
  • ))}
diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index dbbd84a..d8af9da 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { apiRequest } from "../api/client"; +import { apiRequest, ladeObjektEtikett } from "../api/client"; import { useAuth } from "../auth/AuthContext"; import type { Akte } from "../api/types"; import { DokumentePanel } from "../components/DokumentePanel"; @@ -41,6 +41,15 @@ export function AktePage() { 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) { try { await apiRequest(`/geraete/${geraetId}`, { @@ -90,6 +99,12 @@ export function AktePage() { Mangel melden + +