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:
148
.github/specs/feat-notify-ending-chore.md
vendored
Normal file
148
.github/specs/feat-notify-ending-chore.md
vendored
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Feature: Push notification for chores that are going to expire in the next hour.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When a scheduled chore is going to expire within the next hour and it has not be marked completed or pending, a push notification should go out to tell the user that the scheduled chore needs to be finished soon.
|
||||||
|
|
||||||
|
**Goal:** When a chore is about to expire (within the hour) send a push notification to the user and allow the user to acknowlege it.
|
||||||
|
|
||||||
|
**User Story:**
|
||||||
|
As a parent, I should receive a notification when a scheduled chore is going to expire soon. When I click on the notification, I should be brought to the page with the chore.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. If there are multiple chores that are going to expire, present them in the push notification as well.
|
||||||
|
2. The notification should show which child the chore belongs to.
|
||||||
|
3. If a chore is scheduled to expire between 9:00pm to 9:15pm, the notification for it should go out at 8:00pm. That should give the earlier hour expiration plenty of notice.
|
||||||
|
|
||||||
|
**Questions:**
|
||||||
|
|
||||||
|
1. If there are multiple chores in the push, should we say there are multiple chores ending next hour or should we list each by name?
|
||||||
|
2. Should we section the notification if there are multiple children? [ex. Child1 chores: ... Child2 chores: ...]
|
||||||
|
3. Should we reuse one of the other hourly schedulers or create a new one? should we refactor and have one hourly scheduler that does multiple operations?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
### Backend Model
|
||||||
|
|
||||||
|
No new models required. The feature reuses existing models:
|
||||||
|
|
||||||
|
- `ChoreSchedule` — provides schedule mode, day configs, deadline times, and enabled state
|
||||||
|
- `PendingConfirmation` — queried to determine if a chore is already pending or approved (either status means the child has acted on it; no notification is needed)
|
||||||
|
|
||||||
|
### Frontend Model
|
||||||
|
|
||||||
|
No changes to TypeScript interfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Implementation
|
||||||
|
|
||||||
|
**`backend/utils/schedule_utils.py`** (new)
|
||||||
|
|
||||||
|
A Python port of the three core functions from `frontend/vue-app/src/common/scheduleUtils.ts`. The frontend is the authoritative source for schedule math; this file mirrors that logic server-side so the scheduler can determine which chores are active and when they expire without making HTTP calls or duplicating logic ad-hoc.
|
||||||
|
|
||||||
|
- `interval_hits_today(anchor_date, interval_days, local_date)` — returns True if an interval-mode schedule fires on the given local date
|
||||||
|
- `is_scheduled_today(schedule_dict, local_date)` — returns True for days-mode (weekday match) or interval-mode (anchor + interval math); paused schedules always return True
|
||||||
|
- `get_due_time_today(schedule_dict, local_date)` — returns `(hour, minute)` or `None` (None means Anytime or paused — no expiry)
|
||||||
|
|
||||||
|
Note on weekday convention: the app uses JS/Python convention where Sunday = 0 and Saturday = 6 (same as `DayConfig.day`). Python's `date.weekday()` returns Monday = 0, so a conversion of `(weekday + 1) % 7` is applied when comparing.
|
||||||
|
|
||||||
|
**`backend/utils/chore_expiry_notification_scheduler.py`** (new)
|
||||||
|
|
||||||
|
- `get_expiring_chores_for_user(user_id, tz_str, now_dt)` — loads all children for the user, iterates their chore tasks, checks for an active schedule with a deadline falling in `(now, now + 75 min]`, and excludes any chore that already has a `PendingConfirmation` record (any status). Returns a list of dicts with child and task metadata.
|
||||||
|
- `_build_push_payload(expiring)` — constructs the push payload. Single chore: title names the chore and deep-link fields are set for direct navigation. Multiple chores: generic title, `child_id` and `entity_id` are null, and the body lists chores grouped by child name (e.g. `Alex: Clean Room, Make Bed\nSam: Trash`).
|
||||||
|
- `send_chore_expiry_notifications_for_user(user_id, tz_str)` — calls `get_expiring_chores_for_user`, builds the payload, and calls `send_push_to_user`.
|
||||||
|
- `run_chore_expiry_check(app)` — skips the e2e environment, loops all verified users, skips users with `push_notifications_enabled=False`, and calls `send_chore_expiry_notifications_for_user` for each eligible user.
|
||||||
|
- `start_chore_expiry_notification_scheduler(app)` — registers an APScheduler `cron` job at `minute=0` (top of every hour), consistent with the existing schedulers.
|
||||||
|
|
||||||
|
**Push payload type:** `chore_expiring_soon`. The absence of `approve_token` and `deny_token` fields is intentional and is what triggers the service worker to suppress action buttons.
|
||||||
|
|
||||||
|
**`backend/main.py`** (modified)
|
||||||
|
|
||||||
|
Import added and `start_chore_expiry_notification_scheduler(app)` called after the existing schedulers.
|
||||||
|
|
||||||
|
## Backend Tests
|
||||||
|
|
||||||
|
- [x] `get_expiring_chores_for_user`: chore in 75-min window → included
|
||||||
|
- [x] `get_expiring_chores_for_user`: deadline in the past → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: deadline beyond 75-min window → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: Anytime schedule (no deadline) → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: chore not scheduled today (wrong weekday) → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: status=pending confirmation exists → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: status=approved confirmation exists → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: no schedule → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: paused schedule (enabled=False) → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: interval mode hits today → included
|
||||||
|
- [x] `get_expiring_chores_for_user`: interval mode misses today → excluded
|
||||||
|
- [x] `get_expiring_chores_for_user`: multiple children returns entries for both
|
||||||
|
- [x] `_build_push_payload`: single chore → named title, deep-link fields set
|
||||||
|
- [x] `_build_push_payload`: multiple chores → generic title, child_id/entity_id null
|
||||||
|
- [x] `_build_push_payload`: body groups chore names by child
|
||||||
|
- [x] `_build_push_payload`: no approve_token or deny_token in payload
|
||||||
|
- [x] `send_chore_expiry_notifications_for_user`: calls send_push_to_user with correct payload
|
||||||
|
- [x] `send_chore_expiry_notifications_for_user`: no push when no chores expiring
|
||||||
|
- [x] `run_chore_expiry_check`: skips when DB_ENV=e2e
|
||||||
|
- [x] `run_chore_expiry_check`: skips user with push_notifications_enabled=False
|
||||||
|
- [x] `run_chore_expiry_check`: skips unverified user
|
||||||
|
- [x] `run_chore_expiry_check`: sends push for eligible verified user with push enabled
|
||||||
|
- [x] `schedule_utils`: interval_hits_today — same day as anchor hits
|
||||||
|
- [x] `schedule_utils`: interval_hits_today — correct interval day hits
|
||||||
|
- [x] `schedule_utils`: interval_hits_today — non-interval day misses
|
||||||
|
- [x] `schedule_utils`: interval_hits_today — date before anchor misses
|
||||||
|
- [x] `schedule_utils`: interval_hits_today — empty anchor hits today
|
||||||
|
- [x] `schedule_utils`: is_scheduled_today — days mode matching weekday
|
||||||
|
- [x] `schedule_utils`: is_scheduled_today — days mode non-matching weekday
|
||||||
|
- [x] `schedule_utils`: is_scheduled_today — paused always true
|
||||||
|
- [x] `schedule_utils`: is_scheduled_today — interval mode hit
|
||||||
|
- [x] `schedule_utils`: is_scheduled_today — interval mode miss
|
||||||
|
- [x] `schedule_utils`: get_due_time_today — days mode returns (hour, minute)
|
||||||
|
- [x] `schedule_utils`: get_due_time_today — Anytime returns None
|
||||||
|
- [x] `schedule_utils`: get_due_time_today — wrong day returns None
|
||||||
|
- [x] `schedule_utils`: get_due_time_today — paused returns None
|
||||||
|
- [x] `schedule_utils`: get_due_time_today — interval mode returns (hour, minute)
|
||||||
|
- [x] `schedule_utils`: get_due_time_today — interval Anytime returns None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
|
||||||
|
**`frontend/vue-app/public/sw.js`** (modified)
|
||||||
|
|
||||||
|
In the `push` event handler, the `actions` array passed to `showNotification` is now conditionally set: when `payload.type === 'chore_expiring_soon'` the array is empty (no buttons); otherwise Approve and Deny buttons are shown as before. The `notificationclick` handler requires no changes — tapping the notification body already navigates to `/parent/{child_id}?scrollTo={entity_id}` when those fields are present, and falls back to `/parent` when they are null (multi-chore case).
|
||||||
|
|
||||||
|
## Frontend Tests
|
||||||
|
|
||||||
|
No automated tests added. The `sw.js` change is a simple conditional and is covered by manual verification: triggering a `chore_expiring_soon` push and confirming no action buttons appear in the browser notification, and that tapping opens the correct URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- **75-minute window double-notification**: A chore deadline that falls in the overlap of two consecutive 75-minute windows (e.g. 9:10 pm appears in both the 8:00 pm run's window and the 9:00 pm run's window) will produce two notifications. This is a known and accepted trade-off; no deduplication is applied. A future improvement could track notified chores per day in a lightweight DB table to prevent repeats.
|
||||||
|
- **Task extensions**: `TaskExtension` sets an `extension_date` that allows a chore to carry over to another day. Extended chores are treated identically to regular scheduled chores — the schedule deadline time is used as-is. A future enhancement could optionally adjust the deadline time for extended chores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria (Definition of Done)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [x] `backend/utils/schedule_utils.py` created — Python port of schedule math from `scheduleUtils.ts`
|
||||||
|
- [x] `backend/utils/chore_expiry_notification_scheduler.py` created — hourly scheduler with 75-min look-ahead window
|
||||||
|
- [x] Chores with `Anytime` schedule (no deadline) are not notified
|
||||||
|
- [x] Chores with a `pending` or `approved` confirmation are not notified
|
||||||
|
- [x] Chores on paused schedules are not notified
|
||||||
|
- [x] Users with `push_notifications_enabled=False` are skipped
|
||||||
|
- [x] Unverified users are skipped
|
||||||
|
- [x] e2e environment is skipped
|
||||||
|
- [x] Scheduler registered in `backend/main.py`
|
||||||
|
- [x] All backend tests pass
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [x] `sw.js` updated — `chore_expiring_soon` notifications show no Approve/Deny action buttons
|
||||||
|
- [x] Tapping a single-chore notification navigates to the child's chore page
|
||||||
|
- [x] Tapping a multi-chore notification navigates to `/parent`
|
||||||
@@ -247,3 +247,41 @@ def send_test_digest():
|
|||||||
return jsonify({'items_sent': items_sent}), 200
|
return jsonify({'items_sent': items_sent}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/admin/test/trigger-chore-expiry', methods=['POST'])
|
||||||
|
def trigger_test_chore_expiry():
|
||||||
|
"""Trigger the chore expiry notification check for a specific user by email address.
|
||||||
|
|
||||||
|
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
||||||
|
Note: actual push delivery requires VAPID keys to be configured.
|
||||||
|
"""
|
||||||
|
if os.environ.get('DB_ENV') == 'production':
|
||||||
|
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
caller_dict = users_db.get(Query().id == user_id)
|
||||||
|
if not caller_dict or caller_dict.get('role') != 'admin':
|
||||||
|
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
email = data.get('email', '').strip().lower()
|
||||||
|
if not email:
|
||||||
|
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
|
||||||
|
|
||||||
|
target = users_db.get(Query().email == email)
|
||||||
|
if not target:
|
||||||
|
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
target_id = target.get('id')
|
||||||
|
tz_str = target.get('timezone')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.chore_expiry_notification_scheduler import send_chore_expiry_notifications_for_user
|
||||||
|
chores_notified = send_chore_expiry_notifications_for_user(target_id, tz_str)
|
||||||
|
return jsonify({'chores_notified': chores_notified}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from events.broadcaster import Broadcaster
|
|||||||
from events.sse import sse_response_for_user, send_to_user
|
from events.sse import sse_response_for_user, send_to_user
|
||||||
from api.utils import get_current_user_id
|
from api.utils import get_current_user_id
|
||||||
from utils.account_deletion_scheduler import start_deletion_scheduler
|
from utils.account_deletion_scheduler import start_deletion_scheduler
|
||||||
|
from utils.chore_expiry_notification_scheduler import start_chore_expiry_notification_scheduler
|
||||||
from utils.digest_scheduler import start_digest_scheduler
|
from utils.digest_scheduler import start_digest_scheduler
|
||||||
from utils.state_expiry_scheduler import start_state_expiry_scheduler
|
from utils.state_expiry_scheduler import start_state_expiry_scheduler
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ start_background_threads()
|
|||||||
start_deletion_scheduler()
|
start_deletion_scheduler()
|
||||||
start_digest_scheduler(app)
|
start_digest_scheduler(app)
|
||||||
start_state_expiry_scheduler(app)
|
start_state_expiry_scheduler(app)
|
||||||
|
start_chore_expiry_notification_scheduler(app)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
||||||
112
backend/scripts/trigger_chore_expiry.py
Normal file
112
backend/scripts/trigger_chore_expiry.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Script to trigger the chore expiry notification check for a specific user via the admin API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/trigger_chore_expiry.py <email>
|
||||||
|
|
||||||
|
The script logs in as an admin using the ADMIN_EMAIL and ADMIN_PASSWORD
|
||||||
|
environment variables (defaults to localhost:5000).
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
ADMIN_EMAIL - Admin account email (required)
|
||||||
|
ADMIN_PASSWORD - Admin account password (required)
|
||||||
|
API_BASE_URL - Base URL of the backend API (default: http://localhost:5000)
|
||||||
|
|
||||||
|
NOTE: When targeting a proxied frontend URL (e.g. nginx), append /api to the URL.
|
||||||
|
e.g. API_BASE_URL=https://dev.chores.ryankegel.com/api
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:5000').rstrip('/')
|
||||||
|
|
||||||
|
|
||||||
|
def _post(path: str, body: dict, cookie: str | None = None) -> tuple[int, dict]:
|
||||||
|
url = f"{API_BASE_URL}{path}"
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if cookie:
|
||||||
|
headers['Cookie'] = cookie
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return resp.status, json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(e.read())
|
||||||
|
except Exception:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def _login() -> str:
|
||||||
|
"""Log in as admin and return the cookie header string."""
|
||||||
|
admin_email = os.environ.get('ADMIN_EMAIL', '').strip()
|
||||||
|
admin_password = os.environ.get('ADMIN_PASSWORD', '').strip()
|
||||||
|
|
||||||
|
if not admin_email or not admin_password:
|
||||||
|
print('Error: ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
login_url = f"{API_BASE_URL}/auth/login"
|
||||||
|
login_data = json.dumps({'email': admin_email, 'password': admin_password}).encode()
|
||||||
|
login_req = urllib.request.Request(
|
||||||
|
login_url,
|
||||||
|
data=login_data,
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(login_req) as resp:
|
||||||
|
raw_cookies = resp.headers.get_all('Set-Cookie') or []
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f'Login failed ({e.code})')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cookie_header = '; '.join(part.split(';')[0] for part in raw_cookies)
|
||||||
|
if not cookie_header:
|
||||||
|
print('Login succeeded but no cookies were returned')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return cookie_header
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print('Usage: python scripts/trigger_chore_expiry.py <email>')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_email = sys.argv[1].strip()
|
||||||
|
cookie = _login()
|
||||||
|
|
||||||
|
status, body = _post(
|
||||||
|
'/admin/test/trigger-chore-expiry',
|
||||||
|
{'email': target_email},
|
||||||
|
cookie=cookie,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
count = body.get('chores_notified', 0)
|
||||||
|
if count == 0:
|
||||||
|
print(f'No expiring chores found for {target_email} in the next 75 minutes — no push sent.')
|
||||||
|
else:
|
||||||
|
print(f'Push notification sent for {count} expiring chore(s) for {target_email}.')
|
||||||
|
elif status == 404 and body.get('code') == 'NOT_FOUND':
|
||||||
|
print('Error: This endpoint is disabled in the current environment (DB_ENV=production).')
|
||||||
|
sys.exit(1)
|
||||||
|
elif status == 404 and body.get('code') == 'USER_NOT_FOUND':
|
||||||
|
print(f'Error: No user found with email {target_email}')
|
||||||
|
sys.exit(1)
|
||||||
|
elif status == 403:
|
||||||
|
admin_email = os.environ.get('ADMIN_EMAIL', '')
|
||||||
|
print(f'Error: {admin_email} is not an admin account.')
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f'Error ({status}): {body.get("error", body)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
544
backend/tests/test_chore_expiry_notification_scheduler.py
Normal file
544
backend/tests/test_chore_expiry_notification_scheduler.py
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
"""Tests for the chore expiry notification scheduler."""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from flask import Flask
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from utils.chore_expiry_notification_scheduler import (
|
||||||
|
get_expiring_chores_for_user,
|
||||||
|
_build_push_payload,
|
||||||
|
send_chore_expiry_notifications_for_user,
|
||||||
|
run_chore_expiry_check,
|
||||||
|
)
|
||||||
|
from utils.schedule_utils import interval_hits_today, is_scheduled_today, get_due_time_today
|
||||||
|
from db.db import users_db, child_db, task_db, chore_schedules_db, pending_confirmations_db
|
||||||
|
from tests.conftest import TEST_SECRET_KEY
|
||||||
|
|
||||||
|
USER_ID = "expiry_notif_user"
|
||||||
|
CHILD_ID = "expiry_notif_child"
|
||||||
|
CHILD_ID_2 = "expiry_notif_child_2"
|
||||||
|
TASK_ID = "expiry_notif_task"
|
||||||
|
TASK_ID_2 = "expiry_notif_task_2"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _seed_user(push_enabled: bool = True, verified: bool = True):
|
||||||
|
users_db.remove(Query().id == USER_ID)
|
||||||
|
users_db.insert({
|
||||||
|
"id": USER_ID,
|
||||||
|
"first_name": "Notify",
|
||||||
|
"last_name": "Tester",
|
||||||
|
"email": f"{USER_ID}@example.com",
|
||||||
|
"password": "hashed",
|
||||||
|
"verified": verified,
|
||||||
|
"role": "user",
|
||||||
|
"image_id": None,
|
||||||
|
"marked_for_deletion": False,
|
||||||
|
"timezone": "UTC",
|
||||||
|
"email_digest_enabled": False,
|
||||||
|
"push_notifications_enabled": push_enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_child(child_id: str = CHILD_ID, task_ids: list = None):
|
||||||
|
if task_ids is None:
|
||||||
|
task_ids = [TASK_ID]
|
||||||
|
child_db.remove(Query().id == child_id)
|
||||||
|
child_db.insert({
|
||||||
|
"id": child_id,
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"name": "Alex" if child_id == CHILD_ID else "Sam",
|
||||||
|
"age": 8,
|
||||||
|
"points": 0,
|
||||||
|
"tasks": task_ids,
|
||||||
|
"rewards": [],
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_task(task_id: str = TASK_ID, name: str = "Clean Room"):
|
||||||
|
task_db.remove(Query().id == task_id)
|
||||||
|
task_db.insert({
|
||||||
|
"id": task_id,
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"name": name,
|
||||||
|
"points": 10,
|
||||||
|
"type": "chore",
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_schedule(
|
||||||
|
child_id: str = CHILD_ID,
|
||||||
|
task_id: str = TASK_ID,
|
||||||
|
mode: str = "days",
|
||||||
|
day_configs: list = None,
|
||||||
|
default_has_deadline: bool = True,
|
||||||
|
default_hour: int = 21,
|
||||||
|
default_minute: int = 0,
|
||||||
|
enabled: bool = True,
|
||||||
|
interval_days: int = 1,
|
||||||
|
anchor_date: str = "",
|
||||||
|
interval_has_deadline: bool = True,
|
||||||
|
interval_hour: int = 21,
|
||||||
|
interval_minute: int = 0,
|
||||||
|
):
|
||||||
|
"""Seed a chore schedule. For 'days' mode, day_configs defaults to all 7 days."""
|
||||||
|
if day_configs is None:
|
||||||
|
day_configs = [
|
||||||
|
{"day": d, "hour": default_hour, "minute": default_minute}
|
||||||
|
for d in range(7)
|
||||||
|
]
|
||||||
|
chore_schedules_db.remove(
|
||||||
|
(Query().child_id == child_id) & (Query().task_id == task_id)
|
||||||
|
)
|
||||||
|
chore_schedules_db.insert({
|
||||||
|
"id": f"sched_{child_id}_{task_id}",
|
||||||
|
"child_id": child_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"mode": mode,
|
||||||
|
"day_configs": day_configs,
|
||||||
|
"default_hour": default_hour,
|
||||||
|
"default_minute": default_minute,
|
||||||
|
"default_has_deadline": default_has_deadline,
|
||||||
|
"interval_days": interval_days,
|
||||||
|
"anchor_date": anchor_date,
|
||||||
|
"interval_has_deadline": interval_has_deadline,
|
||||||
|
"interval_hour": interval_hour,
|
||||||
|
"interval_minute": interval_minute,
|
||||||
|
"enabled": enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_confirmation(child_id: str = CHILD_ID, task_id: str = TASK_ID, status: str = "pending"):
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(Query().child_id == child_id) & (Query().entity_id == task_id)
|
||||||
|
)
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
"id": f"conf_{child_id}_{task_id}",
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"child_id": child_id,
|
||||||
|
"entity_id": task_id,
|
||||||
|
"entity_type": "chore",
|
||||||
|
"status": status,
|
||||||
|
"approved_at": None,
|
||||||
|
"created_at": 0,
|
||||||
|
"updated_at": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup():
|
||||||
|
for cid in (CHILD_ID, CHILD_ID_2):
|
||||||
|
child_db.remove(Query().id == cid)
|
||||||
|
chore_schedules_db.remove(Query().child_id == cid)
|
||||||
|
pending_confirmations_db.remove(Query().child_id == cid)
|
||||||
|
for tid in (TASK_ID, TASK_ID_2):
|
||||||
|
task_db.remove(Query().id == tid)
|
||||||
|
users_db.remove(Query().id == USER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_with_deadline_in_window(minutes_ahead: int = 30) -> tuple[datetime, int, int]:
|
||||||
|
"""Return (now_dt, hour, minute) so that deadline = now + minutes_ahead."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
deadline = now + timedelta(minutes=minutes_ahead)
|
||||||
|
return now, deadline.hour, deadline.minute
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
flask_app = Flask(__name__)
|
||||||
|
flask_app.config['TESTING'] = True
|
||||||
|
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# schedule_utils unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIntervalHitsToday:
|
||||||
|
def test_same_day_as_anchor_hits(self):
|
||||||
|
from datetime import date
|
||||||
|
d = date(2026, 4, 22)
|
||||||
|
assert interval_hits_today("2026-04-22", 3, d) is True
|
||||||
|
|
||||||
|
def test_interval_day_hits(self):
|
||||||
|
from datetime import date
|
||||||
|
assert interval_hits_today("2026-04-22", 3, date(2026, 4, 25)) is True
|
||||||
|
|
||||||
|
def test_non_interval_day_misses(self):
|
||||||
|
from datetime import date
|
||||||
|
assert interval_hits_today("2026-04-22", 3, date(2026, 4, 24)) is False
|
||||||
|
|
||||||
|
def test_before_anchor_misses(self):
|
||||||
|
from datetime import date
|
||||||
|
assert interval_hits_today("2026-04-22", 1, date(2026, 4, 21)) is False
|
||||||
|
|
||||||
|
def test_empty_anchor_hits_today(self):
|
||||||
|
from datetime import date
|
||||||
|
d = date(2026, 4, 22)
|
||||||
|
assert interval_hits_today("", 1, d) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsScheduledToday:
|
||||||
|
def test_days_mode_matching_weekday(self):
|
||||||
|
from datetime import date
|
||||||
|
# 2026-04-22 is Wednesday → JS weekday 3 (Sun=0..Sat=6)
|
||||||
|
schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 3}]}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
|
||||||
|
|
||||||
|
def test_days_mode_non_matching_weekday(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 1}]}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 22)) is False
|
||||||
|
|
||||||
|
def test_paused_schedule_always_true(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {"mode": "days", "enabled": False, "day_configs": []}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
|
||||||
|
|
||||||
|
def test_interval_mode_hit(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "interval", "enabled": True,
|
||||||
|
"interval_days": 1, "anchor_date": "2026-04-22",
|
||||||
|
}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
|
||||||
|
|
||||||
|
def test_interval_mode_miss(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "interval", "enabled": True,
|
||||||
|
"interval_days": 3, "anchor_date": "2026-04-22",
|
||||||
|
}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 23)) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDueTimeToday:
|
||||||
|
def test_days_mode_returns_due_time(self):
|
||||||
|
from datetime import date
|
||||||
|
# Wednesday → JS 3 (Sun=0..Sat=6)
|
||||||
|
schedule = {
|
||||||
|
"mode": "days", "enabled": True,
|
||||||
|
"default_has_deadline": True,
|
||||||
|
"day_configs": [{"day": 3, "hour": 21, "minute": 0}],
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) == (21, 0)
|
||||||
|
|
||||||
|
def test_days_mode_anytime_returns_none(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "days", "enabled": True,
|
||||||
|
"default_has_deadline": False,
|
||||||
|
"day_configs": [{"day": 4, "hour": 21, "minute": 0}],
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
|
||||||
|
|
||||||
|
def test_days_mode_wrong_day_returns_none(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "days", "enabled": True,
|
||||||
|
"default_has_deadline": True,
|
||||||
|
"day_configs": [{"day": 1, "hour": 21, "minute": 0}],
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
|
||||||
|
|
||||||
|
def test_paused_returns_none(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "days", "enabled": False,
|
||||||
|
"default_has_deadline": True,
|
||||||
|
"day_configs": [{"day": 4, "hour": 21, "minute": 0}],
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
|
||||||
|
|
||||||
|
def test_interval_mode_returns_due_time(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "interval", "enabled": True,
|
||||||
|
"interval_days": 1, "anchor_date": "2026-04-22",
|
||||||
|
"interval_has_deadline": True,
|
||||||
|
"interval_hour": 20, "interval_minute": 30,
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) == (20, 30)
|
||||||
|
|
||||||
|
def test_interval_mode_anytime_returns_none(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "interval", "enabled": True,
|
||||||
|
"interval_days": 1, "anchor_date": "2026-04-22",
|
||||||
|
"interval_has_deadline": False,
|
||||||
|
"interval_hour": 20, "interval_minute": 30,
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_expiring_chores_for_user tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetExpiringChoresForUser:
|
||||||
|
def setup_method(self):
|
||||||
|
_cleanup()
|
||||||
|
_seed_user()
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def test_chore_in_window_included(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]['task_id'] == TASK_ID
|
||||||
|
assert result[0]['child_id'] == CHILD_ID
|
||||||
|
|
||||||
|
def test_chore_deadline_past_excluded(self, app):
|
||||||
|
# Deadline 10 minutes in the past
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
past = now - timedelta(minutes=10)
|
||||||
|
_seed_schedule(default_hour=past.hour, default_minute=past.minute)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_chore_deadline_beyond_window_excluded(self, app):
|
||||||
|
# Deadline 80 minutes ahead (beyond 75-min window)
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=80)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_anytime_chore_excluded(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_has_deadline=False, default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_wrong_day_excluded(self, app):
|
||||||
|
from datetime import date
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# Schedule only on a day that is NOT today
|
||||||
|
today_js = (now.weekday() + 1) % 7
|
||||||
|
wrong_day = (today_js + 1) % 7
|
||||||
|
h = (now + timedelta(minutes=30)).hour
|
||||||
|
m = (now + timedelta(minutes=30)).minute
|
||||||
|
_seed_schedule(
|
||||||
|
day_configs=[{"day": wrong_day, "hour": h, "minute": m}],
|
||||||
|
default_hour=h,
|
||||||
|
default_minute=m,
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_pending_confirmation_excludes_chore(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
_seed_confirmation(status="pending")
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_approved_confirmation_excludes_chore(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
_seed_confirmation(status="approved")
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_no_schedule_excluded(self, app):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# No schedule seeded
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_paused_schedule_excluded(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(enabled=False, default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_interval_mode_hit_included(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
today_iso = now.strftime("%Y-%m-%d")
|
||||||
|
_seed_schedule(
|
||||||
|
mode="interval",
|
||||||
|
interval_days=1,
|
||||||
|
anchor_date=today_iso,
|
||||||
|
interval_has_deadline=True,
|
||||||
|
interval_hour=h,
|
||||||
|
interval_minute=m,
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_interval_mode_miss_excluded(self, app):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
h = (now + timedelta(minutes=30)).hour
|
||||||
|
m = (now + timedelta(minutes=30)).minute
|
||||||
|
# Anchor yesterday with interval_days=2 → not today
|
||||||
|
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
_seed_schedule(
|
||||||
|
mode="interval",
|
||||||
|
interval_days=2,
|
||||||
|
anchor_date=yesterday,
|
||||||
|
interval_has_deadline=True,
|
||||||
|
interval_hour=h,
|
||||||
|
interval_minute=m,
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_multiple_children_grouped(self, app):
|
||||||
|
_seed_child(CHILD_ID_2, task_ids=[TASK_ID_2])
|
||||||
|
_seed_task(TASK_ID_2, name="Take Out Trash")
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(child_id=CHILD_ID, task_id=TASK_ID, default_hour=h, default_minute=m)
|
||||||
|
_seed_schedule(child_id=CHILD_ID_2, task_id=TASK_ID_2, default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert len(result) == 2
|
||||||
|
child_ids = {r['child_id'] for r in result}
|
||||||
|
assert CHILD_ID in child_ids
|
||||||
|
assert CHILD_ID_2 in child_ids
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_push_payload tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBuildPushPayload:
|
||||||
|
def test_single_chore_sets_title_and_deep_link(self):
|
||||||
|
expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}]
|
||||||
|
payload = _build_push_payload(expiring)
|
||||||
|
assert payload['title'] == "Chore ending soon: Clean Room"
|
||||||
|
assert payload['child_id'] == "c1"
|
||||||
|
assert payload['entity_id'] == "t1"
|
||||||
|
assert payload['type'] == 'chore_expiring_soon'
|
||||||
|
|
||||||
|
def test_multiple_chores_uses_generic_title(self):
|
||||||
|
expiring = [
|
||||||
|
{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"},
|
||||||
|
{"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"},
|
||||||
|
]
|
||||||
|
payload = _build_push_payload(expiring)
|
||||||
|
assert payload['title'] == "Chores ending soon"
|
||||||
|
assert payload['child_id'] is None
|
||||||
|
assert payload['entity_id'] is None
|
||||||
|
|
||||||
|
def test_body_groups_by_child(self):
|
||||||
|
expiring = [
|
||||||
|
{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"},
|
||||||
|
{"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"},
|
||||||
|
{"child_id": "c2", "child_name": "Sam", "task_id": "t3", "task_name": "Trash"},
|
||||||
|
]
|
||||||
|
payload = _build_push_payload(expiring)
|
||||||
|
assert "Alex: Clean Room, Make Bed" in payload['body']
|
||||||
|
assert "Sam: Trash" in payload['body']
|
||||||
|
|
||||||
|
def test_no_approve_deny_tokens(self):
|
||||||
|
expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}]
|
||||||
|
payload = _build_push_payload(expiring)
|
||||||
|
assert 'approve_token' not in payload
|
||||||
|
assert 'deny_token' not in payload
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# send_chore_expiry_notifications_for_user tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSendChoreExpiryNotificationsForUser:
|
||||||
|
def setup_method(self):
|
||||||
|
_cleanup()
|
||||||
|
_seed_user()
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def test_sends_push_when_chore_expiring(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
count = send_chore_expiry_notifications_for_user(USER_ID, "UTC")
|
||||||
|
assert count == 1
|
||||||
|
mock_push.assert_called_once()
|
||||||
|
payload = mock_push.call_args[0][1]
|
||||||
|
assert payload['type'] == 'chore_expiring_soon'
|
||||||
|
|
||||||
|
def test_no_push_when_no_expiring_chores(self, app):
|
||||||
|
# No schedule seeded → nothing expiring
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
count = send_chore_expiry_notifications_for_user(USER_ID, "UTC")
|
||||||
|
assert count == 0
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run_chore_expiry_check tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRunChoreExpiryCheck:
|
||||||
|
def setup_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def test_skips_when_db_env_is_e2e(self, app):
|
||||||
|
with patch.dict(os.environ, {'DB_ENV': 'e2e'}):
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
run_chore_expiry_check(app)
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_user_with_push_disabled(self, app):
|
||||||
|
_seed_user(push_enabled=False)
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
run_chore_expiry_check(app)
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_unverified_user(self, app):
|
||||||
|
_seed_user(verified=False)
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
run_chore_expiry_check(app)
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
def test_sends_push_for_eligible_user(self, app):
|
||||||
|
_seed_user(push_enabled=True, verified=True)
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
run_chore_expiry_check(app)
|
||||||
|
mock_push.assert_called_once()
|
||||||
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))
|
||||||
@@ -20,14 +20,19 @@ self.addEventListener('push', function (event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const title = payload.title || 'Reward App'
|
const title = payload.title || 'Reward App'
|
||||||
|
// Expiry-warning notifications are informational only — no action buttons
|
||||||
|
const actions =
|
||||||
|
payload.type === 'chore_expiring_soon'
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{ action: 'approve', title: 'Approve' },
|
||||||
|
{ action: 'deny', title: 'Deny' },
|
||||||
|
]
|
||||||
const options = {
|
const options = {
|
||||||
body: payload.body || '',
|
body: payload.body || '',
|
||||||
icon: '/icons/icon-192x192.png',
|
icon: '/icons/icon-192x192.png',
|
||||||
data: payload,
|
data: payload,
|
||||||
actions: [
|
actions,
|
||||||
{ action: 'approve', title: 'Approve' },
|
|
||||||
{ action: 'deny', title: 'Deny' },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(title, options))
|
event.waitUntil(self.registration.showNotification(title, options))
|
||||||
|
|||||||
Reference in New Issue
Block a user