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