diff --git a/backend/migrations/018_parent_task_id.sql b/backend/migrations/018_parent_task_id.sql new file mode 100644 index 0000000..474aae4 --- /dev/null +++ b/backend/migrations/018_parent_task_id.sql @@ -0,0 +1,12 @@ +-- AP1.5d: Rekursive Tasks unter Action + +ALTER TABLE tasks + ADD COLUMN IF NOT EXISTS parent_task_id UUID NULL + REFERENCES tasks (id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_tasks_parent + ON tasks (tenant_id, parent_task_id) + WHERE parent_task_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_tasks_action_parent + ON tasks (tenant_id, action_id, parent_task_id); diff --git a/backend/routers/actions.py b/backend/routers/actions.py index d908fe3..e68b246 100644 --- a/backend/routers/actions.py +++ b/backend/routers/actions.py @@ -35,6 +35,7 @@ class TaskCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal["open", "in_progress", "done", "discarded"] = "open" + parent_task_id: Optional[str] = None roadmap_item_id: Optional[str] = None sort_order: int = 0 due_at: Optional[str] = None @@ -87,6 +88,7 @@ def create_action_task( title=body.title, description=body.description, status=body.status, + parent_task_id=body.parent_task_id, roadmap_item_id=body.roadmap_item_id, sort_order=body.sort_order, due_at=due_at, diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index af27c5f..13c6e9f 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -18,6 +18,8 @@ class TaskUpdateRequest(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=255) description: Optional[str] = None status: Optional[Literal["open", "in_progress", "done", "discarded"]] = None + parent_task_id: Optional[str] = None + clear_parent_task: bool = False roadmap_item_id: Optional[str] = None clear_roadmap_item: bool = False sort_order: Optional[int] = None @@ -45,6 +47,8 @@ def update_task( title=body.title, description=body.description, status=body.status, + parent_task_id=body.parent_task_id, + clear_parent_task=body.clear_parent_task, roadmap_item_id=body.roadmap_item_id, clear_roadmap_item=body.clear_roadmap_item, sort_order=body.sort_order, @@ -63,7 +67,11 @@ def delete_task( task_id: str, ctx: TenantContext = Depends(require_capability("kairo.action.manage")), ): - if not task_service.delete_task( - tenant_id=ctx.tenant_id, task_id=task_id, user_id=ctx.user_id - ): + try: + deleted = task_service.delete_task( + tenant_id=ctx.tenant_id, task_id=task_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="Task nicht gefunden") diff --git a/backend/services/tasks.py b/backend/services/tasks.py index a296b64..f07e8bd 100644 --- a/backend/services/tasks.py +++ b/backend/services/tasks.py @@ -1,4 +1,4 @@ -"""Task service — kleinste Einheit unter Action (AP1.5).""" +"""Task service — kleinste Einheit unter Action (AP1.5, AP1.5d rekursiv).""" from __future__ import annotations @@ -13,16 +13,18 @@ from services.audit import log_audit TaskStatus = Literal["open", "in_progress", "done", "discarded"] TASK_STATUSES = frozenset({"open", "in_progress", "done", "discarded"}) +TERMINAL_TASK_STATUSES = frozenset({"done", "discarded"}) +MAX_TASK_DEPTH = 10 _TASK_COLUMNS = """ - id, tenant_id, action_id, title, description, status, + id, tenant_id, action_id, parent_task_id, title, description, status, roadmap_item_id, sort_order, 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", "action_id", "roadmap_item_id"): + for key in ("id", "tenant_id", "action_id", "parent_task_id", "roadmap_item_id"): if result.get(key): result[key] = str(result[key]) if result.get("due_at"): @@ -62,6 +64,142 @@ def _validate_roadmap_item_for_action( raise ValueError("Gate gehört nicht zum Vorhaben des Arbeitspakets") +def _load_action_task_maps( + cur, *, tenant_id: str, action_id: str +) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]: + cur.execute( + f""" + SELECT {_TASK_COLUMNS} + FROM tasks + WHERE tenant_id = %s AND action_id = %s + """, + (tenant_id, action_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_task_id") + if parent_id: + by_parent.setdefault(str(parent_id), []).append(str(row["id"])) + return by_id, by_parent + + +def _collect_descendant_ids(task_id: str, by_parent: dict[str, list[str]]) -> set[str]: + result: set[str] = set() + stack = list(by_parent.get(task_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 _task_depth_from_map( + task_id: str, + by_id: dict[str, dict[str, Any]], + memo: dict[str, int], +) -> int: + if task_id in memo: + return memo[task_id] + row = by_id.get(task_id) + if not row or not row.get("parent_task_id"): + memo[task_id] = 1 + return 1 + parent_id = str(row["parent_task_id"]) + depth = 1 + _task_depth_from_map(parent_id, by_id, memo) + memo[task_id] = depth + return depth + + +def _subtree_height(task_id: str, by_parent: dict[str, list[str]]) -> int: + children = by_parent.get(task_id, []) + if not children: + return 1 + return 1 + max(_subtree_height(child_id, by_parent) for child_id in children) + + +def _validate_parent_task( + cur, + *, + tenant_id: str, + action_id: str, + task_id: Optional[str], + parent_task_id: Optional[str], +) -> None: + if not parent_task_id: + return + if task_id and parent_task_id == task_id: + raise ValueError("Task kann nicht sein eigener Parent sein") + + cur.execute( + f""" + SELECT {_TASK_COLUMNS} + FROM tasks + WHERE id = %s AND tenant_id = %s AND action_id = %s + """, + (parent_task_id, tenant_id, action_id), + ) + parent = cur.fetchone() + if not parent: + raise ValueError("Parent-Task gehört nicht zu diesem Arbeitspaket") + + by_id, by_parent = _load_action_task_maps(cur, tenant_id=tenant_id, action_id=action_id) + if task_id: + descendants = _collect_descendant_ids(task_id, by_parent) + if parent_task_id in descendants: + raise ValueError("Zyklus: Parent darf kein Nachfahr sein") + + memo: dict[str, int] = {} + parent_depth = _task_depth_from_map(parent_task_id, by_id, memo) + subtree_height = _subtree_height(task_id, by_parent) if task_id else 1 + if parent_depth + subtree_height > MAX_TASK_DEPTH: + raise ValueError(f"Maximale Task-Tiefe ({MAX_TASK_DEPTH}) würde überschritten") + + +def _has_open_children(cur, *, tenant_id: str, task_id: str) -> bool: + cur.execute( + """ + SELECT 1 FROM tasks + WHERE tenant_id = %s AND parent_task_id = %s + AND status NOT IN ('done', 'discarded') + LIMIT 1 + """, + (tenant_id, task_id), + ) + return cur.fetchone() is not None + + +def _rollup_parent_status(cur, *, tenant_id: str, parent_task_id: str) -> None: + cur.execute( + """ + SELECT status FROM tasks + WHERE tenant_id = %s AND parent_task_id = %s + """, + (tenant_id, parent_task_id), + ) + child_statuses = [row[0] for row in cur.fetchall()] + if not child_statuses: + return + if any(status not in TERMINAL_TASK_STATUSES for status in child_statuses): + return + + cur.execute( + """ + UPDATE tasks + SET status = 'done', updated_at = NOW() + WHERE id = %s AND tenant_id = %s AND status NOT IN ('done', 'discarded') + RETURNING parent_task_id + """, + (parent_task_id, tenant_id), + ) + row = cur.fetchone() + if row and row[0]: + _rollup_parent_status(cur, tenant_id=tenant_id, parent_task_id=str(row[0])) + + def create_task( *, tenant_id: str, @@ -69,6 +207,7 @@ def create_task( title: str, description: str = "", status: TaskStatus = "open", + parent_task_id: Optional[str] = None, roadmap_item_id: Optional[str] = None, sort_order: int = 0, due_at: Optional[datetime] = None, @@ -83,6 +222,13 @@ def create_task( conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: + _validate_parent_task( + cur, + tenant_id=tenant_id, + action_id=action_id, + task_id=None, + parent_task_id=parent_task_id, + ) _validate_roadmap_item_for_action( cur, tenant_id=tenant_id, @@ -92,15 +238,16 @@ def create_task( cur.execute( f""" INSERT INTO tasks ( - tenant_id, action_id, title, description, status, + tenant_id, action_id, parent_task_id, title, description, status, roadmap_item_id, sort_order, due_at ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_TASK_COLUMNS} """, ( tenant_id, action_id, + parent_task_id, title, description, status, @@ -118,7 +265,12 @@ def create_task( "task.created", user_id=user_id, tenant_id=tenant_id, - details={"task_id": row["id"], "action_id": action_id, "title": title}, + details={ + "task_id": row["id"], + "action_id": action_id, + "title": title, + "parent_task_id": parent_task_id, + }, ) return row @@ -164,6 +316,8 @@ def update_task( title: Optional[str] = None, description: Optional[str] = None, status: Optional[TaskStatus] = None, + parent_task_id: Optional[str] = None, + clear_parent_task: bool = False, roadmap_item_id: Optional[str] = None, clear_roadmap_item: bool = False, sort_order: Optional[int] = None, @@ -189,22 +343,14 @@ def update_task( params.append(description) if status is not None: _validate_status(status) - updates.append("status = %s") - params.append(status) + if clear_parent_task: + updates.append("parent_task_id = NULL") + elif parent_task_id is not None: + updates.append("parent_task_id = %s") + params.append(parent_task_id) if clear_roadmap_item: updates.append("roadmap_item_id = NULL") elif roadmap_item_id is not None: - conn = get_connection() - try: - with conn.cursor() as cur: - _validate_roadmap_item_for_action( - cur, - tenant_id=tenant_id, - initiative_id=action["initiative_id"], - roadmap_item_id=roadmap_item_id, - ) - finally: - conn.close() updates.append("roadmap_item_id = %s") params.append(roadmap_item_id) if sort_order is not None: @@ -216,15 +362,39 @@ def update_task( updates.append("due_at = %s") params.append(due_at) - if not updates: - return existing - - updates.append("updated_at = NOW()") - params.extend([task_id, tenant_id]) - conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: + if parent_task_id is not None and not clear_parent_task: + _validate_parent_task( + cur, + tenant_id=tenant_id, + action_id=existing["action_id"], + task_id=task_id, + parent_task_id=parent_task_id, + ) + if roadmap_item_id is not None and not clear_roadmap_item: + _validate_roadmap_item_for_action( + cur, + tenant_id=tenant_id, + initiative_id=action["initiative_id"], + roadmap_item_id=roadmap_item_id, + ) + if status is not None: + if status == "done" and _has_open_children( + cur, tenant_id=tenant_id, task_id=task_id + ): + raise ValueError( + "Task kann nicht abgeschlossen werden — offene Unteraufgaben vorhanden" + ) + updates.append("status = %s") + params.append(status) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([task_id, tenant_id]) cur.execute( f""" UPDATE tasks SET {", ".join(updates)} @@ -237,6 +407,13 @@ def update_task( if not row: return None result = _serialize_row(dict(row)) + + if status == "done" and result.get("parent_task_id"): + _rollup_parent_status( + cur, + tenant_id=tenant_id, + parent_task_id=str(result["parent_task_id"]), + ) conn.commit() finally: conn.close() @@ -247,13 +424,25 @@ def update_task( tenant_id=tenant_id, details={"task_id": task_id}, ) - return result + return get_task(tenant_id=tenant_id, task_id=task_id) def delete_task(*, tenant_id: str, task_id: str, user_id: Optional[str] = None) -> bool: conn = get_connection() try: with conn.cursor() as cur: + cur.execute( + """ + SELECT 1 FROM tasks + WHERE parent_task_id = %s AND tenant_id = %s + LIMIT 1 + """, + (task_id, tenant_id), + ) + if cur.fetchone(): + raise ValueError( + "Task hat Unteraufgaben — zuerst löschen oder verschieben" + ) cur.execute( "DELETE FROM tasks WHERE id = %s AND tenant_id = %s RETURNING id", (task_id, tenant_id), diff --git a/backend/tests/test_ap15d_recursive_tasks.py b/backend/tests/test_ap15d_recursive_tasks.py new file mode 100644 index 0000000..8aff849 --- /dev/null +++ b/backend/tests/test_ap15d_recursive_tasks.py @@ -0,0 +1,108 @@ +"""Rekursive Tasks (AP1.5d).""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def _create_action(client, token, initiative_id, title="AP Alpha"): + res = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": title}, + headers=_auth(token), + ) + assert res.status_code == 201 + return res.json() + + +def _create_task(client, token, action_id, **payload): + return client.post( + f"/api/actions/{action_id}/tasks", + json=payload, + headers=_auth(token), + ) + + +def test_nested_tasks_and_rollup(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative = _create_initiative(client, token, title="Task Tree").json() + action = _create_action(client, token, initiative["id"]) + + parent = _create_task(client, token, action["id"], title="Parent").json() + child = _create_task( + client, + token, + action["id"], + title="Child", + parent_task_id=parent["id"], + ) + assert child.status_code == 201 + child_id = child.json()["id"] + + blocked = client.patch( + f"/api/tasks/{parent['id']}", + json={"status": "done"}, + headers=_auth(token), + ) + assert blocked.status_code == 400 + + done_child = client.patch( + f"/api/tasks/{child_id}", + json={"status": "done"}, + headers=_auth(token), + ) + assert done_child.status_code == 200 + + parent_after = client.get( + f"/api/actions/{action['id']}/tasks", + headers=_auth(token), + ).json() + parent_row = next(item for item in parent_after if item["id"] == parent["id"]) + assert parent_row["status"] == "done" + + +def test_task_cycle_rejected(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative = _create_initiative(client, token, title="Cycle").json() + action = _create_action(client, token, initiative["id"]) + + a = _create_task(client, token, action["id"], title="A").json() + b = _create_task( + client, + token, + action["id"], + title="B", + parent_task_id=a["id"], + ).json() + + res = client.patch( + f"/api/tasks/{a['id']}", + json={"parent_task_id": b["id"]}, + headers=_auth(token), + ) + assert res.status_code == 400 + + +def test_delete_task_with_children_rejected(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative = _create_initiative(client, token, title="Delete Guard").json() + action = _create_action(client, token, initiative["id"]) + + parent = _create_task(client, token, action["id"], title="Parent").json() + _create_task( + client, + token, + action["id"], + title="Child", + parent_task_id=parent["id"], + ) + + res = client.delete( + f"/api/tasks/{parent['id']}", + headers=_auth(token), + ) + assert res.status_code == 400 diff --git a/backend/version.py b/backend/version.py index efad8a3..3db984c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.17.0-ap1.10c" -DB_SCHEMA_VERSION = "017" +APP_VERSION = "0.17.0-ap1.5d" +DB_SCHEMA_VERSION = "018" APP_NAME = "jinkendo-kairo" diff --git a/frontend/package.json b/frontend/package.json index 567f095..a02683f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.17.0-ap1.10c", + "version": "0.17.0-ap1.5d", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/ActionTaskPanel.jsx b/frontend/src/components/ActionTaskPanel.jsx new file mode 100644 index 0000000..0f180bf --- /dev/null +++ b/frontend/src/components/ActionTaskPanel.jsx @@ -0,0 +1,194 @@ +import { useEffect, useMemo, useState } from 'react' +import { + createActionTask, + deleteTask, + listActionTasks, + updateTask, +} from '../api/tasks.js' +import { buildTasksByParent } from '../utils/taskTree.js' +import { Modal } from './Modal.jsx' +import { TaskForm, TaskTreeNodes } from './TaskForm.jsx' + +export function ActionTaskPanel({ + actionId, + roadmapItems, + canManage, + expanded, + onToggle, + openTaskCount, +}) { + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [modal, setModal] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + + useEffect(() => { + if (!expanded || !actionId) return undefined + let cancelled = false + setLoading(true) + setError(null) + listActionTasks(actionId) + .then((data) => { + if (!cancelled) setTasks(data) + }) + .catch((err) => { + if (!cancelled) setError(err.message) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [expanded, actionId, reloadKey]) + + const tasksByParent = useMemo(() => buildTasksByParent(tasks), [tasks]) + + async function reload() { + setReloadKey((value) => value + 1) + } + + async function handleCreate(payload) { + setBusy(true) + setError(null) + try { + await createActionTask(actionId, payload) + setModal(null) + await reload() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + async function handleUpdate(taskId, payload) { + setBusy(true) + setError(null) + try { + await updateTask(taskId, payload) + setModal(null) + await reload() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + async function handleStatus(taskId, status) { + setBusy(true) + setError(null) + try { + await updateTask(taskId, { status }) + await reload() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + async function handleDelete(taskId) { + setBusy(true) + setError(null) + try { + await deleteTask(taskId) + await reload() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + const taskLabel = openTaskCount != null ? ` (${openTaskCount} offen)` : '' + + return ( +
{error}
} + {loading &&Aufgaben werden geladen …
} + {!loading && tasks.length === 0 && ( +Noch keine Aufgaben für dieses Arbeitspaket.
+ )} + {!loading && tasks.length > 0 && ( +- Committete Arbeitspakete im Plan-Kontext — Detail und Ausführung über die - Objektseite. + Committete Arbeitspakete und zugehörige Aufgaben — Ausführung über die Objektseite.
- {formatProjectContext(projects, action.project_id)} -
- {action.roadmap_item_id && ( +- Gate: {gateTitleById(roadmapItems, action.roadmap_item_id)} + {formatProjectContext(projects, action.project_id)}
- )} - {action.description && ( -{action.description}
- )} -+ Gate: {gateTitleById(roadmapItems, action.roadmap_item_id)} +
+ )} + {action.description && ( +{action.description}
+ )} +