"""Модели данных 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))
# Описание живёт в документе (полиморфная привязка: task/project)
document: Mapped["Document | None"] = relationship(
"Document",
primaryjoin="and_(foreign(Document.owner_id) == Task.id, Document.owner_type == 'task')",
uselist=False,
cascade="all, delete-orphan",
lazy="selectin",
overlaps="document",
)
# Тип: разовая / регулярная (регулярные — в следующих вехах)
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)
# «Ментально сложная» (ТЗ 3.13): метка «не хочется делать» — трудность не по
# объёму, а из-за волевых усилий; даёт бонус к XP при закрытии
mentally_hard: 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
)
# Метка «следующий экземпляр регулярной уже создан» (ТЗ 3.5): спавн — один
# раз на цепочку, повторное закрытие/обновление закрытой не дублирует экземпляр
spawned_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
@property
def description(self) -> str:
return self.document.body if self.document else ""
@description.setter
def description(self, value: str) -> None:
if self.document is None:
self.document = Document(owner_type="task", body=value)
else:
self.document.body = value
self.document.updated_at = utcnow()
@property
def document_id(self) -> int | None:
return self.document.id if self.document else None
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200), unique=True)
# Заметка — тоже документ (markdown: ссылки, контекст, картинки)
document: Mapped["Document | None"] = relationship(
"Document",
primaryjoin=(
"and_(foreign(Document.owner_id) == Project.id, Document.owner_type == 'project')"
),
uselist=False,
cascade="all, delete-orphan",
lazy="selectin",
overlaps="document",
)
@property
def note(self) -> str:
return self.document.body if self.document else ""
@note.setter
def note(self, value: str) -> None:
if self.document is None:
self.document = Document(owner_type="project", body=value)
else:
self.document.body = value
self.document.updated_at = utcnow()
@property
def document_id(self) -> int | None:
return self.document.id if self.document else None
# Статус актуальности проекта («Закрыт» теперь выражается архивом)
relevance_status: Mapped[str] = mapped_column(String(20), default="active")
# Архив (ТЗ 3.11): проект уезжает в историю со всеми задачами
is_archived: Mapped[bool] = mapped_column(Boolean, default=False)
priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
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 Document(Base):
"""Markdown-текст с вложениями, привязанный к владельцу (задача/проект).
Описания задач и заметки проектов — одна сущность: текст и его картинки
живут вместе, вложения ссылаются на документ (см. docs/TZ.md, модель данных).
"""
__tablename__ = "documents"
__table_args__ = (UniqueConstraint("owner_type", "owner_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_type: Mapped[str] = mapped_column(String(20)) # 'task' | 'project'
owner_id: Mapped[int] = mapped_column(Integer)
body: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
attachments: Mapped[list["Attachment"]] = relationship(
back_populates="document", cascade="all, delete-orphan"
)
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
document_id: Mapped[int] = mapped_column(ForeignKey("documents.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)
document: Mapped[Document] = relationship(back_populates="attachments")
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
)
# Событие награды: закрытие задачи или создание (задача/проект).
# Растения в саду растут только из событий kind='task'.
kind: Mapped[str] = mapped_column(String(20), default="task", server_default="task")
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)
class CoinEvent(Base):
"""Событие монет (геймификация, ТЗ 3.13): журнал и заработка, и трат.
Монеты — валюта сада, отдельная от XP: опыт только растёт, монеты тратятся.
Баланс = сумма amount по всем событиям. Начисление за задачу — один раз
(вместе с XpEvent), бонус за уровень — один раз на уровень (уникальность
в сервисе), траты — отрицательные amount (source='shop').
"""
__tablename__ = "coin_events"
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
)
# Бонус за уровень (source='level') — какой уровень достигнут
level: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
source: Mapped[str] = mapped_column(String(16), default="task") # task | level | shop
amount: Mapped[int] = mapped_column(Integer) # + заработано / − потрачено
# Ключ покупки (декорация или расширение карты) для трат
item_key: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None)
# День дейли (source='daily'), UTC: уникальность (source, day) против гонки
# двух параллельных POST /api/xp/daily — оба прошли check-then-insert
day: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class GardenItem(Base):
"""Элемент сцены сада: растение (закрытая задача) или декорация.
Растение привязано к XpEvent (не к задаче — растение переживает удаление
задачи); вид зафиксирован в item_key при рождении. Позиция — ячейки
мелкой сетки сада (см. services/garden.py).
"""
__tablename__ = "garden_items"
__table_args__ = (UniqueConstraint("kind", "ref_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16)) # plant | decoration
ref_id: Mapped[int | None] = mapped_column(
ForeignKey("xp_events.id", ondelete="CASCADE"), nullable=True, default=None
)
# Для растения — ключ вида (species), для декорации — ключ каталога
item_key: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None)
# Позиция в ячейках сетки; NULL — декорация в инвентаре (не размещена)
x: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
y: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
# Стадия роста растения: 0 росток, 1 куст, 2 цветение
stage: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)