feat(dokument): Dokumente-Modul (Roadmap Phase 5)
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:
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user