"""Tasks plugin routes — CRUD, assign, status update, subtasks, dependencies.""" from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.core.db import get_db from app.deps import get_current_user, require_permission, require_workspace_scope from app.plugins.builtins.tasks import services from app.plugins.builtins.tasks.schemas import ( TaskAssignRequest, TaskCreate, TaskDependencyRequest, TaskStatusRequest, TaskUpdate, ) router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) TASK_STATUS_PATTERN = "^(open|in_progress|review|blocked|done|cancelled)$" TASK_TYPE_PATTERN = "^(todo|approval|follow_up|review|goal|milestone|agent_subtask)$" ASSIGNEE_TYPE_PATTERN = "^(user|agent|group)$" def _parse_uuid(val: str, field: str) -> uuid.UUID: try: return uuid.UUID(val) except (ValueError, TypeError): raise HTTPException( 400, detail={"detail": f"Invalid {field}", "code": "invalid_id"} ) from None @router.get("", dependencies=[Depends(require_permission("tasks:read"))]) async def list_tasks( page: int = Query(1, ge=1), page_size: int = Query(25, ge=1, le=100), status: str | None = Query(None, pattern=TASK_STATUS_PATTERN), priority: str | None = Query(None, pattern="^(low|medium|high|urgent)$"), assigned_to: str | None = Query(None), contact_id: str | None = Query(None), entity_type: str | None = Query(None), entity_id: str | None = Query(None), assignee_type: str | None = Query(None, pattern=ASSIGNEE_TYPE_PATTERN), assignee_id: str | None = Query(None), parent_task_id: str | None = Query(None), task_type: str | None = Query(None, pattern=TASK_TYPE_PATTERN), search: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), workspace_scope: dict | None = Depends(require_workspace_scope("tasks")), ): """List tasks with filtering and pagination. Phase N4: an active workspace scope with only_mine=true restricts the list to tasks assigned to or created by the current user (pure AND). """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("is_system_admin", False) return await services.list_tasks( db, tenant_id, page=page, page_size=page_size, status=status, priority=priority, assigned_to=assigned_to, contact_id=contact_id, entity_type=entity_type, entity_id=entity_id, assignee_type=assignee_type, assignee_id=assignee_id, parent_task_id=parent_task_id, task_type=task_type, search=search, user_id=user_id, is_system_admin=is_system_admin, workspace_scope=workspace_scope, ) @router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("tasks:write"))]) async def create_task( body: TaskCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Create a new task.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) data = body.model_dump() result = await services.create_task(db, tenant_id, user_id, data) if result and result.get("id"): await log_audit(db, tenant_id, user_id, "create", "task", uuid.UUID(result["id"]), changes={"title": data.get("title", "")}) return result @router.get("/{task_id}", dependencies=[Depends(require_permission("tasks:read"))]) async def get_task( task_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get a single task by ID.""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(task_id, "task_id") result = await services.get_task(db, tenant_id, tid) if result is None: raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"}) return result @router.patch("/{task_id}", dependencies=[Depends(require_permission("tasks:write"))]) async def update_task( task_id: str, body: TaskUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update a task.""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(task_id, "task_id") data = body.model_dump(exclude_unset=True) result = await services.update_task(db, tenant_id, tid, data) if result is None: raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"}) return result @router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tasks:delete"))]) async def delete_task( task_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Delete a task (soft-delete).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) tid = _parse_uuid(task_id, "task_id") deleted = await services.delete_task(db, tenant_id, tid) if not deleted: raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"}) await log_audit(db, tenant_id, user_id, "delete", "task", tid) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/{task_id}/assign", dependencies=[Depends(require_permission("tasks:write"))]) async def assign_task( task_id: str, body: TaskAssignRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Assign a task to a user, agent, or group (polymorphic).""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(task_id, "task_id") assigned_to = _parse_uuid(body.assigned_to, "assigned_to") if body.assigned_to else None assignee_id = _parse_uuid(body.assignee_id, "assignee_id") if body.assignee_id else None result = await services.assign_task( db, tenant_id, tid, assigned_to, assignee_type=body.assignee_type, assignee_id=assignee_id, ) if result is None: raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"}) return result @router.post("/{task_id}/status", dependencies=[Depends(require_permission("tasks:write"))]) async def update_task_status( task_id: str, body: TaskStatusRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update task status.""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(task_id, "task_id") result = await services.update_task_status(db, tenant_id, tid, body.status) if result is None: raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"}) return result # ── F.14 Subtasks ──────────────────────────────────────────────────────────── @router.post("/{task_id}/subtasks", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("tasks:write"))]) async def create_subtask( task_id: str, body: TaskCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Create a subtask under a parent task.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) tid = _parse_uuid(task_id, "task_id") result = await services.create_subtask(db, tenant_id, user_id, tid, body.model_dump()) if result is None: raise HTTPException(404, detail={"detail": "Parent task not found", "code": "not_found"}) return result @router.get("/{task_id}/subtasks", dependencies=[Depends(require_permission("tasks:read"))]) async def list_subtasks( task_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List subtasks for a parent task.""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(task_id, "task_id") return await services.list_subtasks(db, tenant_id, tid) # ── F.14 Dependencies ──────────────────────────────────────────────────────── @router.post("/{task_id}/dependencies", dependencies=[Depends(require_permission("tasks:write"))]) async def add_dependency( task_id: str, body: TaskDependencyRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Add a dependency (this task depends on another task).""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(task_id, "task_id") dep_id = _parse_uuid(body.depends_on, "depends_on") result = await services.add_dependency(db, tenant_id, tid, dep_id) if result is None: raise HTTPException(404, detail={"detail": "Task or dependency not found", "code": "not_found"}) return result @router.delete("/{task_id}/dependencies/{depends_on}", dependencies=[Depends(require_permission("tasks:write"))]) async def remove_dependency( task_id: str, depends_on: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Remove a dependency from a task.""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(task_id, "task_id") dep_id = _parse_uuid(depends_on, "depends_on") result = await services.remove_dependency(db, tenant_id, tid, dep_id) if result is None: raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"}) return result # ── F.14 Goal decomposition ───────────────────────────────────────────────── @router.post("/{task_id}/decompose", dependencies=[Depends(require_permission("tasks:write"))]) async def decompose_goal( task_id: str, request: Request, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Decompose a goal into subtasks (milestones/todos).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) tid = _parse_uuid(task_id, "task_id") body = await request.json() result = await services.decompose_goal( db, tenant_id, user_id, tid, body ) if result is None: raise HTTPException(404, detail={"detail": "Goal not found", "code": "not_found"}) return result