import json from datetime import UTC, date, datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database import get_db from app.models.task import ( Task, TaskAssigneeType, TaskDispatchStatus, TaskQuadrant, TaskSource, TaskStatus, TaskSubTask, ) from app.models.user import User from app.routers.auth import get_current_user from app.schemas.task import ( TaskCreate, TaskDetailOut, TaskDispatchRequest, TaskDispatchResponse, TaskHistoryOut, TaskOut, TaskSubTaskCreate, TaskSubTaskOut, TaskSubTaskReorderRequest, TaskSubTaskUpdate, TaskUpdate, build_task_detail_out, ) from app.services.task_dispatch import append_task_history, load_task_with_details, queue_task_dispatch router = APIRouter(prefix="/api/tasks", tags=["Tasks"]) def _encode_tags(tags: list[str] | None) -> str | None: if not tags: return None return json.dumps(tags, ensure_ascii=False) def _decode_tags(value: str | None) -> list[str]: if not value: return [] try: payload = json.loads(value) except json.JSONDecodeError: return [value] if isinstance(payload, list): return [str(item) for item in payload] return [str(payload)] def _subtask_to_out(subtask: TaskSubTask) -> TaskSubTaskOut: return TaskSubTaskOut.model_validate(subtask) def _history_to_out(history) -> TaskHistoryOut: return TaskHistoryOut.model_validate(history) def _task_to_out(task: Task) -> TaskOut: return TaskOut( id=task.id, title=task.title, description=task.description, status=task.status, priority=task.priority, due_date=task.due_date, completed_at=task.completed_at, tags=_decode_tags(task.tags), source=task.source or TaskSource.MANUAL, conversation_id=task.conversation_id, quadrant=task.quadrant, assignee_type=task.assignee_type, assignee_id=task.assignee_id, dispatch_status=task.dispatch_status or TaskDispatchStatus.IDLE, dispatch_run_id=task.dispatch_run_id, result_summary=task.result_summary, started_at=task.started_at, last_synced_at=task.last_synced_at, created_at=task.created_at, updated_at=task.updated_at, ) def _task_detail_to_out(task: Task) -> TaskDetailOut: return build_task_detail_out(task) async def _get_task_or_404(db: AsyncSession, *, task_id: str, user_id: str) -> Task: task = await load_task_with_details(db, task_id=task_id, user_id=user_id) if task is None: raise HTTPException(status_code=404, detail="Task not found") return task def _sync_task_completion(task: Task) -> None: if task.status == TaskStatus.DONE: task.completed_at = task.completed_at or datetime.now(UTC) elif task.status != TaskStatus.CANCELLED: task.completed_at = None def _sync_subtask_completion(subtask: TaskSubTask) -> None: if subtask.status == TaskStatus.DONE: subtask.completed_at = subtask.completed_at or datetime.now(UTC) elif subtask.status != TaskStatus.CANCELLED: subtask.completed_at = None @router.get("", response_model=list[TaskOut]) async def list_tasks( status: TaskStatus | None = None, due_date: date | None = Query(default=None), date_from: date | None = Query(default=None), date_to: date | None = Query(default=None), quadrant: TaskQuadrant | None = None, assignee_type: TaskAssigneeType | None = None, dispatch_status: TaskDispatchStatus | None = None, conversation_id: str | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): query = ( select(Task) .options(selectinload(Task.subtasks), selectinload(Task.history)) .where(Task.user_id == current_user.id) ) if status: query = query.where(Task.status == status) if quadrant: query = query.where(Task.quadrant == quadrant) if assignee_type: query = query.where(Task.assignee_type == assignee_type) if dispatch_status: query = query.where(Task.dispatch_status == dispatch_status) if conversation_id: query = query.where(Task.conversation_id == conversation_id) if due_date: start = datetime.combine(due_date, datetime.min.time()) end = datetime.combine(due_date, datetime.max.time()) query = query.where(Task.due_date.is_not(None), Task.due_date >= start, Task.due_date <= end) else: start = datetime.combine(date_from, datetime.min.time()) if date_from else None end = datetime.combine(date_to, datetime.max.time()) if date_to else None if start and end and start > end: raise HTTPException(status_code=400, detail="date_from cannot be later than date_to") if start is not None: query = query.where(Task.due_date.is_not(None), Task.due_date >= start) if end is not None: query = query.where(Task.due_date.is_not(None), Task.due_date <= end) query = query.order_by(desc(Task.updated_at), desc(Task.created_at)) result = await db.execute(query) tasks = result.scalars().unique().all() return [_task_to_out(task) for task in tasks] @router.post("", response_model=TaskDetailOut, status_code=201) async def create_task( data: TaskCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): task = Task( user_id=current_user.id, title=data.title, description=data.description, priority=data.priority, due_date=data.due_date, tags=_encode_tags(data.tags), source=data.source, conversation_id=data.conversation_id, quadrant=data.quadrant, assignee_type=data.assignee_type, assignee_id=data.assignee_id, status=data.status, ) _sync_task_completion(task) if data.source == TaskSource.CHAT: append_task_history(task, action="created_from_chat", new_value=task.title) append_task_history(task, action="created", new_value=task.title) for index, subtask_data in enumerate(data.subtasks): subtask = TaskSubTask( title=subtask_data.title, description=subtask_data.description, status=subtask_data.status, order_index=index if subtask_data.order_index is None else subtask_data.order_index, assignee_type=subtask_data.assignee_type, assignee_id=subtask_data.assignee_id, ) _sync_subtask_completion(subtask) task.subtasks.append(subtask) append_task_history(task, action="subtask_created", new_value=subtask.title) db.add(task) await db.commit() task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id) if data.dispatch_to_commander: await queue_task_dispatch(task, db=db) task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id) return _task_detail_to_out(task) @router.get("/{task_id}", response_model=TaskDetailOut) async def get_task( task_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) return _task_detail_to_out(task) @router.patch("/{task_id}", response_model=TaskDetailOut) async def update_task( task_id: str, data: TaskUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) payload = data.model_dump(exclude_none=True) previous_assignee = (task.assignee_type, task.assignee_id) for field, value in payload.items(): previous = getattr(task, field) if field == "tags": task.tags = _encode_tags(value) append_task_history(task, action="updated", old_value=_decode_tags(previous), new_value=value) continue setattr(task, field, value) if field == "status": _sync_task_completion(task) append_task_history(task, action="status_changed", old_value=previous, new_value=value) elif previous != value: append_task_history(task, action="updated", old_value=previous, new_value=value) if ("assignee_type" in payload or "assignee_id" in payload) and previous_assignee != (task.assignee_type, task.assignee_id): append_task_history( task, action="assigned", old_value=f"{previous_assignee[0]}:{previous_assignee[1]}", new_value=f"{task.assignee_type}:{task.assignee_id}", ) await db.commit() task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) return _task_detail_to_out(task) @router.delete("/{task_id}", status_code=204) async def delete_task( task_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) await db.delete(task) await db.commit() @router.post("/{task_id}/subtasks", status_code=201) async def create_subtask( task_id: str, data: TaskSubTaskCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) max_order = max((item.order_index for item in task.subtasks), default=-1) subtask = TaskSubTask( task_id=task.id, title=data.title, description=data.description, status=data.status, order_index=max_order + 1 if data.order_index is None else data.order_index, assignee_type=data.assignee_type, assignee_id=data.assignee_id, ) _sync_subtask_completion(subtask) task.subtasks.append(subtask) append_task_history(task, action="subtask_created", new_value=data.title) await db.commit() db.expire_all() task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) detail = _task_detail_to_out(task) created_subtask = max( (item for item in detail.subtasks if item.title == data.title), key=lambda item: (item.order_index, item.created_at), default=None, ) if created_subtask is None: raise HTTPException(status_code=500, detail="Created subtask could not be loaded") return { **created_subtask.model_dump(), "task": detail.model_dump(), "subtasks": [item.model_dump() for item in detail.subtasks], "history": [item.model_dump() for item in detail.history], "dispatch": detail.dispatch.model_dump(), "dispatch_summary": detail.dispatch_summary.model_dump(), } @router.patch("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut) async def update_subtask( task_id: str, subtask_id: str, data: TaskSubTaskUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) subtask = next((item for item in task.subtasks if item.id == subtask_id), None) if subtask is None: raise HTTPException(status_code=404, detail="Subtask not found") payload = data.model_dump(exclude_none=True) for field, value in payload.items(): previous = getattr(subtask, field) setattr(subtask, field, value) if field == "status": _sync_subtask_completion(subtask) if previous != value: append_task_history( task, action="updated" if field != "status" else "status_changed", old_value=f"{subtask.id}:{field}:{previous}", new_value=f"{subtask.id}:{field}:{value}", ) await db.commit() db.expire_all() task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) return _task_detail_to_out(task) @router.delete("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut) async def delete_subtask( task_id: str, subtask_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) subtask = next((item for item in task.subtasks if item.id == subtask_id), None) if subtask is None: raise HTTPException(status_code=404, detail="Subtask not found") append_task_history(task, action="updated", old_value="subtask_deleted", new_value=subtask.title) await db.delete(subtask) await db.commit() db.expire_all() task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) return _task_detail_to_out(task) @router.post("/{task_id}/subtasks/reorder", response_model=TaskDetailOut) async def reorder_subtasks( task_id: str, data: TaskSubTaskReorderRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) lookup = {item.id: item for item in task.subtasks} for item in data.items: subtask = lookup.get(item.id) if subtask is None: raise HTTPException(status_code=404, detail=f"Subtask not found: {item.id}") subtask.order_index = item.order_index append_task_history( task, action="subtask_reordered", new_value=",".join(f"{item.id}:{item.order_index}" for item in data.items), ) await db.commit() db.expire_all() task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) return _task_detail_to_out(task) @router.post("/{task_id}/dispatch", response_model=TaskDispatchResponse) async def dispatch_task( task_id: str, data: TaskDispatchRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if data.target != "commander": raise HTTPException(status_code=400, detail="Only commander dispatch is supported") task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) _, payload = await queue_task_dispatch(task, db=db) task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) return TaskDispatchResponse( status=task.dispatch_status, run_id=task.dispatch_run_id, task=_task_detail_to_out(task), payload=payload, ) @router.post("/{task_id}/subtasks/{subtask_id}/dispatch", response_model=TaskDispatchResponse) async def dispatch_subtask( task_id: str, subtask_id: str, data: TaskDispatchRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if data.target != "commander": raise HTTPException(status_code=400, detail="Only commander dispatch is supported") task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) subtask = next((item for item in task.subtasks if item.id == subtask_id), None) if subtask is None: raise HTTPException(status_code=404, detail="Subtask not found") _, payload = await queue_task_dispatch(task, db=db, subtask=subtask) task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) return TaskDispatchResponse( status=subtask.dispatch_status, run_id=subtask.dispatch_run_id, task=_task_detail_to_out(task), payload=payload, )