Files
MABEA/backend/app/services/dokument.py
T
patrickandClaude Sonnet 5 977d81ddcd
CI / backend-tests (push) Failing after 2m15s
CI / frontend-build (push) Successful in 28s
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
2026-09-08 11:03:46 +02:00

209 lines
6.4 KiB
Python

import hashlib
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.app_settings import settings
from app.models.dokument import EINGESCHRAENKTE_DOKUMENTTYPEN, Dokument, DokumentTyp
# Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/
# Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) -
# Whitelist statt Blacklist (owasp-Grundsatz: Datei-Upload ist klassischer
# Angriffsvektor, z.B. .html/.svg mit eingebettetem Skript).
ERLAUBTE_MIME_TYPES = {
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
}
class DateityperlaubtError(Exception):
pass
class DateizugrossError(Exception):
pass
class DokumentDuplikatError(Exception):
"""DOC-001: identische Datei (SHA-256) bereits an dieser Entität vorhanden."""
def __init__(self, bestehendes_dokument: Dokument):
self.bestehendes_dokument = bestehendes_dokument
super().__init__(f"Duplikat von Dokument {bestehendes_dokument.id}")
def _upload_pfad() -> Path:
pfad = Path(settings.upload_dir)
pfad.mkdir(parents=True, exist_ok=True)
return pfad
async def _pruefe_und_speichere(
db: AsyncSession,
*,
entitaet_typ: str,
entitaet_id: str,
dateiname: str,
mime_type: str,
inhalt: bytes,
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)
if len(inhalt) > settings.max_upload_size_mb * 1024 * 1024:
raise DateizugrossError(len(inhalt))
dateihash = hashlib.sha256(inhalt).hexdigest()
bestehendes = await db.execute(
select(Dokument).where(
Dokument.entitaet_typ == entitaet_typ,
Dokument.entitaet_id == entitaet_id,
Dokument.dateihash == dateihash,
)
)
duplikat = bestehendes.scalars().first()
if duplikat is not None:
raise DokumentDuplikatError(duplikat)
# Speichername ist server-generiert (UUID), NIEMALS der Original-Dateiname -
# verhindert Path-Traversal (../../etc/passwd) und Namenskollisionen.
endung = Path(dateiname).suffix[:10]
speichername = f"{uuid.uuid4()}{endung}"
ziel = _upload_pfad() / speichername
ziel.write_bytes(inhalt)
dokument = Dokument(
entitaet_typ=entitaet_typ,
entitaet_id=entitaet_id,
dateiname=dateiname,
speicherpfad=speichername,
mime_type=mime_type,
groesse_bytes=len(inhalt),
dateihash=dateihash,
dokumenttyp=dokumenttyp,
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,
*,
entitaet_typ: str,
entitaet_id: str,
dokumenttyp: DokumentTyp | None = None,
darf_eingeschraenkte_sehen: bool = True,
) -> list[Dokument]:
# 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:
bedingungen.append(Dokument.dokumenttyp.notin_(EINGESCHRAENKTE_DOKUMENTTYPEN))
result = await db.execute(select(Dokument).where(*bedingungen).order_by(Dokument.hochgeladen_am.desc()))
return list(result.scalars().all())
def dateipfad(dokument: Dokument) -> Path:
return _upload_pfad() / dokument.speicherpfad
async def loesche_dokument(db: AsyncSession, *, dokument: Dokument) -> None:
pfad = dateipfad(dokument)
await db.delete(dokument)
await db.flush()
# Datei erst nach erfolgreichem DB-Commit-Vorbereiten löschen (flush wirft
# bei FK-Problemen, bevor die Datei weg ist) - hier gibt es keine
# eingehenden FKs auf dokument, daher unkritisch, aber Reihenfolge bewusst
# gewählt für den Fall künftiger Referenzen.
if pfad.exists():
os.remove(pfad)