Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
- 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.
163 lines
5.2 KiB
Python
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
|