Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05bd34af3 | ||
| ea0aa18b8d | |||
| 87ecee5301 | |||
| 17add13d9d | |||
| 88a03a65f2 | |||
| cb165cd821 | |||
| 15f87eada2 | |||
| b80f52cb50 | |||
| 3a0ef3a734 | |||
| 192a65a798 | |||
| 19e962d8ec | |||
| b7d88ce24a | |||
| 0baa5952bc | |||
| 903304adf1 | |||
| be0090d513 | |||
| 69c516dba4 | |||
| 252b3f05f2 | |||
| 89ae15ca4c | |||
| 84d912b8ba | |||
| 02b6469c2d | |||
| 43d33d0edb | |||
| b6434bcc50 | |||
| 390161e224 | |||
| a5154cbd3b | |||
| b747200f3a | |||
| 1bbc5dbfce | |||
| 20636f88d2 | |||
| c5e2c69b1e | |||
| ef5ee0d19b | |||
| a13913f1a0 | |||
| a3b14d9c00 | |||
| f33135b87f | |||
| dc6f25b673 | |||
| f7d65dde01 | |||
| 1c10e5b798 | |||
| 97fab2c76d | |||
| 11b7128204 | |||
| fe1edb84f8 | |||
| 606dd960b8 | |||
| 0443b734c1 | |||
| 75a6a63665 | |||
| 753f178b0c | |||
| 005b371ac0 | |||
| 4fbb3b3469 | |||
| d3bf3c36e0 | |||
| ac475ab92c | |||
| c79bfa844f | |||
| 44fda7beb9 | |||
| 38fed379ae | |||
| 11e36e674e | |||
| 1b55d8558c | |||
| 90934453e6 | |||
| 4ad908bfbf | |||
| ad6740ffa8 | |||
| ee1e1c0671 | |||
| 04d1dbf1f9 | |||
| e65838118c | |||
| e221b5b09f | |||
| c19b6296ed | |||
| 00567bb097 | |||
| f6aba94864 | |||
| 09826d146e | |||
| 82a5b9adec | |||
| 0b57e2ec75 | |||
| 2b0d5dd3d9 | |||
| a097a82e17 | |||
| b8945556ad | |||
| d1bc725ab5 | |||
| 5dc372c043 | |||
| 2359cc8b59 | |||
| 284b9d50fe | |||
| b9bfa9e5bd | |||
| 8546c7021b | |||
| 1166a0c7f1 | |||
| a1b353fcee | |||
| a26b534c34 | |||
| b85a6ec0c7 | |||
| a19c6abd26 | |||
| d7aefed0de | |||
| 364ad44759 | |||
| 0c2ca4a69d | |||
| 2843d8775a | |||
| 064779e187 | |||
| 1fa61b2dac | |||
| 4f8725e234 | |||
| bd64340ca1 | |||
| dbe6f4f684 | |||
| 43d30944aa | |||
| 075cff00a2 | |||
| 7cd8f4b90e | |||
| 7f03504c2d | |||
| 83fdb9a2a2 | |||
| b1686f3a98 | |||
| bcf6f83813 | |||
| 5e2ae9de31 | |||
| b0594df5f2 | |||
| efb45a3ee6 | |||
| 95b743d6fd | |||
| 3bd983297b | |||
| 21d948634e | |||
| 3e96b9c1e9 | |||
| ba28a86fe5 | |||
| a6fc86025c | |||
| 408050bcb8 | |||
| 495993cad6 | |||
| 4f2f801889 | |||
| a99d4a8974 | |||
| e50c04c96f | |||
| bbadf22d39 | |||
| aa7e1626ea | |||
| afb07f70b9 | |||
| 09feddae4a | |||
| d00ae070ec | |||
| 2734215833 | |||
| e42edd86fa | |||
| 33831a8c2c | |||
| 94e815e88a | |||
| bff1248e32 | |||
| 7e4936a2e4 | |||
| 4ca0a2c460 | |||
| 6eeddfdc76 | |||
| 2e7651812a | |||
| 9bf6ccee36 | |||
| e70e3196ec | |||
| 4d1c33bf18 | |||
| 0addcfb382 | |||
| fdda8a9fae | |||
| 09433b5d28 | |||
| a5d9eeb338 | |||
| 687aad6cbd | |||
| 2796965aed | |||
| 8c31802662 | |||
| a520d54bcf | |||
| fc956d4ceb | |||
| 95feba6204 | |||
| 451d62ed46 | |||
| 6ea663b4c4 | |||
| 1b19deb728 | |||
| e25ff86420 | |||
| a025542545 | |||
| c9101db1e1 | |||
| fe3bc4f057 | |||
| 6dd9dd4f62 | |||
| c4a66e9773 | |||
| 01b3c594a1 | |||
| cc3129116a | |||
| a01457debb | |||
| 02b3b17269 | |||
| db06af0489 | |||
| 36487d2869 | |||
| 842c39081b | |||
| 26cd9feeb2 | |||
| 48a599e76c | |||
| 0e7184a59e | |||
| 802c9259dc | |||
| a34a61458e | |||
| afa68150c0 |
|
|
@ -1,30 +1,45 @@
|
|||
---
|
||||
description: Kairo Sprint-0 Architektur-Leitplanken und Dokumentenpriorität
|
||||
description: Kairo Architektur-Leitplanken und Dokumentenpriorität
|
||||
globs: backend/**,frontend/**,docs/**,.gitea/**
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Kairo Architecture Rules
|
||||
|
||||
Du arbeitest an **Jinkendo Kairo** (Sprint 0). Kairo ist mandantenfähig und Actor-first.
|
||||
Du arbeitest an **Jinkendo Kairo**. Kairo ist mandantenfähig und Actor-first.
|
||||
|
||||
## Führende Product-Dokumente (ab 2026-07-05)
|
||||
|
||||
1. `docs/product/Kairo_Vision_and_Product_Direction_v0.2.md`
|
||||
2. `docs/product/Kairo_Canonical_Operating_Model_v0.2.md`
|
||||
3. `docs/product/Kairo_Implementation_Truth_Table_v0.1.md`
|
||||
|
||||
## Product Vision
|
||||
|
||||
Kairo ist Program Director — nicht To-do-Tool, nicht CRUD-Omnibus.
|
||||
|
||||
- **Plan:** Roadmap / RoadmapItem (Gates, Meilensteine, Reifegrade)
|
||||
- **Ist:** committete Actions (Arbeitspakete), später Tasks
|
||||
- **IA:** Workspace (Ausführung), Initiative-Unterseiten (Steuerung, Ausführung, Plan, Eingang, Journey)
|
||||
- **Bearbeitung:** Modal oder Objekt-Detail — nicht Inline-CRUD auf Übersichtsseiten
|
||||
|
||||
Do not reduce Kairo to Initiative → Action.
|
||||
Do not expand InitiativeDetail as all-in-one CRUD wall.
|
||||
Do not continue prompt/AI/workflow/MCP before Plan/Ist and Gates are modeled.
|
||||
|
||||
## Dokumentenpriorität bei Konflikten
|
||||
|
||||
1. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md`
|
||||
2. `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md`
|
||||
3. `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md`
|
||||
4. `docs/reference/design-principles/alignment/*`
|
||||
5. Mitai/Shinkan-Einzelprinzipien (nur Begründung, kein Scope)
|
||||
1. Vision & Product Direction v0.2
|
||||
2. Canonical Operating Model v0.2
|
||||
3. Implementation Truth Table
|
||||
4. System Target State, ADPs (Scope Lock)
|
||||
5. Tenant Invariants, Principle Gate
|
||||
6. Product Spec v0.2 (Referenz)
|
||||
7. Sprint History (Completion Reports — nicht als Zielbild)
|
||||
|
||||
## Reference design principles
|
||||
|
||||
Referenzmaterial:
|
||||
|
||||
- `docs/reference/design-principles/mitai/`
|
||||
- `docs/reference/design-principles/shinkan/`
|
||||
- `docs/reference/design-principles/alignment/`
|
||||
|
||||
Nicht automatisch Implementierungs-Scope. Übernahme nur via Principle Gate oder Architecture Decision.
|
||||
Referenzmaterial unter `docs/reference/design-principles/`. Nicht automatisch Scope.
|
||||
|
||||
## Harte Guardrails
|
||||
|
||||
|
|
@ -34,8 +49,10 @@ Nicht automatisch Implementierungs-Scope. Übernahme nur via Principle Gate oder
|
|||
- Keine hardcodierten Rechte, Prompts oder Fachkonfiguration
|
||||
- Nummerierte SQL-Migrationen; kein ad-hoc DDL in Routern
|
||||
- Keine Mitai-/Shinkan-Domänenlogik kopieren
|
||||
- Keine Vorhaben-/Projektlogik vor Sprint-0-Abschluss
|
||||
- Keine parallele Steuerungslogik außerhalb `backend/steering/`
|
||||
- Keine neuen OM-Tabellen ohne ADP (Scope Lock bis RoadmapItem-ADP)
|
||||
- **Archetyp-/Methoden-Plugin (AP2.3/AP2.4):** siehe `.cursor/rules/kairo-plugin-architecture.mdc` — keine Archetyp-Ifs in Pages, keine neuen Features in `methodUiDefaults.js`
|
||||
|
||||
## Abweichungen
|
||||
|
||||
Architecture Decision Proposal nach Vorlage in `docs/architecture/ARCHITECTURE_DECISION_PROPOSAL_TEMPLATE.md`.
|
||||
Architecture Decision Proposal nach `docs/architecture/ARCHITECTURE_DECISION_PROPOSAL_TEMPLATE.md`.
|
||||
|
|
|
|||
40
.cursor/rules/kairo-deployment-testing.mdc
Normal file
40
.cursor/rules/kairo-deployment-testing.mdc
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
description: Deployment und Tests — kein lokaler Lauf; Verifikation über develop auf Raspberry
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Kairo — Deployment & Tests
|
||||
|
||||
## Kein lokaler Test-/DB-Lauf
|
||||
|
||||
Backend-Tests (pytest), Migrationen und Integrationstests **können auf der Entwicklungsmaschine in der Regel nicht ausgeführt werden** — PostgreSQL/Docker ist lokal oft nicht verfügbar.
|
||||
|
||||
Versuche nicht wiederholt, lokale pytest-/Migration-Läufe als Abnahme zu erzwingen, wenn die DB nicht erreichbar ist.
|
||||
|
||||
## Verifikation über Remote-Deploy
|
||||
|
||||
Praktische Verifikation erfolgt durch **Deployment auf ein entferntes System (Raspberry Pi)**:
|
||||
|
||||
1. Änderungen **committen** und auf Branch **`develop`** pushen
|
||||
2. Deployment wird dadurch ausgelöst (CI/Deploy-Pipeline auf dem Raspberry)
|
||||
3. Dort laufen Migrationen, Backend-Tests und Smoke-Checks gegen echte Infrastruktur
|
||||
|
||||
**Dev-Instanz (kein localhost):** [https://dev.kairo.jinkendo.de](https://dev.kairo.jinkendo.de)
|
||||
Schnellcheck: `GET https://dev.kairo.jinkendo.de/api/health` (Version + DB).
|
||||
|
||||
**Gitea Actions** (Test-Suite läuft nach Deploy automatisch):
|
||||
|
||||
- Übersicht: `http://192.168.2.144:3000/Lars/Kairo-Jinkendo/actions`
|
||||
- Workflows: `deploy-dev.yml` (Deploy), `test.yml` (pytest, lint, k6, Playwright)
|
||||
- Filter z. B. `?workflow=test.yml` oder `?workflow=deploy-dev.yml`
|
||||
- Run-Detail: `/Lars/Kairo-Jinkendo/actions/runs/{id}`
|
||||
|
||||
Nach Push auf `develop`: Deploy-Run grün → Health auf Dev → Test-Suite in Gitea Actions prüfen.
|
||||
|
||||
## Konsequenz für Agenten
|
||||
|
||||
- Code und Tests **schreiben** und dokumentieren — aber fehlende lokale DB nicht als Implementierungsblocker behandeln
|
||||
- Frontend: `npm test` / `vite build` lokal weiterhin sinnvoll, wenn Node verfügbar
|
||||
- Backend: Abnahme über Gitea Actions (`test.yml`) + Dev-Health — nicht lokales pytest erzwingen
|
||||
- Funktionale Checks gegen **https://dev.kairo.jinkendo.de**, nicht localhost
|
||||
- **Nicht** proaktiv committen/pushen, es sei denn, der Nutzer bittet explizit darum
|
||||
61
.cursor/rules/kairo-plugin-architecture.mdc
Normal file
61
.cursor/rules/kairo-plugin-architecture.mdc
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
description: Archetyp-/Methoden-Plugin-Architektur (AP2.3/AP2.4) — verbindlich für neue Implementierungen
|
||||
globs: backend/steering/**,backend/entity_archetypes/**,backend/services/operating_context.py,frontend/src/registry/**,frontend/src/pages/**,frontend/src/context/InitiativeOperationsContext.jsx
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Kairo Plugin-Architektur (AP2.3 / AP2.4)
|
||||
|
||||
Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md`, `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, `docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md`, **`docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md`**, **`.cursor/rules/kairo-steering-kernel.mdc`**
|
||||
|
||||
## Vier Schichten — Auflösung zur Laufzeit
|
||||
|
||||
| Schicht | Registry | Liefert |
|
||||
|---------|----------|---------|
|
||||
| **Archetyp** | `entity_archetypes` + `ui_profile_json` | Nav, Routen, Plan-Outline, `om_capabilities` |
|
||||
| **Methode** | `steering/methods/registrations/` | `data_slices`, `steering_elements`, `ui_features`, `graph_profile`, Strategie |
|
||||
| **Element** | `steering/elements/registry.py` + FE `steeringElementRegistry.js` | Control-/Work-Bausteine |
|
||||
| **Runtime** | `GET /api/initiatives/:id/operating-context` + **`steering/kernel/evaluate_steering()`** | Plugin-Auflösung + **Herzmaschine** |
|
||||
|
||||
```text
|
||||
data_slices = archetype.om_capabilities ∩ method.data_slices
|
||||
steering_elements = method.steering_elements (nur Methode)
|
||||
ui_features = method.ui_features (nur Methode)
|
||||
nav / routes = archetype.ui_profile (nur Archetyp)
|
||||
```
|
||||
|
||||
Archetyp-Default-Methode ist **Empfehlung**, nicht Lock — kompatible Methoden über `compatible_archetype_keys` + `om_capabilities`.
|
||||
|
||||
## Erweitern (Open/Closed)
|
||||
|
||||
**Neue Methode:** neues Modul unter `steering/methods/registrations/` → `MethodDefinition` vollständig → Import in `steering/__init__.py` → pytest. **Kein** Core-Edit.
|
||||
|
||||
**Neuer Archetyp:** Seed in `entity_archetypes/registry.py` + `ui_profiles.py` + optional EFS — **kein** React-If in Pages.
|
||||
|
||||
**Neues Steuerungselement:** Eintrag in `steering/elements/registry.py` + FE `steeringElementRegistry.js`; Methoden referenzieren nur registrierte Keys.
|
||||
|
||||
## Frontend — verbindlich
|
||||
|
||||
- UI aus `operating-context` via `resolveOperatingProfile.js`
|
||||
- Control/Work: `hasSteeringElement(steeringElements, '…')` — **keine** `archetype_key === 'initiative.…'`-Ifs
|
||||
- Daten: `loadOperatingSlices` / `refreshOm([…])` — **kein** Full-Reload nach Mutationen
|
||||
- Route Gating: `OperatingRouteGate` + `MODE_ROUTE_GROUPS`
|
||||
- Provider: `ScopedOperationsBridge` (ein Provider pro Vorhaben)
|
||||
|
||||
## Verboten
|
||||
|
||||
- `uiFeatures` am Archetyp-Profil (deprecated — nur Methode)
|
||||
- Hardcodierte Methoden-UI in `methodUiDefaults.js` für **neue** Archetypen
|
||||
- Parallele Steuerungslogik außerhalb `backend/steering/`
|
||||
- Methoden-Dropdown ohne Archetyp-Filter (`GET /steering/methods?archetype_key=…`)
|
||||
- **Initiative-Steuerung** (Next, Attention) außerhalb `steering/kernel/evaluate_steering()` — siehe `ADP_Steering_Kernel_Spine_v0.1.md`
|
||||
|
||||
## Fallback (legacy)
|
||||
|
||||
`methodUiDefaults.js` nur wenn Operating Context fehlt — nicht für neue Features erweitern.
|
||||
|
||||
## Abnahme neuer Archetyp-/Methoden-Slices
|
||||
|
||||
- Operating Context liefert `steering_elements` + `ui_features`
|
||||
- pytest für Methoden-Vertrag (`test_ap24_*`) grün
|
||||
- Spec unter `docs/product/archetypes/` referenziert Elemente und kompatible Methoden
|
||||
77
.cursor/rules/kairo-steering-kernel.mdc
Normal file
77
.cursor/rules/kairo-steering-kernel.mdc
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
description: Steering Kernel v0.4 — verbindliche Regeln für Read Models, Proposals, Attention, Agent-Slots, Ranker (alle Coding-Agenten)
|
||||
globs: backend/steering/**,backend/data_layer/initiative_snapshot.py,frontend/src/components/SteeringProposalsPanel.jsx,frontend/src/components/AgentSlotsPanel.jsx,frontend/src/utils/steeringProposals.js,frontend/src/utils/agentSlots.js,frontend/src/utils/sprintCommitProposal.js,frontend/src/pages/initiative/InitiativeInboxPage.jsx,frontend/src/pages/initiative/InitiativePlanPage.jsx,frontend/src/pages/initiative/InitiativeOverviewPage.jsx,frontend/src/pages/modes/PlanSprintPage.jsx
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Kairo Steering Kernel — Coding Rules (verbindlich)
|
||||
|
||||
**Primärdokument:** `docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md`
|
||||
**Architektur:** `docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md`
|
||||
|
||||
## Herzmaschine
|
||||
|
||||
- Initiative-Steuerung **nur** via `evaluate_steering()` in `backend/steering/kernel/evaluate.py`
|
||||
- Snapshot spiegelt `evaluation.read_models` + `evaluation.proposals` + `evaluation.agent_slots` — **kein** Duplikat-Rechnen
|
||||
|
||||
## Neue Fähigkeit = Provider registrieren
|
||||
|
||||
| Was | Wo |
|
||||
|-----|-----|
|
||||
| Read Model | `steering/read_models/registry.py` |
|
||||
| Proposal | `steering/proposals/registry.py` |
|
||||
| Attention | `steering/attention/contributors/*.py` + Registry |
|
||||
| Agent-Slot | `steering/agent_slots/registry.py` |
|
||||
| Sprint-Ranker | `register_sprint_commit_ranker()` — weitere Ranker analog |
|
||||
|
||||
Aktivierung über `steering_elements` / `data_slices` im Methoden-Vertrag — **nicht** über Page-Ifs oder `method_key`-Branches im Kernel.
|
||||
|
||||
## Proposals
|
||||
|
||||
- **Kein Auto-Commit** — Accept in UI/API
|
||||
- **Keine feste Bug/Story-Policy** — `ranker_key` + `factors[]`; Heuristik = Fallback (`heuristic_v0`)
|
||||
- Abhängigkeiten: `dependency_refs`, `dependency_blocked`
|
||||
- KI: `agent_v1` Ranker + Actor-Slot — **keine** hardcodierten Prompts (Principle Gate)
|
||||
|
||||
## Agent-Slots
|
||||
|
||||
- Deklarative Fenster für Actors — **kein** Prompt, **keine** Auto-Ausführung
|
||||
- Config: `operatingContext.agent_slot_config` (`enabled_slots`, `guardrail_pack`, `stale_debt_days`)
|
||||
- UI: `AgentSlotsPanel` + Snapshot `steering_kernel.agent_slots`
|
||||
|
||||
## Portfolio (K-Ext-5)
|
||||
|
||||
- Aggregiert Kernel-Output über Initiativen: `GET /api/workspace/steering`
|
||||
- Modul: `steering/portfolio/aggregate.py` — **kein** Duplikat-Rechnen außerhalb Kernel
|
||||
- Legacy `/api/workspace/attention` bleibt (SQL-Regeln) bis vollständige Migration
|
||||
|
||||
## Frontend
|
||||
|
||||
- `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` in `utils/steeringProposals.js`
|
||||
- Daten aus `steeringSnapshot.steering_kernel.proposals`
|
||||
- Keine neue Proposal-Page pro Methode
|
||||
|
||||
## Methoden-Parität (Ist)
|
||||
|
||||
| Provider | Element-Gate |
|
||||
|----------|--------------|
|
||||
| `sprint_commit` | `work_cycle_scope` |
|
||||
| `gate_next_actions` | `gate_fulfillment` |
|
||||
| `intake_triage` | `backlog` slice, **excludes** `work_cycle_scope` |
|
||||
| `epic_rollup` | `backlog_epic_hierarchy` |
|
||||
| `planning_debt` / `execution_graph` | `gate_fulfillment` / `critical_path` |
|
||||
| `tech_debt_summary` | `backlog` + `actions` slices |
|
||||
| `review.due` / `recurring.due` / `review.guardrail` | Agent-Slots (data_slices + elements) |
|
||||
|
||||
## Verboten
|
||||
|
||||
- Steuerungslogik außerhalb `backend/steering/`
|
||||
- Archetyp-Ifs für Steuerungs-UI
|
||||
- `evaluate_next_work` für Commit-Vorschläge (Plan ≠ Ist)
|
||||
- OM-Tabellen für Vorschläge
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit-Test pro Provider/Ranker
|
||||
- Snapshot-Integration wenn DB-Test verfügbar
|
||||
- Truth Table aktualisieren
|
||||
76
.cursor/rules/kairo-vibe-coder-api.mdc
Normal file
76
.cursor/rules/kairo-vibe-coder-api.mdc
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
description: Vibe-Coder Agent — Kairo Operational API mit Actor Service Token
|
||||
alwaysApply: false
|
||||
globs:
|
||||
- "**/*"
|
||||
---
|
||||
|
||||
# Kairo — Vibe-Coder API (AP1.7)
|
||||
|
||||
Du arbeitest als **Agent-Actor** an Kairo. Nutze die **Operational API** — nicht die volle UI-REST-API.
|
||||
|
||||
## Einmal-Setup (Nutzer, einmalig)
|
||||
|
||||
1. In Kairo als Tenant-Admin einloggen (Browser).
|
||||
2. Actor `Cursor Agent` anlegen (Typ `agent`) — oder vorhandenen aus Actor-Verzeichnis nutzen.
|
||||
3. Service Token erstellen:
|
||||
|
||||
```http
|
||||
POST /api/actors/{actor_id}/service-tokens
|
||||
X-Auth-Token: {session}
|
||||
Content-Type: application/json
|
||||
|
||||
{"label": "Cursor Agent", "capabilities": []}
|
||||
```
|
||||
|
||||
Antwort enthält **`token`** (nur einmal sichtbar) — Format: `kairo_at_{prefix}_{secret}`.
|
||||
|
||||
4. In Cursor **Environment Variables** (oder `.env` lokal, nicht committen):
|
||||
|
||||
```text
|
||||
KAIRO_API_BASE=http://192.168.2.144:3097
|
||||
KAIRO_ACTOR_TOKEN=kairo_at_...
|
||||
```
|
||||
|
||||
Optional für Dogfooding:
|
||||
|
||||
```text
|
||||
KAIRO_INITIATIVE_ID={uuid von „Jinkendo Kairo“}
|
||||
```
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Jeder Operational-Request:
|
||||
|
||||
```http
|
||||
X-Actor-Token: {KAIRO_ACTOR_TOKEN}
|
||||
```
|
||||
|
||||
Alternativ: `Authorization: Bearer {KAIRO_ACTOR_TOKEN}`
|
||||
|
||||
**Nicht** Session-Token in Agent-Skripten hardcoden — Service Token ist tenant-scoped, capability-gebunden, widerrufbar.
|
||||
|
||||
## Typischer Agent-Loop
|
||||
|
||||
```text
|
||||
GET /api/operational/me
|
||||
GET /api/operational/initiatives/{id}/context → enthält active_work_cycle
|
||||
GET /api/initiatives/{id}/work-cycles/active → aktive Sprint-Zeitbox
|
||||
POST /api/initiatives/{id}/work-cycles → {"title","status":"active"}
|
||||
GET /api/operational/next-action?initiative_id={id}&limit=5
|
||||
… Arbeit …
|
||||
PATCH /api/operational/actions/{id}/status {"status":"in_progress"}
|
||||
PATCH /api/operational/actions/{id}/status {"status":"done","note":"Commit …"}
|
||||
POST /api/operational/evidence {"initiative_id","action_id","title","body":"Commit-Link"}
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Kein Gate `reached`, kein Lifecycle-Wechsel, kein Portfolio-Reorder über Operational API.
|
||||
- Mutierende Calls werden auditiert (`actor_id` aus Token).
|
||||
- Token-Leak → Token widerrufen: `DELETE /api/actors/service-tokens/{token_id}` (Session-Auth).
|
||||
|
||||
## Referenz
|
||||
|
||||
- `docs/architecture/ADP_Operational_Actor_Interface_Vibe_Coder_v0.1.md`
|
||||
- OpenAPI: `{KAIRO_API_BASE}/api/docs` (Nicht-Prod)
|
||||
130
CLAUDE.md
130
CLAUDE.md
|
|
@ -12,24 +12,27 @@ Welcher nächste Schritt bringt ein Vorhaben aktuell am wirkungsvollsten voran?
|
|||
|
||||
## 1. Aktueller Entwicklungsstand
|
||||
|
||||
Sprint 0.
|
||||
Foundation AP0.1–AP0.7 abgeschlossen. Operating Model AP0.8–AP0.10, Steering AP1.0–AP1.1b technisch geliefert.
|
||||
|
||||
Es geht noch nicht um die vollständige Fachanwendung, sondern um das Fundament.
|
||||
**Technisch vorhanden:**
|
||||
|
||||
Sprint 0 baut:
|
||||
- Tenant, User, Actor, TenantContext, Auth, Capabilities
|
||||
- OM-Entitäten (Backlog, Blocker, Milestone-Brücke, Evidence, Decision, Review, Recurring)
|
||||
- `backend/steering/` (Lifecycle, Snapshot, Method Registry, **Plugin-Architektur AP2.3/AP2.4**)
|
||||
- **Operating Context API** + slice-bewusstes Frontend-Laden (AP2.3)
|
||||
- **Steuerungselement-Registry** + Methoden-Vertrag (AP2.4)
|
||||
- Workspace-GUI, Data Layer, Actor Directory
|
||||
|
||||
- Tenant
|
||||
- User
|
||||
- Actor
|
||||
- TenantContext
|
||||
- Auth-Gates
|
||||
- Capability / Rights Registry
|
||||
- minimale Feature Registry
|
||||
- minimale Prompt Registry
|
||||
- Placeholder Validation
|
||||
- Configuration-Grundmodell
|
||||
- Audit
|
||||
- Migration/Deploy-Grundlage
|
||||
**Produktlich fehlt (Vision):**
|
||||
|
||||
- Getrennte IA (Ausführung / Plan / Eingang / Journey) — nicht Omnibus-InitiativeDetail
|
||||
- RoadmapItem / Quality Gates — Milestone heute nur CRUD
|
||||
- Plan vs. Ist, Abhängigkeiten, Task-Hierarchie
|
||||
- Modal/Detail-Bearbeitung statt Inline-CRUD
|
||||
|
||||
**Ist-Stand:** `docs/product/Kairo_Implementation_Truth_Table_v0.1.md`
|
||||
|
||||
Nächster Fokus: **Specs Welle 1 finalisieren**, dann **Stufe-A End-to-End** (AP2.2b/c/e, AP1.9d) und **AP2.1 Validation** — auf Plugin-Architektur AP2.3/4 aufsetzen, kein Layout-Patching auf InitiativeDetail.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -37,12 +40,60 @@ Sprint 0 baut:
|
|||
|
||||
Lies bei Projektstart in dieser Reihenfolge:
|
||||
|
||||
1. `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md`
|
||||
2. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md`
|
||||
3. `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md`
|
||||
4. `docs/sprints/Sprint0_Vibe_Coder_Handover_v0.1.md`
|
||||
5. `docs/architecture/Kairo_Architecture_References_v0.1.md`
|
||||
6. `.cursor/rules/kairo-architecture.mdc`
|
||||
1. `docs/product/Kairo_Vision_and_Product_Direction_v0.2.md`
|
||||
2. `docs/product/Kairo_Canonical_Operating_Model_v0.2.md`
|
||||
3. `docs/product/Kairo_Implementation_Truth_Table_v0.1.md`
|
||||
4. `docs/product/DOCUMENTATION_REVISION_PROGRAM_v0.2.md`
|
||||
5. `docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md`
|
||||
6. `docs/architecture/Kairo_System_Target_State_v0.1.md`
|
||||
7. `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`
|
||||
8. `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||
9. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md`
|
||||
10. `.cursor/rules/kairo-architecture.mdc`
|
||||
11. **`.cursor/rules/kairo-steering-kernel.mdc`** + `docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md` (Steering Kernel v0.3 — Read Models, Proposals, Ranker)
|
||||
11. `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md` (AP2.3 — **geliefert**)
|
||||
12. `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md` (AP2.4 — **geliefert**)
|
||||
13. `.cursor/rules/kairo-plugin-architecture.mdc`
|
||||
|
||||
Historisch / Referenz (nicht führend bei Konflikt):
|
||||
|
||||
- `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md` → superseded by Vision v0.2
|
||||
- `docs/product/Kairo_Canonical_Operating_Model_v0.1.md` → superseded by v0.2
|
||||
- `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md` — Referenz
|
||||
- Sprint Completion Reports — historisch
|
||||
|
||||
Bei Konflikten gilt folgende Auslegungsreihenfolge:
|
||||
|
||||
1. Vision & Product Direction v0.2
|
||||
2. Canonical Operating Model v0.2
|
||||
3. Implementation Truth Table
|
||||
4. System Target State / ADPs
|
||||
5. Tenant Invariants, Principle Gate
|
||||
6. Product Spec v0.2 (Referenz)
|
||||
7. Sprint History
|
||||
8. Mitai/Shinkan Designprinzipien (Referenz, kein Scope)
|
||||
|
||||
---
|
||||
|
||||
## Aktuelle Produktleitlinie
|
||||
|
||||
Kairo darf nicht auf `Initiative → Action` reduziert werden.
|
||||
|
||||
Der aktuelle Vorhaben-/Maßnahmen-Slice ist nur ein technischer Startpunkt.
|
||||
|
||||
Kairo ist ein operativer Program Director.
|
||||
|
||||
Das kanonische Operating Model umfasst (Zielbild):
|
||||
|
||||
- Initiative / Programm, Project optional
|
||||
- **Roadmap / RoadmapItem** (Gates, Meilensteine, Reifegrade) — Plan-Struktur
|
||||
- BacklogItem (Eingang) → Action (Arbeitspaket) → Task (später) — Ist-Struktur
|
||||
- Assignment, Blocker, Evidence, Decision, Review, RecurringElement
|
||||
- AttentionItem, NextActionCandidate (Read Models via `backend/steering/`)
|
||||
|
||||
**UI-Regel:** Workspace = Portfolio aller Initiativen. Initiative-Übersicht = operative Steuerung (Alltag). Pflege/Anlage auf Unterseiten. Vibe-Coder über Operational Actor Interface (API).
|
||||
|
||||
Prompt-/KI-/Workflow-/MCP-Themen bleiben eingefroren, bis der MVP-Fachkern entlang des Operating Models trägt.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -67,14 +118,12 @@ Sie dürfen nicht dazu verwendet werden, Kairo mit Mitai- oder Shinkan-Domänenl
|
|||
|
||||
## 4. Auslegungsreihenfolge bei Konflikten
|
||||
|
||||
Bei Konflikten gilt:
|
||||
Siehe **Abschnitt 2** — die Product-Reset-Reihenfolge ist verbindlich.
|
||||
|
||||
1. `Kairo_Sprint0_Principle_Gate_v0.1.md`
|
||||
2. `Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md`
|
||||
3. `Jinkendo_Kairo_Product_Spec_v0.2.md`
|
||||
4. `Jinkendo_Foundation_Minimum_Viable_Foundation_v0.2.md`
|
||||
5. `DESIGN_PRINCIPLES_ALIGNMENT.md`
|
||||
6. Mitai/Shinkan Einzelprinzipien
|
||||
Bei technischen Foundation-Konflikten zusätzlich:
|
||||
|
||||
- `Kairo_Sprint0_Principle_Gate_v0.1.md`
|
||||
- `Jinkendo_Foundation_Minimum_Viable_Foundation_v0.2.md`
|
||||
|
||||
Mitai/Shinkan-Prinzipien dürfen Kairo nicht überstimmen.
|
||||
|
||||
|
|
@ -95,19 +144,34 @@ Mitai/Shinkan-Prinzipien dürfen Kairo nicht überstimmen.
|
|||
11. Admin- und Agentenaktionen werden auditiert.
|
||||
12. Migrationen sind nummeriert und reproduzierbar.
|
||||
|
||||
### Plugin-Architektur (AP2.3 / AP2.4 — verbindlich)
|
||||
|
||||
13. **Archetyp ≠ Methode:** Archetyp = IA-Hülle + `om_capabilities`; Methode = Steuerungsmotor (`steering_elements`, `ui_features`, Strategie).
|
||||
14. Runtime-Auflösung über `GET …/operating-context` — kein hardcodiertes Methoden-/Archetyp-UI in React-Pages.
|
||||
15. Neue Archetypen/Methoden nur über **Registrierungsmodule** (`entity_archetypes/`, `steering/methods/registrations/`) — Open/Closed.
|
||||
16. Control-/Work-UI aus `steering_elements` + `steeringElementRegistry.js`, nicht aus Archetyp-Ifs.
|
||||
17. Siehe `.cursor/rules/kairo-plugin-architecture.mdc` und ADP AP2.3 / AP2.4.
|
||||
|
||||
---
|
||||
|
||||
## 6. Nicht tun
|
||||
|
||||
- Kairo auf `Initiative → Action` reduzieren oder als To-do-Tool behandeln
|
||||
- InitiativeDetail weiter als Omnibus-CRUD-Seite ausbauen (Layout-Patches)
|
||||
- Inline-Listenformulare als Haupt-Bearbeitungs-Pattern einführen
|
||||
- Meilensteine als Status-Dropdown ohne Gate-Verifikation behandeln
|
||||
- Prompt-/KI-/Workflow-/MCP-Arbeit vor Operating-Model-MVP
|
||||
- keine Mitai-Domänenlogik kopieren
|
||||
- keine Shinkan-Domänenlogik kopieren
|
||||
- keine Trainingsplanung bauen
|
||||
- keine Gesundheitslogik bauen
|
||||
- keine Lebensmanager-/Seichō-Logik in Kairo einbauen
|
||||
- keine Vorhaben-/Projektlogik in AP0.1 vorziehen
|
||||
- kein Billing oder SSO bauen
|
||||
- keine strategischen Produktentscheidungen eigenmächtig ändern
|
||||
- keine Designprinzipien aus `docs/reference/` ohne Principle-Gate oder Architecture Decision in Scope ziehen
|
||||
- **keine Archetyp-Ifs** in Pages für Steuerungs-UI (`initiative.product === …`) — AP2.4 `steering_elements`
|
||||
- **`methodUiDefaults.js` nicht für neue Archetypen erweitern** — Operating Context + Resolver
|
||||
- **kein Full-`load()`** nach OM-Mutationen — `refreshOm([sliceKeys])`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -115,6 +179,10 @@ Mitai/Shinkan-Prinzipien dürfen Kairo nicht überstimmen.
|
|||
|
||||
Bei notwendiger Abweichung erstelle ein Architecture Decision Proposal.
|
||||
|
||||
## 8. Aktueller erster Auftrag
|
||||
---
|
||||
|
||||
AP0.1 – Projektgrundlage (Backend, Frontend minimal, Tests, Deploy).
|
||||
## 8. Nächste Entwicklungsschritte
|
||||
|
||||
Siehe `docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md` und `Sprint1_AP1_2c_IA_Skeleton_Assignment_v0.1.md`.
|
||||
|
||||
Foundation AP0.1–AP2.4 Plugin-Architektur geliefert. **Produkt:** Specs Welle 1 → AP2.2b/c/e + AP1.9d → AP2.1 Validation. Scope Lock: `ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`; Plugin: ADP AP2.3/AP2.4.
|
||||
|
|
|
|||
226
README.md
226
README.md
|
|
@ -8,6 +8,26 @@ Kairo steuert Vorhaben, Programme, Projekte, Meilensteine, Maßnahmen, Backlogs,
|
|||
|
||||
> Welcher nächste Schritt bringt ein Vorhaben aktuell am wirkungsvollsten voran?
|
||||
|
||||
## Product Direction Reset
|
||||
|
||||
Kairo wurde nach AP0.7 produktstrategisch neu ausgerichtet.
|
||||
|
||||
Die führenden Dokumente sind:
|
||||
|
||||
- `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md`
|
||||
- `docs/product/Kairo_Canonical_Operating_Model_v0.1.md`
|
||||
- `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md`
|
||||
|
||||
Kairo ist nicht als einfache Aufgabenliste zu verstehen, sondern als operativer Program Director.
|
||||
|
||||
Der aktuelle technische Slice `Initiative → Action` bleibt gültig, ist aber nicht das Zielmodell.
|
||||
|
||||
Siehe auch `CLAUDE.md` für die verbindliche Dokumentenreihenfolge.
|
||||
|
||||
Abschlussbericht: `docs/sprints/Sprint0_AP0_R1_Completion_Report_v0.1.md`
|
||||
|
||||
**Nächster geplanter Auftrag (Entwurf):** `docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md`
|
||||
|
||||
## Startzustand
|
||||
|
||||
Dieses Repository startet mit einem reinen Spezifikations- und Sprint-0-Handover-Paket.
|
||||
|
|
@ -22,14 +42,22 @@ Noch nicht enthalten:
|
|||
|
||||
## Verbindliche Dokumente für Sprint 0
|
||||
|
||||
1. `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md`
|
||||
2. `docs/architecture/Jinkendo_Foundation_Minimum_Viable_Foundation_v0.2.md`
|
||||
3. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md`
|
||||
4. `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md`
|
||||
5. `docs/sprints/Sprint0_Vibe_Coder_Handover_v0.1.md`
|
||||
6. `docs/sprints/Sprint0_AP0_1_Project_Setup_Assignment_v0.1.md`
|
||||
7. `CLAUDE.md`
|
||||
8. `.cursor/rules/kairo-architecture.mdc`
|
||||
**Product Reset (führend ab AP0.R1):**
|
||||
|
||||
1. `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md`
|
||||
2. `docs/product/Kairo_Canonical_Operating_Model_v0.1.md`
|
||||
3. `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md`
|
||||
|
||||
**Foundation & Steuerung:**
|
||||
|
||||
4. `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md`
|
||||
5. `docs/architecture/Kairo_Current_State_Gap_Analysis_AP0.1_AP0.6b_v0.1.md`
|
||||
6. `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||
7. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md`
|
||||
8. `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md`
|
||||
9. `docs/sprints/Sprint0_Vibe_Coder_Handover_v0.1.md`
|
||||
10. `CLAUDE.md`
|
||||
11. `.cursor/rules/kairo-architecture.mdc`
|
||||
|
||||
## Arbeitsmodus
|
||||
|
||||
|
|
@ -132,6 +160,178 @@ Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md)
|
|||
| `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` |
|
||||
| `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) |
|
||||
|
||||
Vorhaben und Maßnahmen (AP0.5 minimaler Slice):
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/initiatives` | GET | `kairo.initiative.read` | Vorhaben im aktiven Tenant |
|
||||
| `/api/initiatives` | POST | `kairo.initiative.manage` | Vorhaben anlegen |
|
||||
| `/api/initiatives/{id}` | GET | `kairo.initiative.read` | Einzelnes Vorhaben |
|
||||
| `/api/initiatives/{id}` | PATCH | `kairo.initiative.manage` | Vorhaben bearbeiten |
|
||||
| `/api/initiatives/{id}` | DELETE | `kairo.initiative.manage` | Vorhaben löschen |
|
||||
| `/api/initiatives/{id}/actions` | GET | `kairo.action.read` | Maßnahmen eines Vorhabens |
|
||||
| `/api/initiatives/{id}/actions` | POST | `kairo.action.manage` | Maßnahme anlegen |
|
||||
| `/api/actions/{id}` | GET | `kairo.action.read` | Einzelne Maßnahme |
|
||||
| `/api/actions/{id}` | PATCH | `kairo.action.manage` | Maßnahme bearbeiten / Status |
|
||||
| `/api/actions/{id}/assignments` | PUT | `kairo.action.manage` | Actors zuweisen (ersetzt Liste) |
|
||||
| `/api/actions/{id}` | DELETE | `kairo.action.manage` | Maßnahme löschen |
|
||||
| `/api/actions/me/open` | GET | `kairo.action.read` | Offene Maßnahmen des aktuellen Actors (via Data Layer) |
|
||||
|
||||
Actor Directory und Workspace Data Layer (AP0.7):
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/actors` | GET | `kairo.actor.read` | Actors des aktiven Tenants (Filter: `actor_type`, `q`, `include_inactive`) |
|
||||
| `/api/actors/{id}` | GET | `kairo.actor.read` | Einzelner Actor (404 cross-tenant) |
|
||||
| `/api/workspace/summary` | GET | `kairo.workspace.read` | Workspace-Kennzahlen |
|
||||
| `/api/workspace/actions/open` | GET | `kairo.workspace.read` | Meine offenen Maßnahmen (open/in_progress) |
|
||||
| `/api/workspace/actions/blocked` | GET | `kairo.workspace.read` | Meine blockierten Maßnahmen |
|
||||
| `/api/workspace/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) |
|
||||
| `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) |
|
||||
|
||||
Operating Model Extension I (AP0.8, Schema `007`):
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/workspace/attention` | GET | `kairo.attention.read` | Regelbasierte Attention-Items |
|
||||
| `/api/workspace/next-actions` | GET | `kairo.attention.read` | NextActionCandidates (`?limit=`) |
|
||||
| `/api/initiatives/{id}/blockers` | GET/POST | `kairo.blocker.read/manage` | Blocker eines Vorhabens |
|
||||
| `/api/blockers/{id}` | GET/PATCH/DELETE | `kairo.blocker.read/manage` | Einzelner Blocker |
|
||||
| `/api/initiatives/{id}/backlog` | GET/POST | `kairo.backlog.read/manage` | Backlog-Items |
|
||||
| `/api/backlog/{id}` | GET/PATCH/DELETE | `kairo.backlog.read/manage` | Einzelnes Backlog-Item |
|
||||
| `/api/backlog/{id}/convert-to-action` | POST | `kairo.backlog.manage` | Backlog → Maßnahme |
|
||||
| `/api/initiatives/{id}/milestones` | GET/POST | `kairo.milestone.read/manage` | Meilensteine |
|
||||
| `/api/milestones/{id}` | GET/PATCH/DELETE | `kairo.milestone.read/manage` | Einzelner Meilenstein |
|
||||
|
||||
**Status Blocker:** `open`, `in_progress`, `resolved`, `accepted_risk`, `dismissed`
|
||||
|
||||
**Status Backlog:** `new`, `triaged`, `accepted`, `rejected`, `converted`
|
||||
|
||||
**Status Meilenstein:** `planned`, `active`, `at_risk`, `reached`, `moved`, `discarded`
|
||||
|
||||
Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage).
|
||||
|
||||
Operating Model Extension II (AP0.9, Schema `008`):
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/initiatives/{id}/evidence` | GET/POST | `kairo.evidence.read/manage` | Evidence eines Vorhabens |
|
||||
| `/api/evidence/{id}` | GET/PATCH/DELETE | `kairo.evidence.read/manage` | Einzelnes Evidence |
|
||||
| `/api/initiatives/{id}/decisions` | GET/POST | `kairo.decision.read/manage` | Entscheidungen |
|
||||
| `/api/decisions/{id}` | GET/PATCH/DELETE | `kairo.decision.read/manage` | Einzelne Entscheidung |
|
||||
| `/api/initiatives/{id}/reviews` | GET/POST | `kairo.review.read/manage` | Reviews |
|
||||
| `/api/reviews/{id}` | GET/PATCH/DELETE | `kairo.review.read/manage` | Einzelnes Review |
|
||||
| `/api/initiatives/{id}/recurring` | GET/POST | `kairo.recurring.read/manage` | Recurring-Elemente |
|
||||
| `/api/recurring/{id}` | GET/PATCH/DELETE | `kairo.recurring.read/manage` | Einzelnes Recurring-Element |
|
||||
|
||||
**Action-Status (erweitert):** `open`, `ready`, `in_progress`, `blocked`, `review_required`, `done`, `discarded` — optional `due_at`
|
||||
|
||||
**Attention-Regeln 7–9:** `overdue_action`, `review_due`, `recurring_due`
|
||||
|
||||
Capabilities gesamt nach AP0.9: **33**.
|
||||
|
||||
Operating Model Integration (AP0.10, Schema weiter `008`):
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Lifecycle + Operating Phase (deprecated) |
|
||||
| `/api/initiatives/{id}/steering-context` | GET | `kairo.initiative.read` | SteeringContext (Lifecycle, Methode) |
|
||||
|
||||
**Steering Core (AP1.0, Schema `009`):** `backend/steering/` — Standard Lifecycle, Hook/Method Registry, Signal Engine. Scope Lock: siehe `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`.
|
||||
|
||||
Version nach AP1.0: **`0.11.0-ap1.0`**
|
||||
|
||||
**AP1.1 — Method Registry:**
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/steering/methods` | GET | `kairo.initiative.read` | Built-in Steuerungsmethoden |
|
||||
| `/api/steering/initiatives/{id}/context` | PATCH | `kairo.initiative.manage` | Methode pro Vorhaben setzen |
|
||||
|
||||
Version nach AP1.1: **`0.11.1-ap1.1`**
|
||||
|
||||
**Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required` → `done`
|
||||
|
||||
**Attention Regel 10:** `action_review_required` — Maßnahme ohne geplantes Review
|
||||
|
||||
Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
|
||||
|
||||
Operating Model Integration UI (AP0.10b, Schema weiter `008`):
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/workspace/actions/today` | GET | `kairo.workspace.read` | Meine Maßnahmen heute (überfällig zuerst) |
|
||||
|
||||
**Workspace-Widgets:** Nächste sinnvolle Schritte, Heute, Aufmerksamkeit (erweitert)
|
||||
|
||||
**Steuerungszustand v2:** Meilenstein-Horizont, Next Actions pro Vorhaben, Phase-Klartext
|
||||
|
||||
Version nach AP0.10b: **`0.10.1-ap0.10b`**
|
||||
|
||||
Operating Model Integration UI (AP0.10b, Schema weiter `008`):
|
||||
|
||||
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||
|
||||
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
|
||||
|
||||
**Status Maßnahme:** `open`, `in_progress`, `blocked`, `done`, `discarded`
|
||||
|
||||
**Priorität:** `low`, `normal`, `high`
|
||||
|
||||
```bash
|
||||
# Vorhaben anlegen
|
||||
curl -s -X POST http://localhost:8097/api/initiatives \
|
||||
-H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Erstes Vorhaben","goal":"MVP validieren","priority":"high"}'
|
||||
|
||||
# Maßnahme mit Zuweisung
|
||||
curl -s -X POST http://localhost:8097/api/initiatives/INITIATIVE_ID/actions \
|
||||
-H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"title":"API testen","assigned_actor_ids":["ACTOR_ID"]}'
|
||||
|
||||
# Meine offenen Maßnahmen
|
||||
curl -s http://localhost:8097/api/actions/me/open -H "X-Auth-Token: TOKEN"
|
||||
```
|
||||
|
||||
### Workspace UI (AP0.6 / AP0.6b)
|
||||
|
||||
Nach Login leitet Kairo auf den **Workspace** weiter.
|
||||
|
||||
| Route | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `/workspace` | Karten: Kontext, Überblick, **Aufmerksamkeit**, offene/blockierte Maßnahmen, aktive Vorhaben |
|
||||
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
|
||||
| `/initiatives/:id` | Vorhaben-Detail: Maßnahmen, **Meilensteine**, **Backlog**, **Blocker** |
|
||||
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
|
||||
|
||||
**App Shell (AP0.6b):** Desktop-Sidebar (≥1024px), Mobile-Header + Bottom-Navigation (<1024px), Tenant-/Actor-Kontext, Jinkendo-Family-Design-Tokens (`--jk-*`).
|
||||
|
||||
**Frontend-Struktur:** `frontend/src/api/`, `registry/`, `widgets/`, `pages/`, `components/`, `styles/`, `config/appNav.js`
|
||||
|
||||
**Widget Registry (frontend-only):** Widgets in `registry/widgetRegistry.js` — Capability-Filter, keine Backend-Persistenz.
|
||||
|
||||
**PWA:** `frontend/public/manifest.webmanifest`, Icon unter `public/icons/`. Kein Service Worker (bewusst — Installierbarkeit über Manifest; kein Offline-Caching in Sprint 0).
|
||||
|
||||
**Tests (Frontend):**
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run test && npm run build
|
||||
```
|
||||
|
||||
**Manueller Smoke-Test (Desktop + Mobile):**
|
||||
|
||||
1. Login → Workspace lädt mit Karten
|
||||
2. Navigation: Workspace / Vorhaben / Meine Maßnahmen
|
||||
3. Vorhaben anlegen → Detail → Maßnahme anlegen → Status *Erledigt*
|
||||
4. Prüfen: Maßnahme verschwindet aus „Meine offenen Maßnahmen“
|
||||
5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px
|
||||
6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar
|
||||
7. AP0.7: ActorSelect lädt `/api/actors`; Maßnahme einem Agent zuweisen; Workspace-Überblick sichtbar
|
||||
8. AP0.8: Attention-Widget auf Workspace; Vorhaben-Detail mit Blocker/Backlog/Meilenstein; Backlog → Maßnahme konvertieren
|
||||
|
||||
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md`
|
||||
|
||||
### Registries (AP0.4)
|
||||
|
||||
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
|
||||
|
|
@ -147,6 +347,8 @@ Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry:
|
|||
|
||||
Startup: `sync_prompt_feature_config.py` nach Rights-Sync (`SKIP_REGISTRY_SYNC=1` zum Überspringen).
|
||||
|
||||
Abschlussbericht: `docs/sprints/Sprint0_AP0_4_Completion_Report_v0.2.md`
|
||||
|
||||
**Prompt-Modell:** `PromptDefinition` → `PromptVersion` → optional `PromptStep` (workflow-/pipeline-fähig; AP0.4 führt nur `execution_mode=single` aus).
|
||||
|
||||
**Beispiel-Prompts:** `kairo.system.health_summary`, `kairo.context.debug_summary`
|
||||
|
|
@ -182,7 +384,13 @@ Registry-first: Capabilities werden in `backend/rights_registrations/` registrie
|
|||
|------------|-------|------------------|
|
||||
| `kairo.admin.access` | platform | Portal-Administration |
|
||||
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
|
||||
| `kairo.actor.manage` | tenant | Actor-Verwaltung |
|
||||
| `kairo.actor.read` | tenant | Actors im Tenant lesen (Directory) |
|
||||
| `kairo.actor.manage` | tenant | Actors verwalten |
|
||||
| `kairo.workspace.read` | workspace | Workspace Read-Models / Data Layer |
|
||||
| `kairo.initiative.read` | initiative | Vorhaben lesen |
|
||||
| `kairo.initiative.manage` | initiative | Vorhaben verwalten |
|
||||
| `kairo.action.read` | action | Maßnahmen lesen |
|
||||
| `kairo.action.manage` | action | Maßnahmen verwalten / zuweisen |
|
||||
| `kairo.context.read` | platform | TenantContext lesen |
|
||||
| `kairo.entitlements.read` | platform | Entitlements lesen |
|
||||
|
||||
|
|
|
|||
1
backend/data_layer/__init__.py
Normal file
1
backend/data_layer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Kairo Data Layer — read-oriented aggregation for workspace and dashboards."""
|
||||
119
backend/data_layer/actions.py
Normal file
119
backend/data_layer/actions.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Action read-models for workspace — tenant-scoped, actor-assignment based."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.actions import OPEN_ACTION_STATUSES, _attach_assignments, _serialize_row
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def _require_actor(ctx: TenantContext) -> str:
|
||||
if not ctx.actor_id:
|
||||
raise ValueError("Kein Actor im TenantContext")
|
||||
return ctx.actor_id
|
||||
|
||||
|
||||
def _list_assigned_actions(
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
statuses: list[str],
|
||||
order_today: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
order_clause = """
|
||||
ORDER BY
|
||||
CASE WHEN a.due_at IS NOT NULL AND a.due_at < NOW() THEN 0 ELSE 1 END,
|
||||
CASE a.status WHEN 'blocked' THEN 0 WHEN 'review_required' THEN 1 ELSE 2 END,
|
||||
CASE a.priority
|
||||
WHEN 'high' THEN 0
|
||||
WHEN 'normal' THEN 1
|
||||
WHEN 'low' THEN 2
|
||||
END,
|
||||
a.due_at ASC NULLS LAST,
|
||||
a.updated_at DESC
|
||||
""" if order_today else """
|
||||
ORDER BY
|
||||
CASE a.priority
|
||||
WHEN 'high' THEN 0
|
||||
WHEN 'normal' THEN 1
|
||||
WHEN 'low' THEN 2
|
||||
END,
|
||||
a.updated_at DESC
|
||||
"""
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
|
||||
a.status, a.priority, a.due_at, a.created_at, a.updated_at,
|
||||
i.title AS initiative_title,
|
||||
(a.due_at IS NOT NULL AND a.due_at < NOW()) AS is_overdue
|
||||
FROM actions a
|
||||
JOIN action_assignments aa
|
||||
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||
JOIN initiatives i
|
||||
ON i.id = a.initiative_id AND i.tenant_id = a.tenant_id
|
||||
WHERE a.tenant_id = %s
|
||||
AND aa.actor_id = %s
|
||||
AND a.status = ANY(%s)
|
||||
{order_clause}
|
||||
""",
|
||||
(tenant_id, actor_id, statuses),
|
||||
)
|
||||
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||
for action in actions:
|
||||
action["is_overdue"] = bool(action.pop("is_overdue", False))
|
||||
finally:
|
||||
conn.close()
|
||||
return _attach_assignments(actions, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def get_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||
"""Open + in_progress actions assigned to current actor (excludes blocked)."""
|
||||
actor_id = _require_actor(ctx)
|
||||
return _list_assigned_actions(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=actor_id,
|
||||
statuses=["open", "in_progress"],
|
||||
)
|
||||
|
||||
|
||||
def get_my_blocked_actions(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||
"""Blocked actions assigned to current actor."""
|
||||
actor_id = _require_actor(ctx)
|
||||
return _list_assigned_actions(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=actor_id,
|
||||
statuses=["blocked"],
|
||||
)
|
||||
|
||||
|
||||
def get_all_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||
"""All non-terminal open actions (open, in_progress, blocked) — legacy compat."""
|
||||
actor_id = _require_actor(ctx)
|
||||
return _list_assigned_actions(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=actor_id,
|
||||
statuses=list(OPEN_ACTION_STATUSES),
|
||||
)
|
||||
|
||||
|
||||
def get_my_today_actions(ctx: TenantContext, *, limit: int = 15) -> list[dict[str, Any]]:
|
||||
"""Maßnahmen für heute — überfällig zuerst, dann blockiert/review, dann Priorität."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
if limit > 50:
|
||||
limit = 50
|
||||
actor_id = _require_actor(ctx)
|
||||
actions = _list_assigned_actions(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=actor_id,
|
||||
statuses=["open", "ready", "in_progress", "blocked", "review_required"],
|
||||
order_today=True,
|
||||
)
|
||||
return actions[:limit]
|
||||
61
backend/data_layer/actors.py
Normal file
61
backend/data_layer/actors.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Actor workload read-models — tenant-scoped aggregation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def get_actor_workload(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||
"""Open/blocked/in_progress action counts per active actor in tenant."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
a.id AS actor_id,
|
||||
a.name AS display_name,
|
||||
a.actor_type,
|
||||
COUNT(*) FILTER (WHERE act.status = 'open') AS open_actions,
|
||||
COUNT(*) FILTER (WHERE act.status = 'blocked') AS blocked_actions,
|
||||
COUNT(*) FILTER (WHERE act.status = 'in_progress') AS in_progress_actions
|
||||
FROM actors a
|
||||
LEFT JOIN action_assignments aa
|
||||
ON aa.actor_id = a.id AND aa.tenant_id = a.tenant_id
|
||||
LEFT JOIN actions act
|
||||
ON act.id = aa.action_id
|
||||
AND act.tenant_id = aa.tenant_id
|
||||
AND act.status IN ('open', 'in_progress', 'blocked')
|
||||
WHERE a.tenant_id = %s AND a.is_active = TRUE
|
||||
GROUP BY a.id, a.name, a.actor_type
|
||||
ORDER BY
|
||||
CASE a.actor_type
|
||||
WHEN 'human' THEN 0
|
||||
WHEN 'working_group' THEN 1
|
||||
WHEN 'agent' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
a.name
|
||||
""",
|
||||
(ctx.tenant_id,),
|
||||
)
|
||||
result = []
|
||||
for row in cur.fetchall():
|
||||
result.append(
|
||||
{
|
||||
"actor_id": str(row["actor_id"]),
|
||||
"display_name": row["display_name"],
|
||||
"actor_type": row["actor_type"],
|
||||
"open_actions": int(row["open_actions"] or 0),
|
||||
"blocked_actions": int(row["blocked_actions"] or 0),
|
||||
"in_progress_actions": int(row["in_progress_actions"] or 0),
|
||||
}
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
25
backend/data_layer/attention.py
Normal file
25
backend/data_layer/attention.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Attention and NextAction — thin Data Layer wrapper (AP1.0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from steering.signals.engine import evaluate
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def get_attention_items(ctx: TenantContext):
|
||||
return evaluate(ctx, kind="attention")
|
||||
|
||||
|
||||
def get_next_action_candidates(ctx: TenantContext, *, limit: int = 10):
|
||||
return evaluate(ctx, kind="next_action", limit=limit)
|
||||
|
||||
|
||||
def get_next_action_candidates_for_initiative(
|
||||
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
||||
):
|
||||
return evaluate(
|
||||
ctx,
|
||||
kind="next_action",
|
||||
limit=limit,
|
||||
initiative_id=initiative_id,
|
||||
)
|
||||
420
backend/data_layer/initiative_snapshot.py
Normal file
420
backend/data_layer/initiative_snapshot.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""Initiative steering snapshot — verknüpftes Read-Model (AP0.10a).
|
||||
|
||||
Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steering Core.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from entity_archetypes.registry import archetype_label
|
||||
from method_profiles.registry import get_method_profile
|
||||
from steering.context import get_steering_context_dto
|
||||
from steering.kernel import evaluate_steering
|
||||
from steering.signals.snapshot_signals import derive_initiative_signals
|
||||
from services.initiatives import get_initiative
|
||||
from services.progression_graph import compute_progression_state
|
||||
from tenant_context import TenantContext
|
||||
|
||||
OPEN_BLOCKER = ("open", "in_progress")
|
||||
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
|
||||
ACTIVE_INITIATIVE = ("active", "paused")
|
||||
|
||||
_REASON_LABELS = {
|
||||
"open_action_priority": "Offenes Arbeitspaket",
|
||||
"milestone_at_risk_or_active": "Plan-Element aktiv oder gefährdet",
|
||||
"blocked_action": "Blockiertes Arbeitspaket",
|
||||
"backlog_triage": "Eingang triagieren",
|
||||
}
|
||||
|
||||
|
||||
def _steering_guidance(
|
||||
*,
|
||||
next_actions: list[dict[str, Any]],
|
||||
signals: list[str],
|
||||
method_key: str,
|
||||
) -> str:
|
||||
if next_actions:
|
||||
item = next_actions[0]
|
||||
title = item.get("title") or "Nächster Schritt"
|
||||
hint = (
|
||||
item.get("recommended_action")
|
||||
or item.get("summary")
|
||||
or _REASON_LABELS.get(item.get("reason_code", ""), "")
|
||||
)
|
||||
if hint:
|
||||
return f"Als Nächstes: {title} — {hint}"
|
||||
return f"Als Nächstes: {title}"
|
||||
if signals:
|
||||
return "Attention: Struktur oder Ist-Arbeit ergänzen, um Next Actions zu erzeugen."
|
||||
return (
|
||||
f"Methode {method_key}: Plan oder Eingang befüllen, dann committete Arbeit starten."
|
||||
)
|
||||
|
||||
|
||||
def _attention_items(signals: list[str]) -> list[dict[str, str]]:
|
||||
return [{"code": code, "label": code.replace("_", " ")} for code in signals]
|
||||
|
||||
|
||||
def _sid(value: Any) -> Optional[str]:
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _iso(value: Any) -> Optional[str]:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def get_initiative_steering_snapshot(
|
||||
ctx: TenantContext, *, initiative_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Tenant-scoped Graph-Snapshot eines Vorhabens."""
|
||||
initiative = get_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
if not initiative:
|
||||
return None
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, description, status, priority, due_at,
|
||||
created_at, updated_at
|
||||
FROM actions
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
actions_raw = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, action_id, title, status, created_at, updated_at
|
||||
FROM blockers
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
blockers = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, action_id, milestone_id, title, status, created_at, updated_at
|
||||
FROM evidence
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
evidence = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, action_id, milestone_id, title, status, due_at,
|
||||
created_at, updated_at
|
||||
FROM reviews
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
reviews = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, status, priority, converted_action_id, item_kind,
|
||||
parent_backlog_id, created_at, updated_at
|
||||
FROM backlog_items
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
backlog = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ri.id, ri.title, ri.status, ri.target_date, ri.item_type,
|
||||
ri.sequencing_mode, ri.sort_order, ri.created_at, ri.updated_at
|
||||
FROM roadmap_items ri
|
||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
||||
WHERE ri.tenant_id = %s AND r.initiative_id = %s
|
||||
ORDER BY ri.sort_order ASC, ri.updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
roadmap_items_raw = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, status, outcome, created_at, updated_at
|
||||
FROM decisions
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
decisions = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, status, next_due_at, created_at, updated_at
|
||||
FROM recurring_elements
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id),
|
||||
)
|
||||
recurring = [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
blockers_by_action: dict[str, list[dict]] = {}
|
||||
unlinked_blockers: list[dict] = []
|
||||
for b in blockers:
|
||||
item = {
|
||||
"id": _sid(b["id"]),
|
||||
"title": b["title"],
|
||||
"status": b["status"],
|
||||
"action_id": _sid(b.get("action_id")),
|
||||
}
|
||||
aid = item["action_id"]
|
||||
if aid:
|
||||
blockers_by_action.setdefault(aid, []).append(item)
|
||||
else:
|
||||
unlinked_blockers.append(item)
|
||||
|
||||
evidence_by_action: dict[str, list[dict]] = {}
|
||||
unlinked_evidence: list[dict] = []
|
||||
for e in evidence:
|
||||
item = {
|
||||
"id": _sid(e["id"]),
|
||||
"title": e["title"],
|
||||
"status": e["status"],
|
||||
"action_id": _sid(e.get("action_id")),
|
||||
"milestone_id": _sid(e.get("milestone_id")),
|
||||
}
|
||||
aid = item["action_id"]
|
||||
if aid:
|
||||
evidence_by_action.setdefault(aid, []).append(item)
|
||||
else:
|
||||
unlinked_evidence.append(item)
|
||||
|
||||
reviews_by_action: dict[str, list[dict]] = {}
|
||||
reviews_by_milestone: dict[str, list[dict]] = {}
|
||||
unlinked_reviews: list[dict] = []
|
||||
planned_reviews_due = 0
|
||||
for r in reviews:
|
||||
item = {
|
||||
"id": _sid(r["id"]),
|
||||
"title": r["title"],
|
||||
"status": r["status"],
|
||||
"due_at": _iso(r.get("due_at")),
|
||||
"action_id": _sid(r.get("action_id")),
|
||||
"milestone_id": _sid(r.get("milestone_id")),
|
||||
}
|
||||
if r["status"] == "planned" and r.get("due_at"):
|
||||
planned_reviews_due += 1
|
||||
aid, mid = item["action_id"], item["milestone_id"]
|
||||
if aid:
|
||||
reviews_by_action.setdefault(aid, []).append(item)
|
||||
elif mid:
|
||||
reviews_by_milestone.setdefault(mid, []).append(item)
|
||||
else:
|
||||
unlinked_reviews.append(item)
|
||||
|
||||
actions: list[dict[str, Any]] = []
|
||||
open_actions = blocked_actions = review_required_actions = 0
|
||||
for a in actions_raw:
|
||||
aid = _sid(a["id"])
|
||||
status = a["status"]
|
||||
if status in OPEN_ACTION:
|
||||
open_actions += 1
|
||||
if status == "blocked":
|
||||
blocked_actions += 1
|
||||
if status == "review_required":
|
||||
review_required_actions += 1
|
||||
action_blockers = blockers_by_action.get(aid, [])
|
||||
open_blocker_count = sum(1 for b in action_blockers if b["status"] in OPEN_BLOCKER)
|
||||
actions.append(
|
||||
{
|
||||
"id": aid,
|
||||
"title": a["title"],
|
||||
"status": status,
|
||||
"priority": a["priority"],
|
||||
"due_at": _iso(a.get("due_at")),
|
||||
"blockers": action_blockers,
|
||||
"evidence": evidence_by_action.get(aid, []),
|
||||
"reviews": reviews_by_action.get(aid, []),
|
||||
"open_blocker_count": open_blocker_count,
|
||||
"has_open_blocker": open_blocker_count > 0,
|
||||
}
|
||||
)
|
||||
|
||||
roadmap_items: list[dict[str, Any]] = []
|
||||
for m in roadmap_items_raw:
|
||||
mid = _sid(m["id"])
|
||||
roadmap_items.append(
|
||||
{
|
||||
"id": mid,
|
||||
"title": m["title"],
|
||||
"status": m["status"],
|
||||
"item_type": m["item_type"],
|
||||
"sequencing_mode": m["sequencing_mode"],
|
||||
"sort_order": m.get("sort_order", 0),
|
||||
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
|
||||
"reviews": reviews_by_milestone.get(mid, []),
|
||||
}
|
||||
)
|
||||
|
||||
open_blockers = sum(
|
||||
1 for b in blockers if b["status"] in OPEN_BLOCKER
|
||||
)
|
||||
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
|
||||
|
||||
signals = derive_initiative_signals(
|
||||
initiative_status=initiative["status"],
|
||||
open_actions=open_actions,
|
||||
blocked_actions=blocked_actions,
|
||||
review_required_actions=review_required_actions,
|
||||
open_blockers=open_blockers,
|
||||
backlog_new=backlog_new,
|
||||
planned_reviews_due=planned_reviews_due,
|
||||
)
|
||||
|
||||
upcoming_milestones = sorted(
|
||||
[
|
||||
m
|
||||
for m in roadmap_items
|
||||
if m["status"] in ("planned", "active", "at_risk")
|
||||
],
|
||||
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
||||
)[:5]
|
||||
|
||||
evaluation = evaluate_steering(ctx, initiative_id=initiative_id, next_work_limit=5)
|
||||
next_actions = evaluation.next_work
|
||||
kernel_attention = evaluation.attention
|
||||
epic_rollup = evaluation.read_models.get("epic_rollup") or []
|
||||
sprint_commit_proposals = evaluation.proposals.get("sprint_commit") or []
|
||||
|
||||
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||
metadata = steering.get("lifecycle_metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = {}
|
||||
method_profile_key = metadata.get("method_profile_key")
|
||||
method_profile_label = None
|
||||
if method_profile_key:
|
||||
profile = get_method_profile(method_profile_key)
|
||||
if profile:
|
||||
method_profile_label = profile.get("label")
|
||||
initiative_archetype = initiative.get("archetype_key") or "initiative.generic"
|
||||
|
||||
from services.work_cycle import get_active_work_cycle
|
||||
|
||||
active_work_cycle = get_active_work_cycle(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
|
||||
return {
|
||||
"initiative_id": initiative_id,
|
||||
"initiative_title": initiative["title"],
|
||||
"initiative_status": initiative["status"],
|
||||
"archetype_key": initiative_archetype,
|
||||
"archetype_label": archetype_label(
|
||||
entity_type="initiative", archetype_key=initiative_archetype
|
||||
),
|
||||
"method_profile_key": method_profile_key,
|
||||
"method_profile_label": method_profile_label,
|
||||
"lifecycle_state": steering["lifecycle_state"],
|
||||
"lifecycle_label": steering["lifecycle_label"],
|
||||
"method_key": steering["method_key"],
|
||||
"method_label": steering.get("method_label"),
|
||||
"method_version": steering["method_version"],
|
||||
"steering_guidance": _steering_guidance(
|
||||
next_actions=next_actions,
|
||||
signals=signals,
|
||||
method_key=steering["method_key"],
|
||||
),
|
||||
"attention_items": _attention_items(signals)
|
||||
+ [
|
||||
{
|
||||
"code": item.get("reason_code") or item.get("kind", "attention"),
|
||||
"label": item.get("summary") or item.get("title") or "Attention",
|
||||
}
|
||||
for item in kernel_attention[:5]
|
||||
],
|
||||
"signals": signals,
|
||||
"steering_kernel": {
|
||||
"horizon": evaluation.horizon.to_dict(),
|
||||
"lifecycle": evaluation.lifecycle.to_dict(),
|
||||
"primary_method_key": evaluation.binding.primary_method_key,
|
||||
"composition_modifier": evaluation.binding.composition_modifier,
|
||||
"data_source": evaluation.data_source,
|
||||
"read_models": evaluation.read_models,
|
||||
"proposals": evaluation.proposals,
|
||||
"agent_slots": evaluation.agent_slots,
|
||||
},
|
||||
"upcoming_milestones": upcoming_milestones,
|
||||
"upcoming_roadmap_items": upcoming_milestones,
|
||||
"next_actions": next_actions,
|
||||
"active_work_cycle": active_work_cycle,
|
||||
"epic_rollup": epic_rollup,
|
||||
"sprint_commit_proposals": sprint_commit_proposals,
|
||||
"gate_next_actions_proposals": evaluation.proposals.get("gate_next_actions") or [],
|
||||
"intake_triage_proposals": evaluation.proposals.get("intake_triage") or [],
|
||||
"agent_slots": evaluation.agent_slots,
|
||||
"tech_debt_summary": evaluation.read_models.get("tech_debt_summary") or {},
|
||||
"counts": {
|
||||
"actions_open": open_actions,
|
||||
"actions_blocked": blocked_actions,
|
||||
"actions_review_required": review_required_actions,
|
||||
"blockers_open": open_blockers,
|
||||
"backlog_open": backlog_new,
|
||||
"decisions": len(decisions),
|
||||
"reviews_planned_due": planned_reviews_due,
|
||||
"recurring_active": sum(1 for r in recurring if r["status"] == "active"),
|
||||
"unlinked_blockers": len(unlinked_blockers),
|
||||
"unlinked_evidence": len(unlinked_evidence),
|
||||
"unlinked_reviews": len(unlinked_reviews),
|
||||
},
|
||||
"actions": actions,
|
||||
"roadmap_items": roadmap_items,
|
||||
"milestones": roadmap_items,
|
||||
"progression_state": compute_progression_state(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
),
|
||||
"initiative_level": {
|
||||
"blockers": unlinked_blockers,
|
||||
"evidence": unlinked_evidence,
|
||||
"reviews": unlinked_reviews,
|
||||
"decisions": [
|
||||
{"id": _sid(d["id"]), "title": d["title"], "status": d["status"]}
|
||||
for d in decisions
|
||||
],
|
||||
"backlog": [
|
||||
{
|
||||
"id": _sid(b["id"]),
|
||||
"title": b["title"],
|
||||
"status": b["status"],
|
||||
"item_kind": b.get("item_kind"),
|
||||
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
|
||||
"converted_action_id": _sid(b.get("converted_action_id")),
|
||||
}
|
||||
for b in backlog
|
||||
],
|
||||
"recurring": [
|
||||
{
|
||||
"id": _sid(r["id"]),
|
||||
"title": r["title"],
|
||||
"status": r["status"],
|
||||
"next_due_at": _iso(r.get("next_due_at")),
|
||||
}
|
||||
for r in recurring
|
||||
],
|
||||
},
|
||||
"data_source": "initiative_steering_snapshot",
|
||||
}
|
||||
37
backend/data_layer/initiatives.py
Normal file
37
backend/data_layer/initiatives.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Initiative read-models for workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.initiatives import _serialize_row
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def get_active_initiatives(
|
||||
ctx: TenantContext,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Active and paused initiatives in the current tenant."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
sql = """
|
||||
SELECT id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||
status, priority, portfolio_rank, owner_actor_id, created_at, updated_at
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
||||
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
|
||||
"""
|
||||
params: list[Any] = [ctx.tenant_id]
|
||||
if limit is not None:
|
||||
sql += " LIMIT %s"
|
||||
params.append(limit)
|
||||
cur.execute(sql, params)
|
||||
return [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
23
backend/data_layer/portfolio_steering.py
Normal file
23
backend/data_layer/portfolio_steering.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Portfolio steering read model — K-Ext-5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from steering.portfolio.aggregate import aggregate_portfolio_steering
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def get_portfolio_steering(
|
||||
ctx: TenantContext,
|
||||
*,
|
||||
max_initiatives: int = 50,
|
||||
total_attention: int = 30,
|
||||
total_next_work: int = 15,
|
||||
) -> dict[str, Any]:
|
||||
return aggregate_portfolio_steering(
|
||||
ctx,
|
||||
max_initiatives=max_initiatives,
|
||||
total_attention=total_attention,
|
||||
total_next_work=total_next_work,
|
||||
)
|
||||
70
backend/data_layer/workspace.py
Normal file
70
backend/data_layer/workspace.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Workspace summary read-model — tenant-scoped KPIs for current actor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def get_workspace_summary(ctx: TenantContext) -> dict[str, Any]:
|
||||
"""
|
||||
Summary for workspace dashboard.
|
||||
|
||||
Personal counts (current actor): open, blocked, recently done.
|
||||
Tenant-wide: active initiatives count.
|
||||
"""
|
||||
if not ctx.actor_id:
|
||||
return {
|
||||
"open_actions_count": 0,
|
||||
"blocked_actions_count": 0,
|
||||
"active_initiatives_count": 0,
|
||||
"done_actions_recent_count": 0,
|
||||
}
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
WHERE a.status IN ('open', 'in_progress')
|
||||
) AS open_actions_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE a.status = 'blocked'
|
||||
) AS blocked_actions_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE a.status = 'done'
|
||||
AND a.updated_at >= NOW() - INTERVAL '7 days'
|
||||
) AS done_actions_recent_count
|
||||
FROM actions a
|
||||
JOIN action_assignments aa
|
||||
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||
WHERE a.tenant_id = %s AND aa.actor_id = %s
|
||||
""",
|
||||
(ctx.tenant_id, ctx.actor_id),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS active_initiatives_count
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
||||
""",
|
||||
(ctx.tenant_id,),
|
||||
)
|
||||
initiatives_row = dict(cur.fetchone())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"open_actions_count": int(row["open_actions_count"] or 0),
|
||||
"blocked_actions_count": int(row["blocked_actions_count"] or 0),
|
||||
"active_initiatives_count": int(initiatives_row["active_initiatives_count"] or 0),
|
||||
"done_actions_recent_count": int(row["done_actions_recent_count"] or 0),
|
||||
}
|
||||
21
backend/entity_archetypes/__init__.py
Normal file
21
backend/entity_archetypes/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from entity_archetypes.registry import (
|
||||
ENTITY_ARCHETYPES,
|
||||
INITIATIVE_ARCHETYPES,
|
||||
PROJECT_ARCHETYPES,
|
||||
archetype_label,
|
||||
list_archetypes,
|
||||
mirror_project_archetype_key,
|
||||
resolve_default_method_key,
|
||||
validate_archetype_key,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ENTITY_ARCHETYPES",
|
||||
"INITIATIVE_ARCHETYPES",
|
||||
"PROJECT_ARCHETYPES",
|
||||
"archetype_label",
|
||||
"list_archetypes",
|
||||
"mirror_project_archetype_key",
|
||||
"resolve_default_method_key",
|
||||
"validate_archetype_key",
|
||||
]
|
||||
211
backend/entity_archetypes/registry.py
Normal file
211
backend/entity_archetypes/registry.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""System-Archetypen (AP1.10 + AP2.0) — Seeds, Bindings, Spiegel Project."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from entity_archetypes.ui_profiles import INITIATIVE_UI_PROFILES, resolve_ui_profile
|
||||
|
||||
# initiative suffix → default method (shared by initiative + project mirror)
|
||||
_METHOD_BY_SUFFIX: dict[str, str] = {
|
||||
"generic": "generic_operating",
|
||||
"maturity_journey": "maturity_progression",
|
||||
"linear_project": "sequential_dependency",
|
||||
"recurring_program": "recurring_control",
|
||||
"support_queue": "queue_pull",
|
||||
"program": "program_delivery",
|
||||
"product": "continuous_product",
|
||||
"dispute_case": "dispute_procedure",
|
||||
"content_project": "chapter_based_progression",
|
||||
}
|
||||
|
||||
_INITIATIVE_SPEC_META: tuple[tuple[str, str, str], ...] = (
|
||||
("generic", "Allgemeines Vorhaben", "Fallback ohne spezielle Steuerungsvorlage."),
|
||||
(
|
||||
"maturity_journey",
|
||||
"Reifegrad-Entwicklung",
|
||||
"Mehrstufige Entwicklung, Routinen, Graph-Voraussetzungen.",
|
||||
),
|
||||
(
|
||||
"linear_project",
|
||||
"Lineares Vorhaben",
|
||||
"Einmalprojekte mit Pfaden, Gates und Graph.",
|
||||
),
|
||||
(
|
||||
"recurring_program",
|
||||
"Dauerprogramm / Rhythmus",
|
||||
"Haushalt, Rhythmen, Abweichungs-Steuerung.",
|
||||
),
|
||||
(
|
||||
"support_queue",
|
||||
"Inbox / Queue",
|
||||
"Ad-hoc-Eingang, triage, thematische Zuordnung.",
|
||||
),
|
||||
(
|
||||
"program",
|
||||
"Programm (begrenzt)",
|
||||
"Phasen, Release-Programm mit Abschluss.",
|
||||
),
|
||||
(
|
||||
"product",
|
||||
"Produkt (kontinuierlich)",
|
||||
"Dauerbetrieb, Wartung, Issues, Weiterentwicklung.",
|
||||
),
|
||||
(
|
||||
"dispute_case",
|
||||
"Verfahren / Konflikt",
|
||||
"Reaktive Steuerung, Fristen, Entscheidungen.",
|
||||
),
|
||||
(
|
||||
"content_project",
|
||||
"Inhalt / Kapitel",
|
||||
"Bücher, Konzepte, Kapitelstruktur.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_specs() -> tuple[dict[str, Any], ...]:
|
||||
specs: list[dict[str, Any]] = []
|
||||
for suffix, label, description in _INITIATIVE_SPEC_META:
|
||||
method = _METHOD_BY_SUFFIX[suffix]
|
||||
initiative_key = f"initiative.{suffix}"
|
||||
ui_profile = INITIATIVE_UI_PROFILES.get(initiative_key, {})
|
||||
specs.append(
|
||||
{
|
||||
"key": initiative_key,
|
||||
"entity_type": "initiative",
|
||||
"label": label,
|
||||
"description": description,
|
||||
"is_system": True,
|
||||
"default_method_key": method,
|
||||
"default_method_profile_key": None,
|
||||
"ui_profile_json": ui_profile,
|
||||
}
|
||||
)
|
||||
specs.append(
|
||||
{
|
||||
"key": f"project.{suffix}",
|
||||
"entity_type": "project",
|
||||
"label": label,
|
||||
"description": f"Project-Spiegel: {description}",
|
||||
"is_system": True,
|
||||
"default_method_key": method,
|
||||
"default_method_profile_key": None,
|
||||
"ui_profile_json": {},
|
||||
}
|
||||
)
|
||||
return tuple(specs)
|
||||
|
||||
|
||||
ENTITY_ARCHETYPES: tuple[dict[str, Any], ...] = _build_specs()
|
||||
|
||||
INITIATIVE_ARCHETYPES: tuple[dict[str, Any], ...] = tuple(
|
||||
item for item in ENTITY_ARCHETYPES if item["entity_type"] == "initiative"
|
||||
)
|
||||
|
||||
PROJECT_ARCHETYPES: tuple[dict[str, Any], ...] = tuple(
|
||||
item for item in ENTITY_ARCHETYPES if item["entity_type"] == "project"
|
||||
)
|
||||
|
||||
_ARCHETYPE_INDEX: dict[tuple[str, str], dict[str, Any]] = {
|
||||
(item["entity_type"], item["key"]): item for item in ENTITY_ARCHETYPES
|
||||
}
|
||||
|
||||
_KEY_INDEX: dict[str, dict[str, Any]] = {item["key"]: item for item in ENTITY_ARCHETYPES}
|
||||
|
||||
|
||||
def _list_archetypes_from_memory(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
items = ENTITY_ARCHETYPES
|
||||
if entity_type is not None:
|
||||
items = tuple(i for i in items if i["entity_type"] == entity_type)
|
||||
return [_public_archetype(item) for item in items]
|
||||
|
||||
|
||||
def _public_archetype(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"key": item["key"],
|
||||
"entity_type": item["entity_type"],
|
||||
"label": item["label"],
|
||||
"description": item["description"],
|
||||
"is_system": item["is_system"],
|
||||
"default_method_key": item["default_method_key"],
|
||||
"default_method_profile_key": item.get("default_method_profile_key"),
|
||||
}
|
||||
|
||||
|
||||
def _enrich_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
base = dict(row)
|
||||
spec = _KEY_INDEX.get(base.get("key", ""))
|
||||
if spec:
|
||||
base.setdefault("default_method_key", spec["default_method_key"])
|
||||
base.setdefault("default_method_profile_key", spec.get("default_method_profile_key"))
|
||||
return base
|
||||
|
||||
|
||||
def list_archetypes(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
if entity_type is None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT archetype_key AS key, entity_type, label, description, is_system
|
||||
FROM entity_archetypes
|
||||
ORDER BY entity_type ASC, archetype_key ASC
|
||||
"""
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT archetype_key AS key, entity_type, label, description, is_system
|
||||
FROM entity_archetypes
|
||||
WHERE entity_type = %s
|
||||
ORDER BY archetype_key ASC
|
||||
""",
|
||||
(entity_type,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
return [_enrich_row(dict(row)) for row in rows]
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
return _list_archetypes_from_memory(entity_type=entity_type)
|
||||
|
||||
|
||||
def validate_archetype_key(*, entity_type: str, archetype_key: str) -> None:
|
||||
if (entity_type, archetype_key) not in _ARCHETYPE_INDEX:
|
||||
raise ValueError(f"Unbekannter Archetyp: {archetype_key}")
|
||||
|
||||
|
||||
def archetype_label(*, entity_type: str, archetype_key: str) -> Optional[str]:
|
||||
item = _ARCHETYPE_INDEX.get((entity_type, archetype_key))
|
||||
return item["label"] if item else None
|
||||
|
||||
|
||||
def resolve_default_method_key(archetype_key: str) -> str:
|
||||
spec = _KEY_INDEX.get(archetype_key)
|
||||
if not spec:
|
||||
return "generic_operating"
|
||||
return spec.get("default_method_key", "generic_operating")
|
||||
|
||||
|
||||
def get_ui_profile_json(archetype_key: str) -> dict[str, Any]:
|
||||
"""UI-Profil aus Registry-Seed (DB-Sync-Quelle)."""
|
||||
spec = _KEY_INDEX.get(archetype_key)
|
||||
if spec and spec.get("ui_profile_json"):
|
||||
return dict(spec["ui_profile_json"])
|
||||
return resolve_ui_profile(archetype_key)
|
||||
|
||||
|
||||
def mirror_project_archetype_key(initiative_archetype_key: str) -> str:
|
||||
if initiative_archetype_key.startswith("initiative."):
|
||||
suffix = initiative_archetype_key[len("initiative.") :]
|
||||
candidate = f"project.{suffix}"
|
||||
if ( "project", candidate) in _ARCHETYPE_INDEX:
|
||||
return candidate
|
||||
return "project.generic"
|
||||
238
backend/entity_archetypes/ui_profiles.py
Normal file
238
backend/entity_archetypes/ui_profiles.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"""UI-Profile für Initiative-Archetypen — AP2.3a / AP2.3f."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_PRODUCT_PROCESS = (
|
||||
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
|
||||
{"key": "sprint-plan", "to": "/plan/sprint", "label": "Sprint planen", "mode": "plan"},
|
||||
{"key": "sprint-work", "to": "/work/sprint", "label": "Ausführen", "mode": "work"},
|
||||
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
|
||||
)
|
||||
|
||||
_LINEAR_PROCESS = (
|
||||
{"key": "gates", "to": "/plan/gates", "label": "Zielzustände", "mode": "plan"},
|
||||
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
|
||||
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
|
||||
)
|
||||
|
||||
_MATURITY_PROCESS = (
|
||||
{"key": "gates", "to": "/plan/gates", "label": "Stufen", "mode": "plan"},
|
||||
{"key": "journey", "to": "/control/journey", "label": "Rhythmen", "mode": "control"},
|
||||
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
|
||||
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
|
||||
)
|
||||
|
||||
_PROGRAM_PROCESS = (
|
||||
{"key": "gates", "to": "/plan/gates", "label": "Phasen", "mode": "plan"},
|
||||
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
|
||||
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
|
||||
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
|
||||
)
|
||||
|
||||
_SUPPORT_QUEUE_PROCESS = (
|
||||
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
|
||||
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
|
||||
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
|
||||
)
|
||||
|
||||
_CONTENT_PROCESS = (
|
||||
{"key": "gates", "to": "/plan/gates", "label": "Kapitel", "mode": "plan"},
|
||||
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
|
||||
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
|
||||
)
|
||||
|
||||
_DISPUTE_PROCESS = (
|
||||
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
|
||||
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
|
||||
)
|
||||
|
||||
_COMMON_OM_SLICES = (
|
||||
"actions",
|
||||
"blockers",
|
||||
"evidence",
|
||||
"decisions",
|
||||
"reviews",
|
||||
"steering_methods",
|
||||
)
|
||||
|
||||
GENERIC_UI_PROFILE: dict[str, Any] = {
|
||||
"processSteps": [],
|
||||
"planDefaultRoute": "/plan/profile",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": None,
|
||||
"workNavKeys": None,
|
||||
"dataSlices": [
|
||||
"actions",
|
||||
"backlog",
|
||||
"roadmap",
|
||||
"projects",
|
||||
"blockers",
|
||||
*_COMMON_OM_SLICES[1:],
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
}
|
||||
|
||||
INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
|
||||
"initiative.generic": dict(GENERIC_UI_PROFILE),
|
||||
"initiative.product": {
|
||||
"processSteps": list(_PRODUCT_PROCESS),
|
||||
"planDefaultRoute": "/plan/inbox",
|
||||
"workDefaultRoute": "/work/sprint",
|
||||
"workDefaultRouteWithoutActiveWorkCycle": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "inbox", "work", "sprint", "gates"],
|
||||
"workNavKeys": ["sprint", "today", "mine"],
|
||||
"dataSlices": [
|
||||
"backlog",
|
||||
"actions",
|
||||
"roadmap",
|
||||
"work_cycles",
|
||||
"projects",
|
||||
"blockers",
|
||||
"evidence",
|
||||
"decisions",
|
||||
"reviews",
|
||||
"steering_methods",
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
},
|
||||
"initiative.linear_project": {
|
||||
"processSteps": list(_LINEAR_PROCESS),
|
||||
"planDefaultRoute": "/plan/gates",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "structure", "gates", "inbox", "work"],
|
||||
"workNavKeys": ["today", "mine", "sprint"],
|
||||
"dataSlices": [
|
||||
"backlog",
|
||||
"actions",
|
||||
"roadmap",
|
||||
"projects",
|
||||
"blockers",
|
||||
"evidence",
|
||||
"decisions",
|
||||
"reviews",
|
||||
"steering_methods",
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
},
|
||||
"initiative.maturity_journey": {
|
||||
"processSteps": list(_MATURITY_PROCESS),
|
||||
"planDefaultRoute": "/plan/gates",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "gates", "work"],
|
||||
"workNavKeys": ["today", "mine", "sprint"],
|
||||
"dataSlices": [
|
||||
"actions",
|
||||
"roadmap",
|
||||
"blockers",
|
||||
"evidence",
|
||||
"decisions",
|
||||
"reviews",
|
||||
"recurring",
|
||||
"steering_methods",
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
},
|
||||
"initiative.program": {
|
||||
"processSteps": list(_PROGRAM_PROCESS),
|
||||
"planDefaultRoute": "/plan/gates",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "structure", "gates", "inbox", "sprint", "work"],
|
||||
"workNavKeys": ["today", "mine", "sprint"],
|
||||
"dataSlices": [
|
||||
"backlog",
|
||||
"actions",
|
||||
"roadmap",
|
||||
"projects",
|
||||
"work_cycles",
|
||||
"blockers",
|
||||
"evidence",
|
||||
"decisions",
|
||||
"reviews",
|
||||
"steering_methods",
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
},
|
||||
"initiative.support_queue": {
|
||||
"processSteps": list(_SUPPORT_QUEUE_PROCESS),
|
||||
"planDefaultRoute": "/plan/inbox",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "inbox", "work"],
|
||||
"workNavKeys": ["today", "mine"],
|
||||
"dataSlices": [
|
||||
"backlog",
|
||||
"actions",
|
||||
"blockers",
|
||||
"steering_methods",
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
},
|
||||
"initiative.recurring_program": {
|
||||
"processSteps": list(_MATURITY_PROCESS),
|
||||
"planDefaultRoute": "/plan/gates",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "gates", "inbox", "work"],
|
||||
"workNavKeys": ["today", "mine"],
|
||||
"dataSlices": [
|
||||
"actions",
|
||||
"roadmap",
|
||||
"recurring",
|
||||
"blockers",
|
||||
"steering_methods",
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
},
|
||||
"initiative.content_project": {
|
||||
"processSteps": list(_CONTENT_PROCESS),
|
||||
"planDefaultRoute": "/plan/gates",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "gates", "inbox", "work"],
|
||||
"workNavKeys": ["today", "mine"],
|
||||
"dataSlices": [
|
||||
"actions",
|
||||
"roadmap",
|
||||
"blockers",
|
||||
"evidence",
|
||||
"decisions",
|
||||
"reviews",
|
||||
"steering_methods",
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
},
|
||||
"initiative.dispute_case": {
|
||||
"processSteps": list(_DISPUTE_PROCESS),
|
||||
"planDefaultRoute": "/plan/profile",
|
||||
"workDefaultRoute": "/work/today",
|
||||
"controlDefaultRoute": "/control/status",
|
||||
"planOutlineKeys": ["profile", "work"],
|
||||
"workNavKeys": ["today", "mine"],
|
||||
"dataSlices": [
|
||||
"actions",
|
||||
"blockers",
|
||||
"evidence",
|
||||
"decisions",
|
||||
"reviews",
|
||||
"steering_methods",
|
||||
],
|
||||
"enabledModes": ["plan", "work", "control"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
|
||||
"""UI-Profil aus Code-Seed; unbekannte Archetypen → Generic-Fallback."""
|
||||
profile = INITIATIVE_UI_PROFILES.get(archetype_key)
|
||||
resolved = dict(profile) if profile else dict(GENERIC_UI_PROFILE)
|
||||
slices = resolved.get("dataSlices") or []
|
||||
resolved["omCapabilities"] = list(resolved.get("omCapabilities") or slices)
|
||||
resolved.pop("uiFeatures", None)
|
||||
return resolved
|
||||
20
backend/entity_fields/__init__.py
Normal file
20
backend/entity_fields/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from entity_fields.definitions import FIELD_DEFINITIONS
|
||||
from entity_fields.service import (
|
||||
get_entity_field_values,
|
||||
get_initiative_archetype_key,
|
||||
list_field_definitions,
|
||||
patch_entity_field_values,
|
||||
)
|
||||
from entity_fields.sync import sync_entity_field_registry_to_db
|
||||
from entity_fields.validation import normalize_field_value, validate_required_fields
|
||||
|
||||
__all__ = [
|
||||
"FIELD_DEFINITIONS",
|
||||
"get_entity_field_values",
|
||||
"get_initiative_archetype_key",
|
||||
"list_field_definitions",
|
||||
"normalize_field_value",
|
||||
"patch_entity_field_values",
|
||||
"sync_entity_field_registry_to_db",
|
||||
"validate_required_fields",
|
||||
]
|
||||
88
backend/entity_fields/definitions.py
Normal file
88
backend/entity_fields/definitions.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""System-Felddefinitionen (Seed) — AP1.10b / AP2.0b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
FIELD_DEFINITIONS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"archetype_key": "initiative.program",
|
||||
"field_key": "stakeholder_map",
|
||||
"field_type": "longtext",
|
||||
"label": "Stakeholder-Übersicht",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 10,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.program",
|
||||
"field_key": "success_criteria",
|
||||
"field_type": "longtext",
|
||||
"label": "Erfolgskriterien",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 20,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.product",
|
||||
"field_key": "release_theme",
|
||||
"field_type": "text",
|
||||
"label": "Release-Thema",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 10,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.product",
|
||||
"field_key": "metrics",
|
||||
"field_type": "longtext",
|
||||
"label": "Metriken / KPIs",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 20,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.maturity_journey",
|
||||
"field_key": "current_focus",
|
||||
"field_type": "text",
|
||||
"label": "Aktueller Fokus",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 10,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.maturity_journey",
|
||||
"field_key": "training_frequency",
|
||||
"field_type": "enum",
|
||||
"label": "Trainingsfrequenz",
|
||||
"required": False,
|
||||
"searchable": False,
|
||||
"sort_order": 20,
|
||||
"validation_json": {"options": ["daily", "3x_week", "weekly", "flexible"]},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.content_project",
|
||||
"field_key": "manuscript_status",
|
||||
"field_type": "text",
|
||||
"label": "Manuskript-Status",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 10,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.content_project",
|
||||
"field_key": "word_count_goal",
|
||||
"field_type": "number",
|
||||
"label": "Wortziel",
|
||||
"required": False,
|
||||
"searchable": False,
|
||||
"sort_order": 20,
|
||||
"validation_json": {},
|
||||
},
|
||||
)
|
||||
206
backend/entity_fields/service.py
Normal file
206
backend/entity_fields/service.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Entity Field System — Lesen/Schreiben dynamischer Feldwerte — AP1.10b/10c."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import Json, RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from entity_fields.validation import normalize_field_value, validate_required_fields
|
||||
|
||||
|
||||
def _serialize_definition(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
if result.get("id"):
|
||||
result["id"] = str(result["id"])
|
||||
validation = result.get("validation_json")
|
||||
if validation is None:
|
||||
result["validation_json"] = {}
|
||||
return result
|
||||
|
||||
|
||||
def _api_value_from_row(row: dict[str, Any], field_type: str) -> Any:
|
||||
if field_type == "number":
|
||||
if row.get("value_number") is not None:
|
||||
return float(row["value_number"])
|
||||
return None
|
||||
if field_type == "boolean":
|
||||
if row.get("value_boolean") is not None:
|
||||
return bool(row["value_boolean"])
|
||||
return False
|
||||
if field_type == "date":
|
||||
value_date = row.get("value_date")
|
||||
if value_date is not None:
|
||||
return value_date.isoformat()
|
||||
return None
|
||||
text = row.get("value_text") or ""
|
||||
return text
|
||||
|
||||
|
||||
def list_field_definitions(
|
||||
*,
|
||||
archetype_key: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, archetype_key, field_key, field_type, label,
|
||||
required, searchable, sort_order, validation_json
|
||||
FROM field_definitions
|
||||
WHERE archetype_key = %s
|
||||
ORDER BY sort_order ASC, field_key ASC
|
||||
""",
|
||||
(archetype_key,),
|
||||
)
|
||||
return [_serialize_definition(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_entity_field_values(
|
||||
*,
|
||||
tenant_id: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
definitions: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
if not definitions:
|
||||
return {}
|
||||
|
||||
field_keys = [item["field_key"] for item in definitions]
|
||||
type_by_key = {item["field_key"]: item["field_type"] for item in definitions}
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT field_key, value_text, value_number, value_boolean, value_date
|
||||
FROM field_values
|
||||
WHERE tenant_id = %s
|
||||
AND entity_type = %s
|
||||
AND entity_id = %s
|
||||
AND field_key = ANY(%s)
|
||||
""",
|
||||
(tenant_id, entity_type, entity_id, field_keys),
|
||||
)
|
||||
rows = {row["field_key"]: dict(row) for row in cur.fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for field_key in field_keys:
|
||||
row = rows.get(field_key)
|
||||
if row is None:
|
||||
field_type = type_by_key[field_key]
|
||||
if field_type == "boolean":
|
||||
result[field_key] = False
|
||||
else:
|
||||
result[field_key] = None if field_type in {"number", "date"} else ""
|
||||
else:
|
||||
result[field_key] = _api_value_from_row(row, type_by_key[field_key])
|
||||
return result
|
||||
|
||||
|
||||
def patch_entity_field_values(
|
||||
*,
|
||||
tenant_id: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
definitions: list[dict[str, Any]],
|
||||
values: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not values:
|
||||
return get_entity_field_values(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
|
||||
allowed_keys = {item["field_key"] for item in definitions}
|
||||
unknown = sorted(set(values.keys()) - allowed_keys)
|
||||
if unknown:
|
||||
raise ValueError(f"Unbekannte Felder: {', '.join(unknown)}")
|
||||
|
||||
definition_by_key = {item["field_key"]: item for item in definitions}
|
||||
merged = get_entity_field_values(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
merged.update(values)
|
||||
validate_required_fields(definitions=definitions, values=merged)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for field_key, raw_value in values.items():
|
||||
definition = definition_by_key[field_key]
|
||||
normalized = normalize_field_value(
|
||||
field_type=definition["field_type"],
|
||||
raw_value=raw_value,
|
||||
validation_json=definition.get("validation_json"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO field_values (
|
||||
tenant_id, entity_type, entity_id, field_key,
|
||||
value_json, value_text, value_number, value_boolean, value_date
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (tenant_id, entity_type, entity_id, field_key) DO UPDATE SET
|
||||
value_json = EXCLUDED.value_json,
|
||||
value_text = EXCLUDED.value_text,
|
||||
value_number = EXCLUDED.value_number,
|
||||
value_boolean = EXCLUDED.value_boolean,
|
||||
value_date = EXCLUDED.value_date,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
field_key,
|
||||
Json(normalized["value_json"]) if normalized["value_json"] is not None else None,
|
||||
normalized["value_text"],
|
||||
normalized["value_number"],
|
||||
normalized["value_boolean"],
|
||||
normalized["value_date"],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return get_entity_field_values(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
|
||||
|
||||
def get_initiative_archetype_key(*, tenant_id: str, initiative_id: str) -> Optional[str]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT archetype_key
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s AND id = %s
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
82
backend/entity_fields/sync.py
Normal file
82
backend/entity_fields/sync.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Sync Entity-Archetypen und Felddefinitionen in PostgreSQL — AP1.10b / AP2.0b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from db import get_connection
|
||||
from entity_archetypes.registry import ENTITY_ARCHETYPES
|
||||
from entity_fields.definitions import FIELD_DEFINITIONS
|
||||
|
||||
|
||||
def sync_entity_field_registry_to_db() -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for archetype in ENTITY_ARCHETYPES:
|
||||
ui_profile = archetype.get("ui_profile_json") or {}
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO entity_archetypes (
|
||||
archetype_key, entity_type, label, description, is_system,
|
||||
ui_profile_json
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
||||
ON CONFLICT (archetype_key) DO UPDATE SET
|
||||
entity_type = EXCLUDED.entity_type,
|
||||
label = EXCLUDED.label,
|
||||
description = EXCLUDED.description,
|
||||
is_system = EXCLUDED.is_system,
|
||||
ui_profile_json = EXCLUDED.ui_profile_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
archetype["key"],
|
||||
archetype["entity_type"],
|
||||
archetype["label"],
|
||||
archetype["description"],
|
||||
archetype["is_system"],
|
||||
json.dumps(ui_profile),
|
||||
),
|
||||
)
|
||||
|
||||
for field_def in FIELD_DEFINITIONS:
|
||||
validation_json = field_def.get("validation_json") or {}
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO field_definitions (
|
||||
archetype_key, field_key, field_type, label,
|
||||
required, searchable, sort_order, validation_json
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
ON CONFLICT (archetype_key, field_key) DO UPDATE SET
|
||||
field_type = EXCLUDED.field_type,
|
||||
label = EXCLUDED.label,
|
||||
required = EXCLUDED.required,
|
||||
searchable = EXCLUDED.searchable,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
validation_json = EXCLUDED.validation_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
field_def["archetype_key"],
|
||||
field_def["field_key"],
|
||||
field_def["field_type"],
|
||||
field_def["label"],
|
||||
field_def["required"],
|
||||
field_def["searchable"],
|
||||
field_def["sort_order"],
|
||||
json.dumps(validation_json),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
print(
|
||||
"[entity_fields] Sync OK — "
|
||||
f"{len(ENTITY_ARCHETYPES)} Archetyp(en), "
|
||||
f"{len(FIELD_DEFINITIONS)} Felddefinition(en)"
|
||||
)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
print(f"[entity_fields] Sync FAIL: {exc}")
|
||||
return 1
|
||||
153
backend/entity_fields/validation.py
Normal file
153
backend/entity_fields/validation.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Validierung dynamischer Feldwerte gegen Definitionen — AP1.10b/10c."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
VALID_FIELD_TYPES = frozenset(
|
||||
{"text", "longtext", "number", "boolean", "date", "enum", "actor_ref", "url"}
|
||||
)
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Ungültiges Datum: {value}") from exc
|
||||
|
||||
|
||||
def _validate_url(value: str) -> None:
|
||||
if not value.strip():
|
||||
return
|
||||
parsed = urlparse(value.strip())
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return
|
||||
if _URL_SCHEME_RE.match(value.strip()):
|
||||
return
|
||||
raise ValueError(f"Ungültige URL: {value}")
|
||||
|
||||
|
||||
def normalize_field_value(
|
||||
*,
|
||||
field_type: str,
|
||||
raw_value: Any,
|
||||
validation_json: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Wert normalisieren und in DB-Spalten aufteilen."""
|
||||
validation_json = validation_json or {}
|
||||
|
||||
if field_type in {"text", "longtext", "url", "actor_ref", "enum"}:
|
||||
if raw_value is None:
|
||||
text = ""
|
||||
elif not isinstance(raw_value, str):
|
||||
raise ValueError(f"Feld erwartet Text, erhalten: {type(raw_value).__name__}")
|
||||
else:
|
||||
text = raw_value.strip()
|
||||
if field_type == "url" and text:
|
||||
_validate_url(text)
|
||||
if field_type == "enum" and text:
|
||||
options = validation_json.get("options") or []
|
||||
if options and text not in options:
|
||||
raise ValueError(f"Ungültiger Enum-Wert: {text}")
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": text,
|
||||
"value_number": None,
|
||||
"value_boolean": None,
|
||||
"value_date": None,
|
||||
"api_value": text,
|
||||
}
|
||||
|
||||
if field_type == "number":
|
||||
if raw_value is None or raw_value == "":
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": "",
|
||||
"value_number": None,
|
||||
"value_boolean": None,
|
||||
"value_date": None,
|
||||
"api_value": None,
|
||||
}
|
||||
try:
|
||||
number = float(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Ungültige Zahl: {raw_value}") from exc
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": str(number),
|
||||
"value_number": number,
|
||||
"value_boolean": None,
|
||||
"value_date": None,
|
||||
"api_value": number,
|
||||
}
|
||||
|
||||
if field_type == "boolean":
|
||||
if raw_value is None:
|
||||
bool_val = False
|
||||
elif isinstance(raw_value, bool):
|
||||
bool_val = raw_value
|
||||
elif isinstance(raw_value, str):
|
||||
lowered = raw_value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on"}:
|
||||
bool_val = True
|
||||
elif lowered in {"false", "0", "no", "off", ""}:
|
||||
bool_val = False
|
||||
else:
|
||||
raise ValueError(f"Ungültiger Boolean-Wert: {raw_value}")
|
||||
else:
|
||||
bool_val = bool(raw_value)
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": "true" if bool_val else "false",
|
||||
"value_number": None,
|
||||
"value_boolean": bool_val,
|
||||
"value_date": None,
|
||||
"api_value": bool_val,
|
||||
}
|
||||
|
||||
if field_type == "date":
|
||||
if raw_value is None or raw_value == "":
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": "",
|
||||
"value_number": None,
|
||||
"value_boolean": None,
|
||||
"value_date": None,
|
||||
"api_value": None,
|
||||
}
|
||||
if isinstance(raw_value, date) and not isinstance(raw_value, datetime):
|
||||
parsed = raw_value
|
||||
elif isinstance(raw_value, str):
|
||||
parsed = _parse_date(raw_value)
|
||||
else:
|
||||
raise ValueError(f"Ungültiges Datum: {raw_value}")
|
||||
iso = parsed.isoformat()
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": iso,
|
||||
"value_number": None,
|
||||
"value_boolean": None,
|
||||
"value_date": parsed,
|
||||
"api_value": iso,
|
||||
}
|
||||
|
||||
raise ValueError(f"Unbekannter Feldtyp: {field_type}")
|
||||
|
||||
|
||||
def validate_required_fields(
|
||||
*,
|
||||
definitions: list[dict[str, Any]],
|
||||
values: dict[str, Any],
|
||||
) -> None:
|
||||
for definition in definitions:
|
||||
if not definition.get("required"):
|
||||
continue
|
||||
key = definition["field_key"]
|
||||
raw = values.get(key)
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
raise ValueError(f"Pflichtfeld fehlt: {definition.get('label') or key}")
|
||||
|
|
@ -23,8 +23,9 @@ fi
|
|||
|
||||
if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then
|
||||
python sync_prompt_feature_config.py
|
||||
python sync_entity_field_registry.py
|
||||
else
|
||||
echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config-Sync übersprungen"
|
||||
echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config/EFS-Sync übersprungen"
|
||||
fi
|
||||
|
||||
export KAIRO_DB_READY=1
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from db import check_db
|
||||
from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION
|
||||
|
||||
import steering # noqa: F401 — bootstrap hooks & methods
|
||||
|
||||
if os.getenv("KAIRO_DB_READY") != "1":
|
||||
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
|
||||
import run_migrations
|
||||
|
|
@ -53,13 +55,64 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
from routers import auth, config, features, me, prompts # noqa: E402
|
||||
from routers import ( # noqa: E402
|
||||
actions,
|
||||
actors,
|
||||
auth,
|
||||
backlog,
|
||||
blockers,
|
||||
config,
|
||||
decisions,
|
||||
entity_archetypes,
|
||||
evidence,
|
||||
execution_plan,
|
||||
features,
|
||||
initiatives,
|
||||
journey,
|
||||
me,
|
||||
milestones,
|
||||
projects,
|
||||
roadmap,
|
||||
prompts,
|
||||
recurring,
|
||||
reviews,
|
||||
steering,
|
||||
tasks,
|
||||
workspace,
|
||||
operational,
|
||||
work_cycles,
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(me.router)
|
||||
app.include_router(features.router)
|
||||
app.include_router(prompts.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(initiatives.router)
|
||||
app.include_router(entity_archetypes.router)
|
||||
app.include_router(journey.initiative_router)
|
||||
app.include_router(journey.roadmap_router)
|
||||
app.include_router(actions.router)
|
||||
app.include_router(projects.initiative_router)
|
||||
app.include_router(projects.items_router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(blockers.router)
|
||||
app.include_router(backlog.router)
|
||||
app.include_router(milestones.router)
|
||||
app.include_router(roadmap.initiative_router)
|
||||
app.include_router(roadmap.items_router)
|
||||
app.include_router(roadmap.deps_router)
|
||||
app.include_router(roadmap.criteria_router)
|
||||
app.include_router(execution_plan.initiative_router)
|
||||
app.include_router(evidence.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(reviews.router)
|
||||
app.include_router(recurring.router)
|
||||
app.include_router(steering.router)
|
||||
app.include_router(actors.router)
|
||||
app.include_router(workspace.router)
|
||||
app.include_router(operational.router)
|
||||
app.include_router(work_cycles.initiative_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
1
backend/method_profiles/__init__.py
Normal file
1
backend/method_profiles/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Package marker for method profile seeds."""
|
||||
73
backend/method_profiles/registry.py
Normal file
73
backend/method_profiles/registry.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""Referenz-Ausprägungen (Method Profiles) — Code-Seeds AP2.0b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
METHOD_PROFILES: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"key": "maturity.karate_kumite",
|
||||
"label": "Karate Kumite",
|
||||
"description": "Mehrere Fähigkeiten mit Stufen, Routinen und Voraussetzungs-Graph.",
|
||||
"initiative_archetype_key": "initiative.maturity_journey",
|
||||
"method_key": "maturity_progression",
|
||||
"seed_hints": {
|
||||
"skill_count": 8,
|
||||
"stages_per_skill": "5-7",
|
||||
"graph_prerequisites": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "content.book_writing",
|
||||
"label": "Buch schreiben",
|
||||
"description": "Kapitelstruktur, Schreib-APs, Review-Gates.",
|
||||
"initiative_archetype_key": "initiative.content_project",
|
||||
"method_key": "chapter_based_progression",
|
||||
"seed_hints": {
|
||||
"roadmap_item_type": "chapter",
|
||||
"review_gates": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "product.kairo_dev",
|
||||
"label": "Kairo Entwicklung",
|
||||
"description": "Kontinuierliches Produkt mit Gate-Orientierung und optionaler Sprint-Zeitbox.",
|
||||
"initiative_archetype_key": "initiative.product",
|
||||
"method_key": "continuous_product",
|
||||
"seed_hints": {
|
||||
"agile_iteration_profile": True,
|
||||
"default_actor_setup": "po_plus_vibe_coder",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "project.agile_iteration",
|
||||
"label": "Projekt mit Sprint",
|
||||
"description": "Lineares Projekt (A2) mit optionaler Agile-Iteration.",
|
||||
"initiative_archetype_key": "initiative.linear_project",
|
||||
"method_key": "sequential_dependency",
|
||||
"seed_hints": {
|
||||
"agile_iteration_profile": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
_PROFILE_INDEX = {item["key"]: item for item in METHOD_PROFILES}
|
||||
|
||||
|
||||
def get_method_profile(key: str) -> Optional[dict[str, Any]]:
|
||||
item = _PROFILE_INDEX.get(key)
|
||||
return dict(item) if item else None
|
||||
|
||||
|
||||
def list_method_profiles(*, initiative_archetype_key: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
items = METHOD_PROFILES
|
||||
if initiative_archetype_key:
|
||||
items = tuple(
|
||||
i for i in items if i["initiative_archetype_key"] == initiative_archetype_key
|
||||
)
|
||||
return [dict(item) for item in items]
|
||||
|
||||
|
||||
def resolve_method_for_profile(profile_key: str) -> Optional[str]:
|
||||
profile = get_method_profile(profile_key)
|
||||
return profile["method_key"] if profile else None
|
||||
49
backend/migrations/006_initiatives_actions.sql
Normal file
49
backend/migrations/006_initiatives_actions.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- AP0.5: Minimal initiatives (Vorhaben), actions (Maßnahmen), action_assignments
|
||||
|
||||
CREATE TABLE initiatives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
goal TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'paused', 'completed', 'archived')),
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
|
||||
CHECK (priority IN ('low', 'normal', 'high')),
|
||||
owner_actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_initiatives_tenant ON initiatives(tenant_id);
|
||||
CREATE INDEX idx_initiatives_owner ON initiatives(owner_actor_id);
|
||||
|
||||
CREATE TABLE actions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'in_progress', 'blocked', 'done', 'discarded')),
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
|
||||
CHECK (priority IN ('low', 'normal', 'high')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_actions_tenant ON actions(tenant_id);
|
||||
CREATE INDEX idx_actions_initiative ON actions(initiative_id);
|
||||
CREATE INDEX idx_actions_status ON actions(tenant_id, status);
|
||||
|
||||
CREATE TABLE action_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
|
||||
actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (action_id, actor_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_action_assignments_tenant ON action_assignments(tenant_id);
|
||||
CREATE INDEX idx_action_assignments_actor ON action_assignments(actor_id);
|
||||
CREATE INDEX idx_action_assignments_action ON action_assignments(action_id);
|
||||
52
backend/migrations/007_operating_model_extension_i.sql
Normal file
52
backend/migrations/007_operating_model_extension_i.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-- AP0.8: Operating Model Extension I — blockers, backlog_items, milestones
|
||||
|
||||
CREATE TABLE blockers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed')),
|
||||
reported_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_blockers_tenant_initiative ON blockers(tenant_id, initiative_id);
|
||||
CREATE INDEX idx_blockers_tenant_status ON blockers(tenant_id, status);
|
||||
|
||||
CREATE TABLE backlog_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'new'
|
||||
CHECK (status IN ('new', 'triaged', 'accepted', 'rejected', 'converted')),
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
|
||||
CHECK (priority IN ('low', 'normal', 'high')),
|
||||
converted_action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_backlog_items_tenant_initiative ON backlog_items(tenant_id, initiative_id);
|
||||
CREATE INDEX idx_backlog_items_tenant_status ON backlog_items(tenant_id, status);
|
||||
|
||||
CREATE TABLE milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
goal_description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'planned'
|
||||
CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
|
||||
target_date DATE NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_milestones_tenant_initiative ON milestones(tenant_id, initiative_id);
|
||||
CREATE INDEX idx_milestones_tenant_status ON milestones(tenant_id, status);
|
||||
82
backend/migrations/008_operating_model_extension_ii.sql
Normal file
82
backend/migrations/008_operating_model_extension_ii.sql
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
-- AP0.9: Operating Model Extension II — evidence, decisions, reviews, recurring_elements, action due_at
|
||||
|
||||
ALTER TABLE actions ADD COLUMN due_at TIMESTAMPTZ NULL;
|
||||
|
||||
ALTER TABLE actions DROP CONSTRAINT actions_status_check;
|
||||
|
||||
ALTER TABLE actions ADD CONSTRAINT actions_status_check
|
||||
CHECK (status IN (
|
||||
'open', 'ready', 'in_progress', 'blocked', 'review_required', 'done', 'discarded'
|
||||
));
|
||||
|
||||
CREATE INDEX idx_actions_due_at ON actions(tenant_id, due_at) WHERE due_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE evidence (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
|
||||
milestone_id UUID NULL REFERENCES milestones(id) ON DELETE SET NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'submitted'
|
||||
CHECK (status IN ('submitted', 'accepted', 'rejected')),
|
||||
submitted_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_evidence_tenant_initiative ON evidence(tenant_id, initiative_id);
|
||||
|
||||
CREATE TABLE decisions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'proposed'
|
||||
CHECK (status IN ('proposed', 'decided', 'superseded')),
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
decided_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_decisions_tenant_initiative ON decisions(tenant_id, initiative_id);
|
||||
|
||||
CREATE TABLE reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
|
||||
milestone_id UUID NULL REFERENCES milestones(id) ON DELETE SET NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'planned'
|
||||
CHECK (status IN ('planned', 'completed', 'skipped')),
|
||||
due_at TIMESTAMPTZ NULL,
|
||||
reviewed_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reviews_tenant_initiative ON reviews(tenant_id, initiative_id);
|
||||
CREATE INDEX idx_reviews_due_at ON reviews(tenant_id, due_at) WHERE due_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE recurring_elements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'paused', 'ended')),
|
||||
interval_days INT NULL CHECK (interval_days IS NULL OR interval_days > 0),
|
||||
next_due_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_recurring_elements_tenant_initiative ON recurring_elements(tenant_id, initiative_id);
|
||||
CREATE INDEX idx_recurring_elements_next_due ON recurring_elements(tenant_id, next_due_at)
|
||||
WHERE next_due_at IS NOT NULL;
|
||||
41
backend/migrations/009_steering_contexts.sql
Normal file
41
backend/migrations/009_steering_contexts.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-- AP1.0: Steering Foundation — steering_contexts
|
||||
|
||||
CREATE TABLE steering_contexts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
method_key VARCHAR(64) NOT NULL DEFAULT 'generic_operating',
|
||||
method_version VARCHAR(32) NOT NULL DEFAULT '0.1.0',
|
||||
lifecycle_state VARCHAR(64) NOT NULL DEFAULT 'intake',
|
||||
lifecycle_metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, initiative_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_steering_contexts_tenant ON steering_contexts(tenant_id);
|
||||
CREATE INDEX idx_steering_contexts_initiative ON steering_contexts(tenant_id, initiative_id);
|
||||
|
||||
INSERT INTO steering_contexts (tenant_id, initiative_id, lifecycle_state)
|
||||
SELECT
|
||||
i.tenant_id,
|
||||
i.id,
|
||||
CASE
|
||||
WHEN i.status IN ('completed', 'archived') THEN 'closure'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM actions a
|
||||
WHERE a.initiative_id = i.id
|
||||
AND a.tenant_id = i.tenant_id
|
||||
AND a.status IN (
|
||||
'open', 'ready', 'in_progress', 'blocked', 'review_required'
|
||||
)
|
||||
) THEN 'action_selection'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM backlog_items bi
|
||||
WHERE bi.initiative_id = i.id
|
||||
AND bi.tenant_id = i.tenant_id
|
||||
AND bi.status IN ('new', 'triaged', 'accepted')
|
||||
) THEN 'structure_setup'
|
||||
ELSE 'planning'
|
||||
END
|
||||
FROM initiatives i;
|
||||
86
backend/migrations/010_roadmap_items.sql
Normal file
86
backend/migrations/010_roadmap_items.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
-- AP1.4: Roadmap + RoadmapItem + Dependencies (methodenneutral)
|
||||
|
||||
CREATE TABLE roadmaps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL DEFAULT 'Plan',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, initiative_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_roadmaps_tenant_initiative ON roadmaps(tenant_id, initiative_id);
|
||||
|
||||
CREATE TABLE roadmap_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE,
|
||||
item_type VARCHAR(32) NOT NULL DEFAULT 'milestone'
|
||||
CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage')),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
goal_description TEXT NOT NULL DEFAULT '',
|
||||
definition_of_done JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'planned'
|
||||
CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
|
||||
sequencing_mode VARCHAR(32) NOT NULL DEFAULT 'sequential'
|
||||
CHECK (sequencing_mode IN ('sequential', 'parallel', 'optional')),
|
||||
target_date DATE NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_roadmap_items_tenant_roadmap ON roadmap_items(tenant_id, roadmap_id);
|
||||
CREATE INDEX idx_roadmap_items_tenant_status ON roadmap_items(tenant_id, status);
|
||||
CREATE INDEX idx_roadmap_items_tenant_type ON roadmap_items(tenant_id, item_type);
|
||||
|
||||
CREATE TABLE roadmap_item_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
from_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
|
||||
to_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
|
||||
dependency_type VARCHAR(32) NOT NULL DEFAULT 'requires'
|
||||
CHECK (dependency_type IN ('requires', 'blocks', 'related')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, from_item_id, to_item_id, dependency_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_roadmap_deps_from ON roadmap_item_dependencies(tenant_id, from_item_id);
|
||||
CREATE INDEX idx_roadmap_deps_to ON roadmap_item_dependencies(tenant_id, to_item_id);
|
||||
|
||||
ALTER TABLE backlog_items
|
||||
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE actions
|
||||
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_backlog_roadmap_item ON backlog_items(tenant_id, roadmap_item_id)
|
||||
WHERE roadmap_item_id IS NOT NULL;
|
||||
CREATE INDEX idx_actions_roadmap_item ON actions(tenant_id, roadmap_item_id)
|
||||
WHERE roadmap_item_id IS NOT NULL;
|
||||
|
||||
-- Ein Plan pro Initiative
|
||||
INSERT INTO roadmaps (tenant_id, initiative_id, title)
|
||||
SELECT tenant_id, id, 'Plan' FROM initiatives;
|
||||
|
||||
-- Bestehende Meilensteine → RoadmapItems (IDs beibehalten für Evidence/Review-FKs)
|
||||
INSERT INTO roadmap_items (
|
||||
id, tenant_id, roadmap_id, item_type, title, goal_description, status,
|
||||
sequencing_mode, target_date, sort_order, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
m.id,
|
||||
m.tenant_id,
|
||||
r.id,
|
||||
'milestone',
|
||||
m.title,
|
||||
m.goal_description,
|
||||
m.status,
|
||||
'sequential',
|
||||
m.target_date,
|
||||
0,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM milestones m
|
||||
JOIN roadmaps r ON r.initiative_id = m.initiative_id AND r.tenant_id = m.tenant_id;
|
||||
29
backend/migrations/011_roadmap_item_links.sql
Normal file
29
backend/migrations/011_roadmap_item_links.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- AP1.4b: Plan-Ist-Verknüpfung für Evidence und Reviews
|
||||
|
||||
ALTER TABLE evidence
|
||||
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE reviews
|
||||
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_evidence_roadmap_item ON evidence(tenant_id, roadmap_item_id)
|
||||
WHERE roadmap_item_id IS NOT NULL;
|
||||
CREATE INDEX idx_reviews_roadmap_item ON reviews(tenant_id, roadmap_item_id)
|
||||
WHERE roadmap_item_id IS NOT NULL;
|
||||
|
||||
-- Bestehende milestone_id-Verknüpfungen spiegeln (RoadmapItem behält oft dieselbe UUID)
|
||||
UPDATE evidence e
|
||||
SET roadmap_item_id = e.milestone_id
|
||||
WHERE e.milestone_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM roadmap_items ri
|
||||
WHERE ri.id = e.milestone_id AND ri.tenant_id = e.tenant_id
|
||||
);
|
||||
|
||||
UPDATE reviews r
|
||||
SET roadmap_item_id = r.milestone_id
|
||||
WHERE r.milestone_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM roadmap_items ri
|
||||
WHERE ri.id = r.milestone_id AND ri.tenant_id = r.tenant_id
|
||||
);
|
||||
76
backend/migrations/012_roadmap_item_criteria.sql
Normal file
76
backend/migrations/012_roadmap_item_criteria.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
-- AP1.4b: Gate-Checkliste (Prüfplan pro RoadmapItem)
|
||||
|
||||
CREATE TABLE roadmap_item_criteria (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
roadmap_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
criterion_kind VARCHAR(32) NOT NULL DEFAULT 'evidence_required'
|
||||
CHECK (criterion_kind IN ('manual', 'evidence_required', 'review_required', 'metric')),
|
||||
verification_hint TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'satisfied', 'waived', 'deferred', 'failed')),
|
||||
waived_or_deferred_decision_id UUID NULL REFERENCES decisions(id) ON DELETE SET NULL,
|
||||
satisfied_by_evidence_id UUID NULL REFERENCES evidence(id) ON DELETE SET NULL,
|
||||
satisfied_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_roadmap_criteria_item ON roadmap_item_criteria(tenant_id, roadmap_item_id);
|
||||
CREATE INDEX idx_roadmap_criteria_status ON roadmap_item_criteria(tenant_id, status);
|
||||
|
||||
-- Compat: Default-Kriterium pro bestehendem Plan-Element
|
||||
INSERT INTO roadmap_item_criteria (
|
||||
tenant_id, roadmap_item_id, sort_order, title, description, criterion_kind, status
|
||||
)
|
||||
SELECT
|
||||
ri.tenant_id,
|
||||
ri.id,
|
||||
0,
|
||||
'Gate allgemein',
|
||||
COALESCE(NULLIF(ri.goal_description, ''), 'Akzeptierter Nachweis oder abgeschlossenes Review am Plan-Element.'),
|
||||
'evidence_required',
|
||||
'open'
|
||||
FROM roadmap_items ri;
|
||||
|
||||
-- Bereits vorhandene accepted Evidence → Default-Kriterium satisfied
|
||||
UPDATE roadmap_item_criteria ric
|
||||
SET
|
||||
status = 'satisfied',
|
||||
satisfied_at = NOW(),
|
||||
satisfied_by_evidence_id = sub.evidence_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (ric2.id)
|
||||
ric2.id AS criterion_id,
|
||||
e.id AS evidence_id
|
||||
FROM roadmap_item_criteria ric2
|
||||
JOIN evidence e
|
||||
ON e.tenant_id = ric2.tenant_id
|
||||
AND e.roadmap_item_id = ric2.roadmap_item_id
|
||||
AND e.status = 'accepted'
|
||||
WHERE ric2.title = 'Gate allgemein'
|
||||
ORDER BY ric2.id, e.updated_at DESC
|
||||
) sub
|
||||
WHERE ric.id = sub.criterion_id;
|
||||
|
||||
-- Bereits reached Gates ohne Evidence: Review completed
|
||||
UPDATE roadmap_item_criteria ric
|
||||
SET status = 'satisfied', satisfied_at = NOW()
|
||||
FROM roadmap_items ri
|
||||
WHERE ric.roadmap_item_id = ri.id
|
||||
AND ric.tenant_id = ri.tenant_id
|
||||
AND ric.title = 'Gate allgemein'
|
||||
AND ric.status = 'open'
|
||||
AND ri.status = 'reached'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM reviews rev
|
||||
WHERE rev.tenant_id = ri.tenant_id
|
||||
AND rev.initiative_id IN (
|
||||
SELECT r.initiative_id FROM roadmaps r WHERE r.id = ri.roadmap_id
|
||||
)
|
||||
AND rev.status = 'completed'
|
||||
AND (rev.roadmap_item_id = ri.id OR rev.milestone_id = ri.id)
|
||||
);
|
||||
45
backend/migrations/013_projects_and_tasks.sql
Normal file
45
backend/migrations/013_projects_and_tasks.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- AP1.5: Project, Task, Action.project_id — operative Ist-Hierarchie
|
||||
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'paused', 'completed', 'archived')),
|
||||
roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
target_date DATE NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_projects_tenant_initiative ON projects(tenant_id, initiative_id);
|
||||
CREATE INDEX idx_projects_tenant_status ON projects(tenant_id, status);
|
||||
CREATE INDEX idx_projects_roadmap_item ON projects(tenant_id, roadmap_item_id)
|
||||
WHERE roadmap_item_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE actions
|
||||
ADD COLUMN project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_actions_project ON actions(tenant_id, project_id)
|
||||
WHERE project_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'in_progress', 'done', 'discarded')),
|
||||
roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
due_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tasks_tenant_action ON tasks(tenant_id, action_id);
|
||||
CREATE INDEX idx_tasks_tenant_status ON tasks(tenant_id, status);
|
||||
12
backend/migrations/014_project_hierarchy.sql
Normal file
12
backend/migrations/014_project_hierarchy.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- AP1.5c: Rekursive Projects (parent_project_id, container_kind)
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN parent_project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL,
|
||||
ADD COLUMN container_kind VARCHAR(32) NULL
|
||||
CHECK (
|
||||
container_kind IS NULL
|
||||
OR container_kind IN ('project', 'stream', 'phase', 'release')
|
||||
);
|
||||
|
||||
CREATE INDEX idx_projects_parent ON projects(tenant_id, parent_project_id)
|
||||
WHERE parent_project_id IS NOT NULL;
|
||||
21
backend/migrations/015_backlog_sort_order.sql
Normal file
21
backend/migrations/015_backlog_sort_order.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- AP1.12c: Backlog sort_order für Reorder im Plan-Modus
|
||||
|
||||
ALTER TABLE backlog_items
|
||||
ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
(ROW_NUMBER() OVER (
|
||||
PARTITION BY tenant_id, initiative_id
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) - 1) * 10 AS next_order
|
||||
FROM backlog_items
|
||||
)
|
||||
UPDATE backlog_items bi
|
||||
SET sort_order = ranked.next_order
|
||||
FROM ranked
|
||||
WHERE bi.id = ranked.id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_backlog_items_sort
|
||||
ON backlog_items (tenant_id, initiative_id, sort_order);
|
||||
16
backend/migrations/016_initiative_archetypes.sql
Normal file
16
backend/migrations/016_initiative_archetypes.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- AP1.10a: Initiative Standardfelder + Archetyp-Schlüssel
|
||||
|
||||
ALTER TABLE initiatives
|
||||
ADD COLUMN IF NOT EXISTS vision TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE initiatives
|
||||
ADD COLUMN IF NOT EXISTS target_state_summary TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE initiatives
|
||||
ADD COLUMN IF NOT EXISTS archetype_key VARCHAR(64) NOT NULL DEFAULT 'initiative.generic';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_initiatives_archetype
|
||||
ON initiatives (tenant_id, archetype_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_initiatives_status_tenant
|
||||
ON initiatives (tenant_id, status);
|
||||
59
backend/migrations/017_entity_field_system.sql
Normal file
59
backend/migrations/017_entity_field_system.sql
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
-- AP1.10b: Entity Field System (EFS) — Archetypen, Felddefinitionen, Werte
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entity_archetypes (
|
||||
archetype_key VARCHAR(64) PRIMARY KEY,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_system BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_archetypes_entity_type
|
||||
ON entity_archetypes (entity_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS field_definitions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
archetype_key VARCHAR(64) NOT NULL
|
||||
REFERENCES entity_archetypes (archetype_key) ON DELETE CASCADE,
|
||||
field_key VARCHAR(64) NOT NULL,
|
||||
field_type VARCHAR(32) NOT NULL
|
||||
CHECK (field_type IN (
|
||||
'text', 'longtext', 'number', 'boolean', 'date', 'enum', 'actor_ref', 'url'
|
||||
)),
|
||||
label VARCHAR(255) NOT NULL,
|
||||
required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
searchable BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
validation_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (archetype_key, field_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_definitions_archetype
|
||||
ON field_definitions (archetype_key, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS field_values (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants (id) ON DELETE CASCADE,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
field_key VARCHAR(64) NOT NULL,
|
||||
value_json JSONB,
|
||||
value_text TEXT NOT NULL DEFAULT '',
|
||||
value_number NUMERIC,
|
||||
value_boolean BOOLEAN,
|
||||
value_date DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, entity_type, entity_id, field_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_values_entity
|
||||
ON field_values (tenant_id, entity_type, entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_values_search
|
||||
ON field_values (tenant_id, entity_type, field_key)
|
||||
WHERE value_text <> '';
|
||||
12
backend/migrations/018_parent_task_id.sql
Normal file
12
backend/migrations/018_parent_task_id.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- AP1.5d: Rekursive Tasks unter Action
|
||||
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN IF NOT EXISTS parent_task_id UUID NULL
|
||||
REFERENCES tasks (id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent
|
||||
ON tasks (tenant_id, parent_task_id)
|
||||
WHERE parent_task_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_action_parent
|
||||
ON tasks (tenant_id, action_id, parent_task_id);
|
||||
27
backend/migrations/019_project_archetype_key.sql
Normal file
27
backend/migrations/019_project_archetype_key.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- AP2.0b: Project archetype_key (Spiegel der Initiative)
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS archetype_key VARCHAR(64) NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_archetype
|
||||
ON projects (tenant_id, archetype_key)
|
||||
WHERE archetype_key IS NOT NULL;
|
||||
|
||||
-- Bestehende Projects: Mirror aus Initiative (einmalig)
|
||||
UPDATE projects p
|
||||
SET archetype_key = CASE i.archetype_key
|
||||
WHEN 'initiative.generic' THEN 'project.generic'
|
||||
WHEN 'initiative.maturity_journey' THEN 'project.maturity_journey'
|
||||
WHEN 'initiative.linear_project' THEN 'project.linear_project'
|
||||
WHEN 'initiative.recurring_program' THEN 'project.recurring_program'
|
||||
WHEN 'initiative.support_queue' THEN 'project.support_queue'
|
||||
WHEN 'initiative.program' THEN 'project.program'
|
||||
WHEN 'initiative.product' THEN 'project.product'
|
||||
WHEN 'initiative.dispute_case' THEN 'project.dispute_case'
|
||||
WHEN 'initiative.content_project' THEN 'project.content_project'
|
||||
ELSE 'project.generic'
|
||||
END
|
||||
FROM initiatives i
|
||||
WHERE p.initiative_id = i.id
|
||||
AND p.tenant_id = i.tenant_id
|
||||
AND p.archetype_key IS NULL;
|
||||
17
backend/migrations/020_roadmap_graph_edges.sql
Normal file
17
backend/migrations/020_roadmap_graph_edges.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- AP1.4d: Graph Engine — erweiterte Kantenarten + Parallelgruppe
|
||||
|
||||
ALTER TABLE roadmap_item_dependencies
|
||||
DROP CONSTRAINT IF EXISTS roadmap_item_dependencies_dependency_type_check;
|
||||
|
||||
ALTER TABLE roadmap_item_dependencies
|
||||
ADD CONSTRAINT roadmap_item_dependencies_dependency_type_check
|
||||
CHECK (dependency_type IN (
|
||||
'requires', 'blocks', 'related', 'parallel_group', 'optional_branch'
|
||||
));
|
||||
|
||||
ALTER TABLE roadmap_item_dependencies
|
||||
ADD COLUMN IF NOT EXISTS group_key VARCHAR(128) NULL;
|
||||
|
||||
CREATE INDEX idx_roadmap_deps_group_key
|
||||
ON roadmap_item_dependencies(tenant_id, group_key)
|
||||
WHERE group_key IS NOT NULL;
|
||||
17
backend/migrations/021_roadmap_plan_snapshots.sql
Normal file
17
backend/migrations/021_roadmap_plan_snapshots.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- AP1.14: Plan-Snapshots (Plan-Revision) — Soll-Zustand getrennt von Ist
|
||||
|
||||
CREATE TABLE roadmap_plan_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE,
|
||||
revision_number INT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
plan_payload JSONB NOT NULL,
|
||||
created_by_user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, initiative_id, revision_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_roadmap_plan_snapshots_initiative
|
||||
ON roadmap_plan_snapshots(tenant_id, initiative_id, created_at DESC);
|
||||
24
backend/migrations/022_portfolio_rank.sql
Normal file
24
backend/migrations/022_portfolio_rank.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- AP1.8a: Portfolio-Priorität (relatives Ranking im Tenant)
|
||||
|
||||
ALTER TABLE initiatives
|
||||
ADD COLUMN IF NOT EXISTS portfolio_rank INT;
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY tenant_id
|
||||
ORDER BY updated_at DESC, title
|
||||
) - 1 AS rn
|
||||
FROM initiatives
|
||||
)
|
||||
UPDATE initiatives AS i
|
||||
SET portfolio_rank = ranked.rn
|
||||
FROM ranked
|
||||
WHERE i.id = ranked.id AND i.portfolio_rank IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_initiatives_tenant_portfolio_rank
|
||||
ON initiatives (tenant_id, portfolio_rank);
|
||||
|
||||
COMMENT ON COLUMN initiatives.portfolio_rank IS
|
||||
'Relatives Portfolio-Ranking (0 = höchste Aufmerksamkeit). AP1.8a';
|
||||
32
backend/migrations/023_action_execution_plan.sql
Normal file
32
backend/migrations/023_action_execution_plan.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- AP1.16a: Durchführungsplan — Action-Reihenfolge, kind, Abhängigkeiten
|
||||
|
||||
ALTER TABLE actions
|
||||
ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE actions
|
||||
ADD COLUMN IF NOT EXISTS action_kind VARCHAR(32) NOT NULL DEFAULT 'delivery'
|
||||
CHECK (action_kind IN ('delivery', 'planning', 'review'));
|
||||
|
||||
CREATE INDEX idx_actions_initiative_sort
|
||||
ON actions(tenant_id, initiative_id, sort_order);
|
||||
|
||||
CREATE TABLE action_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
predecessor_action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
|
||||
successor_action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
|
||||
dependency_kind VARCHAR(32) NOT NULL DEFAULT 'requires'
|
||||
CHECK (dependency_kind IN ('requires', 'blocks', 'relates')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, predecessor_action_id, successor_action_id, dependency_kind)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_action_deps_predecessor
|
||||
ON action_dependencies(tenant_id, predecessor_action_id);
|
||||
|
||||
CREATE INDEX idx_action_deps_successor
|
||||
ON action_dependencies(tenant_id, successor_action_id);
|
||||
|
||||
CREATE INDEX idx_action_deps_initiative
|
||||
ON action_dependencies(tenant_id, initiative_id);
|
||||
22
backend/migrations/024_actor_service_tokens.sql
Normal file
22
backend/migrations/024_actor_service_tokens.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- AP1.7c — Actor Service Tokens für Operational API (Vibe-Coder / Agenten)
|
||||
|
||||
CREATE TABLE actor_service_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE CASCADE,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
token_prefix VARCHAR(16) NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
capabilities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
expires_at TIMESTAMPTZ NULL,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
created_by_user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ NULL,
|
||||
UNIQUE (token_prefix)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_actor_service_tokens_tenant ON actor_service_tokens(tenant_id);
|
||||
CREATE INDEX idx_actor_service_tokens_actor ON actor_service_tokens(actor_id);
|
||||
CREATE INDEX idx_actor_service_tokens_hash ON actor_service_tokens(token_hash);
|
||||
11
backend/migrations/025_work_cycle.sql
Normal file
11
backend/migrations/025_work_cycle.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- AP2.0f — work_cycle (Zeitbox/Sprint) + actions.work_cycle_id
|
||||
|
||||
ALTER TABLE roadmap_items DROP CONSTRAINT IF EXISTS roadmap_items_item_type_check;
|
||||
ALTER TABLE roadmap_items ADD CONSTRAINT roadmap_items_item_type_check
|
||||
CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage', 'work_cycle'));
|
||||
|
||||
ALTER TABLE actions
|
||||
ADD COLUMN work_cycle_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_actions_work_cycle ON actions(tenant_id, work_cycle_id)
|
||||
WHERE work_cycle_id IS NOT NULL;
|
||||
26
backend/migrations/026_sprint_planning_kinds.sql
Normal file
26
backend/migrations/026_sprint_planning_kinds.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- AP2.2e: Sprint-Planung — Item-Arten, Feature-Referenz, Unplan-Support
|
||||
|
||||
ALTER TABLE actions DROP CONSTRAINT IF EXISTS actions_action_kind_check;
|
||||
ALTER TABLE actions
|
||||
ADD CONSTRAINT actions_action_kind_check
|
||||
CHECK (action_kind IN ('delivery', 'planning', 'review', 'bug', 'issue'));
|
||||
|
||||
ALTER TABLE actions
|
||||
ADD COLUMN IF NOT EXISTS parent_action_id UUID NULL
|
||||
REFERENCES actions (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_actions_parent
|
||||
ON actions (tenant_id, initiative_id, parent_action_id)
|
||||
WHERE parent_action_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE backlog_items
|
||||
ADD COLUMN IF NOT EXISTS item_kind VARCHAR(32) NOT NULL DEFAULT 'story'
|
||||
CHECK (item_kind IN ('story', 'bug', 'issue'));
|
||||
|
||||
ALTER TABLE backlog_items
|
||||
ADD COLUMN IF NOT EXISTS parent_action_id UUID NULL
|
||||
REFERENCES actions (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_backlog_parent_action
|
||||
ON backlog_items (tenant_id, initiative_id, parent_action_id)
|
||||
WHERE parent_action_id IS NOT NULL;
|
||||
4
backend/migrations/027_entity_archetype_ui_profile.sql
Normal file
4
backend/migrations/027_entity_archetype_ui_profile.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- AP2.3a: UI-Profil pro Archetyp (Registry-Sync → Frontend Operating Context)
|
||||
|
||||
ALTER TABLE entity_archetypes
|
||||
ADD COLUMN IF NOT EXISTS ui_profile_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
14
backend/migrations/028_backlog_epic_hierarchy.sql
Normal file
14
backend/migrations/028_backlog_epic_hierarchy.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- AP2.2f / ADP Backlog Epic-Hierarchie — parent_backlog_id + item_kind epic
|
||||
|
||||
ALTER TABLE backlog_items
|
||||
ADD COLUMN IF NOT EXISTS parent_backlog_id UUID NULL
|
||||
REFERENCES backlog_items (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_backlog_parent_backlog
|
||||
ON backlog_items (tenant_id, initiative_id, parent_backlog_id)
|
||||
WHERE parent_backlog_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE backlog_items DROP CONSTRAINT IF EXISTS backlog_items_item_kind_check;
|
||||
ALTER TABLE backlog_items
|
||||
ADD CONSTRAINT backlog_items_item_kind_check
|
||||
CHECK (item_kind IN ('story', 'bug', 'issue', 'epic'));
|
||||
11
backend/migrations/029_tech_debt_kinds.sql
Normal file
11
backend/migrations/029_tech_debt_kinds.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- P7: Technische Schuld — item_kind / action_kind tech_debt
|
||||
|
||||
ALTER TABLE actions DROP CONSTRAINT IF EXISTS actions_action_kind_check;
|
||||
ALTER TABLE actions
|
||||
ADD CONSTRAINT actions_action_kind_check
|
||||
CHECK (action_kind IN ('delivery', 'planning', 'review', 'bug', 'issue', 'tech_debt'));
|
||||
|
||||
ALTER TABLE backlog_items DROP CONSTRAINT IF EXISTS backlog_items_item_kind_check;
|
||||
ALTER TABLE backlog_items
|
||||
ADD CONSTRAINT backlog_items_item_kind_check
|
||||
CHECK (item_kind IN ('story', 'bug', 'issue', 'epic', 'tech_debt'));
|
||||
25
backend/migrations/030_cadence_instances.sql
Normal file
25
backend/migrations/030_cadence_instances.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
-- AP A1.1 — CadenceInstance (minimal): eine offene Instanz pro Recurring, Tages-Tracking
|
||||
|
||||
CREATE TABLE cadence_instances (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
recurring_element_id UUID NOT NULL REFERENCES recurring_elements(id) ON DELETE CASCADE,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'completed')),
|
||||
due_at TIMESTAMPTZ NOT NULL,
|
||||
completed_at TIMESTAMPTZ NULL,
|
||||
measurement_note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cadence_instances_tenant_recurring
|
||||
ON cadence_instances(tenant_id, recurring_element_id);
|
||||
|
||||
CREATE INDEX idx_cadence_instances_open_due
|
||||
ON cadence_instances(tenant_id, due_at)
|
||||
WHERE status = 'open';
|
||||
|
||||
CREATE UNIQUE INDEX idx_cadence_instances_one_open_per_recurring
|
||||
ON cadence_instances(recurring_element_id)
|
||||
WHERE status = 'open';
|
||||
9
backend/migrations/031_recurring_stage_binding.sql
Normal file
9
backend/migrations/031_recurring_stage_binding.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- AP A1.2 — RecurringElement an Reifegrad-Stufe binden (Activity Set)
|
||||
|
||||
ALTER TABLE recurring_elements
|
||||
ADD COLUMN roadmap_item_id UUID NULL
|
||||
REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_recurring_elements_stage
|
||||
ON recurring_elements(tenant_id, roadmap_item_id)
|
||||
WHERE roadmap_item_id IS NOT NULL;
|
||||
26
backend/migrations/032_a1_progression_join.sql
Normal file
26
backend/migrations/032_a1_progression_join.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- AP-A1-PM-2: Progression graph — join_gate, strand binding, recurring strand
|
||||
|
||||
ALTER TABLE roadmap_items
|
||||
DROP CONSTRAINT IF EXISTS roadmap_items_item_type_check;
|
||||
|
||||
ALTER TABLE roadmap_items
|
||||
ADD CONSTRAINT roadmap_items_item_type_check
|
||||
CHECK (item_type IN (
|
||||
'milestone', 'review_gate', 'maturity_stage', 'work_cycle', 'join_gate'
|
||||
));
|
||||
|
||||
ALTER TABLE roadmap_items
|
||||
ADD COLUMN IF NOT EXISTS strand_project_id UUID NULL
|
||||
REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_roadmap_items_strand
|
||||
ON roadmap_items(tenant_id, strand_project_id)
|
||||
WHERE strand_project_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE recurring_elements
|
||||
ADD COLUMN IF NOT EXISTS project_id UUID NULL
|
||||
REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recurring_elements_project
|
||||
ON recurring_elements(tenant_id, project_id)
|
||||
WHERE project_id IS NOT NULL;
|
||||
51
backend/migrations/033_unified_schedules.sql
Normal file
51
backend/migrations/033_unified_schedules.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- Unified Schedule & Cadence Layer (ADP v0.1)
|
||||
|
||||
CREATE TABLE schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
schedule_kind VARCHAR(16) NOT NULL DEFAULT 'interval'
|
||||
CHECK (schedule_kind IN ('interval', 'weekdays')),
|
||||
interval_days INT NULL CHECK (interval_days IS NULL OR interval_days > 0),
|
||||
weekday_mask INT NOT NULL DEFAULT 0,
|
||||
pause_until DATE NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_schedules_tenant ON schedules(tenant_id);
|
||||
|
||||
ALTER TABLE recurring_elements
|
||||
ADD COLUMN IF NOT EXISTS schedule_id UUID NULL
|
||||
REFERENCES schedules(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE recurring_elements
|
||||
ADD COLUMN IF NOT EXISTS action_id UUID NULL
|
||||
REFERENCES actions(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE UNIQUE INDEX idx_recurring_elements_action
|
||||
ON recurring_elements(action_id)
|
||||
WHERE action_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE actions
|
||||
ADD COLUMN IF NOT EXISTS schedule_id UUID NULL
|
||||
REFERENCES schedules(id) ON DELETE SET NULL;
|
||||
|
||||
-- Backfill: one schedule per recurring without schedule_id
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
sid UUID;
|
||||
idays INT;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT id, tenant_id, interval_days
|
||||
FROM recurring_elements
|
||||
WHERE schedule_id IS NULL
|
||||
LOOP
|
||||
idays := GREATEST(COALESCE(r.interval_days, 1), 1);
|
||||
INSERT INTO schedules (tenant_id, schedule_kind, interval_days, weekday_mask)
|
||||
VALUES (r.tenant_id, 'interval', idays, 0)
|
||||
RETURNING id INTO sid;
|
||||
UPDATE recurring_elements SET schedule_id = sid WHERE id = r.id;
|
||||
END LOOP;
|
||||
END $$;
|
||||
92
backend/operational_auth.py
Normal file
92
backend/operational_auth.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Operational API auth — Session or Actor Service Token (AP1.7c)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
from auth import AUTH_HEADER, get_session
|
||||
from capabilities import _enforce_capability
|
||||
from services.actor_service_tokens import validate_service_token
|
||||
from tenant_context import TenantContext, resolve_tenant_context
|
||||
|
||||
ACTOR_TOKEN_HEADER = "X-Actor-Token"
|
||||
|
||||
|
||||
def _extract_actor_token(
|
||||
x_actor_token: Optional[str],
|
||||
authorization: Optional[str],
|
||||
) -> Optional[str]:
|
||||
if x_actor_token and x_actor_token.strip():
|
||||
return x_actor_token.strip()
|
||||
if authorization:
|
||||
scheme, _, value = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_service_token_context(raw_token: str) -> TenantContext:
|
||||
resolved = validate_service_token(raw_token)
|
||||
if not resolved:
|
||||
raise HTTPException(status_code=401, detail="Ungültiger oder abgelaufener Actor-Token")
|
||||
|
||||
return TenantContext(
|
||||
user_id="",
|
||||
email="",
|
||||
display_name=resolved["actor_name"],
|
||||
portal_role="",
|
||||
tenant_id=resolved["tenant_id"],
|
||||
tenant_slug=resolved["tenant_slug"],
|
||||
tenant_name=resolved["tenant_name"],
|
||||
tenant_role=None,
|
||||
actor_id=resolved["actor_id"],
|
||||
actor_type=resolved["actor_type"],
|
||||
session_token="",
|
||||
capabilities=resolved["capabilities"],
|
||||
auth_source="service_token",
|
||||
service_token_id=resolved["token_id"],
|
||||
)
|
||||
|
||||
|
||||
def get_operational_context(
|
||||
x_actor_token: Optional[str] = Header(default=None, alias=ACTOR_TOKEN_HEADER),
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
x_auth_token: Optional[str] = Header(default=None, alias=AUTH_HEADER),
|
||||
) -> TenantContext:
|
||||
actor_token = _extract_actor_token(x_actor_token, authorization)
|
||||
if actor_token:
|
||||
return resolve_service_token_context(actor_token)
|
||||
|
||||
session_token = (x_auth_token or "").strip()
|
||||
if not session_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentifizierung erforderlich (X-Actor-Token oder X-Auth-Token)",
|
||||
)
|
||||
session = get_session(session_token)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Ungültige Session")
|
||||
ctx = resolve_tenant_context(session)
|
||||
if not ctx.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Kein aktiver Tenant — Tenant wählen")
|
||||
return ctx
|
||||
|
||||
|
||||
def require_operational_capability(capability_key: str) -> Callable[..., TenantContext]:
|
||||
"""Service tokens: always enforce. Sessions: probe/enforce per CAPABILITY_ENFORCE."""
|
||||
|
||||
def _dependency(
|
||||
ctx: TenantContext = Depends(get_operational_context),
|
||||
) -> TenantContext:
|
||||
if ctx.auth_source == "service_token":
|
||||
if capability_key not in ctx.capabilities:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Capability fehlt auf Service Token: {capability_key}",
|
||||
)
|
||||
return ctx
|
||||
return _enforce_capability(ctx, capability_key)
|
||||
|
||||
return _dependency
|
||||
|
|
@ -1,5 +1,19 @@
|
|||
"""Import all module registrations — side effect registers capabilities."""
|
||||
|
||||
from . import platform, registry_ops, tenant_ops # noqa: F401
|
||||
from . import ( # noqa: F401
|
||||
initiative_ops,
|
||||
operating_model_ops,
|
||||
platform,
|
||||
registry_ops,
|
||||
tenant_ops,
|
||||
workspace_ops,
|
||||
)
|
||||
|
||||
__all__ = ["platform", "registry_ops", "tenant_ops"]
|
||||
__all__ = [
|
||||
"initiative_ops",
|
||||
"operating_model_ops",
|
||||
"platform",
|
||||
"registry_ops",
|
||||
"tenant_ops",
|
||||
"workspace_ops",
|
||||
]
|
||||
|
|
|
|||
63
backend/rights_registrations/initiative_ops.py
Normal file
63
backend/rights_registrations/initiative_ops.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Initiative and action capabilities (AP0.5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rights_registry import CapabilityRegistration, register_capability
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.initiative.read",
|
||||
module="initiative",
|
||||
description="Vorhaben im aktiven Tenant lesen",
|
||||
default_grants=(
|
||||
("portal", "admin"),
|
||||
("portal", "user"),
|
||||
("tenant", "owner"),
|
||||
("tenant", "admin"),
|
||||
("tenant", "member"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.initiative.manage",
|
||||
module="initiative",
|
||||
description="Vorhaben im aktiven Tenant anlegen und bearbeiten",
|
||||
default_grants=(
|
||||
("portal", "admin"),
|
||||
("tenant", "owner"),
|
||||
("tenant", "admin"),
|
||||
("tenant", "member"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.action.read",
|
||||
module="action",
|
||||
description="Maßnahmen im aktiven Tenant lesen",
|
||||
default_grants=(
|
||||
("portal", "admin"),
|
||||
("portal", "user"),
|
||||
("tenant", "owner"),
|
||||
("tenant", "admin"),
|
||||
("tenant", "member"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.action.manage",
|
||||
module="action",
|
||||
description="Maßnahmen anlegen, bearbeiten, zuweisen und Status ändern",
|
||||
default_grants=(
|
||||
("portal", "admin"),
|
||||
("tenant", "owner"),
|
||||
("tenant", "admin"),
|
||||
("tenant", "member"),
|
||||
),
|
||||
)
|
||||
)
|
||||
191
backend/rights_registrations/operating_model_ops.py
Normal file
191
backend/rights_registrations/operating_model_ops.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Operating model capabilities (AP0.8)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rights_registry import CapabilityRegistration, register_capability
|
||||
|
||||
_MEMBER_READ = (
|
||||
("portal", "admin"),
|
||||
("portal", "user"),
|
||||
("tenant", "owner"),
|
||||
("tenant", "admin"),
|
||||
("tenant", "member"),
|
||||
)
|
||||
|
||||
_MEMBER_MANAGE = (
|
||||
("portal", "admin"),
|
||||
("tenant", "owner"),
|
||||
("tenant", "admin"),
|
||||
("tenant", "member"),
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.attention.read",
|
||||
module="attention",
|
||||
description="Attention- und NextAction-Sichten lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.blocker.read",
|
||||
module="blocker",
|
||||
description="Blocker im aktiven Tenant lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.blocker.manage",
|
||||
module="blocker",
|
||||
description="Blocker anlegen und bearbeiten",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.backlog.read",
|
||||
module="backlog",
|
||||
description="Backlog-Items im aktiven Tenant lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.backlog.manage",
|
||||
module="backlog",
|
||||
description="Backlog-Items anlegen, bearbeiten und konvertieren",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.milestone.read",
|
||||
module="milestone",
|
||||
description="Meilensteine im aktiven Tenant lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.milestone.manage",
|
||||
module="milestone",
|
||||
description="Meilensteine anlegen und bearbeiten",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.evidence.read",
|
||||
module="evidence",
|
||||
description="Evidence im aktiven Tenant lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.evidence.manage",
|
||||
module="evidence",
|
||||
description="Evidence anlegen und bearbeiten",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.decision.read",
|
||||
module="decision",
|
||||
description="Entscheidungen im aktiven Tenant lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.decision.manage",
|
||||
module="decision",
|
||||
description="Entscheidungen anlegen und bearbeiten",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.review.read",
|
||||
module="review",
|
||||
description="Reviews im aktiven Tenant lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.review.manage",
|
||||
module="review",
|
||||
description="Reviews anlegen und bearbeiten",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.recurring.read",
|
||||
module="recurring",
|
||||
description="Recurring-Elemente im aktiven Tenant lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.recurring.manage",
|
||||
module="recurring",
|
||||
description="Recurring-Elemente anlegen und bearbeiten",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.roadmap.reopen",
|
||||
module="roadmap",
|
||||
description="Erreichte Plan-Elemente wieder öffnen (Reopen)",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.roadmap.reopen.emergency",
|
||||
module="roadmap",
|
||||
description="Plan-Reopen als Portal-Admin (Notfall)",
|
||||
default_grants=(("portal", "admin"),),
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.project.read",
|
||||
module="project",
|
||||
description="Projekte im aktiven Tenant lesen",
|
||||
default_grants=_MEMBER_READ,
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.project.manage",
|
||||
module="project",
|
||||
description="Projekte anlegen und bearbeiten",
|
||||
default_grants=_MEMBER_MANAGE,
|
||||
)
|
||||
)
|
||||
35
backend/rights_registrations/workspace_ops.py
Normal file
35
backend/rights_registrations/workspace_ops.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Workspace read capabilities (AP0.7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rights_registry import CapabilityRegistration, register_capability
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.actor.read",
|
||||
module="tenant",
|
||||
description="Actors im aktiven Tenant lesen (Directory)",
|
||||
default_grants=(
|
||||
("portal", "admin"),
|
||||
("portal", "user"),
|
||||
("tenant", "owner"),
|
||||
("tenant", "admin"),
|
||||
("tenant", "member"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
key="kairo.workspace.read",
|
||||
module="workspace",
|
||||
description="Workspace-Übersichten und aggregierte Read-Models lesen",
|
||||
default_grants=(
|
||||
("portal", "admin"),
|
||||
("portal", "user"),
|
||||
("tenant", "owner"),
|
||||
("tenant", "admin"),
|
||||
("tenant", "member"),
|
||||
),
|
||||
)
|
||||
)
|
||||
251
backend/routers/actions.py
Normal file
251
backend/routers/actions.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"""Action (Maßnahme) API — AP0.5 / AP0.7."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from data_layer import actions as dl_actions
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from schemas.schedule_payload import SchedulePayload
|
||||
from services import actions as action_service
|
||||
from services import scheduled_work as scheduled_work_service
|
||||
from services import tasks as task_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/actions", tags=["actions"])
|
||||
|
||||
|
||||
class ActionUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[
|
||||
Literal["open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"]
|
||||
] = None
|
||||
priority: Optional[Literal["low", "normal", "high"]] = None
|
||||
due_at: Optional[str] = None
|
||||
clear_due_at: bool = False
|
||||
project_id: Optional[str] = None
|
||||
clear_project: bool = False
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
sort_order: Optional[int] = None
|
||||
action_kind: Optional[Literal["delivery", "planning", "review", "bug", "issue"]] = None
|
||||
work_cycle_id: Optional[str] = None
|
||||
clear_work_cycle: bool = False
|
||||
parent_action_id: Optional[str] = None
|
||||
clear_parent_action: bool = False
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["open", "in_progress", "done", "discarded"] = "open"
|
||||
parent_task_id: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
due_at: Optional[str] = None
|
||||
|
||||
|
||||
class ActionAssignmentsRequest(BaseModel):
|
||||
actor_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/me/open")
|
||||
def list_my_open_actions(
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
|
||||
):
|
||||
try:
|
||||
return dl_actions.get_all_my_open_actions(ctx)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{action_id}/tasks")
|
||||
def list_action_tasks(
|
||||
action_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
|
||||
):
|
||||
try:
|
||||
return task_service.list_tasks_for_action(
|
||||
tenant_id=ctx.tenant_id, action_id=action_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{action_id}/tasks", status_code=201)
|
||||
def create_action_task(
|
||||
action_id: str,
|
||||
body: TaskCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
due_at = None
|
||||
if body.due_at:
|
||||
try:
|
||||
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
|
||||
try:
|
||||
return task_service.create_task(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
parent_task_id=body.parent_task_id,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
sort_order=body.sort_order,
|
||||
due_at=due_at,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{action_id}")
|
||||
def get_action(
|
||||
action_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
|
||||
):
|
||||
item = action_service.get_action(tenant_id=ctx.tenant_id, action_id=action_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{action_id}")
|
||||
def update_action(
|
||||
action_id: str,
|
||||
body: ActionUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
due_at = None
|
||||
if body.due_at is not None:
|
||||
try:
|
||||
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
|
||||
try:
|
||||
item = action_service.update_action(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
due_at=due_at,
|
||||
clear_due_at=body.clear_due_at,
|
||||
project_id=body.project_id,
|
||||
clear_project=body.clear_project,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
work_cycle_id=body.work_cycle_id,
|
||||
clear_work_cycle=body.clear_work_cycle,
|
||||
sort_order=body.sort_order,
|
||||
action_kind=body.action_kind,
|
||||
parent_action_id=body.parent_action_id,
|
||||
clear_parent_action=body.clear_parent_action,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.put("/{action_id}/schedule")
|
||||
def upsert_action_schedule(
|
||||
action_id: str,
|
||||
body: SchedulePayload,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
return scheduled_work_service.upsert_action_schedule(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
user_id=ctx.user_id,
|
||||
schedule_kind=body.schedule_kind,
|
||||
interval_days=body.interval_days,
|
||||
weekday_mask=body.weekday_mask,
|
||||
pause_until=body.pause_until_date(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Arbeitspaket nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.delete("/{action_id}/schedule", status_code=200)
|
||||
def clear_action_schedule(
|
||||
action_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
return scheduled_work_service.clear_action_schedule(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Arbeitspaket nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.put("/{action_id}/assignments")
|
||||
def set_action_assignments(
|
||||
action_id: str,
|
||||
body: ActionAssignmentsRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
item = action_service.set_action_assignments(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
actor_ids=body.actor_ids,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/{action_id}/unplan-to-backlog", status_code=200)
|
||||
def unplan_action_to_backlog(
|
||||
action_id: str,
|
||||
carryover: bool = False,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
return action_service.unplan_action_to_backlog(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
user_id=ctx.user_id,
|
||||
carryover=carryover,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Arbeitspaket nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.delete("/{action_id}", status_code=204)
|
||||
def delete_action(
|
||||
action_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
if not action_service.delete_action(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
user_id=ctx.user_id,
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
|
||||
125
backend/routers/actors.py
Normal file
125
backend/routers/actors.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Actor directory API — AP0.7 + AP1.7c Service Tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from services import actor_service_tokens
|
||||
from services import actors as actor_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/actors", tags=["actors"])
|
||||
|
||||
|
||||
class ServiceTokenCreateRequest(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=255)
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
expires_in_days: Optional[int] = Field(default=365, ge=1, le=730)
|
||||
|
||||
|
||||
class ActorCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
actor_type: Literal["human", "agent", "working_group", "external_system"] = "agent"
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_actors(
|
||||
actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
|
||||
include_inactive: bool = Query(default=False),
|
||||
q: Optional[str] = Query(default=None, max_length=100),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
|
||||
):
|
||||
try:
|
||||
return actor_service.list_actors(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_type=actor_type,
|
||||
include_inactive=include_inactive,
|
||||
q=q,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_actor(
|
||||
body: ActorCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
|
||||
):
|
||||
if body.actor_type == "human":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Human-Actors werden über User-Provisioning angelegt",
|
||||
)
|
||||
try:
|
||||
return actor_service.create_actor(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_type=body.actor_type,
|
||||
name=body.name.strip(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{actor_id}")
|
||||
def get_actor(
|
||||
actor_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
|
||||
):
|
||||
item = actor_service.get_actor(tenant_id=ctx.tenant_id, actor_id=actor_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Actor nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/{actor_id}/service-tokens")
|
||||
def list_actor_service_tokens(
|
||||
actor_id: str,
|
||||
include_revoked: bool = Query(default=False),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
|
||||
):
|
||||
actor = actor_service.get_actor(tenant_id=ctx.tenant_id, actor_id=actor_id)
|
||||
if not actor:
|
||||
raise HTTPException(status_code=404, detail="Actor nicht gefunden")
|
||||
return actor_service_tokens.list_service_tokens_for_actor(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=actor_id,
|
||||
include_revoked=include_revoked,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{actor_id}/service-tokens", status_code=201)
|
||||
def create_actor_service_token(
|
||||
actor_id: str,
|
||||
body: ServiceTokenCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
|
||||
):
|
||||
try:
|
||||
return actor_service_tokens.create_service_token(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=actor_id,
|
||||
label=body.label,
|
||||
created_by_user_id=ctx.user_id,
|
||||
capabilities=body.capabilities,
|
||||
expires_in_days=body.expires_in_days,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Actor nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.delete("/service-tokens/{token_id}", status_code=204)
|
||||
def revoke_actor_service_token(
|
||||
token_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
|
||||
):
|
||||
if not actor_service_tokens.revoke_service_token(
|
||||
tenant_id=ctx.tenant_id,
|
||||
token_id=token_id,
|
||||
user_id=ctx.user_id,
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Token nicht gefunden")
|
||||
132
backend/routers/backlog.py
Normal file
132
backend/routers/backlog.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""BacklogItem API — AP0.8c."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import backlog as backlog_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/backlog", tags=["backlog"])
|
||||
|
||||
|
||||
class BacklogCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
|
||||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
roadmap_item_id: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
item_kind: Literal["story", "bug", "issue", "epic", "tech_debt"] = "story"
|
||||
parent_action_id: Optional[str] = None
|
||||
parent_backlog_id: Optional[str] = None
|
||||
|
||||
|
||||
class BacklogUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None
|
||||
priority: Optional[Literal["low", "normal", "high"]] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
sort_order: Optional[int] = None
|
||||
item_kind: Optional[Literal["story", "bug", "issue", "epic", "tech_debt"]] = None
|
||||
parent_action_id: Optional[str] = None
|
||||
clear_parent_action: bool = False
|
||||
parent_backlog_id: Optional[str] = None
|
||||
clear_parent_backlog: bool = False
|
||||
|
||||
|
||||
class BacklogConvertRequest(BaseModel):
|
||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||
work_cycle_id: Optional[str] = None
|
||||
assign_active_sprint: bool = True
|
||||
|
||||
|
||||
@router.get("/{backlog_item_id}")
|
||||
def get_backlog_item(
|
||||
backlog_item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.backlog.read")),
|
||||
):
|
||||
item = backlog_service.get_backlog_item(
|
||||
tenant_id=ctx.tenant_id, backlog_item_id=backlog_item_id
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{backlog_item_id}")
|
||||
def update_backlog_item(
|
||||
backlog_item_id: str,
|
||||
body: BacklogUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
|
||||
):
|
||||
try:
|
||||
item = backlog_service.update_backlog_item(
|
||||
tenant_id=ctx.tenant_id,
|
||||
backlog_item_id=backlog_item_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
sort_order=body.sort_order,
|
||||
item_kind=body.item_kind,
|
||||
parent_action_id=body.parent_action_id,
|
||||
clear_parent_action=body.clear_parent_action,
|
||||
parent_backlog_id=body.parent_backlog_id,
|
||||
clear_parent_backlog=body.clear_parent_backlog,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{backlog_item_id}", status_code=204)
|
||||
def delete_backlog_item(
|
||||
backlog_item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
|
||||
):
|
||||
try:
|
||||
deleted = backlog_service.delete_backlog_item(
|
||||
tenant_id=ctx.tenant_id,
|
||||
backlog_item_id=backlog_item_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
|
||||
|
||||
|
||||
@router.post("/{backlog_item_id}/convert-to-action", status_code=201)
|
||||
def convert_backlog_to_action(
|
||||
backlog_item_id: str,
|
||||
body: BacklogConvertRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
|
||||
):
|
||||
assigned = body.assigned_actor_ids
|
||||
if not assigned and ctx.actor_id:
|
||||
assigned = [ctx.actor_id]
|
||||
try:
|
||||
return backlog_service.convert_backlog_to_action(
|
||||
tenant_id=ctx.tenant_id,
|
||||
backlog_item_id=backlog_item_id,
|
||||
user_id=ctx.user_id,
|
||||
assigned_actor_ids=assigned,
|
||||
work_cycle_id=body.work_cycle_id,
|
||||
assign_active_sprint=body.assign_active_sprint,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Backlog-Item nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
77
backend/routers/blockers.py
Normal file
77
backend/routers/blockers.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Blocker API — AP0.8b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import blockers as blocker_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/blockers", tags=["blockers"])
|
||||
|
||||
|
||||
class BlockerCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open"
|
||||
action_id: Optional[str] = None
|
||||
set_action_blocked: bool = False
|
||||
|
||||
|
||||
class BlockerUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[
|
||||
Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
|
||||
] = None
|
||||
action_id: Optional[str] = None
|
||||
clear_action_id: bool = False
|
||||
|
||||
|
||||
@router.get("/{blocker_id}")
|
||||
def get_blocker(
|
||||
blocker_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.blocker.read")),
|
||||
):
|
||||
item = blocker_service.get_blocker(tenant_id=ctx.tenant_id, blocker_id=blocker_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{blocker_id}")
|
||||
def update_blocker(
|
||||
blocker_id: str,
|
||||
body: BlockerUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
|
||||
):
|
||||
try:
|
||||
item = blocker_service.update_blocker(
|
||||
tenant_id=ctx.tenant_id,
|
||||
blocker_id=blocker_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
action_id=body.action_id,
|
||||
clear_action_id=body.clear_action_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{blocker_id}", status_code=204)
|
||||
def delete_blocker(
|
||||
blocker_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
|
||||
):
|
||||
if not blocker_service.delete_blocker(
|
||||
tenant_id=ctx.tenant_id, blocker_id=blocker_id, user_id=ctx.user_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
|
||||
67
backend/routers/decisions.py
Normal file
67
backend/routers/decisions.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Decision API — AP0.9c."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import decisions as decision_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/decisions", tags=["decisions"])
|
||||
|
||||
|
||||
class DecisionUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[Literal["proposed", "decided", "superseded"]] = None
|
||||
outcome: Optional[str] = None
|
||||
decided_by_actor_id: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{decision_id}")
|
||||
def get_decision(
|
||||
decision_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.decision.read")),
|
||||
):
|
||||
item = decision_service.get_decision(tenant_id=ctx.tenant_id, decision_id=decision_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{decision_id}")
|
||||
def update_decision(
|
||||
decision_id: str,
|
||||
body: DecisionUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
|
||||
):
|
||||
try:
|
||||
item = decision_service.update_decision(
|
||||
tenant_id=ctx.tenant_id,
|
||||
decision_id=decision_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
outcome=body.outcome,
|
||||
decided_by_actor_id=body.decided_by_actor_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{decision_id}", status_code=204)
|
||||
def delete_decision(
|
||||
decision_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
|
||||
):
|
||||
if not decision_service.delete_decision(
|
||||
tenant_id=ctx.tenant_id, decision_id=decision_id, user_id=ctx.user_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
|
||||
53
backend/routers/entity_archetypes.py
Normal file
53
backend/routers/entity_archetypes.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Entity-Archetypen API — AP1.10a/10b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from entity_archetypes import list_archetypes, validate_archetype_key
|
||||
from services.archetype_starter_kit import get_starter_preview
|
||||
from entity_fields.service import list_field_definitions
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/entity-archetypes", tags=["entity-archetypes"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_entity_archetypes(
|
||||
entity_type: Optional[str] = Query(default=None),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
_ = ctx
|
||||
return list_archetypes(entity_type=entity_type)
|
||||
|
||||
|
||||
@router.get("/field-definitions")
|
||||
def get_archetype_field_definitions(
|
||||
entity_type: str = Query(..., min_length=1),
|
||||
archetype_key: str = Query(..., min_length=1),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
_ = ctx
|
||||
archetypes = list_archetypes(entity_type=entity_type)
|
||||
if not any(item["key"] == archetype_key for item in archetypes):
|
||||
raise HTTPException(status_code=404, detail="Archetyp nicht gefunden")
|
||||
return list_field_definitions(archetype_key=archetype_key)
|
||||
|
||||
|
||||
@router.get("/{archetype_key}/starter-preview")
|
||||
def get_archetype_starter_preview(
|
||||
archetype_key: str,
|
||||
method_profile_key: Optional[str] = Query(default=None),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
_ = ctx
|
||||
try:
|
||||
validate_archetype_key(entity_type="initiative", archetype_key=archetype_key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return get_starter_preview(
|
||||
archetype_key=archetype_key,
|
||||
method_profile_key=method_profile_key,
|
||||
)
|
||||
75
backend/routers/evidence.py
Normal file
75
backend/routers/evidence.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Evidence API — AP0.9b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import evidence as evidence_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/evidence", tags=["evidence"])
|
||||
|
||||
|
||||
class EvidenceUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[Literal["submitted", "accepted", "rejected"]] = None
|
||||
action_id: Optional[str] = None
|
||||
milestone_id: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_action_id: bool = False
|
||||
clear_milestone_id: bool = False
|
||||
clear_roadmap_item_id: bool = False
|
||||
|
||||
|
||||
@router.get("/{evidence_id}")
|
||||
def get_evidence(
|
||||
evidence_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.evidence.read")),
|
||||
):
|
||||
item = evidence_service.get_evidence(tenant_id=ctx.tenant_id, evidence_id=evidence_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{evidence_id}")
|
||||
def update_evidence(
|
||||
evidence_id: str,
|
||||
body: EvidenceUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
|
||||
):
|
||||
try:
|
||||
item = evidence_service.update_evidence(
|
||||
tenant_id=ctx.tenant_id,
|
||||
evidence_id=evidence_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
action_id=body.action_id,
|
||||
milestone_id=body.milestone_id,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_action_id=body.clear_action_id,
|
||||
clear_milestone_id=body.clear_milestone_id,
|
||||
clear_roadmap_item_id=body.clear_roadmap_item_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{evidence_id}", status_code=204)
|
||||
def delete_evidence(
|
||||
evidence_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
|
||||
):
|
||||
if not evidence_service.delete_evidence(
|
||||
tenant_id=ctx.tenant_id, evidence_id=evidence_id, user_id=ctx.user_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
|
||||
119
backend/routers/execution_plan.py
Normal file
119
backend/routers/execution_plan.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Execution plan API — AP1.16b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from services import actions as action_service
|
||||
from services import execution_plan as execution_plan_service
|
||||
from steering.graph.execution_engine import load_initiative_execution_graph_state
|
||||
from tenant_context import TenantContext
|
||||
|
||||
initiative_router = APIRouter(prefix="/api/initiatives", tags=["execution-plan"])
|
||||
|
||||
|
||||
class ActionDependencyCreateRequest(BaseModel):
|
||||
predecessor_action_id: str
|
||||
successor_action_id: str
|
||||
dependency_kind: Literal["requires", "blocks", "relates"] = "requires"
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/execution/graph-state")
|
||||
def get_initiative_execution_graph_state(
|
||||
initiative_id: str,
|
||||
scope_roadmap_item_id: Optional[str] = Query(default=None),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
|
||||
):
|
||||
try:
|
||||
state = load_initiative_execution_graph_state(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
scope_roadmap_item_id=scope_roadmap_item_id,
|
||||
)
|
||||
if scope_roadmap_item_id is None:
|
||||
from services import actions as action_service
|
||||
from services import roadmap as roadmap_service
|
||||
|
||||
all_actions = action_service.list_actions_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
roadmap_items = roadmap_service.list_roadmap_items_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
from steering.graph.execution_engine import compute_planning_debt
|
||||
|
||||
state["planning_debt"] = compute_planning_debt(
|
||||
actions=all_actions,
|
||||
roadmap_items=roadmap_items,
|
||||
)
|
||||
return state
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/execution/dependencies")
|
||||
def list_initiative_action_dependencies(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
|
||||
):
|
||||
try:
|
||||
return execution_plan_service.list_dependencies_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{initiative_id}/execution/dependencies", status_code=201)
|
||||
def create_initiative_action_dependency(
|
||||
initiative_id: str,
|
||||
body: ActionDependencyCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
predecessor = action_service.get_action(
|
||||
tenant_id=ctx.tenant_id, action_id=body.predecessor_action_id
|
||||
)
|
||||
successor = action_service.get_action(
|
||||
tenant_id=ctx.tenant_id, action_id=body.successor_action_id
|
||||
)
|
||||
if not predecessor or not successor:
|
||||
raise ValueError("Action nicht gefunden")
|
||||
if (
|
||||
predecessor["initiative_id"] != initiative_id
|
||||
or successor["initiative_id"] != initiative_id
|
||||
):
|
||||
raise ValueError("Actions gehören nicht zum angegebenen Vorhaben")
|
||||
|
||||
return execution_plan_service.add_dependency(
|
||||
tenant_id=ctx.tenant_id,
|
||||
predecessor_action_id=body.predecessor_action_id,
|
||||
successor_action_id=body.successor_action_id,
|
||||
dependency_kind=body.dependency_kind,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.delete(
|
||||
"/{initiative_id}/execution/dependencies/{dependency_id}",
|
||||
status_code=204,
|
||||
)
|
||||
def delete_initiative_action_dependency(
|
||||
initiative_id: str,
|
||||
dependency_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
del initiative_id # scope validated via tenant + dependency row
|
||||
try:
|
||||
execution_plan_service.delete_dependency(
|
||||
tenant_id=ctx.tenant_id,
|
||||
dependency_id=dependency_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
659
backend/routers/initiatives.py
Normal file
659
backend/routers/initiatives.py
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
"""Initiative (Vorhaben) API — AP0.5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from data_layer import initiative_snapshot as dl_initiative_snapshot
|
||||
from entity_fields.service import (
|
||||
get_entity_field_values,
|
||||
list_field_definitions,
|
||||
patch_entity_field_values,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from steering.context import get_steering_context_dto
|
||||
from services import actions as action_service
|
||||
from services import backlog as backlog_service
|
||||
from services import blockers as blocker_service
|
||||
from services import decisions as decision_service
|
||||
from services import evidence as evidence_service
|
||||
from services import initiatives as initiative_service
|
||||
from services import operating_context as operating_context_service
|
||||
from services import milestones as milestone_service
|
||||
from services import recurring as recurring_service
|
||||
from services import reviews as review_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
|
||||
|
||||
|
||||
class InitiativeCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
goal: str = ""
|
||||
vision: str = ""
|
||||
target_state_summary: str = ""
|
||||
archetype_key: str = "initiative.generic"
|
||||
status: Literal["active", "paused", "completed", "archived"] = "active"
|
||||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
owner_actor_id: Optional[str] = None
|
||||
method_profile_key: Optional[str] = Field(default=None, min_length=1)
|
||||
apply_starter_kit: bool = True
|
||||
|
||||
|
||||
class InitiativeUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
goal: Optional[str] = None
|
||||
vision: Optional[str] = None
|
||||
target_state_summary: Optional[str] = None
|
||||
archetype_key: Optional[str] = None
|
||||
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
|
||||
priority: Optional[Literal["low", "normal", "high"]] = None
|
||||
owner_actor_id: Optional[str] = None
|
||||
|
||||
|
||||
class InitiativeFieldsPatchRequest(BaseModel):
|
||||
fields: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ActionCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal[
|
||||
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||
] = "open"
|
||||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
due_at: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
work_cycle_id: Optional[str] = None
|
||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||
sort_order: int = 0
|
||||
action_kind: Literal["delivery", "planning", "review", "bug", "issue"] = "delivery"
|
||||
parent_action_id: Optional[str] = None
|
||||
|
||||
|
||||
class BlockerCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open"
|
||||
action_id: Optional[str] = None
|
||||
set_action_blocked: bool = False
|
||||
|
||||
|
||||
class BacklogCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
|
||||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
roadmap_item_id: Optional[str] = None
|
||||
item_kind: Literal["story", "bug", "issue", "epic"] = "story"
|
||||
parent_action_id: Optional[str] = None
|
||||
parent_backlog_id: Optional[str] = None
|
||||
|
||||
|
||||
class MilestoneCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
goal_description: str = ""
|
||||
status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned"
|
||||
target_date: Optional[str] = None
|
||||
|
||||
|
||||
class EvidenceCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["submitted", "accepted", "rejected"] = "submitted"
|
||||
action_id: Optional[str] = None
|
||||
milestone_id: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
|
||||
|
||||
class DecisionCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["proposed", "decided", "superseded"] = "proposed"
|
||||
outcome: str = ""
|
||||
|
||||
|
||||
class ReviewCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
summary: str = ""
|
||||
status: Literal["planned", "completed", "skipped"] = "planned"
|
||||
due_at: Optional[str] = None
|
||||
action_id: Optional[str] = None
|
||||
milestone_id: Optional[str] = None
|
||||
|
||||
|
||||
class RecurringCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["active", "paused", "ended"] = "active"
|
||||
interval_days: Optional[int] = Field(default=None, gt=0)
|
||||
next_due_at: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
|
||||
|
||||
def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Ungültiges {field_name}") from exc
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_initiatives(
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
return initiative_service.list_initiatives(tenant_id=ctx.tenant_id)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_initiative(
|
||||
body: InitiativeCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||
):
|
||||
owner_actor_id = body.owner_actor_id or ctx.actor_id
|
||||
if not owner_actor_id:
|
||||
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
|
||||
try:
|
||||
return initiative_service.create_initiative(
|
||||
tenant_id=ctx.tenant_id,
|
||||
title=body.title,
|
||||
goal=body.goal,
|
||||
vision=body.vision,
|
||||
target_state_summary=body.target_state_summary,
|
||||
archetype_key=body.archetype_key,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
owner_actor_id=owner_actor_id,
|
||||
user_id=ctx.user_id,
|
||||
method_profile_key=body.method_profile_key,
|
||||
apply_starter_kit=body.apply_starter_kit,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{initiative_id}")
|
||||
def get_initiative(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
item = initiative_service.get_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/steering-snapshot")
|
||||
def get_initiative_steering_snapshot(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
snapshot = dl_initiative_snapshot.get_initiative_steering_snapshot(
|
||||
ctx, initiative_id=initiative_id
|
||||
)
|
||||
if not snapshot:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/steering-context")
|
||||
def get_initiative_steering_context(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
if not initiative_service.get_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/operating-context")
|
||||
def get_initiative_operating_context(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
context = operating_context_service.get_operating_context(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if not context:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return context
|
||||
|
||||
|
||||
def _require_initiative(*, tenant_id: str, initiative_id: str) -> dict:
|
||||
initiative = initiative_service.get_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if not initiative:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return initiative
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/field-definitions")
|
||||
def get_initiative_field_definitions(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
return list_field_definitions(archetype_key=initiative["archetype_key"])
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/fields")
|
||||
def get_initiative_fields(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
definitions = list_field_definitions(archetype_key=initiative["archetype_key"])
|
||||
return get_entity_field_values(
|
||||
tenant_id=ctx.tenant_id,
|
||||
entity_type="initiative",
|
||||
entity_id=initiative_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{initiative_id}/fields")
|
||||
def patch_initiative_fields(
|
||||
initiative_id: str,
|
||||
body: InitiativeFieldsPatchRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||
):
|
||||
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
definitions = list_field_definitions(archetype_key=initiative["archetype_key"])
|
||||
try:
|
||||
return patch_entity_field_values(
|
||||
tenant_id=ctx.tenant_id,
|
||||
entity_type="initiative",
|
||||
entity_id=initiative_id,
|
||||
definitions=definitions,
|
||||
values=body.fields,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/{initiative_id}")
|
||||
def update_initiative(
|
||||
initiative_id: str,
|
||||
body: InitiativeUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||
):
|
||||
try:
|
||||
item = initiative_service.update_initiative(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
goal=body.goal,
|
||||
vision=body.vision,
|
||||
target_state_summary=body.target_state_summary,
|
||||
archetype_key=body.archetype_key,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
owner_actor_id=body.owner_actor_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{initiative_id}", status_code=204)
|
||||
def delete_initiative(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||
):
|
||||
if not initiative_service.delete_initiative(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
user_id=ctx.user_id,
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/actions")
|
||||
def list_initiative_actions(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
|
||||
):
|
||||
if not initiative_service.get_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return action_service.list_actions_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{initiative_id}/actions", status_code=201)
|
||||
def create_initiative_action(
|
||||
initiative_id: str,
|
||||
body: ActionCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
due_at = _parse_optional_datetime(body.due_at, "due_at")
|
||||
return action_service.create_action(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
due_at=due_at,
|
||||
project_id=body.project_id,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
work_cycle_id=body.work_cycle_id,
|
||||
assigned_actor_ids=body.assigned_actor_ids,
|
||||
sort_order=body.sort_order,
|
||||
action_kind=body.action_kind,
|
||||
parent_action_id=body.parent_action_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/blockers")
|
||||
def list_initiative_blockers(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.blocker.read")),
|
||||
):
|
||||
try:
|
||||
return blocker_service.list_blockers_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{initiative_id}/blockers", status_code=201)
|
||||
def create_initiative_blocker(
|
||||
initiative_id: str,
|
||||
body: BlockerCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
|
||||
):
|
||||
try:
|
||||
return blocker_service.create_blocker(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
action_id=body.action_id,
|
||||
reported_by_actor_id=ctx.actor_id,
|
||||
user_id=ctx.user_id,
|
||||
set_action_blocked=body.set_action_blocked,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/backlog")
|
||||
def list_initiative_backlog(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.backlog.read")),
|
||||
):
|
||||
try:
|
||||
return backlog_service.list_backlog_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{initiative_id}/backlog", status_code=201)
|
||||
def create_initiative_backlog_item(
|
||||
initiative_id: str,
|
||||
body: BacklogCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
|
||||
):
|
||||
try:
|
||||
return backlog_service.create_backlog_item(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
item_kind=body.item_kind,
|
||||
parent_action_id=body.parent_action_id,
|
||||
parent_backlog_id=body.parent_backlog_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/milestones")
|
||||
def list_initiative_milestones(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return milestone_service.list_milestones_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{initiative_id}/milestones", status_code=201)
|
||||
def create_initiative_milestone(
|
||||
initiative_id: str,
|
||||
body: MilestoneCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
from datetime import date as date_type
|
||||
|
||||
target_date = None
|
||||
if body.target_date:
|
||||
try:
|
||||
target_date = date_type.fromisoformat(body.target_date)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Ungültiges target_date") from exc
|
||||
try:
|
||||
return milestone_service.create_milestone(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
goal_description=body.goal_description,
|
||||
status=body.status,
|
||||
target_date=target_date,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/evidence")
|
||||
def list_initiative_evidence(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.evidence.read")),
|
||||
):
|
||||
try:
|
||||
return evidence_service.list_evidence_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{initiative_id}/evidence", status_code=201)
|
||||
def create_initiative_evidence(
|
||||
initiative_id: str,
|
||||
body: EvidenceCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
|
||||
):
|
||||
try:
|
||||
return evidence_service.create_evidence(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
action_id=body.action_id,
|
||||
milestone_id=body.milestone_id,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
submitted_by_actor_id=ctx.actor_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/decisions")
|
||||
def list_initiative_decisions(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.decision.read")),
|
||||
):
|
||||
try:
|
||||
return decision_service.list_decisions_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{initiative_id}/decisions", status_code=201)
|
||||
def create_initiative_decision(
|
||||
initiative_id: str,
|
||||
body: DecisionCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
|
||||
):
|
||||
try:
|
||||
return decision_service.create_decision(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
outcome=body.outcome,
|
||||
decided_by_actor_id=ctx.actor_id if body.status == "decided" else None,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/reviews")
|
||||
def list_initiative_reviews(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.review.read")),
|
||||
):
|
||||
try:
|
||||
return review_service.list_reviews_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{initiative_id}/reviews", status_code=201)
|
||||
def create_initiative_review(
|
||||
initiative_id: str,
|
||||
body: ReviewCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
|
||||
):
|
||||
due_at = _parse_optional_datetime(body.due_at, "due_at")
|
||||
try:
|
||||
return review_service.create_review(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
summary=body.summary,
|
||||
status=body.status,
|
||||
due_at=due_at,
|
||||
action_id=body.action_id,
|
||||
milestone_id=body.milestone_id,
|
||||
reviewed_by_actor_id=ctx.actor_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/recurring")
|
||||
def list_initiative_recurring(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
|
||||
):
|
||||
try:
|
||||
return recurring_service.list_recurring_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{initiative_id}/recurring", status_code=201)
|
||||
def create_initiative_recurring(
|
||||
initiative_id: str,
|
||||
body: RecurringCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||
):
|
||||
next_due_at = _parse_optional_datetime(body.next_due_at, "next_due_at")
|
||||
try:
|
||||
return recurring_service.create_recurring_element(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
interval_days=body.interval_days,
|
||||
next_due_at=next_due_at,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
44
backend/routers/journey.py
Normal file
44
backend/routers/journey.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Plan/Ist & Journey API — AP1.6."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from services import plan_ist as plan_ist_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
initiative_router = APIRouter(prefix="/api/initiatives", tags=["journey"])
|
||||
roadmap_router = APIRouter(prefix="/api/roadmap-items", tags=["journey"])
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/journey")
|
||||
def list_initiative_journey(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
try:
|
||||
events = plan_ist_service.list_journey_events(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
return {"events": events}
|
||||
|
||||
|
||||
@roadmap_router.get("/{roadmap_item_id}/contributions")
|
||||
def list_gate_contributions(
|
||||
roadmap_item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return plan_ist_service.list_gate_contributions(
|
||||
tenant_id=ctx.tenant_id, roadmap_item_id=roadmap_item_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Gate nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
77
backend/routers/milestones.py
Normal file
77
backend/routers/milestones.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Milestone API — AP0.8d."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import milestones as milestone_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/milestones", tags=["milestones"])
|
||||
|
||||
|
||||
class MilestoneCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
goal_description: str = ""
|
||||
status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned"
|
||||
target_date: Optional[date] = None
|
||||
|
||||
|
||||
class MilestoneUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
goal_description: Optional[str] = None
|
||||
status: Optional[
|
||||
Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
|
||||
] = None
|
||||
target_date: Optional[date] = None
|
||||
clear_target_date: bool = False
|
||||
|
||||
|
||||
@router.get("/{milestone_id}")
|
||||
def get_milestone(
|
||||
milestone_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
item = milestone_service.get_milestone(tenant_id=ctx.tenant_id, milestone_id=milestone_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{milestone_id}")
|
||||
def update_milestone(
|
||||
milestone_id: str,
|
||||
body: MilestoneUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
item = milestone_service.update_milestone(
|
||||
tenant_id=ctx.tenant_id,
|
||||
milestone_id=milestone_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
goal_description=body.goal_description,
|
||||
status=body.status,
|
||||
target_date=body.target_date,
|
||||
clear_target_date=body.clear_target_date,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{milestone_id}", status_code=204)
|
||||
def delete_milestone(
|
||||
milestone_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
if not milestone_service.delete_milestone(
|
||||
tenant_id=ctx.tenant_id, milestone_id=milestone_id, user_id=ctx.user_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
|
||||
429
backend/routers/operational.py
Normal file
429
backend/routers/operational.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
"""Operational Actor API — AP1.7 (Vibe-Coder / Agenten)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from data_layer.attention import get_next_action_candidates_for_initiative
|
||||
from data_layer.initiative_snapshot import get_initiative_steering_snapshot
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from operational_auth import require_operational_capability
|
||||
from pydantic import BaseModel, Field
|
||||
from services import actions as action_service
|
||||
from services import backlog as backlog_service
|
||||
from services import blockers as blocker_service
|
||||
from services import decisions as decision_service
|
||||
from services import evidence as evidence_service
|
||||
from services import recurring as recurring_service
|
||||
from services.audit import log_audit
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/operational", tags=["operational"])
|
||||
|
||||
|
||||
def _ok(ctx: TenantContext, data: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"data": data,
|
||||
"actor_id": ctx.actor_id,
|
||||
}
|
||||
|
||||
|
||||
class ActionStatusPatch(BaseModel):
|
||||
status: Literal[
|
||||
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||
]
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class ActionAssignmentsBody(BaseModel):
|
||||
actor_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BlockerCreateBody(BaseModel):
|
||||
initiative_id: str
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
action_id: Optional[str] = None
|
||||
status: Literal["open", "in_progress"] = "open"
|
||||
|
||||
|
||||
class BlockerPatchBody(BaseModel):
|
||||
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
|
||||
|
||||
|
||||
class EvidenceCreateBody(BaseModel):
|
||||
initiative_id: str
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = ""
|
||||
action_id: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
status: Literal["submitted", "accepted", "rejected"] = "submitted"
|
||||
|
||||
|
||||
class DecisionProposalBody(BaseModel):
|
||||
initiative_id: str
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
rationale: str = ""
|
||||
proposed_status: Literal["proposed"] = "proposed"
|
||||
|
||||
|
||||
class BacklogProposalBody(BaseModel):
|
||||
initiative_id: str
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
roadmap_item_id: Optional[str] = None
|
||||
|
||||
|
||||
class BacklogConvertBody(BaseModel):
|
||||
work_cycle_id: Optional[str] = None
|
||||
assign_active_sprint: bool = True
|
||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RecurringStatusPatch(BaseModel):
|
||||
status: Literal["active", "paused", "ended"]
|
||||
|
||||
|
||||
class ActionWorkCyclePatch(BaseModel):
|
||||
work_cycle_id: Optional[str] = None
|
||||
clear_work_cycle: bool = False
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def operational_me(ctx: TenantContext = Depends(require_operational_capability("kairo.action.read"))):
|
||||
return _ok(
|
||||
ctx,
|
||||
{
|
||||
"auth_source": ctx.auth_source,
|
||||
"tenant_id": ctx.tenant_id,
|
||||
"tenant_slug": ctx.tenant_slug,
|
||||
"actor_id": ctx.actor_id,
|
||||
"actor_type": ctx.actor_type,
|
||||
"display_name": ctx.display_name,
|
||||
"capabilities": sorted(ctx.capabilities),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/initiatives/{initiative_id}/context")
|
||||
def operational_context(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.initiative.read")),
|
||||
):
|
||||
snapshot = get_initiative_steering_snapshot(ctx, initiative_id=initiative_id)
|
||||
if not snapshot:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
subset = {
|
||||
"initiative_id": snapshot.get("initiative_id"),
|
||||
"title": snapshot.get("initiative_title"),
|
||||
"status": snapshot.get("initiative_status"),
|
||||
"method_key": snapshot.get("method_key"),
|
||||
"method_label": snapshot.get("method_label"),
|
||||
"archetype_key": snapshot.get("archetype_key"),
|
||||
"archetype_label": snapshot.get("archetype_label"),
|
||||
"steering_guidance": snapshot.get("steering_guidance"),
|
||||
"lifecycle_state": snapshot.get("lifecycle_state"),
|
||||
"lifecycle_label": snapshot.get("lifecycle_label"),
|
||||
"signals": snapshot.get("signals"),
|
||||
"attention_items": snapshot.get("attention_items"),
|
||||
"next_actions": snapshot.get("next_actions"),
|
||||
"active_work_cycle": snapshot.get("active_work_cycle"),
|
||||
}
|
||||
return _ok(ctx, subset)
|
||||
|
||||
|
||||
@router.get("/next-action")
|
||||
def operational_next_action(
|
||||
initiative_id: str = Query(...),
|
||||
limit: int = Query(default=5, ge=1, le=20),
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.initiative.read")),
|
||||
):
|
||||
items = get_next_action_candidates_for_initiative(
|
||||
ctx, initiative_id=initiative_id, limit=limit
|
||||
)
|
||||
return _ok(ctx, items)
|
||||
|
||||
|
||||
@router.get("/actions/{action_id}")
|
||||
def operational_get_action(
|
||||
action_id: str,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.action.read")),
|
||||
):
|
||||
item = action_service.get_action(tenant_id=ctx.tenant_id, action_id=action_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.patch("/actions/{action_id}/status")
|
||||
def operational_patch_action_status(
|
||||
action_id: str,
|
||||
body: ActionStatusPatch,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")),
|
||||
):
|
||||
description = None
|
||||
if body.note:
|
||||
description = body.note.strip()
|
||||
try:
|
||||
item = action_service.update_action(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
user_id=ctx.user_id or None,
|
||||
status=body.status,
|
||||
description=description,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
|
||||
|
||||
log_audit(
|
||||
"operational.action.status",
|
||||
user_id=ctx.user_id or None,
|
||||
tenant_id=ctx.tenant_id,
|
||||
details={
|
||||
"action_id": action_id,
|
||||
"status": body.status,
|
||||
"actor_id": ctx.actor_id,
|
||||
"auth_source": ctx.auth_source,
|
||||
},
|
||||
)
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.post("/actions/{action_id}/assignments")
|
||||
def operational_set_assignments(
|
||||
action_id: str,
|
||||
body: ActionAssignmentsBody,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
item = action_service.set_action_assignments(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
actor_ids=body.actor_ids,
|
||||
user_id=ctx.user_id or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.post("/blockers", status_code=201)
|
||||
def operational_create_blocker(
|
||||
body: BlockerCreateBody,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.blocker.manage")),
|
||||
):
|
||||
try:
|
||||
item = blocker_service.create_blocker(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=body.initiative_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
action_id=body.action_id,
|
||||
reported_by_actor_id=ctx.actor_id,
|
||||
user_id=ctx.user_id or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.patch("/blockers/{blocker_id}")
|
||||
def operational_patch_blocker(
|
||||
blocker_id: str,
|
||||
body: BlockerPatchBody,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.blocker.manage")),
|
||||
):
|
||||
try:
|
||||
item = blocker_service.update_blocker(
|
||||
tenant_id=ctx.tenant_id,
|
||||
blocker_id=blocker_id,
|
||||
user_id=ctx.user_id or None,
|
||||
status=body.status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.post("/evidence", status_code=201)
|
||||
def operational_create_evidence(
|
||||
body: EvidenceCreateBody,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.evidence.manage")),
|
||||
):
|
||||
try:
|
||||
item = evidence_service.create_evidence(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=body.initiative_id,
|
||||
title=body.title,
|
||||
description=body.body,
|
||||
status=body.status,
|
||||
action_id=body.action_id,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
submitted_by_actor_id=ctx.actor_id,
|
||||
user_id=ctx.user_id or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.post("/decisions/proposals", status_code=201)
|
||||
def operational_decision_proposal(
|
||||
body: DecisionProposalBody,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.decision.manage")),
|
||||
):
|
||||
try:
|
||||
item = decision_service.create_decision(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=body.initiative_id,
|
||||
title=body.title,
|
||||
description=body.rationale,
|
||||
status=body.proposed_status,
|
||||
decided_by_actor_id=ctx.actor_id,
|
||||
user_id=ctx.user_id or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.post("/backlog/proposals", status_code=201)
|
||||
def operational_backlog_proposal(
|
||||
body: BacklogProposalBody,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.backlog.manage")),
|
||||
):
|
||||
try:
|
||||
item = backlog_service.create_backlog_item(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=body.initiative_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status="new",
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
user_id=ctx.user_id or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.post("/backlog/{backlog_item_id}/convert-to-action", status_code=201)
|
||||
def operational_convert_backlog(
|
||||
backlog_item_id: str,
|
||||
body: BacklogConvertBody,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.backlog.manage")),
|
||||
):
|
||||
assigned = body.assigned_actor_ids
|
||||
if not assigned and ctx.actor_id:
|
||||
assigned = [ctx.actor_id]
|
||||
try:
|
||||
result = backlog_service.convert_backlog_to_action(
|
||||
tenant_id=ctx.tenant_id,
|
||||
backlog_item_id=backlog_item_id,
|
||||
user_id=ctx.user_id or None,
|
||||
assigned_actor_ids=assigned,
|
||||
work_cycle_id=body.work_cycle_id,
|
||||
assign_active_sprint=body.assign_active_sprint,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Backlog-Item nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
log_audit(
|
||||
"operational.backlog.convert",
|
||||
user_id=ctx.user_id or None,
|
||||
tenant_id=ctx.tenant_id,
|
||||
details={
|
||||
"backlog_item_id": backlog_item_id,
|
||||
"action_id": result.get("action", {}).get("id"),
|
||||
"work_cycle_id": body.work_cycle_id,
|
||||
"actor_id": ctx.actor_id,
|
||||
},
|
||||
)
|
||||
return _ok(ctx, result)
|
||||
|
||||
|
||||
@router.patch("/recurring/{recurring_id}")
|
||||
def operational_patch_recurring(
|
||||
recurring_id: str,
|
||||
body: RecurringStatusPatch,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.recurring.manage")),
|
||||
):
|
||||
try:
|
||||
item = recurring_service.update_recurring_element(
|
||||
tenant_id=ctx.tenant_id,
|
||||
recurring_id=recurring_id,
|
||||
user_id=ctx.user_id or None,
|
||||
status=body.status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
|
||||
|
||||
log_audit(
|
||||
"operational.recurring.status",
|
||||
user_id=ctx.user_id or None,
|
||||
tenant_id=ctx.tenant_id,
|
||||
details={
|
||||
"recurring_id": recurring_id,
|
||||
"status": body.status,
|
||||
"actor_id": ctx.actor_id,
|
||||
},
|
||||
)
|
||||
return _ok(ctx, item)
|
||||
|
||||
|
||||
@router.patch("/actions/{action_id}/work-cycle")
|
||||
def operational_patch_action_work_cycle(
|
||||
action_id: str,
|
||||
body: ActionWorkCyclePatch,
|
||||
ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
item = action_service.update_action(
|
||||
tenant_id=ctx.tenant_id,
|
||||
action_id=action_id,
|
||||
user_id=ctx.user_id or None,
|
||||
work_cycle_id=body.work_cycle_id,
|
||||
clear_work_cycle=body.clear_work_cycle,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
|
||||
|
||||
log_audit(
|
||||
"operational.action.work_cycle",
|
||||
user_id=ctx.user_id or None,
|
||||
tenant_id=ctx.tenant_id,
|
||||
details={
|
||||
"action_id": action_id,
|
||||
"work_cycle_id": body.work_cycle_id,
|
||||
"clear_work_cycle": body.clear_work_cycle,
|
||||
"actor_id": ctx.actor_id,
|
||||
},
|
||||
)
|
||||
return _ok(ctx, item)
|
||||
135
backend/routers/projects.py
Normal file
135
backend/routers/projects.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Project API — AP1.5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import projects as project_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
initiative_router = APIRouter(prefix="/api/initiatives", tags=["projects"])
|
||||
items_router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
|
||||
|
||||
class ProjectCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["active", "paused", "completed", "archived"] = "active"
|
||||
parent_project_id: Optional[str] = None
|
||||
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
target_date: Optional[date] = None
|
||||
|
||||
|
||||
class ProjectUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
|
||||
parent_project_id: Optional[str] = None
|
||||
clear_parent_project: bool = False
|
||||
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
|
||||
clear_container_kind: bool = False
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
sort_order: Optional[int] = None
|
||||
target_date: Optional[date] = None
|
||||
clear_target_date: bool = False
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/projects")
|
||||
def list_initiative_projects(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.project.read")),
|
||||
):
|
||||
try:
|
||||
return project_service.list_projects_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{initiative_id}/projects", status_code=201)
|
||||
def create_initiative_project(
|
||||
initiative_id: str,
|
||||
body: ProjectCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
|
||||
):
|
||||
try:
|
||||
return project_service.create_project(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
parent_project_id=body.parent_project_id,
|
||||
container_kind=body.container_kind,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
sort_order=body.sort_order,
|
||||
target_date=body.target_date,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.get("/{project_id}")
|
||||
def get_project(
|
||||
project_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.project.read")),
|
||||
):
|
||||
item = project_service.get_project(tenant_id=ctx.tenant_id, project_id=project_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@items_router.patch("/{project_id}")
|
||||
def update_project(
|
||||
project_id: str,
|
||||
body: ProjectUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
|
||||
):
|
||||
try:
|
||||
item = project_service.update_project(
|
||||
tenant_id=ctx.tenant_id,
|
||||
project_id=project_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
parent_project_id=body.parent_project_id,
|
||||
clear_parent_project=body.clear_parent_project,
|
||||
container_kind=body.container_kind,
|
||||
clear_container_kind=body.clear_container_kind,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
sort_order=body.sort_order,
|
||||
target_date=body.target_date,
|
||||
clear_target_date=body.clear_target_date,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@items_router.delete("/{project_id}", status_code=204)
|
||||
def delete_project(
|
||||
project_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
|
||||
):
|
||||
try:
|
||||
deleted = project_service.delete_project(
|
||||
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
|
||||
122
backend/routers/recurring.py
Normal file
122
backend/routers/recurring.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""RecurringElement API — AP0.9e."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from schemas.schedule_payload import SchedulePayload
|
||||
from services import recurring as recurring_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/recurring", tags=["recurring"])
|
||||
|
||||
|
||||
class RecurringUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[Literal["active", "paused", "ended"]] = None
|
||||
interval_days: Optional[int] = Field(default=None, gt=0)
|
||||
clear_interval_days: bool = False
|
||||
next_due_at: Optional[str] = None
|
||||
clear_next_due_at: bool = False
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item_id: bool = False
|
||||
schedule: Optional[SchedulePayload] = None
|
||||
|
||||
|
||||
class RecurringCompleteRequest(BaseModel):
|
||||
measurement_note: Optional[str] = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Ungültiges next_due_at") from exc
|
||||
|
||||
|
||||
@router.get("/{recurring_id}")
|
||||
def get_recurring_element(
|
||||
recurring_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
|
||||
):
|
||||
item = recurring_service.get_recurring_element(
|
||||
tenant_id=ctx.tenant_id, recurring_id=recurring_id
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{recurring_id}")
|
||||
def update_recurring_element(
|
||||
recurring_id: str,
|
||||
body: RecurringUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||
):
|
||||
next_due_at = _parse_datetime(body.next_due_at) if body.next_due_at is not None else None
|
||||
sched = body.schedule
|
||||
try:
|
||||
item = recurring_service.update_recurring_element(
|
||||
tenant_id=ctx.tenant_id,
|
||||
recurring_id=recurring_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
interval_days=body.interval_days if sched is None else sched.interval_days,
|
||||
clear_interval_days=body.clear_interval_days,
|
||||
next_due_at=next_due_at,
|
||||
clear_next_due_at=body.clear_next_due_at,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item_id=body.clear_roadmap_item_id,
|
||||
schedule_kind=sched.schedule_kind if sched else None,
|
||||
weekday_mask=sched.weekday_mask if sched else None,
|
||||
pause_until=sched.pause_until_date() if sched else None,
|
||||
clear_pause_until=sched is not None and sched.pause_until is None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/{recurring_id}/complete")
|
||||
def complete_recurring_practice(
|
||||
recurring_id: str,
|
||||
body: RecurringCompleteRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||
):
|
||||
from services.cadence_instance import complete_open_instance
|
||||
|
||||
try:
|
||||
result = complete_open_instance(
|
||||
tenant_id=ctx.tenant_id,
|
||||
recurring_element_id=recurring_id,
|
||||
user_id=ctx.user_id or None,
|
||||
measurement_note=body.measurement_note or "",
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Recurring-Element nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{recurring_id}", status_code=204)
|
||||
def delete_recurring_element(
|
||||
recurring_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||
):
|
||||
if not recurring_service.delete_recurring_element(
|
||||
tenant_id=ctx.tenant_id, recurring_id=recurring_id, user_id=ctx.user_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
|
||||
86
backend/routers/reviews.py
Normal file
86
backend/routers/reviews.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Review API — AP0.9d."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import reviews as review_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
|
||||
|
||||
|
||||
class ReviewUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
summary: Optional[str] = None
|
||||
status: Optional[Literal["planned", "completed", "skipped"]] = None
|
||||
due_at: Optional[str] = None
|
||||
clear_due_at: bool = False
|
||||
action_id: Optional[str] = None
|
||||
milestone_id: Optional[str] = None
|
||||
clear_action_id: bool = False
|
||||
clear_milestone_id: bool = False
|
||||
|
||||
|
||||
def _parse_due_at(value: Optional[str]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
|
||||
|
||||
|
||||
@router.get("/{review_id}")
|
||||
def get_review(
|
||||
review_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.review.read")),
|
||||
):
|
||||
item = review_service.get_review(tenant_id=ctx.tenant_id, review_id=review_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Review nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{review_id}")
|
||||
def update_review(
|
||||
review_id: str,
|
||||
body: ReviewUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
|
||||
):
|
||||
due_at = _parse_due_at(body.due_at) if body.due_at is not None else None
|
||||
try:
|
||||
item = review_service.update_review(
|
||||
tenant_id=ctx.tenant_id,
|
||||
review_id=review_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
summary=body.summary,
|
||||
status=body.status,
|
||||
due_at=due_at,
|
||||
clear_due_at=body.clear_due_at,
|
||||
action_id=body.action_id,
|
||||
milestone_id=body.milestone_id,
|
||||
clear_action_id=body.clear_action_id,
|
||||
clear_milestone_id=body.clear_milestone_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Review nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{review_id}", status_code=204)
|
||||
def delete_review(
|
||||
review_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
|
||||
):
|
||||
if not review_service.delete_review(
|
||||
tenant_id=ctx.tenant_id, review_id=review_id, user_id=ctx.user_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Review nicht gefunden")
|
||||
593
backend/routers/roadmap.py
Normal file
593
backend/routers/roadmap.py
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
"""Roadmap API — AP1.4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from schemas.schedule_payload import SchedulePayload
|
||||
from services import roadmap as roadmap_service
|
||||
from services import roadmap_criteria as criteria_service
|
||||
from services import roadmap_plan_snapshot as snapshot_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(tags=["roadmap"])
|
||||
|
||||
|
||||
class RoadmapItemCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"] = "milestone"
|
||||
goal_description: str = ""
|
||||
definition_of_done: list[Any] = Field(default_factory=list)
|
||||
status: Literal[
|
||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||
] = "planned"
|
||||
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
|
||||
target_date: Optional[date] = None
|
||||
sort_order: int = 0
|
||||
strand_project_id: Optional[str] = None
|
||||
initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class RoadmapItemUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"]] = None
|
||||
goal_description: Optional[str] = None
|
||||
definition_of_done: Optional[list[Any]] = None
|
||||
status: Optional[
|
||||
Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
|
||||
] = None
|
||||
sequencing_mode: Optional[Literal["sequential", "parallel", "optional"]] = None
|
||||
target_date: Optional[date] = None
|
||||
clear_target_date: bool = False
|
||||
sort_order: Optional[int] = None
|
||||
strand_project_id: Optional[str] = None
|
||||
clear_strand_project: bool = False
|
||||
|
||||
|
||||
class DependencyCreateRequest(BaseModel):
|
||||
to_item_id: str
|
||||
dependency_type: Literal[
|
||||
"requires", "blocks", "related", "parallel_group", "optional_branch"
|
||||
] = "requires"
|
||||
group_key: Optional[str] = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class CriterionCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
description: str = ""
|
||||
criterion_kind: Literal["manual", "evidence_required", "review_required", "metric"] = "manual"
|
||||
verification_hint: str = ""
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CriterionUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||
description: Optional[str] = None
|
||||
criterion_kind: Optional[
|
||||
Literal["manual", "evidence_required", "review_required", "metric"]
|
||||
] = None
|
||||
verification_hint: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
change_reason: str = ""
|
||||
|
||||
|
||||
class CriterionWaiveDeferRequest(BaseModel):
|
||||
decision_title: str = Field(min_length=1, max_length=255)
|
||||
decision_description: str = ""
|
||||
|
||||
|
||||
class CriterionSatisfyRequest(BaseModel):
|
||||
evidence_id: Optional[str] = None
|
||||
|
||||
|
||||
class ReopenRequest(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class GatePracticeCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["active", "paused", "ended"] = "active"
|
||||
interval_days: int = Field(default=1, gt=0)
|
||||
schedule: Optional[SchedulePayload] = None
|
||||
|
||||
|
||||
class PlanSnapshotCreateRequest(BaseModel):
|
||||
reason: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
|
||||
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
|
||||
deps_router = APIRouter(prefix="/api/roadmap-dependencies", tags=["roadmap"])
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap")
|
||||
def get_initiative_roadmap(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
roadmap = roadmap_service.get_roadmap_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not roadmap:
|
||||
return {"initiative_id": initiative_id, "items": []}
|
||||
items = roadmap_service.list_roadmap_items_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return {**roadmap, "items": items}
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/items")
|
||||
def list_initiative_roadmap_items(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return roadmap_service.list_roadmap_items_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/dependencies")
|
||||
def list_initiative_roadmap_dependencies(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return roadmap_service.list_dependencies_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/criteria-progress")
|
||||
def list_initiative_roadmap_criteria_progress(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return criteria_service.criteria_progress_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/graph-state")
|
||||
def get_initiative_roadmap_graph_state(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
from steering.graph.roadmap_engine import load_initiative_graph_state
|
||||
|
||||
return load_initiative_graph_state(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/progression-state")
|
||||
def get_initiative_progression_state(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
from services.progression_graph import compute_progression_state
|
||||
|
||||
return compute_progression_state(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/plan-ist-view")
|
||||
def get_initiative_plan_ist_view(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
from steering.graph.plan_ist import load_plan_ist_view
|
||||
|
||||
return load_plan_ist_view(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/plan-snapshots")
|
||||
def list_initiative_plan_snapshots(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return snapshot_service.list_plan_snapshots(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{initiative_id}/roadmap/plan-snapshots", status_code=201)
|
||||
def create_initiative_plan_snapshot(
|
||||
initiative_id: str,
|
||||
body: PlanSnapshotCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return snapshot_service.create_plan_snapshot(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
reason=body.reason,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
|
||||
def create_initiative_roadmap_item(
|
||||
initiative_id: str,
|
||||
body: RoadmapItemCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return roadmap_service.create_roadmap_item(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
item_type=body.item_type,
|
||||
goal_description=body.goal_description,
|
||||
definition_of_done=body.definition_of_done,
|
||||
status=body.status,
|
||||
sequencing_mode=body.sequencing_mode,
|
||||
target_date=body.target_date,
|
||||
sort_order=body.sort_order,
|
||||
strand_project_id=body.strand_project_id,
|
||||
initial_criterion_title=body.initial_criterion_title,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.get("/{item_id}")
|
||||
def get_roadmap_item(
|
||||
item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
item = roadmap_service.get_roadmap_item(tenant_id=ctx.tenant_id, item_id=item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@items_router.patch("/{item_id}")
|
||||
def update_roadmap_item(
|
||||
item_id: str,
|
||||
body: RoadmapItemUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
item = roadmap_service.update_roadmap_item(
|
||||
tenant_id=ctx.tenant_id,
|
||||
item_id=item_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
item_type=body.item_type,
|
||||
goal_description=body.goal_description,
|
||||
definition_of_done=body.definition_of_done,
|
||||
status=body.status,
|
||||
sequencing_mode=body.sequencing_mode,
|
||||
target_date=body.target_date,
|
||||
clear_target_date=body.clear_target_date,
|
||||
sort_order=body.sort_order,
|
||||
strand_project_id=body.strand_project_id,
|
||||
clear_strand_project=body.clear_strand_project,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@items_router.delete("/{item_id}", status_code=204)
|
||||
def delete_roadmap_item(
|
||||
item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
deleted = roadmap_service.delete_roadmap_item(
|
||||
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
||||
|
||||
|
||||
def _work_gate_or_400(*, tenant_id: str, item_id: str) -> dict[str, Any]:
|
||||
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
||||
if item.get("item_type") == "join_gate":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Join-Gates haben kein Activity Set (Übungen)",
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
@items_router.get("/{item_id}/practices")
|
||||
def list_gate_practices(
|
||||
item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
|
||||
):
|
||||
from services import recurring as recurring_service
|
||||
|
||||
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
|
||||
try:
|
||||
return recurring_service.list_recurring_for_gate(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
gate_id=item_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.post("/{item_id}/practices", status_code=201)
|
||||
def create_gate_practice(
|
||||
item_id: str,
|
||||
body: GatePracticeCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||
):
|
||||
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
|
||||
sched = body.schedule
|
||||
try:
|
||||
from services.scheduled_work import create_scheduled_action_at_gate
|
||||
|
||||
if sched:
|
||||
return create_scheduled_action_at_gate(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
gate_id=item_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
user_id=ctx.user_id,
|
||||
schedule_kind=sched.schedule_kind,
|
||||
interval_days=sched.interval_days,
|
||||
weekday_mask=sched.weekday_mask,
|
||||
pause_until=sched.pause_until_date(),
|
||||
)
|
||||
return create_scheduled_action_at_gate(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
gate_id=item_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
user_id=ctx.user_id,
|
||||
schedule_kind="weekdays",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.post("/{item_id}/verify-reached")
|
||||
def verify_roadmap_item_reached(
|
||||
item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return roadmap_service.verify_reached(
|
||||
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.post("/{item_id}/reopen")
|
||||
def reopen_roadmap_item(
|
||||
item_id: str,
|
||||
body: ReopenRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.roadmap.reopen")),
|
||||
):
|
||||
try:
|
||||
return roadmap_service.reopen_roadmap_item(
|
||||
tenant_id=ctx.tenant_id,
|
||||
item_id=item_id,
|
||||
user_id=ctx.user_id,
|
||||
reason=body.reason,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.get("/{item_id}/criteria")
|
||||
def list_roadmap_item_criteria(
|
||||
item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
progress = criteria_service.criteria_progress(
|
||||
tenant_id=ctx.tenant_id, item_id=item_id
|
||||
)
|
||||
items = criteria_service.list_criteria_for_item(
|
||||
tenant_id=ctx.tenant_id, item_id=item_id
|
||||
)
|
||||
return {"items": items, "progress": progress}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.post("/{item_id}/criteria", status_code=201)
|
||||
def create_roadmap_item_criterion(
|
||||
item_id: str,
|
||||
body: CriterionCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return criteria_service.create_criterion(
|
||||
tenant_id=ctx.tenant_id,
|
||||
item_id=item_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
criterion_kind=body.criterion_kind,
|
||||
verification_hint=body.verification_hint,
|
||||
sort_order=body.sort_order,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@deps_router.delete("/{dependency_id}", status_code=204)
|
||||
def delete_roadmap_dependency(
|
||||
dependency_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
roadmap_service.delete_dependency(
|
||||
tenant_id=ctx.tenant_id,
|
||||
dependency_id=dependency_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"])
|
||||
|
||||
|
||||
@criteria_router.patch("/{criterion_id}")
|
||||
def update_roadmap_criterion(
|
||||
criterion_id: str,
|
||||
body: CriterionUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
item = criteria_service.update_criterion(
|
||||
tenant_id=ctx.tenant_id,
|
||||
criterion_id=criterion_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
criterion_kind=body.criterion_kind,
|
||||
verification_hint=body.verification_hint,
|
||||
sort_order=body.sort_order,
|
||||
change_reason=body.change_reason,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Kriterium nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@criteria_router.delete("/{criterion_id}", status_code=204)
|
||||
def delete_roadmap_criterion(
|
||||
criterion_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
if not criteria_service.delete_criterion(
|
||||
tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Kriterium nicht gefunden")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@criteria_router.post("/{criterion_id}/satisfy")
|
||||
def satisfy_roadmap_criterion(
|
||||
criterion_id: str,
|
||||
body: CriterionSatisfyRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return criteria_service.satisfy_criterion(
|
||||
tenant_id=ctx.tenant_id,
|
||||
criterion_id=criterion_id,
|
||||
user_id=ctx.user_id,
|
||||
evidence_id=body.evidence_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@criteria_router.post("/{criterion_id}/waive")
|
||||
def waive_roadmap_criterion(
|
||||
criterion_id: str,
|
||||
body: CriterionWaiveDeferRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return criteria_service.waive_criterion(
|
||||
tenant_id=ctx.tenant_id,
|
||||
criterion_id=criterion_id,
|
||||
user_id=ctx.user_id,
|
||||
decision_title=body.decision_title,
|
||||
decision_description=body.decision_description,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@criteria_router.post("/{criterion_id}/defer")
|
||||
def defer_roadmap_criterion(
|
||||
criterion_id: str,
|
||||
body: CriterionWaiveDeferRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return criteria_service.defer_criterion(
|
||||
tenant_id=ctx.tenant_id,
|
||||
criterion_id=criterion_id,
|
||||
user_id=ctx.user_id,
|
||||
decision_title=body.decision_title,
|
||||
decision_description=body.decision_description,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.get("/{item_id}/dependencies")
|
||||
def list_roadmap_item_dependencies(
|
||||
item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return roadmap_service.list_dependencies(tenant_id=ctx.tenant_id, item_id=item_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.post("/{item_id}/dependencies", status_code=201)
|
||||
def add_roadmap_item_dependency(
|
||||
item_id: str,
|
||||
body: DependencyCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return roadmap_service.add_dependency(
|
||||
tenant_id=ctx.tenant_id,
|
||||
from_item_id=item_id,
|
||||
to_item_id=body.to_item_id,
|
||||
dependency_type=body.dependency_type,
|
||||
group_key=body.group_key,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
123
backend/routers/steering.py
Normal file
123
backend/routers/steering.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Steering API — methods, profiles and context (AP1.1 / AP2.0 / AP2.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from entity_archetypes.registry import resolve_default_method_key, resolve_ui_profile
|
||||
from method_profiles.registry import list_method_profiles
|
||||
from services import initiatives as initiative_service
|
||||
from services import steering_context as sc_service
|
||||
from steering.context import get_steering_context_dto
|
||||
from steering.methods.registry import (
|
||||
list_compatible_methods,
|
||||
list_methods,
|
||||
method_to_dict,
|
||||
)
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/steering", tags=["steering"])
|
||||
|
||||
|
||||
class SteeringContextUpdateRequest(BaseModel):
|
||||
method_key: Optional[str] = Field(default=None, min_length=1)
|
||||
method_profile_key: Optional[str] = Field(default=None, min_length=1)
|
||||
clear_method_profile: bool = False
|
||||
|
||||
|
||||
def _om_capabilities_for_archetype(archetype_key: str | None) -> frozenset[str] | None:
|
||||
if not archetype_key:
|
||||
return None
|
||||
profile = resolve_ui_profile(archetype_key)
|
||||
caps = profile.get("omCapabilities") or profile.get("dataSlices") or []
|
||||
return frozenset(caps)
|
||||
|
||||
|
||||
@router.get("/methods")
|
||||
def list_steering_methods(
|
||||
archetype_key: Optional[str] = Query(default=None),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
_ = ctx
|
||||
om_caps = _om_capabilities_for_archetype(archetype_key)
|
||||
if archetype_key:
|
||||
methods = list_compatible_methods(archetype_key, om_capabilities=om_caps)
|
||||
default_key = resolve_default_method_key(archetype_key)
|
||||
else:
|
||||
methods = list_methods()
|
||||
default_key = None
|
||||
|
||||
return [
|
||||
{
|
||||
**method_to_dict(m),
|
||||
"is_default": m.key == default_key if default_key else False,
|
||||
}
|
||||
for m in methods
|
||||
]
|
||||
|
||||
|
||||
@router.get("/method-profiles")
|
||||
def list_steering_method_profiles(
|
||||
initiative_archetype_key: Optional[str] = Query(default=None),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
_ = ctx
|
||||
return [
|
||||
{
|
||||
"key": p["key"],
|
||||
"label": p["label"],
|
||||
"description": p["description"],
|
||||
"initiative_archetype_key": p["initiative_archetype_key"],
|
||||
"method_key": p["method_key"],
|
||||
}
|
||||
for p in list_method_profiles(initiative_archetype_key=initiative_archetype_key)
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/initiatives/{initiative_id}/context")
|
||||
def patch_initiative_steering_context(
|
||||
initiative_id: str,
|
||||
body: SteeringContextUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||
):
|
||||
initiative = initiative_service.get_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if not initiative:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
|
||||
if not body.method_key and not body.method_profile_key and not body.clear_method_profile:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="method_key, method_profile_key oder clear_method_profile erforderlich",
|
||||
)
|
||||
|
||||
try:
|
||||
if body.clear_method_profile:
|
||||
sc_service.update_method_profile_key(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
method_profile_key=None,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
elif body.method_profile_key:
|
||||
sc_service.update_method_profile_key(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
method_profile_key=body.method_profile_key,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
elif body.method_key:
|
||||
sc_service.update_method_key(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
method_key=body.method_key,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||
77
backend/routers/tasks.py
Normal file
77
backend/routers/tasks.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Task item API — AP1.5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import tasks as task_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
class TaskUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[Literal["open", "in_progress", "done", "discarded"]] = None
|
||||
parent_task_id: Optional[str] = None
|
||||
clear_parent_task: bool = False
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
sort_order: Optional[int] = None
|
||||
due_at: Optional[str] = None
|
||||
clear_due_at: bool = False
|
||||
|
||||
|
||||
@router.patch("/{task_id}")
|
||||
def update_task(
|
||||
task_id: str,
|
||||
body: TaskUpdateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
due_at = None
|
||||
if body.due_at is not None:
|
||||
try:
|
||||
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
|
||||
try:
|
||||
item = task_service.update_task(
|
||||
tenant_id=ctx.tenant_id,
|
||||
task_id=task_id,
|
||||
user_id=ctx.user_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
parent_task_id=body.parent_task_id,
|
||||
clear_parent_task=body.clear_parent_task,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
sort_order=body.sort_order,
|
||||
due_at=due_at,
|
||||
clear_due_at=body.clear_due_at,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Task nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=204)
|
||||
def delete_task(
|
||||
task_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||
):
|
||||
try:
|
||||
deleted = task_service.delete_task(
|
||||
tenant_id=ctx.tenant_id, task_id=task_id, user_id=ctx.user_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Task nicht gefunden")
|
||||
166
backend/routers/work_cycles.py
Normal file
166
backend/routers/work_cycles.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Work cycle (Sprint/Zeitbox) API — AP2.0f."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import work_cycle as work_cycle_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
initiative_router = APIRouter(
|
||||
prefix="/api/initiatives/{initiative_id}/work-cycles",
|
||||
tags=["work-cycles"],
|
||||
)
|
||||
|
||||
|
||||
class WorkCycleCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
goal_description: str = ""
|
||||
status: Literal["planned", "active"] = "planned"
|
||||
target_date: Optional[date] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
@initiative_router.get("")
|
||||
def list_work_cycles(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
return work_cycle_service.list_work_cycles_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
|
||||
|
||||
@initiative_router.get("/active")
|
||||
def get_active_work_cycle(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
item = work_cycle_service.get_active_work_cycle(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if not item:
|
||||
return None
|
||||
item["open_action_count"] = work_cycle_service.count_actions_in_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=item["id"],
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
@initiative_router.post("", status_code=201)
|
||||
def create_work_cycle(
|
||||
initiative_id: str,
|
||||
body: WorkCycleCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
item = work_cycle_service.create_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
goal_description=body.goal_description,
|
||||
status=body.status,
|
||||
target_date=body.target_date,
|
||||
sort_order=body.sort_order,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
if body.status == "active":
|
||||
item = work_cycle_service.activate_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=item["id"],
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
return item
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{work_cycle_id}/complete")
|
||||
def complete_work_cycle(
|
||||
initiative_id: str,
|
||||
work_cycle_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return work_cycle_service.complete_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail in ("Initiative nicht gefunden", "Sprint nicht gefunden oder bereits abgeschlossen"):
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{work_cycle_id}/open-actions")
|
||||
def list_work_cycle_open_actions(
|
||||
initiative_id: str,
|
||||
work_cycle_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
work_cycle_service.validate_work_cycle_in_initiative(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
)
|
||||
return work_cycle_service.list_open_actions_in_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{work_cycle_id}/reactivate")
|
||||
def reactivate_work_cycle(
|
||||
initiative_id: str,
|
||||
work_cycle_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return work_cycle_service.reactivate_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail in ("Initiative nicht gefunden", "Sprint nicht gefunden"):
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{work_cycle_id}/activate")
|
||||
def activate_work_cycle(
|
||||
initiative_id: str,
|
||||
work_cycle_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return work_cycle_service.activate_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail in ("Initiative nicht gefunden", "Zeitbox nicht gefunden"):
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
125
backend/routers/workspace.py
Normal file
125
backend/routers/workspace.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Workspace read API — delegates to Data Layer (AP0.7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from data_layer import actions as dl_actions
|
||||
from data_layer import actors as dl_actors
|
||||
from data_layer import attention as dl_attention
|
||||
from data_layer import initiatives as dl_initiatives
|
||||
from data_layer import portfolio_steering as dl_portfolio_steering
|
||||
from data_layer import workspace as dl_workspace
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from services import initiatives as initiative_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
||||
|
||||
|
||||
class PortfolioReorderRequest(BaseModel):
|
||||
initiative_ids: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
def _require_actor_ctx(ctx: TenantContext) -> TenantContext:
|
||||
if not ctx.actor_id:
|
||||
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
|
||||
return ctx
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def workspace_summary(
|
||||
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||
):
|
||||
return dl_workspace.get_workspace_summary(ctx)
|
||||
|
||||
|
||||
@router.get("/actions/open")
|
||||
def workspace_open_actions(
|
||||
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||
):
|
||||
try:
|
||||
return dl_actions.get_my_open_actions(_require_actor_ctx(ctx))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/actions/blocked")
|
||||
def workspace_blocked_actions(
|
||||
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||
):
|
||||
try:
|
||||
return dl_actions.get_my_blocked_actions(_require_actor_ctx(ctx))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/actions/today")
|
||||
def workspace_today_actions(
|
||||
limit: Optional[int] = Query(default=15, ge=1, le=50),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||
):
|
||||
try:
|
||||
return dl_actions.get_my_today_actions(_require_actor_ctx(ctx), limit=limit or 15)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/initiatives/active")
|
||||
def workspace_active_initiatives(
|
||||
limit: Optional[int] = Query(default=None, ge=1, le=100),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||
):
|
||||
return dl_initiatives.get_active_initiatives(ctx, limit=limit)
|
||||
|
||||
|
||||
@router.get("/actors/workload")
|
||||
def workspace_actor_workload(
|
||||
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||
):
|
||||
return dl_actors.get_actor_workload(ctx)
|
||||
|
||||
|
||||
@router.get("/attention")
|
||||
def workspace_attention(
|
||||
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
|
||||
):
|
||||
return dl_attention.get_attention_items(ctx)
|
||||
|
||||
|
||||
@router.get("/next-actions")
|
||||
def workspace_next_actions(
|
||||
limit: Optional[int] = Query(default=10, ge=1, le=50),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
|
||||
):
|
||||
return dl_attention.get_next_action_candidates(ctx, limit=limit or 10)
|
||||
|
||||
|
||||
@router.get("/steering")
|
||||
def workspace_steering(
|
||||
limit: Optional[int] = Query(default=30, ge=1, le=100),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
|
||||
):
|
||||
"""Portfolio-Steuerung — Kernel-Aggregation über alle aktiven Initiativen (K-Ext-5)."""
|
||||
return dl_portfolio_steering.get_portfolio_steering(
|
||||
ctx,
|
||||
total_attention=limit or 30,
|
||||
total_next_work=min(limit or 30, 15),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/portfolio/reorder")
|
||||
def workspace_portfolio_reorder(
|
||||
body: PortfolioReorderRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||
):
|
||||
try:
|
||||
return initiative_service.reorder_portfolio(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_ids=body.initiative_ids,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
25
backend/schemas/schedule_payload.py
Normal file
25
backend/schemas/schedule_payload.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Shared Pydantic models for Schedule API payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class SchedulePayload(BaseModel):
|
||||
schedule_kind: Literal["interval", "weekdays"] = "interval"
|
||||
interval_days: int = Field(default=1, gt=0)
|
||||
weekday_mask: int = Field(default=0, ge=0, le=127)
|
||||
pause_until: Optional[str] = Field(default=None, description="ISO date YYYY-MM-DD")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_kind(self) -> "SchedulePayload":
|
||||
if self.schedule_kind == "weekdays" and self.weekday_mask <= 0:
|
||||
raise ValueError("Mindestens ein Wochentag auswählen")
|
||||
return self
|
||||
|
||||
def pause_until_date(self):
|
||||
from services.schedule import _parse_pause_until
|
||||
|
||||
return _parse_pause_until(self.pause_until)
|
||||
|
|
@ -4,6 +4,21 @@
|
|||
-- Reparatur: ungültige Human-Actors (user_id NULL verletzt CHECK constraint)
|
||||
DELETE FROM actors WHERE actor_type = 'human' AND user_id IS NULL;
|
||||
|
||||
-- AP0.5: Vorhaben/Maßnahmen pytest-Artefakte vor Actor-Löschung entfernen
|
||||
-- (initiatives.owner_actor_id → actors ON DELETE RESTRICT)
|
||||
DELETE FROM initiatives
|
||||
WHERE owner_actor_id IN (
|
||||
SELECT id FROM actors WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$')
|
||||
);
|
||||
|
||||
DELETE FROM initiatives i
|
||||
USING tenants t
|
||||
WHERE i.tenant_id = t.id
|
||||
AND t.slug ~ '^t-[a-f0-9]{10}$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tenant_memberships tm WHERE tm.tenant_id = t.id
|
||||
);
|
||||
|
||||
-- Abhängigkeiten vor User-Löschung (human actors: user_id NOT NULL constraint)
|
||||
DELETE FROM actors
|
||||
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||
|
|
|
|||
456
backend/seeds/seed_004_dogfooding_kairo_jinkendo.dev.py
Normal file
456
backend/seeds/seed_004_dogfooding_kairo_jinkendo.dev.py
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
"""Dogfooding R1 — Referenz-Vorhaben „Jinkendo Kairo“ (idempotent, Nicht-Prod).
|
||||
|
||||
DEPRECATED (2026-07-12): Nur Dev-Visualisierung. Produkt-Anlage über AP2.2a Starter-Kits.
|
||||
Siehe Kairo_MVP_Execution_Plan_v0.2.md — nicht weiter ausbauen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
GITEA_BASE = "http://192.168.2.144:3000/Lars/Kairo-Jinkendo"
|
||||
INITIATIVE_TITLE = "Jinkendo Kairo"
|
||||
TENANT_SLUG = "stommer"
|
||||
|
||||
|
||||
def _gitea_commit(sha: str, label: str = "") -> str:
|
||||
line = f"Commit: [{sha[:7]}]({GITEA_BASE}/commit/{sha})"
|
||||
if label:
|
||||
return f"{label}\n{line}\nBranch: develop"
|
||||
return f"{line}\nBranch: develop"
|
||||
|
||||
|
||||
def _resolve_context() -> tuple[str, str]:
|
||||
from db import get_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT id FROM tenants WHERE slug = %s", (TENANT_SLUG,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError(
|
||||
f"Tenant '{TENANT_SLUG}' nicht gefunden — zuerst Portal-Admin/seed_003"
|
||||
)
|
||||
tenant_id = str(row["id"])
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.id
|
||||
FROM actors a
|
||||
WHERE a.tenant_id = %s AND a.actor_type = 'human' AND a.is_active = TRUE
|
||||
ORDER BY a.created_at
|
||||
LIMIT 1
|
||||
""",
|
||||
(tenant_id,),
|
||||
)
|
||||
actor_row = cur.fetchone()
|
||||
if not actor_row:
|
||||
raise RuntimeError("Kein Human-Actor im Tenant — seed_003 zuerst")
|
||||
return tenant_id, str(actor_row["id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _find_initiative(tenant_id: str) -> Optional[dict[str, Any]]:
|
||||
from services.initiatives import list_initiatives
|
||||
|
||||
for item in list_initiatives(tenant_id=tenant_id):
|
||||
if item["title"] == INITIATIVE_TITLE:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _find_by_title(items: list[dict[str, Any]], title: str) -> Optional[dict[str, Any]]:
|
||||
for item in items:
|
||||
if item["title"] == title:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _close_gate_and_verify(*, tenant_id: str, item_id: str) -> None:
|
||||
from services.roadmap import get_roadmap_item, verify_reached
|
||||
from services.roadmap_criteria import list_criteria_for_item, waive_criterion
|
||||
|
||||
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
||||
if item and item.get("status") == "reached":
|
||||
return
|
||||
|
||||
for criterion in list_criteria_for_item(tenant_id=tenant_id, item_id=item_id):
|
||||
if criterion["status"] not in {"satisfied", "waived", "deferred"}:
|
||||
waive_criterion(
|
||||
tenant_id=tenant_id,
|
||||
criterion_id=criterion["id"],
|
||||
decision_title="Dogfooding R1 — historisches Gate",
|
||||
decision_description="Rückwirkende Abnahme per Seed.",
|
||||
)
|
||||
verify_reached(tenant_id=tenant_id, item_id=item_id)
|
||||
|
||||
|
||||
def _ensure_gate_chain(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
gates: list[tuple[str, str, str]],
|
||||
) -> dict[str, str]:
|
||||
"""gates: (title, status, goal) -> id map."""
|
||||
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
|
||||
|
||||
existing = list_roadmap_items_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
ids: dict[str, str] = {}
|
||||
ordered: list[str] = []
|
||||
|
||||
for index, (title, status, goal) in enumerate(gates):
|
||||
found = _find_by_title(existing, title)
|
||||
if found:
|
||||
gate_id = found["id"]
|
||||
else:
|
||||
created = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
item_type="milestone",
|
||||
goal_description=goal,
|
||||
status="planned" if status == "reached" else status,
|
||||
sort_order=index * 10,
|
||||
initial_criterion_title=f"{title} — Abnahme",
|
||||
)
|
||||
gate_id = created["id"]
|
||||
existing.append(created)
|
||||
|
||||
ids[title] = gate_id
|
||||
ordered.append(gate_id)
|
||||
|
||||
if status == "reached":
|
||||
_close_gate_and_verify(tenant_id=tenant_id, item_id=gate_id)
|
||||
|
||||
for i in range(1, len(ordered)):
|
||||
try:
|
||||
add_dependency(
|
||||
tenant_id=tenant_id,
|
||||
from_item_id=ordered[i],
|
||||
to_item_id=ordered[i - 1],
|
||||
dependency_type="requires",
|
||||
)
|
||||
except ValueError as exc:
|
||||
if "existiert bereits" not in str(exc):
|
||||
raise
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def _ensure_projects(
|
||||
*, tenant_id: str, initiative_id: str, titles: list[str]
|
||||
) -> dict[str, str]:
|
||||
from services.projects import create_project, list_projects_for_initiative
|
||||
|
||||
existing = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
ids: dict[str, str] = {}
|
||||
for index, title in enumerate(titles):
|
||||
found = _find_by_title(existing, title)
|
||||
if found:
|
||||
ids[title] = found["id"]
|
||||
continue
|
||||
created = create_project(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
description=f"Ist-Struktur — {title}",
|
||||
sort_order=index,
|
||||
)
|
||||
ids[title] = created["id"]
|
||||
existing.append(created)
|
||||
return ids
|
||||
|
||||
|
||||
def _ensure_action(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
status: str,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
from services.actions import create_action, list_actions_for_initiative
|
||||
|
||||
existing = list_actions_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
found = _find_by_title(existing, title)
|
||||
if found:
|
||||
if found.get("status") != status:
|
||||
from services.actions import update_action
|
||||
|
||||
updated = update_action(
|
||||
tenant_id=tenant_id,
|
||||
action_id=found["id"],
|
||||
status=status,
|
||||
)
|
||||
if updated:
|
||||
found = updated
|
||||
return found
|
||||
return create_action(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_evidence(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
description: str,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
action_id: Optional[str] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
) -> None:
|
||||
from services.evidence import create_evidence, list_evidence_for_initiative
|
||||
|
||||
for item in list_evidence_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
if item["title"] == title:
|
||||
return
|
||||
create_evidence(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
description=description,
|
||||
status="accepted",
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
action_id=action_id,
|
||||
submitted_by_actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_decision(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
outcome: str,
|
||||
) -> None:
|
||||
from db import get_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from services.decisions import create_decision
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM decisions
|
||||
WHERE tenant_id = %s AND initiative_id = %s AND title = %s
|
||||
""",
|
||||
(tenant_id, initiative_id, title),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
create_decision(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
description=outcome,
|
||||
status="decided",
|
||||
outcome=outcome,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_portfolio_first(*, tenant_id: str, initiative_id: str) -> None:
|
||||
from services.initiatives import list_initiatives, reorder_portfolio
|
||||
|
||||
all_items = list_initiatives(tenant_id=tenant_id)
|
||||
ids = [initiative_id] + [i["id"] for i in all_items if i["id"] != initiative_id]
|
||||
reorder_portfolio(tenant_id=tenant_id, initiative_ids=ids)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
if os.getenv("ENVIRONMENT", "development").strip().lower() == "production":
|
||||
print("[dogfooding] Übersprungen in Production")
|
||||
return
|
||||
|
||||
tenant_id, owner_actor_id = _resolve_context()
|
||||
|
||||
initiative = _find_initiative(tenant_id)
|
||||
if not initiative:
|
||||
from services.initiatives import create_initiative
|
||||
|
||||
initiative = create_initiative(
|
||||
tenant_id=tenant_id,
|
||||
title=INITIATIVE_TITLE,
|
||||
owner_actor_id=owner_actor_id,
|
||||
archetype_key="initiative.product",
|
||||
method_profile_key="product.kairo_dev",
|
||||
goal=(
|
||||
"Operativer Program Director — MVP v0.3 Stufe A (B2b). "
|
||||
f"Repository: {GITEA_BASE} (Branch develop)"
|
||||
),
|
||||
vision="Kairo steuert Kairo — Dogfooding Referenz-Vorhaben.",
|
||||
target_state_summary="Leitfrage in ≤2 Min; Gates + Ist gespiegelt aus Git/Docs.",
|
||||
priority="high",
|
||||
)
|
||||
print(f"[dogfooding] Initiative angelegt: {initiative['id']}")
|
||||
else:
|
||||
print(f"[dogfooding] Initiative vorhanden: {initiative['id']}")
|
||||
|
||||
initiative_id = initiative["id"]
|
||||
|
||||
gate_defs = [
|
||||
("G1 — Foundation AP0", "reached", "Tenant, Actor, Auth, OM, Migrationen"),
|
||||
("G2 — IA & Steering Skeleton", "reached", "AP1.2c, AP1.0, Snapshot, Signals"),
|
||||
("G3 — Gates & Checkliste", "reached", "AP1.4/4b/4c, Verify, Kriterien"),
|
||||
("G4 — Ist-Hierarchie & Plan/Ist", "reached", "AP1.5/5c, AP1.6, AP1.14, Journey"),
|
||||
("G5 — PM Work Modes & Plan-Outline", "active", "AP1.9a/9c ✓; AP1.12 ◐; AP1.16 ◐"),
|
||||
("G6 — Archetyp-Steuerung MVP", "active", "AP2.0 ◐; AP2.0d ✓; Execution-Graph ✓"),
|
||||
("G7 — MVP-Abnahfe B2b", "planned", "Validation, Leitfrage, Cockpit-Signale"),
|
||||
("G8 — Agent & Integration", "planned", "AP1.7, Gitea-Webhook, MCP"),
|
||||
]
|
||||
gate_ids = _ensure_gate_chain(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
gates=gate_defs,
|
||||
)
|
||||
|
||||
project_ids = _ensure_projects(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
titles=["Backend", "Frontend", "Docs & ADPs", "Deploy / CI"],
|
||||
)
|
||||
|
||||
g5 = gate_ids["G5 — PM Work Modes & Plan-Outline"]
|
||||
g6 = gate_ids["G6 — Archetyp-Steuerung MVP"]
|
||||
g4 = gate_ids["G4 — Ist-Hierarchie & Plan/Ist"]
|
||||
|
||||
done_actions = [
|
||||
(
|
||||
"AP1.6b — Journey Gate-Fortschritt",
|
||||
"done",
|
||||
g4,
|
||||
project_ids["Frontend"],
|
||||
"b85a6ec",
|
||||
),
|
||||
(
|
||||
"AP1.8a — Portfolio-Rang Cockpit",
|
||||
"done",
|
||||
g5,
|
||||
project_ids["Frontend"],
|
||||
"a19c6ab",
|
||||
),
|
||||
(
|
||||
"AP1.14 — Plan/Ist-Snapshots",
|
||||
"done",
|
||||
g4,
|
||||
project_ids["Backend"],
|
||||
"064779e",
|
||||
),
|
||||
(
|
||||
"AP2.0 — Archetyp-Slice",
|
||||
"done",
|
||||
g6,
|
||||
project_ids["Backend"],
|
||||
"3bd9832",
|
||||
),
|
||||
(
|
||||
"AP1.16 — Execution-Graph & Durchführungsplan",
|
||||
"done",
|
||||
g6,
|
||||
project_ids["Backend"],
|
||||
"b9bfa9e",
|
||||
),
|
||||
(
|
||||
"AP1.9c — Cockpit-Signale auf Portfolio-Kacheln",
|
||||
"done",
|
||||
g5,
|
||||
project_ids["Frontend"],
|
||||
"b9bfa9e",
|
||||
),
|
||||
(
|
||||
"AP2.0d — Next-Action-Strategien pro Archetyp",
|
||||
"done",
|
||||
g6,
|
||||
project_ids["Backend"],
|
||||
"284b9d5",
|
||||
),
|
||||
|
||||
(
|
||||
"DOC-Sync — Status-Review & Roadmap",
|
||||
"done",
|
||||
g6,
|
||||
project_ids["Docs & ADPs"],
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
for title, status, gate_id, project_id, sha in done_actions:
|
||||
action = _ensure_action(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
status=status,
|
||||
roadmap_item_id=gate_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
if sha:
|
||||
_ensure_evidence(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=f"Merge {title}",
|
||||
description=_gitea_commit(sha, title),
|
||||
roadmap_item_id=gate_id,
|
||||
action_id=action["id"],
|
||||
actor_id=owner_actor_id,
|
||||
)
|
||||
|
||||
open_actions = [
|
||||
("Dogfooding R2 — Ist laufend pflegen", g6, project_ids["Docs & ADPs"]),
|
||||
("AP1.7 — Operational Actor API", g6, project_ids["Backend"]),
|
||||
("AP2.0f — work_cycle / Sprint-Zeitbox", g6, project_ids["Backend"]),
|
||||
]
|
||||
for title, gate_id, project_id in open_actions:
|
||||
_ensure_action(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
status="open",
|
||||
roadmap_item_id=gate_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
_ensure_decision(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Join/Branch Graph — deferred",
|
||||
outcome="OR-Alternativpfade und volle Join-Logik erst bei Use Case (AP1.15d). "
|
||||
"Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md",
|
||||
)
|
||||
_ensure_decision(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="MCP nach Operational API",
|
||||
outcome="MCP-Adapter erst nach AP1.7 (/api/operational). Schicht 4 eingefroren bis B2b-Validation.",
|
||||
)
|
||||
_ensure_decision(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="MVP v0.3 PO-Freigabe",
|
||||
outcome="Abnahfe Stufe A: A1, A2, B2b + B3 minimal. Dokument: Kairo_MVP_Definition_v0.3.md",
|
||||
)
|
||||
|
||||
_ensure_portfolio_first(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
|
||||
from services.actors import create_actor, list_actors
|
||||
|
||||
agents = list_actors(tenant_id=tenant_id, actor_type="agent", q="Cursor")
|
||||
if not any(a["name"] == "Cursor Agent" for a in agents):
|
||||
create_actor(tenant_id=tenant_id, actor_type="agent", name="Cursor Agent")
|
||||
print("[dogfooding] Agent „Cursor Agent“ angelegt — Service Token manuell erstellen")
|
||||
|
||||
print("[dogfooding] R2 sync — Ist-Stand gespiegelt (AP1.9c, AP1.16, AP2.0d done)")
|
||||
762
backend/services/actions.py
Normal file
762
backend/services/actions.py
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
"""Action (Maßnahme) service — tenant-scoped CRUD, assignments, open actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
from services.initiatives import PRIORITIES, get_initiative
|
||||
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||
from services.projects import project_is_leaf
|
||||
from services.work_cycle import validate_work_cycle_in_initiative
|
||||
|
||||
ActionStatus = Literal[
|
||||
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||
]
|
||||
|
||||
ACTION_STATUSES = frozenset(
|
||||
{"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"}
|
||||
)
|
||||
OPEN_ACTION_STATUSES = frozenset(
|
||||
{"open", "ready", "in_progress", "blocked", "review_required"}
|
||||
)
|
||||
|
||||
_ACTION_COLUMNS = """
|
||||
id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, schedule_id,
|
||||
title, description, status, priority, due_at, sort_order, action_kind, parent_action_id,
|
||||
created_at, updated_at
|
||||
"""
|
||||
|
||||
ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue", "tech_debt"})
|
||||
UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"})
|
||||
CARRYOVER_UNPLAN_STATUSES = frozenset(
|
||||
{"open", "ready", "in_progress", "blocked", "review_required"}
|
||||
)
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in (
|
||||
"id",
|
||||
"tenant_id",
|
||||
"initiative_id",
|
||||
"project_id",
|
||||
"roadmap_item_id",
|
||||
"work_cycle_id",
|
||||
"parent_action_id",
|
||||
"owner_actor_id",
|
||||
"schedule_id",
|
||||
):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
result["created_at"] = result["created_at"].isoformat()
|
||||
if result.get("updated_at"):
|
||||
result["updated_at"] = result["updated_at"].isoformat()
|
||||
if result.get("due_at"):
|
||||
result["due_at"] = result["due_at"].isoformat()
|
||||
return result
|
||||
|
||||
|
||||
def _validate_parent_action_in_initiative(
|
||||
*,
|
||||
cur,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
parent_action_id: Optional[str],
|
||||
action_id: Optional[str] = None,
|
||||
) -> None:
|
||||
if not parent_action_id:
|
||||
return
|
||||
if action_id and parent_action_id == action_id:
|
||||
raise ValueError("Arbeitspaket kann nicht auf sich selbst verweisen")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT action_kind
|
||||
FROM actions
|
||||
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(parent_action_id, tenant_id, initiative_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden")
|
||||
action_kind = row["action_kind"] if isinstance(row, dict) else row[0]
|
||||
if action_kind in ("bug", "issue", "tech_debt"):
|
||||
raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue/Schuld")
|
||||
|
||||
|
||||
def _action_kind_to_item_kind(action_kind: str) -> str:
|
||||
if action_kind == "bug":
|
||||
return "bug"
|
||||
if action_kind == "issue":
|
||||
return "issue"
|
||||
if action_kind == "tech_debt":
|
||||
return "tech_debt"
|
||||
return "story"
|
||||
|
||||
|
||||
def _validate_action_kind(action_kind: str) -> None:
|
||||
if action_kind not in ACTION_KINDS:
|
||||
raise ValueError(f"Ungültiger action_kind: {action_kind}")
|
||||
|
||||
|
||||
def _validate_action_status(status: str) -> None:
|
||||
if status not in ACTION_STATUSES:
|
||||
raise ValueError(f"Ungültiger Action-Status: {status}")
|
||||
|
||||
|
||||
def _validate_priority(priority: str) -> None:
|
||||
if priority not in PRIORITIES:
|
||||
raise ValueError(f"Ungültige Priorität: {priority}")
|
||||
|
||||
|
||||
def _load_assignments(*, tenant_id: str, action_ids: list[str]) -> dict[str, list[str]]:
|
||||
if not action_ids:
|
||||
return {}
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT action_id, actor_id
|
||||
FROM action_assignments
|
||||
WHERE tenant_id = %s AND action_id = ANY(%s::uuid[])
|
||||
ORDER BY created_at
|
||||
""",
|
||||
(tenant_id, action_ids),
|
||||
)
|
||||
result: dict[str, list[str]] = {aid: [] for aid in action_ids}
|
||||
for row in cur.fetchall():
|
||||
result[str(row["action_id"])].append(str(row["actor_id"]))
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _attach_assignments(
|
||||
actions: list[dict[str, Any]], *, tenant_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
if not actions:
|
||||
return actions
|
||||
action_ids = [a["id"] for a in actions]
|
||||
assignments = _load_assignments(tenant_id=tenant_id, action_ids=action_ids)
|
||||
for action in actions:
|
||||
action["assigned_actor_ids"] = assignments.get(action["id"], [])
|
||||
return actions
|
||||
|
||||
|
||||
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
|
||||
(actor_id, tenant_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _validate_project_in_initiative(
|
||||
*, tenant_id: str, initiative_id: str, project_id: Optional[str]
|
||||
) -> None:
|
||||
if not project_id:
|
||||
return
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM projects
|
||||
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(project_id, tenant_id, initiative_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise ValueError("Projekt gehört nicht zum Vorhaben")
|
||||
if not project_is_leaf(tenant_id=tenant_id, project_id=project_id):
|
||||
raise ValueError(
|
||||
"Arbeitspakete nur an Blatt-Projekten (ohne Unterprojekte)"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_action(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: ActionStatus = "open",
|
||||
priority: str = "normal",
|
||||
due_at: Optional[Any] = None,
|
||||
project_id: Optional[str] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
work_cycle_id: Optional[str] = None,
|
||||
assigned_actor_ids: Optional[list[str]] = None,
|
||||
sort_order: int = 0,
|
||||
action_kind: str = "delivery",
|
||||
parent_action_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
_validate_action_status(status)
|
||||
_validate_priority(priority)
|
||||
_validate_action_kind(action_kind)
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
_validate_project_in_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, project_id=project_id
|
||||
)
|
||||
|
||||
assigned_actor_ids = assigned_actor_ids or []
|
||||
for actor_id in assigned_actor_ids:
|
||||
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id):
|
||||
raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
validate_roadmap_item_in_initiative(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
validate_work_cycle_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
cur=cur,
|
||||
)
|
||||
_validate_parent_action_in_initiative(
|
||||
cur=cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
parent_action_id=parent_action_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO actions (
|
||||
tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id,
|
||||
title, description, status, priority, due_at, sort_order, action_kind,
|
||||
parent_action_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_ACTION_COLUMNS}
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
project_id,
|
||||
roadmap_item_id,
|
||||
work_cycle_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
due_at,
|
||||
sort_order,
|
||||
action_kind,
|
||||
parent_action_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
for actor_id in assigned_actor_ids:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO action_assignments (tenant_id, action_id, actor_id)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
(tenant_id, row["id"], actor_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
row["assigned_actor_ids"] = assigned_actor_ids
|
||||
log_audit(
|
||||
"action.created",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"action_id": row["id"],
|
||||
"initiative_id": initiative_id,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"assigned_actor_ids": assigned_actor_ids,
|
||||
},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def list_actions_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
return []
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_ACTION_COLUMNS}
|
||||
FROM actions
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY sort_order ASC, title ASC, created_at DESC
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
return _attach_assignments(actions, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def get_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_ACTION_COLUMNS}
|
||||
FROM actions
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
action = _serialize_row(dict(row))
|
||||
finally:
|
||||
conn.close()
|
||||
enriched = _attach_assignments([action], tenant_id=tenant_id)[0]
|
||||
from services.scheduled_work import attach_action_schedule_fields
|
||||
|
||||
return attach_action_schedule_fields(enriched, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def update_action(
|
||||
*,
|
||||
tenant_id: str,
|
||||
action_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[ActionStatus] = None,
|
||||
priority: Optional[str] = None,
|
||||
due_at: Optional[Any] = None,
|
||||
clear_due_at: bool = False,
|
||||
project_id: Optional[str] = None,
|
||||
clear_project: bool = False,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
clear_roadmap_item: bool = False,
|
||||
work_cycle_id: Optional[str] = None,
|
||||
clear_work_cycle: bool = False,
|
||||
sort_order: Optional[int] = None,
|
||||
action_kind: Optional[str] = None,
|
||||
parent_action_id: Optional[str] = None,
|
||||
clear_parent_action: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
old_status = existing["status"]
|
||||
|
||||
if title is not None:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
updates.append("title = %s")
|
||||
params.append(title)
|
||||
if description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(description)
|
||||
if status is not None:
|
||||
_validate_action_status(status)
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if priority is not None:
|
||||
_validate_priority(priority)
|
||||
updates.append("priority = %s")
|
||||
params.append(priority)
|
||||
if clear_due_at:
|
||||
updates.append("due_at = NULL")
|
||||
elif due_at is not None:
|
||||
updates.append("due_at = %s")
|
||||
params.append(due_at)
|
||||
if clear_project:
|
||||
updates.append("project_id = NULL")
|
||||
elif project_id is not None:
|
||||
_validate_project_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
project_id=project_id,
|
||||
)
|
||||
updates.append("project_id = %s")
|
||||
params.append(project_id)
|
||||
if clear_roadmap_item:
|
||||
updates.append("roadmap_item_id = NULL")
|
||||
elif roadmap_item_id is not None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
validate_roadmap_item_in_initiative(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
updates.append("roadmap_item_id = %s")
|
||||
params.append(roadmap_item_id)
|
||||
if clear_work_cycle:
|
||||
updates.append("work_cycle_id = NULL")
|
||||
elif work_cycle_id is not None:
|
||||
validate_work_cycle_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
work_cycle_id=work_cycle_id,
|
||||
)
|
||||
updates.append("work_cycle_id = %s")
|
||||
params.append(work_cycle_id)
|
||||
if sort_order is not None:
|
||||
updates.append("sort_order = %s")
|
||||
params.append(sort_order)
|
||||
if action_kind is not None:
|
||||
_validate_action_kind(action_kind)
|
||||
updates.append("action_kind = %s")
|
||||
params.append(action_kind)
|
||||
if clear_parent_action:
|
||||
updates.append("parent_action_id = NULL")
|
||||
elif parent_action_id is not None:
|
||||
updates.append("parent_action_id = %s")
|
||||
params.append(parent_action_id)
|
||||
|
||||
if not updates:
|
||||
return existing
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
params.extend([action_id, tenant_id])
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
if parent_action_id is not None and not clear_parent_action:
|
||||
_validate_parent_action_in_initiative(
|
||||
cur=cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
parent_action_id=parent_action_id,
|
||||
action_id=action_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE actions
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING {_ACTION_COLUMNS}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||
assert result is not None
|
||||
|
||||
log_audit(
|
||||
"action.updated",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"action_id": action_id, "fields": updates},
|
||||
)
|
||||
if status is not None and status != old_status:
|
||||
log_audit(
|
||||
"action.status_changed",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"action_id": action_id,
|
||||
"from_status": old_status,
|
||||
"to_status": status,
|
||||
},
|
||||
)
|
||||
if status == "done":
|
||||
from services.steering_events import emit_steering_event
|
||||
|
||||
emit_steering_event(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=str(existing["initiative_id"]),
|
||||
event_kind="actor_result",
|
||||
payload={
|
||||
"action_id": action_id,
|
||||
"from_status": old_status,
|
||||
"to_status": status,
|
||||
},
|
||||
user_id=user_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def set_action_assignments(
|
||||
*,
|
||||
tenant_id: str,
|
||||
action_id: str,
|
||||
actor_ids: list[str],
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
unique_actor_ids = list(dict.fromkeys(actor_ids))
|
||||
for actor_id in unique_actor_ids:
|
||||
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id):
|
||||
raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM action_assignments WHERE action_id = %s AND tenant_id = %s",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
for actor_id in unique_actor_ids:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO action_assignments (tenant_id, action_id, actor_id)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
(tenant_id, action_id, actor_id),
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE actions SET updated_at = NOW() WHERE id = %s AND tenant_id = %s",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"action.assigned",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"action_id": action_id,
|
||||
"actor_ids": unique_actor_ids,
|
||||
"previous_actor_ids": existing.get("assigned_actor_ids", []),
|
||||
},
|
||||
)
|
||||
return get_action(tenant_id=tenant_id, action_id=action_id)
|
||||
|
||||
|
||||
def delete_action(
|
||||
*,
|
||||
tenant_id: str,
|
||||
action_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM actions WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
deleted = cur.fetchone() is not None
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted:
|
||||
log_audit(
|
||||
"action.deleted",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"action_id": action_id},
|
||||
)
|
||||
return deleted
|
||||
|
||||
|
||||
def list_open_actions_for_actor(
|
||||
*, tenant_id: str, actor_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
|
||||
a.status, a.priority, a.due_at, a.created_at, a.updated_at,
|
||||
i.title AS initiative_title
|
||||
FROM actions a
|
||||
JOIN action_assignments aa ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||
JOIN initiatives i ON i.id = a.initiative_id AND i.tenant_id = a.tenant_id
|
||||
WHERE a.tenant_id = %s
|
||||
AND aa.actor_id = %s
|
||||
AND a.status = ANY(%s)
|
||||
ORDER BY
|
||||
CASE a.priority
|
||||
WHEN 'high' THEN 0
|
||||
WHEN 'normal' THEN 1
|
||||
WHEN 'low' THEN 2
|
||||
END,
|
||||
a.updated_at DESC
|
||||
""",
|
||||
(tenant_id, actor_id, list(OPEN_ACTION_STATUSES)),
|
||||
)
|
||||
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
return _attach_assignments(actions, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def unplan_action_to_backlog(
|
||||
*,
|
||||
tenant_id: str,
|
||||
action_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
carryover: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Entfernt ein Arbeitspaket aus dem Sprint und stellt es im Eingang wieder her."""
|
||||
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||
if not existing:
|
||||
raise ValueError("Arbeitspaket nicht gefunden")
|
||||
allowed = CARRYOVER_UNPLAN_STATUSES if carryover else UNPLAN_ALLOWED_STATUSES
|
||||
if existing["status"] not in allowed:
|
||||
raise ValueError(
|
||||
"Arbeitspaket kann in diesem Status nicht zurück in den Eingang geschoben werden"
|
||||
if carryover
|
||||
else "Nur offene oder bereite Arbeitspakete können zurück in den Eingang"
|
||||
)
|
||||
|
||||
initiative_id = existing["initiative_id"]
|
||||
item_kind = _action_kind_to_item_kind(existing.get("action_kind") or "delivery")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM backlog_items
|
||||
WHERE tenant_id = %s AND converted_action_id = %s
|
||||
""",
|
||||
(tenant_id, action_id),
|
||||
)
|
||||
linked = cur.fetchone()
|
||||
|
||||
if linked:
|
||||
backlog_id = str(linked["id"])
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE backlog_items
|
||||
SET status = 'accepted',
|
||||
converted_action_id = NULL,
|
||||
title = %s,
|
||||
description = %s,
|
||||
priority = %s,
|
||||
roadmap_item_id = %s,
|
||||
item_kind = %s,
|
||||
parent_action_id = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, tenant_id, initiative_id, title, description, status,
|
||||
priority, roadmap_item_id, converted_action_id, sort_order,
|
||||
item_kind, parent_action_id, created_at, updated_at
|
||||
""",
|
||||
(
|
||||
existing["title"],
|
||||
existing.get("description") or "",
|
||||
existing["priority"],
|
||||
existing.get("roadmap_item_id"),
|
||||
item_kind,
|
||||
existing.get("parent_action_id"),
|
||||
backlog_id,
|
||||
tenant_id,
|
||||
),
|
||||
)
|
||||
backlog_row = _serialize_row(dict(cur.fetchone()))
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
|
||||
FROM backlog_items
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
next_order = int(cur.fetchone()["next_order"])
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO backlog_items (
|
||||
tenant_id, initiative_id, title, description, status, priority,
|
||||
roadmap_item_id, sort_order, item_kind, parent_action_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, 'accepted', %s, %s, %s, %s, %s)
|
||||
RETURNING id, tenant_id, initiative_id, title, description, status,
|
||||
priority, roadmap_item_id, converted_action_id, sort_order,
|
||||
item_kind, parent_action_id, created_at, updated_at
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
existing["title"],
|
||||
existing.get("description") or "",
|
||||
existing["priority"],
|
||||
existing.get("roadmap_item_id"),
|
||||
next_order,
|
||||
item_kind,
|
||||
existing.get("parent_action_id"),
|
||||
),
|
||||
)
|
||||
backlog_row = _serialize_row(dict(cur.fetchone()))
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE actions
|
||||
SET status = 'discarded',
|
||||
work_cycle_id = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, tenant_id, initiative_id, project_id, roadmap_item_id,
|
||||
work_cycle_id, title, description, status, priority, due_at,
|
||||
sort_order, action_kind, parent_action_id, created_at, updated_at
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
action_row = _serialize_row(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
action_result = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||
log_audit(
|
||||
"action.unplanned_to_backlog",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"action_id": action_id,
|
||||
"backlog_item_id": backlog_row["id"],
|
||||
"initiative_id": initiative_id,
|
||||
},
|
||||
)
|
||||
return {"backlog_item": backlog_row, "action": action_result or action_row}
|
||||
280
backend/services/actor_service_tokens.py
Normal file
280
backend/services/actor_service_tokens.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""Actor Service Tokens — AP1.7c (machine auth for Operational API)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
|
||||
TOKEN_PREFIX = "kairo_at_"
|
||||
DEFAULT_AGENT_CAPABILITIES: tuple[str, ...] = (
|
||||
"kairo.initiative.read",
|
||||
"kairo.action.read",
|
||||
"kairo.action.manage",
|
||||
"kairo.blocker.read",
|
||||
"kairo.blocker.manage",
|
||||
"kairo.evidence.read",
|
||||
"kairo.evidence.manage",
|
||||
"kairo.backlog.read",
|
||||
"kairo.backlog.manage",
|
||||
"kairo.decision.read",
|
||||
"kairo.decision.manage",
|
||||
"kairo.recurring.read",
|
||||
"kairo.recurring.manage",
|
||||
)
|
||||
|
||||
ALLOWED_TOKEN_CAPABILITIES = frozenset(DEFAULT_AGENT_CAPABILITIES)
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _generate_token_parts() -> tuple[str, str, str]:
|
||||
prefix = secrets.token_hex(6)
|
||||
secret = secrets.token_urlsafe(24)
|
||||
raw = f"{TOKEN_PREFIX}{prefix}_{secret}"
|
||||
return prefix, secret, raw
|
||||
|
||||
|
||||
def _validate_capabilities(capabilities: list[str]) -> list[str]:
|
||||
if not capabilities:
|
||||
return list(DEFAULT_AGENT_CAPABILITIES)
|
||||
invalid = [cap for cap in capabilities if cap not in ALLOWED_TOKEN_CAPABILITIES]
|
||||
if invalid:
|
||||
raise ValueError(f"Capabilities nicht erlaubt für Service Token: {', '.join(invalid)}")
|
||||
return sorted(set(capabilities))
|
||||
|
||||
|
||||
def _serialize_token_row(row: dict[str, Any], *, include_actor: bool = False) -> dict[str, Any]:
|
||||
item = {
|
||||
"id": str(row["id"]),
|
||||
"tenant_id": str(row["tenant_id"]),
|
||||
"actor_id": str(row["actor_id"]),
|
||||
"label": row["label"],
|
||||
"token_prefix": row["token_prefix"],
|
||||
"capabilities": list(row.get("capabilities") or []),
|
||||
"is_active": bool(row["is_active"]),
|
||||
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
|
||||
"last_used_at": row["last_used_at"].isoformat() if row.get("last_used_at") else None,
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"revoked_at": row["revoked_at"].isoformat() if row.get("revoked_at") else None,
|
||||
}
|
||||
if include_actor and row.get("actor_name"):
|
||||
item["actor_name"] = row["actor_name"]
|
||||
item["actor_type"] = row.get("actor_type")
|
||||
return item
|
||||
|
||||
|
||||
def create_service_token(
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
label: str,
|
||||
created_by_user_id: Optional[str] = None,
|
||||
capabilities: Optional[list[str]] = None,
|
||||
expires_in_days: Optional[int] = 365,
|
||||
) -> dict[str, Any]:
|
||||
label = label.strip()
|
||||
if not label:
|
||||
raise ValueError("Label ist erforderlich")
|
||||
|
||||
caps = _validate_capabilities(list(capabilities or []))
|
||||
prefix, _secret, raw_token = _generate_token_parts()
|
||||
token_hash = _hash_token(raw_token)
|
||||
expires_at = None
|
||||
if expires_in_days and expires_in_days > 0:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, actor_type, name, is_active
|
||||
FROM actors
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(actor_id, tenant_id),
|
||||
)
|
||||
actor = cur.fetchone()
|
||||
if not actor:
|
||||
raise ValueError("Actor nicht gefunden")
|
||||
if not actor["is_active"]:
|
||||
raise ValueError("Actor ist inaktiv")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO actor_service_tokens (
|
||||
tenant_id, actor_id, label, token_prefix, token_hash,
|
||||
capabilities, expires_at, created_by_user_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
|
||||
RETURNING id, tenant_id, actor_id, label, token_prefix, capabilities,
|
||||
is_active, expires_at, last_used_at, created_at, revoked_at
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
actor_id,
|
||||
label,
|
||||
prefix,
|
||||
token_hash,
|
||||
json.dumps(caps),
|
||||
expires_at,
|
||||
created_by_user_id,
|
||||
),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"actor_service_token.created",
|
||||
user_id=created_by_user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"token_id": str(row["id"]), "actor_id": actor_id, "label": label},
|
||||
)
|
||||
|
||||
result = _serialize_token_row(row)
|
||||
result["token"] = raw_token
|
||||
return result
|
||||
|
||||
|
||||
def list_service_tokens_for_actor(
|
||||
*, tenant_id: str, actor_id: str, include_revoked: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
conditions = ["t.tenant_id = %s", "t.actor_id = %s"]
|
||||
params: list[Any] = [tenant_id, actor_id]
|
||||
if not include_revoked:
|
||||
conditions.append("t.revoked_at IS NULL")
|
||||
where = " AND ".join(conditions)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT t.id, t.tenant_id, t.actor_id, t.label, t.token_prefix,
|
||||
t.capabilities, t.is_active, t.expires_at, t.last_used_at,
|
||||
t.created_at, t.revoked_at
|
||||
FROM actor_service_tokens t
|
||||
WHERE {where}
|
||||
ORDER BY t.created_at DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
return [_serialize_token_row(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def revoke_service_token(
|
||||
*, tenant_id: str, token_id: str, user_id: Optional[str] = None
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE actor_service_tokens
|
||||
SET is_active = FALSE, revoked_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s AND revoked_at IS NULL
|
||||
""",
|
||||
(token_id, tenant_id),
|
||||
)
|
||||
updated = cur.rowcount > 0
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if updated:
|
||||
log_audit(
|
||||
"actor_service_token.revoked",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"token_id": token_id},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def validate_service_token(raw_token: str) -> Optional[dict[str, Any]]:
|
||||
"""Resolve token → auth context dict or None."""
|
||||
token = (raw_token or "").strip()
|
||||
if not token.startswith(TOKEN_PREFIX) or len(token) < 20:
|
||||
return None
|
||||
|
||||
token_hash = _hash_token(token)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.tenant_id,
|
||||
t.actor_id,
|
||||
t.capabilities,
|
||||
t.is_active,
|
||||
t.expires_at,
|
||||
t.revoked_at,
|
||||
a.name AS actor_name,
|
||||
a.actor_type,
|
||||
a.is_active AS actor_is_active,
|
||||
tn.slug AS tenant_slug,
|
||||
tn.name AS tenant_name
|
||||
FROM actor_service_tokens t
|
||||
JOIN actors a ON a.id = t.actor_id AND a.tenant_id = t.tenant_id
|
||||
JOIN tenants tn ON tn.id = t.tenant_id
|
||||
WHERE t.token_hash = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
row = dict(row)
|
||||
if not row["is_active"] or row.get("revoked_at"):
|
||||
return None
|
||||
if not row["actor_is_active"] or not row.get("tenant_slug"):
|
||||
return None
|
||||
if row.get("expires_at"):
|
||||
expires = row["expires_at"]
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if expires <= datetime.now(timezone.utc):
|
||||
return None
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE actor_service_tokens
|
||||
SET last_used_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(str(row["id"]),),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
caps = row.get("capabilities") or []
|
||||
if isinstance(caps, str):
|
||||
caps = json.loads(caps)
|
||||
|
||||
return {
|
||||
"token_id": str(row["id"]),
|
||||
"tenant_id": str(row["tenant_id"]),
|
||||
"tenant_slug": row["tenant_slug"],
|
||||
"tenant_name": row["tenant_name"],
|
||||
"actor_id": str(row["actor_id"]),
|
||||
"actor_name": row["actor_name"],
|
||||
"actor_type": row["actor_type"],
|
||||
"capabilities": frozenset(str(c) for c in caps),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Actor creation helpers — User and Actor remain separate concepts."""
|
||||
"""Actor creation and directory helpers — User and Actor remain separate concepts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -10,6 +10,20 @@ from db import get_connection
|
|||
|
||||
ActorType = Literal["human", "agent", "working_group", "external_system"]
|
||||
|
||||
ACTOR_TYPES = frozenset({"human", "agent", "working_group", "external_system"})
|
||||
|
||||
|
||||
def _serialize_actor(row: dict[str, Any], *, include_user_id: bool = False) -> dict[str, Any]:
|
||||
result = {
|
||||
"id": str(row["id"]),
|
||||
"name": row["name"],
|
||||
"actor_type": row["actor_type"],
|
||||
"is_active": bool(row["is_active"]),
|
||||
}
|
||||
if include_user_id and row.get("user_id"):
|
||||
result["user_id"] = str(row["user_id"])
|
||||
return result
|
||||
|
||||
|
||||
def create_actor(
|
||||
*,
|
||||
|
|
@ -66,3 +80,70 @@ def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]:
|
|||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_actors(
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_type: Optional[str] = None,
|
||||
include_inactive: bool = False,
|
||||
q: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if actor_type is not None and actor_type not in ACTOR_TYPES:
|
||||
raise ValueError(f"Ungültiger actor_type: {actor_type}")
|
||||
|
||||
conditions = ["tenant_id = %s"]
|
||||
params: list[Any] = [tenant_id]
|
||||
|
||||
if not include_inactive:
|
||||
conditions.append("is_active = TRUE")
|
||||
if actor_type:
|
||||
conditions.append("actor_type = %s")
|
||||
params.append(actor_type)
|
||||
if q:
|
||||
conditions.append("name ILIKE %s")
|
||||
params.append(f"%{q.strip()}%")
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, actor_type, name, user_id, is_active
|
||||
FROM actors
|
||||
WHERE {where}
|
||||
ORDER BY
|
||||
CASE actor_type
|
||||
WHEN 'human' THEN 0
|
||||
WHEN 'working_group' THEN 1
|
||||
WHEN 'agent' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
name
|
||||
""",
|
||||
params,
|
||||
)
|
||||
return [_serialize_actor(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_actor(*, tenant_id: str, actor_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, actor_type, name, user_id, is_active
|
||||
FROM actors
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(actor_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return _serialize_actor(dict(row))
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
|
|||
539
backend/services/archetype_starter_kit.py
Normal file
539
backend/services/archetype_starter_kit.py
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
"""Archetyp Starter-Kits — AP2.2a (guided creation without Structure Builder)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from entity_archetypes import resolve_default_method_key
|
||||
|
||||
# Stufe-A Archetypen mit automatischem Starter-Kit bei Anlage
|
||||
STARTER_KIT_ARCHETYPES = frozenset(
|
||||
{
|
||||
"initiative.maturity_journey",
|
||||
"initiative.linear_project",
|
||||
"initiative.product",
|
||||
"initiative.program",
|
||||
}
|
||||
)
|
||||
|
||||
_GUIDANCE: dict[str, str] = {
|
||||
"initiative.maturity_journey": (
|
||||
"Aktuelle Stufe und heutige Übung steuern deinen nächsten Schritt."
|
||||
),
|
||||
"initiative.linear_project": (
|
||||
"Kritischer Pfad und Abhängigkeiten steuern Next — nicht die Gesamtliste."
|
||||
),
|
||||
"initiative.product": (
|
||||
"Eingang triagieren, dann committete Actions — optional Sprint-Zeitbox (B3)."
|
||||
),
|
||||
"initiative.program": (
|
||||
"Programm mit Abschluss — Gate-Horizont in Kontrolle."
|
||||
),
|
||||
}
|
||||
|
||||
_PROFILE_HINTS: dict[str, list[str]] = {
|
||||
"initiative.maturity_journey": ["maturity.karate_kumite"],
|
||||
"initiative.linear_project": ["project.agile_iteration"],
|
||||
"initiative.product": ["product.kairo_dev"],
|
||||
"initiative.program": [],
|
||||
}
|
||||
|
||||
|
||||
def _find_by_title(items: list[dict[str, Any]], title: str) -> Optional[dict[str, Any]]:
|
||||
for item in items:
|
||||
if item.get("title") == title:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def supports_starter_kit(archetype_key: str) -> bool:
|
||||
return archetype_key in STARTER_KIT_ARCHETYPES
|
||||
|
||||
|
||||
def get_starter_preview(
|
||||
*,
|
||||
archetype_key: str,
|
||||
method_profile_key: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
method_key = resolve_default_method_key(archetype_key)
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
if archetype_key == "initiative.maturity_journey":
|
||||
items = [
|
||||
{"kind": "maturity_stage", "title": "Stufe 1 — Basis", "status": "active"},
|
||||
{"kind": "maturity_stage", "title": "Stufe 2 — Aufbau", "status": "planned"},
|
||||
{"kind": "maturity_stage", "title": "Stufe 3 — Ziel", "status": "planned"},
|
||||
{"kind": "project", "title": "Training"},
|
||||
{"kind": "recurring", "title": "Vorbeuge-Dehnung"},
|
||||
{"kind": "recurring", "title": "Hüftöffner (Schmetterling)"},
|
||||
]
|
||||
elif archetype_key == "initiative.linear_project":
|
||||
items = [
|
||||
{"kind": "milestone", "title": "G1 — Planung", "status": "active"},
|
||||
{"kind": "milestone", "title": "G2 — Vorbereitung", "status": "planned"},
|
||||
{"kind": "milestone", "title": "G3 — Umsetzung", "status": "planned"},
|
||||
{"kind": "milestone", "title": "G4 — Abschluss", "status": "planned"},
|
||||
{"kind": "project", "title": "Hauptpfad"},
|
||||
{"kind": "project", "title": "Begleitung"},
|
||||
{"kind": "action", "title": "Erster Schritt — Planung konkretisieren"},
|
||||
]
|
||||
elif archetype_key == "initiative.product":
|
||||
items = [
|
||||
{"kind": "milestone", "title": "Orientierung", "status": "active"},
|
||||
{"kind": "milestone", "title": "Nächster Release-Horizont", "status": "planned"},
|
||||
{"kind": "project", "title": "Entwicklung"},
|
||||
{"kind": "project", "title": "Betrieb"},
|
||||
{"kind": "backlog_item", "title": "Idee / Issue"},
|
||||
{"kind": "hint", "title": "Optional: Sprint-Zeitbox unter Plan anlegen (B3)"},
|
||||
]
|
||||
elif archetype_key == "initiative.program":
|
||||
items = [
|
||||
{"kind": "milestone", "title": "Phase 1 — Start", "status": "active"},
|
||||
{"kind": "milestone", "title": "Phase 2 — Lieferung", "status": "planned"},
|
||||
{"kind": "milestone", "title": "Abschluss", "status": "planned"},
|
||||
]
|
||||
|
||||
return {
|
||||
"archetype_key": archetype_key,
|
||||
"method_key": method_key,
|
||||
"method_profile_key": method_profile_key,
|
||||
"supported": supports_starter_kit(archetype_key),
|
||||
"guidance": _GUIDANCE.get(archetype_key, ""),
|
||||
"method_profile_hints": _PROFILE_HINTS.get(archetype_key, []),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def apply_starter_kit(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
archetype_key: str,
|
||||
owner_actor_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply idempotent starter structure for supported Stufe-A archetypes."""
|
||||
if not supports_starter_kit(archetype_key):
|
||||
return {"applied": False, "reason": "unsupported_archetype"}
|
||||
|
||||
from services.roadmap import list_roadmap_items_for_initiative
|
||||
|
||||
existing = list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if existing:
|
||||
return {"applied": False, "reason": "structure_exists"}
|
||||
|
||||
created: dict[str, list[str]] = {
|
||||
"roadmap_items": [],
|
||||
"projects": [],
|
||||
"recurring": [],
|
||||
"backlog_items": [],
|
||||
"actions": [],
|
||||
}
|
||||
|
||||
if archetype_key == "initiative.maturity_journey":
|
||||
created = _apply_a1(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif archetype_key == "initiative.linear_project":
|
||||
created = _apply_a2(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif archetype_key == "initiative.product":
|
||||
created = _apply_b2b(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif archetype_key == "initiative.program":
|
||||
created = _apply_b2a(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
from services.audit import log_audit
|
||||
|
||||
log_audit(
|
||||
"initiative.starter_kit_applied",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"initiative_id": initiative_id,
|
||||
"archetype_key": archetype_key,
|
||||
"created": created,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"applied": True,
|
||||
"archetype_key": archetype_key,
|
||||
"guidance": _GUIDANCE.get(archetype_key, ""),
|
||||
"created": created,
|
||||
}
|
||||
|
||||
|
||||
def _apply_a1(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str],
|
||||
) -> dict[str, list[str]]:
|
||||
from services.projects import create_project, list_projects_for_initiative
|
||||
from services.roadmap import create_roadmap_item
|
||||
|
||||
roadmap_ids: list[str] = []
|
||||
stage_defs = [
|
||||
("Stufe 1 — Basis", "active"),
|
||||
("Stufe 2 — Aufbau", "planned"),
|
||||
("Stufe 3 — Ziel", "planned"),
|
||||
]
|
||||
stage_items: list[dict[str, Any]] = []
|
||||
for index, (title, status) in enumerate(stage_defs):
|
||||
item = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
item_type="maturity_stage",
|
||||
status=status, # type: ignore[arg-type]
|
||||
sort_order=index * 10,
|
||||
user_id=user_id,
|
||||
)
|
||||
roadmap_ids.append(item["id"])
|
||||
stage_items.append(item)
|
||||
|
||||
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
if not _find_by_title(projects, "Training"):
|
||||
create_project(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Training",
|
||||
description="Übungs- und Trainingsstruktur",
|
||||
sort_order=0,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
from services.maturity_practice import seed_practices_for_stage
|
||||
|
||||
exercises = seed_practices_for_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
stage=stage_items[0],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"roadmap_items": roadmap_ids,
|
||||
"projects": ["Training"],
|
||||
"recurring": [e["title"] for e in exercises],
|
||||
}
|
||||
|
||||
|
||||
def _apply_a2(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str],
|
||||
) -> dict[str, list[str]]:
|
||||
from services.projects import create_project, list_projects_for_initiative
|
||||
from services.roadmap import add_dependency, create_roadmap_item
|
||||
|
||||
gate_titles = [
|
||||
("G1 — Planung", "active"),
|
||||
("G2 — Vorbereitung", "planned"),
|
||||
("G3 — Umsetzung", "planned"),
|
||||
("G4 — Abschluss", "planned"),
|
||||
]
|
||||
gate_ids: list[str] = []
|
||||
for index, (title, status) in enumerate(gate_titles):
|
||||
item = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
item_type="milestone",
|
||||
status=status, # type: ignore[arg-type]
|
||||
sort_order=index * 10,
|
||||
user_id=user_id,
|
||||
)
|
||||
gate_ids.append(item["id"])
|
||||
|
||||
for index in range(1, len(gate_ids)):
|
||||
try:
|
||||
add_dependency(
|
||||
tenant_id=tenant_id,
|
||||
from_item_id=gate_ids[index],
|
||||
to_item_id=gate_ids[index - 1],
|
||||
dependency_type="requires",
|
||||
)
|
||||
except ValueError as exc:
|
||||
if "existiert bereits" not in str(exc):
|
||||
raise
|
||||
|
||||
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
for index, title in enumerate(["Hauptpfad", "Begleitung"]):
|
||||
if not _find_by_title(projects, title):
|
||||
create_project(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
description=f"Ist-Struktur — {title}",
|
||||
sort_order=index,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
from services.actions import create_action, list_actions_for_initiative
|
||||
|
||||
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
hauptpfad = _find_by_title(projects, "Hauptpfad")
|
||||
actions = list_actions_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
if not _find_by_title(actions, "Erster Schritt — Planung konkretisieren"):
|
||||
create_action(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Erster Schritt — Planung konkretisieren",
|
||||
description="Starter-Arbeitspaket am ersten Gate",
|
||||
status="open",
|
||||
roadmap_item_id=gate_ids[0],
|
||||
project_id=hauptpfad["id"] if hauptpfad else None,
|
||||
sort_order=0,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"roadmap_items": gate_ids,
|
||||
"projects": ["Hauptpfad", "Begleitung"],
|
||||
"actions": ["Erster Schritt — Planung konkretisieren"],
|
||||
}
|
||||
|
||||
|
||||
def _retire_legacy_linear_stages_for_progression(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_gate_ids: frozenset[str],
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Starter-Kit-Stufen zurückstellen, wenn Join-Graph angelegt wird."""
|
||||
from services.maturity_practice import list_practices_for_stage, update_recurring_element
|
||||
from services.roadmap import list_roadmap_items_for_initiative, update_roadmap_item
|
||||
|
||||
for item in list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
):
|
||||
if item.get("item_type") != "maturity_stage":
|
||||
continue
|
||||
if str(item["id"]) in keep_gate_ids:
|
||||
continue
|
||||
if item.get("status") not in ("active", "at_risk"):
|
||||
continue
|
||||
update_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
item_id=item["id"],
|
||||
status="planned",
|
||||
user_id=user_id,
|
||||
)
|
||||
for practice in list_practices_for_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
stage_id=item["id"],
|
||||
):
|
||||
if practice.get("status") == "active":
|
||||
update_recurring_element(
|
||||
tenant_id=tenant_id,
|
||||
recurring_id=practice["id"],
|
||||
status="paused",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def apply_a1_progression_demo(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""PO-Kernbild: 2 parallele Gates → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
|
||||
from services.maturity_practice import seed_practices_for_stage
|
||||
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
|
||||
|
||||
existing = list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if any(item.get("item_type") == "join_gate" for item in existing):
|
||||
return {"applied": False, "reason": "progression_exists"}
|
||||
|
||||
d1 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Dehnung — Einstieg",
|
||||
item_type="maturity_stage",
|
||||
status="active",
|
||||
sort_order=10,
|
||||
user_id=user_id,
|
||||
)
|
||||
h1 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Hüftrotation — Einstieg",
|
||||
item_type="maturity_stage",
|
||||
status="active",
|
||||
sort_order=20,
|
||||
user_id=user_id,
|
||||
)
|
||||
join = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Mawashi Geri zur Hüfte",
|
||||
item_type="join_gate",
|
||||
status="planned",
|
||||
sort_order=30,
|
||||
user_id=user_id,
|
||||
)
|
||||
d2 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Dehnung — nach Join",
|
||||
item_type="maturity_stage",
|
||||
status="planned",
|
||||
sort_order=40,
|
||||
user_id=user_id,
|
||||
)
|
||||
h2 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Hüftrotation — nach Join",
|
||||
item_type="maturity_stage",
|
||||
status="planned",
|
||||
sort_order=50,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
for from_id, to_id in (
|
||||
(join["id"], d1["id"]),
|
||||
(join["id"], h1["id"]),
|
||||
(d2["id"], join["id"]),
|
||||
(h2["id"], join["id"]),
|
||||
):
|
||||
try:
|
||||
add_dependency(
|
||||
tenant_id=tenant_id,
|
||||
from_item_id=from_id,
|
||||
to_item_id=to_id,
|
||||
dependency_type="requires",
|
||||
user_id=user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if "existiert bereits" not in str(exc):
|
||||
raise
|
||||
|
||||
seed_practices_for_stage(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, stage=d1, user_id=user_id
|
||||
)
|
||||
seed_practices_for_stage(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, stage=h1, user_id=user_id
|
||||
)
|
||||
|
||||
_retire_legacy_linear_stages_for_progression(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
keep_gate_ids=frozenset(
|
||||
str(gate["id"]) for gate in (d1, h1, d2, h2)
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"applied": True,
|
||||
"join": join["title"],
|
||||
"active_gates": [d1["title"], h1["title"]],
|
||||
}
|
||||
|
||||
|
||||
def _apply_b2b(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str],
|
||||
) -> dict[str, list[str]]:
|
||||
from services.backlog import create_backlog_item, list_backlog_for_initiative
|
||||
from services.projects import create_project, list_projects_for_initiative
|
||||
from services.roadmap import create_roadmap_item
|
||||
|
||||
roadmap_ids: list[str] = []
|
||||
for index, (title, status) in enumerate(
|
||||
[("Orientierung", "active"), ("Nächster Release-Horizont", "planned")]
|
||||
):
|
||||
item = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
item_type="milestone",
|
||||
status=status, # type: ignore[arg-type]
|
||||
sort_order=index * 10,
|
||||
user_id=user_id,
|
||||
)
|
||||
roadmap_ids.append(item["id"])
|
||||
|
||||
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
for index, title in enumerate(["Entwicklung", "Betrieb"]):
|
||||
if not _find_by_title(projects, title):
|
||||
create_project(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
description=f"Ist-Struktur — {title}",
|
||||
sort_order=index,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
backlog = list_backlog_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
if not _find_by_title(backlog, "Idee / Issue"):
|
||||
create_backlog_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Idee / Issue",
|
||||
description="Product Backlog — noch nicht committet",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"roadmap_items": roadmap_ids,
|
||||
"projects": ["Entwicklung", "Betrieb"],
|
||||
"backlog_items": ["Idee / Issue"],
|
||||
}
|
||||
|
||||
|
||||
def _apply_b2a(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str],
|
||||
) -> dict[str, list[str]]:
|
||||
from services.roadmap import create_roadmap_item
|
||||
|
||||
roadmap_ids: list[str] = []
|
||||
for index, (title, status) in enumerate(
|
||||
[
|
||||
("Phase 1 — Start", "active"),
|
||||
("Phase 2 — Lieferung", "planned"),
|
||||
("Abschluss", "planned"),
|
||||
]
|
||||
):
|
||||
item = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
item_type="milestone",
|
||||
status=status, # type: ignore[arg-type]
|
||||
sort_order=index * 10,
|
||||
user_id=user_id,
|
||||
)
|
||||
roadmap_ids.append(item["id"])
|
||||
|
||||
return {"roadmap_items": roadmap_ids}
|
||||
|
|
@ -23,7 +23,7 @@ def log_audit(
|
|||
INSERT INTO audit_log (action, user_id, tenant_id, details)
|
||||
VALUES (%s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(action, user_id, tenant_id, json.dumps(details or {})),
|
||||
(action, user_id or None, tenant_id, json.dumps(details or {})),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
|
|
|
|||
587
backend/services/backlog.py
Normal file
587
backend/services/backlog.py
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
"""BacklogItem service — tenant-scoped CRUD + convert (AP0.8c)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.actions import create_action
|
||||
from services.audit import log_audit
|
||||
from services.initiatives import PRIORITIES, get_initiative
|
||||
from services.operating_context import get_operating_context
|
||||
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||
|
||||
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
|
||||
BacklogItemKind = Literal["story", "bug", "issue", "epic", "tech_debt"]
|
||||
|
||||
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
|
||||
BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue", "epic", "tech_debt"})
|
||||
CONVERTIBLE_ITEM_KINDS = frozenset({"story", "bug", "issue", "tech_debt"})
|
||||
|
||||
_BACKLOG_COLUMNS = """
|
||||
id, tenant_id, initiative_id, title, description, status,
|
||||
priority, roadmap_item_id, converted_action_id, sort_order, item_kind,
|
||||
parent_action_id, parent_backlog_id, created_at, updated_at
|
||||
"""
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in (
|
||||
"id",
|
||||
"tenant_id",
|
||||
"initiative_id",
|
||||
"converted_action_id",
|
||||
"roadmap_item_id",
|
||||
"parent_action_id",
|
||||
"parent_backlog_id",
|
||||
):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
result["created_at"] = result["created_at"].isoformat()
|
||||
if result.get("updated_at"):
|
||||
result["updated_at"] = result["updated_at"].isoformat()
|
||||
return result
|
||||
|
||||
|
||||
def _validate_status(status: str) -> None:
|
||||
if status not in BACKLOG_STATUSES:
|
||||
raise ValueError(f"Ungültiger Backlog-Status: {status}")
|
||||
|
||||
|
||||
def _validate_priority(priority: str) -> None:
|
||||
if priority not in PRIORITIES:
|
||||
raise ValueError(f"Ungültige Priorität: {priority}")
|
||||
|
||||
|
||||
def _validate_item_kind(item_kind: str) -> None:
|
||||
if item_kind not in BACKLOG_ITEM_KINDS:
|
||||
raise ValueError(f"Ungültiger Backlog-Typ: {item_kind}")
|
||||
|
||||
|
||||
def _backlog_vocabulary_for_initiative(*, tenant_id: str, initiative_id: str) -> dict[str, Any]:
|
||||
context = get_operating_context(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
if not context:
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
return context.get("backlog_vocabulary") or {}
|
||||
|
||||
|
||||
def _validate_item_kind_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str, item_kind: str
|
||||
) -> dict[str, Any]:
|
||||
_validate_item_kind(item_kind)
|
||||
vocabulary = _backlog_vocabulary_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
allowed = vocabulary.get("kinds") or []
|
||||
if allowed and item_kind not in allowed:
|
||||
raise ValueError(
|
||||
f"Backlog-Typ '{item_kind}' ist für dieses Vorhaben nicht erlaubt"
|
||||
)
|
||||
return vocabulary
|
||||
|
||||
|
||||
def _item_kind_to_action_kind(item_kind: str) -> str:
|
||||
if item_kind == "bug":
|
||||
return "bug"
|
||||
if item_kind == "issue":
|
||||
return "issue"
|
||||
if item_kind == "tech_debt":
|
||||
return "tech_debt"
|
||||
return "delivery"
|
||||
|
||||
|
||||
def _validate_parent_action_in_initiative(
|
||||
*,
|
||||
cur,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
parent_action_id: Optional[str],
|
||||
) -> None:
|
||||
if not parent_action_id:
|
||||
return
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT action_kind, parent_action_id
|
||||
FROM actions
|
||||
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(parent_action_id, tenant_id, initiative_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden")
|
||||
action_kind = row["action_kind"] if isinstance(row, dict) else row[0]
|
||||
if action_kind in ("bug", "issue"):
|
||||
raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue")
|
||||
|
||||
|
||||
def _validate_backlog_hierarchy(
|
||||
*,
|
||||
cur,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
item_kind: str,
|
||||
parent_backlog_id: Optional[str],
|
||||
parent_action_id: Optional[str],
|
||||
backlog_item_id: Optional[str] = None,
|
||||
epic_hierarchy: bool = True,
|
||||
) -> None:
|
||||
if item_kind == "epic" and not epic_hierarchy:
|
||||
raise ValueError("Epic ist für dieses Vorhaben nicht verfügbar")
|
||||
|
||||
if item_kind == "epic":
|
||||
if parent_backlog_id:
|
||||
raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden")
|
||||
if parent_action_id:
|
||||
raise ValueError("Epic kann keinem Arbeitspaket zugeordnet werden")
|
||||
return
|
||||
|
||||
if not parent_backlog_id:
|
||||
return
|
||||
|
||||
if not epic_hierarchy:
|
||||
raise ValueError("Epic-Zuordnung ist für dieses Vorhaben nicht verfügbar")
|
||||
|
||||
if backlog_item_id and parent_backlog_id == backlog_item_id:
|
||||
raise ValueError("Item kann nicht sich selbst als Epic referenzieren")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, item_kind, initiative_id
|
||||
FROM backlog_items
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(parent_backlog_id, tenant_id),
|
||||
)
|
||||
parent = cur.fetchone()
|
||||
if not parent:
|
||||
raise ValueError("Epic-Referenz nicht gefunden")
|
||||
parent_initiative = parent["initiative_id"] if isinstance(parent, dict) else parent[2]
|
||||
parent_kind = parent["item_kind"] if isinstance(parent, dict) else parent[1]
|
||||
if str(parent_initiative) != initiative_id:
|
||||
raise ValueError("Epic gehört zu einem anderen Vorhaben")
|
||||
if parent_kind != "epic":
|
||||
raise ValueError("Übergeordnetes Item muss ein Epic sein")
|
||||
|
||||
|
||||
def create_backlog_item(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: BacklogStatus = "new",
|
||||
priority: str = "normal",
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
sort_order: Optional[int] = None,
|
||||
item_kind: BacklogItemKind = "story",
|
||||
parent_action_id: Optional[str] = None,
|
||||
parent_backlog_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
_validate_status(status)
|
||||
_validate_priority(priority)
|
||||
vocabulary = _validate_item_kind_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, item_kind=item_kind
|
||||
)
|
||||
epic_hierarchy = bool((vocabulary.get("capabilities") or {}).get("epic_hierarchy"))
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
validate_roadmap_item_in_initiative(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
_validate_parent_action_in_initiative(
|
||||
cur=cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
parent_action_id=parent_action_id,
|
||||
)
|
||||
_validate_backlog_hierarchy(
|
||||
cur=cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
item_kind=item_kind,
|
||||
parent_backlog_id=parent_backlog_id,
|
||||
parent_action_id=parent_action_id,
|
||||
epic_hierarchy=epic_hierarchy,
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
|
||||
FROM backlog_items
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
next_order = int(cur.fetchone()["next_order"])
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO backlog_items (
|
||||
tenant_id, initiative_id, title, description, status, priority,
|
||||
roadmap_item_id, sort_order, item_kind, parent_action_id, parent_backlog_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_BACKLOG_COLUMNS}
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
roadmap_item_id,
|
||||
sort_order if sort_order is not None else next_order,
|
||||
item_kind,
|
||||
parent_action_id,
|
||||
parent_backlog_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"backlog.created",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"backlog_item_id": row["id"], "initiative_id": initiative_id, "title": title},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_BACKLOG_COLUMNS}
|
||||
FROM backlog_items
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY sort_order ASC, created_at ASC, title
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_BACKLOG_COLUMNS}
|
||||
FROM backlog_items
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(backlog_item_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return _serialize_row(dict(row)) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_backlog_item(
|
||||
*,
|
||||
tenant_id: str,
|
||||
backlog_item_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[BacklogStatus] = None,
|
||||
priority: Optional[str] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
clear_roadmap_item: bool = False,
|
||||
sort_order: Optional[int] = None,
|
||||
item_kind: Optional[BacklogItemKind] = None,
|
||||
parent_action_id: Optional[str] = None,
|
||||
clear_parent_action: bool = False,
|
||||
parent_backlog_id: Optional[str] = None,
|
||||
clear_parent_backlog: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
|
||||
if not existing:
|
||||
return None
|
||||
if existing["status"] == "converted":
|
||||
raise ValueError("Konvertiertes Backlog-Item kann nicht bearbeitet werden")
|
||||
|
||||
old_status = existing["status"]
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if title is not None:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
updates.append("title = %s")
|
||||
params.append(title)
|
||||
if description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(description)
|
||||
if status is not None:
|
||||
_validate_status(status)
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if priority is not None:
|
||||
_validate_priority(priority)
|
||||
updates.append("priority = %s")
|
||||
params.append(priority)
|
||||
if clear_roadmap_item:
|
||||
updates.append("roadmap_item_id = NULL")
|
||||
elif roadmap_item_id is not None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
validate_roadmap_item_in_initiative(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
updates.append("roadmap_item_id = %s")
|
||||
params.append(roadmap_item_id)
|
||||
if sort_order is not None:
|
||||
updates.append("sort_order = %s")
|
||||
params.append(sort_order)
|
||||
if item_kind is not None:
|
||||
_validate_item_kind(item_kind)
|
||||
updates.append("item_kind = %s")
|
||||
params.append(item_kind)
|
||||
|
||||
effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story"
|
||||
vocabulary = _validate_item_kind_for_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
item_kind=effective_kind,
|
||||
)
|
||||
epic_hierarchy = bool((vocabulary.get("capabilities") or {}).get("epic_hierarchy"))
|
||||
|
||||
if clear_parent_action:
|
||||
updates.append("parent_action_id = NULL")
|
||||
elif parent_action_id is not None:
|
||||
updates.append("parent_action_id = %s")
|
||||
params.append(parent_action_id)
|
||||
if clear_parent_backlog:
|
||||
updates.append("parent_backlog_id = NULL")
|
||||
elif parent_backlog_id is not None:
|
||||
updates.append("parent_backlog_id = %s")
|
||||
params.append(parent_backlog_id)
|
||||
|
||||
if not updates:
|
||||
return existing
|
||||
|
||||
effective_parent_backlog = (
|
||||
None
|
||||
if clear_parent_backlog
|
||||
else parent_backlog_id
|
||||
if parent_backlog_id is not None
|
||||
else existing.get("parent_backlog_id")
|
||||
)
|
||||
effective_parent_action = (
|
||||
None
|
||||
if clear_parent_action
|
||||
else parent_action_id
|
||||
if parent_action_id is not None
|
||||
else existing.get("parent_action_id")
|
||||
)
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
params.extend([backlog_item_id, tenant_id])
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
_validate_backlog_hierarchy(
|
||||
cur=cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
item_kind=effective_kind,
|
||||
parent_backlog_id=effective_parent_backlog,
|
||||
parent_action_id=effective_parent_action,
|
||||
backlog_item_id=backlog_item_id,
|
||||
epic_hierarchy=epic_hierarchy,
|
||||
)
|
||||
if parent_action_id is not None and not clear_parent_action:
|
||||
_validate_parent_action_in_initiative(
|
||||
cur=cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
parent_action_id=parent_action_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE backlog_items
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING {_BACKLOG_COLUMNS}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
result = _serialize_row(dict(row))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"backlog.updated",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"backlog_item_id": backlog_item_id},
|
||||
)
|
||||
if status is not None and status != old_status:
|
||||
log_audit(
|
||||
"backlog.status_changed",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"backlog_item_id": backlog_item_id,
|
||||
"from_status": old_status,
|
||||
"to_status": status,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def delete_backlog_item(
|
||||
*,
|
||||
tenant_id: str,
|
||||
backlog_item_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM backlog_items
|
||||
WHERE parent_backlog_id = %s AND tenant_id = %s AND status != 'converted'
|
||||
LIMIT 1
|
||||
""",
|
||||
(backlog_item_id, tenant_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise ValueError(
|
||||
"Epic mit untergeordneten Items kann nicht gelöscht werden"
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||
(backlog_item_id, tenant_id),
|
||||
)
|
||||
deleted = cur.fetchone() is not None
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted:
|
||||
log_audit(
|
||||
"backlog.deleted",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"backlog_item_id": backlog_item_id},
|
||||
)
|
||||
return deleted
|
||||
|
||||
|
||||
def convert_backlog_to_action(
|
||||
*,
|
||||
tenant_id: str,
|
||||
backlog_item_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
assigned_actor_ids: Optional[list[str]] = None,
|
||||
work_cycle_id: Optional[str] = None,
|
||||
assign_active_sprint: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
|
||||
if not existing:
|
||||
raise ValueError("Backlog-Item nicht gefunden")
|
||||
if existing["status"] == "converted":
|
||||
raise ValueError("Backlog-Item wurde bereits konvertiert")
|
||||
if existing["status"] not in ("accepted", "triaged", "new"):
|
||||
raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden")
|
||||
|
||||
item_kind = existing.get("item_kind") or "story"
|
||||
if item_kind not in CONVERTIBLE_ITEM_KINDS:
|
||||
raise ValueError("Epic kann nicht direkt in ein Arbeitspaket umgewandelt werden")
|
||||
|
||||
resolved_cycle_id = work_cycle_id
|
||||
if not resolved_cycle_id and assign_active_sprint:
|
||||
from services.work_cycle import get_active_work_cycle
|
||||
|
||||
active = get_active_work_cycle(
|
||||
tenant_id=tenant_id, initiative_id=existing["initiative_id"]
|
||||
)
|
||||
if active:
|
||||
resolved_cycle_id = active["id"]
|
||||
|
||||
action = create_action(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
title=existing["title"],
|
||||
description=existing["description"] or "",
|
||||
priority=existing["priority"],
|
||||
roadmap_item_id=existing.get("roadmap_item_id"),
|
||||
work_cycle_id=resolved_cycle_id,
|
||||
assigned_actor_ids=assigned_actor_ids or [],
|
||||
action_kind=_item_kind_to_action_kind(item_kind),
|
||||
parent_action_id=existing.get("parent_action_id"),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE backlog_items
|
||||
SET status = 'converted',
|
||||
converted_action_id = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING {_BACKLOG_COLUMNS}
|
||||
""",
|
||||
(action["id"], backlog_item_id, tenant_id),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"backlog.converted_to_action",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"backlog_item_id": backlog_item_id,
|
||||
"action_id": action["id"],
|
||||
"initiative_id": existing["initiative_id"],
|
||||
},
|
||||
)
|
||||
return {"backlog_item": row, "action": action}
|
||||
306
backend/services/blockers.py
Normal file
306
backend/services/blockers.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"""Blocker service — tenant-scoped CRUD (AP0.8b)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
from services.initiatives import get_initiative
|
||||
|
||||
BlockerStatus = Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
|
||||
|
||||
BLOCKER_STATUSES = frozenset(
|
||||
{"open", "in_progress", "resolved", "accepted_risk", "dismissed"}
|
||||
)
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in ("id", "tenant_id", "initiative_id", "action_id", "reported_by_actor_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
result["created_at"] = result["created_at"].isoformat()
|
||||
if result.get("updated_at"):
|
||||
result["updated_at"] = result["updated_at"].isoformat()
|
||||
return result
|
||||
|
||||
|
||||
def _validate_status(status: str) -> None:
|
||||
if status not in BLOCKER_STATUSES:
|
||||
raise ValueError(f"Ungültiger Blocker-Status: {status}")
|
||||
|
||||
|
||||
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
|
||||
(actor_id, tenant_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM actions
|
||||
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(action_id, tenant_id, initiative_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_blocker(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: BlockerStatus = "open",
|
||||
action_id: Optional[str] = None,
|
||||
reported_by_actor_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
set_action_blocked: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
_validate_status(status)
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
if action_id and not _action_in_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
|
||||
):
|
||||
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||
if reported_by_actor_id and not _actor_in_tenant(
|
||||
tenant_id=tenant_id, actor_id=reported_by_actor_id
|
||||
):
|
||||
raise ValueError("Reporter-Actor gehört nicht zum Tenant")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO blockers (
|
||||
tenant_id, initiative_id, action_id, title, description,
|
||||
status, reported_by_actor_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, tenant_id, initiative_id, action_id, title, description,
|
||||
status, reported_by_actor_id, created_at, updated_at
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
action_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
reported_by_actor_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
if set_action_blocked and action_id:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE actions SET status = 'blocked', updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"blocker.created",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"blocker_id": row["id"], "initiative_id": initiative_id, "title": title},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def list_blockers_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, tenant_id, initiative_id, action_id, title, description,
|
||||
status, reported_by_actor_id, created_at, updated_at
|
||||
FROM blockers
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC, title
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_blocker(*, tenant_id: str, blocker_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, tenant_id, initiative_id, action_id, title, description,
|
||||
status, reported_by_actor_id, created_at, updated_at
|
||||
FROM blockers
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(blocker_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return _serialize_row(dict(row)) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_blocker(
|
||||
*,
|
||||
tenant_id: str,
|
||||
blocker_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[BlockerStatus] = None,
|
||||
action_id: Optional[str] = None,
|
||||
clear_action_id: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_blocker(tenant_id=tenant_id, blocker_id=blocker_id)
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
old_status = existing["status"]
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if title is not None:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
updates.append("title = %s")
|
||||
params.append(title)
|
||||
if description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(description)
|
||||
if status is not None:
|
||||
_validate_status(status)
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if clear_action_id:
|
||||
updates.append("action_id = NULL")
|
||||
elif action_id is not None:
|
||||
if not _action_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
action_id=action_id,
|
||||
):
|
||||
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||
updates.append("action_id = %s")
|
||||
params.append(action_id)
|
||||
|
||||
if not updates:
|
||||
return existing
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
params.extend([blocker_id, tenant_id])
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE blockers
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, tenant_id, initiative_id, action_id, title, description,
|
||||
status, reported_by_actor_id, created_at, updated_at
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
result = _serialize_row(dict(row))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"blocker.updated",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"blocker_id": blocker_id},
|
||||
)
|
||||
if status is not None and status != old_status:
|
||||
log_audit(
|
||||
"blocker.status_changed",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"blocker_id": blocker_id,
|
||||
"from_status": old_status,
|
||||
"to_status": status,
|
||||
},
|
||||
)
|
||||
from services.operating_transitions import after_blocker_status_change
|
||||
|
||||
after_blocker_status_change(
|
||||
tenant_id=tenant_id,
|
||||
blocker_id=blocker_id,
|
||||
action_id=result.get("action_id"),
|
||||
initiative_id=result.get("initiative_id"),
|
||||
old_status=old_status,
|
||||
new_status=status,
|
||||
user_id=user_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def delete_blocker(
|
||||
*,
|
||||
tenant_id: str,
|
||||
blocker_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM blockers WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||
(blocker_id, tenant_id),
|
||||
)
|
||||
deleted = cur.fetchone() is not None
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted:
|
||||
log_audit(
|
||||
"blocker.deleted",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"blocker_id": blocker_id},
|
||||
)
|
||||
return deleted
|
||||
278
backend/services/cadence_instance.py
Normal file
278
backend/services/cadence_instance.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""CadenceInstance service — AP A1.1 minimal daily practice tracking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
|
||||
CadenceStatus = Literal["open", "completed"]
|
||||
|
||||
_CADENCE_COLUMNS = """
|
||||
id, tenant_id, recurring_element_id, status, due_at,
|
||||
completed_at, measurement_note, created_at, updated_at
|
||||
"""
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in ("id", "tenant_id", "recurring_element_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
for ts_key in ("due_at", "completed_at", "created_at", "updated_at"):
|
||||
if result.get(ts_key):
|
||||
result[ts_key] = result[ts_key].isoformat()
|
||||
return result
|
||||
|
||||
|
||||
def get_open_instance(
|
||||
*, tenant_id: str, recurring_element_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_CADENCE_COLUMNS}
|
||||
FROM cadence_instances
|
||||
WHERE tenant_id = %s
|
||||
AND recurring_element_id = %s
|
||||
AND status = 'open'
|
||||
LIMIT 1
|
||||
""",
|
||||
(tenant_id, recurring_element_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return _serialize_row(dict(row)) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def ensure_open_instance(
|
||||
*,
|
||||
tenant_id: str,
|
||||
recurring_element_id: str,
|
||||
due_at: Optional[datetime] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Ensure exactly one open CadenceInstance (idempotent)."""
|
||||
existing = get_open_instance(
|
||||
tenant_id=tenant_id, recurring_element_id=recurring_element_id
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
due = due_at or datetime.now(timezone.utc)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO cadence_instances (
|
||||
tenant_id, recurring_element_id, status, due_at
|
||||
)
|
||||
VALUES (%s, %s, 'open', %s)
|
||||
RETURNING {_CADENCE_COLUMNS}
|
||||
""",
|
||||
(tenant_id, recurring_element_id, due),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
|
||||
def _next_due_at(
|
||||
*,
|
||||
now: datetime,
|
||||
interval_days: int | None = None,
|
||||
schedule: dict[str, Any] | None = None,
|
||||
) -> datetime:
|
||||
if schedule:
|
||||
from services.schedule import next_due_after_completion
|
||||
|
||||
return next_due_after_completion(schedule=schedule, completed_at=now)
|
||||
days = max(int(interval_days or 1), 1)
|
||||
next_date = now.date() + timedelta(days=days)
|
||||
return datetime.combine(next_date, time.min, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _parse_due(value: Any) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def complete_open_instance(
|
||||
*,
|
||||
tenant_id: str,
|
||||
recurring_element_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
measurement_note: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Close open instance, advance recurring next_due_at, open next instance.
|
||||
Returns { completed, recurring, next_instance }.
|
||||
"""
|
||||
from services.recurring import get_recurring_element, update_recurring_element
|
||||
|
||||
recurring = get_recurring_element(
|
||||
tenant_id=tenant_id, recurring_id=recurring_element_id
|
||||
)
|
||||
if not recurring:
|
||||
raise ValueError("Recurring-Element nicht gefunden")
|
||||
if recurring["status"] != "active":
|
||||
raise ValueError("Recurring-Element ist nicht aktiv")
|
||||
|
||||
open_inst = get_open_instance(
|
||||
tenant_id=tenant_id, recurring_element_id=recurring_element_id
|
||||
)
|
||||
if not open_inst:
|
||||
open_inst = ensure_open_instance(
|
||||
tenant_id=tenant_id,
|
||||
recurring_element_id=recurring_element_id,
|
||||
due_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
note = (measurement_note or "").strip()
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE cadence_instances
|
||||
SET status = 'completed',
|
||||
completed_at = %s,
|
||||
measurement_note = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s AND status = 'open'
|
||||
RETURNING {_CADENCE_COLUMNS}
|
||||
""",
|
||||
(now, note, open_inst["id"], tenant_id),
|
||||
)
|
||||
completed_row = cur.fetchone()
|
||||
if not completed_row:
|
||||
raise ValueError("Keine offene Cadence-Instanz")
|
||||
completed = _serialize_row(dict(completed_row))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
interval = recurring.get("interval_days") or 1
|
||||
from services.schedule import resolve_schedule_for_recurring
|
||||
|
||||
schedule = resolve_schedule_for_recurring(recurring, tenant_id=tenant_id)
|
||||
next_due = _next_due_at(now=now, interval_days=interval, schedule=schedule)
|
||||
|
||||
update_recurring_element(
|
||||
tenant_id=tenant_id,
|
||||
recurring_id=recurring_element_id,
|
||||
user_id=user_id,
|
||||
next_due_at=next_due,
|
||||
)
|
||||
|
||||
log_audit(
|
||||
"cadence_instance.completed",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"cadence_instance_id": completed["id"],
|
||||
"recurring_id": recurring_element_id,
|
||||
"measurement_note": note[:200] if note else "",
|
||||
},
|
||||
)
|
||||
|
||||
updated_recurring = get_recurring_element(
|
||||
tenant_id=tenant_id, recurring_id=recurring_element_id
|
||||
)
|
||||
return {
|
||||
"completed_instance": completed,
|
||||
"next_due_at": next_due.isoformat(),
|
||||
"recurring": updated_recurring,
|
||||
}
|
||||
|
||||
|
||||
def attach_open_instances_to_recurring_list(
|
||||
items: list[dict[str, Any]], *, tenant_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
if not items:
|
||||
return items
|
||||
ids = [item["id"] for item in items]
|
||||
now = datetime.now(timezone.utc)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_CADENCE_COLUMNS}
|
||||
FROM cadence_instances
|
||||
WHERE tenant_id = %s
|
||||
AND recurring_element_id = ANY(%s::uuid[])
|
||||
AND status = 'open'
|
||||
""",
|
||||
(tenant_id, ids),
|
||||
)
|
||||
by_recurring = {
|
||||
str(row["recurring_element_id"]): _serialize_row(dict(row))
|
||||
for row in cur.fetchall()
|
||||
}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT ON (recurring_element_id)
|
||||
recurring_element_id, measurement_note, completed_at
|
||||
FROM cadence_instances
|
||||
WHERE tenant_id = %s
|
||||
AND recurring_element_id = ANY(%s::uuid[])
|
||||
AND status = 'completed'
|
||||
AND completed_at >= CURRENT_DATE
|
||||
ORDER BY recurring_element_id, completed_at DESC
|
||||
""",
|
||||
(tenant_id, ids),
|
||||
)
|
||||
completed_today = {
|
||||
str(row["recurring_element_id"]): {
|
||||
"measurement_note": row.get("measurement_note") or "",
|
||||
"completed_at": row["completed_at"].isoformat()
|
||||
if row.get("completed_at")
|
||||
else None,
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
copy = dict(item)
|
||||
today = completed_today.get(item["id"])
|
||||
copy["today_completed"] = today is not None
|
||||
copy["today_measurement_note"] = today.get("measurement_note", "") if today else ""
|
||||
|
||||
open_inst = by_recurring.get(item["id"])
|
||||
next_due = _parse_due(item.get("next_due_at"))
|
||||
due_now = next_due is None or next_due <= now
|
||||
if (
|
||||
not open_inst
|
||||
and item.get("status") == "active"
|
||||
and due_now
|
||||
and not copy["today_completed"]
|
||||
):
|
||||
open_inst = ensure_open_instance(
|
||||
tenant_id=tenant_id,
|
||||
recurring_element_id=item["id"],
|
||||
due_at=next_due or now,
|
||||
)
|
||||
copy["open_cadence_instance"] = open_inst
|
||||
enriched.append(copy)
|
||||
return enriched
|
||||
265
backend/services/decisions.py
Normal file
265
backend/services/decisions.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"""Decision service — tenant-scoped CRUD (AP0.9c)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
from services.initiatives import get_initiative
|
||||
|
||||
DecisionStatus = Literal["proposed", "decided", "superseded"]
|
||||
|
||||
DECISION_STATUSES = frozenset({"proposed", "decided", "superseded"})
|
||||
|
||||
_DECISION_COLUMNS = """
|
||||
id, tenant_id, initiative_id, title, description, status, outcome,
|
||||
decided_by_actor_id, created_at, updated_at
|
||||
"""
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in ("id", "tenant_id", "initiative_id", "decided_by_actor_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
for ts_key in ("created_at", "updated_at"):
|
||||
if result.get(ts_key):
|
||||
result[ts_key] = result[ts_key].isoformat()
|
||||
return result
|
||||
|
||||
|
||||
def _validate_status(status: str) -> None:
|
||||
if status not in DECISION_STATUSES:
|
||||
raise ValueError(f"Ungültiger Decision-Status: {status}")
|
||||
|
||||
|
||||
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
|
||||
(actor_id, tenant_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_decision(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: DecisionStatus = "proposed",
|
||||
outcome: str = "",
|
||||
decided_by_actor_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
_validate_status(status)
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
if decided_by_actor_id and not _actor_in_tenant(
|
||||
tenant_id=tenant_id, actor_id=decided_by_actor_id
|
||||
):
|
||||
raise ValueError("Actor gehört nicht zum Tenant")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO decisions (
|
||||
tenant_id, initiative_id, title, description, status, outcome,
|
||||
decided_by_actor_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_DECISION_COLUMNS}
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
outcome,
|
||||
decided_by_actor_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"decision.created",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"decision_id": row["id"], "initiative_id": initiative_id},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def list_decisions_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_DECISION_COLUMNS}
|
||||
FROM decisions
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC, title
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_decision(*, tenant_id: str, decision_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_DECISION_COLUMNS}
|
||||
FROM decisions
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(decision_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return _serialize_row(dict(row)) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_decision(
|
||||
*,
|
||||
tenant_id: str,
|
||||
decision_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[DecisionStatus] = None,
|
||||
outcome: Optional[str] = None,
|
||||
decided_by_actor_id: Optional[str] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_decision(tenant_id=tenant_id, decision_id=decision_id)
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
old_status = existing["status"]
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if title is not None:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
updates.append("title = %s")
|
||||
params.append(title)
|
||||
if description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(description)
|
||||
if status is not None:
|
||||
_validate_status(status)
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if outcome is not None:
|
||||
updates.append("outcome = %s")
|
||||
params.append(outcome)
|
||||
if decided_by_actor_id is not None:
|
||||
if decided_by_actor_id and not _actor_in_tenant(
|
||||
tenant_id=tenant_id, actor_id=decided_by_actor_id
|
||||
):
|
||||
raise ValueError("Actor gehört nicht zum Tenant")
|
||||
updates.append("decided_by_actor_id = %s")
|
||||
params.append(decided_by_actor_id or None)
|
||||
|
||||
if not updates:
|
||||
return existing
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
params.extend([decision_id, tenant_id])
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE decisions
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING {_DECISION_COLUMNS}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
result = _serialize_row(dict(row))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"decision.updated",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"decision_id": decision_id},
|
||||
)
|
||||
if status is not None and status != old_status:
|
||||
log_audit(
|
||||
"decision.status_changed",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"decision_id": decision_id,
|
||||
"from_status": old_status,
|
||||
"to_status": status,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def delete_decision(
|
||||
*,
|
||||
tenant_id: str,
|
||||
decision_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM decisions WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||
(decision_id, tenant_id),
|
||||
)
|
||||
deleted = cur.fetchone() is not None
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted:
|
||||
log_audit(
|
||||
"decision.deleted",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"decision_id": decision_id},
|
||||
)
|
||||
return deleted
|
||||
396
backend/services/evidence.py
Normal file
396
backend/services/evidence.py
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
"""Evidence service — tenant-scoped CRUD (AP0.9b)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
from services.initiatives import get_initiative
|
||||
|
||||
EvidenceStatus = Literal["submitted", "accepted", "rejected"]
|
||||
|
||||
EVIDENCE_STATUSES = frozenset({"submitted", "accepted", "rejected"})
|
||||
|
||||
_EVIDENCE_COLUMNS = """
|
||||
id, tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id,
|
||||
title, description, status, submitted_by_actor_id, created_at, updated_at
|
||||
"""
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in (
|
||||
"id",
|
||||
"tenant_id",
|
||||
"initiative_id",
|
||||
"action_id",
|
||||
"milestone_id",
|
||||
"roadmap_item_id",
|
||||
"submitted_by_actor_id",
|
||||
):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
for ts_key in ("created_at", "updated_at"):
|
||||
if result.get(ts_key):
|
||||
result[ts_key] = result[ts_key].isoformat()
|
||||
return result
|
||||
|
||||
|
||||
def _validate_status(status: str) -> None:
|
||||
if status not in EVIDENCE_STATUSES:
|
||||
raise ValueError(f"Ungültiger Evidence-Status: {status}")
|
||||
|
||||
|
||||
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
|
||||
(actor_id, tenant_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM actions
|
||||
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(action_id, tenant_id, initiative_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _roadmap_item_in_initiative(
|
||||
*, tenant_id: str, initiative_id: str, roadmap_item_id: str
|
||||
) -> Optional[dict[str, str]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ri.id, ri.item_type
|
||||
FROM roadmap_items ri
|
||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
||||
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
|
||||
""",
|
||||
(roadmap_item_id, tenant_id, initiative_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {"id": str(row["id"]), "item_type": row["item_type"]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _milestone_in_initiative(
|
||||
*, tenant_id: str, initiative_id: str, milestone_id: str
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM milestones
|
||||
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(milestone_id, tenant_id, initiative_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return True
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM roadmap_items ri
|
||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
||||
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
|
||||
""",
|
||||
(milestone_id, tenant_id, initiative_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _resolve_gate_links(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
milestone_id: Optional[str] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Gibt (milestone_id, roadmap_item_id) für Evidence-Insert zurück."""
|
||||
resolved_milestone = milestone_id
|
||||
resolved_roadmap = roadmap_item_id
|
||||
|
||||
if roadmap_item_id:
|
||||
item = _roadmap_item_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
if not item:
|
||||
raise ValueError("Plan-Element gehört nicht zum Vorhaben")
|
||||
resolved_roadmap = item["id"]
|
||||
if item["item_type"] == "milestone":
|
||||
resolved_milestone = item["id"]
|
||||
elif milestone_id:
|
||||
if not _milestone_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
milestone_id=milestone_id,
|
||||
):
|
||||
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
|
||||
resolved_roadmap = milestone_id
|
||||
|
||||
return resolved_milestone, resolved_roadmap
|
||||
|
||||
|
||||
def create_evidence(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: EvidenceStatus = "submitted",
|
||||
action_id: Optional[str] = None,
|
||||
milestone_id: Optional[str] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
submitted_by_actor_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
_validate_status(status)
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
if action_id and not _action_in_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
|
||||
):
|
||||
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||
milestone_id, roadmap_item_id = _resolve_gate_links(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
milestone_id=milestone_id,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
if submitted_by_actor_id and not _actor_in_tenant(
|
||||
tenant_id=tenant_id, actor_id=submitted_by_actor_id
|
||||
):
|
||||
raise ValueError("Actor gehört nicht zum Tenant")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO evidence (
|
||||
tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id,
|
||||
title, description, status, submitted_by_actor_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_EVIDENCE_COLUMNS}
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
action_id,
|
||||
milestone_id,
|
||||
roadmap_item_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
submitted_by_actor_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"evidence.created",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"evidence_id": row["id"], "initiative_id": initiative_id},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def list_evidence_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_EVIDENCE_COLUMNS}
|
||||
FROM evidence
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY updated_at DESC, title
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_evidence(*, tenant_id: str, evidence_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_EVIDENCE_COLUMNS}
|
||||
FROM evidence
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(evidence_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return _serialize_row(dict(row)) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_evidence(
|
||||
*,
|
||||
tenant_id: str,
|
||||
evidence_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[EvidenceStatus] = None,
|
||||
action_id: Optional[str] = None,
|
||||
milestone_id: Optional[str] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
clear_action_id: bool = False,
|
||||
clear_milestone_id: bool = False,
|
||||
clear_roadmap_item_id: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_evidence(tenant_id=tenant_id, evidence_id=evidence_id)
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if title is not None:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
updates.append("title = %s")
|
||||
params.append(title)
|
||||
if description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(description)
|
||||
if status is not None:
|
||||
_validate_status(status)
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if clear_action_id:
|
||||
updates.append("action_id = NULL")
|
||||
elif action_id is not None:
|
||||
if not _action_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
action_id=action_id,
|
||||
):
|
||||
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||
updates.append("action_id = %s")
|
||||
params.append(action_id)
|
||||
if clear_milestone_id or clear_roadmap_item_id:
|
||||
updates.append("milestone_id = NULL")
|
||||
updates.append("roadmap_item_id = NULL")
|
||||
elif roadmap_item_id is not None or milestone_id is not None:
|
||||
link_milestone, link_roadmap = _resolve_gate_links(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
milestone_id=milestone_id,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
updates.append("milestone_id = %s")
|
||||
params.append(link_milestone)
|
||||
updates.append("roadmap_item_id = %s")
|
||||
params.append(link_roadmap)
|
||||
|
||||
if not updates:
|
||||
return existing
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
params.extend([evidence_id, tenant_id])
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE evidence
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING {_EVIDENCE_COLUMNS}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
result = _serialize_row(dict(row))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"evidence.updated",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"evidence_id": evidence_id},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def delete_evidence(
|
||||
*,
|
||||
tenant_id: str,
|
||||
evidence_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM evidence WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||
(evidence_id, tenant_id),
|
||||
)
|
||||
deleted = cur.fetchone() is not None
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted:
|
||||
log_audit(
|
||||
"evidence.deleted",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"evidence_id": evidence_id},
|
||||
)
|
||||
return deleted
|
||||
225
backend/services/execution_plan.py
Normal file
225
backend/services/execution_plan.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""Execution plan — Action dependencies CRUD (AP1.16a/b)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
from services.actions import get_action
|
||||
|
||||
DependencyKind = Literal["requires", "blocks", "relates"]
|
||||
|
||||
DEPENDENCY_KINDS = frozenset({"requires", "blocks", "relates"})
|
||||
|
||||
|
||||
def _validate_dependency_kind(kind: str) -> None:
|
||||
if kind not in DEPENDENCY_KINDS:
|
||||
raise ValueError(f"Ungültiger dependency_kind: {kind}")
|
||||
|
||||
|
||||
def _serialize_dependency(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in (
|
||||
"id",
|
||||
"tenant_id",
|
||||
"initiative_id",
|
||||
"predecessor_action_id",
|
||||
"successor_action_id",
|
||||
):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
result["created_at"] = result["created_at"].isoformat()
|
||||
return result
|
||||
|
||||
|
||||
def _would_create_cycle(
|
||||
*,
|
||||
dependencies: list[dict[str, Any]],
|
||||
predecessor_id: str,
|
||||
successor_id: str,
|
||||
) -> bool:
|
||||
"""Prüft Zyklus wenn successor transitiv predecessor requires."""
|
||||
graph: dict[str, list[str]] = {}
|
||||
for dep in dependencies:
|
||||
if dep.get("dependency_kind", "requires") != "requires":
|
||||
continue
|
||||
pred = str(dep["predecessor_action_id"])
|
||||
succ = str(dep["successor_action_id"])
|
||||
graph.setdefault(succ, []).append(pred)
|
||||
|
||||
graph.setdefault(successor_id, []).append(predecessor_id)
|
||||
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def dfs(node: str) -> bool:
|
||||
if node in visiting:
|
||||
return True
|
||||
if node in visited:
|
||||
return False
|
||||
visiting.add(node)
|
||||
for upstream in graph.get(node, []):
|
||||
if dfs(upstream):
|
||||
return True
|
||||
visiting.remove(node)
|
||||
visited.add(node)
|
||||
return False
|
||||
|
||||
return dfs(successor_id)
|
||||
|
||||
|
||||
def list_dependencies_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, tenant_id, initiative_id,
|
||||
predecessor_action_id, successor_action_id,
|
||||
dependency_kind, created_at
|
||||
FROM action_dependencies
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
ORDER BY created_at
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_dependency(
|
||||
*,
|
||||
tenant_id: str,
|
||||
predecessor_action_id: str,
|
||||
successor_action_id: str,
|
||||
dependency_kind: DependencyKind = "requires",
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
_validate_dependency_kind(dependency_kind)
|
||||
if predecessor_action_id == successor_action_id:
|
||||
raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt")
|
||||
|
||||
predecessor = get_action(tenant_id=tenant_id, action_id=predecessor_action_id)
|
||||
successor = get_action(tenant_id=tenant_id, action_id=successor_action_id)
|
||||
if not predecessor or not successor:
|
||||
raise ValueError("Action nicht gefunden")
|
||||
if predecessor["initiative_id"] != successor["initiative_id"]:
|
||||
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
|
||||
|
||||
initiative_id = predecessor["initiative_id"]
|
||||
existing = list_dependencies_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if _would_create_cycle(
|
||||
dependencies=existing,
|
||||
predecessor_id=predecessor_action_id,
|
||||
successor_id=successor_action_id,
|
||||
):
|
||||
raise ValueError("Abhängigkeit würde Zyklus erzeugen")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM action_dependencies
|
||||
WHERE tenant_id = %s
|
||||
AND predecessor_action_id = %s
|
||||
AND successor_action_id = %s
|
||||
AND dependency_kind = %s
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
predecessor_action_id,
|
||||
successor_action_id,
|
||||
dependency_kind,
|
||||
),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise ValueError("Abhängigkeit existiert bereits")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO action_dependencies (
|
||||
tenant_id, initiative_id,
|
||||
predecessor_action_id, successor_action_id,
|
||||
dependency_kind
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id, tenant_id, initiative_id,
|
||||
predecessor_action_id, successor_action_id,
|
||||
dependency_kind, created_at
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
predecessor_action_id,
|
||||
successor_action_id,
|
||||
dependency_kind,
|
||||
),
|
||||
)
|
||||
row = _serialize_dependency(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"action.dependency_added",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"predecessor_action_id": predecessor_action_id,
|
||||
"successor_action_id": successor_action_id,
|
||||
"dependency_kind": dependency_kind,
|
||||
},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def delete_dependency(
|
||||
*, tenant_id: str, dependency_id: str, user_id: Optional[str] = None
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, predecessor_action_id, successor_action_id, dependency_kind
|
||||
FROM action_dependencies
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(dependency_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Abhängigkeit nicht gefunden")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM action_dependencies
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(dependency_id, tenant_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"action.dependency_removed",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"dependency_id": dependency_id,
|
||||
"predecessor_action_id": str(row["predecessor_action_id"]),
|
||||
"successor_action_id": str(row["successor_action_id"]),
|
||||
},
|
||||
)
|
||||
return True
|
||||
401
backend/services/initiatives.py
Normal file
401
backend/services/initiatives.py
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
"""Initiative (Vorhaben) service — tenant-scoped CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from entity_archetypes import validate_archetype_key
|
||||
from services.audit import log_audit
|
||||
|
||||
InitiativeStatus = Literal["active", "paused", "completed", "archived"]
|
||||
Priority = Literal["low", "normal", "high"]
|
||||
|
||||
INITIATIVE_STATUSES = frozenset({"active", "paused", "completed", "archived"})
|
||||
PRIORITIES = frozenset({"low", "normal", "high"})
|
||||
|
||||
_INITIATIVE_COLUMNS = """
|
||||
id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||
status, priority, portfolio_rank, owner_actor_id, created_at, updated_at
|
||||
"""
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in ("id", "tenant_id", "owner_actor_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
result["created_at"] = result["created_at"].isoformat()
|
||||
if result.get("updated_at"):
|
||||
result["updated_at"] = result["updated_at"].isoformat()
|
||||
return result
|
||||
|
||||
|
||||
def _validate_initiative_status(status: str) -> None:
|
||||
if status not in INITIATIVE_STATUSES:
|
||||
raise ValueError(f"Ungültiger Initiative-Status: {status}")
|
||||
|
||||
|
||||
def _validate_priority(priority: str) -> None:
|
||||
if priority not in PRIORITIES:
|
||||
raise ValueError(f"Ungültige Priorität: {priority}")
|
||||
|
||||
|
||||
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
|
||||
(actor_id, tenant_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _validate_archetype_key(archetype_key: str) -> None:
|
||||
validate_archetype_key(entity_type="initiative", archetype_key=archetype_key)
|
||||
|
||||
|
||||
def create_initiative(
|
||||
*,
|
||||
tenant_id: str,
|
||||
title: str,
|
||||
owner_actor_id: str,
|
||||
goal: str = "",
|
||||
vision: str = "",
|
||||
target_state_summary: str = "",
|
||||
archetype_key: str = "initiative.generic",
|
||||
status: InitiativeStatus = "active",
|
||||
priority: Priority = "normal",
|
||||
user_id: Optional[str] = None,
|
||||
method_profile_key: Optional[str] = None,
|
||||
apply_starter_kit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
_validate_initiative_status(status)
|
||||
_validate_priority(priority)
|
||||
_validate_archetype_key(archetype_key)
|
||||
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
|
||||
raise ValueError("Owner-Actor gehört nicht zum Tenant")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(MAX(portfolio_rank), -1) + 1 AS next_rank
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s
|
||||
""",
|
||||
(tenant_id,),
|
||||
)
|
||||
next_rank = int(cur.fetchone()["next_rank"])
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO initiatives (
|
||||
tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||
status, priority, portfolio_rank, owner_actor_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_INITIATIVE_COLUMNS}
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
title,
|
||||
goal,
|
||||
vision,
|
||||
target_state_summary,
|
||||
archetype_key,
|
||||
status,
|
||||
priority,
|
||||
next_rank,
|
||||
owner_actor_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"initiative.created",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"initiative_id": row["id"], "title": title, "status": status},
|
||||
)
|
||||
from services.steering_context import create_context_for_new_initiative
|
||||
|
||||
create_context_for_new_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=row["id"],
|
||||
user_id=user_id,
|
||||
archetype_key=archetype_key,
|
||||
method_profile_key=method_profile_key,
|
||||
)
|
||||
if apply_starter_kit:
|
||||
from services.archetype_starter_kit import apply_starter_kit
|
||||
|
||||
starter = apply_starter_kit(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=row["id"],
|
||||
archetype_key=archetype_key,
|
||||
owner_actor_id=owner_actor_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
row["starter_kit"] = starter
|
||||
return row
|
||||
|
||||
|
||||
def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_INITIATIVE_COLUMNS}
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s
|
||||
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
|
||||
""",
|
||||
(tenant_id,),
|
||||
)
|
||||
return [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_INITIATIVE_COLUMNS}
|
||||
FROM initiatives
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(initiative_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return _serialize_row(dict(row)) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_initiative(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
goal: Optional[str] = None,
|
||||
vision: Optional[str] = None,
|
||||
target_state_summary: Optional[str] = None,
|
||||
archetype_key: Optional[str] = None,
|
||||
status: Optional[InitiativeStatus] = None,
|
||||
priority: Optional[Priority] = None,
|
||||
owner_actor_id: Optional[str] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
old_status = existing["status"]
|
||||
|
||||
if title is not None:
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
updates.append("title = %s")
|
||||
params.append(title)
|
||||
if goal is not None:
|
||||
updates.append("goal = %s")
|
||||
params.append(goal)
|
||||
if vision is not None:
|
||||
updates.append("vision = %s")
|
||||
params.append(vision)
|
||||
if target_state_summary is not None:
|
||||
updates.append("target_state_summary = %s")
|
||||
params.append(target_state_summary)
|
||||
if archetype_key is not None:
|
||||
_validate_archetype_key(archetype_key)
|
||||
updates.append("archetype_key = %s")
|
||||
params.append(archetype_key)
|
||||
if status is not None:
|
||||
_validate_initiative_status(status)
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if priority is not None:
|
||||
_validate_priority(priority)
|
||||
updates.append("priority = %s")
|
||||
params.append(priority)
|
||||
if owner_actor_id is not None:
|
||||
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
|
||||
raise ValueError("Owner-Actor gehört nicht zum Tenant")
|
||||
updates.append("owner_actor_id = %s")
|
||||
params.append(owner_actor_id)
|
||||
|
||||
if not updates:
|
||||
return existing
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
params.extend([initiative_id, tenant_id])
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE initiatives
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING {_INITIATIVE_COLUMNS}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
result = _serialize_row(dict(row))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"initiative.updated",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"initiative_id": initiative_id, "fields": updates},
|
||||
)
|
||||
if status is not None and status != old_status:
|
||||
log_audit(
|
||||
"initiative.status_changed",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"initiative_id": initiative_id,
|
||||
"from_status": old_status,
|
||||
"to_status": status,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def delete_initiative(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM initiatives WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||
(initiative_id, tenant_id),
|
||||
)
|
||||
deleted = cur.fetchone() is not None
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted:
|
||||
log_audit(
|
||||
"initiative.deleted",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"initiative_id": initiative_id},
|
||||
)
|
||||
return deleted
|
||||
|
||||
|
||||
def load_portfolio_rank_map(*, tenant_id: str) -> dict[str, Optional[int]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, portfolio_rank
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s
|
||||
""",
|
||||
(tenant_id,),
|
||||
)
|
||||
return {
|
||||
str(row["id"]): row["portfolio_rank"]
|
||||
for row in cur.fetchall()
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def reorder_portfolio(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_ids: list[str],
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Setzt portfolio_rank für alle übergebenen Initiativen (0 = höchste Prio)."""
|
||||
if not initiative_ids:
|
||||
raise ValueError("initiative_ids darf nicht leer sein")
|
||||
|
||||
unique_ids = list(dict.fromkeys(initiative_ids))
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s
|
||||
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
|
||||
""",
|
||||
(tenant_id,),
|
||||
)
|
||||
all_ids = [str(row["id"]) for row in cur.fetchall()]
|
||||
all_set = set(all_ids)
|
||||
if set(unique_ids) - all_set:
|
||||
raise ValueError("Unbekannte Initiative im Tenant")
|
||||
if len(unique_ids) != len(all_ids):
|
||||
raise ValueError("Alle Initiativen des Tenants müssen enthalten sein")
|
||||
|
||||
for rank, initiative_id in enumerate(unique_ids):
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE initiatives
|
||||
SET portfolio_rank = %s, updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(rank, initiative_id, tenant_id),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_INITIATIVE_COLUMNS}
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s
|
||||
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
|
||||
""",
|
||||
(tenant_id,),
|
||||
)
|
||||
rows = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"portfolio.reordered",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"initiative_ids": unique_ids},
|
||||
)
|
||||
return rows
|
||||
248
backend/services/maturity_practice.py
Normal file
248
backend/services/maturity_practice.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""A1 — Stage-bound practice exercises (Activity Set)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from services.recurring import (
|
||||
list_recurring_for_initiative,
|
||||
update_recurring_element,
|
||||
)
|
||||
|
||||
# Starter exercise sets per stage title (Spagat reference path).
|
||||
STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = {
|
||||
"Stufe 1 — Basis": [
|
||||
("Vorbeuge-Dehnung", "3×30 Sek. — Hüfte und Beinrückseite"),
|
||||
("Hüftöffner (Schmetterling)", "Sitzend, leichter Druck"),
|
||||
],
|
||||
"Stufe 2 — Aufbau": [
|
||||
("Seitlicher Spagat — links", "Halten, optional Messwert (Grad)"),
|
||||
("Seitlicher Spagat — rechts", "Halten, optional Messwert (Grad)"),
|
||||
("Aktive Vorbeuge", "3× kurz halten"),
|
||||
],
|
||||
"Stufe 3 — Ziel": [
|
||||
("Voller Spagat", "Zielposition — Messwert dokumentieren"),
|
||||
],
|
||||
"Dehnung — Einstieg": [
|
||||
("Vorbeuge halten", "3×30 Sek."),
|
||||
],
|
||||
"Hüftrotation — Einstieg": [
|
||||
("Hüftkreisen im Stand", "10 Wiederholungen je Richtung"),
|
||||
],
|
||||
"Dehnung — nach Join": [
|
||||
("Enddehnung Mawashi-Vorbereitung", "2×45 Sek."),
|
||||
],
|
||||
"Hüftrotation — nach Join": [
|
||||
("Explosive Hüftrotation", "3×8 Wiederholungen"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_active_maturity_stages(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
from services.roadmap import list_roadmap_items_for_initiative
|
||||
|
||||
stages = [
|
||||
item
|
||||
for item in list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if item.get("item_type") == "maturity_stage" and item.get("status") == "active"
|
||||
]
|
||||
stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
|
||||
return stages
|
||||
|
||||
|
||||
def get_active_maturity_stage(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
stages = get_active_maturity_stages(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return stages[0] if stages else None
|
||||
|
||||
|
||||
def list_practices_for_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
stage_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
items = list_recurring_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return [
|
||||
item
|
||||
for item in items
|
||||
if item.get("roadmap_item_id") == stage_id
|
||||
]
|
||||
|
||||
|
||||
def pause_all_practices(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
paused: list[str] = []
|
||||
for item in list_recurring_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
):
|
||||
if item.get("status") == "active":
|
||||
update_recurring_element(
|
||||
tenant_id=tenant_id,
|
||||
recurring_id=item["id"],
|
||||
status="paused",
|
||||
user_id=user_id,
|
||||
)
|
||||
paused.append(item["title"])
|
||||
return paused
|
||||
|
||||
|
||||
def pause_practices_on_same_lane_except_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_stage_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
from services.progression_graph import progression_lanes_for_initiative
|
||||
|
||||
lanes = progression_lanes_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
keep_lane = lanes.get(str(keep_stage_id))
|
||||
if not keep_lane:
|
||||
return pause_all_practices(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
|
||||
)
|
||||
|
||||
lane_gate_ids = {
|
||||
gate_id for gate_id, lane in lanes.items() if lane == keep_lane
|
||||
}
|
||||
paused: list[str] = []
|
||||
for item in list_recurring_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
):
|
||||
if item.get("status") != "active":
|
||||
continue
|
||||
if item.get("roadmap_item_id") == keep_stage_id:
|
||||
continue
|
||||
if str(item.get("roadmap_item_id") or "") not in lane_gate_ids:
|
||||
continue
|
||||
update_recurring_element(
|
||||
tenant_id=tenant_id,
|
||||
recurring_id=item["id"],
|
||||
status="paused",
|
||||
user_id=user_id,
|
||||
)
|
||||
paused.append(item["title"])
|
||||
return paused
|
||||
|
||||
|
||||
def pause_practices_except_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_stage_id: str,
|
||||
strand_project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
del strand_project_id
|
||||
return pause_practices_on_same_lane_except_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
keep_stage_id=keep_stage_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def seed_practices_for_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
stage: dict[str, Any],
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Idempotent: create missing exercises for a stage; activate existing."""
|
||||
stage_id = stage["id"]
|
||||
stage_title = stage.get("title") or ""
|
||||
defs = STAGE_PRACTICE_SETS.get(stage_title)
|
||||
if not defs:
|
||||
defs = [
|
||||
(
|
||||
f"Übung — {stage_title}",
|
||||
f"Routine für {stage_title}",
|
||||
)
|
||||
]
|
||||
|
||||
existing = list_practices_for_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
stage_id=stage_id,
|
||||
)
|
||||
existing_by_title = {item["title"]: item for item in existing}
|
||||
created: list[dict[str, Any]] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
from services.scheduled_work import create_scheduled_action_at_gate
|
||||
|
||||
for title, description in defs:
|
||||
if title in existing_by_title:
|
||||
item = existing_by_title[title]
|
||||
if item.get("status") != "active":
|
||||
updated = update_recurring_element(
|
||||
tenant_id=tenant_id,
|
||||
recurring_id=item["id"],
|
||||
status="active",
|
||||
next_due_at=now,
|
||||
user_id=user_id,
|
||||
)
|
||||
if updated:
|
||||
created.append(updated)
|
||||
else:
|
||||
created.append(item)
|
||||
continue
|
||||
|
||||
row = create_scheduled_action_at_gate(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
gate_id=stage_id,
|
||||
title=title,
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
schedule_kind="weekdays",
|
||||
)
|
||||
created.append(row)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def activate_stage_practice_set(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
stage: dict[str, Any],
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
paused = pause_practices_on_same_lane_except_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
keep_stage_id=stage["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
exercises = seed_practices_for_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
stage=stage,
|
||||
user_id=user_id,
|
||||
)
|
||||
return {
|
||||
"stage_id": stage["id"],
|
||||
"stage_title": stage.get("title"),
|
||||
"paused_practices": paused,
|
||||
"active_practices": [e["title"] for e in exercises],
|
||||
"practice_ids": [e["id"] for e in exercises],
|
||||
}
|
||||
44
backend/services/maturity_stage_transition.py
Normal file
44
backend/services/maturity_stage_transition.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""AP2.0e — maturity stage transition on verify reached (delegates to progression_graph)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def on_maturity_stage_reached(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
reached_item_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
from services.progression_graph import on_work_gate_reached
|
||||
|
||||
return on_work_gate_reached(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
reached_item_id=reached_item_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def on_maturity_stage_reopened(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
stage_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Re-enable exercise set for reopened stage; pause others on same strand."""
|
||||
from services.maturity_practice import activate_stage_practice_set
|
||||
from services.roadmap import get_roadmap_item
|
||||
|
||||
stage = get_roadmap_item(tenant_id=tenant_id, item_id=stage_id)
|
||||
if not stage:
|
||||
return {"stage_id": stage_id, "active_practices": []}
|
||||
return activate_stage_practice_set(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
stage=stage,
|
||||
user_id=user_id,
|
||||
)
|
||||
103
backend/services/milestones.py
Normal file
103
backend/services/milestones.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Milestone service — Kompatibilitäts-Wrapper auf RoadmapItems (AP0.8d → AP1.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from services import roadmap as roadmap_service
|
||||
|
||||
MilestoneStatus = Literal[
|
||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||
]
|
||||
|
||||
MILESTONE_STATUSES = roadmap_service.ROADMAP_ITEM_STATUSES
|
||||
|
||||
|
||||
def _as_milestone(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": item["id"],
|
||||
"tenant_id": item["tenant_id"],
|
||||
"initiative_id": item["initiative_id"],
|
||||
"title": item["title"],
|
||||
"goal_description": item.get("goal_description", ""),
|
||||
"status": item["status"],
|
||||
"target_date": item.get("target_date"),
|
||||
"created_at": item["created_at"],
|
||||
"updated_at": item["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def create_milestone(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
goal_description: str = "",
|
||||
status: MilestoneStatus = "planned",
|
||||
target_date: Optional[date] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
item = roadmap_service.create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
item_type="milestone",
|
||||
goal_description=goal_description,
|
||||
status=status,
|
||||
target_date=target_date,
|
||||
user_id=user_id,
|
||||
)
|
||||
return _as_milestone(item)
|
||||
|
||||
|
||||
def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
|
||||
items = roadmap_service.list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return [_as_milestone(i) for i in items if i["item_type"] == "milestone"]
|
||||
|
||||
|
||||
def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
|
||||
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
|
||||
if not item or item["item_type"] != "milestone":
|
||||
return None
|
||||
return _as_milestone(item)
|
||||
|
||||
|
||||
def update_milestone(
|
||||
*,
|
||||
tenant_id: str,
|
||||
milestone_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
goal_description: Optional[str] = None,
|
||||
status: Optional[MilestoneStatus] = None,
|
||||
target_date: Optional[date] = None,
|
||||
clear_target_date: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
item = roadmap_service.update_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
item_id=milestone_id,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
goal_description=goal_description,
|
||||
status=status,
|
||||
target_date=target_date,
|
||||
clear_target_date=clear_target_date,
|
||||
)
|
||||
return _as_milestone(item) if item else None
|
||||
|
||||
|
||||
def delete_milestone(
|
||||
*,
|
||||
tenant_id: str,
|
||||
milestone_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
|
||||
if not item or item["item_type"] != "milestone":
|
||||
return False
|
||||
return roadmap_service.delete_roadmap_item(
|
||||
tenant_id=tenant_id, item_id=milestone_id, user_id=user_id
|
||||
)
|
||||
248
backend/services/operating_context.py
Normal file
248
backend/services/operating_context.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""Operating Context read model — AP2.3a / AP2.4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from entity_archetypes.registry import (
|
||||
get_ui_profile_json,
|
||||
resolve_default_method_key,
|
||||
resolve_ui_profile,
|
||||
)
|
||||
from services.initiatives import get_initiative
|
||||
from services.steering_context import ensure_steering_context, get_steering_context
|
||||
from steering.graph.profiles import LIGHT, graph_profile_as_dict
|
||||
from steering.backlog_vocabulary import resolve_backlog_vocabulary
|
||||
from steering.effective_contract import resolve_effective_steering_elements
|
||||
from steering.methods.registry import (
|
||||
get_method,
|
||||
list_compatible_methods,
|
||||
list_composable_modifiers,
|
||||
method_to_dict,
|
||||
ui_features_as_dict,
|
||||
)
|
||||
|
||||
|
||||
def _load_ui_profile_from_db(archetype_key: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ui_profile_json
|
||||
FROM entity_archetypes
|
||||
WHERE archetype_key = %s
|
||||
""",
|
||||
(archetype_key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
profile = row.get("ui_profile_json")
|
||||
if isinstance(profile, dict) and profile:
|
||||
return dict(profile)
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_ui_profile(archetype_key: str) -> dict[str, Any]:
|
||||
seed_profile = get_ui_profile_json(archetype_key)
|
||||
if seed_profile:
|
||||
return seed_profile
|
||||
return resolve_ui_profile(archetype_key)
|
||||
|
||||
|
||||
def _normalize_ui_profile_capabilities(profile: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Spiegelt resolve_ui_profile: omCapabilities aus dataSlices falls fehlend."""
|
||||
result = dict(profile)
|
||||
slices = list(result.get("dataSlices") or [])
|
||||
caps = list(result.get("omCapabilities") or slices)
|
||||
result["dataSlices"] = slices
|
||||
result["omCapabilities"] = caps
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
|
||||
seed_profile = _seed_ui_profile(archetype_key)
|
||||
db_profile = _load_ui_profile_from_db(archetype_key)
|
||||
if not db_profile:
|
||||
return _normalize_ui_profile_capabilities(seed_profile)
|
||||
|
||||
merged = {**seed_profile, **db_profile}
|
||||
if not (merged.get("omCapabilities") or merged.get("dataSlices")):
|
||||
seed_slices = seed_profile.get("dataSlices") or []
|
||||
merged["dataSlices"] = list(seed_slices)
|
||||
return _normalize_ui_profile_capabilities(merged)
|
||||
|
||||
|
||||
def _resolve_om_capabilities(ui_profile: dict[str, Any]) -> frozenset[str]:
|
||||
caps = ui_profile.get("omCapabilities") or ui_profile.get("dataSlices") or []
|
||||
return frozenset(caps)
|
||||
|
||||
|
||||
def _resolve_data_slices(
|
||||
*,
|
||||
om_capabilities: frozenset[str],
|
||||
method_key: str,
|
||||
) -> list[str]:
|
||||
"""Schnittmenge Archetyp-OM ∩ Method-Slices."""
|
||||
method = get_method(method_key)
|
||||
if method and method.data_slices:
|
||||
method_slices = set(method.data_slices)
|
||||
return [s for s in om_capabilities if s in method_slices]
|
||||
return list(om_capabilities)
|
||||
|
||||
|
||||
def _method_capabilities(method_key: str) -> dict[str, Any]:
|
||||
method = get_method(method_key)
|
||||
if not method:
|
||||
return {
|
||||
"lifecycle_steps": [],
|
||||
"next_action_strategy_key": "default",
|
||||
}
|
||||
return {
|
||||
"lifecycle_steps": list(method.default_lifecycle_steps),
|
||||
"next_action_strategy_key": method.next_action_strategy_key,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_method_contract(method_key: str) -> dict[str, Any]:
|
||||
method = get_method(method_key)
|
||||
if not method:
|
||||
return {
|
||||
"steering_elements": [],
|
||||
"ui_features": {},
|
||||
"graph_profile": graph_profile_as_dict(LIGHT),
|
||||
}
|
||||
return {
|
||||
"steering_elements": sorted(method.steering_elements),
|
||||
"ui_features": ui_features_as_dict(method.ui_features),
|
||||
"graph_profile": graph_profile_as_dict(method.graph_profile),
|
||||
}
|
||||
|
||||
|
||||
def _compatible_methods_payload(
|
||||
archetype_key: str,
|
||||
*,
|
||||
om_capabilities: frozenset[str],
|
||||
active_method_key: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
default_key = resolve_default_method_key(archetype_key)
|
||||
methods = list_compatible_methods(
|
||||
archetype_key, om_capabilities=om_capabilities
|
||||
)
|
||||
return [
|
||||
{
|
||||
**method_to_dict(m, include_compatibility=False),
|
||||
"is_default": m.key == default_key,
|
||||
"is_active": m.key == active_method_key,
|
||||
}
|
||||
for m in methods
|
||||
]
|
||||
|
||||
|
||||
def _resolve_agent_slot_config(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Governance-Konfiguration für Agent-Slots — keine Prompts."""
|
||||
defaults: dict[str, Any] = {
|
||||
"enabled_slots": ["review.due", "recurring.due", "review.guardrail"],
|
||||
"guardrail_pack": "default_v0",
|
||||
"stale_debt_days": 30,
|
||||
}
|
||||
custom = metadata.get("agent_slots") or metadata.get("agent_slot_config") or {}
|
||||
if isinstance(custom, dict):
|
||||
return {**defaults, **custom}
|
||||
return defaults
|
||||
|
||||
|
||||
def get_operating_context(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
if not initiative:
|
||||
return None
|
||||
|
||||
steering = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
if not steering:
|
||||
steering = ensure_steering_context(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
|
||||
archetype_key = initiative["archetype_key"]
|
||||
ui_profile = _resolve_ui_profile(archetype_key)
|
||||
om_capabilities = _resolve_om_capabilities(ui_profile)
|
||||
method_key = steering["method_key"]
|
||||
metadata = steering.get("lifecycle_metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = {}
|
||||
method_profile_key = metadata.get("method_profile_key")
|
||||
data_slices = _resolve_data_slices(
|
||||
om_capabilities=om_capabilities,
|
||||
method_key=method_key,
|
||||
)
|
||||
method_contract = _resolve_method_contract(method_key)
|
||||
active_composition_modifier = (
|
||||
"agile_iteration"
|
||||
if _resolve_active_agile_composition(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
primary_method_key=method_key,
|
||||
)
|
||||
else None
|
||||
)
|
||||
effective_steering_elements = resolve_effective_steering_elements(
|
||||
method_key,
|
||||
data_slices=data_slices,
|
||||
active_composition_modifier=active_composition_modifier,
|
||||
)
|
||||
backlog_vocabulary = resolve_backlog_vocabulary(
|
||||
data_slices=data_slices,
|
||||
method_key=method_key,
|
||||
steering_elements=effective_steering_elements,
|
||||
)
|
||||
agent_slot_config = _resolve_agent_slot_config(metadata)
|
||||
|
||||
return {
|
||||
"initiative_id": initiative_id,
|
||||
"archetype_key": archetype_key,
|
||||
"method_key": method_key,
|
||||
"method_profile_key": method_profile_key,
|
||||
"ui_profile": ui_profile,
|
||||
"om_capabilities": sorted(om_capabilities),
|
||||
"data_slices": data_slices,
|
||||
"method_capabilities": _method_capabilities(method_key),
|
||||
"steering_elements": sorted(effective_steering_elements),
|
||||
"primary_steering_elements": method_contract["steering_elements"],
|
||||
"ui_features": method_contract["ui_features"],
|
||||
"graph_profile": method_contract["graph_profile"],
|
||||
"compatible_methods": _compatible_methods_payload(
|
||||
archetype_key,
|
||||
om_capabilities=om_capabilities,
|
||||
active_method_key=method_key,
|
||||
),
|
||||
"composable_modifiers": [
|
||||
method_to_dict(m, include_compatibility=False)
|
||||
for m in list_composable_modifiers(method_key)
|
||||
],
|
||||
"active_composition_modifier": active_composition_modifier,
|
||||
"backlog_vocabulary": backlog_vocabulary,
|
||||
"agent_slot_config": agent_slot_config,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_active_agile_composition(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
primary_method_key: str,
|
||||
) -> bool:
|
||||
from services.work_cycle import get_active_work_cycle
|
||||
from steering.methods.registry import get_method
|
||||
|
||||
agile = get_method("agile_iteration")
|
||||
if not agile or primary_method_key not in agile.composes_with:
|
||||
return False
|
||||
return get_active_work_cycle(tenant_id=tenant_id, initiative_id=initiative_id) is not None
|
||||
187
backend/services/operating_transitions.py
Normal file
187
backend/services/operating_transitions.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Minimale Operating-Transitions — AP1.0 mit Hook Dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from steering.hooks.dispatch import dispatch_hook
|
||||
|
||||
RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"})
|
||||
|
||||
|
||||
def after_blocker_status_change(
|
||||
*,
|
||||
tenant_id: str,
|
||||
blocker_id: str,
|
||||
action_id: Optional[str],
|
||||
initiative_id: Optional[str] = None,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Blocker geschlossen → verknüpfte blockierte Maßnahme entblocken."""
|
||||
effects: list[dict[str, Any]] = []
|
||||
if new_status == old_status:
|
||||
return effects
|
||||
if new_status not in RESOLVED_BLOCKER_STATUSES or not action_id:
|
||||
return effects
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, status FROM actions
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
action = cur.fetchone()
|
||||
if not action or action["status"] != "blocked":
|
||||
return effects
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS remaining
|
||||
FROM blockers
|
||||
WHERE tenant_id = %s AND action_id = %s
|
||||
AND id != %s AND status IN ('open', 'in_progress')
|
||||
""",
|
||||
(tenant_id, action_id, blocker_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
remaining = int(row["remaining"]) if row else 0
|
||||
if remaining > 0:
|
||||
effects.append(
|
||||
{
|
||||
"kind": "action_still_blocked",
|
||||
"action_id": str(action_id),
|
||||
"reason": "other_open_blockers",
|
||||
}
|
||||
)
|
||||
return effects
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE actions SET status = 'open', updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s AND status = 'blocked'
|
||||
RETURNING id
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
effects.append(
|
||||
{
|
||||
"kind": "action_unblocked",
|
||||
"action_id": str(action_id),
|
||||
"trigger": "blocker_resolved",
|
||||
"blocker_id": blocker_id,
|
||||
}
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if effects:
|
||||
dispatch_hook(
|
||||
"on_blocker_resolved",
|
||||
None,
|
||||
{
|
||||
"blocker_id": blocker_id,
|
||||
"action_id": action_id,
|
||||
"initiative_id": initiative_id,
|
||||
"effects": effects,
|
||||
},
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
if initiative_id and user_id:
|
||||
from services.steering_events import emit_steering_event
|
||||
|
||||
emit_steering_event(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
event_kind="situational",
|
||||
payload={"reason": "blocker_resolved", "blocker_id": blocker_id},
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if effects and user_id:
|
||||
from services.audit import log_audit
|
||||
|
||||
log_audit(
|
||||
"operating_transition.action_unblocked",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"effects": effects, "blocker_id": blocker_id},
|
||||
)
|
||||
return effects
|
||||
|
||||
|
||||
def after_review_status_change(
|
||||
*,
|
||||
tenant_id: str,
|
||||
review_id: str,
|
||||
action_id: Optional[str],
|
||||
initiative_id: Optional[str] = None,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Review abgeschlossen → Maßnahme aus review_required weiterführen."""
|
||||
effects: list[dict[str, Any]] = []
|
||||
if new_status == old_status or new_status != "completed" or not action_id:
|
||||
return effects
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE actions
|
||||
SET status = 'done', updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s AND status = 'review_required'
|
||||
RETURNING id
|
||||
""",
|
||||
(action_id, tenant_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
effects.append(
|
||||
{
|
||||
"kind": "action_completed_after_review",
|
||||
"action_id": str(action_id),
|
||||
"trigger": "review_completed",
|
||||
"review_id": review_id,
|
||||
}
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if effects:
|
||||
dispatch_hook(
|
||||
"on_review_completed",
|
||||
None,
|
||||
{
|
||||
"review_id": review_id,
|
||||
"action_id": action_id,
|
||||
"initiative_id": initiative_id,
|
||||
"effects": effects,
|
||||
},
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if effects and user_id:
|
||||
from services.audit import log_audit
|
||||
|
||||
log_audit(
|
||||
"operating_transition.action_completed_after_review",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"effects": effects, "review_id": review_id},
|
||||
)
|
||||
return effects
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user