AP1.10a: Initiative-Standardfelder vision, Zielzustand und Archetyp-Schlüssel.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m51s
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 18s
Test Suite / playwright-smoke (push) Successful in 12s

Migration 016, Archetyp-Registry-API, erweitertes Profil-Modal und Plan-Profil-Ansicht.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-09 11:48:54 +02:00
parent 495993cad6
commit 408050bcb8
14 changed files with 342 additions and 40 deletions

View File

@ -21,8 +21,8 @@ def get_active_initiatives(
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
sql = """
SELECT id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
SELECT id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, owner_actor_id, created_at, updated_at
FROM initiatives
WHERE tenant_id = %s AND status IN ('active', 'paused')
ORDER BY updated_at DESC, title

View File

@ -0,0 +1,13 @@
from entity_archetypes.registry import (
INITIATIVE_ARCHETYPES,
archetype_label,
list_archetypes,
validate_archetype_key,
)
__all__ = [
"INITIATIVE_ARCHETYPES",
"archetype_label",
"list_archetypes",
"validate_archetype_key",
]

View File

@ -0,0 +1,49 @@
"""System-Archetypen (AP1.10a) — Seed-Daten bis EFS-Tabellen (10b)."""
from __future__ import annotations
from typing import Any, Optional
INITIATIVE_ARCHETYPES: tuple[dict[str, Any], ...] = (
{
"key": "initiative.generic",
"entity_type": "initiative",
"label": "Allgemeines Vorhaben",
"description": "Standard-Vorhaben ohne spezielle Vorlage.",
"is_system": True,
},
{
"key": "initiative.program",
"entity_type": "initiative",
"label": "Programm / Mega-Vorhaben",
"description": "Mehrere Stränge, Zielzustand und Vision zentral.",
"is_system": True,
},
{
"key": "initiative.product",
"entity_type": "initiative",
"label": "Produkt / Release",
"description": "Produkt- oder Release-orientiertes Vorhaben.",
"is_system": True,
},
)
_ARCHETYPE_INDEX = {
(item["entity_type"], item["key"]): item for item in INITIATIVE_ARCHETYPES
}
def list_archetypes(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]:
if entity_type is None:
return [dict(item) for item in INITIATIVE_ARCHETYPES]
return [dict(item) for item in INITIATIVE_ARCHETYPES if item["entity_type"] == entity_type]
def validate_archetype_key(*, entity_type: str, archetype_key: str) -> None:
if (entity_type, archetype_key) not in _ARCHETYPE_INDEX:
raise ValueError(f"Unbekannter Archetyp: {archetype_key}")
def archetype_label(*, entity_type: str, archetype_key: str) -> Optional[str]:
item = _ARCHETYPE_INDEX.get((entity_type, archetype_key))
return item["label"] if item else None

View File

@ -63,6 +63,7 @@ from routers import ( # noqa: E402
blockers,
config,
decisions,
entity_archetypes,
evidence,
features,
initiatives,
@ -85,6 +86,7 @@ app.include_router(features.router)
app.include_router(prompts.router)
app.include_router(config.router)
app.include_router(initiatives.router)
app.include_router(entity_archetypes.router)
app.include_router(journey.initiative_router)
app.include_router(journey.roadmap_router)
app.include_router(actions.router)

View File

@ -0,0 +1,16 @@
-- AP1.10a: Initiative Standardfelder + Archetyp-Schlüssel
ALTER TABLE initiatives
ADD COLUMN IF NOT EXISTS vision TEXT NOT NULL DEFAULT '';
ALTER TABLE initiatives
ADD COLUMN IF NOT EXISTS target_state_summary TEXT NOT NULL DEFAULT '';
ALTER TABLE initiatives
ADD COLUMN IF NOT EXISTS archetype_key VARCHAR(64) NOT NULL DEFAULT 'initiative.generic';
CREATE INDEX IF NOT EXISTS idx_initiatives_archetype
ON initiatives (tenant_id, archetype_key);
CREATE INDEX IF NOT EXISTS idx_initiatives_status_tenant
ON initiatives (tenant_id, status);

View File

@ -0,0 +1,21 @@
"""Entity-Archetypen API — AP1.10a (In-Memory-Registry bis 10b)."""
from __future__ import annotations
from typing import Optional
from capabilities import require_capability
from entity_archetypes import list_archetypes
from fastapi import APIRouter, Depends, Query
from tenant_context import TenantContext
router = APIRouter(prefix="/api/entity-archetypes", tags=["entity-archetypes"])
@router.get("")
def get_entity_archetypes(
entity_type: Optional[str] = Query(default=None),
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
_ = ctx
return list_archetypes(entity_type=entity_type)

View File

@ -27,6 +27,9 @@ router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
class InitiativeCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
goal: str = ""
vision: str = ""
target_state_summary: str = ""
archetype_key: str = "initiative.generic"
status: Literal["active", "paused", "completed", "archived"] = "active"
priority: Literal["low", "normal", "high"] = "normal"
owner_actor_id: Optional[str] = None
@ -35,6 +38,9 @@ class InitiativeCreateRequest(BaseModel):
class InitiativeUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
goal: Optional[str] = None
vision: Optional[str] = None
target_state_summary: Optional[str] = None
archetype_key: Optional[str] = None
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
priority: Optional[Literal["low", "normal", "high"]] = None
owner_actor_id: Optional[str] = None
@ -138,6 +144,9 @@ def create_initiative(
tenant_id=ctx.tenant_id,
title=body.title,
goal=body.goal,
vision=body.vision,
target_state_summary=body.target_state_summary,
archetype_key=body.archetype_key,
status=body.status,
priority=body.priority,
owner_actor_id=owner_actor_id,
@ -198,6 +207,9 @@ def update_initiative(
user_id=ctx.user_id,
title=body.title,
goal=body.goal,
vision=body.vision,
target_state_summary=body.target_state_summary,
archetype_key=body.archetype_key,
status=body.status,
priority=body.priority,
owner_actor_id=body.owner_actor_id,

View File

@ -7,6 +7,7 @@ from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from entity_archetypes import validate_archetype_key
from services.audit import log_audit
InitiativeStatus = Literal["active", "paused", "completed", "archived"]
@ -15,6 +16,11 @@ Priority = Literal["low", "normal", "high"]
INITIATIVE_STATUSES = frozenset({"active", "paused", "completed", "archived"})
PRIORITIES = frozenset({"low", "normal", "high"})
_INITIATIVE_COLUMNS = """
id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, owner_actor_id, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
@ -51,12 +57,19 @@ def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn.close()
def _validate_archetype_key(archetype_key: str) -> None:
validate_archetype_key(entity_type="initiative", archetype_key=archetype_key)
def create_initiative(
*,
tenant_id: str,
title: str,
owner_actor_id: str,
goal: str = "",
vision: str = "",
target_state_summary: str = "",
archetype_key: str = "initiative.generic",
status: InitiativeStatus = "active",
priority: Priority = "normal",
user_id: Optional[str] = None,
@ -66,6 +79,7 @@ def create_initiative(
raise ValueError("Titel ist erforderlich")
_validate_initiative_status(status)
_validate_priority(priority)
_validate_archetype_key(archetype_key)
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
raise ValueError("Owner-Actor gehört nicht zum Tenant")
@ -73,15 +87,25 @@ def create_initiative(
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
f"""
INSERT INTO initiatives (
tenant_id, title, goal, status, priority, owner_actor_id
tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, owner_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_INITIATIVE_COLUMNS}
""",
(tenant_id, title, goal, status, priority, owner_actor_id),
(
tenant_id,
title,
goal,
vision,
target_state_summary,
archetype_key,
status,
priority,
owner_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
@ -109,9 +133,8 @@ def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]:
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
f"""
SELECT {_INITIATIVE_COLUMNS}
FROM initiatives
WHERE tenant_id = %s
ORDER BY updated_at DESC, title
@ -128,9 +151,8 @@ def get_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str,
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
f"""
SELECT {_INITIATIVE_COLUMNS}
FROM initiatives
WHERE id = %s AND tenant_id = %s
""",
@ -149,6 +171,9 @@ def update_initiative(
user_id: Optional[str] = None,
title: Optional[str] = None,
goal: Optional[str] = None,
vision: Optional[str] = None,
target_state_summary: Optional[str] = None,
archetype_key: Optional[str] = None,
status: Optional[InitiativeStatus] = None,
priority: Optional[Priority] = None,
owner_actor_id: Optional[str] = None,
@ -170,6 +195,16 @@ def update_initiative(
if goal is not None:
updates.append("goal = %s")
params.append(goal)
if vision is not None:
updates.append("vision = %s")
params.append(vision)
if target_state_summary is not None:
updates.append("target_state_summary = %s")
params.append(target_state_summary)
if archetype_key is not None:
_validate_archetype_key(archetype_key)
updates.append("archetype_key = %s")
params.append(archetype_key)
if status is not None:
_validate_initiative_status(status)
updates.append("status = %s")
@ -198,8 +233,7 @@ def update_initiative(
UPDATE initiatives
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, title, goal, status, priority,
owner_actor_id, created_at, updated_at
RETURNING {_INITIATIVE_COLUMNS}
""",
params,
)

View File

@ -0,0 +1,65 @@
"""Initiative-Archetypen & Standardfelder (AP1.10a)."""
from __future__ import annotations
from auth import AUTH_HEADER
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def test_initiative_archetype_fields(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Produkt Alpha",
goal="Kurz-Ziel",
vision="Ausführliches Zielbild für Release 1",
target_state_summary="MVP live auf Dev",
archetype_key="initiative.product",
)
assert created.status_code == 201
body = created.json()
assert body["archetype_key"] == "initiative.product"
assert body["vision"] == "Ausführliches Zielbild für Release 1"
assert body["target_state_summary"] == "MVP live auf Dev"
updated = client.patch(
f"/api/initiatives/{body['id']}",
json={
"archetype_key": "initiative.program",
"vision": "Programm-Vision aktualisiert",
},
headers=_auth(token),
)
assert updated.status_code == 200
assert updated.json()["archetype_key"] == "initiative.program"
assert updated.json()["vision"] == "Programm-Vision aktualisiert"
def test_list_entity_archetypes(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
res = client.get(
"/api/entity-archetypes?entity_type=initiative",
headers=_auth(token),
)
assert res.status_code == 200
keys = {item["key"] for item in res.json()}
assert keys == {"initiative.generic", "initiative.program", "initiative.product"}
def test_invalid_archetype_rejected(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
res = _create_initiative(
client,
token,
title="Bad Archetype",
archetype_key="initiative.unknown",
)
assert res.status_code == 400

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.16.9-ap1.12d-fix"
DB_SCHEMA_VERSION = "015"
APP_VERSION = "0.17.0-ap1.10a"
DB_SCHEMA_VERSION = "016"
APP_NAME = "jinkendo-kairo"

View File

@ -0,0 +1,6 @@
import { apiFetch } from './client.js'
export function listEntityArchetypes(entityType) {
const qs = entityType ? `?entity_type=${encodeURIComponent(entityType)}` : ''
return apiFetch(`/api/entity-archetypes${qs}`)
}

View File

@ -1,5 +1,8 @@
import { useEffect, useState } from 'react'
import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
import { listEntityArchetypes } from '../api/entityArchetypes.js'
export function InitiativeForm({
initial = {},
@ -8,12 +11,38 @@ export function InitiativeForm({
busy = false,
submitLabel = 'Speichern',
}) {
const [archetypes, setArchetypes] = useState(null)
useEffect(() => {
let cancelled = false
listEntityArchetypes('initiative')
.then((items) => {
if (!cancelled) setArchetypes(items)
})
.catch(() => {
if (!cancelled) {
setArchetypes(
Object.entries(INITIATIVE_ARCHETYPE_LABELS).map(([key, label]) => ({
key,
label,
})),
)
}
})
return () => {
cancelled = true
}
}, [])
async function handleSubmit(e) {
e.preventDefault()
const form = e.target
await onSubmit({
title: form.title.value.trim(),
archetype_key: form.archetype_key.value,
goal: form.goal.value,
vision: form.vision.value,
target_state_summary: form.target_state_summary.value,
status: form.status.value,
priority: form.priority.value,
})
@ -26,9 +55,36 @@ export function InitiativeForm({
<input name="title" defaultValue={initial.title || ''} required maxLength={255} />
</label>
<label>
Ziel
<textarea name="goal" rows={3} defaultValue={initial.goal || ''} />
Archetyp
<select
name="archetype_key"
defaultValue={initial.archetype_key || 'initiative.generic'}
disabled={!archetypes}
>
{(archetypes || []).map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</select>
</label>
<label>
Kurzbeschreibung (Ziel)
<textarea name="goal" rows={2} defaultValue={initial.goal || ''} />
</label>
<label>
Vision
<textarea name="vision" rows={4} defaultValue={initial.vision || ''} />
</label>
<label>
Zielzustand (Kurzfassung)
<textarea
name="target_state_summary"
rows={2}
defaultValue={initial.target_state_summary || ''}
/>
</label>
<div className="form-row form-row--2">
<label>
Status
<select name="status" defaultValue={initial.status || 'active'}>
@ -49,8 +105,9 @@ export function InitiativeForm({
))}
</select>
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
<button type="submit" className="btn btn-primary" disabled={busy || !archetypes}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (

View File

@ -0,0 +1,10 @@
/** Fallback-Labels bis API geladen (AP1.10a). */
export const INITIATIVE_ARCHETYPE_LABELS = {
'initiative.generic': 'Allgemeines Vorhaben',
'initiative.program': 'Programm / Mega-Vorhaben',
'initiative.product': 'Produkt / Release',
}
export function initiativeArchetypeLabel(key) {
return INITIATIVE_ARCHETYPE_LABELS[key] || key || 'Allgemeines Vorhaben'
}

View File

@ -7,6 +7,7 @@ import { Modal } from '../../components/Modal.jsx'
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { initiativeArchetypeLabel } from '../../constants/initiativeArchetypes.js'
function PlanProfileInner() {
const ops = useInitiativeOperations()
@ -52,20 +53,36 @@ function PlanProfileInner() {
</div>
</header>
{error && <p className="error">{error}</p>}
<div className="plan-profile__field">
<h3 className="plan-profile__label">Archetyp</h3>
<p>{initiativeArchetypeLabel(initiative.archetype_key)}</p>
</div>
{initiative.goal ? (
<div className="plan-profile__field">
<h3 className="plan-profile__label">Ziel</h3>
<h3 className="plan-profile__label">Kurzbeschreibung (Ziel)</h3>
<p>{initiative.goal}</p>
</div>
) : (
<p className="muted">Noch kein Ziel hinterlegt.</p>
<p className="muted">Noch keine Kurzbeschreibung hinterlegt.</p>
)}
{initiative.vision && (
<div className="plan-profile__field">
<h3 className="plan-profile__label">Vision</h3>
<p>{initiative.vision}</p>
</div>
)}
{initiative.target_state_summary && (
<div className="plan-profile__field">
<h3 className="plan-profile__label">Zielzustand</h3>
<p>{initiative.target_state_summary}</p>
</div>
)}
<p className="muted plan-profile__note">
Archetyp-Felder und dynamische Attribute folgen mit AP1.10 (Entity Field System).
Dynamische Zusatzfelder (EFS) folgen in AP1.10c.
</p>
</section>
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)}>
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)} size="lg">
<InitiativeForm
initial={initiative}
onSubmit={handleSave}