init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,300 @@
|
||||
"""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 = "••••••••"
|
||||
Reference in new issue
Block a user