Newer
Older
gnexus-tasks / backend / app / services / tasklog.py
"""Журнал действий по задачам (ТЗ 3.20): кто, что и когда.

Единственная точка записи в `task_events` — и для REST, и для MCP: иначе «кто
закрыл задачу» отвечалось бы по-разному в двух путях. Запись идёт в ту же
транзакцию, что и само действие; коммитит вызывающий.

Заголовок хранится снапшотом: запись должна читаться после удаления задачи.
"""

from sqlalchemy.orm import Session

from app.actor import Actor
from app.models import Task, TaskEvent

KIND_CREATED = "created"
KIND_ELIGIBLE = "eligible_changed"
KIND_CLAIMED = "claimed"
KIND_RELEASED = "released"
KIND_COMPLETED = "completed"
KIND_ACCEPTED = "accepted"
KIND_REJECTED = "rejected"
KIND_STATUS = "status_changed"
KIND_DELETED = "deleted"

MAX_TITLE = 500
MAX_NAME = 100
MAX_COMMENT = 2000
MAX_KIND = 24


def status_transition(old: str, new: str) -> str:
    """Переход статуса машинной записью («to_do→in_progress»).

    Коды, а не слова: интерфейс подпишет их своим языком, а журнал не хранит
    переводов — записи должны читаться одинаково и через год.
    """
    return f"{old}→{new}"


def _event(
    task: Task,
    kind: str,
    actor: Actor,
    comment: str | None,
    *,
    task_id: int | None,
    project_id: int | None,
) -> TaskEvent:
    return TaskEvent(
        user_id=task.user_id,
        task_id=task_id,
        task_title=task.title[:MAX_TITLE],
        project_id=project_id,
        kind=kind[:MAX_KIND],
        actor_kind=actor.kind,
        actor_name=actor.name[:MAX_NAME],
        via=actor.via,
        comment=comment[:MAX_COMMENT] if comment else None,
    )


def log_event(
    db: Session, task: Task, kind: str, actor: Actor, comment: str | None = None
) -> TaskEvent:
    """Записать действие по задаче (создание, статус, взятие, закрытие, приёмка…)."""
    event = _event(task, kind, actor, comment, task_id=task.id, project_id=task.project_id)
    db.add(event)
    return event


def log_deleted(db: Session, task: Task, actor: Actor) -> TaskEvent:
    """Запись об удалении: задача уже уходит, ссылку не оставляем — только снапшот.

    (FK с ON DELETE SET NULL обнулил бы её сам, но в SQLite внешние ключи в
    тестах не проверяются — журнал остался бы ссылаться на несуществующую задачу.)
    """
    event = _event(task, KIND_DELETED, actor, None, task_id=None, project_id=task.project_id)
    db.add(event)
    return event