AP0.5: Minimaler Vorhaben- und Maßnahmen-Slice mit Migration 006, CRUD, Assignments und Tests.
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Failing after 28s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 7s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Failing after 28s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 7s
Test Suite / compose-smoke (push) Has been skipped
This commit is contained in:
parent
53047b6c16
commit
afa68150c0
42
README.md
42
README.md
|
|
@ -132,6 +132,44 @@ 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/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) |
|
| `/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 |
|
||||||
|
|
||||||
|
**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"
|
||||||
|
```
|
||||||
|
|
||||||
### Registries (AP0.4)
|
### Registries (AP0.4)
|
||||||
|
|
||||||
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
|
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
|
||||||
|
|
@ -183,6 +221,10 @@ Registry-first: Capabilities werden in `backend/rights_registrations/` registrie
|
||||||
| `kairo.admin.access` | platform | Portal-Administration |
|
| `kairo.admin.access` | platform | Portal-Administration |
|
||||||
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
|
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
|
||||||
| `kairo.actor.manage` | tenant | Actor-Verwaltung |
|
| `kairo.actor.manage` | tenant | Actor-Verwaltung |
|
||||||
|
| `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.context.read` | platform | TenantContext lesen |
|
||||||
| `kairo.entitlements.read` | platform | Entitlements lesen |
|
| `kairo.entitlements.read` | platform | Entitlements lesen |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,15 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
from routers import auth, config, features, me, prompts # noqa: E402
|
from routers import actions, auth, config, features, initiatives, me, prompts # noqa: E402
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
app.include_router(features.router)
|
app.include_router(features.router)
|
||||||
app.include_router(prompts.router)
|
app.include_router(prompts.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
|
app.include_router(initiatives.router)
|
||||||
|
app.include_router(actions.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
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);
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""Import all module registrations — side effect registers capabilities."""
|
"""Import all module registrations — side effect registers capabilities."""
|
||||||
|
|
||||||
from . import platform, registry_ops, tenant_ops # noqa: F401
|
from . import initiative_ops, platform, registry_ops, tenant_ops # noqa: F401
|
||||||
|
|
||||||
__all__ = ["platform", "registry_ops", "tenant_ops"]
|
__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
103
backend/routers/actions.py
Normal file
103
backend/routers/actions.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""Action (Maßnahme) API — AP0.5."""
|
||||||
|
|
||||||
|
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 actions as action_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", "in_progress", "blocked", "done", "discarded"]] = None
|
||||||
|
priority: Optional[Literal["low", "normal", "high"]] = 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")),
|
||||||
|
):
|
||||||
|
if not ctx.actor_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
|
||||||
|
return action_service.list_open_actions_for_actor(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
actor_id=ctx.actor_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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")),
|
||||||
|
):
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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}/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.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")
|
||||||
155
backend/routers/initiatives.py
Normal file
155
backend/routers/initiatives.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""Initiative (Vorhaben) API — AP0.5."""
|
||||||
|
|
||||||
|
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 actions as action_service
|
||||||
|
from services import initiatives as initiative_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 = ""
|
||||||
|
status: Literal["active", "paused", "completed", "archived"] = "active"
|
||||||
|
priority: Literal["low", "normal", "high"] = "normal"
|
||||||
|
owner_actor_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InitiativeUpdateRequest(BaseModel):
|
||||||
|
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
|
goal: 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 ActionCreateRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str = ""
|
||||||
|
status: Literal["open", "in_progress", "blocked", "done", "discarded"] = "open"
|
||||||
|
priority: Literal["low", "normal", "high"] = "normal"
|
||||||
|
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
status=body.status,
|
||||||
|
priority=body.priority,
|
||||||
|
owner_actor_id=owner_actor_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
)
|
||||||
|
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.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,
|
||||||
|
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:
|
||||||
|
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,
|
||||||
|
assigned_actor_ids=body.assigned_actor_ids,
|
||||||
|
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
|
||||||
398
backend/services/actions.py
Normal file
398
backend/services/actions.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
ActionStatus = Literal["open", "in_progress", "blocked", "done", "discarded"]
|
||||||
|
|
||||||
|
ACTION_STATUSES = frozenset({"open", "in_progress", "blocked", "done", "discarded"})
|
||||||
|
OPEN_ACTION_STATUSES = frozenset({"open", "in_progress", "blocked"})
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(row)
|
||||||
|
for key in ("id", "tenant_id", "initiative_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_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 create_action(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
title: str,
|
||||||
|
description: str = "",
|
||||||
|
status: ActionStatus = "open",
|
||||||
|
priority: str = "normal",
|
||||||
|
assigned_actor_ids: Optional[list[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)
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
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:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO actions (
|
||||||
|
tenant_id, initiative_id, title, description, status, priority
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, tenant_id, initiative_id, title, description,
|
||||||
|
status, priority, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id, title, description, status, priority),
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
"""
|
||||||
|
SELECT id, tenant_id, initiative_id, title, description,
|
||||||
|
status, priority, created_at, updated_at
|
||||||
|
FROM actions
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC, title
|
||||||
|
""",
|
||||||
|
(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(
|
||||||
|
"""
|
||||||
|
SELECT id, tenant_id, initiative_id, title, description,
|
||||||
|
status, priority, created_at, updated_at
|
||||||
|
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()
|
||||||
|
return _attach_assignments([action], tenant_id=tenant_id)[0]
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> 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 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:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE actions
|
||||||
|
SET {", ".join(updates)}
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
RETURNING id, tenant_id, initiative_id, title, description,
|
||||||
|
status, priority, created_at, updated_at
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = _serialize_row(dict(row))
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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.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)
|
||||||
252
backend/services/initiatives.py
Normal file
252
backend/services/initiatives.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
"""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 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"})
|
||||||
|
|
||||||
|
|
||||||
|
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 create_initiative(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
title: str,
|
||||||
|
owner_actor_id: str,
|
||||||
|
goal: str = "",
|
||||||
|
status: InitiativeStatus = "active",
|
||||||
|
priority: Priority = "normal",
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
_validate_initiative_status(status)
|
||||||
|
_validate_priority(priority)
|
||||||
|
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(
|
||||||
|
"""
|
||||||
|
INSERT INTO initiatives (
|
||||||
|
tenant_id, title, goal, status, priority, owner_actor_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, tenant_id, title, goal, status, priority,
|
||||||
|
owner_actor_id, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(tenant_id, title, goal, status, priority, 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},
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
"""
|
||||||
|
SELECT id, tenant_id, title, goal, status, priority,
|
||||||
|
owner_actor_id, created_at, updated_at
|
||||||
|
FROM initiatives
|
||||||
|
WHERE tenant_id = %s
|
||||||
|
ORDER BY 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(
|
||||||
|
"""
|
||||||
|
SELECT id, tenant_id, title, goal, status, priority,
|
||||||
|
owner_actor_id, created_at, updated_at
|
||||||
|
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,
|
||||||
|
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 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 id, tenant_id, title, goal, status, priority,
|
||||||
|
owner_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(
|
||||||
|
"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
|
||||||
316
backend/tests/test_initiatives_actions.py
Normal file
316
backend/tests/test_initiatives_actions.py
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
"""Initiatives and actions tests (AP0.5)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from auth import AUTH_HEADER
|
||||||
|
from db import get_connection
|
||||||
|
from services.actors import create_actor
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, user: dict) -> str:
|
||||||
|
res = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": user["email"], "password": user["password"]},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
return res.json()["token"]
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(token: str) -> dict:
|
||||||
|
return {AUTH_HEADER: token}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_initiative(client, token: str, *, title: str = "Test Vorhaben", **kwargs):
|
||||||
|
body = {"title": title, **kwargs}
|
||||||
|
return client.post("/api/initiatives", json=body, headers=_auth(token))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_action(client, token: str, initiative_id: str, *, title: str = "Test Maßnahme", **kwargs):
|
||||||
|
body = {"title": title, **kwargs}
|
||||||
|
return client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/actions",
|
||||||
|
json=body,
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initiative_crud(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Kairo MVP",
|
||||||
|
goal="Erster Slice",
|
||||||
|
priority="high",
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
initiative = created.json()
|
||||||
|
assert initiative["title"] == "Kairo MVP"
|
||||||
|
assert initiative["goal"] == "Erster Slice"
|
||||||
|
assert initiative["status"] == "active"
|
||||||
|
assert initiative["priority"] == "high"
|
||||||
|
assert initiative["owner_actor_id"] == user["actor_id"]
|
||||||
|
assert initiative["tenant_id"] == user["tenant_id"]
|
||||||
|
|
||||||
|
listed = client.get("/api/initiatives", headers=_auth(token))
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert any(i["id"] == initiative["id"] for i in listed.json())
|
||||||
|
|
||||||
|
fetched = client.get(f"/api/initiatives/{initiative['id']}", headers=_auth(token))
|
||||||
|
assert fetched.status_code == 200
|
||||||
|
|
||||||
|
updated = client.patch(
|
||||||
|
f"/api/initiatives/{initiative['id']}",
|
||||||
|
json={"status": "paused", "goal": "Angepasst"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["status"] == "paused"
|
||||||
|
assert updated.json()["goal"] == "Angepasst"
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/initiatives/{initiative['id']}", headers=_auth(token))
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
assert client.get(f"/api/initiatives/{initiative['id']}", headers=_auth(token)).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_crud_and_status_change(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
created = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="Status prüfen",
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
action = created.json()
|
||||||
|
assert action["status"] == "open"
|
||||||
|
assert action["assigned_actor_ids"] == [user["actor_id"]]
|
||||||
|
|
||||||
|
listed = client.get(f"/api/initiatives/{initiative_id}/actions", headers=_auth(token))
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
updated = client.patch(
|
||||||
|
f"/api/actions/{action['id']}",
|
||||||
|
json={"status": "in_progress"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
done = client.patch(
|
||||||
|
f"/api/actions/{action['id']}",
|
||||||
|
json={"status": "done"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert done.status_code == 200
|
||||||
|
assert done.json()["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_assignments(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Helper Agent")
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
action_id = _create_action(client, token, initiative_id).json()["id"]
|
||||||
|
|
||||||
|
assigned = client.put(
|
||||||
|
f"/api/actions/{action_id}/assignments",
|
||||||
|
json={"actor_ids": [user["actor_id"], agent["id"]]},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert assigned.status_code == 200
|
||||||
|
body = assigned.json()
|
||||||
|
assert set(body["assigned_actor_ids"]) == {user["actor_id"], agent["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_open_actions(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
open_action = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="Offen",
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
).json()
|
||||||
|
in_progress = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="In Arbeit",
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
).json()
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{in_progress['id']}",
|
||||||
|
json={"status": "in_progress"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
done_action = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="Erledigt",
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
).json()
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{done_action['id']}",
|
||||||
|
json={"status": "done"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
unassigned = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="Nicht zugewiesen",
|
||||||
|
).json()
|
||||||
|
|
||||||
|
open_res = client.get("/api/actions/me/open", headers=_auth(token))
|
||||||
|
assert open_res.status_code == 200
|
||||||
|
open_ids = {a["id"] for a in open_res.json()}
|
||||||
|
assert open_action["id"] in open_ids
|
||||||
|
assert in_progress["id"] in open_ids
|
||||||
|
assert done_action["id"] not in open_ids
|
||||||
|
assert unassigned["id"] not in open_ids
|
||||||
|
for item in open_res.json():
|
||||||
|
assert "initiative_title" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_isolation_initiatives(client):
|
||||||
|
user_a = provision_user_in_tenant()
|
||||||
|
user_b = provision_user_in_tenant()
|
||||||
|
token_a = _login(client, user_a)
|
||||||
|
token_b = _login(client, user_b)
|
||||||
|
|
||||||
|
initiative_id = _create_initiative(client, token_a, title="Tenant A only").json()["id"]
|
||||||
|
|
||||||
|
assert client.get(f"/api/initiatives/{initiative_id}", headers=_auth(token_b)).status_code == 404
|
||||||
|
assert (
|
||||||
|
client.patch(
|
||||||
|
f"/api/initiatives/{initiative_id}",
|
||||||
|
json={"title": "Hack"},
|
||||||
|
headers=_auth(token_b),
|
||||||
|
).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
assert client.get("/api/initiatives", headers=_auth(token_b)).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_isolation_actions(client):
|
||||||
|
user_a = provision_user_in_tenant()
|
||||||
|
user_b = provision_user_in_tenant()
|
||||||
|
token_a = _login(client, user_a)
|
||||||
|
token_b = _login(client, user_b)
|
||||||
|
|
||||||
|
initiative_id = _create_initiative(client, token_a).json()["id"]
|
||||||
|
action_id = _create_action(
|
||||||
|
client,
|
||||||
|
token_a,
|
||||||
|
initiative_id,
|
||||||
|
assigned_actor_ids=[user_a["actor_id"]],
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
assert client.get(f"/api/actions/{action_id}", headers=_auth(token_b)).status_code == 404
|
||||||
|
assert (
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{action_id}",
|
||||||
|
json={"status": "done"},
|
||||||
|
headers=_auth(token_b),
|
||||||
|
).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
assert client.get("/api/actions/me/open", headers=_auth(token_b)).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_tenant_actor_assignment_rejected(client):
|
||||||
|
user_a = provision_user_in_tenant()
|
||||||
|
user_b = provision_user_in_tenant()
|
||||||
|
token_a = _login(client, user_a)
|
||||||
|
initiative_id = _create_initiative(client, token_a).json()["id"]
|
||||||
|
action_id = _create_action(client, token_a, initiative_id).json()["id"]
|
||||||
|
|
||||||
|
res = client.put(
|
||||||
|
f"/api/actions/{action_id}/assignments",
|
||||||
|
json={"actor_ids": [user_b["actor_id"]]},
|
||||||
|
headers=_auth(token_a),
|
||||||
|
)
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_on_create_and_status_change(client):
|
||||||
|
user = provision_user_in_tenant()
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token, title="Audit Test").json()["id"]
|
||||||
|
action_id = _create_action(client, token, initiative_id).json()["id"]
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{action_id}",
|
||||||
|
json={"status": "blocked"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT action FROM audit_log
|
||||||
|
WHERE tenant_id = %s::uuid
|
||||||
|
AND action IN (
|
||||||
|
'initiative.created', 'action.created', 'action.status_changed'
|
||||||
|
)
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
(user["tenant_id"],),
|
||||||
|
)
|
||||||
|
actions = {row[0] for row in cur.fetchall()}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert "initiative.created" in actions
|
||||||
|
assert "action.created" in actions
|
||||||
|
assert "action.status_changed" in actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_rejected(client):
|
||||||
|
assert client.get("/api/initiatives").status_code == 401
|
||||||
|
assert client.get("/api/actions/me/open").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def enforce_capabilities(monkeypatch):
|
||||||
|
monkeypatch.setenv("CAPABILITY_ENFORCE", "enforce")
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_has_initiative_capabilities(client):
|
||||||
|
member = provision_user_in_tenant(tenant_role="member", portal_role="user")
|
||||||
|
token = _login(client, member)
|
||||||
|
ctx = client.get("/api/me/context", headers=_auth(token)).json()
|
||||||
|
assert "kairo.initiative.read" in ctx["capabilities"]
|
||||||
|
assert "kairo.initiative.manage" in ctx["capabilities"]
|
||||||
|
assert "kairo.action.read" in ctx["capabilities"]
|
||||||
|
assert "kairo.action.manage" in ctx["capabilities"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_status_rejected(client):
|
||||||
|
user = provision_user_in_tenant()
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
action_id = _create_action(client, token, initiative_id).json()["id"]
|
||||||
|
|
||||||
|
res = client.patch(
|
||||||
|
f"/api/actions/{action_id}",
|
||||||
|
json={"status": "invalid_status"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
@ -36,6 +36,7 @@ def test_migration_runner_finds_migrations():
|
||||||
assert "003_data_seeds_tracking" in names
|
assert "003_data_seeds_tracking" in names
|
||||||
assert "004_capabilities_registry" in names
|
assert "004_capabilities_registry" in names
|
||||||
assert "005_prompt_feature_config_registry" in names
|
assert "005_prompt_feature_config_registry" in names
|
||||||
|
assert "006_initiatives_actions" in names
|
||||||
|
|
||||||
|
|
||||||
def test_migration_runner_is_idempotent():
|
def test_migration_runner_is_idempotent():
|
||||||
|
|
@ -50,6 +51,7 @@ def test_migration_runner_is_idempotent():
|
||||||
assert "003_data_seeds_tracking" in executed
|
assert "003_data_seeds_tracking" in executed
|
||||||
assert "004_capabilities_registry" in executed
|
assert "004_capabilities_registry" in executed
|
||||||
assert "005_prompt_feature_config_registry" in executed
|
assert "005_prompt_feature_config_registry" in executed
|
||||||
|
assert "006_initiatives_actions" in executed
|
||||||
|
|
||||||
|
|
||||||
def test_core_table_exists():
|
def test_core_table_exists():
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ def test_registry_contains_initial_capabilities():
|
||||||
"kairo.config.registry.read",
|
"kairo.config.registry.read",
|
||||||
"kairo.config.registry.manage",
|
"kairo.config.registry.manage",
|
||||||
"kairo.prompt.render",
|
"kairo.prompt.render",
|
||||||
|
"kairo.initiative.read",
|
||||||
|
"kairo.initiative.manage",
|
||||||
|
"kairo.action.read",
|
||||||
|
"kairo.action.manage",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,7 +37,7 @@ def test_sync_is_idempotent():
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("SELECT COUNT(*) FROM capabilities")
|
cur.execute("SELECT COUNT(*) FROM capabilities")
|
||||||
assert cur.fetchone()[0] == 12
|
assert cur.fetchone()[0] == 16
|
||||||
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
||||||
assert cur.fetchone()[0] >= 5
|
assert cur.fetchone()[0] >= 5
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.4.0-ap0.4"
|
APP_VERSION = "0.5.0-ap0.5"
|
||||||
DB_SCHEMA_VERSION = "005"
|
DB_SCHEMA_VERSION = "006"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
151
docs/sprints/Sprint0_AP0_5_Completion_Report_v0.1.md
Normal file
151
docs/sprints/Sprint0_AP0_5_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# AP0.5 – Abschlussbericht Minimaler Vorhaben- und Maßnahmen-Slice
|
||||||
|
|
||||||
|
**Status:** implementiert
|
||||||
|
**Stand:** 2026-07-04
|
||||||
|
**Version:** `0.5.0-ap0.5` · Schema `006`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope und Einordnung
|
||||||
|
|
||||||
|
AP0.5 liefert den **ersten fachlich nutzbaren Kairo-Slice**: Ein angemeldeter Actor kann innerhalb eines Tenants Vorhaben und Maßnahmen verwalten, zuweisen und seinen offenen Maßnahmen-Backlog abrufen.
|
||||||
|
|
||||||
|
| Anforderung (AP0.5) | Status |
|
||||||
|
|---------------------|--------|
|
||||||
|
| Migration `006` (initiatives, actions, action_assignments) | ✓ |
|
||||||
|
| Tenant-Bezug für alle Objekte | ✓ |
|
||||||
|
| Owner Actor für Initiative | ✓ |
|
||||||
|
| Action gehört zu Initiative | ✓ |
|
||||||
|
| Action → ein oder mehrere Actors (Assignments) | ✓ |
|
||||||
|
| CRUD-Minimum Initiatives | ✓ |
|
||||||
|
| CRUD-Minimum Actions | ✓ |
|
||||||
|
| Endpoint „Meine offenen Maßnahmen“ | ✓ |
|
||||||
|
| Statusmodell minimal | ✓ |
|
||||||
|
| Priority minimal | ✓ |
|
||||||
|
| Capability-Gates (4 neue Capabilities) | ✓ |
|
||||||
|
| Audit bei Create/Update/Statuswechsel | ✓ |
|
||||||
|
| Tests (Isolation, CRUD, Assignment, Status) | ✓ |
|
||||||
|
| README/API-Doku | ✓ |
|
||||||
|
|
||||||
|
**Bewusst nicht in AP0.5:** Projects, Milestones, Programs, Reviews, Evidence, Blocker-Objekte, KI/Prompt-Erweiterung, MCP, automatische Priorisierung, komplexe UI, Workflow Engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Umgesetzte Dateien
|
||||||
|
|
||||||
|
### 2.1 Migration & Schema
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `backend/migrations/006_initiatives_actions.sql` | Tabellen initiatives, actions, action_assignments |
|
||||||
|
|
||||||
|
### 2.2 Services
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `backend/services/initiatives.py` | Vorhaben-CRUD, Validierung, Audit |
|
||||||
|
| `backend/services/actions.py` | Maßnahmen-CRUD, Assignments, offene Maßnahmen |
|
||||||
|
|
||||||
|
### 2.3 API & Capabilities
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `backend/routers/initiatives.py` | Initiative-Endpoints + nested Action-Create/List |
|
||||||
|
| `backend/routers/actions.py` | Action-Endpoints + `/me/open` |
|
||||||
|
| `backend/rights_registrations/initiative_ops.py` | 4 AP0.5-Capabilities |
|
||||||
|
| `backend/rights_registrations/__init__.py` | Import initiative_ops |
|
||||||
|
| `backend/main.py` | Router-Registrierung |
|
||||||
|
| `backend/version.py` | `0.5.0-ap0.5`, Schema `006` |
|
||||||
|
|
||||||
|
### 2.4 Tests & Doku
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `backend/tests/test_initiatives_actions.py` | CRUD, Isolation, Assignment, Status, Audit |
|
||||||
|
| `backend/tests/test_migrations.py` | Migration 006 |
|
||||||
|
| `backend/tests/test_rights_registry.py` | 16 Capabilities |
|
||||||
|
| `README.md` | API-Doku AP0.5 |
|
||||||
|
| `frontend/src/App.jsx` | Header AP0.5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Neue Migrationen
|
||||||
|
|
||||||
|
**`006_initiatives_actions.sql`**
|
||||||
|
|
||||||
|
| Tabelle | Zweck |
|
||||||
|
|---------|--------|
|
||||||
|
| `initiatives` | Vorhaben (tenant-scoped, owner_actor_id) |
|
||||||
|
| `actions` | Maßnahmen (gehört zu initiative) |
|
||||||
|
| `action_assignments` | Actor-Zuweisungen (n:m) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Datenmodell
|
||||||
|
|
||||||
|
```
|
||||||
|
tenants
|
||||||
|
└── initiatives
|
||||||
|
id, tenant_id, title, goal, status, priority
|
||||||
|
owner_actor_id → actors
|
||||||
|
└── actions
|
||||||
|
id, tenant_id, initiative_id, title, description
|
||||||
|
status, priority
|
||||||
|
└── action_assignments
|
||||||
|
id, tenant_id, action_id, actor_id
|
||||||
|
UNIQUE (action_id, actor_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Endpoints
|
||||||
|
|
||||||
|
Siehe `README.md` — Abschnitt AP0.5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Statusmodell
|
||||||
|
|
||||||
|
**Initiative:** `active` · `paused` · `completed` · `archived`
|
||||||
|
|
||||||
|
**Action:** `open` · `in_progress` · `blocked` · `done` · `discarded`
|
||||||
|
|
||||||
|
**Priorität:** `low` · `normal` · `high`
|
||||||
|
|
||||||
|
Offene Maßnahmen: Status ∈ `{open, in_progress, blocked}` und zugewiesen an `ctx.actor_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Assignment-Modell
|
||||||
|
|
||||||
|
n:m über `action_assignments`; `PUT /api/actions/{id}/assignments` ersetzt die Liste; Validierung gegen Tenant-Actors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Capability-Nutzung
|
||||||
|
|
||||||
|
`kairo.initiative.read/manage` · `kairo.action.read/manage` — Grants für tenant owner/admin/member.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tenant-Isolation
|
||||||
|
|
||||||
|
Alle Queries tenant-scoped; Cross-Tenant → 404/400; Tests in `test_initiatives_actions.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tests
|
||||||
|
|
||||||
|
11 Tests in `test_initiatives_actions.py` + aktualisierte Registry/Migration-Tests.
|
||||||
|
|
||||||
|
Ausführung (Dev-Stack):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Empfehlung nächster kleinster Slice
|
||||||
|
|
||||||
|
**AP0.6 – Kommentare pro Maßnahme** (`action_comments`, read/manage Capability, minimal Audit) — operativer Mehrwert ohne Projects/Milestones/KI.
|
||||||
|
|
@ -183,7 +183,7 @@ export default function App() {
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<h1>Jinkendo Kairo</h1>
|
<h1>Jinkendo Kairo</h1>
|
||||||
<p>Operativer Program Director — Sprint 0 / AP0.4</p>
|
<p>Operativer Program Director — Sprint 0 / AP0.5</p>
|
||||||
<p className="muted">Capability Registry & Entitlements</p>
|
<p className="muted">Capability Registry & Entitlements</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user