Newer
Older
gnexus-tasks / backend / app / models.py
"""Модели данных M1: задачи, проекты, теги.

Дедлайны/повторения/бюджет/вложения добавляются миграциями в следующих вехах
(см. docs/TZ.md, модель данных).
"""

from datetime import UTC, date, datetime
from typing import Any

from sqlalchemy import (
    JSON,
    Boolean,
    Date,
    DateTime,
    ForeignKey,
    Integer,
    String,
    Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base


def utcnow() -> datetime:
    return datetime.now(UTC)


class Task(Base):
    __tablename__ = "tasks"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    title: Mapped[str] = mapped_column(String(500))
    description: Mapped[str] = mapped_column(Text, default="")

    # Тип: разовая / регулярная (регулярные — в следующих вехах)
    task_type: Mapped[str] = mapped_column(String(20), default="one_time")

    # Статус выполнения
    status: Mapped[str] = mapped_column(String(20), default="to_do")
    # Плоскость детализации стека: raw (в стеке) / approved (утверждена)
    detail_state: Mapped[str] = mapped_column(String(20), default="raw")
    done: Mapped[bool] = mapped_column(Boolean, default=False)

    parent_task_id: Mapped[int | None] = mapped_column(
        ForeignKey("tasks.id"), nullable=True, default=None
    )
    parent: Mapped["Task | None"] = relationship(
        "Task", back_populates="children", remote_side="Task.id"
    )
    children: Mapped[list["Task"]] = relationship("Task", back_populates="parent")
    project_id: Mapped[int | None] = mapped_column(
        ForeignKey("projects.id"), nullable=True, default=None
    )
    priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

    # Прогнозы и бюджет (M4). estimated_minutes приходит из LLM-детализации
    # или истории завершённых задач; actual — фактическая затрата времени.
    estimated_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    actual_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    # Бюджет опционален (ТЗ 3.4): деньги задаёт пользователь, оценка затрат — рядом
    # (валюта — глобальная настройка app_settings, суммы — целые)
    budget_money: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    cost_estimate_money: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

    # Дедлайны (ТЗ 3.4) — двух видов: строгий (конкретная дата) и нестрогий
    # («в течение дня/недели/месяца/года», период без конкретного дня)
    deadline_date: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
    deadline_period: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None)

    # Правило повторения регулярной задачи (ТЗ 3.5): фиксированный календарь —
    # следующий экземпляр отсчитывается от даты выполнения по календарю
    recur_kind: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None)
    recur_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    recur_weekdays: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None)
    recur_day_of_month: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

    tags: Mapped[list["Tag"]] = relationship(
        secondary="task_tags", back_populates="tasks", lazy="selectin"
    )
    project: Mapped["Project | None"] = relationship(back_populates="tasks", lazy="joined")

    # Черновое предложение автодетализации (LLM): теги/проект/приоритет.
    # Не окончательные метаданные — применяется только при утверждении.
    ai_proposal: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True, default=None)

    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
    approved_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )
    done_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )


class Project(Base):
    __tablename__ = "projects"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(200), unique=True)
    # Статус актуальности проекта
    relevance_status: Mapped[str] = mapped_column(String(20), default="active")
    priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
    note: Mapped[str] = mapped_column(Text, default="")  # markdown: ссылки, контекст

    tasks: Mapped[list[Task]] = relationship(back_populates="project")

    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class Tag(Base):
    __tablename__ = "tags"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(100), unique=True)

    tasks: Mapped[list[Task]] = relationship(
        secondary="task_tags", back_populates="tags", lazy="selectin"
    )


class TaskTag(Base):
    __tablename__ = "task_tags"

    task_id: Mapped[int] = mapped_column(
        ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True
    )
    tag_id: Mapped[int] = mapped_column(
        ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True
    )


class Attachment(Base):
    __tablename__ = "attachments"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"))
    filename: Mapped[str] = mapped_column(String(255))  # имя на диске (uuid)
    original_name: Mapped[str] = mapped_column(String(255))  # имя при загрузке
    mime: Mapped[str] = mapped_column(String(100))
    size: Mapped[int] = mapped_column(Integer)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)


class AppSetting(Base):
    """Глобальные настройки (единственный пользователь — мультитенантности нет).

    Валюта для бюджетов, например: выбирается один раз и применяется всюду.
    """

    __tablename__ = "app_settings"

    key: Mapped[str] = mapped_column(String(100), primary_key=True)
    value: Mapped[str] = mapped_column(String(200), nullable=False)