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,7 +1,8 @@
|
||||
from sqlalchemy import Column, String, Text, Integer, ForeignKey, DateTime, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.models.base import BaseModel
|
||||
|
||||
|
||||
@@ -19,26 +20,144 @@ class TaskPriority(str, PyEnum):
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class TaskSource(str, PyEnum):
|
||||
MANUAL = "manual"
|
||||
CHAT = "chat"
|
||||
SCHEDULE_CENTER = "schedule_center"
|
||||
TODAY_STATUS = "today_status"
|
||||
COMMANDER = "commander"
|
||||
|
||||
|
||||
class TaskQuadrant(str, PyEnum):
|
||||
URGENT_IMPORTANT = "urgent-important"
|
||||
NOT_URGENT_IMPORTANT = "not-urgent-important"
|
||||
URGENT_NOT_IMPORTANT = "urgent-not-important"
|
||||
NOT_URGENT_NOT_IMPORTANT = "not-urgent-not-important"
|
||||
|
||||
|
||||
class TaskAssigneeType(str, PyEnum):
|
||||
USER = "user"
|
||||
COMMANDER = "commander"
|
||||
AGENT = "agent"
|
||||
PLANNER = "planner"
|
||||
EXECUTOR = "executor"
|
||||
KNOWLEDGE = "knowledge"
|
||||
ANALYST = "analyst"
|
||||
CODER = "coder"
|
||||
RESEARCHER = "researcher"
|
||||
|
||||
|
||||
class TaskDispatchStatus(str, PyEnum):
|
||||
IDLE = "idle"
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
DispatchStatus = TaskDispatchStatus
|
||||
|
||||
|
||||
DispatchStatus = TaskDispatchStatus
|
||||
|
||||
|
||||
class TaskHistoryAction(str, PyEnum):
|
||||
CREATED = "created"
|
||||
CREATED_FROM_CHAT = "created_from_chat"
|
||||
UPDATED = "updated"
|
||||
STATUS_CHANGED = "status_changed"
|
||||
ASSIGNED = "assigned"
|
||||
DELETED = "deleted"
|
||||
SUBTASK_CREATED = "subtask_created"
|
||||
SUBTASK_UPDATED = "subtask_updated"
|
||||
SUBTASK_DELETED = "subtask_deleted"
|
||||
SUBTASK_REORDERED = "subtask_reordered"
|
||||
DISPATCHED_TO_COMMANDER = "dispatched_to_commander"
|
||||
DISPATCH_STATUS_CHANGED = "dispatch_status_changed"
|
||||
|
||||
|
||||
def enum_values(enum_cls: type[PyEnum]) -> list[str]:
|
||||
return [item.value for item in enum_cls]
|
||||
|
||||
|
||||
TASK_STATUS_ENUM = Enum(TaskStatus, values_callable=enum_values)
|
||||
TASK_PRIORITY_ENUM = Enum(TaskPriority, values_callable=enum_values)
|
||||
TASK_SOURCE_ENUM = Enum(TaskSource, values_callable=enum_values)
|
||||
TASK_QUADRANT_ENUM = Enum(TaskQuadrant, values_callable=enum_values)
|
||||
TASK_ASSIGNEE_TYPE_ENUM = Enum(TaskAssigneeType, values_callable=enum_values)
|
||||
TASK_DISPATCH_STATUS_ENUM = Enum(TaskDispatchStatus, values_callable=enum_values)
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(Enum(TaskStatus), default=TaskStatus.TODO, nullable=False, index=True)
|
||||
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM, nullable=False)
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
|
||||
priority = Column(TASK_PRIORITY_ENUM, default=TaskPriority.MEDIUM, nullable=False)
|
||||
due_date = Column(DateTime, nullable=True, index=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
tags = Column(String(1000), nullable=True) # JSON 数组
|
||||
tags = Column(String(1000), nullable=True) # JSON array
|
||||
source = Column(TASK_SOURCE_ENUM, default=TaskSource.MANUAL, nullable=False, index=True)
|
||||
conversation_id = Column(String(36), nullable=True, index=True)
|
||||
quadrant = Column(TASK_QUADRANT_ENUM, nullable=True, index=True)
|
||||
assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True)
|
||||
assignee_id = Column(String(255), nullable=True, index=True)
|
||||
dispatch_status = Column(
|
||||
TASK_DISPATCH_STATUS_ENUM,
|
||||
default=TaskDispatchStatus.IDLE,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
dispatch_run_id = Column(String(64), nullable=True, index=True)
|
||||
result_summary = Column(Text, nullable=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
last_synced_at = Column(DateTime, nullable=True)
|
||||
|
||||
history = relationship("TaskHistory", back_populates="task", cascade="all, delete-orphan")
|
||||
subtasks = relationship(
|
||||
"TaskSubTask",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TaskSubTask.order_index.asc()",
|
||||
)
|
||||
history = relationship(
|
||||
"TaskHistory",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TaskHistory.created_at.desc()",
|
||||
)
|
||||
|
||||
|
||||
class TaskSubTask(BaseModel):
|
||||
__tablename__ = "task_subtasks"
|
||||
|
||||
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
|
||||
order_index = Column(Integer, default=0, nullable=False, index=True)
|
||||
assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True)
|
||||
assignee_id = Column(String(255), nullable=True, index=True)
|
||||
dispatch_status = Column(
|
||||
TASK_DISPATCH_STATUS_ENUM,
|
||||
default=TaskDispatchStatus.IDLE,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
dispatch_run_id = Column(String(64), nullable=True, index=True)
|
||||
result_summary = Column(Text, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
task = relationship("Task", back_populates="subtasks")
|
||||
|
||||
|
||||
class TaskHistory(BaseModel):
|
||||
__tablename__ = "task_histories"
|
||||
|
||||
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
|
||||
action = Column(String(100), nullable=False) # created, status_changed, updated, deleted
|
||||
subtask_id = Column(String(36), ForeignKey("task_subtasks.id"), nullable=True, index=True)
|
||||
action = Column(String(100), nullable=False)
|
||||
old_value = Column(Text, nullable=True)
|
||||
new_value = Column(Text, nullable=True)
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@ from app.routers.agent_sessions import router as agent_sessions_router
|
||||
from app.routers.terminal import router as terminal_router
|
||||
from app.routers.tools import router as tools_router
|
||||
from app.routers.remote_mount import router as remote_mount_router
|
||||
from app.routers.office import router as office_router
|
||||
|
||||
@@ -100,6 +100,7 @@ async def chat(
|
||||
conversation_id=data.conversation_id,
|
||||
file_ids=data.file_ids,
|
||||
model_name=data.model_name,
|
||||
runtime=data.runtime,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
@@ -115,7 +116,7 @@ async def chat(
|
||||
conversation_id=conv_id,
|
||||
message_id=msg_id,
|
||||
content=content,
|
||||
agent_name="jarvis",
|
||||
agent_name=data.runtime or "jarvis",
|
||||
model_name=model_name,
|
||||
)
|
||||
|
||||
@@ -141,6 +142,7 @@ async def chat_stream(
|
||||
conversation_id=data.conversation_id,
|
||||
file_ids=data.file_ids,
|
||||
model_name=data.model_name,
|
||||
runtime=data.runtime,
|
||||
)
|
||||
except ValueError as exc:
|
||||
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
@@ -1,25 +1,62 @@
|
||||
from calendar import monthrange
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.goal import Goal
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.task import Task, TaskPriority
|
||||
from app.models.task import Task, TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus
|
||||
from app.models.todo import DailyTodo
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.schedule_center import (
|
||||
ScheduleCenterCommanderSummaryOut,
|
||||
ScheduleCenterDateOut,
|
||||
ScheduleCenterDaySummary,
|
||||
ScheduleCenterFocusTaskOut,
|
||||
ScheduleCenterMonthOut,
|
||||
ScheduleCenterQuadrantOut,
|
||||
ScheduleCenterQuadrantTaskOut,
|
||||
)
|
||||
from app.schemas.task import build_task_out
|
||||
|
||||
router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"])
|
||||
|
||||
QUADRANT_META: dict[TaskQuadrant, dict[str, str]] = {
|
||||
TaskQuadrant.URGENT_IMPORTANT: {
|
||||
"title": "重要且紧急",
|
||||
"subtitle": "CRITICAL",
|
||||
"color": "#ff4757",
|
||||
"glow_color": "rgba(255, 71, 87, 0.4)",
|
||||
"icon": "◈",
|
||||
},
|
||||
TaskQuadrant.NOT_URGENT_IMPORTANT: {
|
||||
"title": "重要不紧急",
|
||||
"subtitle": "PLANNED",
|
||||
"color": "#ffd93d",
|
||||
"glow_color": "rgba(255, 217, 61, 0.4)",
|
||||
"icon": "◇",
|
||||
},
|
||||
TaskQuadrant.URGENT_NOT_IMPORTANT: {
|
||||
"title": "紧急不重要",
|
||||
"subtitle": "DELEGATE",
|
||||
"color": "#00d4ff",
|
||||
"glow_color": "rgba(0, 212, 255, 0.4)",
|
||||
"icon": "◉",
|
||||
},
|
||||
TaskQuadrant.NOT_URGENT_NOT_IMPORTANT: {
|
||||
"title": "不重要不紧急",
|
||||
"subtitle": "ELIMINATE",
|
||||
"color": "#6bcf7f",
|
||||
"glow_color": "rgba(107, 207, 127, 0.4)",
|
||||
"icon": "○",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_summary(
|
||||
target_date: str,
|
||||
@@ -39,6 +76,146 @@ def _build_summary(
|
||||
)
|
||||
|
||||
|
||||
def _coerce_enum(value, enum_cls, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, enum_cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return default
|
||||
for item in enum_cls:
|
||||
if raw == item.value or raw.lower() == item.value:
|
||||
return item
|
||||
if raw.upper() == item.name:
|
||||
return item
|
||||
return default
|
||||
|
||||
|
||||
def _derive_quadrant(task: Task) -> TaskQuadrant:
|
||||
quadrant = _coerce_enum(task.quadrant, TaskQuadrant, None)
|
||||
if quadrant is not None:
|
||||
return quadrant
|
||||
|
||||
priority = _coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM)
|
||||
status = _coerce_enum(task.status, TaskStatus, TaskStatus.TODO)
|
||||
|
||||
if priority in {TaskPriority.HIGH, TaskPriority.URGENT}:
|
||||
return TaskQuadrant.URGENT_IMPORTANT
|
||||
if status == TaskStatus.IN_PROGRESS:
|
||||
return TaskQuadrant.NOT_URGENT_IMPORTANT
|
||||
if priority == TaskPriority.MEDIUM:
|
||||
return TaskQuadrant.URGENT_NOT_IMPORTANT
|
||||
return TaskQuadrant.NOT_URGENT_NOT_IMPORTANT
|
||||
|
||||
|
||||
def _enum_value(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "value"):
|
||||
return str(value.value)
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
return raw or None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _build_focus_tasks(tasks: list[Task]) -> list[ScheduleCenterFocusTaskOut]:
|
||||
priority_rank = {
|
||||
TaskPriority.URGENT: 0,
|
||||
TaskPriority.HIGH: 1,
|
||||
TaskPriority.MEDIUM: 2,
|
||||
TaskPriority.LOW: 3,
|
||||
}
|
||||
status_rank = {
|
||||
TaskStatus.IN_PROGRESS: 0,
|
||||
TaskStatus.TODO: 1,
|
||||
TaskStatus.DONE: 2,
|
||||
TaskStatus.CANCELLED: 3,
|
||||
}
|
||||
ordered = sorted(
|
||||
tasks,
|
||||
key=lambda item: (
|
||||
status_rank.get(_coerce_enum(item.status, TaskStatus, TaskStatus.TODO), 99),
|
||||
priority_rank.get(_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM), 99),
|
||||
item.created_at,
|
||||
),
|
||||
)
|
||||
return [
|
||||
ScheduleCenterFocusTaskOut(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
|
||||
priority=_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM),
|
||||
quadrant=_derive_quadrant(item),
|
||||
assignee_type=_enum_value(item.assignee_type),
|
||||
assignee_id=item.assignee_id,
|
||||
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
|
||||
due_date=item.due_date,
|
||||
)
|
||||
for item in ordered[:6]
|
||||
]
|
||||
|
||||
|
||||
def _build_quadrants(tasks: list[Task]) -> list[ScheduleCenterQuadrantOut]:
|
||||
buckets: dict[TaskQuadrant, list[ScheduleCenterQuadrantTaskOut]] = {
|
||||
quadrant: [] for quadrant in QUADRANT_META
|
||||
}
|
||||
for task in tasks:
|
||||
quadrant = _derive_quadrant(task)
|
||||
buckets[quadrant].append(
|
||||
ScheduleCenterQuadrantTaskOut(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
|
||||
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
|
||||
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
|
||||
assignee_type=_enum_value(task.assignee_type),
|
||||
assignee_id=task.assignee_id,
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
ScheduleCenterQuadrantOut(
|
||||
id=quadrant,
|
||||
title=meta["title"],
|
||||
subtitle=meta["subtitle"],
|
||||
color=meta["color"],
|
||||
glow_color=meta["glow_color"],
|
||||
icon=meta["icon"],
|
||||
tasks=buckets[quadrant],
|
||||
)
|
||||
for quadrant, meta in QUADRANT_META.items()
|
||||
]
|
||||
|
||||
|
||||
def _build_commander_summary(tasks: list[Task]) -> ScheduleCenterCommanderSummaryOut:
|
||||
counts = ScheduleCenterCommanderSummaryOut()
|
||||
for task in tasks:
|
||||
states = [task.dispatch_status, *(subtask.dispatch_status for subtask in task.subtasks)]
|
||||
for state in states:
|
||||
normalized = _coerce_enum(state, TaskDispatchStatus, TaskDispatchStatus.IDLE)
|
||||
if normalized == TaskDispatchStatus.IDLE:
|
||||
continue
|
||||
counts.total += 1
|
||||
if normalized == TaskDispatchStatus.QUEUED:
|
||||
counts.queued += 1
|
||||
elif normalized == TaskDispatchStatus.RUNNING:
|
||||
counts.running += 1
|
||||
elif normalized == TaskDispatchStatus.COMPLETED:
|
||||
counts.completed += 1
|
||||
elif normalized == TaskDispatchStatus.FAILED:
|
||||
counts.failed += 1
|
||||
if counts.running > 0:
|
||||
counts.overall_status = "running"
|
||||
elif counts.queued > 0:
|
||||
counts.overall_status = "queued"
|
||||
elif counts.failed > 0 and counts.completed == 0:
|
||||
counts.overall_status = "failed"
|
||||
return counts
|
||||
|
||||
|
||||
@router.get("/month", response_model=ScheduleCenterMonthOut)
|
||||
async def get_month_schedule(
|
||||
year: int = Query(..., ge=2000, le=2100),
|
||||
@@ -53,27 +230,43 @@ async def get_month_schedule(
|
||||
start_dt = datetime.combine(month_start, datetime.min.time())
|
||||
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
|
||||
|
||||
todos = (await db.execute(
|
||||
select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key)
|
||||
)).scalars().all()
|
||||
tasks = (await db.execute(
|
||||
select(Task).where(
|
||||
Task.user_id == current_user.id,
|
||||
Task.due_date.is_not(None),
|
||||
Task.due_date >= start_dt,
|
||||
Task.due_date <= end_dt,
|
||||
todos = (
|
||||
await db.execute(
|
||||
select(DailyTodo).where(
|
||||
DailyTodo.user_id == current_user.id,
|
||||
DailyTodo.todo_date >= start_key,
|
||||
DailyTodo.todo_date <= end_key,
|
||||
)
|
||||
)
|
||||
)).scalars().all()
|
||||
reminders = (await db.execute(
|
||||
select(Reminder).where(
|
||||
Reminder.user_id == current_user.id,
|
||||
Reminder.reminder_at >= start_dt,
|
||||
Reminder.reminder_at <= end_dt,
|
||||
).scalars().all()
|
||||
tasks = (
|
||||
await db.execute(
|
||||
select(Task).where(
|
||||
Task.user_id == current_user.id,
|
||||
Task.due_date.is_not(None),
|
||||
Task.due_date >= start_dt,
|
||||
Task.due_date <= end_dt,
|
||||
)
|
||||
)
|
||||
)).scalars().all()
|
||||
goals = (await db.execute(
|
||||
select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key)
|
||||
)).scalars().all()
|
||||
).scalars().all()
|
||||
reminders = (
|
||||
await db.execute(
|
||||
select(Reminder).where(
|
||||
Reminder.user_id == current_user.id,
|
||||
Reminder.reminder_at >= start_dt,
|
||||
Reminder.reminder_at <= end_dt,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
goals = (
|
||||
await db.execute(
|
||||
select(Goal).where(
|
||||
Goal.user_id == current_user.id,
|
||||
Goal.goal_date >= start_key,
|
||||
Goal.goal_date <= end_key,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
todo_map: dict[str, list[DailyTodo]] = {}
|
||||
for item in todos:
|
||||
@@ -96,18 +289,20 @@ async def get_month_schedule(
|
||||
days = []
|
||||
for day in range(1, days_in_month + 1):
|
||||
date_key = month_start.replace(day=day).isoformat()
|
||||
days.append(_build_summary(
|
||||
date_key,
|
||||
todo_map.get(date_key, []),
|
||||
task_map.get(date_key, []),
|
||||
reminder_map.get(date_key, []),
|
||||
goal_map.get(date_key, []),
|
||||
))
|
||||
days.append(
|
||||
_build_summary(
|
||||
date_key,
|
||||
todo_map.get(date_key, []),
|
||||
task_map.get(date_key, []),
|
||||
reminder_map.get(date_key, []),
|
||||
goal_map.get(date_key, []),
|
||||
)
|
||||
)
|
||||
|
||||
return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days)
|
||||
|
||||
|
||||
@router.get("/date", response_model=ScheduleCenterDateOut)
|
||||
@router.get("/date", response_model=ScheduleCenterDateOut, response_model_exclude_none=True)
|
||||
async def get_date_schedule(
|
||||
date_str: date = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -118,43 +313,55 @@ async def get_date_schedule(
|
||||
end_dt = datetime.combine(target_date, datetime.max.time())
|
||||
date_key = target_date.isoformat()
|
||||
|
||||
todos = (await db.execute(
|
||||
select(DailyTodo)
|
||||
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
|
||||
.order_by(DailyTodo.created_at.desc())
|
||||
)).scalars().all()
|
||||
tasks = (await db.execute(
|
||||
select(Task)
|
||||
.where(
|
||||
Task.user_id == current_user.id,
|
||||
Task.due_date.is_not(None),
|
||||
Task.due_date >= start_dt,
|
||||
Task.due_date <= end_dt,
|
||||
todos = (
|
||||
await db.execute(
|
||||
select(DailyTodo)
|
||||
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
|
||||
.order_by(DailyTodo.created_at.desc())
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
)).scalars().all()
|
||||
reminders = (await db.execute(
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.user_id == current_user.id,
|
||||
Reminder.reminder_at >= start_dt,
|
||||
Reminder.reminder_at <= end_dt,
|
||||
).scalars().all()
|
||||
tasks = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.options(selectinload(Task.subtasks), selectinload(Task.history))
|
||||
.where(
|
||||
Task.user_id == current_user.id,
|
||||
Task.due_date.is_not(None),
|
||||
Task.due_date >= start_dt,
|
||||
Task.due_date <= end_dt,
|
||||
)
|
||||
.order_by(Task.priority.desc(), Task.created_at.desc())
|
||||
)
|
||||
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
||||
)).scalars().all()
|
||||
goals = (await db.execute(
|
||||
select(Goal)
|
||||
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
|
||||
.order_by(Goal.created_at.desc())
|
||||
)).scalars().all()
|
||||
).scalars().unique().all()
|
||||
reminders = (
|
||||
await db.execute(
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.user_id == current_user.id,
|
||||
Reminder.reminder_at >= start_dt,
|
||||
Reminder.reminder_at <= end_dt,
|
||||
)
|
||||
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
goals = (
|
||||
await db.execute(
|
||||
select(Goal)
|
||||
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
|
||||
.order_by(Goal.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
summary = _build_summary(date_key, todos, tasks, reminders, goals)
|
||||
return ScheduleCenterDateOut(
|
||||
date=date_key,
|
||||
todos=todos,
|
||||
tasks=tasks,
|
||||
tasks=[build_task_out(task) for task in tasks],
|
||||
reminders=reminders,
|
||||
goals=goals,
|
||||
summary=summary,
|
||||
focus_tasks=_build_focus_tasks(tasks),
|
||||
quadrants=_build_quadrants(tasks),
|
||||
commander_summary=_build_commander_summary(tasks),
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,146 @@
|
||||
from pydantic import BaseModel
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app.models.task import TaskStatus, TaskPriority
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm.attributes import NO_VALUE
|
||||
|
||||
from app.models.task import (
|
||||
Task,
|
||||
TaskAssigneeType,
|
||||
TaskDispatchStatus,
|
||||
TaskHistory,
|
||||
TaskPriority,
|
||||
TaskQuadrant,
|
||||
TaskSource,
|
||||
TaskStatus,
|
||||
TaskSubTask,
|
||||
)
|
||||
|
||||
|
||||
def _coerce_enum(value, enum_cls, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, enum_cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return default
|
||||
for item in enum_cls:
|
||||
if raw == item.value or raw.lower() == item.value:
|
||||
return item
|
||||
if raw.upper() == item.name:
|
||||
return item
|
||||
return default
|
||||
|
||||
|
||||
def parse_tags(raw_tags: str | None) -> list[str]:
|
||||
if not raw_tags:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw_tags)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return [str(item) for item in parsed]
|
||||
|
||||
|
||||
def serialize_tags(tags: list[str] | None) -> str | None:
|
||||
if not tags:
|
||||
return None
|
||||
return json.dumps([str(item) for item in tags], ensure_ascii=False)
|
||||
|
||||
|
||||
class TaskSubTaskCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.TODO
|
||||
order_index: int | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
|
||||
|
||||
class TaskSubTaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
order_index: int | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
dispatch_status: TaskDispatchStatus | None = None
|
||||
dispatch_run_id: str | None = None
|
||||
result_summary: str | None = None
|
||||
|
||||
|
||||
class TaskSubTaskReorderItem(BaseModel):
|
||||
id: str
|
||||
order_index: int
|
||||
|
||||
|
||||
class TaskSubTaskReorderRequest(BaseModel):
|
||||
items: list[TaskSubTaskReorderItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskSubTaskOut(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
title: str
|
||||
description: str | None
|
||||
status: TaskStatus
|
||||
order_index: int
|
||||
assignee_type: TaskAssigneeType | None
|
||||
assignee_id: str | None
|
||||
dispatch_status: TaskDispatchStatus
|
||||
dispatch_run_id: str | None
|
||||
result_summary: str | None = None
|
||||
completed_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskHistoryOut(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
action: str
|
||||
old_value: str | None
|
||||
new_value: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskDispatchSummary(BaseModel):
|
||||
status: TaskDispatchStatus
|
||||
run_id: str | None = None
|
||||
result_summary: str | None = None
|
||||
started_at: datetime | None = None
|
||||
last_synced_at: datetime | None = None
|
||||
total_subtasks: int = 0
|
||||
dispatched_subtasks: int = 0
|
||||
subtask_dispatch_statuses: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.TODO
|
||||
priority: TaskPriority = TaskPriority.MEDIUM
|
||||
due_date: datetime | None = None
|
||||
tags: list[str] | None = None
|
||||
source: TaskSource = TaskSource.MANUAL
|
||||
conversation_id: str | None = None
|
||||
quadrant: TaskQuadrant | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
subtasks: list[TaskSubTaskCreate] = Field(default_factory=list)
|
||||
dispatch_to_commander: bool = False
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
@@ -18,6 +150,16 @@ class TaskUpdate(BaseModel):
|
||||
priority: TaskPriority | None = None
|
||||
due_date: datetime | None = None
|
||||
tags: list[str] | None = None
|
||||
source: TaskSource | None = None
|
||||
conversation_id: str | None = None
|
||||
quadrant: TaskQuadrant | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
dispatch_status: TaskDispatchStatus | None = None
|
||||
dispatch_run_id: str | None = None
|
||||
result_summary: str | None = None
|
||||
started_at: datetime | None = None
|
||||
last_synced_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
@@ -28,12 +170,128 @@ class TaskOut(BaseModel):
|
||||
priority: TaskPriority
|
||||
due_date: datetime | None
|
||||
completed_at: datetime | None
|
||||
tags: str | None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
source: TaskSource
|
||||
conversation_id: str | None
|
||||
quadrant: TaskQuadrant | None
|
||||
assignee_type: TaskAssigneeType | None
|
||||
assignee_id: str | None
|
||||
dispatch_status: TaskDispatchStatus
|
||||
dispatch_run_id: str | None
|
||||
result_summary: str | None
|
||||
started_at: datetime | None
|
||||
last_synced_at: datetime | None
|
||||
subtask_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class TaskDetailOut(TaskOut):
|
||||
subtasks: list[TaskSubTaskOut] = Field(default_factory=list)
|
||||
history: list[TaskHistoryOut] = Field(default_factory=list)
|
||||
dispatch: TaskDispatchSummary
|
||||
dispatch_summary: TaskDispatchSummary
|
||||
|
||||
|
||||
|
||||
|
||||
class TaskDispatchRequest(BaseModel):
|
||||
target: str = "commander"
|
||||
conversation_id: str | None = None
|
||||
assignee_type: TaskAssigneeType | None = None
|
||||
assignee_id: str | None = None
|
||||
|
||||
|
||||
class TaskDispatchResponse(BaseModel):
|
||||
status: TaskDispatchStatus
|
||||
run_id: str | None = None
|
||||
task: TaskDetailOut
|
||||
payload: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DailyPlanRequest(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
def build_task_out(task: Task) -> TaskOut:
|
||||
subtasks_attr = inspect(task).attrs.subtasks.loaded_value
|
||||
return TaskOut(
|
||||
id=task.id,
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
|
||||
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
|
||||
due_date=task.due_date,
|
||||
completed_at=task.completed_at,
|
||||
tags=parse_tags(task.tags),
|
||||
source=_coerce_enum(task.source, TaskSource, TaskSource.MANUAL),
|
||||
conversation_id=task.conversation_id,
|
||||
quadrant=_coerce_enum(task.quadrant, TaskQuadrant, None),
|
||||
assignee_type=_coerce_enum(task.assignee_type, TaskAssigneeType, None),
|
||||
assignee_id=task.assignee_id,
|
||||
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, 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,
|
||||
subtask_count=0 if subtasks_attr is NO_VALUE else len(subtasks_attr or []),
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def build_task_detail_out(task: Task) -> TaskDetailOut:
|
||||
normalized_task_dispatch = _coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE)
|
||||
|
||||
normalized_subtasks = [
|
||||
TaskSubTaskOut(
|
||||
id=item.id,
|
||||
task_id=item.task_id,
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
|
||||
order_index=item.order_index,
|
||||
assignee_type=_coerce_enum(item.assignee_type, TaskAssigneeType, None),
|
||||
assignee_id=item.assignee_id,
|
||||
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
|
||||
dispatch_run_id=item.dispatch_run_id,
|
||||
result_summary=item.result_summary,
|
||||
completed_at=item.completed_at,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
for item in task.subtasks
|
||||
]
|
||||
|
||||
subtask_dispatch_statuses: dict[str, int] = {}
|
||||
for item in normalized_subtasks:
|
||||
key = item.dispatch_status.value
|
||||
subtask_dispatch_statuses[key] = subtask_dispatch_statuses.get(key, 0) + 1
|
||||
|
||||
dispatched_subtasks = sum(1 for item in normalized_subtasks if item.dispatch_status != TaskDispatchStatus.IDLE)
|
||||
|
||||
return TaskDetailOut(
|
||||
**build_task_out(task).model_dump(),
|
||||
subtasks=normalized_subtasks,
|
||||
history=[TaskHistoryOut.model_validate(item) for item in task.history],
|
||||
dispatch=TaskDispatchSummary(
|
||||
status=normalized_task_dispatch,
|
||||
run_id=task.dispatch_run_id,
|
||||
result_summary=task.result_summary,
|
||||
started_at=task.started_at,
|
||||
last_synced_at=task.last_synced_at,
|
||||
total_subtasks=len(normalized_subtasks),
|
||||
dispatched_subtasks=dispatched_subtasks,
|
||||
subtask_dispatch_statuses=subtask_dispatch_statuses,
|
||||
),
|
||||
dispatch_summary=TaskDispatchSummary(
|
||||
status=normalized_task_dispatch,
|
||||
run_id=task.dispatch_run_id,
|
||||
result_summary=task.result_summary,
|
||||
started_at=task.started_at,
|
||||
last_synced_at=task.last_synced_at,
|
||||
total_subtasks=len(normalized_subtasks),
|
||||
dispatched_subtasks=dispatched_subtasks,
|
||||
subtask_dispatch_statuses=subtask_dispatch_statuses,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user