feat(akte): FILE-009 Freitext-Notizen an beliebiger Ressource
CI / backend-tests (push) Successful in 2m39s
CI / frontend-build (push) Successful in 20s

Polymorphes akte_notiz-Modell (gleiches entitaet_typ/entitaet_id-Muster wie
Dokument/Historie/Mangel), CRUD-Endpunkte, NotizenPanel im Akte-Grid. Löschen
nur durch Autor oder Administrator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-10 00:49:47 +02:00
co-authored by Claude Sonnet 5
parent 9c6426190e
commit 1e8d66f636
13 changed files with 366 additions and 0 deletions
@@ -0,0 +1,34 @@
"""FILE-009: Freitext-Notizen in der Akte.
Revision ID: 0035_akte_notiz
Revises: 0034_person
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0035_akte_notiz"
down_revision: Union[str, None] = "0034_person"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE akte_notiz (
id UUID PRIMARY KEY,
entitaet_typ TEXT NOT NULL,
entitaet_id TEXT NOT NULL,
text TEXT NOT NULL,
autor_id INTEGER NOT NULL REFERENCES benutzer(id),
erstellt_am TIMESTAMPTZ NOT NULL
)
"""
)
op.execute("CREATE INDEX ix_akte_notiz_entitaet ON akte_notiz (entitaet_typ, entitaet_id)")
def downgrade() -> None:
op.execute("DROP TABLE akte_notiz")
+2
View File
@@ -16,6 +16,7 @@ from app.api.v1.endpoints import (
lager,
lagerbewegung,
mangel,
notiz,
objekte,
permission,
personal,
@@ -44,6 +45,7 @@ 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"])
api_router.include_router(notiz.router, tags=["notiz"])
api_router.include_router(permission.router, tags=["permission"])
api_router.include_router(akte.router, tags=["akte"])
api_router.include_router(lager.router, tags=["lager"])
+63
View File
@@ -0,0 +1,63 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
from app.db.session import get_db
from app.models.auth import Benutzer, RolleTyp
from app.models.notiz import AkteNotiz
from app.schemas.dokument import EntitaetTyp
from app.schemas.notiz import AkteNotizCreate, AkteNotizRead
from app.services.notiz import erstelle_notiz, liste_fuer_entitaet, loesche_notiz
router = APIRouter()
_mitarbeiter_plus = require_roles(
RolleTyp.mitarbeiter,
RolleTyp.materialverantwortlicher,
RolleTyp.leitungsverantwortlicher,
RolleTyp.administration,
)
@router.get("/notizen", response_model=list[AkteNotizRead])
async def liste_notizen(
entitaet_typ: EntitaetTyp,
entitaet_id: str,
db: AsyncSession = Depends(get_db),
_=Depends(_mitarbeiter_plus),
) -> list[AkteNotiz]:
return await liste_fuer_entitaet(db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id)
@router.post("/notizen", response_model=AkteNotizRead, status_code=status.HTTP_201_CREATED)
async def notiz_anlegen(
payload: AkteNotizCreate,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> AkteNotiz:
return await erstelle_notiz(
db,
entitaet_typ=payload.entitaet_typ,
entitaet_id=payload.entitaet_id,
text=payload.text,
autor_id=current_user.id,
)
@router.delete("/notizen/{notiz_id}", status_code=status.HTTP_204_NO_CONTENT)
async def notiz_loeschen(
notiz_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> None:
notiz = await db.get(AkteNotiz, notiz_id)
if notiz is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notiz nicht gefunden")
ist_administrator = RolleTyp.administration.value in current_user.rollen_namen
if notiz.autor_id != current_user.id and not ist_administrator:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Nur eigene Notizen oder als Administrator löschbar"
)
await loesche_notiz(db, notiz=notiz)
+2
View File
@@ -12,6 +12,7 @@ from app.models.lagerbewegung import Lagerbewegung
from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.nachfuellung import Nachfuellung
from app.models.notiz import AkteNotiz
from app.models.objekt import Objekt, ObjektStatus
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung
@@ -65,6 +66,7 @@ __all__ = [
"MindermengeStatus",
"MindermengenGenehmigung",
"Nachfuellung",
"AkteNotiz",
"Objekt",
"ObjektStatus",
"Objektposition",
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, String
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AkteNotiz(Base):
"""FILE-009: formlose Freitext-Notiz an beliebiger Ressource - gleiches
entitaet_typ/entitaet_id-Muster wie Dokument/Historie/Mangel."""
__tablename__ = "akte_notiz"
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)
text: Mapped[str] = mapped_column(String, nullable=False)
autor_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.schemas.dokument import EntitaetTyp
class AkteNotizCreate(BaseModel):
entitaet_typ: EntitaetTyp
entitaet_id: str
text: str
class AkteNotizRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
entitaet_typ: str
entitaet_id: str
text: str
autor_id: int
erstellt_am: datetime
+38
View File
@@ -0,0 +1,38 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notiz import AkteNotiz
async def erstelle_notiz(
db: AsyncSession, *, entitaet_typ: str, entitaet_id: str, text: str, autor_id: int
) -> AkteNotiz:
notiz = AkteNotiz(
id=uuid.uuid4(),
entitaet_typ=entitaet_typ,
entitaet_id=entitaet_id,
text=text,
autor_id=autor_id,
erstellt_am=datetime.now(timezone.utc),
)
db.add(notiz)
await db.commit()
await db.refresh(notiz)
return notiz
async def liste_fuer_entitaet(db: AsyncSession, *, entitaet_typ: str, entitaet_id: str) -> list[AkteNotiz]:
ergebnis = await db.execute(
select(AkteNotiz)
.where(AkteNotiz.entitaet_typ == entitaet_typ, AkteNotiz.entitaet_id == entitaet_id)
.order_by(AkteNotiz.erstellt_am.desc())
)
return list(ergebnis.scalars().all())
async def loesche_notiz(db: AsyncSession, *, notiz: AkteNotiz) -> None:
await db.delete(notiz)
await db.commit()
+60
View File
@@ -0,0 +1,60 @@
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_notiz_anlegen_und_lesen(client, objekt_mit_position, mitarbeiter_user):
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
angelegt = await client.post(
"/api/v1/notizen",
json={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "text": "Funkgerät klemmt manchmal"},
headers=auth_header(token),
)
assert angelegt.status_code == 201
zweite = await client.post(
"/api/v1/notizen",
json={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "text": "Rücksprache Hersteller 12.03."},
headers=auth_header(token),
)
assert zweite.status_code == 201
liste = await client.get(
f"/api/v1/notizen?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token)
)
assert liste.status_code == 200
daten = liste.json()
assert len(daten) == 2
# neueste zuerst
assert daten[0]["text"] == "Rücksprache Hersteller 12.03."
@pytest.mark.asyncio
async def test_fremde_notiz_nicht_loeschbar_ausser_admin(client, objekt_mit_position, mitarbeiter_user, admin_user):
objekt, _material = objekt_mit_position
token_mitarbeiter = await login(client, "mitarbeiter1")
angelegt = await client.post(
"/api/v1/notizen",
json={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "text": "Testnotiz"},
headers=auth_header(token_mitarbeiter),
)
notiz_id = angelegt.json()["id"]
token_admin = await login(client, "admin1")
abgelehnt = await client.delete(f"/api/v1/notizen/{notiz_id}", headers=auth_header(token_mitarbeiter))
# eigene Notiz darf der Autor selbst löschen
assert abgelehnt.status_code == 204
zweite = await client.post(
"/api/v1/notizen",
json={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "text": "Zweite Notiz"},
headers=auth_header(token_mitarbeiter),
)
zweite_id = zweite.json()["id"]
erlaubt = await client.delete(f"/api/v1/notizen/{zweite_id}", headers=auth_header(token_admin))
assert erlaubt.status_code == 204
+9
View File
@@ -157,6 +157,15 @@ export interface Dokument {
ist_original: boolean;
}
export interface AkteNotiz {
id: string;
entitaet_typ: string;
entitaet_id: string;
text: string;
autor_id: number;
erstellt_am: string;
}
export interface Lagerbewegung {
id: string;
objekt_id: number;
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { ApiError, apiRequest } from "../api/client";
import type { AkteNotiz } from "../api/types";
interface Props {
entitaetTyp: string;
entitaetId: string;
onFehler: (text: string) => void;
}
function formatZeitpunkt(iso: string): string {
return new Date(iso).toLocaleString("de-DE");
}
// FILE-009: formlose Freitext-Notizen, gleiche entitaet_typ/entitaet_id-
// Adressierung wie DokumentePanel.
export function NotizenPanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const [notizen, setNotizen] = useState<AkteNotiz[]>([]);
const [laedt, setLaedt] = useState(true);
const [text, setText] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
async function laden() {
setLaedt(true);
try {
const daten = await apiRequest<AkteNotiz[]>(
`/notizen?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}`
);
setNotizen(daten);
} catch {
onFehler("Notizen konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entitaetTyp, entitaetId]);
async function anlegen() {
if (!text.trim()) return;
setWirdAngelegt(true);
try {
await apiRequest("/notizen", {
method: "POST",
body: { entitaet_typ: entitaetTyp, entitaet_id: entitaetId, text: text.trim() },
});
setText("");
await laden();
} catch {
onFehler("Notiz konnte nicht angelegt werden.");
} finally {
setWirdAngelegt(false);
}
}
async function loeschen(notiz: AkteNotiz) {
try {
await apiRequest(`/notizen/${notiz.id}`, { method: "DELETE" });
await laden();
} catch (fehler) {
if (fehler instanceof ApiError && fehler.status === 403) {
onFehler("Nur eigene Notizen oder als Administrator löschbar.");
} else {
onFehler("Notiz konnte nicht gelöscht werden.");
}
}
}
return (
<div className="stack">
<div className="row" style={{ flexWrap: "wrap", gap: "0.4rem" }}>
<input
className="input"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Notiz hinzufügen…"
style={{ flex: 1, minWidth: "12rem" }}
/>
<button className="btn btn-primary" onClick={anlegen} disabled={!text.trim() || wirdAngelegt}>
Hinzufügen
</button>
</div>
{laedt && <p className="text-muted">Lade</p>}
{!laedt && notizen.length === 0 && <p className="text-muted">Keine Notizen.</p>}
{!laedt && notizen.length > 0 && (
<ul className="card-list">
{notizen.map((n) => (
<li key={n.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{n.text} <span className="text-muted">({formatZeitpunkt(n.erstellt_am)})</span>
</span>
<button className="btn btn-secondary" onClick={() => loeschen(n)}>
Löschen
</button>
</li>
))}
</ul>
)}
</div>
);
}
+6
View File
@@ -5,6 +5,7 @@ import { apiRequest, ladeObjektEtikett } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Akte } from "../api/types";
import { DokumentePanel } from "../components/DokumentePanel";
import { NotizenPanel } from "../components/NotizenPanel";
import { WartungSection } from "../components/WartungSection";
import { ReadinessBadge } from "../components/status/ReadinessBadge";
import { GRUND_TEXT } from "../components/status/gruendeText";
@@ -283,6 +284,11 @@ export function AktePage() {
<DokumentePanel entitaetTyp="objekt" entitaetId={String(objekt.id)} onFehler={setFehler} />
</div>
<div className="akte-notizen card">
<h3 style={{ marginTop: 0 }}>Notizen</h3>
<NotizenPanel entitaetTyp="objekt" entitaetId={String(objekt.id)} onFehler={setFehler} />
</div>
<div className="akte-historie card">
<h3 style={{ marginTop: 0 }}>Historie</h3>
{akte.historie.length === 0 && <p className="text-muted">Noch keine Änderungen protokolliert.</p>}
+2
View File
@@ -12,6 +12,7 @@
"verantwortlich"
"maengel"
"dokumente"
"notizen"
"historie";
}
@@ -24,4 +25,5 @@
.akte-verantwortlich { grid-area: verantwortlich; }
.akte-maengel { grid-area: maengel; }
.akte-dokumente { grid-area: dokumente; }
.akte-notizen { grid-area: notizen; }
.akte-historie { grid-area: historie; }
@@ -19,6 +19,7 @@
"pruefungen beladung"
"maengel verantwortlich"
"dokumente dokumente"
"notizen notizen"
"historie historie";
}
}