From 91ef97d853e989e71e64b439c93badc145182b00 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Sun, 1 Mar 2026 21:25:50 -0500 Subject: [PATCH] Feb 02 2026: implement issue following preferences --- app/models/notification.py | 59 +++- app/routes/inspections.py | 3 + app/routes/issues.py | 75 ++--- app/routes/notifications.py | 138 +++++++- app/templates/base.html | 146 +++++---- app/templates/notifications/index.html | 127 ++++++++ app/templates/notifications/preferences.html | 201 ++++++++++++ app/utils/notifications.py | 318 ++++++++++++++++--- config.py | 18 +- 9 files changed, 901 insertions(+), 184 deletions(-) create mode 100644 app/templates/notifications/index.html create mode 100644 app/templates/notifications/preferences.html diff --git a/app/models/notification.py b/app/models/notification.py index 1e156a6..9355da1 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -2,6 +2,25 @@ from app import db from app.utils.time_utils import now_eastern +# ── Event type constants ─────────────────────────────────────────────────────── +# These are the canonical keys used across the preference system. +# Every call to notify() should pass one of these as event_type. + +EVENT_ISSUE_ASSIGNED = 'issue_assigned' +EVENT_ISSUE_STATUS = 'issue_status' +EVENT_ISSUE_COMMENT = 'issue_comment' +EVENT_ISSUE_FOLLOW = 'issue_follow_update' +EVENT_INSPECTION_DONE = 'inspection_completed' + +ALL_EVENT_TYPES = { + EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', + EVENT_ISSUE_STATUS: 'Issue status changed', + EVENT_ISSUE_COMMENT: 'New comment on issue', + EVENT_ISSUE_FOLLOW: 'Updates on followed issues', + EVENT_INSPECTION_DONE: 'Inspection completed', +} + + class Notification(db.Model): """Stores in-app notifications for users. @@ -14,15 +33,47 @@ class Notification(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) title = db.Column(db.String(255), nullable=False) body = db.Column(db.Text, nullable=False) - link = db.Column(db.String(512)) # URL the bell-click should navigate to + link = db.Column(db.String(512)) is_read = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) # Optional FK references — only one will be populated at a time - issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True) - inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True) + issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True) + inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True) + + # Digest tracking: set to True when created, cleared after digest email sent + digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True) recipient = db.relationship('User', foreign_keys=[user_id], backref='notifications') def __repr__(self): - return f'' \ No newline at end of file + return f'' + + +class NotificationPreference(db.Model): + """Per-user, per-event notification preferences. + + One row per (user_id, event_type) combination. + If no row exists for a user+event, defaults apply (email on, no digest). + """ + __tablename__ = 'notification_preferences' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, index=True) + event_type = db.Column(db.String(50), nullable=False) + email_enabled = db.Column(db.Boolean, default=True, nullable=False) + digest_mode = db.Column(db.Boolean, default=False, nullable=False) + # digest_frequency: 'hourly' or 'daily' — only relevant when digest_mode is True + digest_frequency = db.Column(db.String(10), default='daily', nullable=False) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'event_type', name='uq_notif_pref_user_event'), + ) + + user = db.relationship('User', foreign_keys=[user_id], + backref=db.backref('notification_preferences', lazy='dynamic')) + + def __repr__(self): + return (f'') \ No newline at end of file diff --git a/app/routes/inspections.py b/app/routes/inspections.py index f00279e..fbc7c1d 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -16,6 +16,7 @@ from app.utils.forms import StartInspectionForm, IssueForm from app.utils.decorators import supervisor_required from app.utils.pdf_export import generate_inspection_pdf from app.utils.notifications import notify +from app.models.notification import EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED bp = Blueprint('inspections', __name__, url_prefix='/inspections') @@ -328,6 +329,7 @@ def execute(inspection_id): ), link = inspection_link, inspection_id = inspection.id, + event_type = EVENT_INSPECTION_DONE, send_email = True, ) db.session.commit() # Commit notifications @@ -456,6 +458,7 @@ def flag_issue(inspection_id): ), link = url_for('issues.view', issue_id=issue.id), issue_id = issue.id, + event_type = EVENT_ISSUE_ASSIGNED, send_email = True, ) db.session.commit() # Commit notification diff --git a/app/routes/issues.py b/app/routes/issues.py index f99666f..0814243 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -6,6 +6,10 @@ from app import db from app.models.issue import Issue, IssueComment, IssueFollower from app.models.facility import Facility, Area from app.models.user import User +from app.models.notification import ( + EVENT_ISSUE_ASSIGNED, EVENT_ISSUE_STATUS, + EVENT_ISSUE_COMMENT, EVENT_ISSUE_FOLLOW, +) from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.decorators import supervisor_required from app.utils.notifications import notify @@ -16,16 +20,8 @@ bp = Blueprint('issues', __name__, url_prefix='/issues') # ── Shared helper ───────────────────────────────────────────────────────────── def _notify_followers(issue, title, body, exclude_user_ids=None): - """Dispatch a notification to every follower of the given issue. - - Parameters - ---------- - issue : Issue ORM instance - title : Notification headline - body : Notification body - exclude_user_ids : Set/list of user IDs to skip (e.g. the actor themselves) - """ - exclude = set(exclude_user_ids or []) + """Dispatch a notification to every follower of the given issue.""" + exclude = set(exclude_user_ids or []) issue_link = url_for('issues.view', issue_id=issue.id) for follower in issue.followers.all(): if follower.user_id in exclude: @@ -36,6 +32,7 @@ def _notify_followers(issue, title, body, exclude_user_ids=None): body = body, link = issue_link, issue_id = issue.id, + event_type = EVENT_ISSUE_FOLLOW, send_email = True, ) @@ -46,10 +43,8 @@ def _notify_followers(issue, title, body, exclude_user_ids=None): @login_required def index(): page = request.args.get('page', 1, type=int) + q = Issue.query.order_by(Issue.reported_at.desc()) - q = Issue.query.order_by(Issue.reported_at.desc()) - - # Inspectors only see issues assigned to them if current_user.role == 'inspector': q = q.filter(Issue.assigned_to == current_user.id) @@ -61,7 +56,6 @@ def index(): q = q.filter(Issue.status == status_filter) issues = q.paginate(page=page, per_page=25, error_out=False) - return render_template('issues/list.html', issues=issues, severity_filter=severity_filter, @@ -75,13 +69,11 @@ def index(): def view(issue_id): issue = Issue.query.get_or_404(issue_id) - # Access control: inspectors may only view/edit issues assigned to them if current_user.role == 'inspector' and issue.assigned_to != current_user.id: flash('Access denied. You can only view issues assigned to you.', 'danger') return redirect(url_for('issues.index')) form = IssueUpdateForm(obj=issue) - staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all() form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] form.status.data = form.status.data or issue.status @@ -92,7 +84,6 @@ def view(issue_id): issue.status = form.status.data - # Only admin/supervisor can reassign; inspectors can only update status if current_user.role in ['admin', 'supervisor']: issue.assigned_to = form.assigned_to.data or None @@ -101,10 +92,8 @@ def view(issue_id): elif form.status.data != 'resolved': issue.resolved_at = None - # Save result notes (overwrite with latest value) issue.result_notes = form.result_notes.data or None - # Append any newly uploaded result photos from app.routes.inspections import _save_photo new_photos = [] for file_obj in request.files.getlist('result_photos'): @@ -115,7 +104,6 @@ def view(issue_id): existing = issue.result_photos or [] issue.result_photos = existing + new_photos - # Persist a comment entry if the user wrote update notes comment_body = form.update_notes.data.strip() if form.update_notes.data else '' if comment_body: comment = IssueComment( @@ -133,12 +121,11 @@ def view(issue_id): ) # ── Notifications ──────────────────────────────────────────────── - issue_link = url_for('issues.view', issue_id=issue.id) - new_assigned_to = issue.assigned_to - # Always exclude the actor from receiving their own notifications - actor_id = current_user.id + issue_link = url_for('issues.view', issue_id=issue.id) + new_assigned_to = issue.assigned_to + actor_id = current_user.id - # 1. Notify the assignee when status changes + # 1. Status changed — notify assignee if old_status != issue.status and new_assigned_to: assignee = User.query.get(new_assigned_to) if assignee and assignee.id != actor_id: @@ -153,10 +140,11 @@ def view(issue_id): ), link = issue_link, issue_id = issue.id, + event_type = EVENT_ISSUE_STATUS, send_email = True, ) - # 2. Notify newly assigned user when the assignee changes + # 2. Reassigned — notify new assignee if (old_assigned_to != new_assigned_to) and new_assigned_to: new_assignee = User.query.get(new_assigned_to) if new_assignee and new_assignee.id != actor_id: @@ -170,10 +158,11 @@ def view(issue_id): ), link = issue_link, issue_id = issue.id, + event_type = EVENT_ISSUE_ASSIGNED, send_email = True, ) - # 3. Notify the previously assigned user when unassigned + # 3. Unassigned — notify previous assignee if old_assigned_to and old_assigned_to != new_assigned_to: old_assignee = User.query.get(old_assigned_to) if old_assignee and old_assignee.id != actor_id: @@ -186,10 +175,11 @@ def view(issue_id): ), link = issue_link, issue_id = issue.id, + event_type = EVENT_ISSUE_ASSIGNED, send_email = True, ) - # 4. Notify the assignee when a comment is added (if not the commenter) + # 4. Comment added — notify assignee if comment_body and new_assigned_to: commentee = User.query.get(new_assigned_to) if commentee and commentee.id != actor_id: @@ -202,11 +192,11 @@ def view(issue_id): ), link = issue_link, issue_id = issue.id, + event_type = EVENT_ISSUE_COMMENT, send_email = True, ) - # 5. Notify all followers of any update (status change, comment, or reassignment) - # Exclude the actor and the assignee (already notified above). + # 5. Notify followers — consolidated message, exclude actor + assignees exclude_ids = {actor_id} if new_assigned_to: exclude_ids.add(new_assigned_to) @@ -226,14 +216,13 @@ def view(issue_id): changes.append(f'new comment added by {current_user.username}') if changes: - follower_body = ( - f'Issue #{issue.id} in {issue.area.name} was updated by ' - f'{current_user.username}: {"; ".join(changes)}.' - ) _notify_followers( - issue = issue, - title = f'Issue #{issue.id} Updated', - body = follower_body, + issue = issue, + title = f'Issue #{issue.id} Updated', + body = ( + f'Issue #{issue.id} in {issue.area.name} was updated by ' + f'{current_user.username}: {"; ".join(changes)}.' + ), exclude_user_ids = exclude_ids, ) @@ -242,7 +231,7 @@ def view(issue_id): return redirect(url_for('issues.view', issue_id=issue_id)) is_following = issue.is_followed_by(current_user) - comments = issue.comments.order_by(IssueComment.created_at.asc()).all() + comments = issue.comments.order_by(IssueComment.created_at.asc()).all() return render_template('issues/view.html', issue=issue, form=form, @@ -256,7 +245,6 @@ def view(issue_id): @login_required def follow(issue_id): issue = Issue.query.get_or_404(issue_id) - if not issue.is_followed_by(current_user): follower = IssueFollower(issue_id=issue.id, user_id=current_user.id) db.session.add(follower) @@ -268,7 +256,6 @@ def follow(issue_id): flash('You are now following this issue and will receive notifications for any updates.', 'success') else: flash('You are already following this issue.', 'info') - return redirect(url_for('issues.view', issue_id=issue_id)) @@ -277,8 +264,7 @@ def follow(issue_id): @bp.route('//unfollow', methods=['POST']) @login_required def unfollow(issue_id): - issue = Issue.query.get_or_404(issue_id) - + issue = Issue.query.get_or_404(issue_id) follower = issue.followers.filter_by(user_id=current_user.id).first() if follower: db.session.delete(follower) @@ -290,11 +276,10 @@ def unfollow(issue_id): flash('You have unfollowed this issue.', 'info') else: flash('You are not following this issue.', 'info') - return redirect(url_for('issues.view', issue_id=issue_id)) -# ── Standalone create (not from an inspection) ──────────────────────────────── +# ── Standalone create ───────────────────────────────────────────────────────── @bp.route('/new', methods=['GET', 'POST']) @login_required @@ -326,7 +311,6 @@ def create(): issue.id, issue.severity, issue.area_id, issue.assigned_to, current_user.username ) - # Notify the assignee of the new issue if issue.assigned_to: assignee = User.query.get(issue.assigned_to) if assignee and assignee.id != current_user.id: @@ -341,6 +325,7 @@ def create(): ), link = url_for('issues.view', issue_id=issue.id), issue_id = issue.id, + event_type = EVENT_ISSUE_ASSIGNED, send_email = True, ) db.session.commit() diff --git a/app/routes/notifications.py b/app/routes/notifications.py index e98fd9b..ef5c314 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -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('//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}) \ No newline at end of file + + # 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}) \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 9ab15bf..c8c6fb7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,58 +14,35 @@ {% block extra_css %}{% endblock %} @@ -124,30 +101,56 @@ {% endif %} -