Files
chore/backend/utils/chore_expiry_notification_scheduler.py
Ryan Kegel 8907184fde
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m59s
Add chore expiry notification system with scheduling and tests
- 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.
2026-04-22 15:37:40 -04:00

238 lines
7.7 KiB
Python

"""
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