Files
chore/backend/utils/digest_scheduler.py
Ryan Kegel 2c7e9b8b5e
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
feat: add admin endpoint to send digest emails for users
- Implemented a new endpoint `/admin/test/send-digest` in `admin_api.py` to trigger digest emails for specific users.
- Added a script `send_digest.py` to facilitate sending digest emails via the admin API.
- Enhanced the digest action handling in `digest_action_api.py` to support token peeking without consuming it.
- Updated the `send_digests` function in `digest_scheduler.py` to utilize the new `send_digest_for_user` function for sending emails.
- Introduced a new utility function `peek_token` in `digest_token.py` to validate tokens without consuming them.
- Modified the `ParentView.vue` component to handle digest actions upon receiving a digest token in the URL.
- Updated `.gitignore` to exclude sensitive certificate files.
2026-04-21 13:08:03 -04:00

163 lines
5.2 KiB
Python

import logging
import os
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from tinydb import Query
logger = logging.getLogger(__name__)
def _get_local_hour(tz_str: str | None) -> int:
"""Return the current hour (0-23) in the given IANA timezone, falling back to UTC."""
try:
if tz_str:
from zoneinfo import ZoneInfo # Python 3.9+
local_now = datetime.now(ZoneInfo(tz_str))
return local_now.hour
except Exception:
pass
return datetime.now(timezone.utc).hour
def send_digest_for_user(user_id: str, user_email: str, frontend_url: str) -> int:
"""Gather pending items for a user and send them a digest email.
Returns the number of items included in the digest (0 if nothing was sent).
Must be called within a Flask app context with all DB imports already available.
"""
from db.db import pending_confirmations_db, child_db, task_db, reward_db
from utils.email_sender import send_digest_email
from utils.digest_token import create_action_token, create_unsubscribe_token
PendingQ = Query()
pending_items = pending_confirmations_db.search(
(PendingQ.user_id == user_id) & (PendingQ.status == 'pending')
)
if not pending_items:
return 0
ChildQ = Query()
TaskQ = Query()
RewardQ = Query()
items = []
for p in pending_items:
child_id = p.get('child_id')
entity_id = p.get('entity_id')
entity_type = p.get('entity_type')
child_result = child_db.get(ChildQ.id == child_id)
if not child_result:
logger.warning(f'Digest: child {child_id} not found for pending item {p.get("id")} (user {user_id})')
continue
child_name = child_result.get('name') or 'Unknown'
if entity_type == 'chore':
entity_result = task_db.get(
(TaskQ.id == entity_id) &
((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
else:
entity_result = reward_db.get(
(RewardQ.id == entity_id) &
((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
)
if not entity_result:
logger.warning(f'Digest: {entity_type} {entity_id} not found for pending item {p.get("id")} (user {user_id})')
continue
entity_name = entity_result.get('name') or 'Unknown'
approve_token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action='approve',
)
deny_token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action='deny',
)
view_url = (
f"{frontend_url}/parent/{child_id}"
f"?scrollTo={entity_id}&entityType={entity_type}"
)
approve_url = f"{frontend_url}/api/digest-action/{approve_token.id}"
deny_url = f"{frontend_url}/api/digest-action/{deny_token.id}"
items.append({
'child_name': child_name,
'entity_name': entity_name,
'entity_type': entity_type,
'view_url': view_url,
'approve_url': approve_url,
'deny_url': deny_url,
'child_id': child_id,
'entity_id': entity_id,
})
if not items:
return 0
unsubscribe_token = create_unsubscribe_token(user_id)
send_digest_email(user_email, items, unsubscribe_token)
return len(items)
def send_digests(app) -> None:
"""Hourly job: send the 9pm digest to every eligible user whose local time is 21."""
if os.environ.get('DB_ENV') == 'e2e':
return
with app.app_context():
try:
from db.db import users_db
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
UserQ = Query()
users = users_db.search(
(UserQ.verified == True) & (UserQ.email_digest_enabled == True)
)
for user_dict in users:
user_id = user_dict.get('id')
user_email = user_dict.get('email')
user_tz = user_dict.get('timezone')
local_hour = _get_local_hour(user_tz)
if local_hour != 21:
continue
try:
send_digest_for_user(user_id, user_email, frontend_url)
except Exception as e:
logger.error(f'Failed to send digest to {user_email}: {e}')
except Exception as e:
logger.error(f'Error in send_digests job: {e}')
def start_digest_scheduler(app) -> BackgroundScheduler:
"""Start the hourly digest scheduler. Returns the scheduler instance."""
scheduler = BackgroundScheduler()
scheduler.add_job(
send_digests,
'cron',
minute=0,
args=[app],
id='digest_scheduler',
replace_existing=True,
)
scheduler.start()
logger.info('Digest scheduler started (hourly)')
return scheduler