"""API задач M1/M2: быстрый захват, стек, детализация (LLM-предложение), CRUD."""
from typing import Any, cast
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
from sqlalchemy import Select, or_, select
from app.dependencies import DbDep, UserDep
from app.models import CoinEvent, Project, Tag, Task, utcnow
from app.realtime import publish
from app.schemas import (
DEADLINE_PERIODS,
RECUR_KINDS,
ApproveIn,
SuggestIn,
TaskCreate,
TaskOut,
TaskUpdate,
)
from app.services.closing import handle_task_closed
from app.services.detailing import apply_proposal, detail_task
from app.services.options import pick_options
from app.services.xp import (
coins_for_create,
grant_create_xp,
)
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 _get_task_for_update(db: Any, task_id: int) -> Task:
"""Задача с блокировкой строки (Postgres FOR UPDATE; в SQLite игнорируется).
Параллельные PATCH одной задачи сериализуются: двойное закрытие не гонит
двух наград (уникальный XpEvent.task_id — вторая линия обороны).
"""
task = cast(
Task | None,
# of=Task: запрос тянет project joined-ом — Postgres запрещает FOR UPDATE
# на nullable-стороне outer join, блокируем только строку задачи
db.scalar(select(Task).where(Task.id == task_id).with_for_update(of=Task)),
)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
def _archived_project_ids(db: Any) -> Select[tuple[int]]:
"""id проектов в архиве — их задачи в рабочих видах не показываются."""
return select(Project.id).where(Project.is_archived.is_(True))
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, response: Response
) -> 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()
# Микронаграда за создание (ТЗ 3.13): без конфетти, просто тост —
# заголовки X-Created-* фронт буферизует отдельно от награды за закрытие
grant_create_xp(db, "create_task")
db.add(CoinEvent(source="create_task", amount=coins_for_create("create_task")))
response.headers["X-Created-XP"] = "2"
response.headers["X-Created-Coins"] = "1"
# Коммитим до планирования фоновой работы: BackgroundTasks выполняются ДО
# teardown-коммита зависимости get_db, а detail_task открывает свою сессию.
db.commit()
publish(str(user["user_id"]), "task.changed", {"id": task.id})
publish(str(user["user_id"]), "xp.changed", {"celebrate": False})
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),
include_archived: bool = Query(False),
) -> 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)
elif not include_archived:
# задачи архивных проектов — только в истории (ТЗ 3.11)
stmt = stmt.where(
or_(Task.project_id.is_(None), Task.project_id.not_in(_archived_project_ids(db)))
)
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, response: Response
) -> Task:
task = _get_task_for_update(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 "deadline_period" in data and data["deadline_period"] not in (None,) + DEADLINE_PERIODS:
raise HTTPException(
status_code=422, detail=f"Unknown deadline period: {data['deadline_period']}"
)
if "recur_kind" in data and data["recur_kind"] not in (None,) + RECUR_KINDS:
raise HTTPException(
status_code=422, detail=f"Unknown recurrence rule: {data['recur_kind']}"
)
if "task_type" in data and data["task_type"] not in ("one_time", "recurring"):
raise HTTPException(status_code=422, detail=f"Unknown task type: {data['task_type']}")
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)
# Пометка источника закрытия (геймификация): не поле задачи, в setattr не идёт
earned_via = data.pop("earned_via", None)
earned_event = None
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()
# Регулярная (ТЗ 3.5) + геймификация (ТЗ 3.13) — общий путь с MCP:
# закрытие агентом даёт те же спавн/XP/монеты, что и из UI
outcome = handle_task_closed(db, task, via_options=earned_via == "options")
event = outcome.event
if event is not None:
response.headers["X-Earned-XP"] = str(event.amount)
response.headers["X-Plant-Rarity"] = event.rarity
if event.via_options:
response.headers["X-Options-Close"] = "1"
if task.mentally_hard:
# «Ментально сложная» — фронт украшает праздник («сила воли»)
response.headers["X-Mentally-Hard"] = "1"
response.headers["X-Earned-Coins"] = str(outcome.coins)
earned_event = event
elif data.get("status") not in (None, "done") and task.done_at is not None:
# Выход из done: отметка выполнения сбрасывается. Повторное закрытие
# снова пройдёт через ветку наград, но XP grant_task_xp повторно не
# даст, а спавн удержит spawned_at — дубликатов не будет
task.done_at = None
db.flush()
db.refresh(task) # перечитать связи (project/tags) после обновления
# События после коммита: подписчик SSE может сразу перечитать данные
db.commit()
publish(str(user["user_id"]), "task.changed", {"id": task.id})
if earned_event is not None:
publish(
str(user["user_id"]),
"xp.changed",
{"amount": earned_event.amount, "rarity": earned_event.rarity, "celebrate": True},
)
publish(str(user["user_id"]), "garden.changed", {"reason": "task"})
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)
db.commit()
publish(str(user["user_id"]), "task.changed", {"id": task.id})
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)
if task.detail_state != "raw":
# Утверждённая задача уже детализирована: повтор — no-op, предложение
# не сбрасываем, фоновую LLM не будим (worker всё равно работает
# только с raw)
return task
task.ai_proposal = None
# Коммит до фоновой работы, чтобы worker не прочитал старое состояние
# и его запись не перезаписалась teardown-коммитом (см. create_task).
db.commit()
publish(str(user["user_id"]), "task.changed", {"id": task.id})
background.add_task(detail_task, task.id)
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))
db.commit()
publish(str(user["user_id"]), "task.deleted", {"id": task_id})
return {"ok": True}