Add chore expiry notification system with scheduling and tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m59s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m59s
- Implemented `trigger_chore_expiry.py` script to manually trigger chore expiry notifications for users via the admin API. - Developed `chore_expiry_notification_scheduler.py` to handle the logic for sending notifications for chores expiring within the next 75 minutes. - Created utility functions in `schedule_utils.py` to determine scheduling and deadlines for chores. - Added comprehensive tests for the chore expiry notification system in `test_chore_expiry_notification_scheduler.py`, covering various scenarios including scheduled chores, confirmations, and user settings.
This commit is contained in:
91
backend/utils/schedule_utils.py
Normal file
91
backend/utils/schedule_utils.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Python port of the frontend schedule logic in scheduleUtils.ts.
|
||||
|
||||
These utilities determine whether a chore is scheduled on a given day and what
|
||||
its deadline time is. The frontend is the source of truth for schedule math;
|
||||
keep this file in sync with any changes to scheduleUtils.ts.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
def interval_hits_today(anchor_date: str, interval_days: int, local_date: date) -> bool:
|
||||
"""Return True if an interval-mode schedule fires on local_date.
|
||||
|
||||
anchor_date: ISO date string e.g. "2026-02-25", or "" (backward compat →
|
||||
treats local_date itself as anchor, so it always hits today and
|
||||
every interval_days days after).
|
||||
Dates before the anchor always return False (scheduling hasn't started yet).
|
||||
"""
|
||||
if anchor_date:
|
||||
parts = anchor_date.split('-')
|
||||
anchor = date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
else:
|
||||
# Empty anchor = start from today (backward compat)
|
||||
anchor = local_date
|
||||
|
||||
diff_days = (local_date - anchor).days
|
||||
if diff_days < 0:
|
||||
return False
|
||||
return diff_days % interval_days == 0
|
||||
|
||||
|
||||
def is_scheduled_today(schedule: dict, local_date: date) -> bool:
|
||||
"""Return True if the schedule applies on local_date.
|
||||
|
||||
- days mode: today's weekday (0=Sun … 6=Sat) is in day_configs
|
||||
- interval mode: interval_hits_today
|
||||
- paused (enabled=False): always True (chore shows every day, like no schedule)
|
||||
"""
|
||||
if not schedule.get('enabled', True):
|
||||
return True # paused = show always, like an unscheduled chore
|
||||
|
||||
mode = schedule.get('mode', 'days')
|
||||
if mode == 'days':
|
||||
# Python's weekday(): Mon=0..Sun=6. Our convention: Sun=0..Sat=6 (JS/TS).
|
||||
# Convert: js_day = (python_weekday + 1) % 7
|
||||
js_weekday = (local_date.weekday() + 1) % 7
|
||||
day_configs = schedule.get('day_configs', [])
|
||||
return any(dc.get('day') == js_weekday for dc in day_configs)
|
||||
else:
|
||||
return interval_hits_today(
|
||||
schedule.get('anchor_date', ''),
|
||||
schedule.get('interval_days', 2),
|
||||
local_date,
|
||||
)
|
||||
|
||||
|
||||
def get_due_time_today(schedule: dict, local_date: date) -> tuple[int, int] | None:
|
||||
"""Return (hour, minute) for the deadline today, or None if no deadline applies.
|
||||
|
||||
Returns None when:
|
||||
- The schedule is paused (enabled=False) — no deadline, acts like Anytime
|
||||
- The day is not scheduled
|
||||
- The schedule has no deadline (default_has_deadline=False or
|
||||
interval_has_deadline=False → 'Anytime')
|
||||
|
||||
Callers treat None as 'active all day with no expiry'.
|
||||
"""
|
||||
if not schedule.get('enabled', True):
|
||||
return None # paused = no deadline, no expiry
|
||||
|
||||
mode = schedule.get('mode', 'days')
|
||||
if mode == 'days':
|
||||
if not schedule.get('default_has_deadline', True):
|
||||
return None # Anytime
|
||||
js_weekday = (local_date.weekday() + 1) % 7
|
||||
day_configs = schedule.get('day_configs', [])
|
||||
dc = next((d for d in day_configs if d.get('day') == js_weekday), None)
|
||||
if dc is None:
|
||||
return None
|
||||
return (dc.get('hour', 0), dc.get('minute', 0))
|
||||
else:
|
||||
if not interval_hits_today(
|
||||
schedule.get('anchor_date', ''),
|
||||
schedule.get('interval_days', 2),
|
||||
local_date,
|
||||
):
|
||||
return None
|
||||
if not schedule.get('interval_has_deadline', True):
|
||||
return None # Anytime
|
||||
return (schedule.get('interval_hour', 0), schedule.get('interval_minute', 0))
|
||||
Reference in New Issue
Block a user