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:
@@ -215,7 +215,7 @@ vergeben statt geraten.
|
|||||||
| READY-004 | Readiness | Readiness-Dashboard | P1 | S | LOW | READY-003 | ✅ (`EinsatzbereitschaftTile.tsx`, `/dashboard/einsatzbereitschaft`) |
|
| 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) |
|
| 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-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-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-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) |
|
| DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ⬜ (kein dokumentspezifisches Berechtigungsmodell gefunden, nur allgemeine Rollen) |
|
||||||
|
|||||||
@@ -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.api.deps import get_current_user, require_roles
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.auth import RolleTyp
|
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.schemas.dokument import DokumentRead, EntitaetTyp
|
||||||
from app.services.dokument import (
|
from app.services.dokument import (
|
||||||
DateityperlaubtError,
|
DateityperlaubtError,
|
||||||
@@ -36,16 +36,20 @@ _materialverantwortliche = require_roles(
|
|||||||
async def liste_dokumente(
|
async def liste_dokumente(
|
||||||
entitaet_typ: EntitaetTyp,
|
entitaet_typ: EntitaetTyp,
|
||||||
entitaet_id: str,
|
entitaet_id: str,
|
||||||
|
dokumenttyp: DokumentTyp | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
) -> list[Dokument]:
|
) -> 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)
|
@router.post("/dokumente", response_model=DokumentRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def lade_dokument_hoch(
|
async def lade_dokument_hoch(
|
||||||
entitaet_typ: EntitaetTyp = Form(...),
|
entitaet_typ: EntitaetTyp = Form(...),
|
||||||
entitaet_id: str = Form(...),
|
entitaet_id: str = Form(...),
|
||||||
|
dokumenttyp: DokumentTyp = Form(...),
|
||||||
beschreibung: str | None = Form(None),
|
beschreibung: str | None = Form(None),
|
||||||
datei: UploadFile = File(...),
|
datei: UploadFile = File(...),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -60,6 +64,7 @@ async def lade_dokument_hoch(
|
|||||||
dateiname=datei.filename or "unbenannt",
|
dateiname=datei.filename or "unbenannt",
|
||||||
mime_type=datei.content_type or "application/octet-stream",
|
mime_type=datei.content_type or "application/octet-stream",
|
||||||
inhalt=inhalt,
|
inhalt=inhalt,
|
||||||
|
dokumenttyp=dokumenttyp,
|
||||||
beschreibung=beschreibung,
|
beschreibung=beschreibung,
|
||||||
hochgeladen_von=current_user.id,
|
hochgeladen_von=current_user.id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base
|
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):
|
class Dokument(Base):
|
||||||
"""Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige
|
"""Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige
|
||||||
Ressource (Objekt, Objektposition, Geräteinstanz, Mangel, Fahrzeugdetails,
|
Ressource (Objekt, Objektposition, Geräteinstanz, Mangel, Fahrzeugdetails,
|
||||||
@@ -30,6 +47,9 @@ class Dokument(Base):
|
|||||||
# Nullable wegen Altbestand vor dieser Migration (Hash nachträglich ohne
|
# Nullable wegen Altbestand vor dieser Migration (Hash nachträglich ohne
|
||||||
# Dateizugriff nicht sinnvoll befüllbar); jeder neue Upload setzt ihn.
|
# Dateizugriff nicht sinnvoll befüllbar); jeder neue Upload setzt ihn.
|
||||||
dateihash: Mapped[str | None] = mapped_column(String(64))
|
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)
|
beschreibung: Mapped[str | None] = mapped_column(String)
|
||||||
hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
||||||
hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), 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 pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.dokument import DokumentTyp
|
||||||
|
|
||||||
# Bewusst geschlossene Liste statt Freitext (Konsistenz mit Mangel.entitaet_typ-
|
# Bewusst geschlossene Liste statt Freitext (Konsistenz mit Mangel.entitaet_typ-
|
||||||
# Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen,
|
# Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen,
|
||||||
# muss hier explizit freigeschaltet werden.
|
# muss hier explizit freigeschaltet werden.
|
||||||
@@ -20,6 +22,7 @@ class DokumentRead(BaseModel):
|
|||||||
dateiname: str
|
dateiname: str
|
||||||
mime_type: str
|
mime_type: str
|
||||||
groesse_bytes: int
|
groesse_bytes: int
|
||||||
|
dokumenttyp: DokumentTyp
|
||||||
beschreibung: str | None
|
beschreibung: str | None
|
||||||
hochgeladen_von: int
|
hochgeladen_von: int
|
||||||
hochgeladen_am: datetime
|
hochgeladen_am: datetime
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.app_settings import settings
|
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/
|
# Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/
|
||||||
# Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) -
|
# Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) -
|
||||||
@@ -52,6 +52,7 @@ async def speichere_dokument(
|
|||||||
dateiname: str,
|
dateiname: str,
|
||||||
mime_type: str,
|
mime_type: str,
|
||||||
inhalt: bytes,
|
inhalt: bytes,
|
||||||
|
dokumenttyp: DokumentTyp,
|
||||||
beschreibung: str | None,
|
beschreibung: str | None,
|
||||||
hochgeladen_von: int,
|
hochgeladen_von: int,
|
||||||
) -> Dokument:
|
) -> Dokument:
|
||||||
@@ -87,6 +88,7 @@ async def speichere_dokument(
|
|||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
groesse_bytes=len(inhalt),
|
groesse_bytes=len(inhalt),
|
||||||
dateihash=dateihash,
|
dateihash=dateihash,
|
||||||
|
dokumenttyp=dokumenttyp,
|
||||||
beschreibung=beschreibung,
|
beschreibung=beschreibung,
|
||||||
hochgeladen_von=hochgeladen_von,
|
hochgeladen_von=hochgeladen_von,
|
||||||
hochgeladen_am=datetime.now(timezone.utc),
|
hochgeladen_am=datetime.now(timezone.utc),
|
||||||
@@ -96,12 +98,13 @@ async def speichere_dokument(
|
|||||||
return dokument
|
return dokument
|
||||||
|
|
||||||
|
|
||||||
async def liste_fuer_entitaet(db: AsyncSession, *, entitaet_typ: str, entitaet_id: str) -> list[Dokument]:
|
async def liste_fuer_entitaet(
|
||||||
result = await db.execute(
|
db: AsyncSession, *, entitaet_typ: str, entitaet_id: str, dokumenttyp: DokumentTyp | None = None
|
||||||
select(Dokument)
|
) -> list[Dokument]:
|
||||||
.where(Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id)
|
bedingungen = [Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id]
|
||||||
.order_by(Dokument.hochgeladen_am.desc())
|
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())
|
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(
|
hochgeladen = await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("zulassung.pdf", b"%PDF-1.4 fake", "application/pdf")},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
@@ -31,7 +31,7 @@ async def test_download_liefert_inhalt(client, objekt_mit_position, mitarbeiter_
|
|||||||
token = await login(client, "mitarbeiter1")
|
token = await login(client, "mitarbeiter1")
|
||||||
hochgeladen = await client.post(
|
hochgeladen = await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("foto.png", b"\x89PNG fake", "image/png")},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
@@ -49,7 +49,7 @@ async def test_unerlaubter_dateityp_wird_abgelehnt(client, objekt_mit_position,
|
|||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("boese.html", b"<script>alert(1)</script>", "text/html")},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
@@ -64,7 +64,7 @@ async def test_materialverantwortlicher_kann_dokument_loeschen(
|
|||||||
mitarbeiter_token = await login(client, "mitarbeiter1")
|
mitarbeiter_token = await login(client, "mitarbeiter1")
|
||||||
hochgeladen = await client.post(
|
hochgeladen = await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("bericht.pdf", b"%PDF-1.4 x", "application/pdf")},
|
||||||
headers=auth_header(mitarbeiter_token),
|
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")
|
token = await login(client, "mitarbeiter1")
|
||||||
hochgeladen = await client.post(
|
hochgeladen = await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("x.pdf", b"%PDF-1.4 x", "application/pdf")},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
@@ -105,7 +105,7 @@ async def test_identische_datei_am_gleichen_objekt_wird_abgelehnt(client, objekt
|
|||||||
|
|
||||||
erster = await client.post(
|
erster = await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("original.pdf", inhalt, "application/pdf")},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
@@ -113,7 +113,7 @@ async def test_identische_datei_am_gleichen_objekt_wird_abgelehnt(client, objekt
|
|||||||
|
|
||||||
zweiter = await client.post(
|
zweiter = await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("kopie.pdf", inhalt, "application/pdf")},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
@@ -136,14 +136,62 @@ async def test_identische_datei_an_anderer_entitaet_ist_kein_duplikat(client, ob
|
|||||||
|
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("anleitung.pdf", inhalt, "application/pdf")},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
andere_entitaet = await client.post(
|
andere_entitaet = await client.post(
|
||||||
"/api/v1/dokumente",
|
"/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")},
|
files={"datei": ("anleitung.pdf", inhalt, "application/pdf")},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
assert andere_entitaet.status_code == 201
|
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
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
export async function ladeDokumentHoch(felder: {
|
export async function ladeDokumentHoch(felder: {
|
||||||
entitaet_typ: string;
|
entitaet_typ: string;
|
||||||
entitaet_id: string;
|
entitaet_id: string;
|
||||||
|
dokumenttyp: string;
|
||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
datei: File;
|
datei: File;
|
||||||
}): Promise<unknown> {
|
}): Promise<unknown> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("entitaet_typ", felder.entitaet_typ);
|
formData.append("entitaet_typ", felder.entitaet_typ);
|
||||||
formData.append("entitaet_id", felder.entitaet_id);
|
formData.append("entitaet_id", felder.entitaet_id);
|
||||||
|
formData.append("dokumenttyp", felder.dokumenttyp);
|
||||||
if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung);
|
if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung);
|
||||||
formData.append("datei", felder.datei);
|
formData.append("datei", felder.datei);
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ export interface Mangel {
|
|||||||
erledigt_am: string | null;
|
erledigt_am: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DokumentTyp =
|
||||||
|
| "pruefprotokoll"
|
||||||
|
| "wartungsbericht"
|
||||||
|
| "bedienungsanleitung"
|
||||||
|
| "rechnung"
|
||||||
|
| "zulassungsdokument"
|
||||||
|
| "sonstiges";
|
||||||
|
|
||||||
export interface Dokument {
|
export interface Dokument {
|
||||||
id: string;
|
id: string;
|
||||||
entitaet_typ: string;
|
entitaet_typ: string;
|
||||||
@@ -132,6 +140,7 @@ export interface Dokument {
|
|||||||
dateiname: string;
|
dateiname: string;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
groesse_bytes: number;
|
groesse_bytes: number;
|
||||||
|
dokumenttyp: DokumentTyp;
|
||||||
beschreibung: string | null;
|
beschreibung: string | null;
|
||||||
hochgeladen_von: number;
|
hochgeladen_von: number;
|
||||||
hochgeladen_am: string;
|
hochgeladen_am: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client";
|
import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import type { Dokument } from "../api/types";
|
import type { Dokument, DokumentTyp } from "../api/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entitaetTyp: string;
|
entitaetTyp: string;
|
||||||
@@ -10,6 +10,17 @@ interface Props {
|
|||||||
onFehler: (text: string) => void;
|
onFehler: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOKUMENTTYP_LABEL: Record<DokumentTyp, string> = {
|
||||||
|
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 {
|
function formatGroesse(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
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<Dokument[]>([]);
|
const [dokumente, setDokumente] = useState<Dokument[]>([]);
|
||||||
const [laedt, setLaedt] = useState(true);
|
const [laedt, setLaedt] = useState(true);
|
||||||
const [beschreibung, setBeschreibung] = useState("");
|
const [beschreibung, setBeschreibung] = useState("");
|
||||||
|
const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges");
|
||||||
|
const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">("");
|
||||||
const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
|
const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
|
||||||
|
|
||||||
async function laden() {
|
async function laden() {
|
||||||
setLaedt(true);
|
setLaedt(true);
|
||||||
try {
|
try {
|
||||||
|
const filter = filterTyp ? `&dokumenttyp=${filterTyp}` : "";
|
||||||
const daten = await apiRequest<Dokument[]>(
|
const daten = await apiRequest<Dokument[]>(
|
||||||
`/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}`
|
`/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}${filter}`
|
||||||
);
|
);
|
||||||
setDokumente(daten);
|
setDokumente(daten);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -45,14 +59,14 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
laden();
|
laden();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [entitaetTyp, entitaetId]);
|
}, [entitaetTyp, entitaetId, filterTyp]);
|
||||||
|
|
||||||
async function hochladen(e: { target: HTMLInputElement }) {
|
async function hochladen(e: { target: HTMLInputElement }) {
|
||||||
const datei = e.target.files?.[0];
|
const datei = e.target.files?.[0];
|
||||||
if (!datei) return;
|
if (!datei) return;
|
||||||
setWirdHochgeladen(true);
|
setWirdHochgeladen(true);
|
||||||
try {
|
try {
|
||||||
await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, beschreibung, datei });
|
await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, dokumenttyp, beschreibung, datei });
|
||||||
setBeschreibung("");
|
setBeschreibung("");
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
await laden();
|
await laden();
|
||||||
@@ -96,6 +110,18 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={dokumenttyp}
|
||||||
|
onChange={(e) => setDokumenttyp(e.target.value as DokumentTyp)}
|
||||||
|
style={{ maxWidth: "12rem" }}
|
||||||
|
>
|
||||||
|
{DOKUMENTTYPEN.map((typ) => (
|
||||||
|
<option key={typ} value={typ}>
|
||||||
|
{DOKUMENTTYP_LABEL[typ]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={beschreibung}
|
value={beschreibung}
|
||||||
@@ -105,6 +131,21 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
|
|||||||
/>
|
/>
|
||||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" onChange={hochladen} disabled={wirdHochgeladen} />
|
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" onChange={hochladen} disabled={wirdHochgeladen} />
|
||||||
</div>
|
</div>
|
||||||
|
{dokumente.length > 0 || filterTyp ? (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={filterTyp}
|
||||||
|
onChange={(e) => setFilterTyp(e.target.value as DokumentTyp | "")}
|
||||||
|
style={{ maxWidth: "14rem" }}
|
||||||
|
>
|
||||||
|
<option value="">Alle Typen</option>
|
||||||
|
{DOKUMENTTYPEN.map((typ) => (
|
||||||
|
<option key={typ} value={typ}>
|
||||||
|
{DOKUMENTTYP_LABEL[typ]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
{laedt && <p className="text-muted">Lade…</p>}
|
{laedt && <p className="text-muted">Lade…</p>}
|
||||||
{!laedt && dokumente.length === 0 && <p className="text-muted">Keine Dokumente.</p>}
|
{!laedt && dokumente.length === 0 && <p className="text-muted">Keine Dokumente.</p>}
|
||||||
{!laedt && dokumente.length > 0 && (
|
{!laedt && dokumente.length > 0 && (
|
||||||
@@ -112,6 +153,9 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
|
|||||||
{dokumente.map((d) => (
|
{dokumente.map((d) => (
|
||||||
<li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}>
|
<li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}>
|
||||||
<span>
|
<span>
|
||||||
|
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
|
||||||
|
{DOKUMENTTYP_LABEL[d.dokumenttyp]}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => ansehen(d)}
|
onClick={() => ansehen(d)}
|
||||||
title="In neuem Tab ansehen"
|
title="In neuem Tab ansehen"
|
||||||
|
|||||||
Reference in New Issue
Block a user