"""API задач M1/M2: быстрый захват, стек, детализация (LLM-предложение), CRUD."""
from typing import Any, cast
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from sqlalchemy import select
from app.dependencies import DbDep, UserDep
from app.models import Project, Tag, Task, utcnow
from app.schemas import ApproveIn, SuggestIn, TaskCreate, TaskOut, TaskUpdate
from app.services.detailing import apply_proposal, detail_task
from app.services.predict import pick_options, predict_minutes
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"}
def _get_task_or_404(db: Any, task_id: int) -> Task:
task = cast(Task | None, db.get(Task, task_id))
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
def _validate_parent(db: Any, task: Task | None, parent_id: int | None) -> None:
"""Родитель должен существовать; циклы в дереве запрещены."""
if parent_id is None:
return
if task is not None and parent_id == task.id:
raise HTTPException(status_code=400, detail="Task cannot be its own parent")
parent = cast(Task | None, db.get(Task, parent_id))
if parent is None:
raise HTTPException(status_code=400, detail="Unknown parent task")
if task is not None:
ancestor: Task | None = parent
while ancestor is not None:
if ancestor.id == task.id:
raise HTTPException(status_code=400, detail="Cycle in task tree")
ancestor = ancestor.parent
@router.post("")
async def create_task(
schema: TaskCreate, db: DbDep, user: UserDep, background: BackgroundTasks
) -> dict[str, int]:
"""Быстрый захват: достаточно title — задача попадает в стек (raw, to_do).
С parent_task_id — создание подзадачи (M3). Сразу в фоне запускается
автодетализация (LLM-предложение метаданных).
"""
_validate_parent(db, None, schema.parent_task_id)
task = Task(
title=schema.title, description=schema.description, parent_task_id=schema.parent_task_id
)
db.add(task)
db.flush()
# Коммитим до планирования фоновой работы: BackgroundTasks выполняются ДО
# teardown-коммита зависимости get_db, а detail_task открывает свою сессию.
db.commit()
background.add_task(detail_task, task.id)
return {"id": task.id}
@router.get("", response_model=list[TaskOut])
async def list_tasks(
db: DbDep,
user: UserDep,
detail_state: str | None = Query(None),
status: str | None = Query(None),
project_id: int | None = Query(None),
tag_id: int | None = Query(None),
parent_id: int | None = Query(None),
) -> list[Task]:
stmt = select(Task).order_by(Task.created_at.desc())
if detail_state:
stmt = stmt.where(Task.detail_state == detail_state)
if status:
stmt = stmt.where(Task.status == status)
if project_id:
stmt = stmt.where(Task.project_id == project_id)
if parent_id is not None:
stmt = stmt.where(Task.parent_task_id == parent_id)
if tag_id:
stmt = stmt.where(Task.tags.any(Tag.id == tag_id))
return list(db.scalars(stmt).all())
@router.get("/{task_id}", response_model=TaskOut)
async def get_task(task_id: int, db: DbDep, user: UserDep) -> Task:
return _get_task_or_404(db, task_id)
@router.patch("/{task_id}", response_model=TaskOut)
async def update_task(task_id: int, schema: TaskUpdate, db: DbDep, user: UserDep) -> Task:
task = _get_task_or_404(db, task_id)
data = schema.model_dump(exclude_unset=True)
if "title" in data and not str(data["title"]).strip():
raise HTTPException(status_code=422, detail="title cannot be empty")
if "status" in data and data["status"] not in VALID_STATUSES:
raise HTTPException(status_code=422, detail=f"Unknown status: {data['status']}")
if "project_id" in data and data["project_id"] is not None:
if db.get(Project, data["project_id"]) is None:
raise HTTPException(status_code=400, detail="Unknown project")
if "parent_task_id" in data:
_validate_parent(db, task, data["parent_task_id"])
if "tag_ids" in data:
tag_ids = data.pop("tag_ids") or []
tags = db.scalars(select(Tag).where(Tag.id.in_(tag_ids))).all()
if len(tags) != len(set(tag_ids)):
raise HTTPException(status_code=400, detail="Unknown tag id in tag_ids")
task.tags = list(tags)
for field, value in data.items():
setattr(task, field, value)
if data.get("status") == "done" and task.done_at is None:
task.done_at = utcnow()
db.flush()
db.refresh(task) # перечитать связи (project/tags) после обновления
return task
@router.post("/{task_id}/approve", response_model=TaskOut)
async def approve_task(
task_id: int, db: DbDep, user: UserDep, schema: ApproveIn | None = None
) -> Task:
"""Утверждение детализации: raw → approved.
schema.apply_proposal=True — принять предложение автодетализации перед
утверждением (кнопка «Да, всё верно»); иначе предложенные метаданные
игнорируются (после ручной правки их уже применил PATCH).
"""
task = _get_task_or_404(db, task_id)
if schema and schema.apply_proposal and task.ai_proposal:
apply_proposal(db, task, task.ai_proposal)
task.detail_state = "approved"
task.approved_at = utcnow()
db.flush()
db.refresh(task)
return task
@router.post("/{task_id}/redetail", response_model=TaskOut)
async def redetail_task(
task_id: int, db: DbDep, user: UserDep, background: BackgroundTasks
) -> Task:
"""Перезапустить автодетализацию (например, после появления новых тегов/проектов)."""
task = _get_task_or_404(db, task_id)
task.ai_proposal = None
# Коммит до фоновой работы, чтобы worker не прочитал старое состояние
# и его запись не перезаписалась teardown-коммитом (см. create_task).
db.commit()
background.add_task(detail_task, task.id)
return task
@router.post("/{task_id}/predict", response_model=TaskOut)
async def predict_task(task_id: int, db: DbDep, user: UserDep) -> Task:
"""Пересчитать прогноз длительности по истории похожих завершённых задач."""
task = _get_task_or_404(db, task_id)
task.estimated_minutes = predict_minutes(db, task)
db.flush()
db.refresh(task)
return task
@router.post("/suggest", response_model=list[TaskOut])
async def suggest_tasks(schema: SuggestIn, db: DbDep, user: UserDep) -> list[Task]:
"""Режим «3 варианта»: до трёх задач под доступное время."""
return pick_options(db, schema.available_minutes)
@router.delete("/{task_id}")
async def delete_task(task_id: int, db: DbDep, user: UserDep) -> dict[str, bool]:
db.delete(_get_task_or_404(db, task_id))
return {"ok": True}