Neues Feld ist_original (Boolean, Migration 0029) - explizit beim Upload und beim Ersetzen (DOC-003) setzbar, kein Default in eine Richtung erraten. Wird beim Ersetzen bewusst NICHT vom Vorgänger übernommen (neue Version könnte ein nachgereichtes Original sein, das eine bisherige Kopie ersetzt, oder umgekehrt). Frontend: Checkbox "Original (nicht nur Kopie/Scan)" im Upload-Formular, "Original"-Badge in der Dokumentenliste. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
217 lines
6.8 KiB
Python
217 lines
6.8 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,
|
|
ist_original: bool,
|
|
) -> 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,
|
|
ist_original=ist_original,
|
|
)
|
|
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,
|
|
ist_original: bool,
|
|
) -> 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,
|
|
ist_original=ist_original,
|
|
)
|
|
|
|
|
|
async def ersetze_dokument(
|
|
db: AsyncSession,
|
|
*,
|
|
altes_dokument: Dokument,
|
|
dateiname: str,
|
|
mime_type: str,
|
|
inhalt: bytes,
|
|
beschreibung: str | None,
|
|
hochgeladen_von: int,
|
|
ist_original: bool,
|
|
) -> 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). ist_original wird bewusst NICHT vom Vorgänger
|
|
übernommen (DOC-004) - die neue Version könnte z.B. ein nachgereichtes
|
|
Original sein, das die bisherige Kopie ersetzt."""
|
|
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,
|
|
ist_original=ist_original,
|
|
)
|
|
|
|
|
|
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)
|