Sprint 1: Stammdaten-Modelle/-Endpunkte, Admin-Benutzerverwaltung, Zuständigkeits-Vererbung (E2)
CI / backend-tests (push) Failing after 0s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-03 23:12:49 +02:00
co-authored by Claude Sonnet 5
parent 035b22fd1e
commit b9cf195a63
19 changed files with 937 additions and 2 deletions
+12
View File
@@ -1119,3 +1119,15 @@ Keine Commits in dieser Session.
- deploy/install_server.sh | 21 +++++++++++++++------ - 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 ++++++++++++++--
---
+4 -1
View File
@@ -1,7 +1,10 @@
from fastapi import APIRouter 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 = APIRouter()
api_router.include_router(health.router, tags=["health"]) api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, tags=["auth"]) 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"])
+70
View File
@@ -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)
+161
View File
@@ -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
@@ -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
+20 -1
View File
@@ -1,3 +1,22 @@
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp 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",
]
+35
View File
@@ -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
)
+66
View File
@@ -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)
+37
View File
@@ -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)
View File
+35
View File
@@ -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
+76
View File
@@ -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
+45
View File
@@ -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
View File
+28
View File
@@ -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())
+61
View File
@@ -8,6 +8,8 @@ from app.core.security import hash_password
from app.db.session import get_db from app.db.session import get_db
from app.main import app from app.main import app
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp 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, # 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 # 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)) db_session.add(BenutzerRolle(benutzer_id=benutzer.id, rolle=RolleTyp.administration))
await db_session.flush() await db_session.flush()
return benutzer 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
+56
View File
@@ -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"]
+62
View File
@@ -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
+80
View File
@@ -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