diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index 41a7247..98e3d7e 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -205,7 +205,7 @@ vergeben statt geraten. | PERS-001 | Personnel | Person (fachlich) | P1 | M | HIGH | FOUND-002 | ⬜ (laut DECISION-Abschnitt der Datei selbst nicht getrennt, nur `Benutzer`) | | PERS-002 | Personnel | Benutzer (technisch, verknüpft) | P0 | M | HIGH | PERS-001, FOUND-003 | ✅ (`Benutzer`-Modell, `benutzer.py`-Endpunkt) | | PERS-003 | Personnel | Einheiten/Organisationsstruktur | P1 | S | LOW | PERS-001 | ✅ (`Einheit`-Modell, hierarchisch, `einheiten`-Endpunkt) | -| PERS-004 | Personnel | Funktionen/Rollen im Einsatzkontext | P2 | S | LOW | PERS-003 | ⬜ (nur technische Rollen `RolleTyp` gefunden, keine Einsatzfunktionen) | +| PERS-004 | Personnel | Funktionen/Rollen im Einsatzkontext | P2 | S | LOW | PERS-003 | ✅ (`Einsatzfunktion`-Stammdaten, Migration 0031, `benutzer.einsatzfunktion_id`, Admin-UI in BenutzerSection) | | PERS-005 | Personnel | Qualifikationen/Lehrgänge | P1 | S | LOW | PERS-001 | ✅ (`Qualifikationstyp`, `BenutzerQualifikation`) | | PERS-006 | Personnel | Führerscheine/Berechtigungen mit Gültigkeit | P1 | M | MEDIUM | PERS-005, ASSET-002 | ✅ (`Qualifikationskategorie.fuehrerschein`, `ObjekttypQualifikationsanforderung`) | | PERS-007 | Personnel | Qualifikationsablauf-Warnungen | P1 | S | LOW | PERS-006 | ✅ (`QualifikationTile.tsx`, `/dashboard/qualifikationsablaeufe`) | diff --git a/backend/alembic/versions/0031_einsatzfunktion.py b/backend/alembic/versions/0031_einsatzfunktion.py new file mode 100644 index 0000000..58a3b44 --- /dev/null +++ b/backend/alembic/versions/0031_einsatzfunktion.py @@ -0,0 +1,33 @@ +"""PERS-004: Einsatzfunktionen (Gruppenführer/Melder/...), benutzer.einsatzfunktion_id. + +Revision ID: 0031_einsatzfunktion +Revises: 0030_hersteller_modell +Create Date: 2026-09-08 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0031_einsatzfunktion" +down_revision: Union[str, None] = "0030_hersteller_modell" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE einsatzfunktion ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE + ) + """ + ) + op.execute("ALTER TABLE benutzer ADD COLUMN einsatzfunktion_id INTEGER REFERENCES einsatzfunktion(id)") + op.execute("CREATE INDEX ix_benutzer_einsatzfunktion_id ON benutzer (einsatzfunktion_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_benutzer_einsatzfunktion_id") + op.execute("ALTER TABLE benutzer DROP COLUMN einsatzfunktion_id") + op.execute("DROP TABLE einsatzfunktion") diff --git a/backend/app/api/v1/endpoints/benutzer.py b/backend/app/api/v1/endpoints/benutzer.py index ed579b6..08ff78f 100644 --- a/backend/app/api/v1/endpoints/benutzer.py +++ b/backend/app/api/v1/endpoints/benutzer.py @@ -38,6 +38,7 @@ async def erstelle_benutzer( passwort_hash=hash_password(payload.passwort), aktiv=True, einheit_id=payload.einheit_id, + einsatzfunktion_id=payload.einsatzfunktion_id, ) db.add(benutzer) await db.flush() @@ -70,6 +71,8 @@ async def aendere_benutzer( # im Request enthalten" - anders als die übrigen Felder hier, da null # ein gültiger, gewollter Zielzustand ist (Bugfix vor Auslieferung). benutzer.einheit_id = payload.einheit_id + if "einsatzfunktion_id" in payload.model_fields_set: + benutzer.einsatzfunktion_id = payload.einsatzfunktion_id if payload.rollen is not None: result = await db.execute(select(BenutzerRolle).where(BenutzerRolle.benutzer_id == benutzer_id)) for bestehende in result.scalars().all(): diff --git a/backend/app/api/v1/endpoints/personal.py b/backend/app/api/v1/endpoints/personal.py index d748ad6..0e25c7f 100644 --- a/backend/app/api/v1/endpoints/personal.py +++ b/backend/app/api/v1/endpoints/personal.py @@ -9,6 +9,7 @@ from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.personal import ( BenutzerQualifikation, Einheit, + Einsatzfunktion, ObjekttypQualifikationsanforderung, Qualifikationstyp, ) @@ -19,6 +20,8 @@ from app.schemas.personal import ( EinheitCreate, EinheitRead, EinheitUpdate, + EinsatzfunktionCreate, + EinsatzfunktionRead, ObjekttypQualifikationsanforderungCreate, ObjekttypQualifikationsanforderungRead, QualifikationstypCreate, @@ -75,6 +78,31 @@ async def aendere_einheit( return einheit +# -- Einsatzfunktion (PERS-004) ------------------------------------------------ + +@router.get("/einsatzfunktionen", response_model=list[EinsatzfunktionRead]) +async def liste_einsatzfunktionen( + db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[Einsatzfunktion]: + result = await db.execute(select(Einsatzfunktion)) + return list(result.scalars().all()) + + +@router.post("/einsatzfunktionen", response_model=EinsatzfunktionRead, status_code=status.HTTP_201_CREATED) +async def erstelle_einsatzfunktion( + payload: EinsatzfunktionCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> Einsatzfunktion: + # Explizit vorab prüfen statt sich auf den UNIQUE-Constraint zu verlassen + # (PendingRollbackError-Risiko in geteilter Session, siehe geraet_instanz.py). + bestehende = await db.execute(select(Einsatzfunktion).where(Einsatzfunktion.name == payload.name)) + if bestehende.scalar_one_or_none() is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Einsatzfunktion existiert bereits") + einsatzfunktion = Einsatzfunktion(**payload.model_dump()) + db.add(einsatzfunktion) + await db.flush() + return einsatzfunktion + + # -- Qualifikationstyp --------------------------------------------------------- @router.get("/qualifikationstypen", response_model=list[QualifikationstypRead]) diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index 75c5bdb..8f5a409 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -45,6 +45,8 @@ class Benutzer(Base): aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) email: Mapped[str | None] = mapped_column(String) # Sprint 6 / Karte 05 (Migration 0003) einheit_id: Mapped[int | None] = mapped_column(ForeignKey("einheit.id")) # Personal-Modul (Migration 0012) + # PERS-004: primäre Funktion im Einsatzkontext, optional. + einsatzfunktion_id: Mapped[int | None] = mapped_column(ForeignKey("einsatzfunktion.id")) rollen: Mapped[list["BenutzerRolle"]] = relationship(back_populates="benutzer", lazy="selectin") diff --git a/backend/app/models/personal.py b/backend/app/models/personal.py index 8c7e8ed..b40121c 100644 --- a/backend/app/models/personal.py +++ b/backend/app/models/personal.py @@ -74,6 +74,21 @@ class BenutzerQualifikation(Base): ) +class Einsatzfunktion(Base): + """PERS-004: Funktion im Einsatzkontext (z.B. Gruppenführer, Truppführer, + Melder, Maschinist, Atemschutzgeräteträger) - unabhängig von den + technischen App-Rollen (RolleTyp: mitarbeiter/materialverantwortlicher/ + ...), die nur steuern, was jemand in MABEA tun darf, nicht welche Funktion + er im Einsatz hat. Bewusst analog zu Benutzer.einheit_id als einzelnes + optionales Feld modelliert, nicht als M:N - eine Person hat im Regelfall + genau eine primäre Einsatzfunktion, keine Historie/Zeitraum nötig.""" + + __tablename__ = "einsatzfunktion" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String, unique=True, nullable=False) + + class ObjekttypQualifikationsanforderung(Base): """M:N: welche Qualifikationstypen braucht man, um Objekte dieses Objekttyps zu bedienen/fahren (Nutzer-Beispiel: "wer darf GW-San 01 fahren?").""" diff --git a/backend/app/schemas/benutzer.py b/backend/app/schemas/benutzer.py index b19709d..15ed40a 100644 --- a/backend/app/schemas/benutzer.py +++ b/backend/app/schemas/benutzer.py @@ -10,6 +10,7 @@ class BenutzerCreate(BaseModel): email: str | None = None rollen: list[RolleTyp] = [] einheit_id: int | None = None + einsatzfunktion_id: int | None = None class BenutzerRead(BaseModel): @@ -21,6 +22,7 @@ class BenutzerRead(BaseModel): aktiv: bool rollen: list[str] einheit_id: int | None + einsatzfunktion_id: int | None @classmethod def from_orm_benutzer(cls, benutzer) -> "BenutzerRead": @@ -32,6 +34,7 @@ class BenutzerRead(BaseModel): aktiv=benutzer.aktiv, rollen=benutzer.rollen_namen, einheit_id=benutzer.einheit_id, + einsatzfunktion_id=benutzer.einsatzfunktion_id, ) @@ -41,3 +44,4 @@ class BenutzerUpdate(BaseModel): aktiv: bool | None = None rollen: list[RolleTyp] | None = None einheit_id: int | None = None + einsatzfunktion_id: int | None = None diff --git a/backend/app/schemas/personal.py b/backend/app/schemas/personal.py index fb7880b..bd1ae89 100644 --- a/backend/app/schemas/personal.py +++ b/backend/app/schemas/personal.py @@ -26,6 +26,15 @@ class EinheitUpdate(BaseModel): standort_id: int | None = None +class EinsatzfunktionCreate(BaseModel): + name: str + + +class EinsatzfunktionRead(EinsatzfunktionCreate): + model_config = ConfigDict(from_attributes=True) + id: int + + class QualifikationstypCreate(BaseModel): name: str kategorie: Qualifikationskategorie diff --git a/backend/tests/test_personal.py b/backend/tests/test_personal.py index b33900a..166287c 100644 --- a/backend/tests/test_personal.py +++ b/backend/tests/test_personal.py @@ -139,6 +139,51 @@ async def test_benutzer_einheit_zuordnung(client, admin_user): assert geloest.json()["einheit_id"] is None +@pytest.mark.asyncio +async def test_einsatzfunktion_anlegen_und_benutzer_zuordnen(client, admin_user): + """PERS-004: Einsatzfunktion unabhängig von der technischen App-Rolle.""" + token = await login(client, "admin1") + + funktion = await client.post( + "/api/v1/einsatzfunktionen", json={"name": "Gruppenführer"}, headers=auth_header(token) + ) + assert funktion.status_code == 201 + funktion_id = funktion.json()["id"] + + duplikat = await client.post( + "/api/v1/einsatzfunktionen", json={"name": "Gruppenführer"}, headers=auth_header(token) + ) + assert duplikat.status_code == 409 + + benutzer = await client.post( + "/api/v1/benutzer", + json={ + "name": "Neuer Gruppenführer", + "login": "gf1", + "passwort": "test-passwort-123", + "einsatzfunktion_id": funktion_id, + }, + headers=auth_header(token), + ) + assert benutzer.status_code == 201 + assert benutzer.json()["einsatzfunktion_id"] == funktion_id + + benutzer_id = benutzer.json()["id"] + geloest = await client.patch( + f"/api/v1/benutzer/{benutzer_id}", json={"einsatzfunktion_id": None}, headers=auth_header(token) + ) + assert geloest.json()["einsatzfunktion_id"] is None + + +@pytest.mark.asyncio +async def test_mitarbeiter_darf_einsatzfunktion_nicht_anlegen(client, mitarbeiter_user): + token = await login(client, "mitarbeiter1") + response = await client.post( + "/api/v1/einsatzfunktionen", json={"name": "Melder"}, headers=auth_header(token) + ) + assert response.status_code == 403 + + @pytest.mark.asyncio async def test_bald_ablaufende_qualifikation_erscheint_im_dashboard( client, admin_user, mitarbeiter_user diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a09b6ec..66d20b4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -392,6 +392,12 @@ export interface Benutzer { aktiv: boolean; rollen: string[]; einheit_id: number | null; + einsatzfunktion_id: number | null; +} + +export interface Einsatzfunktion { + id: number; + name: string; } export interface Berechtigung { diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index ce943f9..e83f936 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from "react-router-dom"; import { apiRequest } from "../api/client"; import { AusgabeSection } from "./admin/AusgabeSection"; import { useAuth } from "../auth/AuthContext"; -import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types"; +import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Einsatzfunktion, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types"; import { BenutzerSection } from "./admin/BenutzerSection"; import { BereichSection } from "./admin/BereichSection"; import { EinheitSection } from "./admin/EinheitSection"; @@ -108,6 +108,7 @@ export function AdminPage() { const [benutzer, setBenutzer] = useState([]); const [faecher, setFaecher] = useState([]); const [einheiten, setEinheiten] = useState([]); + const [einsatzfunktionen, setEinsatzfunktionen] = useState([]); const [qualifikationstypen, setQualifikationstypen] = useState([]); const [fehler, setFehler] = useState(null); const [laedt, setLaedt] = useState(true); @@ -127,7 +128,7 @@ export function AdminPage() { // Seite als "nicht geladen" zeigen, obwohl deren Tabs (z.B. Kontroll- // verantwortung) trotzdem funktionieren sollen. allSettled + leerer // Fallback statt hartem Fehler. - const [be, ka, ot, s, v, o, m, b, f, ei, qt] = await Promise.allSettled([ + const [be, ka, ot, s, v, o, m, b, f, ei, qt, ef] = await Promise.allSettled([ apiRequest("/bereiche"), apiRequest("/kategorien"), apiRequest("/objekttypen"), @@ -139,6 +140,7 @@ export function AdminPage() { apiRequest("/faecher"), apiRequest("/einheiten"), apiRequest("/qualifikationstypen"), + apiRequest("/einsatzfunktionen"), ]); const wert = (r: PromiseSettledResult): T[] => (r.status === "fulfilled" ? r.value : []); setBereiche(wert(be)); @@ -152,6 +154,7 @@ export function AdminPage() { setFaecher(wert(f)); setEinheiten(wert(ei)); setQualifikationstypen(wert(qt)); + setEinsatzfunktionen(wert(ef)); } useEffect(() => { @@ -238,7 +241,13 @@ export function AdminPage() { )} {aktiverTab === "benutzer" && ( - + )} {aktiverTab === "zustaendigkeit" && ( diff --git a/frontend/src/pages/admin/BenutzerSection.tsx b/frontend/src/pages/admin/BenutzerSection.tsx index 45d492d..11f5e6b 100644 --- a/frontend/src/pages/admin/BenutzerSection.tsx +++ b/frontend/src/pages/admin/BenutzerSection.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { apiRequest } from "../../api/client"; -import type { Benutzer, Einheit, Rolle } from "../../api/types"; +import type { Benutzer, Einheit, Einsatzfunktion, Rolle } from "../../api/types"; interface Props { benutzer: Benutzer[]; einheiten: Einheit[]; + einsatzfunktionen: Einsatzfunktion[]; onGeaendert: () => Promise; onFehler: (text: string) => void; } @@ -17,18 +18,21 @@ const ALLE_ROLLEN: Rolle[] = [ "administration", ]; -export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: Props) { +export function BenutzerSection({ benutzer, einheiten, einsatzfunktionen, onGeaendert, onFehler }: Props) { const [name, setName] = useState(""); const [login, setLogin] = useState(""); const [passwort, setPasswort] = useState(""); const [email, setEmail] = useState(""); const [einheitId, setEinheitId] = useState(""); + const [einsatzfunktionId, setEinsatzfunktionId] = useState(""); + const [neueEinsatzfunktion, setNeueEinsatzfunktion] = useState(""); const [rollen, setRollen] = useState(["mitarbeiter"]); const [wirdAngelegt, setWirdAngelegt] = useState(false); const [bearbeiteteId, setBearbeiteteId] = useState(null); const [bearbeitungsName, setBearbeitungsName] = useState(""); const [bearbeitungsEmail, setBearbeitungsEmail] = useState(""); const [bearbeitungsEinheitId, setBearbeitungsEinheitId] = useState(""); + const [bearbeitungsEinsatzfunktionId, setBearbeitungsEinsatzfunktionId] = useState(""); const [bearbeitungsRollen, setBearbeitungsRollen] = useState([]); function einheitName(id: number | null): string { @@ -37,6 +41,12 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: return e ? e.name : `#${id}`; } + function einsatzfunktionName(id: number | null): string { + if (id === null) return ""; + const f = einsatzfunktionen.find((x) => x.id === id); + return f ? f.name : `#${id}`; + } + function rolleUmschalten(rolle: Rolle) { setRollen((bisherige) => bisherige.includes(rolle) ? bisherige.filter((r) => r !== rolle) : [...bisherige, rolle] @@ -48,13 +58,22 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: try { await apiRequest("/benutzer", { method: "POST", - body: { name, login, passwort, email: email || null, einheit_id: einheitId === "" ? null : einheitId, rollen }, + body: { + name, + login, + passwort, + email: email || null, + einheit_id: einheitId === "" ? null : einheitId, + einsatzfunktion_id: einsatzfunktionId === "" ? null : einsatzfunktionId, + rollen, + }, }); setName(""); setLogin(""); setPasswort(""); setEmail(""); setEinheitId(""); + setEinsatzfunktionId(""); setRollen(["mitarbeiter"]); await onGeaendert(); } catch { @@ -64,6 +83,17 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: } } + async function einsatzfunktionAnlegen() { + if (!neueEinsatzfunktion.trim()) return; + try { + await apiRequest("/einsatzfunktionen", { method: "POST", body: { name: neueEinsatzfunktion.trim() } }); + setNeueEinsatzfunktion(""); + await onGeaendert(); + } catch { + onFehler("Einsatzfunktion konnte nicht angelegt werden (existiert evtl. schon)."); + } + } + async function aktivSchalten(b: Benutzer) { try { await apiRequest(`/benutzer/${b.id}`, { method: "PATCH", body: { aktiv: !b.aktiv } }); @@ -78,6 +108,7 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: setBearbeitungsName(b.name); setBearbeitungsEmail(b.email ?? ""); setBearbeitungsEinheitId(b.einheit_id ?? ""); + setBearbeitungsEinsatzfunktionId(b.einsatzfunktion_id ?? ""); setBearbeitungsRollen(b.rollen as Rolle[]); } @@ -95,6 +126,7 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: name: bearbeitungsName, email: bearbeitungsEmail || null, einheit_id: bearbeitungsEinheitId === "" ? null : bearbeitungsEinheitId, + einsatzfunktion_id: bearbeitungsEinsatzfunktionId === "" ? null : bearbeitungsEinsatzfunktionId, rollen: bearbeitungsRollen, }, }); @@ -133,6 +165,28 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: ))} + + setNeueEinsatzfunktion(e.target.value)} + placeholder="Neue Einsatzfunktion" + style={{ maxWidth: "10rem" }} + /> +
{ALLE_ROLLEN.map((r) => ( @@ -181,6 +235,18 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: ))} +
{ALLE_ROLLEN.map((r) => ( @@ -208,6 +274,7 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: {b.name} ({b.login}) – {b.rollen.join(", ")} {b.einheit_id !== null && ` – ${einheitName(b.einheit_id)}`} + {b.einsatzfunktion_id !== null && ` – ${einsatzfunktionName(b.einsatzfunktion_id)}`} {!b.aktiv && " – deaktiviert"}