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:
237
backend/utils/chore_expiry_notification_scheduler.py
Normal file
237
backend/utils/chore_expiry_notification_scheduler.py
Normal 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
|
||||
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