screen-locker/screen_locker/_workday_penalty.py
Krzysztof kuhy Rudnicki 1a305c0627 Sick days: per-locker keep (sickdays 0.2.0) for the workout lock
The workout lock, its explanation, the status view and the workday penalty's
penalised day now ask _sick.stands_down_workout: a sick day that keeps the
workout locker behaves as a normal day. The penalty's ring day is judged by
the alarm switch. Shutdown (20:00 floor, grace floor, base, target, preview
scripts) keeps is_sick: the floor belongs to the day, not to a locker. The
one-tap button still keeps nothing. Test pool fixture and preview harness now
redirect every sickdays resolve_paths binding, so keep edits never reach the
real pool. Pins bumped to sickdays-v0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:18:49 +02:00

83 lines
3.1 KiB
Python

"""Read the workday-stick penalty wake-alarm signed for today.
A missed Tuesday/Wednesday/Thursday wake-alarm ring costs tomorrow's
relaxed-day leniency (:func:`screen_locker._weekly_check.is_relaxed_day`)
instead of just resetting to normal. This module never re-derives that
policy: the file carries a signed ``penalty_date``, and the only decision
here is *HMAC ok, dated today*. Anything else -- missing file, stale date,
bad signature -- is "no penalty", i.e. today's ordinary relaxed-day rule.
The file is void when EITHER its ``issued_date`` (the day of the missed
ring) belongs to a sick day whose ``alarm`` switch is off, OR its
``penalty_date`` belongs to a sick day whose ``workout`` lock is off
(``sickdays``). Each date is judged by what it owns: the ring is the alarm's,
and the penalty it costs -- the relaxed-day leniency -- is the workout lock's.
A sick day that keeps that locker leaves its half of the penalty in force.
Wake-alarm's own retraction of the file can lag its sync by ~15 min, so it
is not relied on here.
"""
from __future__ import annotations
from datetime import date
import json
import logging
from typing import TYPE_CHECKING
from gatelock.log_integrity import verify_entry_hmac
import sickdays
from screen_locker._constants import WORKDAY_PENALTY_FILE
from screen_locker._day import today_str
from screen_locker._sick import stands_down
if TYPE_CHECKING:
from datetime import datetime
_logger = logging.getLogger(__name__)
def _read_verified() -> dict[str, object] | None:
"""The file's entry when it exists, parses and verifies; else None."""
if not WORKDAY_PENALTY_FILE.exists():
_logger.debug("No workday penalty file at %s", WORKDAY_PENALTY_FILE)
return None
try:
entry = json.loads(WORKDAY_PENALTY_FILE.read_text())
except (OSError, json.JSONDecodeError) as exc:
_logger.warning("Cannot read %s: %s", WORKDAY_PENALTY_FILE, exc)
return None
if not isinstance(entry, dict) or not verify_entry_hmac(entry):
_logger.warning("Workday penalty file is not a signed object")
return None
return entry
def workday_penalty_today(now: datetime | None = None) -> bool:
"""Whether today lost its relaxed-day leniency to a missed prior ring."""
entry = _read_verified()
if entry is None or entry.get("penalty_date") != today_str(now):
return False
for field, locker in (
("issued_date", sickdays.LOCKER_ALARM),
("penalty_date", sickdays.LOCKER_WORKOUT),
):
if _stands_down_iso(entry.get(field), locker):
_logger.warning(
"Workday penalty void: its %s is a sick day with %s off", field, locker
)
return False
return True
def _stands_down_iso(value: object, locker: str) -> bool:
"""Whether ``value`` is a ``YYYY-MM-DD`` on which ``locker`` is switched off."""
if value is None:
return False # an older file without issued_date: penalty_date rules
try:
day = date.fromisoformat(str(value))
except ValueError:
_logger.warning("Workday penalty: %r is not a date; not a sick day", value)
return False
return stands_down(locker, day)