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
|
||||
except Exception as e:
|
||||
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 api.utils import get_current_user_id
|
||||
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.state_expiry_scheduler import start_state_expiry_scheduler
|
||||
|
||||
@@ -142,6 +143,7 @@ start_background_threads()
|
||||
start_deletion_scheduler()
|
||||
start_digest_scheduler(app)
|
||||
start_state_expiry_scheduler(app)
|
||||
start_chore_expiry_notification_scheduler(app)
|
||||
|
||||
if __name__ == '__main__':
|
||||
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'
|
||||
// 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 = {
|
||||
body: payload.body || '',
|
||||
icon: '/icons/icon-192x192.png',
|
||||
data: payload,
|
||||
actions: [
|
||||
{ action: 'approve', title: 'Approve' },
|
||||
{ action: 'deny', title: 'Deny' },
|
||||
],
|
||||
actions,
|
||||
}
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options))
|
||||
|
||||
Reference in New Issue
Block a user