Feb 02 2026: implement issue following preferences
This commit is contained in:
@@ -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
|
||||
|
||||
+30
-45
@@ -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('/<int:issue_id>/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()
|
||||
|
||||
+130
-8
@@ -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})
|
||||
Reference in New Issue
Block a user