feat(dokument): Dokumente-Modul (Roadmap Phase 5)
CI / backend-tests (push) Failing after 1m51s
CI / frontend-build (push) Successful in 17s

Polymorphe Datei-Anhänge (PDF/JPEG/PNG/WebP, Whitelist statt Blacklist -
owasp-Grundsatz) an beliebige Ressource, gleiches entitaet_typ/entitaet_id-
Muster wie Mangel/Historie. Lokale Ablage (settings.upload_dir, kein Cloud-
Zwang, Self-Hosting-Anforderung), server-generierter Dateiname verhindert
Path-Traversal/Namenskollisionen.

POST /dokumente (multipart), GET /dokumente (Filter Pflicht: entitaet_typ +
entitaet_id), GET /dokumente/{id}/download, DELETE /dokumente/{id}. Hochladen:
alle Mitarbeiter+, Löschen: Materialverantwortliche+Leitung+Admin.

Frontend: wiederverwendbares DokumentePanel (Upload/Liste/Download/Löschen),
eingebunden in ObjektSection (je Objekt) und MangelListePage (Fotos zu
Mängeln) - weitere Ressourcen (Geräteinstanz, Fahrzeugdetails, Benutzer)
können denselben Baustein später einfach wiederverwenden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 13:56:27 +02:00
co-authored by Claude Sonnet 5
parent 11ad747136
commit 73bf6c1605
16 changed files with 631 additions and 0 deletions
+2
View File
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
auth,
benutzer,
dashboard,
dokument,
eskalation,
fehlbestaende,
geraet_instanz,
@@ -36,3 +37,4 @@ api_router.include_router(dashboard.router, tags=["dashboard"])
api_router.include_router(personal.router, tags=["personal"])
api_router.include_router(mangel.router, tags=["mangel"])
api_router.include_router(lagerbewegung.router, tags=["lagerbewegung"])
api_router.include_router(dokument.router, tags=["dokument"])
+95
View File
@@ -0,0 +1,95 @@
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles
from app.db.session import get_db
from app.models.auth import RolleTyp
from app.models.dokument import Dokument
from app.schemas.dokument import DokumentRead, EntitaetTyp
from app.services.dokument import (
DateityperlaubtError,
DateizugrossError,
dateipfad,
liste_fuer_entitaet,
loesche_dokument,
speichere_dokument,
)
router = APIRouter()
_mitarbeiter_plus = require_roles(
RolleTyp.mitarbeiter,
RolleTyp.materialverantwortlicher,
RolleTyp.leitungsverantwortlicher,
RolleTyp.administration,
)
_materialverantwortliche = require_roles(
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher
)
@router.get("/dokumente", response_model=list[DokumentRead])
async def liste_dokumente(
entitaet_typ: EntitaetTyp,
entitaet_id: str,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> list[Dokument]:
return await liste_fuer_entitaet(db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id)
@router.post("/dokumente", response_model=DokumentRead, status_code=status.HTTP_201_CREATED)
async def lade_dokument_hoch(
entitaet_typ: EntitaetTyp = Form(...),
entitaet_id: str = Form(...),
beschreibung: str | None = Form(None),
datei: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user=Depends(_mitarbeiter_plus),
) -> Dokument:
inhalt = await datei.read()
try:
return await speichere_dokument(
db,
entitaet_typ=entitaet_typ,
entitaet_id=entitaet_id,
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
@router.get("/dokumente/{dokument_id}/download")
async def lade_dokument_herunter(
dokument_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> FileResponse:
dokument = await db.get(Dokument, dokument_id)
if dokument is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
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.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)
) -> None:
dokument = await db.get(Dokument, dokument_id)
if dokument is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
await loesche_dokument(db, dokument=dokument)
+7
View File
@@ -24,5 +24,12 @@ class Settings(BaseSettings):
smtp_from: str = "mabea@example.org"
smtp_use_tls: bool = True
# Dokumente-Modul (Roadmap Phase 5): lokale Ablage, kein Cloud-Storage-
# Zwang (Self-Hosting-Anforderung). Relativer Default reicht für
# Entwicklung, Produktion setzt einen absoluten Pfad außerhalb des
# Anwendungsverzeichnisses (z.B. /opt/mabea/uploads, siehe deploy/README.md).
upload_dir: str = "./uploads"
max_upload_size_mb: int = 25
settings = Settings()
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Dokument(Base):
"""Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige
Ressource (Objekt, Objektposition, Geräteinstanz, Mangel, Fahrzeugdetails,
Benutzer, ...) - gleiches entitaet_typ/entitaet_id-Muster wie Historie/
Mangel. Datei liegt lokal auf Platte (self-hosted, kein Cloud-Zwang),
speicherpfad ist relativ zu settings.upload_dir."""
__tablename__ = "dokument"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
entitaet_typ: Mapped[str] = mapped_column(String, nullable=False)
entitaet_id: Mapped[str] = mapped_column(String, nullable=False)
dateiname: Mapped[str] = mapped_column(String, nullable=False)
speicherpfad: Mapped[str] = mapped_column(String, nullable=False)
mime_type: Mapped[str] = mapped_column(String, nullable=False)
groesse_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
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)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
# Bewusst geschlossene Liste statt Freitext (Konsistenz mit Mangel.entitaet_typ-
# Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen,
# muss hier explizit freigeschaltet werden.
EntitaetTyp = Literal[
"objekt", "objektposition", "geraet_instanz", "mangel", "fahrzeugdetails", "benutzer"
]
class DokumentRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
entitaet_typ: str
entitaet_id: str
dateiname: str
mime_type: str
groesse_bytes: int
beschreibung: str | None
hochgeladen_von: int
hochgeladen_am: datetime
+99
View File
@@ -0,0 +1,99 @@
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 Dokument
# 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
def _upload_pfad() -> Path:
pfad = Path(settings.upload_dir)
pfad.mkdir(parents=True, exist_ok=True)
return pfad
async def speichere_dokument(
db: AsyncSession,
*,
entitaet_typ: str,
entitaet_id: str,
dateiname: str,
mime_type: str,
inhalt: bytes,
beschreibung: str | None,
hochgeladen_von: int,
) -> 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))
# 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),
beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von,
hochgeladen_am=datetime.now(timezone.utc),
)
db.add(dokument)
await db.flush()
return dokument
async def liste_fuer_entitaet(db: AsyncSession, *, entitaet_typ: str, entitaet_id: str) -> list[Dokument]:
result = await db.execute(
select(Dokument)
.where(Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id)
.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)