feat(dokumente): DOC-002 feste Dokumenttypen statt Freitext
Neues Pflichtfeld dokumenttyp (Enum: Prüfprotokoll/Wartungsbericht/ Bedienungsanleitung/Rechnung/Zulassungsdokument/Sonstiges, Migration 0026) - macht Dokumente kategorisier- und filterbar statt nur per Freitext- Beschreibung auffindbar zu sein. Backend: Pflichtfeld beim Upload, optionaler Query-Filter bei GET /dokumente. Frontend: Auswahl-Dropdown beim Upload, Typ-Badge + Filter-Dropdown in der Liste (DokumentePanel). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
"""DOC-002: feste Dokumenttypen statt reinem Freitext (Kategorie beim Upload).
|
||||
|
||||
Revision ID: 0026_dokumenttyp
|
||||
Revises: 0025_material_suchindex
|
||||
Create Date: 2026-09-08
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0026_dokumenttyp"
|
||||
down_revision: Union[str, None] = "0025_material_suchindex"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE TYPE dokument_typ AS ENUM "
|
||||
"('pruefprotokoll', 'wartungsbericht', 'bedienungsanleitung', 'rechnung', "
|
||||
"'zulassungsdokument', 'sonstiges')"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE dokument ADD COLUMN dokumenttyp dokument_typ NOT NULL DEFAULT 'sonstiges'"
|
||||
)
|
||||
# Default nur für den Backfill nötig - künftige Inserts sollen den Typ immer
|
||||
# explizit mitgeben (Anwendungsebene), kein stiller Fallback mehr.
|
||||
op.execute("ALTER TABLE dokument ALTER COLUMN dokumenttyp DROP DEFAULT")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE dokument DROP COLUMN dokumenttyp")
|
||||
op.execute("DROP TYPE dokument_typ")
|
||||
@@ -7,7 +7,7 @@ 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.models.dokument import Dokument, DokumentTyp
|
||||
from app.schemas.dokument import DokumentRead, EntitaetTyp
|
||||
from app.services.dokument import (
|
||||
DateityperlaubtError,
|
||||
@@ -36,16 +36,20 @@ _materialverantwortliche = require_roles(
|
||||
async def liste_dokumente(
|
||||
entitaet_typ: EntitaetTyp,
|
||||
entitaet_id: str,
|
||||
dokumenttyp: DokumentTyp | None = None,
|
||||
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)
|
||||
return await liste_fuer_entitaet(
|
||||
db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id, dokumenttyp=dokumenttyp
|
||||
)
|
||||
|
||||
|
||||
@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(...),
|
||||
dokumenttyp: DokumentTyp = Form(...),
|
||||
beschreibung: str | None = Form(None),
|
||||
datei: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -60,6 +64,7 @@ async def lade_dokument_hoch(
|
||||
dateiname=datei.filename or "unbenannt",
|
||||
mime_type=datei.content_type or "application/octet-stream",
|
||||
inhalt=inhalt,
|
||||
dokumenttyp=dokumenttyp,
|
||||
beschreibung=beschreibung,
|
||||
hochgeladen_von=current_user.id,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class DokumentTyp(str, enum.Enum):
|
||||
"""DOC-002: feste Kategorie statt Freitext - macht Dokumente filter-/
|
||||
wiederfindbar (Nutzer-Vorgabe Modul 10: Prüfprotokolle/Wartungsberichte/
|
||||
Bedienungsanleitungen/Rechnungen/Zulassungsdokumente)."""
|
||||
|
||||
pruefprotokoll = "pruefprotokoll"
|
||||
wartungsbericht = "wartungsbericht"
|
||||
bedienungsanleitung = "bedienungsanleitung"
|
||||
rechnung = "rechnung"
|
||||
zulassungsdokument = "zulassungsdokument"
|
||||
sonstiges = "sonstiges"
|
||||
|
||||
|
||||
dokument_typ_pg = PgEnum(DokumentTyp, name="dokument_typ", create_type=False)
|
||||
|
||||
|
||||
class Dokument(Base):
|
||||
"""Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige
|
||||
Ressource (Objekt, Objektposition, Geräteinstanz, Mangel, Fahrzeugdetails,
|
||||
@@ -30,6 +47,9 @@ class Dokument(Base):
|
||||
# Nullable wegen Altbestand vor dieser Migration (Hash nachträglich ohne
|
||||
# Dateizugriff nicht sinnvoll befüllbar); jeder neue Upload setzt ihn.
|
||||
dateihash: Mapped[str | None] = mapped_column(String(64))
|
||||
dokumenttyp: Mapped[DokumentTyp] = mapped_column(
|
||||
dokument_typ_pg, nullable=False, default=DokumentTyp.sonstiges
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.dokument import DokumentTyp
|
||||
|
||||
# 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.
|
||||
@@ -20,6 +22,7 @@ class DokumentRead(BaseModel):
|
||||
dateiname: str
|
||||
mime_type: str
|
||||
groesse_bytes: int
|
||||
dokumenttyp: DokumentTyp
|
||||
beschreibung: str | None
|
||||
hochgeladen_von: int
|
||||
hochgeladen_am: datetime
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.app_settings import settings
|
||||
from app.models.dokument import Dokument
|
||||
from app.models.dokument import Dokument, DokumentTyp
|
||||
|
||||
# Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/
|
||||
# Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) -
|
||||
@@ -52,6 +52,7 @@ async def speichere_dokument(
|
||||
dateiname: str,
|
||||
mime_type: str,
|
||||
inhalt: bytes,
|
||||
dokumenttyp: DokumentTyp,
|
||||
beschreibung: str | None,
|
||||
hochgeladen_von: int,
|
||||
) -> Dokument:
|
||||
@@ -87,6 +88,7 @@ async def speichere_dokument(
|
||||
mime_type=mime_type,
|
||||
groesse_bytes=len(inhalt),
|
||||
dateihash=dateihash,
|
||||
dokumenttyp=dokumenttyp,
|
||||
beschreibung=beschreibung,
|
||||
hochgeladen_von=hochgeladen_von,
|
||||
hochgeladen_am=datetime.now(timezone.utc),
|
||||
@@ -96,12 +98,13 @@ async def speichere_dokument(
|
||||
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())
|
||||
)
|
||||
async def liste_fuer_entitaet(
|
||||
db: AsyncSession, *, entitaet_typ: str, entitaet_id: str, dokumenttyp: DokumentTyp | None = None
|
||||
) -> list[Dokument]:
|
||||
bedingungen = [Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id]
|
||||
if dokumenttyp is not None:
|
||||
bedingungen.append(Dokument.dokumenttyp == dokumenttyp)
|
||||
result = await db.execute(select(Dokument).where(*bedingungen).order_by(Dokument.hochgeladen_am.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ async def test_dokument_hochladen_und_liste(client, objekt_mit_position, mitarbe
|
||||
|
||||
hochgeladen = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "beschreibung": "Zulassung"},
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "zulassungsdokument", "beschreibung": "Zulassung"},
|
||||
files={"datei": ("zulassung.pdf", b"%PDF-1.4 fake", "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
@@ -31,7 +31,7 @@ async def test_download_liefert_inhalt(client, objekt_mit_position, mitarbeiter_
|
||||
token = await login(client, "mitarbeiter1")
|
||||
hochgeladen = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)},
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
|
||||
files={"datei": ("foto.png", b"\x89PNG fake", "image/png")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
@@ -49,7 +49,7 @@ async def test_unerlaubter_dateityp_wird_abgelehnt(client, objekt_mit_position,
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)},
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
|
||||
files={"datei": ("boese.html", b"<script>alert(1)</script>", "text/html")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
@@ -64,7 +64,7 @@ async def test_materialverantwortlicher_kann_dokument_loeschen(
|
||||
mitarbeiter_token = await login(client, "mitarbeiter1")
|
||||
hochgeladen = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)},
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
|
||||
files={"datei": ("bericht.pdf", b"%PDF-1.4 x", "application/pdf")},
|
||||
headers=auth_header(mitarbeiter_token),
|
||||
)
|
||||
@@ -86,7 +86,7 @@ async def test_mitarbeiter_darf_dokument_nicht_loeschen(client, objekt_mit_posit
|
||||
token = await login(client, "mitarbeiter1")
|
||||
hochgeladen = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)},
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
|
||||
files={"datei": ("x.pdf", b"%PDF-1.4 x", "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
@@ -105,7 +105,7 @@ async def test_identische_datei_am_gleichen_objekt_wird_abgelehnt(client, objekt
|
||||
|
||||
erster = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)},
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
|
||||
files={"datei": ("original.pdf", inhalt, "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
@@ -113,7 +113,7 @@ async def test_identische_datei_am_gleichen_objekt_wird_abgelehnt(client, objekt
|
||||
|
||||
zweiter = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)},
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
|
||||
files={"datei": ("kopie.pdf", inhalt, "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
@@ -136,14 +136,62 @@ async def test_identische_datei_an_anderer_entitaet_ist_kein_duplikat(client, ob
|
||||
|
||||
await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)},
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
|
||||
files={"datei": ("anleitung.pdf", inhalt, "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
andere_entitaet = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "mangel", "entitaet_id": "irgendein-mangel-id"},
|
||||
data={"entitaet_typ": "mangel", "entitaet_id": "irgendein-mangel-id", "dokumenttyp": "sonstiges"},
|
||||
files={"datei": ("anleitung.pdf", inhalt, "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert andere_entitaet.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liste_kann_nach_dokumenttyp_gefiltert_werden(client, objekt_mit_position, mitarbeiter_user):
|
||||
"""DOC-002: feste Kategorie statt Freitext - Dokumente pro Typ auffindbar."""
|
||||
objekt, _material = objekt_mit_position
|
||||
token = await login(client, "mitarbeiter1")
|
||||
|
||||
await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "rechnung"},
|
||||
files={"datei": ("rechnung.pdf", b"%PDF-1.4 r", "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "pruefprotokoll"},
|
||||
files={"datei": ("pruefung.pdf", b"%PDF-1.4 p", "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
|
||||
alle = await client.get(
|
||||
f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token)
|
||||
)
|
||||
assert len(alle.json()) == 2
|
||||
|
||||
nur_rechnungen = await client.get(
|
||||
f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}&dokumenttyp=rechnung",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
body = nur_rechnungen.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["dateiname"] == "rechnung.pdf"
|
||||
assert body[0]["dokumenttyp"] == "rechnung"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unbekannter_dokumenttyp_wird_abgelehnt(client, objekt_mit_position, mitarbeiter_user):
|
||||
objekt, _material = objekt_mit_position
|
||||
token = await login(client, "mitarbeiter1")
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "erfunden"},
|
||||
files={"datei": ("x.pdf", b"%PDF-1.4 x", "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
Reference in New Issue
Block a user