This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
"""HTTP layer. Routers are thin; the thinking happens in `app.services`."""
from fastapi import APIRouter
from app.api import (
routes_blocker,
routes_reports,
routes_settings,
routes_tasks,
routes_timer,
)
api_router = APIRouter()
api_router.include_router(routes_timer.router)
api_router.include_router(routes_tasks.router)
api_router.include_router(routes_blocker.router)
api_router.include_router(routes_reports.router)
api_router.include_router(routes_settings.router)
__all__ = ["api_router"]
+37
View File
@@ -0,0 +1,37 @@
"""Shared dependencies: the database session and the optional shared secret."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.config import env_settings
from app.db import get_session
from app.services.settings_service import load_settings
from app.settings_schema import AppSettings
DbSession = Annotated[Session, Depends(get_session)]
def require_token(x_api_token: Annotated[str | None, Header()] = None) -> None:
"""Guard every API call when `API_TOKEN` is set.
Off by default: on a laptop the backend is bound to localhost and a token is
friction. Set it the moment the container is reachable from anywhere else.
"""
expected = env_settings().api_token
if not expected:
return
if x_api_token != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bad or missing X-API-Token."
)
def get_settings(session: DbSession) -> AppSettings:
return load_settings(session)
Settings = Annotated[AppSettings, Depends(get_settings)]
+58
View File
@@ -0,0 +1,58 @@
"""Blocker endpoints.
`GET /api/blocker/policy` is the one the desktop app polls every few seconds;
everything else is for the UI.
"""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import BlockEventRequest
from app.services import blocker_service
from app.services.notify import notify
router = APIRouter(prefix="/api/blocker", tags=["blocker"])
@router.get("/policy")
def policy(session: DbSession, settings: Settings) -> dict:
return blocker_service.policy(session, settings)
@router.get("/events")
def events(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None),
) -> dict:
return blocker_service.events_for_day(session, settings, day)
@router.post("/events")
def record(body: BlockEventRequest, session: DbSession, settings: Settings) -> dict:
"""The desktop app reports every attempt it turned away.
These are what make the daily report fun ("you reached for youtube 12
times"), so they are recorded even when the blocker only warned.
"""
event = blocker_service.record_event(
session,
settings,
kind=body.kind,
target=body.target,
action=body.action,
details=body.details,
)
if settings.blocker.notify_on_block and body.action == "killed":
notify(settings, f"🛑 Closed <b>{body.target}</b> — you are in a work interval.")
return {"id": event.id, "recorded": True}
@router.get("/defaults")
def defaults() -> dict:
"""Starter lists, so the blocker page is not an empty text box."""
return {"domains": blocker_service.DEFAULT_DOMAINS, "apps": blocker_service.DEFAULT_APPS}
+100
View File
@@ -0,0 +1,100 @@
"""Report and statistics endpoints."""
from __future__ import annotations
from datetime import date, timedelta
from fastapi import APIRouter, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import SendReportRequest, TestNotificationRequest
from app.services import notify as notify_service
from app.services import report, scheduler, stats
from app.timeutil import ensure_utc, local_date
router = APIRouter(prefix="/api", tags=["reports"])
@router.get("/reports/daily")
def daily(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None),
rebuild: bool = Query(default=True),
) -> dict:
target = day or local_date(settings.timezone)
row, payload = report.get_or_build_daily(session, settings, target, rebuild=rebuild)
return {
"report": payload,
"sent_at": ensure_utc(row.sent_at).isoformat() if row.sent_at else None,
"telegram_preview": report.render_daily_telegram(payload, settings),
}
@router.post("/reports/daily/send")
def send_daily(body: SendReportRequest, session: DbSession, settings: Settings) -> dict:
target = body.report_date or local_date(settings.timezone)
return report.send_daily(session, settings, target, force=body.force)
@router.get("/reports/weekly")
def weekly(
session: DbSession,
settings: Settings,
end: date | None = Query(default=None),
) -> dict:
target = end or local_date(settings.timezone)
payload = report.build_weekly(session, settings, target)
return {"report": payload, "telegram_preview": report.render_weekly_telegram(payload, settings)}
@router.post("/reports/weekly/send")
def send_weekly(session: DbSession, settings: Settings) -> dict:
payload = report.build_weekly(session, settings, local_date(settings.timezone))
text = report.render_weekly_telegram(payload, settings)
deliveries = notify_service.notify(settings, text, urgent=True)
return {"sent": any(d.ok for d in deliveries), "deliveries": [d.as_dict() for d in deliveries]}
@router.post("/reports/briefing/send")
def send_briefing(session: DbSession, settings: Settings) -> dict:
text = report.render_morning_briefing(session, settings)
deliveries = notify_service.notify(settings, text, urgent=True)
return {
"sent": any(d.ok for d in deliveries),
"deliveries": [d.as_dict() for d in deliveries],
"text": text,
}
@router.get("/stats/summary")
def summary(session: DbSession, settings: Settings, days: int = Query(default=30, ge=1, le=365)) -> dict:
today = local_date(settings.timezone)
return {
"today": stats.day_metrics(session, settings, today),
"streak": stats.streak(session, settings),
"lifetime": stats.lifetime(session, settings),
"average_7d": stats.averages(session, settings, 7, until=today),
"range": stats.range_metrics(session, settings, today - timedelta(days=days - 1), today),
"heatmap": stats.heatmap(session, settings, weeks=12),
}
@router.get("/stats/day")
def day_stats(session: DbSession, settings: Settings, day: date | None = Query(default=None)) -> dict:
return stats.day_metrics(session, settings, day or local_date(settings.timezone))
@router.post("/notifications/test")
def test_notification(body: TestNotificationRequest, settings: Settings) -> dict:
text = body.text or (
"🍅 <b>Kadence</b>\nNotifications are wired up correctly."
)
deliveries = notify_service.notify(settings, text, urgent=True)
return {"deliveries": [d.as_dict() for d in deliveries]}
@router.get("/scheduler/jobs")
def scheduler_jobs() -> dict:
"""What the background jobs are, and when they next fire."""
return {"jobs": scheduler.describe()}
+54
View File
@@ -0,0 +1,54 @@
"""Settings endpoints.
The browser never sees a real token: `GET` masks every secret, and a `PUT` that
sends the mask back untouched keeps the stored value.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Body, HTTPException
from pydantic import ValidationError
from app.api.deps import DbSession, Settings
from app.services import blocker_service, settings_service
from app.settings_schema import AppSettings
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("")
def get_settings(settings: Settings) -> dict:
return {
"settings": settings_service.mask_secrets(settings.model_dump(mode="json")),
"defaults": {
"blocked_domains": blocker_service.DEFAULT_DOMAINS,
"blocked_apps": blocker_service.DEFAULT_APPS,
},
}
@router.put("")
def put_settings(session: DbSession, patch: dict[str, Any] = Body(...)) -> dict:
try:
updated = settings_service.apply_patch(session, patch)
except ValidationError as exc:
# `exc.errors()` can carry exception objects in `ctx`, which JSON cannot
# take; pydantic's own `json()` renders them as text.
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
@router.post("/reset")
def reset_settings(session: DbSession) -> dict:
"""Back to defaults, re-seeded from the environment."""
updated = settings_service.save_settings(session, settings_service.settings_from_env())
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
@router.get("/schema")
def settings_schema() -> dict:
"""The JSON Schema, so a client can render the settings form generically."""
return AppSettings.model_json_schema()
+121
View File
@@ -0,0 +1,121 @@
"""Task endpoints: the aggregated list, refreshing it, and acting on it."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import TaskCreateRequest, TaskDoneRequest, TaskUpdateRequest
from app.models import TaskRecord
from app.services import tasks_service
from app.sources.registry import SOURCE_LABELS, check_source
from app.timeutil import ensure_utc, local_date
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
def task_dict(record: TaskRecord, today) -> dict:
return {
"id": record.id,
"source": record.source,
"source_label": SOURCE_LABELS.get(record.source, record.source),
"external_id": record.external_id,
"title": record.title,
"description": record.description,
"url": record.url,
"project": record.project,
"status": record.status,
"priority": record.priority,
"due_date": record.due_date.isoformat() if record.due_date else None,
"start_date": record.start_date.isoformat() if record.start_date else None,
"labels": record.labels or [],
"is_done": record.is_done,
"completed_at": ensure_utc(record.completed_at).isoformat() if record.completed_at else None,
"pinned": record.pinned,
"stale": record.stale,
"estimated_pomodoros": record.estimated_pomodoros,
"days_overdue": (today - record.due_date).days if record.due_date and record.due_date < today else 0,
"bucket": _bucket_of(record, today),
}
def _bucket_of(record: TaskRecord, today) -> str:
if record.due_date is None:
return "someday"
if record.due_date < today:
return "overdue"
if record.due_date == today:
return "today"
return "upcoming"
@router.get("")
def list_tasks(
session: DbSession,
settings: Settings,
include_done: bool = Query(default=False),
include_stale: bool = Query(default=False),
source: list[str] | None = Query(default=None),
) -> dict:
today = local_date(settings.timezone)
records = tasks_service.list_tasks(
session, include_done=include_done, include_stale=include_stale, sources=source
)
buckets = tasks_service.bucket_tasks(records, today)
return {
"date": today.isoformat(),
"tasks": [task_dict(record, today) for record in records],
"counts": {name: len(items) for name, items in buckets.items()},
"sources": sorted({record.source for record in records}),
}
@router.post("/refresh")
def refresh(session: DbSession, settings: Settings) -> dict:
"""Pull every enabled source now. Failures come back per source, not as a 500."""
return tasks_service.refresh_tasks(session, settings)
@router.post("")
def create(body: TaskCreateRequest, session: DbSession, settings: Settings) -> dict:
record = tasks_service.create_local_task(
session,
title=body.title,
description=body.description,
due_date=body.due_date,
priority=body.priority,
project=body.project,
estimated_pomodoros=body.estimated_pomodoros,
)
return task_dict(record, local_date(settings.timezone))
@router.patch("/{task_id}")
def update(task_id: int, body: TaskUpdateRequest, session: DbSession, settings: Settings) -> dict:
try:
record = tasks_service.update_task(session, task_id, **body.model_dump(exclude_unset=True))
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return task_dict(record, local_date(settings.timezone))
@router.post("/{task_id}/done")
def mark_done(task_id: int, body: TaskDoneRequest, session: DbSession, settings: Settings) -> dict:
try:
record, warning = tasks_service.complete_task(session, settings, task_id, done=body.done)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"task": task_dict(record, local_date(settings.timezone)), "warning": warning}
@router.delete("/{task_id}")
def delete(task_id: int, session: DbSession) -> dict:
tasks_service.delete_task(session, task_id)
return {"deleted": task_id}
@router.post("/sources/{name}/test")
def test_source(name: str, settings: Settings) -> dict:
"""What the settings page's "Test connection" button calls."""
ok, detail = check_source(settings, name)
return {"source": name, "ok": ok, "detail": detail}
+155
View File
@@ -0,0 +1,155 @@
"""The timer endpoints. Thin: every decision lives in `services.pomodoro`."""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, HTTPException, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import (
AbandonRequest,
AttachTaskRequest,
CompleteTimerRequest,
ExtendRequest,
StartTimerRequest,
)
from app.services import pomodoro, report, stats
from app.services.notify import count_sent, notify, record_sent
from app.timeutil import local_date
router = APIRouter(prefix="/api/timer", tags=["timer"])
@router.get("/state")
def get_state(session: DbSession, settings: Settings) -> dict:
return pomodoro.state(session, settings)
@router.post("/start")
def start(body: StartTimerRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.start(
session,
settings,
kind=body.kind,
minutes=body.minutes,
task_id=body.task_id,
note=body.note,
)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/pause")
def pause(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.pause(session, settings)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/resume")
def resume(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.resume(session)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/complete")
def complete(body: CompleteTimerRequest, session: DbSession, settings: Settings) -> dict:
try:
finished, follow_on = pomodoro.complete(session, settings, auto_next=body.auto_next)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
celebration = _maybe_celebrate(session, settings) if finished.is_work else None
state = pomodoro.state(session, settings)
state["finished"] = pomodoro.session_dict(finished)
state["auto_started"] = pomodoro.session_dict(follow_on)
state["celebration"] = celebration
return state
@router.post("/abandon")
def abandon(body: AbandonRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.abandon(session, settings, reason=body.reason)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/interrupt")
def interrupt(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.add_interruption(session)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/extend")
def extend(body: ExtendRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.extend(session, body.minutes)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/task")
def attach_task(body: AttachTaskRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.attach_task(session, body.task_id)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.get("/sessions")
def sessions(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None, description="Local date; defaults to today."),
) -> dict:
target = day or local_date(settings.timezone)
rows = pomodoro.sessions_for_day(session, settings, target)
return {
"date": target.isoformat(),
"sessions": [pomodoro.session_dict(row) for row in rows],
"metrics": stats.day_metrics(session, settings, target),
}
def _maybe_celebrate(session, settings) -> str | None:
"""Cheer in Telegram every N pomodoros — the cheapest dopamine there is."""
every = settings.notifications.telegram.celebrate_every_n_pomodoros
if not (settings.notifications.telegram.enabled and every):
return None
done = pomodoro.completed_work_today(session, settings)
if done == 0 or done % every != 0:
return None
today = local_date(settings.timezone)
kind = f"celebrate:{done}"
if count_sent(session, kind, today):
return None
ru = settings.notifications.report.language == "ru"
goal = settings.pomodoro.daily_goal_pomodoros
bar = report.progress_bar(done, goal)
text = (
f"🎉 <b>{done}</b> помидорок готово!\n{bar} {done}/{goal}\nДержи темп."
if ru
else f"🎉 <b>{done}</b> pomodoros done!\n{bar} {done}/{goal}\nKeep the rhythm."
)
deliveries = notify(settings, text)
if any(delivery.ok for delivery in deliveries):
record_sent(session, kind, today, {"pomodoros": done})
return text
+79
View File
@@ -0,0 +1,79 @@
"""Request bodies. Responses are plain dicts built by the services."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal
from pydantic import BaseModel, Field
class StartTimerRequest(BaseModel):
kind: Literal["work", "short_break", "long_break"] | None = None
minutes: int | None = Field(default=None, ge=1, le=240)
task_id: int | None = None
note: str | None = None
class AbandonRequest(BaseModel):
reason: str | None = None
class ExtendRequest(BaseModel):
minutes: int = Field(default=5, ge=1, le=120)
class AttachTaskRequest(BaseModel):
task_id: int | None = None
class CompleteTimerRequest(BaseModel):
auto_next: bool = True
class TaskCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=500)
description: str | None = None
due_date: date | None = None
priority: str | None = None
project: str | None = None
estimated_pomodoros: int | None = Field(default=None, ge=1, le=50)
class TaskUpdateRequest(BaseModel):
title: str | None = None
description: str | None = None
due_date: date | None = None
priority: str | None = None
project: str | None = None
estimated_pomodoros: int | None = None
pinned: bool | None = None
class TaskDoneRequest(BaseModel):
done: bool = True
class BlockEventRequest(BaseModel):
kind: Literal["domain", "app"] = "domain"
target: str = Field(min_length=1, max_length=255)
action: Literal["blocked", "killed", "warned"] = "blocked"
details: str | None = None
class SendReportRequest(BaseModel):
report_date: date | None = None
force: bool = True
class TestNotificationRequest(BaseModel):
text: str | None = None
class SettingsPatch(BaseModel):
"""A partial settings document; anything omitted keeps its stored value."""
model_config = {"extra": "allow"}
def as_dict(self) -> dict[str, Any]:
return self.model_dump(exclude_unset=True)