fix(zustaendigkeit): ZUST-004 Runde 7 - Lagerort-Lücken aus Runde 5 geschlossen
GET /lagerorte war entgegen /lagerplaetze nicht standortgefiltert - Nutzer- Review deckte das auf. Zusätzlich: standort_id konnte bisher an jedem Lagerort-Typ gesetzt werden, nur dokumentiert als "nur bei typ=standort sinnvoll". Jetzt harte 409-Validierung bei POST und PATCH (PATCH prüft die resultierende Typ/standort_id-Kombination, nicht nur einzeln übergebene Felder). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
This commit is contained in:
1 parent
b68314a06d
commit
260e0f6133
4 files changed
+192
-4
No files matched your search
@@ -16183,3 +16183,83 @@ Keine Commits in dieser Session.
|
||||
- frontend/src/pages/admin/LagerSection.tsx | 41 ++++++++++++++++++++++++++++---
|
||||
|
||||
---
|
||||
## 2026-09-14 23:53 – 23:57 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
- b68314a feat(zustaendigkeit): ZUST-004 Runde 6 - kennzahlen/ablaufdaten/wartungen
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- arbeitskacheln/17_zustaendigkeit.md | 18 ++++++++----
|
||||
- backend/app/api/v1/endpoints/dashboard.py | 30 +++++++++++++------
|
||||
- backend/app/services/dashboard.py | 59 ++++++++++++++++++++++++++++---------
|
||||
- backend/app/services/wartung.py | 12 ++++++--
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 35 ++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-15 00:01 – 00:01 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- arbeitskacheln/17_zustaendigkeit.md | 18 ++++++++----
|
||||
- backend/app/api/v1/endpoints/dashboard.py | 30 +++++++++++++------
|
||||
- backend/app/services/dashboard.py | 59 ++++++++++++++++++++++++++++---------
|
||||
- backend/app/services/wartung.py | 12 ++++++--
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 35 ++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-15 00:06 – 00:06 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- arbeitskacheln/17_zustaendigkeit.md | 18 ++++++++----
|
||||
- backend/app/api/v1/endpoints/dashboard.py | 30 +++++++++++++------
|
||||
- backend/app/services/dashboard.py | 59 ++++++++++++++++++++++++++++---------
|
||||
- backend/app/services/wartung.py | 12 ++++++--
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 35 ++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-15 00:07 – 00:07 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- arbeitskacheln/17_zustaendigkeit.md | 18 ++++++++----
|
||||
- backend/app/api/v1/endpoints/dashboard.py | 30 +++++++++++++------
|
||||
- backend/app/services/dashboard.py | 59 ++++++++++++++++++++++++++++---------
|
||||
- backend/app/services/wartung.py | 12 ++++++--
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 35 ++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-15 00:22 – 00:22 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- arbeitskacheln/17_zustaendigkeit.md | 18 ++++++++----
|
||||
- backend/app/api/v1/endpoints/dashboard.py | 30 +++++++++++++------
|
||||
- backend/app/services/dashboard.py | 59 ++++++++++++++++++++++++++++---------
|
||||
- backend/app/services/wartung.py | 12 ++++++--
|
||||
- backend/tests/test_zust004_standort_sichtbarkeit.py | 35 ++++++++++++++++++++++
|
||||
|
||||
---
|
||||
@@ -244,6 +244,19 @@ war die Vorgängerlücke, diese hier ist die zweite gefundene Lücke).
|
||||
Objekt-bezogenen Endpunkte durchgezogen. Test:
|
||||
`test_dashboard_kennzahlen_ablaufdaten_wartungen_standort_eingeschraenkt`
|
||||
in `backend/tests/test_zust004_standort_sichtbarkeit.py`.
|
||||
**Runde 7 (2026-09-15, Nutzer-Review "Lager/Lagerorte nochmal
|
||||
durchgehen"): zwei Lücken in Runde 5 gefunden und geschlossen.**
|
||||
(1) `GET /lagerorte` selbst war NICHT gefiltert - nur `/lagerplaetze`
|
||||
war es, ein auf Standort A beschränkter Nutzer sah trotzdem den
|
||||
kompletten Lagerort-Baum. Jetzt gleiches Filter-Muster wie bei
|
||||
Lagerplatz. (2) `standort_id` konnte an jedem Lagerort-Typ gesetzt werden
|
||||
(nur dokumentiert, nicht erzwungen) - Nutzer-Entscheidung: jetzt harte
|
||||
409-Validierung, `standort_id` nur an `typ=standort` erlaubt (POST UND
|
||||
PATCH, PATCH prüft die resultierende Typ/standort_id-Kombination, nicht
|
||||
nur die einzeln übergebenen Felder). Tests:
|
||||
`test_lagerorte_liste_standort_eingeschraenkt`,
|
||||
`test_lagerort_standort_id_nur_bei_typ_standort`
|
||||
in `backend/tests/test_zust004_standort_sichtbarkeit.py`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ 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.lager import Bestand, Lagerort, Lagerplatz, Materialbewegung
|
||||
from app.models.lager import Bestand, Lagerort, LagerortTyp, Lagerplatz, Materialbewegung
|
||||
from app.schemas.lager import (
|
||||
BestandRead,
|
||||
EinAuslagernRequest,
|
||||
@@ -47,9 +47,36 @@ async def _get_or_404(db: AsyncSession, model: type[_T], id_: int, meldung: str)
|
||||
# -- Lagerort (WH-001) ---------------------------------------------------------
|
||||
|
||||
@router.get("/lagerorte", response_model=list[LagerortRead])
|
||||
async def liste_lagerorte(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)) -> list[Lagerort]:
|
||||
result = await db.execute(select(Lagerort))
|
||||
return list(result.scalars().all())
|
||||
async def liste_lagerorte(db: AsyncSession = Depends(get_db), current_user=Depends(get_current_user)) -> list[Lagerort]:
|
||||
"""ZUST-004-Nachtrag (Runde 5 hatte diese Lücke übersehen, Nutzer-Fund
|
||||
2026-09-15): war bisher NICHT gefiltert, obwohl /lagerplaetze es schon
|
||||
war - ein auf Standort A beschränkter Nutzer sah trotzdem den kompletten
|
||||
Lagerort-Baum anderer Standorte. Gleiches Muster wie bei Lagerplatz:
|
||||
ein Knoten ohne auflösbaren Standort bleibt sichtbar (unrestricted),
|
||||
einer mit fremdem Standort wird ausgeblendet."""
|
||||
lagerorte = list((await db.execute(select(Lagerort))).scalars().all())
|
||||
sichtbare = await sichtbare_standort_ids(db, current_user.id)
|
||||
if sichtbare is None:
|
||||
return lagerorte
|
||||
gefiltert = []
|
||||
for lagerort in lagerorte:
|
||||
standort_id = await standort_fuer_lagerort(db, lagerort.id)
|
||||
if standort_id is None or standort_id in sichtbare:
|
||||
gefiltert.append(lagerort)
|
||||
return gefiltert
|
||||
|
||||
|
||||
def _pruefe_standort_id_nur_bei_typ_standort(typ: LagerortTyp, standort_id: int | None) -> None:
|
||||
"""Nutzer-Entscheidung 2026-09-15: standort_id ist fachlich nur an einem
|
||||
typ=standort-Knoten sinnvoll (dessen Vorfahren/Nachfahren lösen ihren
|
||||
Standort über diesen Knoten auf) - an raum/lager/gebaeude/organisation
|
||||
gesetzt wäre eine irreführende Fehlbedienung, jetzt hart abgelehnt statt
|
||||
nur dokumentiert."""
|
||||
if standort_id is not None and typ != LagerortTyp.standort:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="standort_id ist nur an einem Lagerort vom Typ 'standort' erlaubt",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/lagerorte", response_model=LagerortRead, status_code=status.HTTP_201_CREATED)
|
||||
@@ -58,6 +85,7 @@ async def erstelle_lagerort(
|
||||
) -> Lagerort:
|
||||
if payload.parent_id is not None:
|
||||
await _get_or_404(db, Lagerort, payload.parent_id, "Übergeordneter Lagerort nicht gefunden")
|
||||
_pruefe_standort_id_nur_bei_typ_standort(payload.typ, payload.standort_id)
|
||||
lagerort = Lagerort(**payload.model_dump())
|
||||
db.add(lagerort)
|
||||
await db.flush()
|
||||
@@ -72,6 +100,9 @@ async def aendere_lagerort(
|
||||
daten = payload.model_dump(exclude_unset=True)
|
||||
if daten.get("parent_id") == lagerort_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Lagerort kann nicht sein eigener Elternknoten sein")
|
||||
neuer_typ = daten.get("typ", lagerort.typ)
|
||||
neue_standort_id = daten.get("standort_id", lagerort.standort_id)
|
||||
_pruefe_standort_id_nur_bei_typ_standort(neuer_typ, neue_standort_id)
|
||||
for feld, wert in daten.items():
|
||||
setattr(lagerort, feld, wert)
|
||||
await db.flush()
|
||||
|
||||
@@ -443,6 +443,70 @@ async def test_dashboard_kennzahlen_ablaufdaten_wartungen_standort_eingeschraenk
|
||||
assert wartungen.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lagerorte_liste_standort_eingeschraenkt(
|
||||
client, admin_user, materialverantwortlicher_user, standort_factory
|
||||
):
|
||||
"""Nutzer-Fund 2026-09-15: GET /lagerorte selbst war in Runde 5 übersehen
|
||||
worden - nur /lagerplaetze war gefiltert, der Lagerort-Baum nicht."""
|
||||
standort_a = await standort_factory("Standort A14")
|
||||
standort_b = await standort_factory("Standort B14")
|
||||
admin_token = await login(client, "admin1")
|
||||
|
||||
ort_a = await client.post(
|
||||
"/api/v1/lagerorte",
|
||||
json={"name": "Wache A14", "typ": "standort", "standort_id": standort_a.id},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
ort_b = await client.post(
|
||||
"/api/v1/lagerorte",
|
||||
json={"name": "Wache B14", "typ": "standort", "standort_id": standort_b.id},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
|
||||
await client.put(
|
||||
f"/api/v1/benutzer/{materialverantwortlicher_user.id}/standorte",
|
||||
json={"standort_ids": [standort_a.id]},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
liste = await client.get("/api/v1/lagerorte", headers=auth_header(token))
|
||||
ids = {o["id"] for o in liste.json()}
|
||||
assert ort_a.json()["id"] in ids
|
||||
assert ort_b.json()["id"] not in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lagerort_standort_id_nur_bei_typ_standort(client, admin_user, standort_factory):
|
||||
"""Nutzer-Entscheidung 2026-09-15: standort_id an einem raum/lager-Knoten
|
||||
ist eine Fehlbedienung, jetzt hart abgelehnt (409) statt nur dokumentiert."""
|
||||
standort = await standort_factory("Standort C14")
|
||||
token = await login(client, "admin1")
|
||||
|
||||
falsch = await client.post(
|
||||
"/api/v1/lagerorte",
|
||||
json={"name": "Regal falsch verknüpft", "typ": "lager", "standort_id": standort.id},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert falsch.status_code == 409
|
||||
|
||||
richtig = await client.post(
|
||||
"/api/v1/lagerorte",
|
||||
json={"name": "Wache richtig verknüpft", "typ": "standort", "standort_id": standort.id},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert richtig.status_code == 201
|
||||
|
||||
# PATCH auf einen anderen Typ, ohne standort_id zu löschen, muss ebenfalls
|
||||
# scheitern (neue Typ/standort_id-Kombination wird geprüft, nicht nur die
|
||||
# jeweils einzeln übergebenen Felder).
|
||||
umbenannt = await client.patch(
|
||||
f"/api/v1/lagerorte/{richtig.json()['id']}", json={"typ": "lager"}, headers=auth_header(token)
|
||||
)
|
||||
assert umbenannt.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standort_zugriff_setzen_nur_admin(client, mitarbeiter_user, mitarbeiter2_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
|
||||
Reference in new issue
Block a user