Files
JQC_multi_tenant/app/routes/notifications.py
T
2026-06-26 09:04:34 -04:00

308 lines
11 KiB
Python

# 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('/<int:notif_id>/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=<float> 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})