feat(backend): enhance task and schedule center APIs with expanded endpoints
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,15 +1,116 @@
|
||||
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, TaskStatus
|
||||
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, TaskUpdate, TaskOut
|
||||
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=["看板"])
|
||||
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])
|
||||
@@ -18,12 +119,28 @@ async def list_tasks(
|
||||
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).where(Task.user_id == current_user.id)
|
||||
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())
|
||||
@@ -32,65 +149,109 @@ async def list_tasks(
|
||||
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="开始日期不能晚于结束日期")
|
||||
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.created_at))
|
||||
|
||||
query = query.order_by(desc(Task.updated_at), desc(Task.created_at))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
tasks = result.scalars().unique().all()
|
||||
return [_task_to_out(task) for task in tasks]
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut, status_code=201)
|
||||
@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),
|
||||
):
|
||||
import json
|
||||
task = Task(
|
||||
user_id=current_user.id,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
priority=data.priority,
|
||||
due_date=data.due_date,
|
||||
tags=json.dumps(data.tags) if data.tags else None,
|
||||
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()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
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.patch("/{task_id}", response_model=TaskOut)
|
||||
@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),
|
||||
):
|
||||
import json
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
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 data.model_dump(exclude_none=True).items():
|
||||
for field, value in payload.items():
|
||||
previous = getattr(task, field)
|
||||
if field == "tags":
|
||||
setattr(task, field, json.dumps(value))
|
||||
elif field == "status" and value == TaskStatus.DONE:
|
||||
task.completed_at = datetime.now(UTC)
|
||||
setattr(task, field, value)
|
||||
elif field == "status":
|
||||
task.completed_at = None
|
||||
setattr(task, field, value)
|
||||
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()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
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)
|
||||
@@ -99,11 +260,171 @@ async def delete_task(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user