Files
JARVIS/backend/app/routers/task.py

431 lines
15 KiB
Python

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,
)