First commit
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
# 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})
|
||||
Reference in New Issue
Block a user