disallow to disable guard during focus
docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s

This commit is contained in:
timka123 committed 2026-10-28 09:50:58 +03:00
1 parent 157a113c39
commit d280101bce
21 files changed
+481 -43

No files matched your search

+8 -1
View File
@@ -38,13 +38,20 @@ def put_settings(session: DbSession, patch: dict[str, Any] = Body(...)) -> dict:
# `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
except blocker_service.GuardLocked as exc:
# 409, not 422: the body is valid, the current state refuses it. A plain
# string detail is what every client's error path already surfaces.
raise HTTPException(status_code=409, detail=str(exc)) 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())
try:
updated = settings_service.reset_to_env(session)
except blocker_service.GuardLocked as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
+50 -2
View File
@@ -187,12 +187,59 @@ def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bo
return False
GUARD_LOCK_NOTE = "Finish or drop the interval to switch the focus guard off."
"""Said in one voice: the 409 detail, and the `lock_reason` every client renders."""
class GuardLocked(RuntimeError):
"""Switching the guard off was refused because an interval is live."""
def guard_locked(session: Session, settings: AppSettings) -> bool:
"""The guard is on and an interval is live, so it may not be switched off.
Any kind, running or paused — `pomodoro.current_session` is exactly that. A
break is not an escape hatch and neither is pausing the clock.
Deliberately keyed off `blocker.enabled` rather than off `policy()["active"]`:
in `scheduled` mode outside every window nothing is being blocked right now,
and the guard still must not be quietly switched off before the window opens.
"""
return settings.blocker.enabled and pomodoro.current_session(session) is not None
def ensure_guard_not_switched_off(
session: Session, before: AppSettings, after: AppSettings
) -> None:
"""Refuse a settings write that would turn the guard off mid-interval.
Compares the two documents rather than reading the patch. The patch is a deep
merge of a shared object and arrives loosely typed — `{"enabled": "no"}`
coerces to `False`, so inspecting the raw keys would have to re-implement
pydantic to be right — and "did the stored value go from on to off" is the
only question that cannot be fooled. It also means an unrelated patch, a
full-document echo and a re-sent `enabled: false` all pass by construction.
Switching the guard *on* is never refused.
"""
if not after.blocker.enabled and guard_locked(session, before):
raise GuardLocked(GUARD_LOCK_NOTE)
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
"""What the desktop app polls: should I block, and what."""
"""What the desktop app polls: should I block, and what.
`active` and `locked` answer two different questions and do disagree: in
`scheduled` mode outside every window, nothing is blocked (`active` false)
while the guard is still locked on (`locked` true) because an interval runs.
"""
blocker = settings.blocker
current = pomodoro.current_session(session)
active = False
reason = "disabled"
# The same rule as `guard_locked()`, reusing the row this function already
# fetched rather than querying twice — `test_blocker.py` pins the two together.
locked = blocker.enabled and current is not None
if blocker.enabled:
if blocker.mode == "always":
@@ -218,7 +265,8 @@ def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
"reason": reason,
"mode": blocker.mode,
"enabled": blocker.enabled,
"strict": blocker.strict,
"locked": locked,
"lock_reason": GUARD_LOCK_NOTE if locked else "",
"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 [],
+23 -3
View File
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
from app.config import env_settings
from app.models import SettingsRow
from app.services import pomodoro
from app.services import blocker_service, pomodoro
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
_SETTINGS_ID = 1
@@ -192,10 +192,17 @@ def unmask_secrets(stored: dict[str, Any], incoming: dict[str, Any]) -> dict[str
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
"""Merge a partial settings document (as the UI sends it) and store it."""
current = load_settings(session).model_dump(mode="json")
stored = load_settings(session)
current = stored.model_dump(mode="json")
patch = unmask_secrets(current, patch)
merged = _merge(current, patch)
updated = save_settings(session, AppSettings.model_validate(merged))
candidate = AppSettings.model_validate(merged)
# Validate, then refuse, then write: a malformed patch stays a 422 rather
# than becoming a 409, and nothing is stored either way.
blocker_service.ensure_guard_not_switched_off(session, stored, candidate)
updated = save_settings(session, candidate)
# A changed Focus/Short/Long minute (directly, or via a preset) resizes
# whatever interval is already running or paused — see
@@ -203,3 +210,16 @@ def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
# is simplest to always run it rather than detect which keys changed.
pomodoro.rescale_current(session, updated)
return updated
def reset_to_env(session: Session) -> AppSettings:
"""Back to the environment seed — refused if that would unguard a live interval.
`settings_from_env` never touches the `blocker` subtree, so a reset always
lands on the schema default `enabled = False`: without this check one POST
would switch the focus guard off mid-interval.
"""
stored = load_settings(session)
fresh = settings_from_env()
blocker_service.ensure_guard_not_switched_off(session, stored, fresh)
return save_settings(session, fresh)
-2
View File
@@ -204,8 +204,6 @@ class BlockerSettings(BaseModel):
"""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)