diff --git a/backend/data_layer/initiatives.py b/backend/data_layer/initiatives.py index 319b98d..36b23a7 100644 --- a/backend/data_layer/initiatives.py +++ b/backend/data_layer/initiatives.py @@ -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 diff --git a/backend/entity_archetypes/__init__.py b/backend/entity_archetypes/__init__.py new file mode 100644 index 0000000..783cd51 --- /dev/null +++ b/backend/entity_archetypes/__init__.py @@ -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", +] diff --git a/backend/entity_archetypes/registry.py b/backend/entity_archetypes/registry.py new file mode 100644 index 0000000..63d292b --- /dev/null +++ b/backend/entity_archetypes/registry.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 388e047..60a5f19 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/migrations/016_initiative_archetypes.sql b/backend/migrations/016_initiative_archetypes.sql new file mode 100644 index 0000000..274e0d5 --- /dev/null +++ b/backend/migrations/016_initiative_archetypes.sql @@ -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); diff --git a/backend/routers/entity_archetypes.py b/backend/routers/entity_archetypes.py new file mode 100644 index 0000000..7380042 --- /dev/null +++ b/backend/routers/entity_archetypes.py @@ -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) diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 2fc2db3..75c509e 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -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, diff --git a/backend/services/initiatives.py b/backend/services/initiatives.py index 00b4bfe..cfcf930 100644 --- a/backend/services/initiatives.py +++ b/backend/services/initiatives.py @@ -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, ) diff --git a/backend/tests/test_ap110_initiative_archetypes.py b/backend/tests/test_ap110_initiative_archetypes.py new file mode 100644 index 0000000..74e6731 --- /dev/null +++ b/backend/tests/test_ap110_initiative_archetypes.py @@ -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 diff --git a/backend/version.py b/backend/version.py index 2b80c6b..6fcc6e7 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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" diff --git a/frontend/src/api/entityArchetypes.js b/frontend/src/api/entityArchetypes.js new file mode 100644 index 0000000..a46979b --- /dev/null +++ b/frontend/src/api/entityArchetypes.js @@ -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}`) +} diff --git a/frontend/src/components/InitiativeForm.jsx b/frontend/src/components/InitiativeForm.jsx index a8c0cb5..e8b3f6e 100644 --- a/frontend/src/components/InitiativeForm.jsx +++ b/frontend/src/components/InitiativeForm.jsx @@ -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,31 +55,59 @@ export function InitiativeForm({