Feb 02 2026: implement issue following preferences

This commit is contained in:
2026-03-01 21:25:50 -05:00
parent 30b0b8a807
commit 91ef97d853
9 changed files with 901 additions and 184 deletions
+130 -8
View File
@@ -1,21 +1,24 @@
# app/routes/notifications.py
import logging
from flask import Blueprint, jsonify, request, abort
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
from app.models.notification import Notification
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.
Used by the navbar bell icon to populate the dropdown.
"""
"""Return the 20 most recent notifications for the current user as JSON."""
notifs = (
Notification.query
.filter_by(user_id=current_user.id)
@@ -41,10 +44,42 @@ def feed():
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):
"""Mark a single notification as read."""
notif = Notification.query.get_or_404(notif_id)
if notif.user_id != current_user.id:
abort(403)
@@ -57,10 +92,11 @@ def mark_read(notif_id):
return jsonify({'ok': True})
# ── Mark all read ─────────────────────────────────────────────────────────────
@bp.route('/mark-all-read', methods=['POST'])
@login_required
def mark_all_read():
"""Mark all unread notifications for the current user as read."""
updated = (
Notification.query
.filter_by(user_id=current_user.id, is_read=False)
@@ -71,4 +107,90 @@ def mark_all_read():
'NOTIFICATIONS ALL READ | user=%s | count=%s',
current_user.username, updated,
)
return jsonify({'ok': True, 'marked': 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'])
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})