feat(dokumente): DOC-004 Original-vs-Kopie-Kennzeichnung
Neues Feld ist_original (Boolean, Migration 0029) - explizit beim Upload und beim Ersetzen (DOC-003) setzbar, kein Default in eine Richtung erraten. Wird beim Ersetzen bewusst NICHT vom Vorgänger übernommen (neue Version könnte ein nachgereichtes Original sein, das eine bisherige Kopie ersetzt, oder umgekehrt). Frontend: Checkbox "Original (nicht nur Kopie/Scan)" im Upload-Formular, "Original"-Badge in der Dokumentenliste. 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,27 @@
|
||||
"""DOC-004: Original-vs-Kopie-Kennzeichnung bei Dokumenten.
|
||||
|
||||
Revision ID: 0029_dokument_original
|
||||
Revises: 0028_fk_indizes
|
||||
Create Date: 2026-09-08
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0029_dokument_original"
|
||||
down_revision: Union[str, None] = "0028_fk_indizes"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE dokument ADD COLUMN ist_original BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
# Default nur für den Backfill nötig - künftige Inserts geben es immer
|
||||
# explizit an (Anwendungsebene).
|
||||
op.execute("ALTER TABLE dokument ALTER COLUMN ist_original DROP DEFAULT")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE dokument DROP COLUMN ist_original")
|
||||
@@ -72,6 +72,7 @@ async def lade_dokument_hoch(
|
||||
entitaet_id: str = Form(...),
|
||||
dokumenttyp: DokumentTyp = Form(...),
|
||||
beschreibung: str | None = Form(None),
|
||||
ist_original: bool = Form(False),
|
||||
datei: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(_mitarbeiter_plus),
|
||||
@@ -88,6 +89,7 @@ async def lade_dokument_hoch(
|
||||
dokumenttyp=dokumenttyp,
|
||||
beschreibung=beschreibung,
|
||||
hochgeladen_von=current_user.id,
|
||||
ist_original=ist_original,
|
||||
)
|
||||
except DateityperlaubtError as exc:
|
||||
raise HTTPException(
|
||||
@@ -128,6 +130,7 @@ async def lade_dokument_herunter(
|
||||
async def dokument_ersetzen(
|
||||
dokument_id: uuid.UUID,
|
||||
beschreibung: str | None = Form(None),
|
||||
ist_original: bool = Form(False),
|
||||
datei: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(_mitarbeiter_plus),
|
||||
@@ -148,6 +151,7 @@ async def dokument_ersetzen(
|
||||
inhalt=inhalt,
|
||||
beschreibung=beschreibung,
|
||||
hochgeladen_von=current_user.id,
|
||||
ist_original=ist_original,
|
||||
)
|
||||
except DateityperlaubtError as exc:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -2,7 +2,7 @@ import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -67,3 +67,8 @@ class Dokument(Base):
|
||||
vorgaenger_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("dokument.id"), nullable=True
|
||||
)
|
||||
# DOC-004: rechtlich/organisatorisch relevant bei Zulassungsdokumenten/
|
||||
# Rechnungen - ist das hier das Original (z.B. eingescannt vom Papier-
|
||||
# Original) oder nur eine Kopie/ein Duplikat davon? Explizite Angabe beim
|
||||
# Upload statt Annahme, kein Default in eine Richtung.
|
||||
ist_original: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -27,3 +27,4 @@ class DokumentRead(BaseModel):
|
||||
hochgeladen_von: int
|
||||
hochgeladen_am: datetime
|
||||
vorgaenger_id: uuid.UUID | None
|
||||
ist_original: bool
|
||||
|
||||
@@ -56,6 +56,7 @@ async def _pruefe_und_speichere(
|
||||
beschreibung: str | None,
|
||||
hochgeladen_von: int,
|
||||
vorgaenger_id: uuid.UUID | None,
|
||||
ist_original: bool,
|
||||
) -> Dokument:
|
||||
if mime_type not in ERLAUBTE_MIME_TYPES:
|
||||
raise DateityperlaubtError(mime_type)
|
||||
@@ -94,6 +95,7 @@ async def _pruefe_und_speichere(
|
||||
hochgeladen_von=hochgeladen_von,
|
||||
hochgeladen_am=datetime.now(timezone.utc),
|
||||
vorgaenger_id=vorgaenger_id,
|
||||
ist_original=ist_original,
|
||||
)
|
||||
db.add(dokument)
|
||||
await db.flush()
|
||||
@@ -111,6 +113,7 @@ async def speichere_dokument(
|
||||
dokumenttyp: DokumentTyp,
|
||||
beschreibung: str | None,
|
||||
hochgeladen_von: int,
|
||||
ist_original: bool,
|
||||
) -> Dokument:
|
||||
return await _pruefe_und_speichere(
|
||||
db,
|
||||
@@ -123,6 +126,7 @@ async def speichere_dokument(
|
||||
beschreibung=beschreibung,
|
||||
hochgeladen_von=hochgeladen_von,
|
||||
vorgaenger_id=None,
|
||||
ist_original=ist_original,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,11 +139,14 @@ async def ersetze_dokument(
|
||||
inhalt: bytes,
|
||||
beschreibung: str | None,
|
||||
hochgeladen_von: int,
|
||||
ist_original: bool,
|
||||
) -> Dokument:
|
||||
"""DOC-003: neue Version anlegen statt zu überschreiben - altes_dokument
|
||||
bleibt unverändert als Historieneintrag erhalten, entitaet_typ/entitaet_id/
|
||||
dokumenttyp werden von ihm übernommen (nicht neu wählbar, es ist dieselbe
|
||||
Sache in neuer Fassung)."""
|
||||
Sache in neuer Fassung). ist_original wird bewusst NICHT vom Vorgänger
|
||||
übernommen (DOC-004) - die neue Version könnte z.B. ein nachgereichtes
|
||||
Original sein, das die bisherige Kopie ersetzt."""
|
||||
return await _pruefe_und_speichere(
|
||||
db,
|
||||
entitaet_typ=altes_dokument.entitaet_typ,
|
||||
@@ -151,6 +158,7 @@ async def ersetze_dokument(
|
||||
beschreibung=beschreibung,
|
||||
hochgeladen_von=hochgeladen_von,
|
||||
vorgaenger_id=altes_dokument.id,
|
||||
ist_original=ist_original,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -368,3 +368,44 @@ async def test_versionshistorie_respektiert_zugriffsrechte(
|
||||
mitarbeiter_token = await login(client, "mitarbeiter1")
|
||||
verweigert = await client.get(f"/api/v1/dokumente/{v1_id}/versionen", headers=auth_header(mitarbeiter_token))
|
||||
assert verweigert.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_original_kennzeichnung_default_false_und_explizit_setzbar(
|
||||
client, objekt_mit_position, mitarbeiter_user
|
||||
):
|
||||
"""DOC-004: ohne Angabe gilt eine Datei als Kopie (False), kann aber
|
||||
explizit als Original markiert werden - auch beim Ersetzen unabhängig
|
||||
vom Vorgänger neu wählbar."""
|
||||
objekt, _material = objekt_mit_position
|
||||
token = await login(client, "mitarbeiter1")
|
||||
|
||||
ohne_angabe = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "zulassungsdokument"},
|
||||
files={"datei": ("kopie.pdf", b"%PDF-1.4 kopie", "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert ohne_angabe.json()["ist_original"] is False
|
||||
|
||||
original = await client.post(
|
||||
"/api/v1/dokumente",
|
||||
data={
|
||||
"entitaet_typ": "objekt",
|
||||
"entitaet_id": str(objekt.id),
|
||||
"dokumenttyp": "zulassungsdokument",
|
||||
"ist_original": "true",
|
||||
},
|
||||
files={"datei": ("original.pdf", b"%PDF-1.4 original", "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
original_body = original.json()
|
||||
assert original_body["ist_original"] is True
|
||||
|
||||
ersetzt = await client.post(
|
||||
f"/api/v1/dokumente/{original_body['id']}/ersetzen",
|
||||
data={"ist_original": "false"},
|
||||
files={"datei": ("nachtraeglich_kopie.pdf", b"%PDF-1.4 v2", "application/pdf")},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert ersetzt.json()["ist_original"] is False
|
||||
|
||||
Reference in New Issue
Block a user