AP0.9: Operating Model Extension II (Evidence, Decision, Review, Recurring, due_at)
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 1m6s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 21s
Test Suite / playwright-smoke (push) Successful in 14s
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 1m6s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 21s
Test Suite / playwright-smoke (push) Successful in 14s
Schließt den zweiten OM-Slice entlang der Roadmap: neue Entitäten parallel im Vorhaben, erweiterte Action-Status/Fälligkeit und Attention-Regeln 7–9. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c9101db1e1
commit
a025542545
21
README.md
21
README.md
|
|
@ -211,7 +211,26 @@ Operating Model Extension I (AP0.8, Schema `007`):
|
||||||
|
|
||||||
Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage).
|
Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage).
|
||||||
|
|
||||||
Zielarchitektur-Einordnung: AP0.8 ist Schritt 5 der Evolutionslinie (Signal/NextAction) und Operating-Model-Phase A — siehe `docs/architecture/Kairo_System_Target_State_v0.1.md` und `Sprint0_AP0_8_Assignment_v0.2.md`.
|
Operating Model Extension II (AP0.9, Schema `008`):
|
||||||
|
|
||||||
|
| Endpoint | Methode | Capability | Beschreibung |
|
||||||
|
|----------|---------|------------|--------------|
|
||||||
|
| `/api/initiatives/{id}/evidence` | GET/POST | `kairo.evidence.read/manage` | Evidence eines Vorhabens |
|
||||||
|
| `/api/evidence/{id}` | GET/PATCH/DELETE | `kairo.evidence.read/manage` | Einzelnes Evidence |
|
||||||
|
| `/api/initiatives/{id}/decisions` | GET/POST | `kairo.decision.read/manage` | Entscheidungen |
|
||||||
|
| `/api/decisions/{id}` | GET/PATCH/DELETE | `kairo.decision.read/manage` | Einzelne Entscheidung |
|
||||||
|
| `/api/initiatives/{id}/reviews` | GET/POST | `kairo.review.read/manage` | Reviews |
|
||||||
|
| `/api/reviews/{id}` | GET/PATCH/DELETE | `kairo.review.read/manage` | Einzelnes Review |
|
||||||
|
| `/api/initiatives/{id}/recurring` | GET/POST | `kairo.recurring.read/manage` | Recurring-Elemente |
|
||||||
|
| `/api/recurring/{id}` | GET/PATCH/DELETE | `kairo.recurring.read/manage` | Einzelnes Recurring-Element |
|
||||||
|
|
||||||
|
**Action-Status (erweitert):** `open`, `ready`, `in_progress`, `blocked`, `review_required`, `done`, `discarded` — optional `due_at`
|
||||||
|
|
||||||
|
**Attention-Regeln 7–9:** `overdue_action`, `review_due`, `recurring_due`
|
||||||
|
|
||||||
|
Capabilities gesamt nach AP0.9: **33**.
|
||||||
|
|
||||||
|
Zielarchitektur-Einordnung: AP0.9 ist Schritt 7 der Evolutionslinie (Evidence/Review/Decision/Recurring) — siehe `docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md`.
|
||||||
|
|
||||||
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ AttentionKind = Literal[
|
||||||
"initiative_without_next_action",
|
"initiative_without_next_action",
|
||||||
"stale_initiative",
|
"stale_initiative",
|
||||||
"milestone_at_risk",
|
"milestone_at_risk",
|
||||||
|
"overdue_action",
|
||||||
|
"review_due",
|
||||||
|
"recurring_due",
|
||||||
]
|
]
|
||||||
|
|
||||||
NextActionKind = Literal[
|
NextActionKind = Literal[
|
||||||
|
|
@ -32,13 +35,21 @@ Severity = Literal["info", "warning", "critical"]
|
||||||
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
|
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
|
||||||
|
|
||||||
OPEN_BLOCKER_STATUSES = ("open", "in_progress")
|
OPEN_BLOCKER_STATUSES = ("open", "in_progress")
|
||||||
OPEN_ACTION_STATUSES = ("open", "in_progress", "blocked")
|
OPEN_ACTION_STATUSES = ("open", "ready", "in_progress", "blocked", "review_required")
|
||||||
ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
|
ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
|
||||||
|
|
||||||
|
|
||||||
def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]:
|
def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
for key in ("scope_id", "initiative_id", "action_id", "blocker_id", "milestone_id"):
|
for key in (
|
||||||
|
"scope_id",
|
||||||
|
"initiative_id",
|
||||||
|
"action_id",
|
||||||
|
"blocker_id",
|
||||||
|
"milestone_id",
|
||||||
|
"review_id",
|
||||||
|
"recurring_element_id",
|
||||||
|
):
|
||||||
if item.get(key):
|
if item.get(key):
|
||||||
item[key] = str(item[key])
|
item[key] = str(item[key])
|
||||||
return item
|
return item
|
||||||
|
|
@ -136,7 +147,7 @@ def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
FROM actions a
|
FROM actions a
|
||||||
WHERE a.tenant_id = %s
|
WHERE a.tenant_id = %s
|
||||||
AND a.priority = 'high'
|
AND a.priority = 'high'
|
||||||
AND a.status IN ('open', 'in_progress')
|
AND a.status IN ('open', 'ready', 'in_progress')
|
||||||
{actor_filter}
|
{actor_filter}
|
||||||
ORDER BY a.updated_at DESC
|
ORDER BY a.updated_at DESC
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
|
|
@ -164,7 +175,7 @@ def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
'actions' AS data_source
|
'actions' AS data_source
|
||||||
FROM actions a
|
FROM actions a
|
||||||
WHERE a.tenant_id = %s
|
WHERE a.tenant_id = %s
|
||||||
AND a.status IN ('open', 'in_progress')
|
AND a.status IN ('open', 'ready', 'in_progress')
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM action_assignments aa
|
SELECT 1 FROM action_assignments aa
|
||||||
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||||
|
|
@ -200,7 +211,7 @@ def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str,
|
||||||
SELECT 1 FROM actions a
|
SELECT 1 FROM actions a
|
||||||
WHERE a.initiative_id = i.id
|
WHERE a.initiative_id = i.id
|
||||||
AND a.tenant_id = i.tenant_id
|
AND a.tenant_id = i.tenant_id
|
||||||
AND a.status IN ('open', 'in_progress', 'blocked')
|
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
||||||
)
|
)
|
||||||
ORDER BY i.updated_at DESC
|
ORDER BY i.updated_at DESC
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
|
|
@ -264,6 +275,106 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'overdue_action' AS kind,
|
||||||
|
'warning' AS severity,
|
||||||
|
a.title AS title,
|
||||||
|
'Maßnahme überfällig' AS summary,
|
||||||
|
'action' AS scope_type,
|
||||||
|
a.id AS scope_id,
|
||||||
|
a.initiative_id,
|
||||||
|
a.id AS action_id,
|
||||||
|
NULL::uuid AS blocker_id,
|
||||||
|
NULL::uuid AS milestone_id,
|
||||||
|
NULL::uuid AS review_id,
|
||||||
|
NULL::uuid AS recurring_element_id,
|
||||||
|
'action_overdue' AS reason_code,
|
||||||
|
'actions' AS data_source
|
||||||
|
FROM actions a
|
||||||
|
WHERE a.tenant_id = %s
|
||||||
|
AND a.due_at IS NOT NULL
|
||||||
|
AND a.due_at < NOW()
|
||||||
|
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
||||||
|
ORDER BY a.due_at ASC
|
||||||
|
LIMIT 30
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id,),
|
||||||
|
)
|
||||||
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _reviews_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'review_due' AS kind,
|
||||||
|
'warning' AS severity,
|
||||||
|
r.title AS title,
|
||||||
|
'Review fällig' AS summary,
|
||||||
|
'review' AS scope_type,
|
||||||
|
r.id AS scope_id,
|
||||||
|
r.initiative_id,
|
||||||
|
r.action_id,
|
||||||
|
NULL::uuid AS blocker_id,
|
||||||
|
r.milestone_id,
|
||||||
|
r.id AS review_id,
|
||||||
|
NULL::uuid AS recurring_element_id,
|
||||||
|
'review_due' AS reason_code,
|
||||||
|
'reviews' AS data_source
|
||||||
|
FROM reviews r
|
||||||
|
WHERE r.tenant_id = %s
|
||||||
|
AND r.status = 'planned'
|
||||||
|
AND r.due_at IS NOT NULL
|
||||||
|
AND r.due_at <= NOW()
|
||||||
|
ORDER BY r.due_at ASC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id,),
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = _serialize_attention(dict(row))
|
||||||
|
for fk in ("action_id", "milestone_id"):
|
||||||
|
if item.get(fk):
|
||||||
|
item[fk] = str(item[fk])
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'recurring_due' AS kind,
|
||||||
|
'info' AS severity,
|
||||||
|
re.title AS title,
|
||||||
|
'Wiederkehrendes Element fällig' AS summary,
|
||||||
|
'recurring_element' AS scope_type,
|
||||||
|
re.id AS scope_id,
|
||||||
|
re.initiative_id,
|
||||||
|
NULL::uuid AS action_id,
|
||||||
|
NULL::uuid AS blocker_id,
|
||||||
|
NULL::uuid AS milestone_id,
|
||||||
|
NULL::uuid AS review_id,
|
||||||
|
re.id AS recurring_element_id,
|
||||||
|
'recurring_due' AS reason_code,
|
||||||
|
'recurring_elements' AS data_source
|
||||||
|
FROM recurring_elements re
|
||||||
|
WHERE re.tenant_id = %s
|
||||||
|
AND re.status = 'active'
|
||||||
|
AND re.next_due_at IS NOT NULL
|
||||||
|
AND re.next_due_at <= NOW()
|
||||||
|
ORDER BY re.next_due_at ASC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id,),
|
||||||
|
)
|
||||||
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
"""Regelbasierte Attention Items — tenant-scoped, erklärbar."""
|
"""Regelbasierte Attention Items — tenant-scoped, erklärbar."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|
@ -277,6 +388,9 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
items.extend(_initiatives_without_next_action(cur, ctx))
|
items.extend(_initiatives_without_next_action(cur, ctx))
|
||||||
items.extend(_stale_initiatives(cur, ctx))
|
items.extend(_stale_initiatives(cur, ctx))
|
||||||
items.extend(_milestones_at_risk(cur, ctx))
|
items.extend(_milestones_at_risk(cur, ctx))
|
||||||
|
items.extend(_overdue_actions(cur, ctx))
|
||||||
|
items.extend(_reviews_due(cur, ctx))
|
||||||
|
items.extend(_recurring_due(cur, ctx))
|
||||||
|
|
||||||
items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
|
items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
|
||||||
return items
|
return items
|
||||||
|
|
@ -337,7 +451,7 @@ def get_next_action_candidates(
|
||||||
'Maßnahme zuweisen' AS recommended_action
|
'Maßnahme zuweisen' AS recommended_action
|
||||||
FROM actions a
|
FROM actions a
|
||||||
WHERE a.tenant_id = %s
|
WHERE a.tenant_id = %s
|
||||||
AND a.status IN ('open', 'in_progress')
|
AND a.status IN ('open', 'ready', 'in_progress')
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM action_assignments aa
|
SELECT 1 FROM action_assignments aa
|
||||||
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||||
|
|
@ -401,7 +515,7 @@ def get_next_action_candidates(
|
||||||
SELECT 1 FROM actions a
|
SELECT 1 FROM actions a
|
||||||
WHERE a.initiative_id = i.id
|
WHERE a.initiative_id = i.id
|
||||||
AND a.tenant_id = i.tenant_id
|
AND a.tenant_id = i.tenant_id
|
||||||
AND a.status IN ('open', 'in_progress', 'blocked')
|
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
||||||
)
|
)
|
||||||
ORDER BY i.updated_at DESC
|
ORDER BY i.updated_at DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,15 @@ from routers import ( # noqa: E402
|
||||||
backlog,
|
backlog,
|
||||||
blockers,
|
blockers,
|
||||||
config,
|
config,
|
||||||
|
decisions,
|
||||||
|
evidence,
|
||||||
features,
|
features,
|
||||||
initiatives,
|
initiatives,
|
||||||
me,
|
me,
|
||||||
milestones,
|
milestones,
|
||||||
prompts,
|
prompts,
|
||||||
|
recurring,
|
||||||
|
reviews,
|
||||||
workspace,
|
workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -78,6 +82,10 @@ app.include_router(actions.router)
|
||||||
app.include_router(blockers.router)
|
app.include_router(blockers.router)
|
||||||
app.include_router(backlog.router)
|
app.include_router(backlog.router)
|
||||||
app.include_router(milestones.router)
|
app.include_router(milestones.router)
|
||||||
|
app.include_router(evidence.router)
|
||||||
|
app.include_router(decisions.router)
|
||||||
|
app.include_router(reviews.router)
|
||||||
|
app.include_router(recurring.router)
|
||||||
app.include_router(actors.router)
|
app.include_router(actors.router)
|
||||||
app.include_router(workspace.router)
|
app.include_router(workspace.router)
|
||||||
|
|
||||||
|
|
|
||||||
82
backend/migrations/008_operating_model_extension_ii.sql
Normal file
82
backend/migrations/008_operating_model_extension_ii.sql
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
-- AP0.9: Operating Model Extension II — evidence, decisions, reviews, recurring_elements, action due_at
|
||||||
|
|
||||||
|
ALTER TABLE actions ADD COLUMN due_at TIMESTAMPTZ NULL;
|
||||||
|
|
||||||
|
ALTER TABLE actions DROP CONSTRAINT actions_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE actions ADD CONSTRAINT actions_status_check
|
||||||
|
CHECK (status IN (
|
||||||
|
'open', 'ready', 'in_progress', 'blocked', 'review_required', 'done', 'discarded'
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE INDEX idx_actions_due_at ON actions(tenant_id, due_at) WHERE due_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE evidence (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||||
|
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
|
||||||
|
milestone_id UUID NULL REFERENCES milestones(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'submitted'
|
||||||
|
CHECK (status IN ('submitted', 'accepted', 'rejected')),
|
||||||
|
submitted_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_evidence_tenant_initiative ON evidence(tenant_id, initiative_id);
|
||||||
|
|
||||||
|
CREATE TABLE decisions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'proposed'
|
||||||
|
CHECK (status IN ('proposed', 'decided', 'superseded')),
|
||||||
|
outcome TEXT NOT NULL DEFAULT '',
|
||||||
|
decided_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_decisions_tenant_initiative ON decisions(tenant_id, initiative_id);
|
||||||
|
|
||||||
|
CREATE TABLE reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||||
|
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
|
||||||
|
milestone_id UUID NULL REFERENCES milestones(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
summary TEXT NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'planned'
|
||||||
|
CHECK (status IN ('planned', 'completed', 'skipped')),
|
||||||
|
due_at TIMESTAMPTZ NULL,
|
||||||
|
reviewed_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_reviews_tenant_initiative ON reviews(tenant_id, initiative_id);
|
||||||
|
CREATE INDEX idx_reviews_due_at ON reviews(tenant_id, due_at) WHERE due_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE recurring_elements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'paused', 'ended')),
|
||||||
|
interval_days INT NULL CHECK (interval_days IS NULL OR interval_days > 0),
|
||||||
|
next_due_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_recurring_elements_tenant_initiative ON recurring_elements(tenant_id, initiative_id);
|
||||||
|
CREATE INDEX idx_recurring_elements_next_due ON recurring_elements(tenant_id, next_due_at)
|
||||||
|
WHERE next_due_at IS NOT NULL;
|
||||||
|
|
@ -81,3 +81,75 @@ register_capability(
|
||||||
default_grants=_MEMBER_MANAGE,
|
default_grants=_MEMBER_MANAGE,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.evidence.read",
|
||||||
|
module="evidence",
|
||||||
|
description="Evidence im aktiven Tenant lesen",
|
||||||
|
default_grants=_MEMBER_READ,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.evidence.manage",
|
||||||
|
module="evidence",
|
||||||
|
description="Evidence anlegen und bearbeiten",
|
||||||
|
default_grants=_MEMBER_MANAGE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.decision.read",
|
||||||
|
module="decision",
|
||||||
|
description="Entscheidungen im aktiven Tenant lesen",
|
||||||
|
default_grants=_MEMBER_READ,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.decision.manage",
|
||||||
|
module="decision",
|
||||||
|
description="Entscheidungen anlegen und bearbeiten",
|
||||||
|
default_grants=_MEMBER_MANAGE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.review.read",
|
||||||
|
module="review",
|
||||||
|
description="Reviews im aktiven Tenant lesen",
|
||||||
|
default_grants=_MEMBER_READ,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.review.manage",
|
||||||
|
module="review",
|
||||||
|
description="Reviews anlegen und bearbeiten",
|
||||||
|
default_grants=_MEMBER_MANAGE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.recurring.read",
|
||||||
|
module="recurring",
|
||||||
|
description="Recurring-Elemente im aktiven Tenant lesen",
|
||||||
|
default_grants=_MEMBER_READ,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.recurring.manage",
|
||||||
|
module="recurring",
|
||||||
|
description="Recurring-Elemente anlegen und bearbeiten",
|
||||||
|
default_grants=_MEMBER_MANAGE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
|
|
@ -17,8 +18,12 @@ router = APIRouter(prefix="/api/actions", tags=["actions"])
|
||||||
class ActionUpdateRequest(BaseModel):
|
class ActionUpdateRequest(BaseModel):
|
||||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: Optional[Literal["open", "in_progress", "blocked", "done", "discarded"]] = None
|
status: Optional[
|
||||||
|
Literal["open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"]
|
||||||
|
] = None
|
||||||
priority: Optional[Literal["low", "normal", "high"]] = None
|
priority: Optional[Literal["low", "normal", "high"]] = None
|
||||||
|
due_at: Optional[str] = None
|
||||||
|
clear_due_at: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ActionAssignmentsRequest(BaseModel):
|
class ActionAssignmentsRequest(BaseModel):
|
||||||
|
|
@ -52,6 +57,12 @@ def update_action(
|
||||||
body: ActionUpdateRequest,
|
body: ActionUpdateRequest,
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||||
):
|
):
|
||||||
|
due_at = None
|
||||||
|
if body.due_at is not None:
|
||||||
|
try:
|
||||||
|
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
|
||||||
try:
|
try:
|
||||||
item = action_service.update_action(
|
item = action_service.update_action(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
|
|
@ -61,6 +72,8 @@ def update_action(
|
||||||
description=body.description,
|
description=body.description,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
|
due_at=due_at,
|
||||||
|
clear_due_at=body.clear_due_at,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
67
backend/routers/decisions.py
Normal file
67
backend/routers/decisions.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Decision API — AP0.9c."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from capabilities import require_capability
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from services import decisions as decision_service
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/decisions", tags=["decisions"])
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionUpdateRequest(BaseModel):
|
||||||
|
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[Literal["proposed", "decided", "superseded"]] = None
|
||||||
|
outcome: Optional[str] = None
|
||||||
|
decided_by_actor_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{decision_id}")
|
||||||
|
def get_decision(
|
||||||
|
decision_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.decision.read")),
|
||||||
|
):
|
||||||
|
item = decision_service.get_decision(tenant_id=ctx.tenant_id, decision_id=decision_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{decision_id}")
|
||||||
|
def update_decision(
|
||||||
|
decision_id: str,
|
||||||
|
body: DecisionUpdateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
item = decision_service.update_decision(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
decision_id=decision_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
status=body.status,
|
||||||
|
outcome=body.outcome,
|
||||||
|
decided_by_actor_id=body.decided_by_actor_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{decision_id}", status_code=204)
|
||||||
|
def delete_decision(
|
||||||
|
decision_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
|
||||||
|
):
|
||||||
|
if not decision_service.delete_decision(
|
||||||
|
tenant_id=ctx.tenant_id, decision_id=decision_id, user_id=ctx.user_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
|
||||||
71
backend/routers/evidence.py
Normal file
71
backend/routers/evidence.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Evidence API — AP0.9b."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from capabilities import require_capability
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from services import evidence as evidence_service
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/evidence", tags=["evidence"])
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceUpdateRequest(BaseModel):
|
||||||
|
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[Literal["submitted", "accepted", "rejected"]] = None
|
||||||
|
action_id: Optional[str] = None
|
||||||
|
milestone_id: Optional[str] = None
|
||||||
|
clear_action_id: bool = False
|
||||||
|
clear_milestone_id: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{evidence_id}")
|
||||||
|
def get_evidence(
|
||||||
|
evidence_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.evidence.read")),
|
||||||
|
):
|
||||||
|
item = evidence_service.get_evidence(tenant_id=ctx.tenant_id, evidence_id=evidence_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{evidence_id}")
|
||||||
|
def update_evidence(
|
||||||
|
evidence_id: str,
|
||||||
|
body: EvidenceUpdateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
item = evidence_service.update_evidence(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
evidence_id=evidence_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
status=body.status,
|
||||||
|
action_id=body.action_id,
|
||||||
|
milestone_id=body.milestone_id,
|
||||||
|
clear_action_id=body.clear_action_id,
|
||||||
|
clear_milestone_id=body.clear_milestone_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{evidence_id}", status_code=204)
|
||||||
|
def delete_evidence(
|
||||||
|
evidence_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
|
||||||
|
):
|
||||||
|
if not evidence_service.delete_evidence(
|
||||||
|
tenant_id=ctx.tenant_id, evidence_id=evidence_id, user_id=ctx.user_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
|
|
@ -10,8 +11,12 @@ from pydantic import BaseModel, Field
|
||||||
from services import actions as action_service
|
from services import actions as action_service
|
||||||
from services import backlog as backlog_service
|
from services import backlog as backlog_service
|
||||||
from services import blockers as blocker_service
|
from services import blockers as blocker_service
|
||||||
|
from services import decisions as decision_service
|
||||||
|
from services import evidence as evidence_service
|
||||||
from services import initiatives as initiative_service
|
from services import initiatives as initiative_service
|
||||||
from services import milestones as milestone_service
|
from services import milestones as milestone_service
|
||||||
|
from services import recurring as recurring_service
|
||||||
|
from services import reviews as review_service
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
|
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
|
||||||
|
|
@ -36,8 +41,11 @@ class InitiativeUpdateRequest(BaseModel):
|
||||||
class ActionCreateRequest(BaseModel):
|
class ActionCreateRequest(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
description: str = ""
|
description: str = ""
|
||||||
status: Literal["open", "in_progress", "blocked", "done", "discarded"] = "open"
|
status: Literal[
|
||||||
|
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||||
|
] = "open"
|
||||||
priority: Literal["low", "normal", "high"] = "normal"
|
priority: Literal["low", "normal", "high"] = "normal"
|
||||||
|
due_at: Optional[str] = None
|
||||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -63,6 +71,47 @@ class MilestoneCreateRequest(BaseModel):
|
||||||
target_date: Optional[str] = None
|
target_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceCreateRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str = ""
|
||||||
|
status: Literal["submitted", "accepted", "rejected"] = "submitted"
|
||||||
|
action_id: Optional[str] = None
|
||||||
|
milestone_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionCreateRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str = ""
|
||||||
|
status: Literal["proposed", "decided", "superseded"] = "proposed"
|
||||||
|
outcome: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewCreateRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
summary: str = ""
|
||||||
|
status: Literal["planned", "completed", "skipped"] = "planned"
|
||||||
|
due_at: Optional[str] = None
|
||||||
|
action_id: Optional[str] = None
|
||||||
|
milestone_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringCreateRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str = ""
|
||||||
|
status: Literal["active", "paused", "ended"] = "active"
|
||||||
|
interval_days: Optional[int] = Field(default=None, gt=0)
|
||||||
|
next_due_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Ungültiges {field_name}") from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_initiatives(
|
def list_initiatives(
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||||
|
|
@ -163,6 +212,7 @@ def create_initiative_action(
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
due_at = _parse_optional_datetime(body.due_at, "due_at")
|
||||||
return action_service.create_action(
|
return action_service.create_action(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
|
|
@ -170,6 +220,7 @@ def create_initiative_action(
|
||||||
description=body.description,
|
description=body.description,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
|
due_at=due_at,
|
||||||
assigned_actor_ids=body.assigned_actor_ids,
|
assigned_actor_ids=body.assigned_actor_ids,
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
)
|
)
|
||||||
|
|
@ -302,3 +353,164 @@ def create_initiative_milestone(
|
||||||
if detail == "Initiative nicht gefunden":
|
if detail == "Initiative nicht gefunden":
|
||||||
raise HTTPException(status_code=404, detail=detail) from exc
|
raise HTTPException(status_code=404, detail=detail) from exc
|
||||||
raise HTTPException(status_code=400, detail=detail) from exc
|
raise HTTPException(status_code=400, detail=detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{initiative_id}/evidence")
|
||||||
|
def list_initiative_evidence(
|
||||||
|
initiative_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.evidence.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return evidence_service.list_evidence_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == "Initiative nicht gefunden":
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{initiative_id}/evidence", status_code=201)
|
||||||
|
def create_initiative_evidence(
|
||||||
|
initiative_id: str,
|
||||||
|
body: EvidenceCreateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return evidence_service.create_evidence(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
status=body.status,
|
||||||
|
action_id=body.action_id,
|
||||||
|
milestone_id=body.milestone_id,
|
||||||
|
submitted_by_actor_id=ctx.actor_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "Initiative nicht gefunden":
|
||||||
|
raise HTTPException(status_code=404, detail=detail) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{initiative_id}/decisions")
|
||||||
|
def list_initiative_decisions(
|
||||||
|
initiative_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.decision.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return decision_service.list_decisions_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == "Initiative nicht gefunden":
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{initiative_id}/decisions", status_code=201)
|
||||||
|
def create_initiative_decision(
|
||||||
|
initiative_id: str,
|
||||||
|
body: DecisionCreateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return decision_service.create_decision(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
status=body.status,
|
||||||
|
outcome=body.outcome,
|
||||||
|
decided_by_actor_id=ctx.actor_id if body.status == "decided" else None,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "Initiative nicht gefunden":
|
||||||
|
raise HTTPException(status_code=404, detail=detail) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{initiative_id}/reviews")
|
||||||
|
def list_initiative_reviews(
|
||||||
|
initiative_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.review.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return review_service.list_reviews_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == "Initiative nicht gefunden":
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{initiative_id}/reviews", status_code=201)
|
||||||
|
def create_initiative_review(
|
||||||
|
initiative_id: str,
|
||||||
|
body: ReviewCreateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
|
||||||
|
):
|
||||||
|
due_at = _parse_optional_datetime(body.due_at, "due_at")
|
||||||
|
try:
|
||||||
|
return review_service.create_review(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
title=body.title,
|
||||||
|
summary=body.summary,
|
||||||
|
status=body.status,
|
||||||
|
due_at=due_at,
|
||||||
|
action_id=body.action_id,
|
||||||
|
milestone_id=body.milestone_id,
|
||||||
|
reviewed_by_actor_id=ctx.actor_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail == "Initiative nicht gefunden":
|
||||||
|
raise HTTPException(status_code=404, detail=detail) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{initiative_id}/recurring")
|
||||||
|
def list_initiative_recurring(
|
||||||
|
initiative_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return recurring_service.list_recurring_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == "Initiative nicht gefunden":
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{initiative_id}/recurring", status_code=201)
|
||||||
|
def create_initiative_recurring(
|
||||||
|
initiative_id: str,
|
||||||
|
body: RecurringCreateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||||
|
):
|
||||||
|
next_due_at = _parse_optional_datetime(body.next_due_at, "next_due_at")
|
||||||
|
try:
|
||||||
|
return recurring_service.create_recurring_element(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
status=body.status,
|
||||||
|
interval_days=body.interval_days,
|
||||||
|
next_due_at=next_due_at,
|
||||||
|
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
|
||||||
|
|
|
||||||
84
backend/routers/recurring.py
Normal file
84
backend/routers/recurring.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""RecurringElement API — AP0.9e."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from capabilities import require_capability
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from services import recurring as recurring_service
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/recurring", tags=["recurring"])
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringUpdateRequest(BaseModel):
|
||||||
|
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[Literal["active", "paused", "ended"]] = None
|
||||||
|
interval_days: Optional[int] = Field(default=None, gt=0)
|
||||||
|
clear_interval_days: bool = False
|
||||||
|
next_due_at: Optional[str] = None
|
||||||
|
clear_next_due_at: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiges next_due_at") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{recurring_id}")
|
||||||
|
def get_recurring_element(
|
||||||
|
recurring_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
|
||||||
|
):
|
||||||
|
item = recurring_service.get_recurring_element(
|
||||||
|
tenant_id=ctx.tenant_id, recurring_id=recurring_id
|
||||||
|
)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{recurring_id}")
|
||||||
|
def update_recurring_element(
|
||||||
|
recurring_id: str,
|
||||||
|
body: RecurringUpdateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||||
|
):
|
||||||
|
next_due_at = _parse_datetime(body.next_due_at) if body.next_due_at is not None else None
|
||||||
|
try:
|
||||||
|
item = recurring_service.update_recurring_element(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
recurring_id=recurring_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
status=body.status,
|
||||||
|
interval_days=body.interval_days,
|
||||||
|
clear_interval_days=body.clear_interval_days,
|
||||||
|
next_due_at=next_due_at,
|
||||||
|
clear_next_due_at=body.clear_next_due_at,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{recurring_id}", status_code=204)
|
||||||
|
def delete_recurring_element(
|
||||||
|
recurring_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||||
|
):
|
||||||
|
if not recurring_service.delete_recurring_element(
|
||||||
|
tenant_id=ctx.tenant_id, recurring_id=recurring_id, user_id=ctx.user_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
|
||||||
86
backend/routers/reviews.py
Normal file
86
backend/routers/reviews.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Review API — AP0.9d."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from capabilities import require_capability
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from services import reviews as review_service
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewUpdateRequest(BaseModel):
|
||||||
|
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
|
summary: Optional[str] = None
|
||||||
|
status: Optional[Literal["planned", "completed", "skipped"]] = None
|
||||||
|
due_at: Optional[str] = None
|
||||||
|
clear_due_at: bool = False
|
||||||
|
action_id: Optional[str] = None
|
||||||
|
milestone_id: Optional[str] = None
|
||||||
|
clear_action_id: bool = False
|
||||||
|
clear_milestone_id: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_due_at(value: Optional[str]) -> Optional[datetime]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{review_id}")
|
||||||
|
def get_review(
|
||||||
|
review_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.review.read")),
|
||||||
|
):
|
||||||
|
item = review_service.get_review(tenant_id=ctx.tenant_id, review_id=review_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Review nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{review_id}")
|
||||||
|
def update_review(
|
||||||
|
review_id: str,
|
||||||
|
body: ReviewUpdateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
|
||||||
|
):
|
||||||
|
due_at = _parse_due_at(body.due_at) if body.due_at is not None else None
|
||||||
|
try:
|
||||||
|
item = review_service.update_review(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
review_id=review_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
title=body.title,
|
||||||
|
summary=body.summary,
|
||||||
|
status=body.status,
|
||||||
|
due_at=due_at,
|
||||||
|
clear_due_at=body.clear_due_at,
|
||||||
|
action_id=body.action_id,
|
||||||
|
milestone_id=body.milestone_id,
|
||||||
|
clear_action_id=body.clear_action_id,
|
||||||
|
clear_milestone_id=body.clear_milestone_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Review nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{review_id}", status_code=204)
|
||||||
|
def delete_review(
|
||||||
|
review_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
|
||||||
|
):
|
||||||
|
if not review_service.delete_review(
|
||||||
|
tenant_id=ctx.tenant_id, review_id=review_id, user_id=ctx.user_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Review nicht gefunden")
|
||||||
|
|
@ -10,10 +10,21 @@ from db import get_connection
|
||||||
from services.audit import log_audit
|
from services.audit import log_audit
|
||||||
from services.initiatives import PRIORITIES, get_initiative
|
from services.initiatives import PRIORITIES, get_initiative
|
||||||
|
|
||||||
ActionStatus = Literal["open", "in_progress", "blocked", "done", "discarded"]
|
ActionStatus = Literal[
|
||||||
|
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||||
|
]
|
||||||
|
|
||||||
ACTION_STATUSES = frozenset({"open", "in_progress", "blocked", "done", "discarded"})
|
ACTION_STATUSES = frozenset(
|
||||||
OPEN_ACTION_STATUSES = frozenset({"open", "in_progress", "blocked"})
|
{"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"}
|
||||||
|
)
|
||||||
|
OPEN_ACTION_STATUSES = frozenset(
|
||||||
|
{"open", "ready", "in_progress", "blocked", "review_required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
_ACTION_COLUMNS = """
|
||||||
|
id, tenant_id, initiative_id, title, description,
|
||||||
|
status, priority, due_at, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
@ -25,6 +36,8 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
result["created_at"] = result["created_at"].isoformat()
|
result["created_at"] = result["created_at"].isoformat()
|
||||||
if result.get("updated_at"):
|
if result.get("updated_at"):
|
||||||
result["updated_at"] = result["updated_at"].isoformat()
|
result["updated_at"] = result["updated_at"].isoformat()
|
||||||
|
if result.get("due_at"):
|
||||||
|
result["due_at"] = result["due_at"].isoformat()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -94,6 +107,7 @@ def create_action(
|
||||||
description: str = "",
|
description: str = "",
|
||||||
status: ActionStatus = "open",
|
status: ActionStatus = "open",
|
||||||
priority: str = "normal",
|
priority: str = "normal",
|
||||||
|
due_at: Optional[Any] = None,
|
||||||
assigned_actor_ids: Optional[list[str]] = None,
|
assigned_actor_ids: Optional[list[str]] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -114,15 +128,14 @@ def create_action(
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
f"""
|
||||||
INSERT INTO actions (
|
INSERT INTO actions (
|
||||||
tenant_id, initiative_id, title, description, status, priority
|
tenant_id, initiative_id, title, description, status, priority, due_at
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id, tenant_id, initiative_id, title, description,
|
RETURNING {_ACTION_COLUMNS}
|
||||||
status, priority, created_at, updated_at
|
|
||||||
""",
|
""",
|
||||||
(tenant_id, initiative_id, title, description, status, priority),
|
(tenant_id, initiative_id, title, description, status, priority, due_at),
|
||||||
)
|
)
|
||||||
row = _serialize_row(dict(cur.fetchone()))
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
for actor_id in assigned_actor_ids:
|
for actor_id in assigned_actor_ids:
|
||||||
|
|
@ -162,9 +175,8 @@ def list_actions_for_initiative(
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT id, tenant_id, initiative_id, title, description,
|
SELECT {_ACTION_COLUMNS}
|
||||||
status, priority, created_at, updated_at
|
|
||||||
FROM actions
|
FROM actions
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
ORDER BY updated_at DESC, title
|
ORDER BY updated_at DESC, title
|
||||||
|
|
@ -182,9 +194,8 @@ def get_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT id, tenant_id, initiative_id, title, description,
|
SELECT {_ACTION_COLUMNS}
|
||||||
status, priority, created_at, updated_at
|
|
||||||
FROM actions
|
FROM actions
|
||||||
WHERE id = %s AND tenant_id = %s
|
WHERE id = %s AND tenant_id = %s
|
||||||
""",
|
""",
|
||||||
|
|
@ -208,6 +219,8 @@ def update_action(
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
status: Optional[ActionStatus] = None,
|
status: Optional[ActionStatus] = None,
|
||||||
priority: Optional[str] = None,
|
priority: Optional[str] = None,
|
||||||
|
due_at: Optional[Any] = None,
|
||||||
|
clear_due_at: bool = False,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|
@ -234,6 +247,11 @@ def update_action(
|
||||||
_validate_priority(priority)
|
_validate_priority(priority)
|
||||||
updates.append("priority = %s")
|
updates.append("priority = %s")
|
||||||
params.append(priority)
|
params.append(priority)
|
||||||
|
if clear_due_at:
|
||||||
|
updates.append("due_at = NULL")
|
||||||
|
elif due_at is not None:
|
||||||
|
updates.append("due_at = %s")
|
||||||
|
params.append(due_at)
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return existing
|
return existing
|
||||||
|
|
@ -249,15 +267,13 @@ def update_action(
|
||||||
UPDATE actions
|
UPDATE actions
|
||||||
SET {", ".join(updates)}
|
SET {", ".join(updates)}
|
||||||
WHERE id = %s AND tenant_id = %s
|
WHERE id = %s AND tenant_id = %s
|
||||||
RETURNING id, tenant_id, initiative_id, title, description,
|
RETURNING {_ACTION_COLUMNS}
|
||||||
status, priority, created_at, updated_at
|
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
result = _serialize_row(dict(row))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -374,7 +390,7 @@ def list_open_actions_for_actor(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
|
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
|
||||||
a.status, a.priority, a.created_at, a.updated_at,
|
a.status, a.priority, a.due_at, a.created_at, a.updated_at,
|
||||||
i.title AS initiative_title
|
i.title AS initiative_title
|
||||||
FROM actions a
|
FROM actions a
|
||||||
JOIN action_assignments aa ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
JOIN action_assignments aa ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||||
|
|
|
||||||
265
backend/services/decisions.py
Normal file
265
backend/services/decisions.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""Decision service — tenant-scoped CRUD (AP0.9c)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.audit import log_audit
|
||||||
|
from services.initiatives import get_initiative
|
||||||
|
|
||||||
|
DecisionStatus = Literal["proposed", "decided", "superseded"]
|
||||||
|
|
||||||
|
DECISION_STATUSES = frozenset({"proposed", "decided", "superseded"})
|
||||||
|
|
||||||
|
_DECISION_COLUMNS = """
|
||||||
|
id, tenant_id, initiative_id, title, description, status, outcome,
|
||||||
|
decided_by_actor_id, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(row)
|
||||||
|
for key in ("id", "tenant_id", "initiative_id", "decided_by_actor_id"):
|
||||||
|
if result.get(key):
|
||||||
|
result[key] = str(result[key])
|
||||||
|
for ts_key in ("created_at", "updated_at"):
|
||||||
|
if result.get(ts_key):
|
||||||
|
result[ts_key] = result[ts_key].isoformat()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_status(status: str) -> None:
|
||||||
|
if status not in DECISION_STATUSES:
|
||||||
|
raise ValueError(f"Ungültiger Decision-Status: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
|
||||||
|
(actor_id, tenant_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_decision(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
title: str,
|
||||||
|
description: str = "",
|
||||||
|
status: DecisionStatus = "proposed",
|
||||||
|
outcome: str = "",
|
||||||
|
decided_by_actor_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
_validate_status(status)
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
if decided_by_actor_id and not _actor_in_tenant(
|
||||||
|
tenant_id=tenant_id, actor_id=decided_by_actor_id
|
||||||
|
):
|
||||||
|
raise ValueError("Actor gehört nicht zum Tenant")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO decisions (
|
||||||
|
tenant_id, initiative_id, title, description, status, outcome,
|
||||||
|
decided_by_actor_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING {_DECISION_COLUMNS}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
tenant_id,
|
||||||
|
initiative_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
outcome,
|
||||||
|
decided_by_actor_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"decision.created",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"decision_id": row["id"], "initiative_id": initiative_id},
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def list_decisions_for_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_DECISION_COLUMNS}
|
||||||
|
FROM decisions
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC, title
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_decision(*, tenant_id: str, decision_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_DECISION_COLUMNS}
|
||||||
|
FROM decisions
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(decision_id, tenant_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _serialize_row(dict(row)) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_decision(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
decision_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
status: Optional[DecisionStatus] = None,
|
||||||
|
outcome: Optional[str] = None,
|
||||||
|
decided_by_actor_id: Optional[str] = None,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
existing = get_decision(tenant_id=tenant_id, decision_id=decision_id)
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_status = existing["status"]
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
updates.append("title = %s")
|
||||||
|
params.append(title)
|
||||||
|
if description is not None:
|
||||||
|
updates.append("description = %s")
|
||||||
|
params.append(description)
|
||||||
|
if status is not None:
|
||||||
|
_validate_status(status)
|
||||||
|
updates.append("status = %s")
|
||||||
|
params.append(status)
|
||||||
|
if outcome is not None:
|
||||||
|
updates.append("outcome = %s")
|
||||||
|
params.append(outcome)
|
||||||
|
if decided_by_actor_id is not None:
|
||||||
|
if decided_by_actor_id and not _actor_in_tenant(
|
||||||
|
tenant_id=tenant_id, actor_id=decided_by_actor_id
|
||||||
|
):
|
||||||
|
raise ValueError("Actor gehört nicht zum Tenant")
|
||||||
|
updates.append("decided_by_actor_id = %s")
|
||||||
|
params.append(decided_by_actor_id or None)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
updates.append("updated_at = NOW()")
|
||||||
|
params.extend([decision_id, tenant_id])
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE decisions
|
||||||
|
SET {", ".join(updates)}
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
RETURNING {_DECISION_COLUMNS}
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = _serialize_row(dict(row))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"decision.updated",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"decision_id": decision_id},
|
||||||
|
)
|
||||||
|
if status is not None and status != old_status:
|
||||||
|
log_audit(
|
||||||
|
"decision.status_changed",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={
|
||||||
|
"decision_id": decision_id,
|
||||||
|
"from_status": old_status,
|
||||||
|
"to_status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def delete_decision(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
decision_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM decisions WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||||
|
(decision_id, tenant_id),
|
||||||
|
)
|
||||||
|
deleted = cur.fetchone() is not None
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
log_audit(
|
||||||
|
"decision.deleted",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"decision_id": decision_id},
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
318
backend/services/evidence.py
Normal file
318
backend/services/evidence.py
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
"""Evidence service — tenant-scoped CRUD (AP0.9b)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.audit import log_audit
|
||||||
|
from services.initiatives import get_initiative
|
||||||
|
|
||||||
|
EvidenceStatus = Literal["submitted", "accepted", "rejected"]
|
||||||
|
|
||||||
|
EVIDENCE_STATUSES = frozenset({"submitted", "accepted", "rejected"})
|
||||||
|
|
||||||
|
_EVIDENCE_COLUMNS = """
|
||||||
|
id, tenant_id, initiative_id, action_id, milestone_id, title, description,
|
||||||
|
status, submitted_by_actor_id, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(row)
|
||||||
|
for key in (
|
||||||
|
"id",
|
||||||
|
"tenant_id",
|
||||||
|
"initiative_id",
|
||||||
|
"action_id",
|
||||||
|
"milestone_id",
|
||||||
|
"submitted_by_actor_id",
|
||||||
|
):
|
||||||
|
if result.get(key):
|
||||||
|
result[key] = str(result[key])
|
||||||
|
for ts_key in ("created_at", "updated_at"):
|
||||||
|
if result.get(ts_key):
|
||||||
|
result[ts_key] = result[ts_key].isoformat()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_status(status: str) -> None:
|
||||||
|
if status not in EVIDENCE_STATUSES:
|
||||||
|
raise ValueError(f"Ungültiger Evidence-Status: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
|
||||||
|
(actor_id, tenant_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM actions
|
||||||
|
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||||
|
""",
|
||||||
|
(action_id, tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _milestone_in_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str, milestone_id: str
|
||||||
|
) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM milestones
|
||||||
|
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||||
|
""",
|
||||||
|
(milestone_id, tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_evidence(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
title: str,
|
||||||
|
description: str = "",
|
||||||
|
status: EvidenceStatus = "submitted",
|
||||||
|
action_id: Optional[str] = None,
|
||||||
|
milestone_id: Optional[str] = None,
|
||||||
|
submitted_by_actor_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
_validate_status(status)
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
if action_id and not _action_in_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
|
||||||
|
):
|
||||||
|
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||||
|
if milestone_id and not _milestone_in_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id, milestone_id=milestone_id
|
||||||
|
):
|
||||||
|
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
|
||||||
|
if submitted_by_actor_id and not _actor_in_tenant(
|
||||||
|
tenant_id=tenant_id, actor_id=submitted_by_actor_id
|
||||||
|
):
|
||||||
|
raise ValueError("Actor gehört nicht zum Tenant")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO evidence (
|
||||||
|
tenant_id, initiative_id, action_id, milestone_id,
|
||||||
|
title, description, status, submitted_by_actor_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING {_EVIDENCE_COLUMNS}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
tenant_id,
|
||||||
|
initiative_id,
|
||||||
|
action_id,
|
||||||
|
milestone_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
submitted_by_actor_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"evidence.created",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"evidence_id": row["id"], "initiative_id": initiative_id},
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def list_evidence_for_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_EVIDENCE_COLUMNS}
|
||||||
|
FROM evidence
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC, title
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_evidence(*, tenant_id: str, evidence_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_EVIDENCE_COLUMNS}
|
||||||
|
FROM evidence
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(evidence_id, tenant_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _serialize_row(dict(row)) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_evidence(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
evidence_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
status: Optional[EvidenceStatus] = None,
|
||||||
|
action_id: Optional[str] = None,
|
||||||
|
milestone_id: Optional[str] = None,
|
||||||
|
clear_action_id: bool = False,
|
||||||
|
clear_milestone_id: bool = False,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
existing = get_evidence(tenant_id=tenant_id, evidence_id=evidence_id)
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
updates.append("title = %s")
|
||||||
|
params.append(title)
|
||||||
|
if description is not None:
|
||||||
|
updates.append("description = %s")
|
||||||
|
params.append(description)
|
||||||
|
if status is not None:
|
||||||
|
_validate_status(status)
|
||||||
|
updates.append("status = %s")
|
||||||
|
params.append(status)
|
||||||
|
if clear_action_id:
|
||||||
|
updates.append("action_id = NULL")
|
||||||
|
elif action_id is not None:
|
||||||
|
if not _action_in_initiative(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=existing["initiative_id"],
|
||||||
|
action_id=action_id,
|
||||||
|
):
|
||||||
|
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||||
|
updates.append("action_id = %s")
|
||||||
|
params.append(action_id)
|
||||||
|
if clear_milestone_id:
|
||||||
|
updates.append("milestone_id = NULL")
|
||||||
|
elif milestone_id is not None:
|
||||||
|
if not _milestone_in_initiative(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=existing["initiative_id"],
|
||||||
|
milestone_id=milestone_id,
|
||||||
|
):
|
||||||
|
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
|
||||||
|
updates.append("milestone_id = %s")
|
||||||
|
params.append(milestone_id)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
updates.append("updated_at = NOW()")
|
||||||
|
params.extend([evidence_id, tenant_id])
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE evidence
|
||||||
|
SET {", ".join(updates)}
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
RETURNING {_EVIDENCE_COLUMNS}
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = _serialize_row(dict(row))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"evidence.updated",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"evidence_id": evidence_id},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def delete_evidence(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
evidence_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM evidence WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||||
|
(evidence_id, tenant_id),
|
||||||
|
)
|
||||||
|
deleted = cur.fetchone() is not None
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
log_audit(
|
||||||
|
"evidence.deleted",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"evidence_id": evidence_id},
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
260
backend/services/recurring.py
Normal file
260
backend/services/recurring.py
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
"""RecurringElement service — tenant-scoped CRUD (AP0.9e)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.audit import log_audit
|
||||||
|
from services.initiatives import get_initiative
|
||||||
|
|
||||||
|
RecurringStatus = Literal["active", "paused", "ended"]
|
||||||
|
|
||||||
|
RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
|
||||||
|
|
||||||
|
_RECURRING_COLUMNS = """
|
||||||
|
id, tenant_id, initiative_id, title, description, status,
|
||||||
|
interval_days, next_due_at, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(row)
|
||||||
|
for key in ("id", "tenant_id", "initiative_id"):
|
||||||
|
if result.get(key):
|
||||||
|
result[key] = str(result[key])
|
||||||
|
for ts_key in ("created_at", "updated_at", "next_due_at"):
|
||||||
|
if result.get(ts_key):
|
||||||
|
result[ts_key] = result[ts_key].isoformat()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_status(status: str) -> None:
|
||||||
|
if status not in RECURRING_STATUSES:
|
||||||
|
raise ValueError(f"Ungültiger Recurring-Status: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_recurring_element(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
title: str,
|
||||||
|
description: str = "",
|
||||||
|
status: RecurringStatus = "active",
|
||||||
|
interval_days: Optional[int] = None,
|
||||||
|
next_due_at: Optional[datetime] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
_validate_status(status)
|
||||||
|
if interval_days is not None and interval_days <= 0:
|
||||||
|
raise ValueError("interval_days muss positiv sein")
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO recurring_elements (
|
||||||
|
tenant_id, initiative_id, title, description, status,
|
||||||
|
interval_days, next_due_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING {_RECURRING_COLUMNS}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
tenant_id,
|
||||||
|
initiative_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
interval_days,
|
||||||
|
next_due_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"recurring.created",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"recurring_id": row["id"], "initiative_id": initiative_id},
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def list_recurring_for_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_RECURRING_COLUMNS}
|
||||||
|
FROM recurring_elements
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC, title
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_recurring_element(
|
||||||
|
*, tenant_id: str, recurring_id: str
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_RECURRING_COLUMNS}
|
||||||
|
FROM recurring_elements
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(recurring_id, tenant_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _serialize_row(dict(row)) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_recurring_element(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
recurring_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
status: Optional[RecurringStatus] = None,
|
||||||
|
interval_days: Optional[int] = None,
|
||||||
|
clear_interval_days: bool = False,
|
||||||
|
next_due_at: Optional[datetime] = None,
|
||||||
|
clear_next_due_at: bool = False,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_status = existing["status"]
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
updates.append("title = %s")
|
||||||
|
params.append(title)
|
||||||
|
if description is not None:
|
||||||
|
updates.append("description = %s")
|
||||||
|
params.append(description)
|
||||||
|
if status is not None:
|
||||||
|
_validate_status(status)
|
||||||
|
updates.append("status = %s")
|
||||||
|
params.append(status)
|
||||||
|
if clear_interval_days:
|
||||||
|
updates.append("interval_days = NULL")
|
||||||
|
elif interval_days is not None:
|
||||||
|
if interval_days <= 0:
|
||||||
|
raise ValueError("interval_days muss positiv sein")
|
||||||
|
updates.append("interval_days = %s")
|
||||||
|
params.append(interval_days)
|
||||||
|
if clear_next_due_at:
|
||||||
|
updates.append("next_due_at = NULL")
|
||||||
|
elif next_due_at is not None:
|
||||||
|
updates.append("next_due_at = %s")
|
||||||
|
params.append(next_due_at)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
updates.append("updated_at = NOW()")
|
||||||
|
params.extend([recurring_id, tenant_id])
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE recurring_elements
|
||||||
|
SET {", ".join(updates)}
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
RETURNING {_RECURRING_COLUMNS}
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = _serialize_row(dict(row))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"recurring.updated",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"recurring_id": recurring_id},
|
||||||
|
)
|
||||||
|
if status is not None and status != old_status:
|
||||||
|
log_audit(
|
||||||
|
"recurring.status_changed",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={
|
||||||
|
"recurring_id": recurring_id,
|
||||||
|
"from_status": old_status,
|
||||||
|
"to_status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def delete_recurring_element(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
recurring_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM recurring_elements
|
||||||
|
WHERE id = %s AND tenant_id = %s RETURNING id
|
||||||
|
""",
|
||||||
|
(recurring_id, tenant_id),
|
||||||
|
)
|
||||||
|
deleted = cur.fetchone() is not None
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
log_audit(
|
||||||
|
"recurring.deleted",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"recurring_id": recurring_id},
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
340
backend/services/reviews.py
Normal file
340
backend/services/reviews.py
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
"""Review service — tenant-scoped CRUD (AP0.9d)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.audit import log_audit
|
||||||
|
from services.initiatives import get_initiative
|
||||||
|
|
||||||
|
ReviewStatus = Literal["planned", "completed", "skipped"]
|
||||||
|
|
||||||
|
REVIEW_STATUSES = frozenset({"planned", "completed", "skipped"})
|
||||||
|
|
||||||
|
_REVIEW_COLUMNS = """
|
||||||
|
id, tenant_id, initiative_id, action_id, milestone_id, title, summary,
|
||||||
|
status, due_at, reviewed_by_actor_id, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(row)
|
||||||
|
for key in (
|
||||||
|
"id",
|
||||||
|
"tenant_id",
|
||||||
|
"initiative_id",
|
||||||
|
"action_id",
|
||||||
|
"milestone_id",
|
||||||
|
"reviewed_by_actor_id",
|
||||||
|
):
|
||||||
|
if result.get(key):
|
||||||
|
result[key] = str(result[key])
|
||||||
|
for ts_key in ("created_at", "updated_at", "due_at"):
|
||||||
|
if result.get(ts_key):
|
||||||
|
result[ts_key] = result[ts_key].isoformat()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_status(status: str) -> None:
|
||||||
|
if status not in REVIEW_STATUSES:
|
||||||
|
raise ValueError(f"Ungültiger Review-Status: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
|
||||||
|
(actor_id, tenant_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM actions
|
||||||
|
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||||
|
""",
|
||||||
|
(action_id, tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _milestone_in_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str, milestone_id: str
|
||||||
|
) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM milestones
|
||||||
|
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
|
||||||
|
""",
|
||||||
|
(milestone_id, tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_review(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
title: str,
|
||||||
|
summary: str = "",
|
||||||
|
status: ReviewStatus = "planned",
|
||||||
|
due_at: Optional[datetime] = None,
|
||||||
|
action_id: Optional[str] = None,
|
||||||
|
milestone_id: Optional[str] = None,
|
||||||
|
reviewed_by_actor_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
_validate_status(status)
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
if action_id and not _action_in_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
|
||||||
|
):
|
||||||
|
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||||
|
if milestone_id and not _milestone_in_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id, milestone_id=milestone_id
|
||||||
|
):
|
||||||
|
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
|
||||||
|
if reviewed_by_actor_id and not _actor_in_tenant(
|
||||||
|
tenant_id=tenant_id, actor_id=reviewed_by_actor_id
|
||||||
|
):
|
||||||
|
raise ValueError("Actor gehört nicht zum Tenant")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO reviews (
|
||||||
|
tenant_id, initiative_id, action_id, milestone_id,
|
||||||
|
title, summary, status, due_at, reviewed_by_actor_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING {_REVIEW_COLUMNS}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
tenant_id,
|
||||||
|
initiative_id,
|
||||||
|
action_id,
|
||||||
|
milestone_id,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
status,
|
||||||
|
due_at,
|
||||||
|
reviewed_by_actor_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"review.created",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"review_id": row["id"], "initiative_id": initiative_id},
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def list_reviews_for_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_REVIEW_COLUMNS}
|
||||||
|
FROM reviews
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY updated_at DESC, title
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_review(*, tenant_id: str, review_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_REVIEW_COLUMNS}
|
||||||
|
FROM reviews
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(review_id, tenant_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _serialize_row(dict(row)) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_review(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
review_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
summary: Optional[str] = None,
|
||||||
|
status: Optional[ReviewStatus] = None,
|
||||||
|
due_at: Optional[datetime] = None,
|
||||||
|
clear_due_at: bool = False,
|
||||||
|
action_id: Optional[str] = None,
|
||||||
|
milestone_id: Optional[str] = None,
|
||||||
|
clear_action_id: bool = False,
|
||||||
|
clear_milestone_id: bool = False,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
existing = get_review(tenant_id=tenant_id, review_id=review_id)
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_status = existing["status"]
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
updates.append("title = %s")
|
||||||
|
params.append(title)
|
||||||
|
if summary is not None:
|
||||||
|
updates.append("summary = %s")
|
||||||
|
params.append(summary)
|
||||||
|
if status is not None:
|
||||||
|
_validate_status(status)
|
||||||
|
updates.append("status = %s")
|
||||||
|
params.append(status)
|
||||||
|
if clear_due_at:
|
||||||
|
updates.append("due_at = NULL")
|
||||||
|
elif due_at is not None:
|
||||||
|
updates.append("due_at = %s")
|
||||||
|
params.append(due_at)
|
||||||
|
if clear_action_id:
|
||||||
|
updates.append("action_id = NULL")
|
||||||
|
elif action_id is not None:
|
||||||
|
if not _action_in_initiative(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=existing["initiative_id"],
|
||||||
|
action_id=action_id,
|
||||||
|
):
|
||||||
|
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||||
|
updates.append("action_id = %s")
|
||||||
|
params.append(action_id)
|
||||||
|
if clear_milestone_id:
|
||||||
|
updates.append("milestone_id = NULL")
|
||||||
|
elif milestone_id is not None:
|
||||||
|
if not _milestone_in_initiative(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=existing["initiative_id"],
|
||||||
|
milestone_id=milestone_id,
|
||||||
|
):
|
||||||
|
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
|
||||||
|
updates.append("milestone_id = %s")
|
||||||
|
params.append(milestone_id)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
updates.append("updated_at = NOW()")
|
||||||
|
params.extend([review_id, tenant_id])
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE reviews
|
||||||
|
SET {", ".join(updates)}
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
RETURNING {_REVIEW_COLUMNS}
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = _serialize_row(dict(row))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"review.updated",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"review_id": review_id},
|
||||||
|
)
|
||||||
|
if status is not None and status != old_status:
|
||||||
|
log_audit(
|
||||||
|
"review.status_changed",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={
|
||||||
|
"review_id": review_id,
|
||||||
|
"from_status": old_status,
|
||||||
|
"to_status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def delete_review(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
review_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM reviews WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||||
|
(review_id, tenant_id),
|
||||||
|
)
|
||||||
|
deleted = cur.fetchone() is not None
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
log_audit(
|
||||||
|
"review.deleted",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"review_id": review_id},
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
165
backend/tests/test_ap09_operating_model.py
Normal file
165
backend/tests/test_ap09_operating_model.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""AP0.9 — Operating Model Extension II tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import (
|
||||||
|
_auth,
|
||||||
|
_create_action,
|
||||||
|
_create_initiative,
|
||||||
|
_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_evidence(client, token, initiative_id, **kwargs):
|
||||||
|
body = {"title": "Evidence Test", **kwargs}
|
||||||
|
return client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/evidence",
|
||||||
|
json=body,
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_decision(client, token, initiative_id, **kwargs):
|
||||||
|
body = {"title": "Decision Test", **kwargs}
|
||||||
|
return client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/decisions",
|
||||||
|
json=body,
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_review(client, token, initiative_id, **kwargs):
|
||||||
|
body = {"title": "Review Test", **kwargs}
|
||||||
|
return client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/reviews",
|
||||||
|
json=body,
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_recurring(client, token, initiative_id, **kwargs):
|
||||||
|
body = {"title": "Recurring Test", **kwargs}
|
||||||
|
return client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/recurring",
|
||||||
|
json=body,
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_ready_status_and_due_at(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
due = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||||
|
created = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/actions",
|
||||||
|
json={"title": "Due Action", "status": "ready", "due_at": due},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
action = created.json()
|
||||||
|
assert action["status"] == "ready"
|
||||||
|
assert action["due_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_attention_overdue_action(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
action = _create_action(client, token, initiative_id, title="Overdue").json()
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{action['id']}",
|
||||||
|
json={"due_at": past},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/attention", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert any(i["kind"] == "overdue_action" for i in res.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_attention_review_due(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||||
|
created = _create_review(
|
||||||
|
client, token, initiative_id, status="planned", due_at=past
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/attention", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert any(i["kind"] == "review_due" for i in res.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_attention_recurring_due(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||||
|
created = _create_recurring(
|
||||||
|
client, token, initiative_id, status="active", next_due_at=past
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/attention", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert any(i["kind"] == "recurring_due" for i in res.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_crud(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
created = _create_evidence(client, token, initiative_id)
|
||||||
|
assert created.status_code == 201
|
||||||
|
ev_id = created.json()["id"]
|
||||||
|
|
||||||
|
listed = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/evidence", headers=_auth(token)
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/evidence/{ev_id}", headers=_auth(token))
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_decision_crud(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
created = _create_decision(client, token, initiative_id, status="proposed")
|
||||||
|
assert created.status_code == 201
|
||||||
|
dec_id = created.json()["id"]
|
||||||
|
|
||||||
|
patched = client.patch(
|
||||||
|
f"/api/decisions/{dec_id}",
|
||||||
|
json={"status": "decided", "outcome": "Ja"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert patched.status_code == 200
|
||||||
|
assert patched.json()["status"] == "decided"
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_has_ap09_capabilities(client):
|
||||||
|
member = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, member)
|
||||||
|
ctx = client.get("/api/me/context", headers=_auth(token)).json()
|
||||||
|
caps = ctx["capabilities"]
|
||||||
|
for key in (
|
||||||
|
"kairo.evidence.read",
|
||||||
|
"kairo.evidence.manage",
|
||||||
|
"kairo.decision.read",
|
||||||
|
"kairo.decision.manage",
|
||||||
|
"kairo.review.read",
|
||||||
|
"kairo.review.manage",
|
||||||
|
"kairo.recurring.read",
|
||||||
|
"kairo.recurring.manage",
|
||||||
|
):
|
||||||
|
assert key in caps
|
||||||
|
|
@ -38,6 +38,7 @@ def test_migration_runner_finds_migrations():
|
||||||
assert "005_prompt_feature_config_registry" in names
|
assert "005_prompt_feature_config_registry" in names
|
||||||
assert "006_initiatives_actions" in names
|
assert "006_initiatives_actions" in names
|
||||||
assert "007_operating_model_extension_i" in names
|
assert "007_operating_model_extension_i" in names
|
||||||
|
assert "008_operating_model_extension_ii" in names
|
||||||
|
|
||||||
|
|
||||||
def test_migration_runner_is_idempotent():
|
def test_migration_runner_is_idempotent():
|
||||||
|
|
@ -54,6 +55,7 @@ def test_migration_runner_is_idempotent():
|
||||||
assert "005_prompt_feature_config_registry" in executed
|
assert "005_prompt_feature_config_registry" in executed
|
||||||
assert "006_initiatives_actions" in executed
|
assert "006_initiatives_actions" in executed
|
||||||
assert "007_operating_model_extension_i" in executed
|
assert "007_operating_model_extension_i" in executed
|
||||||
|
assert "008_operating_model_extension_ii" in executed
|
||||||
|
|
||||||
|
|
||||||
def test_core_table_exists():
|
def test_core_table_exists():
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,14 @@ def test_registry_contains_initial_capabilities():
|
||||||
"kairo.backlog.manage",
|
"kairo.backlog.manage",
|
||||||
"kairo.milestone.read",
|
"kairo.milestone.read",
|
||||||
"kairo.milestone.manage",
|
"kairo.milestone.manage",
|
||||||
|
"kairo.evidence.read",
|
||||||
|
"kairo.evidence.manage",
|
||||||
|
"kairo.decision.read",
|
||||||
|
"kairo.decision.manage",
|
||||||
|
"kairo.review.read",
|
||||||
|
"kairo.review.manage",
|
||||||
|
"kairo.recurring.read",
|
||||||
|
"kairo.recurring.manage",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -46,7 +54,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] == 25
|
assert cur.fetchone()[0] == 33
|
||||||
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.8.0-ap0.8"
|
APP_VERSION = "0.9.0-ap0.9"
|
||||||
DB_SCHEMA_VERSION = "007"
|
DB_SCHEMA_VERSION = "008"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
62
docs/sprints/Sprint0_AP0_8_Completion_Report_v0.2.md
Normal file
62
docs/sprints/Sprint0_AP0_8_Completion_Report_v0.2.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# AP0.8 – Abschlussbericht Operating Model Extension I
|
||||||
|
|
||||||
|
**Status:** abgeschlossen (Remote-QA)
|
||||||
|
**Stand:** 2026-07-05 (final)
|
||||||
|
**Version:** Backend/Frontend `0.8.0-ap0.8` · Schema `007`
|
||||||
|
**Commits:** `fe3bc4f`, `c9101db`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope und Einordnung
|
||||||
|
|
||||||
|
AP0.8 liefert den ersten Operating-Model-Ausbau nach dem Product Reset: Attention/NextAction (regelbasiert), Blocker, BacklogItem, Milestone minimal.
|
||||||
|
|
||||||
|
**Bewusste Grenze (bestätigt durch Nutzer-QA):** Objekte stehen **parallel zur Maßnahme** — keine Steuerungsgraph-Logik, keine Abhängigkeiten, keine Sprint-/Methoden-Anbindung, kein durchgängiger Next-Best-Action-Flow in der UI. Das ist **architekturkonform für AP0.8** und wird erst mit Steering Core / AP1.x+ verbunden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Definition of Done — Prüfmatrix
|
||||||
|
|
||||||
|
| Kriterium | Ergebnis |
|
||||||
|
|-----------|----------|
|
||||||
|
| Migration 007, Schema 007 | ✓ Pi: `0.8.0-ap0.8` |
|
||||||
|
| Attention Regeln 1–6 | ✓ |
|
||||||
|
| Blocker, Backlog, Milestone CRUD | ✓ |
|
||||||
|
| Convert Backlog → Action | ✓ |
|
||||||
|
| AttentionWidget + Initiative-Sektionen | ✓ (nach UI-Fix `c9101db`) |
|
||||||
|
| 25 Capabilities | ✓ |
|
||||||
|
| Remote Deploy + lauffähig | ✓ Nutzer bestätigt |
|
||||||
|
| Kein KI/MCP/Steering | ✓ |
|
||||||
|
| Abschlussbericht + README | ✓ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. QA-Feedback und technische Schulden
|
||||||
|
|
||||||
|
| Feedback | Einordnung | Folge |
|
||||||
|
|----------|------------|-------|
|
||||||
|
| UI „nicht schön“, erweiterungsnötig | Akzeptiert für MVP-Slice | AP0.10 Validation; kein Redesign in AP0.8 |
|
||||||
|
| Alles parallel zu Maßnahmen | Erwarteter Zwischenzustand | AP0.9 optionale FKs; Steuerung später |
|
||||||
|
| Verbindungen / Abhängigkeiten fehlen | Nicht-Scope AP0.8 | SteeringContext, Method Registry → AP1.x |
|
||||||
|
| Next Best Action nicht durchgängig | API vorhanden, kein UI-Widget | AP0.9+ oder dedizierter UI-Slice |
|
||||||
|
| Sprints etc. fehlen | Nicht-Scope | Roadmap/WorkCycles später |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Abweichungen vom Auftrag
|
||||||
|
|
||||||
|
| Punkt | Abweichung |
|
||||||
|
|-------|------------|
|
||||||
|
| Attention-Widget Capability | UI: `kairo.workspace.read` (Fix Deploy-Sichtbarkeit) |
|
||||||
|
| Initiative-OM-Sektionen | Sichtbar bei `kairo.initiative.read` |
|
||||||
|
| Regel 1b open_blocker | Ergänzung v0.2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Empfehlung nächster Schritt
|
||||||
|
|
||||||
|
**AP0.9 — Operating Model Extension II** (Evidence, Decision, Review, RecurringElement, Action-Status-Erweiterung, Due Dates, Attention 7–9) — schließt Canonical MVP §12 Lücke vor AP0.10 Validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*v0.2 final — ersetzt Sprint0_AP0_8_Completion_Report_v0.1.md*
|
||||||
55
docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md
Normal file
55
docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# AP0.9 – Operating Model Extension II
|
||||||
|
## Implementierungsauftrag v0.1 (freigegeben)
|
||||||
|
|
||||||
|
**Status:** freigegeben zur Umsetzung
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Vorgänger:** AP0.8 ✓
|
||||||
|
**Zielversion:** `0.9.0-ap0.9` · Schema `008`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einordnung
|
||||||
|
|
||||||
|
AP0.9 schließt **Steuerung und Nachvollziehbarkeit** im Operating Model — noch **ohne** Steering Core, Methoden, Abhängigkeiten oder Sprint-Logik.
|
||||||
|
|
||||||
|
AP0.8-Feedback: parallele Sektionen sind **akzeptiert**; AP0.9 führt **optionale Verknüpfungen** (Evidence/Review → Action/Milestone) ein, aber **keine** orchestrated Steuerung.
|
||||||
|
|
||||||
|
Evolutionslinie: System Target State §24 Schritt 7 (Evidence/Review/Decision) + Attention-Regeln 7–9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
| Slice | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| 9a | Action: Status `ready`, `review_required`; `due_at`; Attention Regel 7 |
|
||||||
|
| 9b | Evidence minimal |
|
||||||
|
| 9c | Decision minimal |
|
||||||
|
| 9d | Review minimal + Attention Regel 8 |
|
||||||
|
| 9e | RecurringElement + Attention Regel 9 |
|
||||||
|
|
||||||
|
## Nicht-Scope
|
||||||
|
|
||||||
|
- SteeringContext, Method Registry, Dependencies, Sprints
|
||||||
|
- Review → Backlog/Action Auto-Erzeugung
|
||||||
|
- Kalender, LLM, MCP, Workflow
|
||||||
|
- UI-Redesign
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration `008_operating_model_extension_ii.sql`
|
||||||
|
|
||||||
|
- `ALTER actions` — `due_at`, erweiterte Status-Checks
|
||||||
|
- `evidence`, `decisions`, `reviews`, `recurring_elements`
|
||||||
|
|
||||||
|
## Capabilities (+8 → 33 gesamt)
|
||||||
|
|
||||||
|
`kairo.evidence.*`, `kairo.decision.*`, `kairo.review.*`, `kairo.recurring.*`
|
||||||
|
|
||||||
|
## Abnahme
|
||||||
|
|
||||||
|
Schema 008, CRUD tenant-sicher, Attention 7–9, Initiative-Detail-Sektionen, Tests, README, Abschlussbericht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Freigegeben — Umsetzung 9a→9e.*
|
||||||
46
docs/sprints/Sprint0_AP0_9_Completion_Report_v0.1.md
Normal file
46
docs/sprints/Sprint0_AP0_9_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# AP0.9 – Abschlussbericht Operating Model Extension II
|
||||||
|
|
||||||
|
**Status:** implementiert (Remote-QA ausstehend)
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Version:** Backend/Frontend `0.9.0-ap0.9` · Schema `008`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Geliefert
|
||||||
|
|
||||||
|
| Slice | Inhalt | Status |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| 9a | Action `ready`/`review_required`, `due_at`, Attention Regel 7 | ✓ |
|
||||||
|
| 9b | Evidence CRUD | ✓ |
|
||||||
|
| 9c | Decision CRUD | ✓ |
|
||||||
|
| 9d | Review CRUD + Attention Regel 8 | ✓ |
|
||||||
|
| 9e | RecurringElement CRUD + Attention Regel 9 | ✓ |
|
||||||
|
|
||||||
|
Migration `008_operating_model_extension_ii.sql`, 8 neue Capabilities (33 gesamt), Initiative-Detail-Sektionen, Tests `test_ap09_operating_model.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Bewusste Grenzen (Fortführung AP0.8-Feedback)
|
||||||
|
|
||||||
|
- Sektionen weiterhin **parallel** im Vorhaben-Detail — kein Steuerungsgraph
|
||||||
|
- Optionale FKs Evidence/Review → Action/Milestone in API, **keine** UI-Verknüpfung
|
||||||
|
- Kein Steering Core, keine Abhängigkeiten, keine Sprints
|
||||||
|
- NextActionCandidates unverändert (kein UI-Widget)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Abnahme
|
||||||
|
|
||||||
|
- [ ] Pi-Deploy `0.9.0-ap0.9` / Schema `008`
|
||||||
|
- [ ] Gitea `test.yml` grün
|
||||||
|
- [ ] Manuelle QA Vorhaben-Detail (neue Sektionen, Fälligkeit Maßnahme)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Nächster Schritt
|
||||||
|
|
||||||
|
**AP0.10 — Operating Model Validation** laut Corrected MVP Roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*v0.1 — ersetzt nach Remote-QA durch v0.2*
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.8.0-ap0.8",
|
"version": "0.9.0-ap0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
23
frontend/src/api/decisions.js
Normal file
23
frontend/src/api/decisions.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { apiFetch } from './client.js'
|
||||||
|
|
||||||
|
export function listInitiativeDecisions(initiativeId) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/decisions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitiativeDecision(initiativeId, body) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/decisions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDecision(decisionId, body) {
|
||||||
|
return apiFetch(`/api/decisions/${decisionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteDecision(decisionId) {
|
||||||
|
return apiFetch(`/api/decisions/${decisionId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
23
frontend/src/api/evidence.js
Normal file
23
frontend/src/api/evidence.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { apiFetch } from './client.js'
|
||||||
|
|
||||||
|
export function listInitiativeEvidence(initiativeId) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/evidence`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitiativeEvidence(initiativeId, body) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/evidence`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEvidence(evidenceId, body) {
|
||||||
|
return apiFetch(`/api/evidence/${evidenceId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEvidence(evidenceId) {
|
||||||
|
return apiFetch(`/api/evidence/${evidenceId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
23
frontend/src/api/recurring.js
Normal file
23
frontend/src/api/recurring.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { apiFetch } from './client.js'
|
||||||
|
|
||||||
|
export function listInitiativeRecurring(initiativeId) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/recurring`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitiativeRecurring(initiativeId, body) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/recurring`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRecurring(recurringId, body) {
|
||||||
|
return apiFetch(`/api/recurring/${recurringId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRecurring(recurringId) {
|
||||||
|
return apiFetch(`/api/recurring/${recurringId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
23
frontend/src/api/reviews.js
Normal file
23
frontend/src/api/reviews.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { apiFetch } from './client.js'
|
||||||
|
|
||||||
|
export function listInitiativeReviews(initiativeId) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/reviews`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitiativeReview(initiativeId, body) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/reviews`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateReview(reviewId, body) {
|
||||||
|
return apiFetch(`/api/reviews/${reviewId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteReview(reviewId) {
|
||||||
|
return apiFetch(`/api/reviews/${reviewId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,13 @@ import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
|
||||||
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
||||||
import { ActorSelect } from './ActorSelect.jsx'
|
import { ActorSelect } from './ActorSelect.jsx'
|
||||||
|
|
||||||
|
function isoToLocalInput(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
export function ActionForm({
|
export function ActionForm({
|
||||||
initial = {},
|
initial = {},
|
||||||
actors = [],
|
actors = [],
|
||||||
|
|
@ -23,6 +30,7 @@ export function ActionForm({
|
||||||
description: form.description.value,
|
description: form.description.value,
|
||||||
status: form.status.value,
|
status: form.status.value,
|
||||||
priority: form.priority.value,
|
priority: form.priority.value,
|
||||||
|
due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null,
|
||||||
assigned_actor_ids: selected,
|
assigned_actor_ids: selected,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +67,14 @@ export function ActionForm({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Fällig am (optional)
|
||||||
|
<input
|
||||||
|
name="due_at"
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={isoToLocalInput(initial.due_at)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<ActorSelect
|
<ActorSelect
|
||||||
actors={actors}
|
actors={actors}
|
||||||
defaultSelected={defaultActors}
|
defaultSelected={defaultActors}
|
||||||
|
|
|
||||||
90
frontend/src/components/DecisionsSection.jsx
Normal file
90
frontend/src/components/DecisionsSection.jsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { DECISION_STATUSES, DECISION_STATUS_LABELS } from '../constants/status.js'
|
||||||
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
||||||
|
export function DecisionsSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) return
|
||||||
|
await onCreate({ title: title.trim() })
|
||||||
|
setTitle('')
|
||||||
|
setShowForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Entscheidungen</h2>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Entscheidung anlegen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && canManage && (
|
||||||
|
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Titel
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 && <EmptyState message="Keine Entscheidungen." />}
|
||||||
|
|
||||||
|
<ul className="item-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.outcome && <p className="list-item-desc">{item.outcome}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="decision" status={item.status} />
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="inline-select"
|
||||||
|
value={item.status}
|
||||||
|
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
||||||
|
aria-label="Entscheidungs-Status"
|
||||||
|
>
|
||||||
|
{DECISION_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{DECISION_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
frontend/src/components/EvidenceSection.jsx
Normal file
90
frontend/src/components/EvidenceSection.jsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { EVIDENCE_STATUSES, EVIDENCE_STATUS_LABELS } from '../constants/status.js'
|
||||||
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
||||||
|
export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) return
|
||||||
|
await onCreate({ title: title.trim() })
|
||||||
|
setTitle('')
|
||||||
|
setShowForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Evidence</h2>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Evidence hinzufügen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && canManage && (
|
||||||
|
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Titel
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 && <EmptyState message="Kein Evidence." />}
|
||||||
|
|
||||||
|
<ul className="item-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.description && <p className="list-item-desc">{item.description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="evidence" status={item.status} />
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="inline-select"
|
||||||
|
value={item.status}
|
||||||
|
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
||||||
|
aria-label="Evidence-Status"
|
||||||
|
>
|
||||||
|
{EVIDENCE_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{EVIDENCE_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
frontend/src/components/RecurringSection.jsx
Normal file
114
frontend/src/components/RecurringSection.jsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { RECURRING_STATUSES, RECURRING_STATUS_LABELS } from '../constants/status.js'
|
||||||
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
||||||
|
function formatDueAt(iso) {
|
||||||
|
if (!iso) return null
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('de-DE')
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecurringSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [nextDueAt, setNextDueAt] = useState('')
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) return
|
||||||
|
await onCreate({
|
||||||
|
title: title.trim(),
|
||||||
|
next_due_at: nextDueAt ? new Date(nextDueAt).toISOString() : null,
|
||||||
|
})
|
||||||
|
setTitle('')
|
||||||
|
setNextDueAt('')
|
||||||
|
setShowForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Wiederkehrend</h2>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Element anlegen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && canManage && (
|
||||||
|
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Titel
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Nächste Fälligkeit
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={nextDueAt}
|
||||||
|
onChange={(e) => setNextDueAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 && <EmptyState message="Keine wiederkehrenden Elemente." />}
|
||||||
|
|
||||||
|
<ul className="item-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.next_due_at && (
|
||||||
|
<p className="muted list-item-sub">Fällig: {formatDueAt(item.next_due_at)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="recurring" status={item.status} />
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="inline-select"
|
||||||
|
value={item.status}
|
||||||
|
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
||||||
|
aria-label="Recurring-Status"
|
||||||
|
>
|
||||||
|
{RECURRING_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{RECURRING_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
frontend/src/components/ReviewsSection.jsx
Normal file
114
frontend/src/components/ReviewsSection.jsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { REVIEW_STATUSES, REVIEW_STATUS_LABELS } from '../constants/status.js'
|
||||||
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
||||||
|
function formatDueAt(iso) {
|
||||||
|
if (!iso) return null
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('de-DE')
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewsSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [dueAt, setDueAt] = useState('')
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) return
|
||||||
|
await onCreate({
|
||||||
|
title: title.trim(),
|
||||||
|
due_at: dueAt ? new Date(dueAt).toISOString() : null,
|
||||||
|
})
|
||||||
|
setTitle('')
|
||||||
|
setDueAt('')
|
||||||
|
setShowForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Reviews</h2>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Review planen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && canManage && (
|
||||||
|
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Titel
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Fällig am
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={dueAt}
|
||||||
|
onChange={(e) => setDueAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 && <EmptyState message="Keine Reviews." />}
|
||||||
|
|
||||||
|
<ul className="item-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.due_at && (
|
||||||
|
<p className="muted list-item-sub">Fällig: {formatDueAt(item.due_at)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="review" status={item.status} />
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="inline-select"
|
||||||
|
value={item.status}
|
||||||
|
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
||||||
|
aria-label="Review-Status"
|
||||||
|
>
|
||||||
|
{REVIEW_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{REVIEW_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,17 @@ const VARIANTS = {
|
||||||
resolved: 'status-done',
|
resolved: 'status-done',
|
||||||
accepted_risk: 'status-paused',
|
accepted_risk: 'status-paused',
|
||||||
dismissed: 'status-discarded',
|
dismissed: 'status-discarded',
|
||||||
|
ready: 'status-active',
|
||||||
|
review_required: 'status-progress',
|
||||||
|
submitted: 'status-open',
|
||||||
|
accepted: 'status-done',
|
||||||
|
rejected: 'status-discarded',
|
||||||
|
proposed: 'status-open',
|
||||||
|
decided: 'status-done',
|
||||||
|
superseded: 'status-paused',
|
||||||
|
completed: 'status-done',
|
||||||
|
skipped: 'status-paused',
|
||||||
|
ended: 'status-archived',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ kind = 'action', status }) {
|
export function StatusBadge({ kind = 'action', status }) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived']
|
export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived']
|
||||||
export const ACTION_STATUSES = ['open', 'in_progress', 'blocked', 'done', 'discarded']
|
export const ACTION_STATUSES = [
|
||||||
|
'open',
|
||||||
|
'ready',
|
||||||
|
'in_progress',
|
||||||
|
'blocked',
|
||||||
|
'review_required',
|
||||||
|
'done',
|
||||||
|
'discarded',
|
||||||
|
]
|
||||||
export const BLOCKER_STATUSES = ['open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed']
|
export const BLOCKER_STATUSES = ['open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed']
|
||||||
export const BACKLOG_STATUSES = ['new', 'triaged', 'accepted', 'rejected', 'converted']
|
export const BACKLOG_STATUSES = ['new', 'triaged', 'accepted', 'rejected', 'converted']
|
||||||
export const MILESTONE_STATUSES = ['planned', 'active', 'at_risk', 'reached', 'moved', 'discarded']
|
export const MILESTONE_STATUSES = ['planned', 'active', 'at_risk', 'reached', 'moved', 'discarded']
|
||||||
|
export const EVIDENCE_STATUSES = ['submitted', 'accepted', 'rejected']
|
||||||
|
export const DECISION_STATUSES = ['proposed', 'decided', 'superseded']
|
||||||
|
export const REVIEW_STATUSES = ['planned', 'completed', 'skipped']
|
||||||
|
export const RECURRING_STATUSES = ['active', 'paused', 'ended']
|
||||||
export const PRIORITIES = ['low', 'normal', 'high']
|
export const PRIORITIES = ['low', 'normal', 'high']
|
||||||
export const OPEN_ACTION_STATUSES = ['open', 'in_progress', 'blocked']
|
export const OPEN_ACTION_STATUSES = ['open', 'ready', 'in_progress', 'blocked', 'review_required']
|
||||||
|
|
||||||
export const INITIATIVE_STATUS_LABELS = {
|
export const INITIATIVE_STATUS_LABELS = {
|
||||||
active: 'Aktiv',
|
active: 'Aktiv',
|
||||||
|
|
@ -15,8 +27,10 @@ export const INITIATIVE_STATUS_LABELS = {
|
||||||
|
|
||||||
export const ACTION_STATUS_LABELS = {
|
export const ACTION_STATUS_LABELS = {
|
||||||
open: 'Offen',
|
open: 'Offen',
|
||||||
|
ready: 'Bereit',
|
||||||
in_progress: 'In Arbeit',
|
in_progress: 'In Arbeit',
|
||||||
blocked: 'Blockiert',
|
blocked: 'Blockiert',
|
||||||
|
review_required: 'Review nötig',
|
||||||
done: 'Erledigt',
|
done: 'Erledigt',
|
||||||
discarded: 'Verworfen',
|
discarded: 'Verworfen',
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +60,30 @@ export const MILESTONE_STATUS_LABELS = {
|
||||||
discarded: 'Verworfen',
|
discarded: 'Verworfen',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const EVIDENCE_STATUS_LABELS = {
|
||||||
|
submitted: 'Eingereicht',
|
||||||
|
accepted: 'Akzeptiert',
|
||||||
|
rejected: 'Abgelehnt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DECISION_STATUS_LABELS = {
|
||||||
|
proposed: 'Vorgeschlagen',
|
||||||
|
decided: 'Entschieden',
|
||||||
|
superseded: 'Ersetzt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REVIEW_STATUS_LABELS = {
|
||||||
|
planned: 'Geplant',
|
||||||
|
completed: 'Abgeschlossen',
|
||||||
|
skipped: 'Übersprungen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECURRING_STATUS_LABELS = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
paused: 'Pausiert',
|
||||||
|
ended: 'Beendet',
|
||||||
|
}
|
||||||
|
|
||||||
export const PRIORITY_LABELS = {
|
export const PRIORITY_LABELS = {
|
||||||
low: 'Niedrig',
|
low: 'Niedrig',
|
||||||
normal: 'Normal',
|
normal: 'Normal',
|
||||||
|
|
@ -58,4 +96,8 @@ export const STATUS_LABELS_BY_KIND = {
|
||||||
blocker: BLOCKER_STATUS_LABELS,
|
blocker: BLOCKER_STATUS_LABELS,
|
||||||
backlog: BACKLOG_STATUS_LABELS,
|
backlog: BACKLOG_STATUS_LABELS,
|
||||||
milestone: MILESTONE_STATUS_LABELS,
|
milestone: MILESTONE_STATUS_LABELS,
|
||||||
|
evidence: EVIDENCE_STATUS_LABELS,
|
||||||
|
decision: DECISION_STATUS_LABELS,
|
||||||
|
review: REVIEW_STATUS_LABELS,
|
||||||
|
recurring: RECURRING_STATUS_LABELS,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,41 @@ import {
|
||||||
updateMilestone,
|
updateMilestone,
|
||||||
deleteMilestone,
|
deleteMilestone,
|
||||||
} from '../api/milestones.js'
|
} from '../api/milestones.js'
|
||||||
import { ACTION_STATUS_LABELS } from '../constants/status.js'
|
import {
|
||||||
|
listInitiativeEvidence,
|
||||||
|
createInitiativeEvidence,
|
||||||
|
updateEvidence,
|
||||||
|
deleteEvidence,
|
||||||
|
} from '../api/evidence.js'
|
||||||
|
import {
|
||||||
|
listInitiativeDecisions,
|
||||||
|
createInitiativeDecision,
|
||||||
|
updateDecision,
|
||||||
|
deleteDecision,
|
||||||
|
} from '../api/decisions.js'
|
||||||
|
import {
|
||||||
|
listInitiativeReviews,
|
||||||
|
createInitiativeReview,
|
||||||
|
updateReview,
|
||||||
|
deleteReview,
|
||||||
|
} from '../api/reviews.js'
|
||||||
|
import {
|
||||||
|
listInitiativeRecurring,
|
||||||
|
createInitiativeRecurring,
|
||||||
|
updateRecurring,
|
||||||
|
deleteRecurring,
|
||||||
|
} from '../api/recurring.js'
|
||||||
|
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
|
||||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { ActionForm } from '../components/ActionForm.jsx'
|
import { ActionForm } from '../components/ActionForm.jsx'
|
||||||
import { BlockersSection } from '../components/BlockersSection.jsx'
|
import { BlockersSection } from '../components/BlockersSection.jsx'
|
||||||
import { BacklogSection } from '../components/BacklogSection.jsx'
|
import { BacklogSection } from '../components/BacklogSection.jsx'
|
||||||
import { MilestonesSection } from '../components/MilestonesSection.jsx'
|
import { MilestonesSection } from '../components/MilestonesSection.jsx'
|
||||||
|
import { EvidenceSection } from '../components/EvidenceSection.jsx'
|
||||||
|
import { DecisionsSection } from '../components/DecisionsSection.jsx'
|
||||||
|
import { ReviewsSection } from '../components/ReviewsSection.jsx'
|
||||||
|
import { RecurringSection } from '../components/RecurringSection.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
|
@ -56,6 +84,10 @@ export function InitiativeDetailPage() {
|
||||||
const [blockers, setBlockers] = useState([])
|
const [blockers, setBlockers] = useState([])
|
||||||
const [backlogItems, setBacklogItems] = useState([])
|
const [backlogItems, setBacklogItems] = useState([])
|
||||||
const [milestones, setMilestones] = useState([])
|
const [milestones, setMilestones] = useState([])
|
||||||
|
const [evidenceItems, setEvidenceItems] = useState([])
|
||||||
|
const [decisions, setDecisions] = useState([])
|
||||||
|
const [reviews, setReviews] = useState([])
|
||||||
|
const [recurringItems, setRecurringItems] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [showActionForm, setShowActionForm] = useState(false)
|
const [showActionForm, setShowActionForm] = useState(false)
|
||||||
|
|
@ -83,6 +115,10 @@ export function InitiativeDetailPage() {
|
||||||
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
|
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
|
||||||
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
|
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
|
||||||
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
|
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
|
||||||
|
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
|
||||||
|
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
||||||
|
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
||||||
|
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
|
||||||
}
|
}
|
||||||
await Promise.all(loads)
|
await Promise.all(loads)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -127,6 +163,8 @@ export function InitiativeDetailPage() {
|
||||||
description: payload.description,
|
description: payload.description,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
priority: payload.priority,
|
priority: payload.priority,
|
||||||
|
due_at: payload.due_at,
|
||||||
|
clear_due_at: !payload.due_at,
|
||||||
})
|
})
|
||||||
if (capabilities.has('kairo.action.manage')) {
|
if (capabilities.has('kairo.action.manage')) {
|
||||||
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
|
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
|
||||||
|
|
@ -252,6 +290,126 @@ export function InitiativeDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateEvidence(body) {
|
||||||
|
setFormBusy(true)
|
||||||
|
try {
|
||||||
|
await createInitiativeEvidence(id, body)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEvidenceStatus(evidenceId, status) {
|
||||||
|
try {
|
||||||
|
await updateEvidence(evidenceId, { status })
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEvidence(evidenceId) {
|
||||||
|
try {
|
||||||
|
await deleteEvidence(evidenceId)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateDecision(body) {
|
||||||
|
setFormBusy(true)
|
||||||
|
try {
|
||||||
|
await createInitiativeDecision(id, body)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecisionStatus(decisionId, status) {
|
||||||
|
try {
|
||||||
|
await updateDecision(decisionId, { status })
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteDecision(decisionId) {
|
||||||
|
try {
|
||||||
|
await deleteDecision(decisionId)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateReview(body) {
|
||||||
|
setFormBusy(true)
|
||||||
|
try {
|
||||||
|
await createInitiativeReview(id, body)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReviewStatus(reviewId, status) {
|
||||||
|
try {
|
||||||
|
await updateReview(reviewId, { status })
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteReview(reviewId) {
|
||||||
|
try {
|
||||||
|
await deleteReview(reviewId)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateRecurring(body) {
|
||||||
|
setFormBusy(true)
|
||||||
|
try {
|
||||||
|
await createInitiativeRecurring(id, body)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecurringStatus(recurringId, status) {
|
||||||
|
try {
|
||||||
|
await updateRecurring(recurringId, { status })
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRecurring(recurringId) {
|
||||||
|
try {
|
||||||
|
await deleteRecurring(recurringId)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
|
|
@ -367,6 +525,11 @@ export function InitiativeDetailPage() {
|
||||||
Zugewiesen: {action.assigned_actor_ids.length} Actor(s)
|
Zugewiesen: {action.assigned_actor_ids.length} Actor(s)
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{action.due_at && (
|
||||||
|
<p className="muted list-item-sub">
|
||||||
|
Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
<StatusBadge status={action.status} />
|
<StatusBadge status={action.status} />
|
||||||
|
|
@ -379,7 +542,7 @@ export function InitiativeDetailPage() {
|
||||||
onChange={(e) => handleQuickStatus(action, e.target.value)}
|
onChange={(e) => handleQuickStatus(action, e.target.value)}
|
||||||
aria-label="Status ändern"
|
aria-label="Status ändern"
|
||||||
>
|
>
|
||||||
{['open', 'in_progress', 'blocked', 'done', 'discarded'].map((s) => (
|
{ACTION_STATUSES.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
{ACTION_STATUS_LABELS[s]}
|
{ACTION_STATUS_LABELS[s]}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -438,6 +601,50 @@ export function InitiativeDetailPage() {
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{capabilities.has('kairo.initiative.read') && (
|
||||||
|
<EvidenceSection
|
||||||
|
items={evidenceItems}
|
||||||
|
canManage={capabilities.has('kairo.evidence.manage')}
|
||||||
|
onCreate={handleCreateEvidence}
|
||||||
|
onUpdateStatus={handleEvidenceStatus}
|
||||||
|
onDelete={handleDeleteEvidence}
|
||||||
|
busy={formBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capabilities.has('kairo.initiative.read') && (
|
||||||
|
<DecisionsSection
|
||||||
|
items={decisions}
|
||||||
|
canManage={capabilities.has('kairo.decision.manage')}
|
||||||
|
onCreate={handleCreateDecision}
|
||||||
|
onUpdateStatus={handleDecisionStatus}
|
||||||
|
onDelete={handleDeleteDecision}
|
||||||
|
busy={formBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capabilities.has('kairo.initiative.read') && (
|
||||||
|
<ReviewsSection
|
||||||
|
items={reviews}
|
||||||
|
canManage={capabilities.has('kairo.review.manage')}
|
||||||
|
onCreate={handleCreateReview}
|
||||||
|
onUpdateStatus={handleReviewStatus}
|
||||||
|
onDelete={handleDeleteReview}
|
||||||
|
busy={formBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capabilities.has('kairo.initiative.read') && (
|
||||||
|
<RecurringSection
|
||||||
|
items={recurringItems}
|
||||||
|
canManage={capabilities.has('kairo.recurring.manage')}
|
||||||
|
onCreate={handleCreateRecurring}
|
||||||
|
onUpdateStatus={handleRecurringStatus}
|
||||||
|
onDelete={handleDeleteRecurring}
|
||||||
|
busy={formBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user