From b9cf195a637aa5b8013f7939cb20e8715a600192 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 3 Sep 2026 23:12:49 +0200 Subject: [PATCH] =?UTF-8?q?Sprint=201:=20Stammdaten-Modelle/-Endpunkte,=20?= =?UTF-8?q?Admin-Benutzerverwaltung,=20Zust=C3=A4ndigkeits-Vererbung=20(E2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ORM-Modelle: Bereich, Kategorie, Standort, Objekttyp, Material (Prompt 06/07), minimales Objekt-Modell (voll ausgebaut erst Sprint 2), Zustaendigkeit/ Kontrollverantwortung - Admin-API 7a: GET/POST Stammdaten-Endpunkte, PATCH Material, Benutzerverwaltung (anlegen/Rollen ändern), Zuständigkeits-/Kontrollverantwortungs-CRUD - Vererbungslogik E2 als eigener Service (app/services/zustaendigkeit.py): Standort-Zuordnung vererbt sich auf alle Objekte, Objekt-Zeile ist Vereinigung statt Ersatz - GET /zustaendigkeiten/benutzer/{id}/objekte exponiert das - Tests: Stammdaten-CRUD + Rollenrechte, Benutzerverwaltung (inkl. 409 bei Login-Duplikat), E2-Vererbungslogik (Standort-only, Standort+Objekt-Vereinigung, End-to-End über den Endpunkt) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt --- DEVLOG.md | 12 ++ backend/app/api/v1/api.py | 5 +- backend/app/api/v1/endpoints/benutzer.py | 70 ++++++++ backend/app/api/v1/endpoints/stammdaten.py | 161 ++++++++++++++++++ .../app/api/v1/endpoints/zustaendigkeit.py | 89 ++++++++++ backend/app/models/__init__.py | 21 ++- backend/app/models/objekt.py | 35 ++++ backend/app/models/stammdaten.py | 66 +++++++ backend/app/models/zustaendigkeit.py | 37 ++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/benutzer.py | 35 ++++ backend/app/schemas/stammdaten.py | 76 +++++++++ backend/app/schemas/zustaendigkeit.py | 45 +++++ backend/app/services/__init__.py | 0 backend/app/services/zustaendigkeit.py | 28 +++ backend/tests/conftest.py | 61 +++++++ backend/tests/test_benutzer_admin.py | 56 ++++++ backend/tests/test_stammdaten.py | 62 +++++++ backend/tests/test_zustaendigkeit.py | 80 +++++++++ 19 files changed, 937 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/v1/endpoints/benutzer.py create mode 100644 backend/app/api/v1/endpoints/stammdaten.py create mode 100644 backend/app/api/v1/endpoints/zustaendigkeit.py create mode 100644 backend/app/models/objekt.py create mode 100644 backend/app/models/stammdaten.py create mode 100644 backend/app/models/zustaendigkeit.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/benutzer.py create mode 100644 backend/app/schemas/stammdaten.py create mode 100644 backend/app/schemas/zustaendigkeit.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/zustaendigkeit.py create mode 100644 backend/tests/test_benutzer_admin.py create mode 100644 backend/tests/test_stammdaten.py create mode 100644 backend/tests/test_zustaendigkeit.py diff --git a/DEVLOG.md b/DEVLOG.md index 7d1145a..31e8327 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1119,3 +1119,15 @@ Keine Commits in dieser Session. - deploy/install_server.sh | 21 +++++++++++++++------ --- +## 2026-09-03 23:04 – 23:07 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- 035b22f Migration-Bugfix: DDL statementweise statt als Multi-Statement-Block ausführen + +### Geänderte Dateien +- DEVLOG.md | 12 ++++++++++++ +- backend/alembic/versions/0001_initial_schema.py | 16 ++++++++++++++-- + +--- diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index f5fb485..882114d 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,7 +1,10 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health +from app.api.v1.endpoints import auth, benutzer, health, stammdaten, zustaendigkeit api_router = APIRouter() api_router.include_router(health.router, tags=["health"]) api_router.include_router(auth.router, tags=["auth"]) +api_router.include_router(stammdaten.router, tags=["stammdaten"]) +api_router.include_router(benutzer.router, tags=["benutzer"]) +api_router.include_router(zustaendigkeit.router, tags=["zustaendigkeit"]) diff --git a/backend/app/api/v1/endpoints/benutzer.py b/backend/app/api/v1/endpoints/benutzer.py new file mode 100644 index 0000000..0b1cd2d --- /dev/null +++ b/backend/app/api/v1/endpoints/benutzer.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import require_roles +from app.core.security import hash_password +from app.db.session import get_db +from app.models.auth import Benutzer, BenutzerRolle, RolleTyp +from app.schemas.benutzer import BenutzerCreate, BenutzerRead, BenutzerUpdate + +router = APIRouter() + +_admin_only = require_roles(RolleTyp.administration) + + +@router.get("/benutzer", response_model=list[BenutzerRead]) +async def liste_benutzer(db: AsyncSession = Depends(get_db), _=Depends(_admin_only)) -> list[BenutzerRead]: + result = await db.execute(select(Benutzer)) + return [BenutzerRead.from_orm_benutzer(b) for b in result.scalars().all()] + + +@router.post("/benutzer", response_model=BenutzerRead, status_code=status.HTTP_201_CREATED) +async def erstelle_benutzer( + payload: BenutzerCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> BenutzerRead: + result = await db.execute(select(Benutzer).where(Benutzer.login == payload.login)) + if result.scalar_one_or_none() is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Login bereits vergeben") + + benutzer = Benutzer( + name=payload.name, + login=payload.login, + passwort_hash=hash_password(payload.passwort), + aktiv=True, + ) + db.add(benutzer) + await db.flush() + for rolle in payload.rollen: + db.add(BenutzerRolle(benutzer_id=benutzer.id, rolle=rolle)) + await db.flush() + await db.refresh(benutzer, attribute_names=["rollen"]) + return BenutzerRead.from_orm_benutzer(benutzer) + + +@router.patch("/benutzer/{benutzer_id}", response_model=BenutzerRead) +async def aendere_benutzer( + benutzer_id: int, + payload: BenutzerUpdate, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), +) -> BenutzerRead: + benutzer = await db.get(Benutzer, benutzer_id) + if benutzer is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Benutzer nicht gefunden") + + if payload.name is not None: + benutzer.name = payload.name + if payload.aktiv is not None: + benutzer.aktiv = payload.aktiv + if payload.rollen is not None: + result = await db.execute(select(BenutzerRolle).where(BenutzerRolle.benutzer_id == benutzer_id)) + for bestehende in result.scalars().all(): + await db.delete(bestehende) + await db.flush() + for rolle in payload.rollen: + db.add(BenutzerRolle(benutzer_id=benutzer_id, rolle=rolle)) + + await db.flush() + await db.refresh(benutzer, attribute_names=["rollen"]) + return BenutzerRead.from_orm_benutzer(benutzer) diff --git a/backend/app/api/v1/endpoints/stammdaten.py b/backend/app/api/v1/endpoints/stammdaten.py new file mode 100644 index 0000000..006caa4 --- /dev/null +++ b/backend/app/api/v1/endpoints/stammdaten.py @@ -0,0 +1,161 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +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.stammdaten import Bereich, Kategorie, Material, Objekttyp, Standort +from app.schemas.stammdaten import ( + BereichCreate, + BereichRead, + KategorieCreate, + KategorieRead, + MaterialCreate, + MaterialRead, + MaterialUpdate, + ObjekttypCreate, + ObjekttypRead, + StandortCreate, + StandortRead, +) + +router = APIRouter() + + +# -- Bereich ------------------------------------------------------------------ + +@router.get("/bereiche", response_model=list[BereichRead]) +async def liste_bereiche( + db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[Bereich]: + result = await db.execute(select(Bereich)) + return list(result.scalars().all()) + + +@router.post("/bereiche", response_model=BereichRead, status_code=status.HTTP_201_CREATED) +async def erstelle_bereich( + payload: BereichCreate, + db: AsyncSession = Depends(get_db), + _=Depends(require_roles(RolleTyp.administration)), +) -> Bereich: + bereich = Bereich(**payload.model_dump()) + db.add(bereich) + await db.flush() + return bereich + + +# -- Kategorie ------------------------------------------------------------------ + +@router.get("/kategorien", response_model=list[KategorieRead]) +async def liste_kategorien( + db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[Kategorie]: + result = await db.execute(select(Kategorie)) + return list(result.scalars().all()) + + +@router.post("/kategorien", response_model=KategorieRead, status_code=status.HTTP_201_CREATED) +async def erstelle_kategorie( + payload: KategorieCreate, + db: AsyncSession = Depends(get_db), + _=Depends(require_roles(RolleTyp.administration)), +) -> Kategorie: + kategorie = Kategorie(**payload.model_dump()) + db.add(kategorie) + await db.flush() + return kategorie + + +# -- Standort ------------------------------------------------------------------ + +@router.get("/standorte", response_model=list[StandortRead]) +async def liste_standorte( + db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[Standort]: + result = await db.execute(select(Standort)) + return list(result.scalars().all()) + + +@router.post("/standorte", response_model=StandortRead, status_code=status.HTTP_201_CREATED) +async def erstelle_standort( + payload: StandortCreate, + db: AsyncSession = Depends(get_db), + _=Depends(require_roles(RolleTyp.administration)), +) -> Standort: + standort = Standort(**payload.model_dump()) + db.add(standort) + await db.flush() + return standort + + +# -- Objekttyp ------------------------------------------------------------------ + +@router.get("/objekttypen", response_model=list[ObjekttypRead]) +async def liste_objekttypen( + db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[Objekttyp]: + result = await db.execute(select(Objekttyp)) + return list(result.scalars().all()) + + +@router.post("/objekttypen", response_model=ObjekttypRead, status_code=status.HTTP_201_CREATED) +async def erstelle_objekttyp( + payload: ObjekttypCreate, + db: AsyncSession = Depends(get_db), + _=Depends(require_roles(RolleTyp.administration)), +) -> Objekttyp: + objekttyp = Objekttyp(**payload.model_dump()) + db.add(objekttyp) + await db.flush() + return objekttyp + + +# -- Material (Prompt 07) -------------------------------------------------- + +@router.get("/materialien", response_model=list[MaterialRead]) +async def liste_materialien( + db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[Material]: + result = await db.execute(select(Material)) + return list(result.scalars().all()) + + +@router.get("/materialien/{material_id}", response_model=MaterialRead) +async def hole_material( + material_id: int, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_user), +) -> Material: + material = await db.get(Material, material_id) + if material is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Material nicht gefunden") + return material + + +@router.post("/materialien", response_model=MaterialRead, status_code=status.HTTP_201_CREATED) +async def erstelle_material( + payload: MaterialCreate, + db: AsyncSession = Depends(get_db), + _=Depends(require_roles(RolleTyp.administration)), +) -> Material: + material = Material(**payload.model_dump()) + db.add(material) + await db.flush() + return material + + +@router.patch("/materialien/{material_id}", response_model=MaterialRead) +async def aendere_material( + material_id: int, + payload: MaterialUpdate, + db: AsyncSession = Depends(get_db), + _=Depends(require_roles(RolleTyp.administration)), +) -> Material: + material = await db.get(Material, material_id) + if material is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Material nicht gefunden") + for feld, wert in payload.model_dump(exclude_unset=True).items(): + setattr(material, feld, wert) + await db.flush() + return material diff --git a/backend/app/api/v1/endpoints/zustaendigkeit.py b/backend/app/api/v1/endpoints/zustaendigkeit.py new file mode 100644 index 0000000..80cb3a6 --- /dev/null +++ b/backend/app/api/v1/endpoints/zustaendigkeit.py @@ -0,0 +1,89 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +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 RolleTyp +from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit +from app.schemas.zustaendigkeit import ( + KontrollverantwortungCreate, + KontrollverantwortungRead, + ObjektKurz, + ZustaendigkeitCreate, + ZustaendigkeitRead, +) +from app.services.zustaendigkeit import zustaendige_objekte + +router = APIRouter() + +_admin_only = require_roles(RolleTyp.administration) +_verantwortliche = require_roles( + RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher +) + + +@router.get("/zustaendigkeiten", response_model=list[ZustaendigkeitRead]) +async def liste_zustaendigkeiten( + db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> list[Zustaendigkeit]: + result = await db.execute(select(Zustaendigkeit)) + return list(result.scalars().all()) + + +@router.post( + "/zustaendigkeiten", response_model=ZustaendigkeitRead, status_code=status.HTTP_201_CREATED +) +async def erstelle_zustaendigkeit( + payload: ZustaendigkeitCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> Zustaendigkeit: + zustaendigkeit = Zustaendigkeit(**payload.model_dump()) + db.add(zustaendigkeit) + await db.flush() + return zustaendigkeit + + +@router.delete("/zustaendigkeiten/{zustaendigkeit_id}", status_code=status.HTTP_204_NO_CONTENT) +async def loesche_zustaendigkeit( + zustaendigkeit_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> None: + zustaendigkeit = await db.get(Zustaendigkeit, zustaendigkeit_id) + if zustaendigkeit is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zuständigkeit nicht gefunden") + await db.delete(zustaendigkeit) + + +@router.get("/zustaendigkeiten/benutzer/{benutzer_id}/objekte", response_model=list[ObjektKurz]) +async def objekte_eines_verantwortlichen( + benutzer_id: int, db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> list[ObjektKurz]: + """Vererbungslogik E2: Standort-Zuordnung + Objekt-Zuordnung als Vereinigung.""" + objekte = await zustaendige_objekte(db, benutzer_id) + return [ObjektKurz.model_validate(o) for o in objekte] + + +@router.get("/kontrollverantwortung", response_model=list[KontrollverantwortungRead]) +async def liste_kontrollverantwortung( + db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> list[Kontrollverantwortung]: + result = await db.execute(select(Kontrollverantwortung)) + return list(result.scalars().all()) + + +@router.post( + "/kontrollverantwortung", + response_model=KontrollverantwortungRead, + status_code=status.HTTP_201_CREATED, +) +async def erstelle_kontrollverantwortung( + payload: KontrollverantwortungCreate, + db: AsyncSession = Depends(get_db), + _=Depends(_verantwortliche), +) -> Kontrollverantwortung: + """Karte 01 / E3: reine Sichtbarkeit/Priorisierung, erzwingt keine Pflichtkontrolle.""" + kontrollverantwortung = Kontrollverantwortung(**payload.model_dump()) + db.add(kontrollverantwortung) + await db.flush() + return kontrollverantwortung diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index aded821..61254f2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,22 @@ from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp +from app.models.objekt import Objekt, ObjektStatus +from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort +from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit -__all__ = ["Benutzer", "BenutzerRolle", "RolleTyp", "Systemknoten", "KnotenTyp"] +__all__ = [ + "Benutzer", + "BenutzerRolle", + "RolleTyp", + "Systemknoten", + "KnotenTyp", + "Objekt", + "ObjektStatus", + "Bereich", + "Kategorie", + "Material", + "MaterialTyp", + "Objekttyp", + "Standort", + "Kontrollverantwortung", + "Zustaendigkeit", +] diff --git a/backend/app/models/objekt.py b/backend/app/models/objekt.py new file mode 100644 index 0000000..ccebfd5 --- /dev/null +++ b/backend/app/models/objekt.py @@ -0,0 +1,35 @@ +import enum + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import ENUM as PgEnum +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ObjektStatus(str, enum.Enum): + aktiv = "aktiv" + ausser_dienst = "ausser_dienst" + + +objekt_status_pg = PgEnum(ObjektStatus, name="objekt_status", create_type=False) + + +class Objekt(Base): + """Minimales Modell für Sprint 1 (nur für Zuständigkeits-Vererbung, Karte 04/E2 + benötigt). Volle Objekt-Verwaltung (Vorlagen-Referenz, Duplizieren, Objektpositionen) + folgt Sprint 2 (Prompt 08/09/10) – dann kommt u.a. `vorlage_id` dazu.""" + + __tablename__ = "objekt" + + id: Mapped[int] = mapped_column(primary_key=True) + code: Mapped[str] = mapped_column(String, unique=True, nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), nullable=False) + standort_id: Mapped[int] = mapped_column(ForeignKey("standort.id"), nullable=False) + status: Mapped[ObjektStatus] = mapped_column( + objekt_status_pg, nullable=False, default=ObjektStatus.aktiv + ) + zustaendiger_server_id: Mapped[int] = mapped_column( + ForeignKey("systemknoten.id"), nullable=False + ) diff --git a/backend/app/models/stammdaten.py b/backend/app/models/stammdaten.py new file mode 100644 index 0000000..6a6e666 --- /dev/null +++ b/backend/app/models/stammdaten.py @@ -0,0 +1,66 @@ +import enum + +from sqlalchemy import Boolean, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import ENUM as PgEnum +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class MaterialTyp(str, enum.Enum): + standard = "standard" + ablauf_charge = "ablauf_charge" + geraet_sn = "geraet_sn" + + +materialtyp_pg = PgEnum(MaterialTyp, name="materialtyp", create_type=False) + + +class Bereich(Base): + __tablename__ = "bereich" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String, unique=True, nullable=False) + beschreibung: Mapped[str | None] = mapped_column(Text) + + +class Kategorie(Base): + __tablename__ = "kategorie" + + id: Mapped[int] = mapped_column(primary_key=True) + bereich_id: Mapped[int] = mapped_column(ForeignKey("bereich.id"), nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + ueberkategorie_id: Mapped[int | None] = mapped_column(ForeignKey("kategorie.id")) + + +class Standort(Base): + __tablename__ = "standort" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String, unique=True, nullable=False) + adresse: Mapped[str | None] = mapped_column(Text) + + +class Objekttyp(Base): + __tablename__ = "objekttyp" + + id: Mapped[int] = mapped_column(primary_key=True) + bereich_id: Mapped[int] = mapped_column(ForeignKey("bereich.id"), nullable=False) + kategorie_id: Mapped[int | None] = mapped_column(ForeignKey("kategorie.id")) + name: Mapped[str] = mapped_column(String, nullable=False) + + +class Material(Base): + __tablename__ = "material" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + artikelnummer: Mapped[str | None] = mapped_column(String) + einheit: Mapped[str] = mapped_column(String, nullable=False) + materialtyp: Mapped[MaterialTyp] = mapped_column(materialtyp_pg, nullable=False) + kategorie_id: Mapped[int | None] = mapped_column(ForeignKey("kategorie.id")) + hersteller: Mapped[str | None] = mapped_column(String) + beschreibung: Mapped[str | None] = mapped_column(Text) + code: Mapped[str | None] = mapped_column(String, unique=True) + warnzeitraum_tage: Mapped[int | None] = mapped_column() + aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/backend/app/models/zustaendigkeit.py b/backend/app/models/zustaendigkeit.py new file mode 100644 index 0000000..9542a44 --- /dev/null +++ b/backend/app/models/zustaendigkeit.py @@ -0,0 +1,37 @@ +import uuid + +from sqlalchemy import CheckConstraint, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Zustaendigkeit(Base): + """Karte 04 / Sprintplan E2: Standort-Zuordnung vererbt sich auf alle Objekte + am Standort, Objekt-Zeile ist feinere Ergänzung (Vereinigung, kein Ersatz). + Siehe app/services/zustaendigkeit.py für die Auswertelogik.""" + + __tablename__ = "zustaendigkeit" + __table_args__ = ( + CheckConstraint( + "standort_id IS NOT NULL OR objekt_id IS NOT NULL", + name="zustaendigkeit_standort_oder_objekt", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) + standort_id: Mapped[int | None] = mapped_column(ForeignKey("standort.id")) + objekt_id: Mapped[int | None] = mapped_column(ForeignKey("objekt.id")) + + +class Kontrollverantwortung(Base): + """Karte 01 / Sprintplan E3: reine Sichtbarkeit/Priorisierung, keine Pflichtkontrolle.""" + + __tablename__ = "kontrollverantwortung" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) + benutzer_id: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id")) + gruppe: Mapped[str | None] = mapped_column(String) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/benutzer.py b/backend/app/schemas/benutzer.py new file mode 100644 index 0000000..d238e19 --- /dev/null +++ b/backend/app/schemas/benutzer.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, ConfigDict + +from app.models.auth import RolleTyp + + +class BenutzerCreate(BaseModel): + name: str + login: str + passwort: str + rollen: list[RolleTyp] = [] + + +class BenutzerRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + login: str + aktiv: bool + rollen: list[str] + + @classmethod + def from_orm_benutzer(cls, benutzer) -> "BenutzerRead": + return cls( + id=benutzer.id, + name=benutzer.name, + login=benutzer.login, + aktiv=benutzer.aktiv, + rollen=benutzer.rollen_namen, + ) + + +class BenutzerUpdate(BaseModel): + name: str | None = None + aktiv: bool | None = None + rollen: list[RolleTyp] | None = None diff --git a/backend/app/schemas/stammdaten.py b/backend/app/schemas/stammdaten.py new file mode 100644 index 0000000..3a87a5b --- /dev/null +++ b/backend/app/schemas/stammdaten.py @@ -0,0 +1,76 @@ +from pydantic import BaseModel, ConfigDict + +from app.models.stammdaten import MaterialTyp + + +class BereichCreate(BaseModel): + name: str + beschreibung: str | None = None + + +class BereichRead(BereichCreate): + model_config = ConfigDict(from_attributes=True) + id: int + + +class KategorieCreate(BaseModel): + bereich_id: int + name: str + ueberkategorie_id: int | None = None + + +class KategorieRead(KategorieCreate): + model_config = ConfigDict(from_attributes=True) + id: int + + +class StandortCreate(BaseModel): + name: str + adresse: str | None = None + + +class StandortRead(StandortCreate): + model_config = ConfigDict(from_attributes=True) + id: int + + +class ObjekttypCreate(BaseModel): + bereich_id: int + kategorie_id: int | None = None + name: str + + +class ObjekttypRead(ObjekttypCreate): + model_config = ConfigDict(from_attributes=True) + id: int + + +class MaterialCreate(BaseModel): + name: str + artikelnummer: str | None = None + einheit: str + materialtyp: MaterialTyp + kategorie_id: int | None = None + hersteller: str | None = None + beschreibung: str | None = None + code: str | None = None + warnzeitraum_tage: int | None = None + aktiv: bool = True + + +class MaterialUpdate(BaseModel): + name: str | None = None + artikelnummer: str | None = None + einheit: str | None = None + materialtyp: MaterialTyp | None = None + kategorie_id: int | None = None + hersteller: str | None = None + beschreibung: str | None = None + code: str | None = None + warnzeitraum_tage: int | None = None + aktiv: bool | None = None + + +class MaterialRead(MaterialCreate): + model_config = ConfigDict(from_attributes=True) + id: int diff --git a/backend/app/schemas/zustaendigkeit.py b/backend/app/schemas/zustaendigkeit.py new file mode 100644 index 0000000..a393246 --- /dev/null +++ b/backend/app/schemas/zustaendigkeit.py @@ -0,0 +1,45 @@ +import uuid + +from pydantic import BaseModel, ConfigDict, model_validator + + +class ZustaendigkeitCreate(BaseModel): + benutzer_id: int + standort_id: int | None = None + objekt_id: int | None = None + + @model_validator(mode="after") + def _standort_oder_objekt(self) -> "ZustaendigkeitCreate": + if self.standort_id is None and self.objekt_id is None: + raise ValueError("Entweder standort_id oder objekt_id muss gesetzt sein.") + return self + + +class ZustaendigkeitRead(ZustaendigkeitCreate): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + + +class KontrollverantwortungCreate(BaseModel): + objekt_id: int + benutzer_id: int | None = None + gruppe: str | None = None + + @model_validator(mode="after") + def _person_oder_gruppe(self) -> "KontrollverantwortungCreate": + if self.benutzer_id is None and self.gruppe is None: + raise ValueError("Entweder benutzer_id oder gruppe muss gesetzt sein.") + return self + + +class KontrollverantwortungRead(KontrollverantwortungCreate): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + + +class ObjektKurz(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + code: str + name: str + standort_id: int diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/zustaendigkeit.py b/backend/app/services/zustaendigkeit.py new file mode 100644 index 0000000..6a8a105 --- /dev/null +++ b/backend/app/services/zustaendigkeit.py @@ -0,0 +1,28 @@ +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.objekt import Objekt +from app.models.zustaendigkeit import Zustaendigkeit + + +async def zustaendige_objekte(db: AsyncSession, benutzer_id: int) -> list[Objekt]: + """Karte 04 / Sprintplan E2: ein Benutzer ist für ein Objekt zuständig, wenn + ENTWEDER eine Standort-Zeile für dessen Standort ODER eine Objekt-Zeile für + das Objekt selbst existiert (Vereinigung, keine Überschreibung – eine + objektspezifische Zuordnung ergänzt die Standort-Vererbung, ersetzt sie nicht).""" + standort_ids_subq = ( + select(Zustaendigkeit.standort_id) + .where(Zustaendigkeit.benutzer_id == benutzer_id, Zustaendigkeit.standort_id.is_not(None)) + ) + objekt_ids_subq = ( + select(Zustaendigkeit.objekt_id) + .where(Zustaendigkeit.benutzer_id == benutzer_id, Zustaendigkeit.objekt_id.is_not(None)) + ) + stmt = select(Objekt).where( + or_( + Objekt.standort_id.in_(standort_ids_subq), + Objekt.id.in_(objekt_ids_subq), + ) + ) + result = await db.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1d9f53e..a3fb48c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -8,6 +8,8 @@ from app.core.security import hash_password from app.db.session import get_db from app.main import app from app.models.auth import Benutzer, BenutzerRolle, RolleTyp +from app.models.objekt import Objekt +from app.models.stammdaten import Bereich, Objekttyp, Standort # Erwartet eine bereits per Alembic migrierte Test-Datenbank (CI: install -> migrate -> pytest, # siehe testphasen.md Phase 0). Jeder Test läuft in einer Transaktion, die am Ende zurückgerollt @@ -80,3 +82,62 @@ async def admin_user(db_session): db_session.add(BenutzerRolle(benutzer_id=benutzer.id, rolle=RolleTyp.administration)) await db_session.flush() return benutzer + + +async def login(client, username: str, password: str = "test-passwort-123") -> str: + response = await client.post( + "/api/v1/auth/login", data={"username": username, "password": password} + ) + assert response.status_code == 200, response.text + return response.json()["access_token"] + + +def auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +@pytest_asyncio.fixture +async def hauptserver_id(db_session): + # Seed-Migration 0002 legt genau einen Datensatz mit typ='haupt' an (Sprintplan E6). + from sqlalchemy import select + + from app.models.auth import KnotenTyp, Systemknoten + + result = await db_session.execute( + select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt) + ) + return result.scalar_one() + + +@pytest_asyncio.fixture +async def standort_factory(db_session): + async def _make(name: str) -> Standort: + standort = Standort(name=name) + db_session.add(standort) + await db_session.flush() + return standort + + return _make + + +@pytest_asyncio.fixture +async def objekt_factory(db_session, hauptserver_id): + async def _make(*, name: str, code: str, standort: Standort) -> Objekt: + bereich = Bereich(name=f"Bereich-{code}") + db_session.add(bereich) + await db_session.flush() + objekttyp = Objekttyp(bereich_id=bereich.id, name=f"Typ-{code}") + db_session.add(objekttyp) + await db_session.flush() + objekt = Objekt( + code=code, + name=name, + objekttyp_id=objekttyp.id, + standort_id=standort.id, + zustaendiger_server_id=hauptserver_id, + ) + db_session.add(objekt) + await db_session.flush() + return objekt + + return _make diff --git a/backend/tests/test_benutzer_admin.py b/backend/tests/test_benutzer_admin.py new file mode 100644 index 0000000..f268bca --- /dev/null +++ b/backend/tests/test_benutzer_admin.py @@ -0,0 +1,56 @@ +import pytest + +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_admin_can_create_benutzer_with_rollen(client, admin_user): + token = await login(client, "admin1") + response = await client.post( + "/api/v1/benutzer", + json={ + "name": "Neue Kollegin", + "login": "neu1", + "passwort": "sicheres-passwort", + "rollen": ["mitarbeiter", "materialverantwortlicher"], + }, + headers=auth_header(token), + ) + assert response.status_code == 201 + body = response.json() + assert body["login"] == "neu1" + assert set(body["rollen"]) == {"mitarbeiter", "materialverantwortlicher"} + + +@pytest.mark.asyncio +async def test_duplicate_login_conflict(client, admin_user): + token = await login(client, "admin1") + payload = { + "name": "Doppelt", + "login": "doppelt1", + "passwort": "sicheres-passwort", + "rollen": [], + } + first = await client.post("/api/v1/benutzer", json=payload, headers=auth_header(token)) + assert first.status_code == 201 + second = await client.post("/api/v1/benutzer", json=payload, headers=auth_header(token)) + assert second.status_code == 409 + + +@pytest.mark.asyncio +async def test_mitarbeiter_cannot_manage_benutzer(client, mitarbeiter_user): + token = await login(client, "mitarbeiter1") + response = await client.get("/api/v1/benutzer", headers=auth_header(token)) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_can_update_rollen(client, admin_user, mitarbeiter_user): + token = await login(client, "admin1") + response = await client.patch( + f"/api/v1/benutzer/{mitarbeiter_user.id}", + json={"rollen": ["leitungsverantwortlicher"]}, + headers=auth_header(token), + ) + assert response.status_code == 200 + assert response.json()["rollen"] == ["leitungsverantwortlicher"] diff --git a/backend/tests/test_stammdaten.py b/backend/tests/test_stammdaten.py new file mode 100644 index 0000000..507e44e --- /dev/null +++ b/backend/tests/test_stammdaten.py @@ -0,0 +1,62 @@ +import pytest + +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_admin_can_create_bereich(client, admin_user): + token = await login(client, "admin1") + response = await client.post( + "/api/v1/bereiche", json={"name": "Rettungsdienst"}, headers=auth_header(token) + ) + assert response.status_code == 201 + assert response.json()["name"] == "Rettungsdienst" + + +@pytest.mark.asyncio +async def test_mitarbeiter_cannot_create_bereich(client, mitarbeiter_user): + token = await login(client, "mitarbeiter1") + response = await client.post( + "/api/v1/bereiche", json={"name": "Rettungsdienst"}, headers=auth_header(token) + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_mitarbeiter_can_list_bereiche(client, mitarbeiter_user, admin_user, db_session): + from app.models.stammdaten import Bereich + + db_session.add(Bereich(name="KatS")) + await db_session.flush() + + token = await login(client, "mitarbeiter1") + response = await client.get("/api/v1/bereiche", headers=auth_header(token)) + assert response.status_code == 200 + assert any(b["name"] == "KatS" for b in response.json()) + + +@pytest.mark.asyncio +async def test_material_lifecycle(client, admin_user): + token = await login(client, "admin1") + create_response = await client.post( + "/api/v1/materialien", + json={"name": "Kompresse steril", "einheit": "Stück", "materialtyp": "standard"}, + headers=auth_header(token), + ) + assert create_response.status_code == 201 + material_id = create_response.json()["id"] + + patch_response = await client.patch( + f"/api/v1/materialien/{material_id}", + json={"aktiv": False}, + headers=auth_header(token), + ) + assert patch_response.status_code == 200 + assert patch_response.json()["aktiv"] is False + + +@pytest.mark.asyncio +async def test_material_not_found(client, admin_user): + token = await login(client, "admin1") + response = await client.get("/api/v1/materialien/999999", headers=auth_header(token)) + assert response.status_code == 404 diff --git a/backend/tests/test_zustaendigkeit.py b/backend/tests/test_zustaendigkeit.py new file mode 100644 index 0000000..50c8de4 --- /dev/null +++ b/backend/tests/test_zustaendigkeit.py @@ -0,0 +1,80 @@ +import pytest + +from app.models.zustaendigkeit import Zustaendigkeit +from app.services.zustaendigkeit import zustaendige_objekte +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_standort_zuordnung_vererbt_sich_auf_alle_objekte( + db_session, standort_factory, objekt_factory, mitarbeiter_user +): + """Sprintplan E2: Standort-Zuordnung -> Sicht enthält ALLE Objekte des Standorts.""" + standort_a = await standort_factory("Wache A") + standort_b = await standort_factory("Wache B") + + objekt_a1 = await objekt_factory(name="Rucksack A1", code="A1", standort=standort_a) + objekt_a2 = await objekt_factory(name="Rucksack A2", code="A2", standort=standort_a) + objekt_b1 = await objekt_factory(name="Rucksack B1", code="B1", standort=standort_b) + + db_session.add(Zustaendigkeit(benutzer_id=mitarbeiter_user.id, standort_id=standort_a.id)) + await db_session.flush() + + objekte = await zustaendige_objekte(db_session, mitarbeiter_user.id) + objekt_ids = {o.id for o in objekte} + + assert objekt_a1.id in objekt_ids + assert objekt_a2.id in objekt_ids + assert objekt_b1.id not in objekt_ids + + +@pytest.mark.asyncio +async def test_objekt_zuordnung_ist_vereinigung_nicht_ersatz( + db_session, standort_factory, objekt_factory, mitarbeiter_user +): + """Objektspezifische Zuordnung ergänzt die Standort-Vererbung (Vereinigung).""" + standort_a = await standort_factory("Wache A") + standort_b = await standort_factory("Wache B") + + objekt_a1 = await objekt_factory(name="Rucksack A1", code="A1", standort=standort_a) + objekt_b1 = await objekt_factory(name="Rucksack B1", code="B1", standort=standort_b) + + db_session.add(Zustaendigkeit(benutzer_id=mitarbeiter_user.id, standort_id=standort_a.id)) + db_session.add(Zustaendigkeit(benutzer_id=mitarbeiter_user.id, objekt_id=objekt_b1.id)) + await db_session.flush() + + objekte = await zustaendige_objekte(db_session, mitarbeiter_user.id) + objekt_ids = {o.id for o in objekte} + + assert objekt_ids == {objekt_a1.id, objekt_b1.id} + + +@pytest.mark.asyncio +async def test_endpoint_liefert_vererbte_objekte( + client, db_session, standort_factory, objekt_factory, admin_user, mitarbeiter_user +): + standort_a = await standort_factory("Wache A") + objekt_a1 = await objekt_factory(name="Rucksack A1", code="A1", standort=standort_a) + + db_session.add(Zustaendigkeit(benutzer_id=mitarbeiter_user.id, standort_id=standort_a.id)) + await db_session.flush() + + token = await login(client, "admin1") + response = await client.get( + f"/api/v1/zustaendigkeiten/benutzer/{mitarbeiter_user.id}/objekte", + headers=auth_header(token), + ) + assert response.status_code == 200 + ids = {o["id"] for o in response.json()} + assert objekt_a1.id in ids + + +@pytest.mark.asyncio +async def test_zustaendigkeit_braucht_standort_oder_objekt(client, admin_user, mitarbeiter_user): + token = await login(client, "admin1") + response = await client.post( + "/api/v1/zustaendigkeiten", + json={"benutzer_id": mitarbeiter_user.id}, + headers=auth_header(token), + ) + assert response.status_code == 422