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

- 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:
2026-04-22 15:37:40 -04:00
parent 6982fa561f
commit 8907184fde
9 changed files with 1181 additions and 4 deletions

View File

@@ -0,0 +1,237 @@
"""
Hourly scheduler that sends push notifications to parents when their children's
scheduled chores are going to expire within the next 75 minutes and haven't been
marked completed or submitted for confirmation yet.
Window rationale (75 min):
The spec requires that chores expiring between 9:00 pm and 9:15 pm both
generate a notification at 8:00 pm. A 75-minute look-ahead window
(now < deadline <= now + 75 min) satisfies this for the hourly cron run at
the top of each hour.
Double-notification note:
A chore deadline that falls within the overlap of two consecutive 75-minute
windows (e.g. 9:10 pm appears in both the 8:00 pm and 9:00 pm windows) may
produce two push notifications. No deduplication is applied; this is
intentional per the product decision made during spec review.
"""
import logging
import os
from datetime import datetime, timedelta, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from tinydb import Query
from utils.schedule_utils import get_due_time_today, is_scheduled_today
from utils.push_sender import send_push_to_user
logger = logging.getLogger(__name__)
_WINDOW_MINUTES = 75
def get_expiring_chores_for_user(
user_id: str,
tz_str: str | None,
now_dt: datetime,
) -> list[dict]:
"""Return chores that will expire within the next _WINDOW_MINUTES minutes.
Each entry is::
{
'child_id': str,
'child_name': str,
'task_id': str,
'task_name': str,
}
A chore is included only when ALL of the following are true:
- It has a ChoreSchedule with enabled=True
- It is scheduled today (days or interval mode)
- It has a deadline (not Anytime)
- The deadline falls in (now_dt, now_dt + _WINDOW_MINUTES]
- There is no existing pending_confirmation record for this child + task
(status='pending' means awaiting approval; status='approved' means done)
Must be called within a Flask app context.
"""
from db.db import child_db, task_db, pending_confirmations_db
from db.chore_schedules import get_schedule
try:
from zoneinfo import ZoneInfo
local_now = datetime.now(ZoneInfo(tz_str)) if tz_str else now_dt
except Exception:
local_now = now_dt
local_date = local_now.date()
window_end = local_now + timedelta(minutes=_WINDOW_MINUTES)
ChildQ = Query()
children = child_db.search(ChildQ.user_id == user_id)
expiring: list[dict] = []
for child_dict in children:
child_id = child_dict.get('id')
child_name = child_dict.get('name', 'Unknown')
task_ids = child_dict.get('tasks', [])
TaskQ = Query()
for task_id in task_ids:
task = task_db.get(
(TaskQ.id == task_id) &
(TaskQ.type == 'chore') &
((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
if not task:
continue
schedule = get_schedule(child_id, task_id)
if schedule is None:
continue
schedule_dict = schedule.to_dict()
if not schedule_dict.get('enabled', True):
continue # paused schedules have no deadline
if not is_scheduled_today(schedule_dict, local_date):
continue
due = get_due_time_today(schedule_dict, local_date)
if due is None:
continue # Anytime — no expiry
due_hour, due_minute = due
# Build a timezone-aware deadline datetime for today
deadline_dt = local_now.replace(
hour=due_hour, minute=due_minute, second=0, microsecond=0
)
# Include only when deadline is strictly after now and within window
if not (local_now < deadline_dt <= window_end):
continue
# Skip if any confirmation record exists (pending OR approved = done today)
PendingQ = Query()
has_confirmation = pending_confirmations_db.get(
(PendingQ.child_id == child_id) &
(PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') &
(PendingQ.user_id == user_id)
)
if has_confirmation:
continue
expiring.append({
'child_id': child_id,
'child_name': child_name,
'task_id': task_id,
'task_name': task.get('name', 'Unknown'),
})
return expiring
def _build_push_payload(expiring: list[dict]) -> dict:
"""Build the push notification payload from a list of expiring chore entries."""
if len(expiring) == 1:
entry = expiring[0]
title = f"Chore ending soon: {entry['task_name']}"
child_id = entry['child_id']
entity_id = entry['task_id']
else:
title = 'Chores ending soon'
child_id = None
entity_id = None
# Group by child and format body text
by_child: dict[str, list[str]] = {}
for entry in expiring:
by_child.setdefault(entry['child_name'], []).append(entry['task_name'])
body_lines = [
f"{name}: {', '.join(tasks)}" for name, tasks in by_child.items()
]
body = '\n'.join(body_lines)
return {
'type': 'chore_expiring_soon',
'title': title,
'body': body,
'child_id': child_id,
'entity_id': entity_id,
'entity_type': 'chore',
}
def send_chore_expiry_notifications_for_user(user_id: str, tz_str: str | None) -> int:
"""Check for expiring chores and send a push notification if any are found.
Returns the number of chores included in the notification (0 = nothing sent).
Must be called within a Flask app context.
"""
now_dt = datetime.now(timezone.utc)
expiring = get_expiring_chores_for_user(user_id, tz_str, now_dt)
if not expiring:
return 0
payload = _build_push_payload(expiring)
send_push_to_user(user_id, payload)
logger.info(
f'Chore expiry: sent notification for {len(expiring)} chore(s) to user {user_id}'
)
return len(expiring)
def run_chore_expiry_check(app) -> None:
"""Check all verified, push-enabled users for soon-to-expire chores."""
if os.environ.get('DB_ENV') == 'e2e':
return
with app.app_context():
try:
from db.db import users_db
UserQ = Query()
users = users_db.search(UserQ.verified == True)
for user_dict in users:
user_id = user_dict.get('id')
tz_str = user_dict.get('timezone')
if not user_dict.get('push_notifications_enabled', True):
continue
try:
count = send_chore_expiry_notifications_for_user(user_id, tz_str)
if count:
logger.info(
f'Chore expiry: notified {count} expiring chore(s) for user {user_id}'
)
except Exception as e:
logger.error(f'Chore expiry: failed for user {user_id}: {e}')
except Exception as e:
logger.error(f'Error in run_chore_expiry_check: {e}')
def start_chore_expiry_notification_scheduler(app) -> BackgroundScheduler:
"""Start the hourly chore-expiry notification scheduler.
Runs at the top of every hour (minute=0), matching the existing schedulers.
Returns the scheduler instance.
"""
scheduler = BackgroundScheduler()
scheduler.add_job(
run_chore_expiry_check,
'cron',
minute=0,
args=[app],
)
scheduler.start()
logger.info('Chore expiry notification scheduler started (runs every hour at :00)')
return scheduler

View 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))