Files
kadence/backend/app/settings_schema.py
T
2026-10-19 09:19:47 +03:00

328 lines
11 KiB
Python

"""The user-editable settings, as one validated tree.
Everything the UI can change lives here: timer lengths, which todo sources are
on, what the blocker blocks, when Telegram gets poked and in what tone. It is
stored as a single JSON row (see `app.models.SettingsRow`), which keeps
"add a new option" to a one-line change instead of a migration.
Defaults are deliberately a complete, working configuration: a fresh install is
a classic 25/5/15 pomodoro with no integrations and nothing blocked.
"""
from __future__ import annotations
from datetime import time
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# Timer
# --------------------------------------------------------------------------
class TimerPreset(BaseModel):
"""A named, user-editable recipe of durations the UI can apply in one tap."""
name: str
work_minutes: int = Field(default=25, ge=1, le=240)
short_break_minutes: int = Field(default=5, ge=1, le=120)
long_break_minutes: int = Field(default=15, ge=1, le=240)
cycles_before_long_break: int = Field(default=4, ge=1, le=12)
def _default_timer_presets() -> list[TimerPreset]:
return [
TimerPreset(
name="Classic", work_minutes=45, short_break_minutes=15, long_break_minutes=30
),
TimerPreset(
name="Пара", work_minutes=85, short_break_minutes=10, long_break_minutes=10
),
]
class PomodoroSettings(BaseModel):
"""Lengths and automation of the timer itself. All durations in minutes."""
work_minutes: int = Field(default=25, ge=1, le=240)
short_break_minutes: int = Field(default=5, ge=1, le=120)
long_break_minutes: int = Field(default=15, ge=1, le=240)
cycles_before_long_break: int = Field(default=4, ge=1, le=12)
presets: list[TimerPreset] = Field(default_factory=_default_timer_presets)
"""Saved work/break combinations, applied to the fields above in one tap."""
auto_start_breaks: bool = True
"""Roll straight into the break when a work interval ends."""
auto_start_work: bool = False
"""Roll straight into the next work interval when a break ends."""
daily_goal_pomodoros: int = Field(default=8, ge=1, le=40)
"""The bar the daily report measures you against."""
strict_mode: bool = False
"""Abandoning a running work interval requires a reason, and is counted."""
allow_pause: bool = True
"""When false the only ways out of a work interval are finishing or abandoning."""
count_interruptions: bool = True
tick_sound: bool = False
alarm_sound: bool = True
alarm_volume: int = Field(default=70, ge=0, le=100)
timer_enabled: bool = True
"""When false the timer controls are hidden — the app becomes view-only."""
desktop_notifications: bool = True
keep_on_top_during_work: bool = False
"""Desktop app: float the window above everything while a work interval runs."""
# --------------------------------------------------------------------------
# Todo sources
# --------------------------------------------------------------------------
class JiraSourceSettings(BaseModel):
"""Jira Cloud, authenticated with account email + API token (Basic auth)."""
enabled: bool = False
base_url: str = ""
email: str = ""
api_token: str = ""
extra_jql: str = ""
"""ANDed onto every query, e.g. `assignee = currentUser()`."""
start_date_field: str = "customfield_10015"
"""Jira's "Start date"; its id differs per site (see /rest/api/3/field)."""
include_all_open: bool = True
"""Fetch every open issue, not only the ones due today or overdue."""
max_issues: int = Field(default=200, ge=1, le=2000)
class GoogleTasksSourceSettings(BaseModel):
"""Google Tasks, via an OAuth refresh token (see jira-helper's README)."""
enabled: bool = False
client_id: str = ""
client_secret: str = ""
refresh_token: str = ""
lists: list[str] = Field(default_factory=list)
"""Titles or ids to limit to. Empty = every list in the account."""
include_undated: bool = True
"""Keep tasks with no due date (jira-helper drops them; here they are todos)."""
class TodoistSourceSettings(BaseModel):
"""Todoist, via a personal API token."""
enabled: bool = False
api_token: str = ""
projects: list[str] = Field(default_factory=list)
"""Names or ids to limit to. Empty = every project."""
filter_query: str = ""
"""Override the server-side filter. Empty = all open tasks."""
class LaundrySourceSettings(BaseModel):
"""Campus laundry bookings — not a todo, but a thing that owns your time."""
enabled: bool = False
base_url: str = "https://4ka.inbicst.ru"
session_cookie: str = ""
"""The `sessionid` cookie, or a whole `name=value; ...` cookie string."""
class SourcesSettings(BaseModel):
jira: JiraSourceSettings = Field(default_factory=JiraSourceSettings)
google_tasks: GoogleTasksSourceSettings = Field(
default_factory=GoogleTasksSourceSettings
)
todoist: TodoistSourceSettings = Field(default_factory=TodoistSourceSettings)
laundry: LaundrySourceSettings = Field(default_factory=LaundrySourceSettings)
refresh_interval_minutes: int = Field(default=15, ge=1, le=1440)
"""How often the background job re-pulls every source."""
request_timeout_seconds: int = Field(default=30, ge=5, le=120)
# --------------------------------------------------------------------------
# Blocker (enforced by the desktop app)
# --------------------------------------------------------------------------
class BlockWindow(BaseModel):
"""A recurring time window in which blocking is on regardless of the timer."""
label: str = ""
start: time = time(9, 0)
end: time = time(18, 0)
days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4])
"""ISO weekdays, Monday = 0 .. Sunday = 6."""
class BlockerSettings(BaseModel):
enabled: bool = False
mode: Literal["during_work", "always", "scheduled"] = "during_work"
"""`during_work` blocks only while a work interval runs — the useful default."""
block_during_breaks: bool = False
blocked_domains: list[str] = Field(default_factory=list)
"""Hostnames or masks.
`youtube.com` blocks the site (plus `www.` and `m.`); `*.youtube.com` blocks
its subdomains too, expanded with `subdomain_expansions` because a hosts file
has no wildcards of its own.
"""
allowed_domains: list[str] = Field(default_factory=list)
"""Exceptions, masks allowed: `music.youtube.com`, `*.docs.google.com`."""
subdomain_expansions: list[str] = Field(
default_factory=lambda: ["www", "m", "mobile", "app", "web", "i", "old", "new"]
)
"""What `*.example.com` turns into. Add the subdomains you actually visit."""
blocked_apps: list[str] = Field(default_factory=list)
"""Process names or masks: `steam`, `*discord*`, `telegram-*`."""
kill_blocked_apps: bool = True
"""Terminate a blocked app when it appears; otherwise only warn and log."""
schedule: list[BlockWindow] = Field(default_factory=list)
strict: bool = False
"""The desktop app refuses to switch blocking off while a work interval runs."""
redirect_ip: str = "127.0.0.1"
app_poll_seconds: int = Field(default=5, ge=1, le=60)
notify_on_block: bool = True
# --------------------------------------------------------------------------
# Notifications and reports
# --------------------------------------------------------------------------
class QuietHours(BaseModel):
enabled: bool = False
start: time = time(23, 0)
end: time = time(8, 0)
class TelegramSettings(BaseModel):
"""Everything about the bot that nags, congratulates and reports."""
enabled: bool = False
bot_token: str = ""
chat_id: str = ""
commands_enabled: bool = True
"""Run a long-polling bot so /today, /report, /stats work in the chat."""
daily_report: bool = True
daily_report_time: time = time(21, 0)
morning_briefing: bool = True
"""A "here is your day" message with the todo list and yesterday's streak."""
morning_briefing_time: time = time(9, 0)
weekly_report: bool = True
weekly_report_weekday: int = Field(default=6, ge=0, le=6)
"""ISO weekday for the weekly wrap-up, Monday = 0 (default Sunday)."""
celebrate_every_n_pomodoros: int = Field(default=4, ge=0, le=50)
"""Cheer in the chat every N completed pomodoros today. 0 = never."""
nudge_enabled: bool = True
nudge_after_idle_minutes: int = Field(default=90, ge=10, le=600)
"""Poke you when you have not run a pomodoro for this long during the day."""
nudge_window_start: time = time(10, 0)
nudge_window_end: time = time(20, 0)
nudge_max_per_day: int = Field(default=4, ge=0, le=24)
goal_warning: bool = True
"""Warn in the evening when the daily goal is still out of reach."""
quiet_hours: QuietHours = Field(default_factory=QuietHours)
class SlackSettings(BaseModel):
enabled: bool = False
webhook_url: str = ""
class ReportSettings(BaseModel):
tone: Literal["friendly", "coach", "drill_sergeant", "dry"] = "friendly"
language: Literal["en", "ru"] = "en"
include_funny_stats: bool = True
include_task_list: bool = True
include_blocked_sites: bool = True
include_achievements: bool = True
include_comparison: bool = True
"""Compare today against your 7-day average."""
max_tasks_listed: int = Field(default=15, ge=1, le=100)
class NotificationsSettings(BaseModel):
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
slack: SlackSettings = Field(default_factory=SlackSettings)
report: ReportSettings = Field(default_factory=ReportSettings)
# --------------------------------------------------------------------------
# Root
# --------------------------------------------------------------------------
class AppearanceSettings(BaseModel):
theme: Literal["light", "dark", "system"] = "system"
accent: str = "#6750A4"
"""Material 3 seed colour; the whole palette is derived from it."""
compact: bool = False
class AppSettings(BaseModel):
"""The single settings document stored in the database."""
timezone: str = "Europe/Moscow"
week_starts_monday: bool = True
pomodoro: PomodoroSettings = Field(default_factory=PomodoroSettings)
sources: SourcesSettings = Field(default_factory=SourcesSettings)
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
@field_validator("timezone")
@classmethod
def _known_timezone(cls, value: str) -> str:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as exc: # pragma: no cover
raise ValueError(f"unknown timezone: {value!r}") from exc
return value
# Keys whose values are never sent to a browser in full; the API replaces them
# with a masked placeholder and the UI sends the placeholder back unchanged.
SECRET_FIELDS = frozenset(
{
"api_token",
"client_secret",
"refresh_token",
"session_cookie",
"bot_token",
"webhook_url",
}
)
SECRET_MASK = "••••••••"