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.
288 lines
10 KiB
Python
288 lines
10 KiB
Python
import os
|
|
|
|
from flask import Blueprint, request, jsonify
|
|
from datetime import datetime, timedelta
|
|
from tinydb import Query
|
|
|
|
from db.db import users_db
|
|
from models.user import User
|
|
from api.utils import admin_required, get_validated_user_id
|
|
from config.deletion_config import (
|
|
ACCOUNT_DELETION_THRESHOLD_HOURS,
|
|
MIN_THRESHOLD_HOURS,
|
|
MAX_THRESHOLD_HOURS,
|
|
validate_threshold
|
|
)
|
|
from utils.account_deletion_scheduler import trigger_deletion_manually
|
|
|
|
admin_api = Blueprint('admin_api', __name__)
|
|
|
|
|
|
@admin_api.route('/admin/deletion-queue', methods=['GET'])
|
|
@admin_required
|
|
def get_deletion_queue():
|
|
"""
|
|
Get list of users pending deletion.
|
|
Returns users marked for deletion with their deletion due dates.
|
|
"""
|
|
try:
|
|
Query_ = Query()
|
|
marked_users = users_db.search(Query_.marked_for_deletion == True)
|
|
|
|
users_data = []
|
|
for user_dict in marked_users:
|
|
user = User.from_dict(user_dict)
|
|
|
|
# Calculate deletion_due_at
|
|
deletion_due_at = None
|
|
if user.marked_for_deletion_at:
|
|
try:
|
|
marked_at = datetime.fromisoformat(user.marked_for_deletion_at)
|
|
due_at = marked_at + timedelta(hours=ACCOUNT_DELETION_THRESHOLD_HOURS)
|
|
deletion_due_at = due_at.isoformat()
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
users_data.append({
|
|
'id': user.id,
|
|
'email': user.email,
|
|
'marked_for_deletion_at': user.marked_for_deletion_at,
|
|
'deletion_due_at': deletion_due_at,
|
|
'deletion_in_progress': user.deletion_in_progress,
|
|
'deletion_attempted_at': user.deletion_attempted_at
|
|
})
|
|
|
|
return jsonify({
|
|
'count': len(users_data),
|
|
'users': users_data
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
|
|
|
@admin_api.route('/admin/deletion-threshold', methods=['GET'])
|
|
@admin_required
|
|
def get_deletion_threshold():
|
|
"""
|
|
Get current deletion threshold configuration.
|
|
"""
|
|
return jsonify({
|
|
'threshold_hours': ACCOUNT_DELETION_THRESHOLD_HOURS,
|
|
'threshold_min': MIN_THRESHOLD_HOURS,
|
|
'threshold_max': MAX_THRESHOLD_HOURS
|
|
}), 200
|
|
|
|
@admin_api.route('/admin/deletion-threshold', methods=['PUT'])
|
|
@admin_required
|
|
def update_deletion_threshold():
|
|
"""
|
|
Update deletion threshold.
|
|
Note: This updates the runtime value but doesn't persist to environment variables.
|
|
For permanent changes, update the ACCOUNT_DELETION_THRESHOLD_HOURS env variable.
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data or 'threshold_hours' not in data:
|
|
return jsonify({
|
|
'error': 'threshold_hours is required',
|
|
'code': 'MISSING_THRESHOLD'
|
|
}), 400
|
|
|
|
new_threshold = data['threshold_hours']
|
|
|
|
# Validate type
|
|
if not isinstance(new_threshold, int):
|
|
return jsonify({
|
|
'error': 'threshold_hours must be an integer',
|
|
'code': 'INVALID_TYPE'
|
|
}), 400
|
|
|
|
# Validate range
|
|
if new_threshold < MIN_THRESHOLD_HOURS:
|
|
return jsonify({
|
|
'error': f'threshold_hours must be at least {MIN_THRESHOLD_HOURS}',
|
|
'code': 'THRESHOLD_TOO_LOW'
|
|
}), 400
|
|
|
|
if new_threshold > MAX_THRESHOLD_HOURS:
|
|
return jsonify({
|
|
'error': f'threshold_hours must be at most {MAX_THRESHOLD_HOURS}',
|
|
'code': 'THRESHOLD_TOO_HIGH'
|
|
}), 400
|
|
|
|
# Update the global config
|
|
import config.deletion_config as config
|
|
config.ACCOUNT_DELETION_THRESHOLD_HOURS = new_threshold
|
|
|
|
# Validate and log warning if needed
|
|
validate_threshold()
|
|
|
|
return jsonify({
|
|
'message': 'Deletion threshold updated successfully',
|
|
'threshold_hours': new_threshold
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
|
|
|
@admin_api.route('/admin/deletion-queue/trigger', methods=['POST'])
|
|
@admin_required
|
|
def trigger_deletion_queue():
|
|
"""
|
|
Manually trigger the deletion scheduler to process the queue immediately.
|
|
Returns stats about the run.
|
|
"""
|
|
try:
|
|
# Trigger the deletion process
|
|
result = trigger_deletion_manually()
|
|
|
|
# Get updated queue stats
|
|
Query_ = Query()
|
|
marked_users = users_db.search(Query_.marked_for_deletion == True)
|
|
|
|
# Count users that were just processed (this is simplified)
|
|
processed = result.get('queued_users', 0)
|
|
|
|
# In a real implementation, you'd return actual stats from the deletion run
|
|
# For now, we'll return simplified stats
|
|
return jsonify({
|
|
'message': 'Deletion scheduler triggered',
|
|
'processed': processed,
|
|
'deleted': 0, # TODO: Track this in the deletion function
|
|
'failed': 0 # TODO: Track this in the deletion function
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test-only endpoint — active ONLY when DB_ENV=e2e
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@admin_api.route('/admin/test/digest-token', methods=['POST'])
|
|
def create_test_digest_token():
|
|
"""Create a valid DigestActionToken for E2E tests.
|
|
|
|
Only active when DB_ENV=e2e. Requires authentication.
|
|
"""
|
|
if os.environ.get('DB_ENV') != 'e2e':
|
|
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
|
|
|
|
data = request.get_json() or {}
|
|
child_id = data.get('child_id')
|
|
entity_id = data.get('entity_id')
|
|
entity_type = data.get('entity_type')
|
|
action = data.get('action')
|
|
expires_in_hours = data.get('expires_in_hours', 24)
|
|
|
|
if not all([child_id, entity_id, entity_type, action]):
|
|
return jsonify({'error': 'child_id, entity_id, entity_type, and action are required',
|
|
'code': 'MISSING_FIELDS'}), 400
|
|
|
|
if entity_type not in ('chore', 'reward'):
|
|
return jsonify({'error': 'entity_type must be "chore" or "reward"',
|
|
'code': 'INVALID_ENTITY_TYPE'}), 400
|
|
|
|
if action not in ('approve', 'deny'):
|
|
return jsonify({'error': 'action must be "approve" or "deny"',
|
|
'code': 'INVALID_ACTION'}), 400
|
|
|
|
try:
|
|
from utils.digest_token import create_action_token
|
|
token = create_action_token(
|
|
user_id=user_id,
|
|
child_id=child_id,
|
|
entity_id=entity_id,
|
|
entity_type=entity_type,
|
|
action=action,
|
|
expiry_hours=int(expires_in_hours),
|
|
)
|
|
return jsonify({'token': token.id}), 200
|
|
except Exception as e:
|
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
|
|
|
|
|
@admin_api.route('/admin/test/send-digest', methods=['POST'])
|
|
def send_test_digest():
|
|
"""Trigger a digest email for a specific user by email address.
|
|
|
|
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
|
Note: actual email delivery is skipped in e2e mode by email_sender.
|
|
"""
|
|
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
|
|
|
|
# Verify caller is admin
|
|
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')
|
|
target_email = target.get('email')
|
|
|
|
try:
|
|
from flask import current_app
|
|
from utils.digest_scheduler import send_digest_for_user
|
|
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
|
items_sent = send_digest_for_user(target_id, target_email, frontend_url)
|
|
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
|