feat(zustaendigkeit): ZUST-004 Runde 4 - Historie und Dashboard-Aggregate
CI / backend-tests (push) Failing after 3m39s
CI / frontend-build (push) Successful in 21s

GET /historie prüft Standort-Sichtbarkeit, wenn entitaet_typ+entitaet_id
gesetzt sind (globale Liste ohne Filter bleibt unrestricted). Nutzer-
Entscheidung: Dashboard-Aggregate (Einsatzbereitschaft, Prüftermine)
berücksichtigen jetzt ebenfalls sichtbare Standorte - bewusste Erweiterung
der ursprünglich dokumentierten "V1 zeigt globale Zahlen"-Festlegung.
readiness_snapshot.py bleibt unrestricted (kein Nutzer-Konzept im
Hintergrund-Job). kennzahlen/ablaufdaten/wartungen/qualifikationsablaeufe
bewusst nicht angefasst, eigene Folge-Runde bei Bedarf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-14 23:07:28 +02:00
1 parent 1fd5649282
commit 6c05d4514d
6 files changed
+159 -31

No files matched your search

+36
View File
@@ -15955,3 +15955,39 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/ObjekttypSection.tsx | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
---
## 2026-09-14 22:58 – 23:00 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 1fd5649 feat(admin): Akte-Anzeige-Felder visuell gruppiert + Hinweistext
### Geänderte Dateien
- DEVLOG.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjekttypSection.tsx | 163 ++++++++++++++++++++++++++++++++++++++++--------------------------------
---
## 2026-09-14 23:01 – 23:01 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjekttypSection.tsx | 163 ++++++++++++++++++++++++++++++++++++++++--------------------------------
---
## 2026-09-14 23:03 – 23:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/admin/ObjekttypSection.tsx | 163 ++++++++++++++++++++++++++++++++++++++++--------------------------------
---
+19 -5
View File
@@ -198,15 +198,29 @@ war die Vorgängerlücke, diese hier ist die zweite gefundene Lücke).
unverändert an `standort` hängen, Lagerort ist ein zusätzliches, feineres
Konzept"). ZUST-004 lässt sich hier nicht 1:1 anwenden, ohne vorher zu
klären, wie ein `Lagerort`-Knoten einem echten `Standort` zugeordnet wird.
**Noch NICHT eingeschränkt (Follow-up bei Bedarf):** Historie, Dashboard
(Einsatzbereitschaft/Prüftermine). Wer diese Lücken ausnutzt, sieht
weiterhin standortübergreifend - kein vollständiger Schutz, bis diese
Runden nachgezogen sind. Tests:
Umgesetzt (Runde 4, 2026-09-14): `GET /historie` (nur wenn
`entitaet_typ`+`entitaet_id` gesetzt sind - ohne diese Filter ist es eine
globale Liste ohne Einzelobjekt-Bezug, bleibt unrestricted). **Nutzer-
Entscheidung, bewusste Erweiterung ggü. der ursprünglichen "V1 zeigt
globale Zahlen"-Festlegung bei `kennzahlen()`:** `GET /dashboard/
einsatzbereitschaft` und `GET /dashboard/prueftermine` filtern jetzt
ebenfalls nach sichtbaren Standorten (`objekt_kategorien()`/
`bevorstehende_prueftermine()` bekommen optionales
`sichtbare_standort_ids`, `None` = unrestricted - `readiness_snapshot.py`
ruft weiterhin ohne Filter auf, der Snapshot-Job kennt kein Nutzer-
Konzept). **Bewusst NICHT eingeschränkt:** `kennzahlen()`,
`bevorstehende_ablaufdaten()`, `alle_faelligen_wartungen()` (dashboard-
weit), `bevorstehende_qualifikationsablaeufe()` (Personal-bezogen, kein
Objekt-/Standort-Konzept) - eigene Follow-up-Runde bei Bedarf, gleiche
"kein Big-Bang"-Linie. Lagerbewegungen/Lagerplätze weiterhin blockiert
(siehe oben). Tests:
`test_mangel_liste_standort_eingeschraenkt`,
`test_kontroll_fortschritt_standort_eingeschraenkt`,
`test_wartungsauftraege_liste_standort_eingeschraenkt`,
`test_verleih_liste_und_uebergabeliste_standort_eingeschraenkt`,
`test_objekt_zaehlerstand_und_aussonderung_uebergabeliste_standort_eingeschraenkt`
`test_objekt_zaehlerstand_und_aussonderung_uebergabeliste_standort_eingeschraenkt`,
`test_historie_standort_eingeschraenkt`,
`test_dashboard_einsatzbereitschaft_und_prueftermine_standort_eingeschraenkt`
in `backend/tests/test_zust004_standort_sichtbarkeit.py`.
---
+15 -6
View File
@@ -19,6 +19,7 @@ from app.services.dashboard import (
einsatzbereitschaft,
kennzahlen,
)
from app.services.standort_sichtbarkeit import sichtbare_standort_ids
from app.services.wartung import alle_faelligen_wartungen
router = APIRouter()
@@ -43,21 +44,29 @@ async def hole_ablaufdaten(db: AsyncSession = Depends(get_db), _=Depends(_verant
@router.get("/dashboard/einsatzbereitschaft", response_model=EinsatzbereitschaftRead)
async def hole_einsatzbereitschaft(
db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
db: AsyncSession = Depends(get_db), current_user=Depends(_verantwortliche)
) -> EinsatzbereitschaftRead:
"""Kachel 1 Leitungs-Dashboard: einsatzbereit/eingeschränkt/nicht einsatzbereit
je Objekt, siehe app/services/dashboard.py:einsatzbereitschaft für die
Kriterien und die Mindermenge-Sonderrolle (U2, Karte 08)."""
return EinsatzbereitschaftRead(**await einsatzbereitschaft(db))
Kriterien und die Mindermenge-Sonderrolle (U2, Karte 08).
ZUST-004-Erweiterung (2026-09-14, Nutzer-Entscheidung): Aggregat
berücksichtigt jetzt die Standort-Sichtbarkeit des Nutzers - bewusste
Verhaltensänderung ggü. der ursprünglichen "V1 zeigt globale Zahlen"-
Entscheidung bei kennzahlen()."""
sichtbare = await sichtbare_standort_ids(db, current_user.id)
return EinsatzbereitschaftRead(**await einsatzbereitschaft(db, sichtbare))
@router.get("/dashboard/prueftermine", response_model=list[PrueftermineintragRead])
async def hole_prueftermine(
db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
db: AsyncSession = Depends(get_db), current_user=Depends(_verantwortliche)
) -> list[PrueftermineintragRead]:
"""Kachel 2 Leitungs-Dashboard: Geräteprüfungen (Karte 14), bewusst getrennt
von /dashboard/ablaufdaten (Nutzer-Entscheidung: Prüfung ≠ Ablauf)."""
return [PrueftermineintragRead(**eintrag) for eintrag in await bevorstehende_prueftermine(db)]
von /dashboard/ablaufdaten (Nutzer-Entscheidung: Prüfung ≠ Ablauf).
ZUST-004-Erweiterung: Standort-Sichtbarkeit wie bei einsatzbereitschaft."""
sichtbare = await sichtbare_standort_ids(db, current_user.id)
eintraege = await bevorstehende_prueftermine(db, sichtbare_standort_ids=sichtbare)
return [PrueftermineintragRead(**eintrag) for eintrag in eintraege]
@router.get("/dashboard/faellige-wartungen", response_model=list[FaelligeWartungRead])
+9 -2
View File
@@ -11,6 +11,7 @@ from app.models.historie import Historie
from app.schemas.historie import HistorieRead
from app.schemas.historie_verifikation import HistorieVerifikation
from app.services.historie import pruefe_kette
from app.services.standort_sichtbarkeit import pruefe_standort_sichtbar, standort_fuer_entitaet
router = APIRouter()
@@ -30,11 +31,17 @@ async def liste_historie(
limit: int = 100,
offset: int = 0,
db: AsyncSession = Depends(get_db),
_=Depends(_verantwortliche),
current_user: Benutzer = Depends(_verantwortliche),
) -> list[HistorieRead]:
"""Materialänderungslog (Prompt 13): append-only Audit-Trail über Kontrolle,
Fehlbestand, Nachfüllung, Mindermenge. Neueste zuerst, Standard-Seitengröße
100 (openapi-Review-Fund: Listen-Endpunkte brauchen Pagination)."""
100 (openapi-Review-Fund: Listen-Endpunkte brauchen Pagination).
ZUST-004-Erweiterung: Standort-Prüfung nur möglich/nötig, wenn eine
konkrete Entität gefiltert wird (sonst wäre es eine globale Liste über
alle Standorte, kein Einzelobjekt-Zugriff)."""
if entitaet_typ is not None and entitaet_id is not None:
standort_id = await standort_fuer_entitaet(db, entitaet_typ, entitaet_id)
await pruefe_standort_sichtbar(db, benutzer_id=current_user.id, standort_id=standort_id)
stmt = select(Historie, Benutzer.name).outerjoin(Benutzer, Benutzer.id == Historie.benutzer_id)
if entitaet_typ is not None:
stmt = stmt.where(Historie.entitaet_typ == entitaet_typ)
+25 -18
View File
@@ -121,28 +121,27 @@ async def aktive_regel_schluessel(db: AsyncSession) -> set[str]:
return {s for s in alle_schluessel if bekannt.get(s, True)}
async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[str]]]:
async def objekt_kategorien(
db: AsyncSession, sichtbare_standort_ids: set[int] | None = None
) -> list[tuple[Objekt, str, list[str]]]:
"""Aus `einsatzbereitschaft()` extrahiert (READY-006), damit der
Snapshot-Job (app/services/readiness_snapshot.py) dieselbe Fachlogik je
Objekt nutzt statt sie zu duplizieren - liefert Kategorie+Gründe JE Objekt,
`einsatzbereitschaft()` aggregiert das nur noch zu Summen."""
`einsatzbereitschaft()` aggregiert das nur noch zu Summen.
ZUST-004-Erweiterung: `sichtbare_standort_ids=None` = unrestricted (wie
bisher), eine gesetzte Menge schränkt zusätzlich auf diese Standorte ein -
Snapshot-Job (readiness_snapshot.py) ruft weiterhin ohne Filter auf, der
Snapshot selbst kennt kein Nutzer-Konzept."""
heute = datetime.now(timezone.utc).date()
# ausser_dienst-Objekte zählen fachlich gar nicht mehr mit (Roadmap Phase 3,
# Modul Fahrzeuge: 5-stufiger Status) - anders als in_wartung, das als
# aktiver Grund weiter unten erscheint statt das Objekt komplett auszublenden.
# ausgesondert (ASSET-013) genauso ausgeblendet - dauerhaft entfernt ist
# noch eindeutiger "zählt nicht mit" als temporär außer Dienst.
objekte = (
(
await db.execute(
select(Objekt).where(
Objekt.status.notin_([ObjektStatus.ausser_dienst, ObjektStatus.ausgesondert])
)
)
)
.scalars()
.all()
)
stmt = select(Objekt).where(Objekt.status.notin_([ObjektStatus.ausser_dienst, ObjektStatus.ausgesondert]))
if sichtbare_standort_ids is not None:
stmt = stmt.where(Objekt.standort_id.in_(sichtbare_standort_ids))
objekte = (await db.execute(stmt)).scalars().all()
offene_fehlbestaende = (
await db.execute(
@@ -247,7 +246,7 @@ async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[st
]
async def einsatzbereitschaft(db: AsyncSession) -> dict:
async def einsatzbereitschaft(db: AsyncSession, sichtbare_standort_ids: set[int] | None = None) -> dict:
"""Karte-Nachtrag (Leitungs-Dashboard Kachel 1): ein Objekt ist einsatzbereit,
wenn kein offener Fehlbestand (deckt auch Geräte-Instanzen ab, da
Statuswechsel defekt/in_reparatur automatisch einen Fehlbestand erzeugt,
@@ -256,7 +255,7 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
Aktive Mindermengen-Genehmigung erledigt den Fehlbestand fachlich NICHT
(Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt
einsatzbereit", nicht "einsatzbereit"."""
kategorien = await objekt_kategorien(db)
kategorien = await objekt_kategorien(db, sichtbare_standort_ids)
bereit = eingeschraenkt = unbekannt = 0
nie_kontrolliert_gesamt = 0
@@ -443,20 +442,28 @@ async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict:
return {"status": kategorie, "gruende": gruende}
async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = 30) -> list[dict]:
async def bevorstehende_prueftermine(
db: AsyncSession, warnzeitraum_tage: int = 30, sichtbare_standort_ids: set[int] | None = None
) -> list[dict]:
"""Karte-Nachtrag (Leitungs-Dashboard Kachel 2): Geräteprüfungen (Karte 14),
nicht Ablaufdaten von Verbrauchsmaterial (bleibt eigene, bestehende Kachel/
Endpoint /dashboard/ablaufdaten) - Prüfung ist Wartungsintervall, Ablauf ist
Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung)."""
Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung).
ZUST-004-Erweiterung: `sichtbare_standort_ids=None` = unrestricted."""
heute = datetime.now(timezone.utc).date()
eintraege = []
result = await db.execute(
stmt = (
select(GeraetInstanz, Objektposition, Material)
.join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id)
.join(Material, Objektposition.material_id == Material.id)
.where(GeraetInstanz.naechste_pruefung.is_not(None))
)
if sichtbare_standort_ids is not None:
stmt = stmt.join(Objekt, Objekt.id == Objektposition.objekt_id).where(
Objekt.standort_id.in_(sichtbare_standort_ids)
)
result = await db.execute(stmt)
for geraet, objektposition, material in result.all():
status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute)
# INSP-004 Phase 2: eine nicht bestandene Prüfung überstrahlt die
@@ -291,6 +291,61 @@ async def test_objekt_zaehlerstand_und_aussonderung_uebergabeliste_standort_eing
assert uebergabeliste.status_code == 403
@pytest.mark.asyncio
async def test_historie_standort_eingeschraenkt(client, admin_user, mitarbeiter_user, standort_factory, objekt_factory):
standort_a = await standort_factory("Standort A10")
standort_b = await standort_factory("Standort B10")
objekt_b = await objekt_factory(name="Objekt B10", code="ZUST4-B10", standort=standort_b)
admin_token = await login(client, "admin1")
await client.patch(
f"/api/v1/objekte/{objekt_b.id}", json={"status": "in_wartung"}, headers=auth_header(admin_token)
)
await client.put(
f"/api/v1/benutzer/{mitarbeiter_user.id}/standorte",
json={"standort_ids": [standort_a.id]},
headers=auth_header(admin_token),
)
token = await login(client, "mitarbeiter1")
ok = await client.get("/api/v1/historie", headers=auth_header(token))
assert ok.status_code == 200 # ohne entitaet-Filter unverändert erreichbar
verboten = await client.get(
f"/api/v1/historie?entitaet_typ=objekt&entitaet_id={objekt_b.id}", headers=auth_header(token)
)
assert verboten.status_code == 403
@pytest.mark.asyncio
async def test_dashboard_einsatzbereitschaft_und_prueftermine_standort_eingeschraenkt(
client, admin_user, materialverantwortlicher_user, standort_factory, objekt_factory
):
"""Nutzer-Entscheidung 2026-09-14: Dashboard-Aggregate berücksichtigen
jetzt ebenfalls die Standort-Sichtbarkeit."""
standort_a = await standort_factory("Standort A11")
standort_b = await standort_factory("Standort B11")
await objekt_factory(name="Objekt A11", code="ZUST4-A11", standort=standort_a)
await objekt_factory(name="Objekt B11", code="ZUST4-B11", standort=standort_b)
admin_token = await login(client, "admin1")
unbeschraenkt = await client.get("/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(admin_token))
gesamt_vorher = unbeschraenkt.json()["gesamt"]
assert gesamt_vorher >= 2
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")
eingeschraenkt = await client.get("/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(token))
assert eingeschraenkt.json()["gesamt"] < gesamt_vorher
prueftermine = await client.get("/api/v1/dashboard/prueftermine", headers=auth_header(token))
assert prueftermine.status_code == 200
@pytest.mark.asyncio
async def test_standort_zugriff_setzen_nur_admin(client, mitarbeiter_user, mitarbeiter2_user):
token = await login(client, "mitarbeiter1")