mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Real fixes: split over-long functions (lock-decision chain, manual sync, validation, phone verification, status), hoist a dead lazy import, context-manage Xvfb and temp dirs, add encodings, share the silent-failure argv loop. lock_grab_handoff read lock._recovery, which gatelock's LockWindow does not have (its public property is recovery); a contract test now checks every attribute the handoff reads against the real class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
233 lines
8.4 KiB
Python
233 lines
8.4 KiB
Python
"""Manual (unverified) workout rate-limiting, validation, and entry-building.
|
|
|
|
Pure logic — no Tk imports. Manual-workout entries live directly in
|
|
``log.json`` (not a separate history file): a manual
|
|
entry is just another ``workout_data`` dict with ``type="manual_workout"``,
|
|
so ``LogMixin.save_workout_log`` already HMAC-signs it for free and the
|
|
weekly-minimum/bonus logic already counts it once wired into
|
|
``COUNTED_WORKOUT_TYPES``. This module only adds the rate-budget check (by
|
|
scanning ``log.json`` itself) and the evidence-form validation.
|
|
|
|
Each sport has its own structured fields (e.g. table tennis tracks
|
|
matches/sets won-lost and racket/balls, rather than one free-text blob) —
|
|
see :class:`ManualWorkoutDraft` and :data:`SPORT_CHOICES`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import logging
|
|
|
|
from screen_locker._constants import (
|
|
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
|
MANUAL_WORKOUT_RPE_MAX,
|
|
MANUAL_WORKOUT_RPE_MIN,
|
|
MIN_WORKOUT_DURATION_MINUTES,
|
|
WORKOUT_DURATION_ACCEPT_MINUTES,
|
|
)
|
|
from screen_locker._manual_workout_checks import (
|
|
validate_reflections,
|
|
validate_required,
|
|
)
|
|
from screen_locker._manual_workout_types import (
|
|
MANUAL_WORKOUT_TYPE,
|
|
SPORT_CHOICES,
|
|
SPORT_LABELS,
|
|
SPORT_OTHER,
|
|
SPORT_TABLE_TENNIS,
|
|
ManualWorkoutDraft,
|
|
budget_summary,
|
|
count_in_window,
|
|
is_budget_exhausted,
|
|
)
|
|
|
|
__all__ = [
|
|
"MANUAL_WORKOUT_TYPE",
|
|
"SPORT_CHOICES",
|
|
"SPORT_LABELS",
|
|
"SPORT_OTHER",
|
|
"SPORT_TABLE_TENNIS",
|
|
"ManualWorkoutDraft",
|
|
"budget_summary",
|
|
"build_entry",
|
|
"build_sync_payload",
|
|
"count_in_window",
|
|
"is_budget_exhausted",
|
|
"manual_sync_record_id",
|
|
"validate_manual_workout",
|
|
]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _parse_hhmm(value: str) -> datetime | None:
|
|
"""Parse an ``HH:MM`` string into a datetime on an arbitrary fixed date."""
|
|
try:
|
|
return datetime.strptime(value.strip(), "%H:%M").replace(tzinfo=UTC)
|
|
except ValueError as exc:
|
|
_logger.warning(
|
|
"Manual workout time %r is not HH:MM (%s) — the workout's duration "
|
|
"cannot be computed, so the draft will fail validation",
|
|
value,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
|
|
def _duration_minutes(draft: ManualWorkoutDraft) -> float | None:
|
|
"""Return ``end_time - start_time`` in minutes, or None if unparsable."""
|
|
start = _parse_hhmm(draft.start_time)
|
|
end = _parse_hhmm(draft.end_time)
|
|
if start is None or end is None or end <= start:
|
|
return None
|
|
return (end - start).total_seconds() / 60
|
|
|
|
|
|
def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
|
|
"""Return an error message if the draft is invalid, else None."""
|
|
error = validate_required(draft)
|
|
if error is not None:
|
|
return error
|
|
|
|
duration = _duration_minutes(draft)
|
|
if duration is None:
|
|
return "Start/end time must be valid HH:MM, with end after start"
|
|
# Accept bar carries the hidden leeway; the message advertises the round
|
|
# number only (see _constants.py).
|
|
if duration < WORKOUT_DURATION_ACCEPT_MINUTES:
|
|
return (
|
|
f"Session must be at least {MIN_WORKOUT_DURATION_MINUTES} "
|
|
f"minutes (currently {duration:.0f})"
|
|
)
|
|
|
|
if not MANUAL_WORKOUT_RPE_MIN <= draft.rpe <= MANUAL_WORKOUT_RPE_MAX:
|
|
return (
|
|
f"RPE must be between {MANUAL_WORKOUT_RPE_MIN} and {MANUAL_WORKOUT_RPE_MAX}"
|
|
)
|
|
|
|
if draft.sport == SPORT_TABLE_TENNIS:
|
|
error = _validate_table_tennis(draft)
|
|
else:
|
|
error = _validate_other_sport(draft)
|
|
if error is not None:
|
|
return error
|
|
|
|
return validate_reflections(draft)
|
|
|
|
|
|
def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
|
|
"""Validate the table-tennis-specific fields of a draft."""
|
|
for label, value in (
|
|
("Matches won", draft.matches_won),
|
|
("Matches lost", draft.matches_lost),
|
|
("Sets won", draft.sets_won),
|
|
("Sets lost", draft.sets_lost),
|
|
):
|
|
if value < 0:
|
|
return f"{label} cannot be negative"
|
|
if not draft.matches_won + draft.matches_lost:
|
|
return "Enter at least one match played (won + lost)"
|
|
if not draft.racket.strip():
|
|
return "Racket is required"
|
|
if not draft.balls.strip():
|
|
return "Balls are required"
|
|
return None
|
|
|
|
|
|
def _validate_other_sport(draft: ManualWorkoutDraft) -> str | None:
|
|
"""Validate the generic "other sport" fields of a draft."""
|
|
if not draft.activity_type_other.strip():
|
|
return "Activity type is required"
|
|
if len(draft.activity_details.strip()) < MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS:
|
|
return (
|
|
"What was done must be at least "
|
|
f"{MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} characters "
|
|
f"(currently {len(draft.activity_details.strip())})"
|
|
)
|
|
return None
|
|
|
|
|
|
def build_entry(draft: ManualWorkoutDraft) -> dict[str, object]:
|
|
"""Convert a validated draft into a ``workout_data`` dict for the log."""
|
|
duration = _duration_minutes(draft)
|
|
if draft.sport == SPORT_TABLE_TENNIS:
|
|
activity_label = "table tennis"
|
|
else:
|
|
activity_label = draft.activity_type_other.strip()
|
|
|
|
entry: dict[str, object] = {
|
|
"type": MANUAL_WORKOUT_TYPE,
|
|
"source": f"{activity_label} at {draft.location_name.strip()}",
|
|
"sport": draft.sport,
|
|
"activity_type": activity_label,
|
|
"start_time": draft.start_time.strip(),
|
|
"end_time": draft.end_time.strip(),
|
|
"duration_minutes": f"{duration:.1f}" if duration is not None else "",
|
|
"location_name": draft.location_name.strip(),
|
|
"transport_method": draft.transport_method.strip(),
|
|
"cost": draft.cost.strip(),
|
|
"reservation_phone": draft.reservation_phone.strip(),
|
|
"rpe": int(draft.rpe),
|
|
"techniques_practiced": draft.techniques_practiced.strip(),
|
|
"warm_up_minutes": draft.warm_up_minutes.strip(),
|
|
"pain_or_injury": draft.pain_or_injury.strip(),
|
|
"went_well": draft.went_well.strip(),
|
|
"to_improve": draft.to_improve.strip(),
|
|
"overall_feeling": draft.overall_feeling.strip(),
|
|
}
|
|
if draft.sport == SPORT_TABLE_TENNIS:
|
|
entry.update(
|
|
{
|
|
"matches_won": int(draft.matches_won),
|
|
"matches_lost": int(draft.matches_lost),
|
|
"sets_won": int(draft.sets_won),
|
|
"sets_lost": int(draft.sets_lost),
|
|
"racket": draft.racket.strip(),
|
|
"balls": draft.balls.strip(),
|
|
}
|
|
)
|
|
else:
|
|
entry.update(
|
|
{
|
|
"activity_details": draft.activity_details.strip(),
|
|
"equipment": draft.equipment.strip(),
|
|
}
|
|
)
|
|
return entry
|
|
|
|
|
|
# ── Cross-device sync wire format ─────────────────────────────────────────────
|
|
# The value of the record ``kind`` discriminator equals ``MANUAL_WORKOUT_TYPE``
|
|
# by design: on the wire a manual record is tagged ``kind="manual_workout"`` and
|
|
# its payload also carries ``type="manual_workout"``. A StrongLifts session
|
|
# payload has no ``kind`` field, so an ingesting PC routes on ``kind`` first and
|
|
# a manual record can never be mistaken for (or stamped as) a verified session.
|
|
MANUAL_WORKOUT_SYNC_KIND = MANUAL_WORKOUT_TYPE
|
|
|
|
|
|
def manual_sync_record_id(date: str, start_time: str) -> str:
|
|
"""Return the stable crdt_sync record id for a manual workout.
|
|
|
|
Prefixed ``manual:`` so it never collides with the bare-ISO session ids the
|
|
phone app writes into the same device log. Derived only from
|
|
``(date, start_time)`` so re-syncing the same workout is idempotent — this
|
|
id must never be re-minted for a given workout.
|
|
"""
|
|
return f"manual:{date}T{start_time.strip()}"
|
|
|
|
|
|
def build_sync_payload(draft: ManualWorkoutDraft, date: str) -> dict[str, object]:
|
|
"""Build the cross-device sync payload for a manual workout.
|
|
|
|
This is the wire contract shared with the phone app: the same fields as
|
|
:func:`build_entry` (what lands in ``log.json``), plus a ``kind``
|
|
discriminator and the workout's own ``date``. ``log.json`` is
|
|
day-keyed on the PC, but the synced payload must carry its date so the
|
|
shared budget can be windowed and the entry filed in the right ISO week on
|
|
ingest.
|
|
"""
|
|
payload = build_entry(draft)
|
|
payload["kind"] = MANUAL_WORKOUT_SYNC_KIND
|
|
payload["date"] = date
|
|
return payload
|