diff --git a/DEVLOG.md b/DEVLOG.md index 23fd7ad..714ae9d 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 ++++++++++++++++++++++++++++++++++++++++-------------------------------- + +--- diff --git a/arbeitskacheln/17_zustaendigkeit.md b/arbeitskacheln/17_zustaendigkeit.md index e7053eb..2867615 100644 --- a/arbeitskacheln/17_zustaendigkeit.md +++ b/arbeitskacheln/17_zustaendigkeit.md @@ -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`. --- diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index ef7b3d0..0f2ec8f 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -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]) diff --git a/backend/app/api/v1/endpoints/historie.py b/backend/app/api/v1/endpoints/historie.py index 9d98028..c7f3447 100644 --- a/backend/app/api/v1/endpoints/historie.py +++ b/backend/app/api/v1/endpoints/historie.py @@ -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) diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index d91b743..10cfcbc 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -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 diff --git a/backend/tests/test_zust004_standort_sichtbarkeit.py b/backend/tests/test_zust004_standort_sichtbarkeit.py index 69f0c3a..ee40cbd 100644 --- a/backend/tests/test_zust004_standort_sichtbarkeit.py +++ b/backend/tests/test_zust004_standort_sichtbarkeit.py @@ -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")