# app/routes/notifications.py import logging from flask import (Blueprint, jsonify, request, abort, render_template, redirect, url_for, flash, current_app) from flask_login import login_required, current_user from app import db, csrf from app.models.notification import ( Notification, NotificationPreference, ALL_EVENT_TYPES ) logger = logging.getLogger(__name__) bp = Blueprint('notifications', __name__, url_prefix='/notifications') # ── Bell feed (navbar dropdown) ─────────────────────────────────────────────── @bp.route('/feed') @login_required def feed(): """Return the 20 most recent notifications for the current user as JSON.""" notifs = ( Notification.query .filter_by(user_id=current_user.id) .order_by(Notification.created_at.desc()) .limit(20) .all() ) unread_count = Notification.query.filter_by( user_id=current_user.id, is_read=False ).count() items = [] for n in notifs: items.append({ 'id': n.id, 'title': n.title, 'body': n.body, 'link': n.link, 'is_read': n.is_read, 'created_at': n.created_at.strftime('%b %d, %Y %I:%M %p'), }) return jsonify({'notifications': items, 'unread_count': unread_count}) # ── Full notification history page ──────────────────────────────────────────── @bp.route('/') @login_required def index(): """Full paginated notification history with read/unread filter.""" page = request.args.get('page', 1, type=int) filter_read = request.args.get('filter', 'all') # 'all' | 'unread' | 'read' q = Notification.query.filter_by(user_id=current_user.id) if filter_read == 'unread': q = q.filter_by(is_read=False) elif filter_read == 'read': q = q.filter_by(is_read=True) notifications = q.order_by(Notification.created_at.desc()).paginate( page=page, per_page=25, error_out=False ) unread_count = Notification.query.filter_by( user_id=current_user.id, is_read=False ).count() return render_template( 'notifications/index.html', notifications=notifications, filter_read=filter_read, unread_count=unread_count, ) # ── Mark single notification read ───────────────────────────────────────────── @bp.route('//mark-read', methods=['POST']) @login_required def mark_read(notif_id): notif = db.session.get(Notification, notif_id) if notif is None: abort(404) if notif.user_id != current_user.id: abort(403) notif.is_read = True db.session.commit() logger.info( 'NOTIFICATION READ | id=%s | user=%s', notif_id, current_user.username, ) return jsonify({'ok': True}) # ── Mark all read ───────────────────────────────────────────────────────────── @bp.route('/mark-all-read', methods=['POST']) @login_required def mark_all_read(): updated = ( Notification.query .filter_by(user_id=current_user.id, is_read=False) .update({'is_read': True}) ) db.session.commit() logger.info( 'NOTIFICATIONS ALL READ | user=%s | count=%s', current_user.username, updated, ) # Support both AJAX (returns JSON) and form POST (redirects to index) if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or \ request.content_type == 'application/json': return jsonify({'ok': True, 'marked': updated}) return redirect(url_for('notifications.index')) # ── Notification preferences ────────────────────────────────────────────────── @bp.route('/preferences', methods=['GET', 'POST']) @login_required def preferences(): """Display and save per-event notification preferences.""" if request.method == 'POST': for event_type in ALL_EVENT_TYPES: pref = NotificationPreference.query.filter_by( user_id=current_user.id, event_type=event_type, ).first() if pref is None: pref = NotificationPreference( user_id=current_user.id, event_type=event_type, ) db.session.add(pref) pref.email_enabled = bool(request.form.get(f'email_{event_type}')) pref.digest_mode = bool(request.form.get(f'digest_{event_type}')) pref.digest_frequency = request.form.get(f'freq_{event_type}', 'daily') # Guard: digest_mode only meaningful when email is enabled if not pref.email_enabled: pref.digest_mode = False db.session.commit() logger.info( 'NOTIFICATION PREFERENCES SAVED | user=%s', current_user.username, ) flash('Notification preferences saved.', 'success') return redirect(url_for('notifications.preferences')) # Build a dict keyed by event_type for easy template access prefs_map = {} for pref in NotificationPreference.query.filter_by(user_id=current_user.id).all(): prefs_map[pref.event_type] = pref return render_template( 'notifications/preferences.html', event_types=ALL_EVENT_TYPES, prefs_map=prefs_map, ) # ── Digest trigger (called by cron) ─────────────────────────────────────────── @bp.route('/send-digest', methods=['POST']) @csrf.exempt def send_digest(): """Trigger digest email delivery. Protected by a shared secret token. Called by a cron job, e.g.: # Hourly digest 0 * * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \ -d "token=YOUR_DIGEST_SECRET&frequency=hourly" # Daily digest at 07:00 0 7 * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \ -d "token=YOUR_DIGEST_SECRET&frequency=daily" """ token = request.form.get('token') or request.args.get('token') frequency = request.form.get('frequency', 'daily') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('DIGEST TRIGGER REJECTED | bad or missing token') abort(403) if frequency not in ('hourly', 'daily'): abort(400) from app.utils.notifications import send_pending_digests sent = send_pending_digests(frequency=frequency) logger.info('DIGEST TRIGGERED | frequency=%s | sent=%s', frequency, sent) return jsonify({'ok': True, 'sent': sent, 'frequency': frequency}) # ── SLA alert trigger (called by cron) ──────────────────────────────────────── @bp.route('/check-sla', methods=['POST']) @csrf.exempt def check_sla(): """Scan all open issues for SLA breaches and dispatch alerts. Protected by the same DIGEST_SECRET token used for digest delivery. Recommended cron schedule — every 30 minutes is sufficient for most deployments; adjust based on your shortest SLA threshold (critical = 4h): */30 * * * * curl -s -X POST https://yourdomain.com/notifications/check-sla \\ -d "token=YOUR_DIGEST_SECRET" """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('SLA CHECK REJECTED | bad or missing token') abort(403) from app.utils.sla import send_sla_alerts sent = send_sla_alerts() logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent) return jsonify({'ok': True, 'notifications_sent': sent}) # ── Expired token cleanup (called by cron) ──────────────────────────────────── @bp.route('/cleanup-tokens', methods=['POST']) @csrf.exempt def cleanup_tokens(): """Purge expired and revoked refresh tokens from api_refresh_tokens. Safe to run frequently — only deletes rows where expires_at has passed OR revoked=True. Keeps the table lean without touching live sessions. Recommended cron schedule — nightly is sufficient: 0 3 * * * curl -s -X POST https://yourdomain.com/notifications/cleanup-tokens \\ -d "token=YOUR_DIGEST_SECRET" """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('TOKEN CLEANUP REJECTED | bad or missing token') abort(403) from app.models.api_token import RefreshToken from app.utils.time_utils import now_eastern now = now_eastern() deleted = ( RefreshToken.query .filter( db.or_( RefreshToken.expires_at < now, RefreshToken.revoked == True, # noqa: E712 ) ) .delete(synchronize_session=False) ) db.session.commit() logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted) return jsonify({'ok': True, 'deleted': deleted}) # ── Score trend alert trigger (called by cron) ──────────────────────────────── @bp.route('/check-score-trends', methods=['POST']) @csrf.exempt def check_score_trends(): """Scan facility score trends and dispatch alerts for significant drops. Compares each active facility's avg inspection score for the last 30 days against the prior 30-day period. Alerts fire when the drop exceeds the configured threshold (default: 5 percentage points). Protected by the same DIGEST_SECRET token used by the other cron endpoints. Recommended cron schedule — once per day is sufficient: 0 8 * * * curl -s -X POST https://yourdomain.com/notifications/check-score-trends \\ -d "token=YOUR_DIGEST_SECRET" Optional param: threshold= Override the default 5.0-point drop threshold. """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('SCORE TREND CHECK REJECTED | bad or missing token') abort(403) threshold = request.form.get('threshold', type=float) or None from app.utils.sla import send_score_alerts kwargs = {} if threshold is not None: kwargs['threshold'] = threshold sent = send_score_alerts(**kwargs) logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent) return jsonify({'ok': True, 'alerts_sent': sent}) # ── Photo retention purge (called by cron) ──────────────────────────────────── @bp.route('/purge-old-photos', methods=['POST']) @csrf.exempt def purge_old_photos(): """Delete photo FILES (not the issue records) for issues resolved longer ago than PHOTO_RETENTION_DAYS, addressing GDPR Art. 5(1)(e) storage limitation — evidence photos otherwise persist forever. Disabled by default (no-op) unless PHOTO_RETENTION_DAYS is set in config/ env — this is a data-minimization policy the operator opts into, not a forced deletion, since some deployments may have a longer required retention for their own contractual/audit reasons. Only touches RESOLVED issues whose resolved_at predates the cutoff. Clears photo_path / mobile_photo_paths / result_photos to null/empty and deletes the underlying files via the storage abstraction (safe on both the local and R2 backends). The issue record itself, its description, and its audit trail are untouched — only the photo bytes are removed. Recommended cron schedule — nightly is sufficient: 0 4 * * * curl -s -X POST https://yourdomain.com/notifications/purge-old-photos \\ -d "token=YOUR_DIGEST_SECRET" """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('PHOTO PURGE REJECTED | bad or missing token') abort(403) retention_days = current_app.config.get('PHOTO_RETENTION_DAYS') if not retention_days: return jsonify({'ok': True, 'skipped': 'PHOTO_RETENTION_DAYS not configured', 'issues_purged': 0}) from datetime import timedelta from app.models.issue import Issue from app.utils.time_utils import now_eastern from app.utils.audit import log_action, ACTION_UPDATE from app.utils import storage cutoff = now_eastern() - timedelta(days=int(retention_days)) candidates = ( Issue.query .filter(Issue.status == 'resolved') .filter(Issue.resolved_at.isnot(None)) .filter(Issue.resolved_at < cutoff) .filter( db.or_( Issue.photo_path.isnot(None), Issue.mobile_photo_paths.isnot(None), Issue.result_photos.isnot(None), ) ) .all() ) purged_count = 0 for issue in candidates: keys = [] if issue.photo_path: keys.append(issue.photo_path) keys.extend(issue.mobile_photo_paths or []) keys.extend(issue.result_photos or []) for key in keys: try: storage.delete(key) except Exception as exc: logger.warning('PHOTO PURGE | failed to delete key=%s issue_id=%s: %s', key, issue.id, exc) issue.photo_path = None issue.mobile_photo_paths = None issue.result_photos = None purged_count += 1 db.session.commit() if purged_count: log_action( ACTION_UPDATE, 'Issue', None, f'Photo retention purge — {purged_count} resolved issue(s)', f'cutoff={cutoff.strftime("%Y-%m-%d %H:%M:%S")}; retention_days={retention_days}', ) logger.info('PHOTO PURGE TRIGGERED | issues_purged=%s | retention_days=%s', purged_count, retention_days) return jsonify({'ok': True, 'issues_purged': purged_count, 'retention_days': retention_days})