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/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)
|
||||
|
||||
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.tenant.manage` | tenant | Tenant-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.entitlements.read` | platform | Entitlements lesen |
|
||||
|
||||
|
|
|
|||
|
|
@ -53,13 +53,15 @@ app.add_middleware(
|
|||
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(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(actions.router)
|
||||
|
||||
|
||||
@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."""
|
||||
|
||||
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 "004_capabilities_registry" in names
|
||||
assert "005_prompt_feature_config_registry" in names
|
||||
assert "006_initiatives_actions" in names
|
||||
|
||||
|
||||
def test_migration_runner_is_idempotent():
|
||||
|
|
@ -50,6 +51,7 @@ def test_migration_runner_is_idempotent():
|
|||
assert "003_data_seeds_tracking" in executed
|
||||
assert "004_capabilities_registry" in executed
|
||||
assert "005_prompt_feature_config_registry" in executed
|
||||
assert "006_initiatives_actions" in executed
|
||||
|
||||
|
||||
def test_core_table_exists():
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ def test_registry_contains_initial_capabilities():
|
|||
"kairo.config.registry.read",
|
||||
"kairo.config.registry.manage",
|
||||
"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:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) FROM capabilities")
|
||||
assert cur.fetchone()[0] == 12
|
||||
assert cur.fetchone()[0] == 16
|
||||
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
||||
assert cur.fetchone()[0] >= 5
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.4.0-ap0.4"
|
||||
DB_SCHEMA_VERSION = "005"
|
||||
APP_VERSION = "0.5.0-ap0.5"
|
||||
DB_SCHEMA_VERSION = "006"
|
||||
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">
|
||||
<header className="hero">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user