"""Модели данных 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,
UniqueConstraint,
)
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)
class XpEvent(Base):
"""Событие начисления XP за закрытую задачу (геймификация, ТЗ 3.13).
Начисляется один раз при первом закрытии задачи; уникальность по task_id
делает начисление идемпотентным (повторное закрытие ничего не добавляет).
Задача может быть удалена — заработанный XP остаётся (only-positive).
"""
__tablename__ = "xp_events"
__table_args__ = (UniqueConstraint("task_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_id: Mapped[int | None] = mapped_column(
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None
)
amount: Mapped[int] = mapped_column(Integer)
# Редкость растения в саду — решается в момент закрытия по весу задачи
rarity: Mapped[str] = mapped_column(String(16), default="common")
# Закрыто из режима «3 вариантов» (ТЗ 3.9) — для счётчика «выбрал и сделал»
via_options: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)