301 lines
10 KiB
Python
301 lines
10 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 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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
writeback_done: bool = True
|
|
"""Completing a Jira task here transitions the issue to Done in Jira."""
|
|
|
|
done_transition_name: str = "Done"
|
|
"""Preferred transition name when writing back; falls back to any 'done' one."""
|
|
|
|
|
|
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)."""
|
|
|
|
writeback_done: bool = True
|
|
|
|
|
|
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."""
|
|
|
|
writeback_done: bool = True
|
|
|
|
|
|
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)
|
|
"""Bare hostnames, e.g. `youtube.com`. `www.` and subdomains are added for you."""
|
|
|
|
allowed_domains: list[str] = Field(default_factory=list)
|
|
"""Exceptions that survive a wildcard entry, e.g. `music.youtube.com`."""
|
|
|
|
blocked_apps: list[str] = Field(default_factory=list)
|
|
"""Process names, e.g. `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 = "••••••••"
|