From 069a1ee7ee53319b4db6908c191dc83364a8bd8e Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 8 Sep 2026 10:32:25 +0200 Subject: [PATCH] feat(dokumente): DOC-002 feste Dokumenttypen statt Freitext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- arbeitskacheln/00_index.md | 2 +- backend/alembic/versions/0026_dokumenttyp.py | 33 ++++++++++ backend/app/api/v1/endpoints/dokument.py | 9 ++- backend/app/models/dokument.py | 22 ++++++- backend/app/schemas/dokument.py | 3 + backend/app/services/dokument.py | 17 ++--- backend/tests/test_dokument.py | 66 +++++++++++++++++--- frontend/src/api/client.ts | 2 + frontend/src/api/types.ts | 9 +++ frontend/src/components/DokumentePanel.tsx | 52 +++++++++++++-- 10 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 backend/alembic/versions/0026_dokumenttyp.py diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index 5e80df1..d904336 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -215,7 +215,7 @@ vergeben statt geraten. | READY-004 | Readiness | Readiness-Dashboard | P1 | S | LOW | READY-003 | ✅ (`EinsatzbereitschaftTile.tsx`, `/dashboard/einsatzbereitschaft`) | | READY-005 | Readiness | Regel-Konfiguration-UI | P2 | M | MEDIUM | READY-003 | ⬜ (keine UI zur Regel-Konfiguration gefunden, Regeln sind Code) | | DOC-001 | Documents | Dokument-Grundgerüst (Upload) | P1 | M | MEDIUM | FILE-001 | ✅ (`dokument.py` Model+Endpunkt, inkl. SHA-256-Duplikaterkennung, Migration 0024/0016 referenzieren DOC-001) | -| DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | ⬜ (kein `dokumenttyp`-Feld im Modell gefunden) | +| DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | ✅ (`DokumentTyp`-Enum, Migration 0026, Filter beim Listen, Auswahl beim Upload) | | DOC-003 | Documents | Versionierung | P2 | M | MEDIUM | DOC-001 | ⬜ (kein Versionsfeld/-verweis im Dokument-Modell) | | DOC-004 | Documents | Original-vs-Kopie-Kennzeichnung | P2 | S | LOW | DOC-001 | ⬜ (kein entsprechendes Feld gefunden) | | DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ⬜ (kein dokumentspezifisches Berechtigungsmodell gefunden, nur allgemeine Rollen) | diff --git a/backend/alembic/versions/0026_dokumenttyp.py b/backend/alembic/versions/0026_dokumenttyp.py new file mode 100644 index 0000000..ae8ca0a --- /dev/null +++ b/backend/alembic/versions/0026_dokumenttyp.py @@ -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") diff --git a/backend/app/api/v1/endpoints/dokument.py b/backend/app/api/v1/endpoints/dokument.py index 80b25bb..dd58ddb 100644 --- a/backend/app/api/v1/endpoints/dokument.py +++ b/backend/app/api/v1/endpoints/dokument.py @@ -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, ) diff --git a/backend/app/models/dokument.py b/backend/app/models/dokument.py index b4b70f9..7d7b563 100644 --- a/backend/app/models/dokument.py +++ b/backend/app/models/dokument.py @@ -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) diff --git a/backend/app/schemas/dokument.py b/backend/app/schemas/dokument.py index 0109991..57bc5c6 100644 --- a/backend/app/schemas/dokument.py +++ b/backend/app/schemas/dokument.py @@ -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 diff --git a/backend/app/services/dokument.py b/backend/app/services/dokument.py index d92f140..80abdeb 100644 --- a/backend/app/services/dokument.py +++ b/backend/app/services/dokument.py @@ -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()) diff --git a/backend/tests/test_dokument.py b/backend/tests/test_dokument.py index 56cfd90..6d788f5 100644 --- a/backend/tests/test_dokument.py +++ b/backend/tests/test_dokument.py @@ -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"", "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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4e3a12e..3029594 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -67,12 +67,14 @@ export async function apiRequest(path: string, options: RequestOptions = {}): export async function ladeDokumentHoch(felder: { entitaet_typ: string; entitaet_id: string; + dokumenttyp: string; beschreibung?: string; datei: File; }): Promise { const formData = new FormData(); formData.append("entitaet_typ", felder.entitaet_typ); formData.append("entitaet_id", felder.entitaet_id); + formData.append("dokumenttyp", felder.dokumenttyp); if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung); formData.append("datei", felder.datei); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 45cc64d..d89f7af 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -125,6 +125,14 @@ export interface Mangel { erledigt_am: string | null; } +export type DokumentTyp = + | "pruefprotokoll" + | "wartungsbericht" + | "bedienungsanleitung" + | "rechnung" + | "zulassungsdokument" + | "sonstiges"; + export interface Dokument { id: string; entitaet_typ: string; @@ -132,6 +140,7 @@ export interface Dokument { dateiname: string; mime_type: string; groesse_bytes: number; + dokumenttyp: DokumentTyp; beschreibung: string | null; hochgeladen_von: number; hochgeladen_am: string; diff --git a/frontend/src/components/DokumentePanel.tsx b/frontend/src/components/DokumentePanel.tsx index 8922fb7..b9d792b 100644 --- a/frontend/src/components/DokumentePanel.tsx +++ b/frontend/src/components/DokumentePanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client"; import { useAuth } from "../auth/AuthContext"; -import type { Dokument } from "../api/types"; +import type { Dokument, DokumentTyp } from "../api/types"; interface Props { entitaetTyp: string; @@ -10,6 +10,17 @@ interface Props { onFehler: (text: string) => void; } +const DOKUMENTTYP_LABEL: Record = { + pruefprotokoll: "Prüfprotokoll", + wartungsbericht: "Wartungsbericht", + bedienungsanleitung: "Bedienungsanleitung", + rechnung: "Rechnung", + zulassungsdokument: "Zulassungsdokument", + sonstiges: "Sonstiges", +}; + +const DOKUMENTTYPEN = Object.keys(DOKUMENTTYP_LABEL) as DokumentTyp[]; + function formatGroesse(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; @@ -26,13 +37,16 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { const [dokumente, setDokumente] = useState([]); const [laedt, setLaedt] = useState(true); const [beschreibung, setBeschreibung] = useState(""); + const [dokumenttyp, setDokumenttyp] = useState("sonstiges"); + const [filterTyp, setFilterTyp] = useState(""); const [wirdHochgeladen, setWirdHochgeladen] = useState(false); async function laden() { setLaedt(true); try { + const filter = filterTyp ? `&dokumenttyp=${filterTyp}` : ""; const daten = await apiRequest( - `/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}` + `/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}${filter}` ); setDokumente(daten); } catch { @@ -45,14 +59,14 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { useEffect(() => { laden(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entitaetTyp, entitaetId]); + }, [entitaetTyp, entitaetId, filterTyp]); async function hochladen(e: { target: HTMLInputElement }) { const datei = e.target.files?.[0]; if (!datei) return; setWirdHochgeladen(true); try { - await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, beschreibung, datei }); + await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, dokumenttyp, beschreibung, datei }); setBeschreibung(""); e.target.value = ""; await laden(); @@ -96,6 +110,18 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { return (
+
+ {dokumente.length > 0 || filterTyp ? ( + + ) : null} {laedt &&

Lade…

} {!laedt && dokumente.length === 0 &&

Keine Dokumente.

} {!laedt && dokumente.length > 0 && ( @@ -112,6 +153,9 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { {dokumente.map((d) => (
  • + + {DOKUMENTTYP_LABEL[d.dokumenttyp]} +