Newer
Older
gnexus-tasks / backend / app / api / tasks.py
"""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, TaskCreate, TaskOut, TaskUpdate
from app.services.detailing import apply_proposal, detail_task

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


@router.post("")
async def create_task(
    schema: TaskCreate, db: DbDep, user: UserDep, background: BackgroundTasks
) -> dict[str, int]:
    """Быстрый захват: достаточно title — задача попадает в стек (raw, to_do).

    Сразу в фоне запускается автодетализация (LLM-предложение метаданных).
    """
    task = Task(title=schema.title, description=schema.description)
    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),
) -> 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)
    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 "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.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}