Files
kadence/backend/app/models.py
T
timka123andClaude Opus 5 ec7eedbc66 style: say which lint rules this repo keeps, then keep them
`[tool.ruff]` set a line length and a target version and nothing else, so which
rules ran depended on the installed ruff's defaults — fine while nobody ran it,
not fine for a CI job that can turn a push red because a tool changed its mind.

The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that
are each a real decision rather than a shrug: B008, because FastAPI's whole
dependency idiom is a call in a default argument; E501, because wrapping prose
is a formatter's business; and RUF001, because the reports and the bot speak
Russian and "ambiguous character" is the wrong complaint about a string the
user is meant to read.

The 73 findings that remained are fixed — mostly `datetime.timezone.utc` →
`datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order,
and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two
try/except/pass pairs, unpacking instead of list concatenation, one negated
return.

`ruff format` is deliberately *not* adopted: it would reformat 51 files, and
some of the layout here is hand-set (the comment tables, the DNS wire-format
structs) in ways a formatter flattens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:28:57 +03:00

207 lines
8.3 KiB
Python

"""Database tables.
The shape follows the three things the app records: **settings** (one JSON
document), **what you did** (pomodoro sessions, task completions) and **what you
were tempted by** (block events). Reports are derived from those and cached per
day so a re-send shows the same numbers.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
Date,
DateTime,
Float,
ForeignKey,
Index,
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 SettingsRow(Base):
"""The single settings document (`id = 1`), validated by `AppSettings`."""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
class PomodoroSession(Base):
"""One interval of the timer — work or break — as it actually happened.
`planned_seconds` is what the settings asked for, `actual_seconds` is what
the clock recorded, and they differ whenever you finish early or abandon.
"""
__tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), index=True) # work|short_break|long_break
status: Mapped[str] = mapped_column(String(16), index=True, default="running")
# running | paused | completed | abandoned
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
local_date: Mapped[date] = mapped_column(Date, index=True)
"""The day this interval belongs to in the user's timezone — reports group on it."""
planned_seconds: Mapped[int] = mapped_column(Integer, default=1500)
actual_seconds: Mapped[int] = mapped_column(Integer, default=0)
paused_seconds: Mapped[int] = mapped_column(Integer, default=0)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
interruptions: Mapped[int] = mapped_column(Integer, default=0)
cycle_index: Mapped[int] = mapped_column(Integer, default=1)
"""Position in the current long-break cycle (1..cycles_before_long_break)."""
task_source: Mapped[str | None] = mapped_column(String(32))
task_external_id: Mapped[str | None] = mapped_column(String(128))
task_title: Mapped[str | None] = mapped_column(Text)
note: Mapped[str | None] = mapped_column(Text)
abandon_reason: Mapped[str | None] = mapped_column(Text)
block_events: Mapped[list[BlockEvent]] = relationship(back_populates="session")
@property
def is_work(self) -> bool:
return self.kind == "work"
class TaskRecord(Base):
"""A todo item, wherever it came from.
External tasks are mirrored here on every refresh so the UI is instant and
the daily report still works when Jira is down. Locally created tasks live
here too, with `source = "local"`.
Completion is never recorded from this app: you close a task where it lives,
and the next refresh notices. That keeps one source of truth per task.
"""
__tablename__ = "tasks"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_task_source_external"),
Index("ix_task_source_done", "source", "is_done"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source: Mapped[str] = mapped_column(String(32), index=True)
external_id: Mapped[str] = mapped_column(String(128))
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
url: Mapped[str | None] = mapped_column(Text)
project: Mapped[str | None] = mapped_column(Text)
status: Mapped[str | None] = mapped_column(String(64))
priority: Mapped[str | None] = mapped_column(String(32))
due_date: Mapped[date | None] = mapped_column(Date, index=True)
start_date: Mapped[date | None] = mapped_column(Date)
labels: Mapped[list[str]] = mapped_column(JSON, default=list)
raw: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
is_done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_date: Mapped[date | None] = mapped_column(Date, index=True)
"""The day the task was seen to close, in your timezone.
Tasks are never ticked off here — Jira, Todoist and Google Tasks own that —
so this is set when a refresh finds a task closed, or finds it gone from a
source that was otherwise healthy. That is what the daily report counts.
"""
estimated_pomodoros: Mapped[int | None] = mapped_column(Integer)
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
"""Shown at the top of the timer's task picker."""
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow
)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
stale: Mapped[bool] = mapped_column(Boolean, default=False)
"""Set when a refresh no longer returns the task (closed elsewhere, or filtered)."""
class BlockEvent(Base):
"""A blocked site or app you reached for anyway. Pure gold for the report."""
__tablename__ = "block_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), index=True) # domain | app
target: Mapped[str] = mapped_column(String(255), index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
local_date: Mapped[date] = mapped_column(Date, index=True)
action: Mapped[str] = mapped_column(String(16), default="blocked") # blocked|killed|warned
details: Mapped[str | None] = mapped_column(Text)
session_id: Mapped[int | None] = mapped_column(ForeignKey("pomodoro_sessions.id"))
session: Mapped[PomodoroSession | None] = relationship(back_populates="block_events")
class DailyReport(Base):
"""A generated report, kept so re-sends and the web view agree."""
__tablename__ = "daily_reports"
__table_args__ = (UniqueConstraint("report_date", name="uq_report_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
report_date: Mapped[date] = mapped_column(Date, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sent_channel: Mapped[str | None] = mapped_column(String(32))
score: Mapped[float] = mapped_column(Float, default=0.0)
class AuthSession(Base):
"""A browser login, created by a password or Telegram sign-in.
The token is the cookie value itself (a random opaque string) — no server
side id/secret split needed for a single-user app. Expired rows are pruned
lazily whenever a session is looked up.
"""
__tablename__ = "auth_sessions"
token: Mapped[str] = mapped_column(String(64), primary_key=True)
method: Mapped[str] = mapped_column(String(16)) # password | telegram
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
class EventLog(Base):
"""Small append-only log used for "did we already send this?" questions.
Nudges, celebrations and briefings all need to be idempotent per day, and a
row here is cheaper than a column per message type.
"""
__tablename__ = "event_log"
__table_args__ = (Index("ix_eventlog_kind_date", "kind", "local_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(48), index=True)
local_date: Mapped[date] = mapped_column(Date, index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)