From 6eeddfdc76e15d850edc402eb2ea08d16a66c0b9 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 6 Jul 2026 08:19:05 +0200 Subject: [PATCH] =?UTF-8?q?AP1.5:=20Project,=20Task=20und=20Ausf=C3=BChrun?= =?UTF-8?q?g=20als=20Ist-Kern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema 013; Projekte/Arbeitspakete/Aufgaben pflegbar; Tab Zielzustände vs Ausführung. Co-authored-by: Cursor --- backend/main.py | 5 + backend/migrations/013_projects_and_tasks.sql | 45 +++ .../operating_model_ops.py | 18 ++ backend/routers/actions.py | 55 ++++ backend/routers/initiatives.py | 2 + backend/routers/projects.py | 119 ++++++++ backend/routers/tasks.py | 69 +++++ backend/services/actions.py | 48 ++- backend/services/projects.py | 283 ++++++++++++++++++ backend/services/tasks.py | 273 +++++++++++++++++ backend/tests/test_ap15_hierarchy.py | 75 +++++ backend/tests/test_rights_registry.py | 4 +- backend/version.py | 4 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 10 +- frontend/src/api/projects.js | 23 ++ frontend/src/api/tasks.js | 23 ++ frontend/src/components/ActionForm.jsx | 15 + .../src/components/InitiativeActionsHub.jsx | 13 +- frontend/src/components/InitiativeSubNav.jsx | 2 +- frontend/src/components/ProjectsSection.jsx | 124 ++++++++ .../src/components/RoadmapPlanSection.jsx | 6 +- frontend/src/components/TasksSection.jsx | 151 ++++++++++ frontend/src/constants/status.js | 8 + .../context/InitiativeOperationsContext.jsx | 58 +++- frontend/src/pages/ActionDetailPage.jsx | 57 ++-- .../initiative/InitiativeExecutionPage.jsx | 17 +- .../initiative/RoadmapItemDetailPage.jsx | 2 +- 27 files changed, 1459 insertions(+), 50 deletions(-) create mode 100644 backend/migrations/013_projects_and_tasks.sql create mode 100644 backend/routers/projects.py create mode 100644 backend/routers/tasks.py create mode 100644 backend/services/projects.py create mode 100644 backend/services/tasks.py create mode 100644 backend/tests/test_ap15_hierarchy.py create mode 100644 frontend/src/api/projects.js create mode 100644 frontend/src/api/tasks.js create mode 100644 frontend/src/components/ProjectsSection.jsx create mode 100644 frontend/src/components/TasksSection.jsx diff --git a/backend/main.py b/backend/main.py index b60b845..52f24e5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -68,11 +68,13 @@ from routers import ( # noqa: E402 initiatives, me, milestones, + projects, roadmap, prompts, recurring, reviews, steering, + tasks, workspace, ) @@ -83,6 +85,9 @@ app.include_router(prompts.router) app.include_router(config.router) app.include_router(initiatives.router) app.include_router(actions.router) +app.include_router(projects.initiative_router) +app.include_router(projects.items_router) +app.include_router(tasks.router) app.include_router(blockers.router) app.include_router(backlog.router) app.include_router(milestones.router) diff --git a/backend/migrations/013_projects_and_tasks.sql b/backend/migrations/013_projects_and_tasks.sql new file mode 100644 index 0000000..cd70892 --- /dev/null +++ b/backend/migrations/013_projects_and_tasks.sql @@ -0,0 +1,45 @@ +-- AP1.5: Project, Task, Action.project_id — operative Ist-Hierarchie + +CREATE TABLE projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'paused', 'completed', 'archived')), + roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL, + sort_order INT NOT NULL DEFAULT 0, + target_date DATE NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_projects_tenant_initiative ON projects(tenant_id, initiative_id); +CREATE INDEX idx_projects_tenant_status ON projects(tenant_id, status); +CREATE INDEX idx_projects_roadmap_item ON projects(tenant_id, roadmap_item_id) + WHERE roadmap_item_id IS NOT NULL; + +ALTER TABLE actions + ADD COLUMN project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX idx_actions_project ON actions(tenant_id, project_id) + WHERE project_id IS NOT NULL; + +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'done', 'discarded')), + roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL, + sort_order INT NOT NULL DEFAULT 0, + due_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_tasks_tenant_action ON tasks(tenant_id, action_id); +CREATE INDEX idx_tasks_tenant_status ON tasks(tenant_id, status); diff --git a/backend/rights_registrations/operating_model_ops.py b/backend/rights_registrations/operating_model_ops.py index 5320c6a..d0c01e3 100644 --- a/backend/rights_registrations/operating_model_ops.py +++ b/backend/rights_registrations/operating_model_ops.py @@ -171,3 +171,21 @@ register_capability( default_grants=(("portal", "admin"),), ) ) + +register_capability( + CapabilityRegistration( + key="kairo.project.read", + module="project", + description="Projekte im aktiven Tenant lesen", + default_grants=_MEMBER_READ, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.project.manage", + module="project", + description="Projekte anlegen und bearbeiten", + default_grants=_MEMBER_MANAGE, + ) +) diff --git a/backend/routers/actions.py b/backend/routers/actions.py index b741400..71c1793 100644 --- a/backend/routers/actions.py +++ b/backend/routers/actions.py @@ -10,6 +10,7 @@ from data_layer import actions as dl_actions from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from services import actions as action_service +from services import tasks as task_service from tenant_context import TenantContext router = APIRouter(prefix="/api/actions", tags=["actions"]) @@ -24,6 +25,17 @@ class ActionUpdateRequest(BaseModel): priority: Optional[Literal["low", "normal", "high"]] = None due_at: Optional[str] = None clear_due_at: bool = False + project_id: Optional[str] = None + clear_project: bool = False + + +class TaskCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + description: str = "" + status: Literal["open", "in_progress", "done", "discarded"] = "open" + roadmap_item_id: Optional[str] = None + sort_order: int = 0 + due_at: Optional[str] = None class ActionAssignmentsRequest(BaseModel): @@ -40,6 +52,47 @@ def list_my_open_actions( raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.get("/{action_id}/tasks") +def list_action_tasks( + action_id: str, + ctx: TenantContext = Depends(require_capability("kairo.action.read")), +): + try: + return task_service.list_tasks_for_action( + tenant_id=ctx.tenant_id, action_id=action_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{action_id}/tasks", status_code=201) +def create_action_task( + action_id: str, + body: TaskCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + due_at = None + if body.due_at: + try: + due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc + try: + return task_service.create_task( + tenant_id=ctx.tenant_id, + action_id=action_id, + user_id=ctx.user_id, + title=body.title, + description=body.description, + status=body.status, + roadmap_item_id=body.roadmap_item_id, + sort_order=body.sort_order, + due_at=due_at, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.get("/{action_id}") def get_action( action_id: str, @@ -74,6 +127,8 @@ def update_action( priority=body.priority, due_at=due_at, clear_due_at=body.clear_due_at, + project_id=body.project_id, + clear_project=body.clear_project, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 5721df2..535cd96 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -48,6 +48,7 @@ class ActionCreateRequest(BaseModel): ] = "open" priority: Literal["low", "normal", "high"] = "normal" due_at: Optional[str] = None + project_id: Optional[str] = None assigned_actor_ids: list[str] = Field(default_factory=list) @@ -249,6 +250,7 @@ def create_initiative_action( status=body.status, priority=body.priority, due_at=due_at, + project_id=body.project_id, assigned_actor_ids=body.assigned_actor_ids, user_id=ctx.user_id, ) diff --git a/backend/routers/projects.py b/backend/routers/projects.py new file mode 100644 index 0000000..fc78bb2 --- /dev/null +++ b/backend/routers/projects.py @@ -0,0 +1,119 @@ +"""Project API — AP1.5.""" + +from __future__ import annotations + +from datetime import date +from typing import Literal, Optional + +from capabilities import require_capability +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from services import projects as project_service +from tenant_context import TenantContext + +initiative_router = APIRouter(prefix="/api/initiatives", tags=["projects"]) +items_router = APIRouter(prefix="/api/projects", tags=["projects"]) + + +class ProjectCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + description: str = "" + status: Literal["active", "paused", "completed", "archived"] = "active" + roadmap_item_id: Optional[str] = None + sort_order: int = 0 + target_date: Optional[date] = None + + +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 + roadmap_item_id: Optional[str] = None + clear_roadmap_item: bool = False + sort_order: Optional[int] = None + target_date: Optional[date] = None + clear_target_date: bool = False + + +@initiative_router.get("/{initiative_id}/projects") +def list_initiative_projects( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.project.read")), +): + try: + return project_service.list_projects_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@initiative_router.post("/{initiative_id}/projects", status_code=201) +def create_initiative_project( + initiative_id: str, + body: ProjectCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.project.manage")), +): + try: + return project_service.create_project( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + user_id=ctx.user_id, + title=body.title, + description=body.description, + status=body.status, + roadmap_item_id=body.roadmap_item_id, + sort_order=body.sort_order, + target_date=body.target_date, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@items_router.get("/{project_id}") +def get_project( + project_id: str, + ctx: TenantContext = Depends(require_capability("kairo.project.read")), +): + item = project_service.get_project(tenant_id=ctx.tenant_id, project_id=project_id) + if not item: + raise HTTPException(status_code=404, detail="Projekt nicht gefunden") + return item + + +@items_router.patch("/{project_id}") +def update_project( + project_id: str, + body: ProjectUpdateRequest, + ctx: TenantContext = Depends(require_capability("kairo.project.manage")), +): + try: + item = project_service.update_project( + tenant_id=ctx.tenant_id, + project_id=project_id, + user_id=ctx.user_id, + title=body.title, + description=body.description, + status=body.status, + roadmap_item_id=body.roadmap_item_id, + clear_roadmap_item=body.clear_roadmap_item, + sort_order=body.sort_order, + target_date=body.target_date, + clear_target_date=body.clear_target_date, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Projekt nicht gefunden") + return item + + +@items_router.delete("/{project_id}", status_code=204) +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 + ): + raise HTTPException(status_code=404, detail="Projekt nicht gefunden") diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py new file mode 100644 index 0000000..af27c5f --- /dev/null +++ b/backend/routers/tasks.py @@ -0,0 +1,69 @@ +"""Task item API — AP1.5.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Optional + +from capabilities import require_capability +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from services import tasks as task_service +from tenant_context import TenantContext + +router = APIRouter(prefix="/api/tasks", tags=["tasks"]) + + +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 + roadmap_item_id: Optional[str] = None + clear_roadmap_item: bool = False + sort_order: Optional[int] = None + due_at: Optional[str] = None + clear_due_at: bool = False + + +@router.patch("/{task_id}") +def update_task( + task_id: str, + body: TaskUpdateRequest, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + due_at = None + if body.due_at is not None: + try: + due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc + try: + item = task_service.update_task( + tenant_id=ctx.tenant_id, + task_id=task_id, + user_id=ctx.user_id, + title=body.title, + description=body.description, + status=body.status, + roadmap_item_id=body.roadmap_item_id, + clear_roadmap_item=body.clear_roadmap_item, + sort_order=body.sort_order, + due_at=due_at, + clear_due_at=body.clear_due_at, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Task nicht gefunden") + return item + + +@router.delete("/{task_id}", status_code=204) +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 + ): + raise HTTPException(status_code=404, detail="Task nicht gefunden") diff --git a/backend/services/actions.py b/backend/services/actions.py index 6bab3aa..5c29d75 100644 --- a/backend/services/actions.py +++ b/backend/services/actions.py @@ -22,14 +22,14 @@ OPEN_ACTION_STATUSES = frozenset( ) _ACTION_COLUMNS = """ - id, tenant_id, initiative_id, title, description, + id, tenant_id, initiative_id, project_id, title, description, status, priority, due_at, created_at, updated_at """ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "initiative_id", "owner_actor_id"): + for key in ("id", "tenant_id", "initiative_id", "project_id", "owner_actor_id"): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): @@ -99,6 +99,27 @@ def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool: conn.close() +def _validate_project_in_initiative( + *, tenant_id: str, initiative_id: str, project_id: Optional[str] +) -> None: + if not project_id: + return + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT 1 FROM projects + WHERE id = %s AND tenant_id = %s AND initiative_id = %s + """, + (project_id, tenant_id, initiative_id), + ) + if not cur.fetchone(): + raise ValueError("Projekt gehört nicht zum Vorhaben") + finally: + conn.close() + + def create_action( *, tenant_id: str, @@ -108,6 +129,7 @@ def create_action( status: ActionStatus = "open", priority: str = "normal", due_at: Optional[Any] = None, + project_id: Optional[str] = None, assigned_actor_ids: Optional[list[str]] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: @@ -118,6 +140,9 @@ def create_action( _validate_priority(priority) if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") + _validate_project_in_initiative( + tenant_id=tenant_id, initiative_id=initiative_id, project_id=project_id + ) assigned_actor_ids = assigned_actor_ids or [] for actor_id in assigned_actor_ids: @@ -130,12 +155,13 @@ def create_action( cur.execute( f""" INSERT INTO actions ( - tenant_id, initiative_id, title, description, status, priority, due_at + tenant_id, initiative_id, project_id, title, description, + status, priority, due_at ) - VALUES (%s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_ACTION_COLUMNS} """, - (tenant_id, initiative_id, title, description, status, priority, due_at), + (tenant_id, initiative_id, project_id, title, description, status, priority, due_at), ) row = _serialize_row(dict(cur.fetchone())) for actor_id in assigned_actor_ids: @@ -221,6 +247,8 @@ def update_action( priority: Optional[str] = None, due_at: Optional[Any] = None, clear_due_at: bool = False, + project_id: Optional[str] = None, + clear_project: bool = False, ) -> Optional[dict[str, Any]]: existing = get_action(tenant_id=tenant_id, action_id=action_id) if not existing: @@ -252,6 +280,16 @@ def update_action( elif due_at is not None: updates.append("due_at = %s") params.append(due_at) + if clear_project: + updates.append("project_id = NULL") + elif project_id is not None: + _validate_project_in_initiative( + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + project_id=project_id, + ) + updates.append("project_id = %s") + params.append(project_id) if not updates: return existing diff --git a/backend/services/projects.py b/backend/services/projects.py new file mode 100644 index 0000000..09d5cf5 --- /dev/null +++ b/backend/services/projects.py @@ -0,0 +1,283 @@ +"""Project service — operative Struktur unter Initiative (AP1.5).""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.audit import log_audit +from services.initiatives import get_initiative + +ProjectStatus = Literal["active", "paused", "completed", "archived"] +PROJECT_STATUSES = frozenset({"active", "paused", "completed", "archived"}) + +_PROJECT_COLUMNS = """ + id, tenant_id, initiative_id, 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"): + if result.get(key): + result[key] = str(result[key]) + if result.get("target_date"): + result["target_date"] = ( + result["target_date"].isoformat() + if hasattr(result["target_date"], "isoformat") + else str(result["target_date"]) + ) + for ts in ("created_at", "updated_at"): + if result.get(ts): + result[ts] = result[ts].isoformat() + return result + + +def _validate_status(status: str) -> None: + if status not in PROJECT_STATUSES: + raise ValueError(f"Ungültiger Project-Status: {status}") + + +def _validate_roadmap_item( + cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str] +) -> None: + if not roadmap_item_id: + return + cur.execute( + """ + SELECT 1 FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s + """, + (roadmap_item_id, tenant_id, initiative_id), + ) + if not cur.fetchone(): + raise ValueError("Gate/Plan-Element gehört nicht zu diesem Vorhaben") + + +def create_project( + *, + tenant_id: str, + initiative_id: str, + title: str, + description: str = "", + status: ProjectStatus = "active", + roadmap_item_id: Optional[str] = None, + sort_order: int = 0, + target_date: Optional[date] = None, + user_id: Optional[str] = None, +) -> dict[str, Any]: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + _validate_status(status) + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + _validate_roadmap_item( + cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + roadmap_item_id=roadmap_item_id, + ) + cur.execute( + f""" + INSERT INTO projects ( + tenant_id, initiative_id, title, description, status, + roadmap_item_id, sort_order, target_date + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING {_PROJECT_COLUMNS} + """, + ( + tenant_id, + initiative_id, + title, + description, + status, + roadmap_item_id, + sort_order, + target_date, + ), + ) + row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + log_audit( + "project.created", + user_id=user_id, + tenant_id=tenant_id, + details={"project_id": row["id"], "initiative_id": initiative_id, "title": title}, + ) + return row + + +def list_projects_for_initiative( + *, tenant_id: str, initiative_id: str +) -> list[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + SELECT {_PROJECT_COLUMNS} + FROM projects + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY sort_order ASC, updated_at DESC, title + """, + (tenant_id, initiative_id), + ) + return [_serialize_row(dict(r)) for r in cur.fetchall()] + finally: + conn.close() + + +def get_project(*, tenant_id: str, project_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + SELECT {_PROJECT_COLUMNS} + 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 + finally: + conn.close() + + +def update_project( + *, + tenant_id: str, + project_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + status: Optional[ProjectStatus] = None, + roadmap_item_id: Optional[str] = None, + clear_roadmap_item: bool = False, + sort_order: Optional[int] = None, + target_date: Optional[date] = None, + clear_target_date: bool = False, +) -> Optional[dict[str, Any]]: + existing = get_project(tenant_id=tenant_id, project_id=project_id) + if not existing: + return None + + updates: list[str] = [] + params: list[Any] = [] + + if title is not None: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + updates.append("title = %s") + params.append(title) + if description is not None: + updates.append("description = %s") + params.append(description) + if status is not None: + _validate_status(status) + updates.append("status = %s") + params.append(status) + if clear_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( + cur, + tenant_id=tenant_id, + initiative_id=existing["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: + updates.append("sort_order = %s") + params.append(sort_order) + if clear_target_date: + updates.append("target_date = NULL") + elif target_date is not None: + updates.append("target_date = %s") + params.append(target_date) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([project_id, tenant_id]) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + UPDATE projects SET {", ".join(updates)} + WHERE id = %s AND tenant_id = %s + RETURNING {_PROJECT_COLUMNS} + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + + log_audit( + "project.updated", + user_id=user_id, + tenant_id=tenant_id, + details={"project_id": project_id}, + ) + return result + + +def delete_project( + *, tenant_id: str, project_id: str, user_id: Optional[str] = None +) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "UPDATE actions SET project_id = NULL WHERE project_id = %s AND tenant_id = %s", + (project_id, tenant_id), + ) + cur.execute( + "DELETE FROM projects WHERE id = %s AND tenant_id = %s RETURNING id", + (project_id, tenant_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + finally: + conn.close() + + if deleted: + log_audit( + "project.deleted", + user_id=user_id, + tenant_id=tenant_id, + details={"project_id": project_id}, + ) + return deleted diff --git a/backend/services/tasks.py b/backend/services/tasks.py new file mode 100644 index 0000000..a296b64 --- /dev/null +++ b/backend/services/tasks.py @@ -0,0 +1,273 @@ +"""Task service — kleinste Einheit unter Action (AP1.5).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.actions import get_action +from services.audit import log_audit + +TaskStatus = Literal["open", "in_progress", "done", "discarded"] +TASK_STATUSES = frozenset({"open", "in_progress", "done", "discarded"}) + +_TASK_COLUMNS = """ + id, tenant_id, action_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"): + if result.get(key): + result[key] = str(result[key]) + if result.get("due_at"): + result["due_at"] = result["due_at"].isoformat() + for ts in ("created_at", "updated_at"): + if result.get(ts): + result[ts] = result[ts].isoformat() + return result + + +def _validate_status(status: str) -> None: + if status not in TASK_STATUSES: + raise ValueError(f"Ungültiger Task-Status: {status}") + + +def _get_action_or_raise(*, tenant_id: str, action_id: str) -> dict[str, Any]: + action = get_action(tenant_id=tenant_id, action_id=action_id) + if not action: + raise ValueError("Arbeitspaket nicht gefunden") + return action + + +def _validate_roadmap_item_for_action( + cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str] +) -> None: + if not roadmap_item_id: + return + cur.execute( + """ + SELECT 1 FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s + """, + (roadmap_item_id, tenant_id, initiative_id), + ) + if not cur.fetchone(): + raise ValueError("Gate gehört nicht zum Vorhaben des Arbeitspakets") + + +def create_task( + *, + tenant_id: str, + action_id: str, + title: str, + description: str = "", + status: TaskStatus = "open", + roadmap_item_id: Optional[str] = None, + sort_order: int = 0, + due_at: Optional[datetime] = None, + user_id: Optional[str] = None, +) -> dict[str, Any]: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + _validate_status(status) + action = _get_action_or_raise(tenant_id=tenant_id, action_id=action_id) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + _validate_roadmap_item_for_action( + cur, + tenant_id=tenant_id, + initiative_id=action["initiative_id"], + roadmap_item_id=roadmap_item_id, + ) + cur.execute( + f""" + INSERT INTO tasks ( + tenant_id, action_id, title, description, status, + roadmap_item_id, sort_order, due_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING {_TASK_COLUMNS} + """, + ( + tenant_id, + action_id, + title, + description, + status, + roadmap_item_id, + sort_order, + due_at, + ), + ) + row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + log_audit( + "task.created", + user_id=user_id, + tenant_id=tenant_id, + details={"task_id": row["id"], "action_id": action_id, "title": title}, + ) + return row + + +def list_tasks_for_action(*, tenant_id: str, action_id: str) -> list[dict[str, Any]]: + _get_action_or_raise(tenant_id=tenant_id, action_id=action_id) + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + SELECT {_TASK_COLUMNS} + FROM tasks + WHERE tenant_id = %s AND action_id = %s + ORDER BY sort_order ASC, created_at ASC + """, + (tenant_id, action_id), + ) + return [_serialize_row(dict(r)) for r in cur.fetchall()] + finally: + conn.close() + + +def get_task(*, tenant_id: str, task_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id = %s AND tenant_id = %s", + (task_id, tenant_id), + ) + row = cur.fetchone() + return _serialize_row(dict(row)) if row else None + finally: + conn.close() + + +def update_task( + *, + tenant_id: str, + task_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + status: Optional[TaskStatus] = None, + roadmap_item_id: Optional[str] = None, + clear_roadmap_item: bool = False, + sort_order: Optional[int] = None, + due_at: Optional[datetime] = None, + clear_due_at: bool = False, +) -> Optional[dict[str, Any]]: + existing = get_task(tenant_id=tenant_id, task_id=task_id) + if not existing: + return None + + action = _get_action_or_raise(tenant_id=tenant_id, action_id=existing["action_id"]) + updates: list[str] = [] + params: list[Any] = [] + + if title is not None: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + updates.append("title = %s") + params.append(title) + if description is not None: + updates.append("description = %s") + params.append(description) + if status is not None: + _validate_status(status) + updates.append("status = %s") + params.append(status) + if clear_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: + updates.append("sort_order = %s") + params.append(sort_order) + if clear_due_at: + updates.append("due_at = NULL") + elif due_at is not None: + updates.append("due_at = %s") + params.append(due_at) + + if not updates: + 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: + cur.execute( + f""" + UPDATE tasks SET {", ".join(updates)} + WHERE id = %s AND tenant_id = %s + RETURNING {_TASK_COLUMNS} + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + + log_audit( + "task.updated", + user_id=user_id, + tenant_id=tenant_id, + details={"task_id": task_id}, + ) + return result + + +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( + "DELETE FROM tasks WHERE id = %s AND tenant_id = %s RETURNING id", + (task_id, tenant_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + finally: + conn.close() + + if deleted: + log_audit( + "task.deleted", + user_id=user_id, + tenant_id=tenant_id, + details={"task_id": task_id}, + ) + return deleted diff --git a/backend/tests/test_ap15_hierarchy.py b/backend/tests/test_ap15_hierarchy.py new file mode 100644 index 0000000..eeb0950 --- /dev/null +++ b/backend/tests/test_ap15_hierarchy.py @@ -0,0 +1,75 @@ +"""AP1.5 — Project, Task, Action.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_project_and_action_hierarchy(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + project = client.post( + f"/api/initiatives/{initiative_id}/projects", + json={"title": "Karate Grundlagen", "description": "8 Fähigkeiten"}, + headers=_auth(token), + ) + assert project.status_code == 201 + project_id = project.json()["id"] + + action = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Kata Heian Shodan", "project_id": project_id}, + headers=_auth(token), + ) + assert action.status_code == 201 + assert action.json()["project_id"] == project_id + + task = client.post( + f"/api/actions/{action.json()['id']}/tasks", + json={"title": "Standfußarbeit üben"}, + headers=_auth(token), + ) + assert task.status_code == 201 + assert task.json()["status"] == "open" + + listed = client.get( + f"/api/actions/{action.json()['id']}/tasks", + headers=_auth(token), + ) + assert listed.status_code == 200 + assert len(listed.json()) == 1 + + projects = client.get( + f"/api/initiatives/{initiative_id}/projects", + headers=_auth(token), + ) + assert projects.status_code == 200 + assert len(projects.json()) == 1 + + +def test_initiative_without_projects_still_works(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + action = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Freistehende Aufgabe"}, + headers=_auth(token), + ) + assert action.status_code == 201 + assert action.json().get("project_id") is None + + projects = client.get( + f"/api/initiatives/{initiative_id}/projects", + headers=_auth(token), + ) + assert projects.status_code == 200 + assert projects.json() == [] diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py index 706929f..1bb1db5 100644 --- a/backend/tests/test_rights_registry.py +++ b/backend/tests/test_rights_registry.py @@ -45,6 +45,8 @@ def test_registry_contains_initial_capabilities(): "kairo.recurring.manage", "kairo.roadmap.reopen", "kairo.roadmap.reopen.emergency", + "kairo.project.read", + "kairo.project.manage", } @@ -56,7 +58,7 @@ def test_sync_is_idempotent(): try: with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM capabilities") - assert cur.fetchone()[0] == 35 + assert cur.fetchone()[0] == 37 cur.execute("SELECT COUNT(*) FROM role_capability_grants") assert cur.fetchone()[0] >= 5 finally: diff --git a/backend/version.py b/backend/version.py index 0f8fd6e..946151a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.13.1-ap1.4b" -DB_SCHEMA_VERSION = "012" +APP_VERSION = "0.14.0-ap1.5" +DB_SCHEMA_VERSION = "013" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index a63eb3f..3b118ef 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -1,7 +1,7 @@ # Kairo — Implementation Truth Table v0.1 **Status:** living document — bei jedem AP aktualisieren -**Stand:** 2026-07-06 (nach AP1.4b) +**Stand:** 2026-07-06 (nach AP1.5) **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 012 | +| Migrationen nummeriert | ✓ | Schema 013 | --- @@ -39,8 +39,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Element | Stand | Anmerkung | |---------|-------|-----------| | Initiative | ✓ | | -| Project | ○ | Schema/API teils; **keine GUI** | -| Action | ✓ | UI-Label „Maßnahme“ | +| Project | ◐ | AP1.5 GUI + API | +| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) | | ActionAssignment | ✓ | | | BacklogItem | ✓ | | | Blocker | ✓ | `action_id` optional | @@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Decision | ✓ | | | Review | ✓ | | | RecurringElement | ✓ | | -| Task (unter Action) | ✗ | | +| Task (unter Action) | ◐ | AP1.5 minimal GUI | | Roadmap | ◐ | 1 pro Initiative; Migration 010 | | RoadmapItem | ◐ | Verify über Checkliste AP1.4b | | RoadmapItem Dependencies | ◐ | requires/blocks/related | diff --git a/frontend/src/api/projects.js b/frontend/src/api/projects.js new file mode 100644 index 0000000..490d84c --- /dev/null +++ b/frontend/src/api/projects.js @@ -0,0 +1,23 @@ +import { apiFetch } from './client.js' + +export function listInitiativeProjects(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/projects`) +} + +export function createInitiativeProject(initiativeId, body) { + return apiFetch(`/api/initiatives/${initiativeId}/projects`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function updateProject(projectId, body) { + return apiFetch(`/api/projects/${projectId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +export function deleteProject(projectId) { + return apiFetch(`/api/projects/${projectId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/api/tasks.js b/frontend/src/api/tasks.js new file mode 100644 index 0000000..344662e --- /dev/null +++ b/frontend/src/api/tasks.js @@ -0,0 +1,23 @@ +import { apiFetch } from './client.js' + +export function listActionTasks(actionId) { + return apiFetch(`/api/actions/${actionId}/tasks`) +} + +export function createActionTask(actionId, body) { + return apiFetch(`/api/actions/${actionId}/tasks`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function updateTask(taskId, body) { + return apiFetch(`/api/tasks/${taskId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +export function deleteTask(taskId) { + return apiFetch(`/api/tasks/${taskId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx index 0c7c8fb..2752fc3 100644 --- a/frontend/src/components/ActionForm.jsx +++ b/frontend/src/components/ActionForm.jsx @@ -11,6 +11,7 @@ function isoToLocalInput(iso) { export function ActionForm({ initial = {}, + projects = [], actors = [], actorsLoading = false, actorsError = null, @@ -31,6 +32,7 @@ export function ActionForm({ status: form.status.value, priority: form.priority.value, due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null, + project_id: form.project_id?.value || undefined, assigned_actor_ids: selected, }) } @@ -75,6 +77,19 @@ export function ActionForm({ defaultValue={isoToLocalInput(initial.due_at)} /> + {projects.length > 0 && ( + + )}
-

Maßnahmen — operative Arbeit

+

Arbeitspakete

- Blocker, Nachweise und Reviews hängen an der Maßnahme, nicht lose im Vorhaben. + Committete operative Einheit — Aufgaben im Detail. Blocker, Nachweise und Reviews + hängen am Arbeitspaket.

@@ -50,7 +52,7 @@ export function InitiativeActionsHub({ className="btn btn-primary btn-block-mobile" onClick={onToggleForm} > - {showForm ? 'Abbrechen' : 'Maßnahme anlegen'} + {showForm ? 'Abbrechen' : 'Arbeitspaket'} )}
@@ -59,6 +61,7 @@ export function InitiativeActionsHub({ {showForm && canManage && (
)} {actions.length === 0 && ( - + )}
diff --git a/frontend/src/components/InitiativeSubNav.jsx b/frontend/src/components/InitiativeSubNav.jsx index 2be8770..e60511a 100644 --- a/frontend/src/components/InitiativeSubNav.jsx +++ b/frontend/src/components/InitiativeSubNav.jsx @@ -2,7 +2,7 @@ import { NavLink, useParams } from 'react-router-dom' const TABS = [ { to: '', label: 'Steuerung', end: true }, - { to: 'plan', label: 'Plan' }, + { to: 'plan', label: 'Zielzustände' }, { to: 'execution', label: 'Ausführung' }, { to: 'inbox', label: 'Eingang' }, { to: 'journey', label: 'Nachvollziehbarkeit' }, diff --git a/frontend/src/components/ProjectsSection.jsx b/frontend/src/components/ProjectsSection.jsx new file mode 100644 index 0000000..3f4037f --- /dev/null +++ b/frontend/src/components/ProjectsSection.jsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { EmptyState } from './EmptyState.jsx' +import { StatusBadge } from './StatusBadge.jsx' + +export function ProjectsSection({ + projects, + selectedProjectId, + onSelectProject, + canManage, + onCreate, + onDelete, + busy, +}) { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [showForm, setShowForm] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + if (!title.trim()) return + await onCreate({ + title: title.trim(), + description: description.trim(), + }) + setTitle('') + setDescription('') + setShowForm(false) + } + + return ( +
+
+
+

Projekte

+

+ Operative Struktur — Arbeitspakete gruppieren. Zielzustände (Gates) sind optional. +

+
+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ +