from __future__ import annotations import json from datetime import datetime 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): title: str | None = None description: str | None = None status: TaskStatus | None = None 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): id: str title: str description: str | None status: TaskStatus priority: TaskPriority due_date: datetime | None completed_at: datetime | 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 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, ), )