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:
@@ -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) |
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -26,3 +26,4 @@ class DokumentRead(BaseModel):
|
||||
beschreibung: str | None
|
||||
hochgeladen_von: int
|
||||
hochgeladen_am: datetime
|
||||
vorgaenger_id: uuid.UUID | None
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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
|
||||
// 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<void> {
|
||||
async function ladeUndOeffneInNeuemTab(pfad: string): Promise<void> {
|
||||
const tab = window.open("", "_blank");
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
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<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> {
|
||||
const body = new URLSearchParams({ username, password });
|
||||
const response = await fetch(`${BASE_URL}/auth/login`, {
|
||||
|
||||
@@ -144,6 +144,7 @@ export interface Dokument {
|
||||
beschreibung: string | null;
|
||||
hochgeladen_von: number;
|
||||
hochgeladen_am: string;
|
||||
vorgaenger_id: string | null;
|
||||
}
|
||||
|
||||
export interface Lagerbewegung {
|
||||
|
||||
@@ -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<DokumentTyp>("sonstiges");
|
||||
const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">("");
|
||||
const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
|
||||
const [versionenOffen, setVersionenOffen] = useState<Record<string, Dokument[]>>({});
|
||||
const ersetzenInputs = useRef(new Map<string, HTMLInputElement>());
|
||||
|
||||
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<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 {
|
||||
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 && (
|
||||
<ul className="card-list">
|
||||
{dokumente.map((d) => (
|
||||
<li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}>
|
||||
<span>
|
||||
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
|
||||
{DOKUMENTTYP_LABEL[d.dokumenttyp]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => ansehen(d)}
|
||||
title="In neuem Tab ansehen"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
color: "var(--color-primary)",
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{d.dateiname}
|
||||
</button>
|
||||
{d.beschreibung && <span className="text-muted"> – {d.beschreibung}</span>}
|
||||
<span className="text-muted"> ({formatGroesse(d.groesse_bytes)})</span>
|
||||
</span>
|
||||
<span className="row" style={{ gap: "0.4rem" }}>
|
||||
<button className="btn btn-secondary" onClick={() => herunterladen(d)}>
|
||||
Herunterladen
|
||||
</button>
|
||||
{darfLoeschen && (
|
||||
<button className="btn btn-secondary" onClick={() => loeschen(d)}>
|
||||
Löschen
|
||||
<li key={d.id} style={{ padding: "0.3rem 0" }}>
|
||||
<div className="row-between">
|
||||
<span>
|
||||
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
|
||||
{DOKUMENTTYP_LABEL[d.dokumenttyp]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => ansehen(d.id)}
|
||||
title="In neuem Tab ansehen"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
color: "var(--color-primary)",
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{d.dateiname}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{d.beschreibung && <span className="text-muted"> – {d.beschreibung}</span>}
|
||||
<span className="text-muted"> ({formatGroesse(d.groesse_bytes)})</span>
|
||||
</span>
|
||||
<span className="row" style={{ gap: "0.4rem" }}>
|
||||
<button className="btn btn-secondary" onClick={() => herunterladen(d.id, d.dateiname)}>
|
||||
Herunterladen
|
||||
</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 && (
|
||||
<button className="btn btn-secondary" onClick={() => loeschen(d)}>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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() {
|
||||
<Link to={`/maengel?objekt_id=${objekt.id}`} className="btn btn-secondary">
|
||||
Mangel melden
|
||||
</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 className="akte-stamm card">
|
||||
|
||||
Reference in New Issue
Block a user