AP1.5c: Rekursive Projects fuer Mega-Programme ohne Stream-Typ.
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 1m42s
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 53s
Test Suite / pytest-backend (push) Successful in 1m42s
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
parent_project_id und container_kind ermoeglichen verschachtelte Projektstruktur; Arbeitspakete bleiben an Blatt-Projekten gebunden. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6eeddfdc76
commit
4ca0a2c460
12
backend/migrations/014_project_hierarchy.sql
Normal file
12
backend/migrations/014_project_hierarchy.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- AP1.5c: Rekursive Projects (parent_project_id, container_kind)
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN parent_project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL,
|
||||
ADD COLUMN container_kind VARCHAR(32) NULL
|
||||
CHECK (
|
||||
container_kind IS NULL
|
||||
OR container_kind IN ('project', 'stream', 'phase', 'release')
|
||||
);
|
||||
|
||||
CREATE INDEX idx_projects_parent ON projects(tenant_id, parent_project_id)
|
||||
WHERE parent_project_id IS NOT NULL;
|
||||
|
|
@ -19,6 +19,8 @@ class ProjectCreateRequest(BaseModel):
|
|||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["active", "paused", "completed", "archived"] = "active"
|
||||
parent_project_id: Optional[str] = None
|
||||
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
target_date: Optional[date] = None
|
||||
|
|
@ -28,6 +30,10 @@ class ProjectUpdateRequest(BaseModel):
|
|||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
|
||||
parent_project_id: Optional[str] = None
|
||||
clear_parent_project: bool = False
|
||||
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
|
||||
clear_container_kind: bool = False
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
sort_order: Optional[int] = None
|
||||
|
|
@ -62,6 +68,8 @@ def create_initiative_project(
|
|||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
parent_project_id=body.parent_project_id,
|
||||
container_kind=body.container_kind,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
sort_order=body.sort_order,
|
||||
target_date=body.target_date,
|
||||
|
|
@ -95,6 +103,10 @@ def update_project(
|
|||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
parent_project_id=body.parent_project_id,
|
||||
clear_parent_project=body.clear_parent_project,
|
||||
container_kind=body.container_kind,
|
||||
clear_container_kind=body.clear_container_kind,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
sort_order=body.sort_order,
|
||||
|
|
@ -113,7 +125,11 @@ def delete_project(
|
|||
project_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
|
||||
):
|
||||
if not project_service.delete_project(
|
||||
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
|
||||
):
|
||||
try:
|
||||
deleted = project_service.delete_project(
|
||||
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from psycopg2.extras import RealDictCursor
|
|||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
from services.initiatives import PRIORITIES, get_initiative
|
||||
from services.projects import project_is_leaf
|
||||
|
||||
ActionStatus = Literal[
|
||||
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||
|
|
@ -116,6 +117,10 @@ def _validate_project_in_initiative(
|
|||
)
|
||||
if not cur.fetchone():
|
||||
raise ValueError("Projekt gehört nicht zum Vorhaben")
|
||||
if not project_is_leaf(tenant_id=tenant_id, project_id=project_id):
|
||||
raise ValueError(
|
||||
"Arbeitspakete nur an Blatt-Projekten (ohne Unterprojekte)"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Project service — operative Struktur unter Initiative (AP1.5)."""
|
||||
"""Project service — operative Struktur unter Initiative (AP1.5, AP1.5c)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -14,15 +14,21 @@ from services.initiatives import get_initiative
|
|||
ProjectStatus = Literal["active", "paused", "completed", "archived"]
|
||||
PROJECT_STATUSES = frozenset({"active", "paused", "completed", "archived"})
|
||||
|
||||
ContainerKind = Literal["project", "stream", "phase", "release"]
|
||||
CONTAINER_KINDS = frozenset({"project", "stream", "phase", "release"})
|
||||
|
||||
MAX_PROJECT_DEPTH = 5
|
||||
|
||||
_PROJECT_COLUMNS = """
|
||||
id, tenant_id, initiative_id, title, description, status,
|
||||
id, tenant_id, initiative_id, parent_project_id, container_kind,
|
||||
title, description, status,
|
||||
roadmap_item_id, sort_order, target_date, 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", "roadmap_item_id"):
|
||||
for key in ("id", "tenant_id", "initiative_id", "parent_project_id", "roadmap_item_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
if result.get("target_date"):
|
||||
|
|
@ -42,6 +48,11 @@ def _validate_status(status: str) -> None:
|
|||
raise ValueError(f"Ungültiger Project-Status: {status}")
|
||||
|
||||
|
||||
def _validate_container_kind(container_kind: Optional[str]) -> None:
|
||||
if container_kind is not None and container_kind not in CONTAINER_KINDS:
|
||||
raise ValueError(f"Ungültiger container_kind: {container_kind}")
|
||||
|
||||
|
||||
def _validate_roadmap_item(
|
||||
cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str]
|
||||
) -> None:
|
||||
|
|
@ -59,6 +70,151 @@ def _validate_roadmap_item(
|
|||
raise ValueError("Gate/Plan-Element gehört nicht zu diesem Vorhaben")
|
||||
|
||||
|
||||
def _fetch_project_row(
|
||||
cur, *, tenant_id: str, project_id: str, initiative_id: Optional[str] = None
|
||||
) -> Optional[dict[str, Any]]:
|
||||
query = f"""
|
||||
SELECT {_PROJECT_COLUMNS}
|
||||
FROM projects
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
"""
|
||||
params: list[Any] = [project_id, tenant_id]
|
||||
if initiative_id is not None:
|
||||
query += " AND initiative_id = %s"
|
||||
params.append(initiative_id)
|
||||
cur.execute(query, params)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _project_depth_from_map(
|
||||
project_id: str, by_id: dict[str, dict[str, Any]], memo: dict[str, int]
|
||||
) -> int:
|
||||
if project_id in memo:
|
||||
return memo[project_id]
|
||||
project = by_id.get(project_id)
|
||||
if not project or not project.get("parent_project_id"):
|
||||
memo[project_id] = 1
|
||||
return 1
|
||||
parent_id = str(project["parent_project_id"])
|
||||
if parent_id == project_id:
|
||||
raise ValueError("Zyklus in Project-Hierarchie")
|
||||
depth = _project_depth_from_map(parent_id, by_id, memo) + 1
|
||||
memo[project_id] = depth
|
||||
return depth
|
||||
|
||||
|
||||
def _collect_descendant_ids(
|
||||
project_id: str, by_parent: dict[str, list[str]]
|
||||
) -> set[str]:
|
||||
result: set[str] = set()
|
||||
stack = list(by_parent.get(project_id, []))
|
||||
while stack:
|
||||
child_id = stack.pop()
|
||||
if child_id in result:
|
||||
continue
|
||||
result.add(child_id)
|
||||
stack.extend(by_parent.get(child_id, []))
|
||||
return result
|
||||
|
||||
|
||||
def _subtree_height(project_id: str, by_parent: dict[str, list[str]]) -> int:
|
||||
children = by_parent.get(project_id, [])
|
||||
if not children:
|
||||
return 1
|
||||
return 1 + max(_subtree_height(child_id, by_parent) for child_id in children)
|
||||
|
||||
|
||||
def _load_initiative_project_maps(
|
||||
cur, *, tenant_id: str, initiative_id: str
|
||||
) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_PROJECT_COLUMNS}
|
||||
FROM projects
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
by_id = {str(r["id"]): r for r in rows}
|
||||
by_parent: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
parent_id = row.get("parent_project_id")
|
||||
if parent_id:
|
||||
by_parent.setdefault(str(parent_id), []).append(str(row["id"]))
|
||||
return by_id, by_parent
|
||||
|
||||
|
||||
def _validate_parent_project(
|
||||
cur,
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
project_id: Optional[str],
|
||||
parent_project_id: Optional[str],
|
||||
) -> None:
|
||||
if not parent_project_id:
|
||||
return
|
||||
if project_id and parent_project_id == project_id:
|
||||
raise ValueError("Projekt kann nicht sein eigener Parent sein")
|
||||
|
||||
parent = _fetch_project_row(
|
||||
cur, tenant_id=tenant_id, project_id=parent_project_id, initiative_id=initiative_id
|
||||
)
|
||||
if not parent:
|
||||
raise ValueError("Parent-Projekt gehört nicht zu diesem Vorhaben")
|
||||
|
||||
by_id, by_parent = _load_initiative_project_maps(
|
||||
cur, tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if project_id:
|
||||
descendants = _collect_descendant_ids(project_id, by_parent)
|
||||
if parent_project_id in descendants:
|
||||
raise ValueError("Zyklus: Parent darf kein Nachfahr sein")
|
||||
|
||||
memo: dict[str, int] = {}
|
||||
parent_depth = _project_depth_from_map(parent_project_id, by_id, memo)
|
||||
subtree_height = (
|
||||
_subtree_height(project_id, by_parent) if project_id else 1
|
||||
)
|
||||
if parent_depth + subtree_height > MAX_PROJECT_DEPTH:
|
||||
raise ValueError(
|
||||
f"Maximale Project-Tiefe ({MAX_PROJECT_DEPTH}) würde überschritten"
|
||||
)
|
||||
|
||||
|
||||
def _attach_depths(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not projects:
|
||||
return projects
|
||||
by_id = {p["id"]: p for p in projects}
|
||||
memo: dict[str, int] = {}
|
||||
for project in projects:
|
||||
project["depth"] = _project_depth_from_map(project["id"], by_id, memo)
|
||||
project["has_children"] = any(
|
||||
other.get("parent_project_id") == project["id"] for other in projects
|
||||
)
|
||||
project["is_leaf"] = not project["has_children"]
|
||||
return projects
|
||||
|
||||
|
||||
def project_is_leaf(*, tenant_id: str, project_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM projects
|
||||
WHERE parent_project_id = %s AND tenant_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(project_id, tenant_id),
|
||||
)
|
||||
return cur.fetchone() is None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_project(
|
||||
*,
|
||||
tenant_id: str,
|
||||
|
|
@ -66,6 +222,8 @@ def create_project(
|
|||
title: str,
|
||||
description: str = "",
|
||||
status: ProjectStatus = "active",
|
||||
parent_project_id: Optional[str] = None,
|
||||
container_kind: Optional[ContainerKind] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
sort_order: int = 0,
|
||||
target_date: Optional[date] = None,
|
||||
|
|
@ -75,6 +233,7 @@ def create_project(
|
|||
if not title:
|
||||
raise ValueError("Titel ist erforderlich")
|
||||
_validate_status(status)
|
||||
_validate_container_kind(container_kind)
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
|
|
@ -87,18 +246,28 @@ def create_project(
|
|||
initiative_id=initiative_id,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
_validate_parent_project(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
project_id=None,
|
||||
parent_project_id=parent_project_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO projects (
|
||||
tenant_id, initiative_id, title, description, status,
|
||||
tenant_id, initiative_id, parent_project_id, container_kind,
|
||||
title, description, status,
|
||||
roadmap_item_id, sort_order, target_date
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_PROJECT_COLUMNS}
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
parent_project_id,
|
||||
container_kind,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
|
|
@ -116,7 +285,12 @@ def create_project(
|
|||
"project.created",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"project_id": row["id"], "initiative_id": initiative_id, "title": title},
|
||||
details={
|
||||
"project_id": row["id"],
|
||||
"initiative_id": initiative_id,
|
||||
"title": title,
|
||||
"parent_project_id": parent_project_id,
|
||||
},
|
||||
)
|
||||
return row
|
||||
|
||||
|
|
@ -139,7 +313,8 @@ def list_projects_for_initiative(
|
|||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
rows = [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
return _attach_depths(rows)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -150,16 +325,25 @@ def get_project(*, tenant_id: str, project_id: str) -> Optional[dict[str, Any]]:
|
|||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_PROJECT_COLUMNS}
|
||||
SELECT initiative_id
|
||||
FROM projects WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(project_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return _serialize_row(dict(row)) if row else None
|
||||
if not row:
|
||||
return None
|
||||
initiative_id = str(row["initiative_id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
for project in list_projects_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
):
|
||||
if project["id"] == project_id:
|
||||
return project
|
||||
return None
|
||||
|
||||
|
||||
def update_project(
|
||||
*,
|
||||
|
|
@ -169,6 +353,10 @@ def update_project(
|
|||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[ProjectStatus] = None,
|
||||
parent_project_id: Optional[str] = None,
|
||||
clear_parent_project: bool = False,
|
||||
container_kind: Optional[ContainerKind] = None,
|
||||
clear_container_kind: bool = False,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
clear_roadmap_item: bool = False,
|
||||
sort_order: Optional[int] = None,
|
||||
|
|
@ -195,6 +383,29 @@ def update_project(
|
|||
_validate_status(status)
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if clear_parent_project:
|
||||
updates.append("parent_project_id = NULL")
|
||||
elif parent_project_id is not None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
_validate_parent_project(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
project_id=project_id,
|
||||
parent_project_id=parent_project_id,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
updates.append("parent_project_id = %s")
|
||||
params.append(parent_project_id)
|
||||
if clear_container_kind:
|
||||
updates.append("container_kind = NULL")
|
||||
elif container_kind is not None:
|
||||
_validate_container_kind(container_kind)
|
||||
updates.append("container_kind = %s")
|
||||
params.append(container_kind)
|
||||
if clear_roadmap_item:
|
||||
updates.append("roadmap_item_id = NULL")
|
||||
elif roadmap_item_id is not None:
|
||||
|
|
@ -251,7 +462,7 @@ def update_project(
|
|||
tenant_id=tenant_id,
|
||||
details={"project_id": project_id},
|
||||
)
|
||||
return result
|
||||
return get_project(tenant_id=tenant_id, project_id=project_id)
|
||||
|
||||
|
||||
def delete_project(
|
||||
|
|
@ -260,6 +471,18 @@ def delete_project(
|
|||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM projects
|
||||
WHERE parent_project_id = %s AND tenant_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(project_id, tenant_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise ValueError(
|
||||
"Projekt hat Unterprojekte — zuerst löschen oder verschieben"
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE actions SET project_id = NULL WHERE project_id = %s AND tenant_id = %s",
|
||||
(project_id, tenant_id),
|
||||
|
|
|
|||
111
backend/tests/test_ap15c_project_hierarchy.py
Normal file
111
backend/tests/test_ap15c_project_hierarchy.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""AP1.5c — Rekursive Projects (parent_project_id)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.factories import provision_user_in_tenant
|
||||
from tests.test_initiatives_actions import (
|
||||
_auth,
|
||||
_create_initiative,
|
||||
_login,
|
||||
)
|
||||
|
||||
|
||||
def test_nested_projects_and_depth(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
|
||||
root = client.post(
|
||||
f"/api/initiatives/{initiative_id}/projects",
|
||||
json={"title": "Programm", "container_kind": "project"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert root.status_code == 201
|
||||
root_id = root.json()["id"]
|
||||
|
||||
stream = client.post(
|
||||
f"/api/initiatives/{initiative_id}/projects",
|
||||
json={
|
||||
"title": "Backend Stream",
|
||||
"parent_project_id": root_id,
|
||||
"container_kind": "stream",
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert stream.status_code == 201
|
||||
stream_id = stream.json()["id"]
|
||||
|
||||
leaf = client.post(
|
||||
f"/api/initiatives/{initiative_id}/projects",
|
||||
json={"title": "API", "parent_project_id": stream_id},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert leaf.status_code == 201
|
||||
leaf_id = leaf.json()["id"]
|
||||
|
||||
listed = client.get(
|
||||
f"/api/initiatives/{initiative_id}/projects",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
items = {p["id"]: p for p in listed.json()}
|
||||
assert items[stream_id]["parent_project_id"] == root_id
|
||||
assert items[leaf_id]["depth"] == 3
|
||||
assert items[leaf_id]["is_leaf"] is True
|
||||
assert items[root_id]["has_children"] is True
|
||||
|
||||
|
||||
def test_action_only_on_leaf_project(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
|
||||
parent = client.post(
|
||||
f"/api/initiatives/{initiative_id}/projects",
|
||||
json={"title": "Container"},
|
||||
headers=_auth(token),
|
||||
).json()
|
||||
child = client.post(
|
||||
f"/api/initiatives/{initiative_id}/projects",
|
||||
json={"title": "Blatt", "parent_project_id": parent["id"]},
|
||||
headers=_auth(token),
|
||||
).json()
|
||||
|
||||
blocked = client.post(
|
||||
f"/api/initiatives/{initiative_id}/actions",
|
||||
json={"title": "Am Container", "project_id": parent["id"]},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert blocked.status_code == 400
|
||||
assert "Blatt" in blocked.json()["detail"]
|
||||
|
||||
ok = client.post(
|
||||
f"/api/initiatives/{initiative_id}/actions",
|
||||
json={"title": "Am Blatt", "project_id": child["id"]},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert ok.status_code == 201
|
||||
|
||||
|
||||
def test_cannot_delete_project_with_children(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
|
||||
parent = client.post(
|
||||
f"/api/initiatives/{initiative_id}/projects",
|
||||
json={"title": "Parent"},
|
||||
headers=_auth(token),
|
||||
).json()
|
||||
child = client.post(
|
||||
f"/api/initiatives/{initiative_id}/projects",
|
||||
json={"title": "Child", "parent_project_id": parent["id"]},
|
||||
headers=_auth(token),
|
||||
).json()
|
||||
|
||||
resp = client.delete(f"/api/projects/{parent['id']}", headers=_auth(token))
|
||||
assert resp.status_code == 400
|
||||
|
||||
client.delete(f"/api/projects/{child['id']}", headers=_auth(token))
|
||||
ok = client.delete(f"/api/projects/{parent['id']}", headers=_auth(token))
|
||||
assert ok.status_code == 204
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.14.0-ap1.5"
|
||||
DB_SCHEMA_VERSION = "013"
|
||||
APP_VERSION = "0.14.1-ap1.5c"
|
||||
DB_SCHEMA_VERSION = "014"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
|
|
@ -82,10 +82,12 @@ Minimaler Fix erlaubt: Tab-Beschreibung / Breadcrumb „Gates“ statt implizit
|
|||
|
||||
```text
|
||||
✓ AP1.4b Gate-Checkliste Backend + Minimal-Detail-Route
|
||||
→ AP1.5 Project, Task, Ausführung als Ist-Kern (NÄCHSTES CODE-PAKET)
|
||||
→ AP1.5 Project, Task, Ausführung als Ist-Kern ✓
|
||||
→ AP1.5c Rekursive Projects (parent_project_id)
|
||||
→ AP1.5b IA/Begriffe: Gates vs. Ausführung, optionale Gate-Sicht
|
||||
→ AP1.6 Plan/Ist-Verknüpfung, Journey (Action/Backlog → Gate)
|
||||
⏸ AP1.4c Gate-UI (Modal, Checkliste-UX) — nach AP1.5
|
||||
→ AP1.6 Plan/Ist-Verknüpfung, Journey (Action/Backlog/Project → Gate)
|
||||
→ AP1.5d Rekursive Tasks (parent_task_id), Roll-up
|
||||
⏸ AP1.4c Gate-UI (Modal, Checkliste-UX) — nach AP1.6
|
||||
→ AP1.4d Graph Engine (Gate-Kanten only)
|
||||
→ AP1.4e Methodenprofile (generic_operating ohne Gate-Zwang)
|
||||
```
|
||||
|
|
@ -101,9 +103,9 @@ Initiative (Vorhaben / Programm)
|
|||
│ ▲
|
||||
│ │ zahlt ein (Evidence, Zuordnung)
|
||||
│ │
|
||||
├── [optional] Project ← AP1.5
|
||||
├── [optional] Project* ← AP1.5c parent_project_id
|
||||
│ └── Action (Arbeitspaket) ← Ist — „Plan zwischen Gates“
|
||||
│ └── Task ← AP1.5 minimal
|
||||
│ └── Task* ← AP1.5d parent_task_id
|
||||
│
|
||||
├── Backlog → Commit → Action
|
||||
└── steering_context (Lifecycle, method_key) ← getrennt von Gate-Graph
|
||||
|
|
@ -155,4 +157,4 @@ Initiative (Vorhaben / Programm)
|
|||
|
||||
---
|
||||
|
||||
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (PO-Korrektur 2026-07-06), `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`*
|
||||
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (PO-Korrektur 2026-07-06), `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
# ADP — Rekursive Container & Ausführungs-Granularität v0.1
|
||||
|
||||
**Status:** PO-Freigabe (2026-07-06)
|
||||
**Stand:** 2026-07-06
|
||||
**Auslöser:** PO-Review — Mega-Programme vs. normale Vorhaben; Granularität für Mensch vs. Agent
|
||||
**Bezug:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, Canonical OM v0.2, Target State §10–11
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Zwei verwandte Fragen ohne feste Extra-Typen (`Stream`, `Subtask`, …):
|
||||
|
||||
1. **Organisation:** Sehr große Programme brauchen optional **Project in Project**; normale Vorhaben nicht.
|
||||
2. **Ausführung:** Arbeitspakete und Aufgaben brauchen **methoden- und akteursabhängige Tiefe** — Menschen ~2–3 Untergliederungen, Agenten feiner.
|
||||
|
||||
Fest codierte Ebenen (`Stream`, `WorkPackage`, `Subtask` als eigene Typen) wären für kleine Vorhaben überdimensioniert und duplizieren semantisch **Project** bzw. **Task**.
|
||||
|
||||
---
|
||||
|
||||
## Betroffene Regeln
|
||||
|
||||
| Dokument | Regel |
|
||||
|----------|--------|
|
||||
| Canonical OM v0.2 §4 | Project optional; Action = Arbeitspaket; Task methodenabhängig |
|
||||
| Vision v0.2 §4 | Action ≠ atomare Aufgabe; Task-Hierarchie / WBS |
|
||||
| ADP Product Layering | Ist = Project → Action → Task; Gates orthogonal |
|
||||
| Target State §10–11 | Steuerbare Objekte inkl. `project`; `SteeringContext` mit `scope_type` |
|
||||
| Tenant Invariants | Kein paralleles Steering; Actor-first Assignments |
|
||||
|
||||
---
|
||||
|
||||
## Optionen (Struktur)
|
||||
|
||||
| Option | Kurz | Pro | Contra |
|
||||
|--------|------|-----|--------|
|
||||
| A | Feste Typen `Stream`, `Subtask` | Klare UI-Labels | Schema-Ballast; Doppelung zu Project/Task |
|
||||
| B | **Rekursion gleicher Typen** (`parent_*_id`) | Ein Modell für klein + groß | Baum-UI; Roll-up-Regeln nötig |
|
||||
| C | Unbegrenzte Rekursion + Steuerung pro Ebene | Maximale Flexibilität | Steuerungs-Explosion; MVP-Verzögerung |
|
||||
|
||||
---
|
||||
|
||||
## Entscheidung (PO-Freigabe 2026-07-06)
|
||||
|
||||
### 1. Organisations-Ebene: rekursives **Project**
|
||||
|
||||
- **`parent_project_id`** nullable auf `projects` (Adjacency List).
|
||||
- Gleiche Initiative; **Zyklen verboten**; **max. Tiefe 5** (hard block).
|
||||
- Optional **`container_kind`** (Registry/Label): `project`, `stream`, `phase`, `release` — **nur Anzeige/Methoden-Hint**, keine Sonderlogik pro Kind.
|
||||
- **Kein** eigener Typ `Stream`.
|
||||
|
||||
**Semantik nach Tiefe (UI, nicht Schema):**
|
||||
|
||||
| Tiefe | Typisches Label | Beispiel |
|
||||
|-------|-----------------|----------|
|
||||
| 0 | Projekt | „Kairo MVP“ |
|
||||
| 1 | Stream / Phase | „Backend“, „Kumite“ |
|
||||
| 2+ | Teilprojekt | „API-Schicht“ |
|
||||
|
||||
**Guardrails:**
|
||||
|
||||
- Actions bevorzugt an **Blatt-Projects** oder direkt an Initiative (Warnung bei Container mit Kindern).
|
||||
- Gates (`roadmap_item_id`) an jeder Project-Tiefe oder Initiative — Plan/Ist in AP1.6.
|
||||
|
||||
**Implementierung:** **AP1.5c** — Migration `014` o.ä.
|
||||
|
||||
---
|
||||
|
||||
### 2. Ausführungs-Ebene: rekursives **Task** (Haupthebel Granularität)
|
||||
|
||||
- **`parent_task_id`** nullable auf `tasks` — gleiche Tabelle, Zeiger auf weitere Instanz.
|
||||
- **Max. Tiefe Schema:** 10 (Agent/API).
|
||||
- **Max. Tiefe UI (Menschen):** 3 Ebenen (Task → Subtask → Sub-Subtask) — **Policy**, kein Schema-Limit.
|
||||
- Roll-up: Action `done` wenn Task-Baum erfüllt (Regeln in AP1.5d); Task `done` wenn alle Kinder `done` oder explizit ohne Kinder.
|
||||
|
||||
**Guardrails:**
|
||||
|
||||
- **Steuerung bleibt auf Action** (Assignment, Blocker, Evidence, Next Action für Menschen).
|
||||
- Kein `steering_context` pro Task-Ebene.
|
||||
- Agenten: Blatt-Tasks als Ausführungsschritte; tiefe Bäume in UI **aggregiert** anzeigen.
|
||||
|
||||
**Implementierung:** **AP1.5d** — **nach AP1.6** (Plan/Ist-Verknüpfung + Gate-Beitrag zuerst).
|
||||
|
||||
---
|
||||
|
||||
### 3. Action-Verschachtelung: **eingeschränkt**, nicht Default
|
||||
|
||||
- **`parent_action_id`** nur für **`wbs_driven`** (später), max. Tiefe **2**.
|
||||
- Nur **Root-Action** aus Backlog-Commit; Kind-Actions = strukturelle Teilpakete desselben Commits.
|
||||
- Default-Vorhaben: **eine Action + Task-Baum** — kein Action-Baum.
|
||||
|
||||
**Implementierung:** dokumentiert; Code **nicht** in AP1.5c/d — erst mit Methodenprofil / Structure Builder.
|
||||
|
||||
---
|
||||
|
||||
### 4. Steuerung: Vererbung, nicht Explosion
|
||||
|
||||
**Stufe 1 (MVP bis AP1.6+):**
|
||||
|
||||
```text
|
||||
Initiative = einziger Steering-Root (steering_context, heute implementiert)
|
||||
Project* = Struktur; erbt Initiative-Steuerung
|
||||
Action = operative Steuerungs-Anker für Menschen (Assignment, Blocker, Evidence)
|
||||
Task* = Ausführungsschritte; Roll-up, kein eigener Context
|
||||
```
|
||||
|
||||
**Stufe 2 (später, Mega-Programme):**
|
||||
|
||||
- Polymorpher `SteeringContext` (`scope_type`, `scope_id`) wie Target State.
|
||||
- Nur Projects mit `steering_root = true` oder explizitem Override — **nicht** jede Ebene.
|
||||
|
||||
---
|
||||
|
||||
## Schichtenmodell (Ziel)
|
||||
|
||||
```text
|
||||
Initiative ── steering_context (Pflicht)
|
||||
│
|
||||
├── [optional] Project* parent_project_id
|
||||
│ └── Project* …
|
||||
│ └── Action Commit-Grenze (Root aus Backlog)
|
||||
│ └── Task* parent_task_id
|
||||
│ └── Task* …
|
||||
│
|
||||
├── [optional] Gates (RoadmapItem) orthogonal
|
||||
└── Backlog → Commit → Action (Root)
|
||||
```
|
||||
|
||||
**Zwei orthogonale Dimensionen:**
|
||||
|
||||
| Dimension | Rekursion | Mensch-Tiefe | Agent-Tiefe |
|
||||
|-----------|-----------|--------------|-------------|
|
||||
| Project (Organisation) | ja | 0–2 typisch | — |
|
||||
| Action (Arbeitspaket) | selten (WBS) | 1 | 1–2 |
|
||||
| Task (Ausführung) | ja | 2–3 | bis 10 |
|
||||
|
||||
---
|
||||
|
||||
## Repriorisierung (verbindlich)
|
||||
|
||||
```text
|
||||
✓ AP1.5 Project, Task flach, Ausführung (0.14.0-ap1.5)
|
||||
→ AP1.5c parent_project_id, Baum-UI, container_kind optional
|
||||
→ AP1.6 Plan/Ist-Verknüpfung, Journey
|
||||
→ AP1.5d parent_task_id, Roll-up, UI-Tiefen-Policy
|
||||
⏸ AP1.4c Gate-UI — weiter eingefroren
|
||||
→ AP1.4d Gate-Graph
|
||||
→ AP1.7 Operational Actor Interface (unverändert)
|
||||
```
|
||||
|
||||
AP1.5b (IA-Begriffe) kann parallel zu AP1.5c.
|
||||
|
||||
---
|
||||
|
||||
## Konsequenzen
|
||||
|
||||
| Bereich | Konsequenz |
|
||||
|---------|------------|
|
||||
| Schema | Zwei Migrationen: Project-Baum, Task-Baum — getrennte APs |
|
||||
| UI | Baum/Outline statt nur flache Listen; Default collapsed ab Tiefe 2 |
|
||||
| API | Cycle-Detection; depth validation |
|
||||
| Tests | Klein (flach), Mega (Project-Tiefe 3), Agent (Task-Tiefe >3) |
|
||||
| Steering | Keine Änderung in AP1.5c/d außer Roll-up-Fortschritt |
|
||||
| Agent AP1.7 | Nutzt Task-Blätter; tiefe Zerlegung über AP1.5d |
|
||||
|
||||
---
|
||||
|
||||
## Risiko
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|--------|------------|
|
||||
| UI-Overload durch Tiefe | UI soft cap 3 Tasks; Mega-Projects collapsed |
|
||||
| Redundante Pfade (Project + Action + Task alle tief) | Guardrails + PO-Dokumentation |
|
||||
| Commit-Modell bei nested Actions | Nested Actions erst WBS-Profil; Default flach |
|
||||
| Steuerungs-Explosion | Stufe 1: nur Initiative-Context |
|
||||
|
||||
---
|
||||
|
||||
## Rückbaubarkeit
|
||||
|
||||
- `parent_*_id` nullable — flache Nutzung bleibt Default.
|
||||
- `container_kind` optional — entfernbar ohne Schema-Bruch.
|
||||
- Polymorpher SteeringContext ist **Add-on**, kein Breaking Change für AP1.5c/d.
|
||||
|
||||
---
|
||||
|
||||
## Abnahme PO
|
||||
|
||||
- [x] Rekursives Project statt Stream-Typ
|
||||
- [x] Rekursives Task für Granularität (Mensch 2–3, Agent tiefer)
|
||||
- [x] Steering-Root = Initiative (Stufe 1)
|
||||
- [x] AP1.5c → AP1.6 → AP1.5d Reihenfolge
|
||||
- [x] Implementierung AP1.5c
|
||||
- [ ] Implementierung AP1.5d
|
||||
|
||||
---
|
||||
|
||||
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (Update 2026-07-06), `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`*
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
**Vision:** `Kairo_Vision_and_Product_Direction_v0.2.md`
|
||||
**Ist-Stand:** `Kairo_Implementation_Truth_Table_v0.1.md`
|
||||
**PO-ADP:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`
|
||||
**Container-ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -28,8 +29,8 @@
|
|||
| Entscheidung | |
|
||||
|--------------|--|
|
||||
| AP1.4b Backend | ✓ behalten (Kriterien, Verify, Reopen) |
|
||||
| Gate-UI-Politur (Modal, Graph-Workflow) | ⏸ **eingefroren** bis AP1.5 |
|
||||
| Nächstes Code-Paket | **AP1.5** Hierarchie + Ausführung als Ist-Kern |
|
||||
| Gate-UI-Politur (Modal, Graph-Workflow) | ⏸ **eingefroren** bis AP1.6 |
|
||||
| Nächstes Code-Paket | **AP1.5c** rekursive Projects |
|
||||
| Tab „Plan“ | fachlich **Gates / Zielzustände** — Umbenennung AP1.5b |
|
||||
| Graph Engine AP1.4d | Gate-Kanten only — **kein** Lifecycle-Workflow |
|
||||
|
||||
|
|
@ -37,6 +38,24 @@ Siehe `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`.
|
|||
|
||||
---
|
||||
|
||||
## 1.2 PO-Entscheidung 2026-07-06 — Rekursive Container & Task-Tiefe
|
||||
|
||||
**Auslöser:** Mega-Programme vs. normale Vorhaben; Granularität Mensch vs. Agent.
|
||||
|
||||
| Entscheidung | |
|
||||
|--------------|--|
|
||||
| Stream als eigener Typ | **Nein** — `Project` mit `parent_project_id` + optionalem `container_kind` |
|
||||
| Project in Project | **Ja** — AP1.5c |
|
||||
| Task → Subtask → … | **Ja** — `parent_task_id`; UI max. ~3 Ebenen; Schema tiefer für Agenten — AP1.5d |
|
||||
| Nested Actions | **Optional** später (`wbs_driven` only); nicht Default |
|
||||
| Steuerung pro Ebene | **Nein (Stufe 1)** — Initiative-Root; Action = Anker für Menschen |
|
||||
|
||||
Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`.
|
||||
|
||||
**Reihenfolge:** AP1.5c → AP1.6 → AP1.5d
|
||||
|
||||
---
|
||||
|
||||
## 2. Phasenübersicht
|
||||
|
||||
```text
|
||||
|
|
@ -47,11 +66,12 @@ Phase C Dokumentation Welle 1–2 ✓
|
|||
Phase D IA-Skeleton AP1.2 + AP1.2c ✓
|
||||
Phase E Steering AP1.2, AP1.3 ◐
|
||||
Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal
|
||||
Phase G **Ist-Hierarchie AP1.5** ← **NÄCHSTES CODE**
|
||||
Phase H Plan/Ist-Verknüpfung AP1.6 geplant
|
||||
Phase I Gate-Graph AP1.4d–4e nach AP1.5
|
||||
Phase G Ist-Hierarchie AP1.5 ✓
|
||||
Phase G2 Rekursive Container AP1.5c–5d ✓ 5c / → 5d nach AP1.6
|
||||
Phase H Plan/Ist-Verknüpfung AP1.6 ← **NÄCHSTES CODE**
|
||||
Phase I Gate-Graph AP1.4d–4e nach AP1.6
|
||||
Phase J Portfolio & Kontext AP1.8 geplant
|
||||
Phase K Agent Interface AP1.7 geplant
|
||||
Phase K Agent Interface AP1.7 geplant (unverändert)
|
||||
Phase L Validation AP0.10d (laufend PO) parallel
|
||||
```
|
||||
|
||||
|
|
@ -71,13 +91,15 @@ Phase L Validation AP0.10d (laufend PO) parallel
|
|||
| AP1.2c | 0.12.0-ap1.2c | IA-Skeleton |
|
||||
| AP1.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal |
|
||||
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
|
||||
| AP1.5 | 0.14.0-ap1.5 | Project, Task flach, Ausführung-Tab |
|
||||
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
|
||||
| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity |
|
||||
|
||||
---
|
||||
|
||||
## 4. Nächste Pakete (verbindliche Reihenfolge — PO 2026-07-06)
|
||||
|
||||
### AP1.5 — Hierarchie & Ist-Ebene (**NÄCHSTES CODE-PAKET**)
|
||||
### AP1.5 — Hierarchie & Ist-Ebene ✓
|
||||
|
||||
**Ziel:** Operativer Plan zwischen Gates — Project, Task, Ausführung-Tab; Gates optional nutzbar.
|
||||
|
||||
|
|
@ -94,6 +116,22 @@ Phase L Validation AP0.10d (laufend PO) parallel
|
|||
|
||||
---
|
||||
|
||||
### AP1.5c — Rekursive Projects ✓
|
||||
|
||||
**Ziel:** Project-Baum für Mega-Programme; normale Vorhaben unverändert flach.
|
||||
|
||||
| Scope | |
|
||||
|-------|--|
|
||||
| `parent_project_id`, Zyklen-Check, max. Tiefe 5 |
|
||||
| Optional `container_kind` (Label) |
|
||||
| Baum-UI in Ausführung; Actions nur an Blatt-Projects |
|
||||
| **Kein** eigener Steering-Context pro Project |
|
||||
|
||||
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
|
||||
**Version:** `0.14.1-ap1.5c`
|
||||
|
||||
---
|
||||
|
||||
### AP1.5b — IA-Begriffe Gates vs. Ausführung
|
||||
|
||||
| Scope | |
|
||||
|
|
@ -106,17 +144,35 @@ Phase L Validation AP0.10d (laufend PO) parallel
|
|||
|
||||
---
|
||||
|
||||
### AP1.6 — Plan/Ist-Verknüpfung & Journey
|
||||
### AP1.6 — Plan/Ist-Verknüpfung & Journey (**NÄCHSTES CODE-PAKET**)
|
||||
|
||||
Backlog/Action → RoadmapItem; Decision-Spur; Journey-Timeline.
|
||||
Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
|
||||
|
||||
**Nach AP1.5** — Gates bekommen Bedeutung durch Ist-Bezug.
|
||||
**Nach AP1.5c** — Gates bekommen Bedeutung durch Ist-Bezug (auch auf Project-Ebenen).
|
||||
|
||||
**Version:** `0.15.0-ap1.6`
|
||||
|
||||
---
|
||||
|
||||
### AP1.4c — Gate-UI-Politur ⏸ (eingefroren bis AP1.5)
|
||||
### AP1.5d — Rekursive Tasks & Roll-up
|
||||
|
||||
**Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer.
|
||||
|
||||
| Scope | |
|
||||
|-------|--|
|
||||
| Migration `parent_task_id`, Cycle/Depth-Validation |
|
||||
| Roll-up Status Action ↔ Task-Baum |
|
||||
| Gate-Beitrag auf Blatt-Tasks (Fortsetzung AP1.6) |
|
||||
| UI: Outline, collapsed default ab Tiefe 2 |
|
||||
| **Kein** nested Action (Default) |
|
||||
|
||||
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
|
||||
**Nach AP1.6**
|
||||
**Version:** `0.15.1-ap1.5d`
|
||||
|
||||
---
|
||||
|
||||
### AP1.4c — Gate-UI-Politur ⏸ (eingefroren bis AP1.6)
|
||||
|
||||
Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste.
|
||||
|
||||
|
|
@ -134,7 +190,7 @@ Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste.
|
|||
| Deprecate `sequencing_mode` am Item |
|
||||
| Read Models: blocked, ready, fulfillment_ratio |
|
||||
|
||||
**Version:** `0.14.1-ap1.4d` (nach AP1.5)
|
||||
**Version:** `0.15.2-ap1.4d` (nach AP1.6)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -204,7 +260,7 @@ Criteria, Waive/Defer, Reopen, Detail-Route. Version `0.13.1-ap1.4b`
|
|||
|
||||
| Thema | Bis wann |
|
||||
|-------|----------|
|
||||
| Gate-UI-Politur (Modal, Graph-Workflow) | **Nach AP1.5** (PO 2026-07-06) |
|
||||
| Gate-UI-Politur (Modal, Graph-Workflow) | **Nach AP1.6** (PO 2026-07-06) |
|
||||
| Prompt/KI/MCP produktiv | Ist-Hierarchie + Plan/Ist |
|
||||
| Method Designer UI | AP1.3+ |
|
||||
| Gantt / Kalender | nach Plan/Ist |
|
||||
|
|
@ -230,12 +286,14 @@ MVP-nah wenn:
|
|||
0.12.0-ap1.2c ✓ IA-Skeleton
|
||||
0.13.0-ap1.4 ✓ RoadmapItem / Gates Backend
|
||||
0.13.1-ap1.4b ✓ Gate-Checkliste Backend
|
||||
0.14.0-ap1.5 → Hierarchie (Project, Task) — NÄCHSTES
|
||||
0.15.0-ap1.6 → Journey / Plan-Ist
|
||||
0.14.1-ap1.4d → Gate-Graph (nach AP1.5)
|
||||
0.14.0-ap1.5 ✓ Hierarchie (Project, Task flach)
|
||||
0.14.1-ap1.5c ✓ Rekursive Projects
|
||||
0.15.0-ap1.6 → Journey / Plan-Ist — NÄCHSTES
|
||||
0.15.1-ap1.5d → Rekursive Tasks / Roll-up
|
||||
0.15.2-ap1.4d → Gate-Graph
|
||||
0.16.0-ap1.7 → Agent Interface
|
||||
0.17.0-ap1.8 → Portfolio + Kontext
|
||||
```
|
||||
---
|
||||
|
||||
*Siehe auch: `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`, `Kairo_MVP_Usability_Recovery_Plan_v0.2.md`*
|
||||
*Siehe auch: `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`, `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`, `Kairo_MVP_Usability_Recovery_Plan_v0.2.md`*
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Kairo — Implementation Truth Table v0.1
|
||||
|
||||
**Status:** living document — bei jedem AP aktualisieren
|
||||
**Stand:** 2026-07-06 (nach AP1.5)
|
||||
**Stand:** 2026-07-06 (nach AP1.5c)
|
||||
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
|
||||
|
||||
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||
|
|
@ -30,7 +30,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
| Capabilities | ✓ | Keine Objekt-Sichtbarkeit |
|
||||
| Auth / Session | ✓ | |
|
||||
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
|
||||
| Migrationen nummeriert | ✓ | Schema 013 |
|
||||
| Migrationen nummeriert | ✓ | Schema 014 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
| Element | Stand | Anmerkung |
|
||||
|---------|-------|-----------|
|
||||
| Initiative | ✓ | |
|
||||
| Project | ◐ | AP1.5 GUI + API |
|
||||
| Project | ◐ | AP1.5 + AP1.5c: Baum (`parent_project_id`), Blatt-Regel für Actions |
|
||||
| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) |
|
||||
| ActionAssignment | ✓ | |
|
||||
| BacklogItem | ✓ | |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
|
||||
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
||||
import { ActorSelect } from './ActorSelect.jsx'
|
||||
import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js'
|
||||
|
||||
function isoToLocalInput(iso) {
|
||||
if (!iso) return ''
|
||||
|
|
@ -38,6 +39,8 @@ export function ActionForm({
|
|||
}
|
||||
|
||||
const defaultActors = initial.assigned_actor_ids || []
|
||||
const leafProjects = getLeafProjects(projects)
|
||||
const projectOptions = flattenLeafProjectOptions(projects)
|
||||
|
||||
return (
|
||||
<form className="form workspace-form" onSubmit={handleSubmit}>
|
||||
|
|
@ -77,14 +80,14 @@ export function ActionForm({
|
|||
defaultValue={isoToLocalInput(initial.due_at)}
|
||||
/>
|
||||
</label>
|
||||
{projects.length > 0 && (
|
||||
{leafProjects.length > 0 && (
|
||||
<label>
|
||||
Projekt (optional)
|
||||
Projekt (optional, Blatt-Ebene)
|
||||
<select name="project_id" defaultValue={initial.project_id || ''}>
|
||||
<option value="">— keins —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.title}
|
||||
{projectOptions.map(({ project, label }) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,80 @@
|
|||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import {
|
||||
buildProjectsByParent,
|
||||
containerKindLabel,
|
||||
flattenProjectOptions,
|
||||
} from '../utils/projectTree.js'
|
||||
|
||||
function ProjectTreeNodes({
|
||||
projects,
|
||||
parentId,
|
||||
depth,
|
||||
selectedProjectId,
|
||||
onSelectProject,
|
||||
canManage,
|
||||
onDelete,
|
||||
busy,
|
||||
}) {
|
||||
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
|
||||
const nodes = byParent.get(parentId || '') || []
|
||||
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
return (
|
||||
<ul className="project-tree-children">
|
||||
{nodes.map((project) => (
|
||||
<li key={project.id} className="project-tree-branch">
|
||||
<div className="list-item card-list-item project-tree-item">
|
||||
<div className="list-item-main">
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() =>
|
||||
onSelectProject(selectedProjectId === project.id ? '' : project.id)
|
||||
}
|
||||
>
|
||||
<strong>{project.title}</strong>
|
||||
</button>
|
||||
{project.container_kind && (
|
||||
<span className="project-kind-badge muted">
|
||||
{containerKindLabel(project.container_kind)}
|
||||
</span>
|
||||
)}
|
||||
{project.description && (
|
||||
<p className="list-item-desc muted">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="initiative" status={project.status} />
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(project.id)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ProjectTreeNodes
|
||||
projects={projects}
|
||||
parentId={project.id}
|
||||
depth={depth + 1}
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={onSelectProject}
|
||||
canManage={canManage}
|
||||
onDelete={onDelete}
|
||||
busy={busy}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectsSection({
|
||||
projects,
|
||||
|
|
@ -13,37 +87,65 @@ export function ProjectsSection({
|
|||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [parentProjectId, setParentProjectId] = useState('')
|
||||
const [containerKind, setContainerKind] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const parentOptions = useMemo(
|
||||
() => flattenProjectOptions(projects),
|
||||
[projects]
|
||||
)
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
await onCreate({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
parent_project_id: parentProjectId || undefined,
|
||||
container_kind: containerKind || undefined,
|
||||
})
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setParentProjectId('')
|
||||
setContainerKind('')
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
function openFormUnderSelected() {
|
||||
setParentProjectId(selectedProjectId || '')
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card projects-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Projekte</h2>
|
||||
<p className="section-lead muted">
|
||||
Operative Struktur — Arbeitspakete gruppieren. Zielzustände (Gates) sind optional.
|
||||
Operative Struktur — verschachtelbar für große Vorhaben. Arbeitspakete nur an
|
||||
Blatt-Projekten. Gates optional.
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block-mobile"
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
>
|
||||
{showForm ? 'Abbrechen' : 'Projekt'}
|
||||
</button>
|
||||
<div className="section-header-actions">
|
||||
{selectedProjectId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-block-mobile"
|
||||
onClick={openFormUnderSelected}
|
||||
>
|
||||
Unterprojekt
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block-mobile"
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
>
|
||||
{showForm ? 'Abbrechen' : 'Projekt'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -67,6 +169,32 @@ export function ProjectsSection({
|
|||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
{parentOptions.length > 0 && (
|
||||
<label>
|
||||
Unter (optional)
|
||||
<select
|
||||
value={parentProjectId}
|
||||
onChange={(e) => setParentProjectId(e.target.value)}
|
||||
>
|
||||
<option value="">— oberste Ebene —</option>
|
||||
{parentOptions.map(({ project, label }) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Art (optional)
|
||||
<select value={containerKind} onChange={(e) => setContainerKind(e.target.value)}>
|
||||
<option value="">— Standard —</option>
|
||||
<option value="project">Projekt</option>
|
||||
<option value="stream">Stream</option>
|
||||
<option value="phase">Phase</option>
|
||||
<option value="release">Release</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Anlegen
|
||||
</button>
|
||||
|
|
@ -77,7 +205,7 @@ export function ProjectsSection({
|
|||
<EmptyState message="Noch keine Projekte — optional. Du kannst auch direkt Arbeitspakete anlegen." />
|
||||
)}
|
||||
|
||||
<ul className="item-list project-filter-list">
|
||||
<ul className="item-list project-filter-list project-tree-list">
|
||||
<li className="list-item card-list-item">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -87,38 +215,17 @@ export function ProjectsSection({
|
|||
Alle Arbeitspakete
|
||||
</button>
|
||||
</li>
|
||||
{projects.map((project) => (
|
||||
<li key={project.id} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() =>
|
||||
onSelectProject(selectedProjectId === project.id ? '' : project.id)
|
||||
}
|
||||
>
|
||||
<strong>{project.title}</strong>
|
||||
</button>
|
||||
{project.description && (
|
||||
<p className="list-item-desc muted">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="initiative" status={project.status} />
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(project.id)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ProjectTreeNodes
|
||||
projects={projects}
|
||||
parentId=""
|
||||
depth={1}
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={onSelectProject}
|
||||
canManage={canManage}
|
||||
onDelete={onDelete}
|
||||
busy={busy}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/stee
|
|||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||
import { useActors } from '../hooks/useActors.js'
|
||||
import { useSession } from './SessionContext.jsx'
|
||||
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
|
||||
|
||||
const InitiativeOperationsContext = createContext(null)
|
||||
|
||||
|
|
@ -154,17 +155,25 @@ export function InitiativeOperationsProvider({ children }) {
|
|||
load()
|
||||
}, [load])
|
||||
|
||||
const selectedLeafProjectId = useMemo(() => {
|
||||
if (!selectedProjectId) return ''
|
||||
const match = projects.find((p) => p.id === selectedProjectId)
|
||||
if (match?.is_leaf) return selectedProjectId
|
||||
return ''
|
||||
}, [selectedProjectId, projects])
|
||||
|
||||
const visibleActions = useMemo(
|
||||
() => {
|
||||
let list = hideDone
|
||||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||
: actions
|
||||
if (selectedProjectId) {
|
||||
list = list.filter((a) => a.project_id === selectedProjectId)
|
||||
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
|
||||
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
|
||||
}
|
||||
return list
|
||||
},
|
||||
[actions, hideDone, selectedProjectId]
|
||||
[actions, hideDone, selectedProjectId, projects]
|
||||
)
|
||||
|
||||
const actionContextById = useMemo(
|
||||
|
|
@ -200,7 +209,7 @@ export function InitiativeOperationsProvider({ children }) {
|
|||
: []
|
||||
await createInitiativeAction(id, {
|
||||
...payload,
|
||||
project_id: payload.project_id || selectedProjectId || undefined,
|
||||
project_id: payload.project_id || selectedLeafProjectId || undefined,
|
||||
assigned_actor_ids: assigned,
|
||||
})
|
||||
setShowActionForm(false)
|
||||
|
|
|
|||
|
|
@ -426,6 +426,38 @@
|
|||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-tree-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.project-tree-children {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 0.75rem;
|
||||
border-left: 2px solid var(--jk-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.project-tree-branch {
|
||||
list-style: none;
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.project-tree-item {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.project-kind-badge {
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
|
|
|
|||
80
frontend/src/utils/projectTree.js
Normal file
80
frontend/src/utils/projectTree.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/** AP1.5c — Project-Baum Hilfsfunktionen */
|
||||
|
||||
const CONTAINER_KIND_LABELS = {
|
||||
project: 'Projekt',
|
||||
stream: 'Stream',
|
||||
phase: 'Phase',
|
||||
release: 'Release',
|
||||
}
|
||||
|
||||
export function containerKindLabel(kind) {
|
||||
if (!kind) return null
|
||||
return CONTAINER_KIND_LABELS[kind] || kind
|
||||
}
|
||||
|
||||
export function buildProjectsByParent(projects) {
|
||||
const byParent = new Map()
|
||||
for (const project of projects) {
|
||||
const key = project.parent_project_id || ''
|
||||
if (!byParent.has(key)) byParent.set(key, [])
|
||||
byParent.get(key).push(project)
|
||||
}
|
||||
for (const list of byParent.values()) {
|
||||
list.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.title.localeCompare(b.title))
|
||||
}
|
||||
return byParent
|
||||
}
|
||||
|
||||
export function collectProjectSubtreeIds(projects, rootId) {
|
||||
const byParent = buildProjectsByParent(projects)
|
||||
const ids = new Set([rootId])
|
||||
const stack = [rootId]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()
|
||||
for (const child of byParent.get(current) || []) {
|
||||
if (!ids.has(child.id)) {
|
||||
ids.add(child.id)
|
||||
stack.push(child.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function getLeafProjects(projects) {
|
||||
const parentIds = new Set(
|
||||
projects.map((p) => p.parent_project_id).filter(Boolean)
|
||||
)
|
||||
return projects.filter((p) => !parentIds.has(p.id))
|
||||
}
|
||||
|
||||
export function projectOptionLabel(project, depth = 1) {
|
||||
const prefix = depth > 1 ? `${'— '.repeat(depth - 1)}` : ''
|
||||
const kind = containerKindLabel(project.container_kind)
|
||||
const kindSuffix = kind ? ` (${kind})` : ''
|
||||
return `${prefix}${project.title}${kindSuffix}`
|
||||
}
|
||||
|
||||
export function flattenLeafProjectOptions(projects, parentId = '', depth = 1) {
|
||||
const byParent = buildProjectsByParent(projects)
|
||||
const result = []
|
||||
for (const project of byParent.get(parentId) || []) {
|
||||
const children = byParent.get(project.id) || []
|
||||
if (children.length === 0) {
|
||||
result.push({ project, depth, label: projectOptionLabel(project, depth) })
|
||||
} else {
|
||||
result.push(...flattenLeafProjectOptions(projects, project.id, depth + 1))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function flattenProjectOptions(projects, parentId = '', depth = 1) {
|
||||
const byParent = buildProjectsByParent(projects)
|
||||
const result = []
|
||||
for (const project of byParent.get(parentId) || []) {
|
||||
result.push({ project, depth, label: projectOptionLabel(project, depth) })
|
||||
result.push(...flattenProjectOptions(projects, project.id, depth + 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user