Files
kadence/backend/app/services/blocker_service.py
T
2026-10-18 18:02:05 +03:00

166 lines
5.6 KiB
Python

"""Blocking policy.
The backend decides *whether* blocking should be on right now and *what* is on
the list; the desktop app is the thing that actually enforces it (it is the only
piece running on your machine with the privileges to rewrite `/etc/hosts` or
kill a process). Keeping the policy here means the web UI can edit the rules,
and a second machine running the desktop app obeys the same list.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BlockEvent
from app.services import pomodoro
from app.settings_schema import AppSettings
from app.timeutil import ensure_utc, in_window, local_date, local_time
def expand_domains(settings: AppSettings) -> list[str]:
"""Every hostname the desktop app should point at the redirect IP.
A rule like `youtube.com` is meant as "that site", so `www.` and the common
subdomains come along; anything in `allowed_domains` is removed again, which
is how `youtube.com` blocked + `music.youtube.com` allowed works.
"""
blocker = settings.blocker
allowed = {entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip()}
hosts: list[str] = []
for raw in blocker.blocked_domains:
domain = raw.strip().lower().lstrip(".")
if not domain or domain in allowed:
continue
for candidate in (domain, f"www.{domain}", f"m.{domain}"):
if candidate not in allowed and candidate not in hosts:
hosts.append(candidate)
return hosts
def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bool:
now_time = local_time(settings.timezone, moment)
weekday = (moment or datetime.now()).weekday()
for window in settings.blocker.schedule:
if weekday in window.days and in_window(now_time, window.start, window.end):
return True
return False
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
"""What the desktop app polls: should I block, and what."""
blocker = settings.blocker
current = pomodoro.current_session(session)
active = False
reason = "disabled"
if blocker.enabled:
if blocker.mode == "always":
active, reason = True, "always on"
elif blocker.mode == "scheduled":
active = schedule_active(settings)
reason = "inside a blocking window" if active else "outside every window"
else: # during_work
if current is not None and current.kind == "work" and current.status in ("running", "paused"):
active, reason = True, "a work interval is running"
elif (
current is not None
and current.kind != "work"
and blocker.block_during_breaks
and current.status in ("running", "paused")
):
active, reason = True, "blocking stays on through breaks"
else:
reason = "no work interval is running"
return {
"active": active,
"reason": reason,
"mode": blocker.mode,
"enabled": blocker.enabled,
"strict": blocker.strict,
"redirect_ip": blocker.redirect_ip,
"domains": expand_domains(settings) if active else [],
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
"kill_apps": blocker.kill_blocked_apps,
"notify_on_block": blocker.notify_on_block,
"poll_seconds": blocker.app_poll_seconds,
"session_id": current.id if current else None,
"all_domains": expand_domains(settings),
"all_apps": [app.strip() for app in blocker.blocked_apps if app.strip()],
}
def record_event(
session: Session,
settings: AppSettings,
*,
kind: str,
target: str,
action: str = "blocked",
details: str | None = None,
) -> BlockEvent:
current = pomodoro.current_session(session)
event = BlockEvent(
kind=kind,
target=target.strip().lower(),
action=action,
details=details,
local_date=local_date(settings.timezone),
session_id=current.id if current else None,
)
session.add(event)
session.commit()
return event
def events_for_day(session: Session, settings: AppSettings, day: date | None = None) -> dict[str, Any]:
day = day or local_date(settings.timezone)
rows = list(session.scalars(select(BlockEvent).where(BlockEvent.local_date == day)))
by_target = dict(
session.execute(
select(BlockEvent.target, func.count(BlockEvent.id))
.where(BlockEvent.local_date == day)
.group_by(BlockEvent.target)
.order_by(func.count(BlockEvent.id).desc())
).all()
)
return {
"date": day.isoformat(),
"total": len(rows),
"by_target": [{"target": target, "count": count} for target, count in by_target.items()],
"events": [
{
"id": row.id,
"kind": row.kind,
"target": row.target,
"action": row.action,
"at": ensure_utc(row.at).isoformat(),
"details": row.details,
}
for row in sorted(rows, key=lambda row: ensure_utc(row.at), reverse=True)[:100]
],
}
DEFAULT_DOMAINS = [
"youtube.com",
"twitter.com",
"x.com",
"instagram.com",
"tiktok.com",
"reddit.com",
"facebook.com",
"vk.com",
"twitch.tv",
"netflix.com",
"9gag.com",
"pikabu.ru",
]
DEFAULT_APPS = ["steam", "discord", "telegram-desktop", "spotify"]