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
+55 -4
View File
@@ -2,6 +2,25 @@ from app import db
from app.utils.time_utils import now_eastern 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): class Notification(db.Model):
"""Stores in-app notifications for users. """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) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
title = db.Column(db.String(255), nullable=False) title = db.Column(db.String(255), nullable=False)
body = db.Column(db.Text, 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) is_read = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
# Optional FK references — only one will be populated at a time # 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) 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) 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') recipient = db.relationship('User', foreign_keys=[user_id], backref='notifications')
def __repr__(self): def __repr__(self):
return f'<Notification {self.id} user={self.user_id} read={self.is_read}>' return f'<Notification {self.id} user={self.user_id} read={self.is_read}>'
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'<NotificationPreference user={self.user_id} '
f'event={self.event_type} email={self.email_enabled} digest={self.digest_mode}>')
+3
View File
@@ -16,6 +16,7 @@ from app.utils.forms import StartInspectionForm, IssueForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.pdf_export import generate_inspection_pdf from app.utils.pdf_export import generate_inspection_pdf
from app.utils.notifications import notify from app.utils.notifications import notify
from app.models.notification import EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED
bp = Blueprint('inspections', __name__, url_prefix='/inspections') bp = Blueprint('inspections', __name__, url_prefix='/inspections')
@@ -328,6 +329,7 @@ def execute(inspection_id):
), ),
link = inspection_link, link = inspection_link,
inspection_id = inspection.id, inspection_id = inspection.id,
event_type = EVENT_INSPECTION_DONE,
send_email = True, send_email = True,
) )
db.session.commit() # Commit notifications db.session.commit() # Commit notifications
@@ -456,6 +458,7 @@ def flag_issue(inspection_id):
), ),
link = url_for('issues.view', issue_id=issue.id), link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id, issue_id = issue.id,
event_type = EVENT_ISSUE_ASSIGNED,
send_email = True, send_email = True,
) )
db.session.commit() # Commit notification db.session.commit() # Commit notification
+30 -45
View File
@@ -6,6 +6,10 @@ from app import db
from app.models.issue import Issue, IssueComment, IssueFollower from app.models.issue import Issue, IssueComment, IssueFollower
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.user import User 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.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.notifications import notify from app.utils.notifications import notify
@@ -16,16 +20,8 @@ bp = Blueprint('issues', __name__, url_prefix='/issues')
# ── Shared helper ───────────────────────────────────────────────────────────── # ── Shared helper ─────────────────────────────────────────────────────────────
def _notify_followers(issue, title, body, exclude_user_ids=None): def _notify_followers(issue, title, body, exclude_user_ids=None):
"""Dispatch a notification to every follower of the given issue. """Dispatch a notification to every follower of the given issue."""
exclude = set(exclude_user_ids or [])
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 [])
issue_link = url_for('issues.view', issue_id=issue.id) issue_link = url_for('issues.view', issue_id=issue.id)
for follower in issue.followers.all(): for follower in issue.followers.all():
if follower.user_id in exclude: if follower.user_id in exclude:
@@ -36,6 +32,7 @@ def _notify_followers(issue, title, body, exclude_user_ids=None):
body = body, body = body,
link = issue_link, link = issue_link,
issue_id = issue.id, issue_id = issue.id,
event_type = EVENT_ISSUE_FOLLOW,
send_email = True, send_email = True,
) )
@@ -46,10 +43,8 @@ def _notify_followers(issue, title, body, exclude_user_ids=None):
@login_required @login_required
def index(): def index():
page = request.args.get('page', 1, type=int) 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': if current_user.role == 'inspector':
q = q.filter(Issue.assigned_to == current_user.id) q = q.filter(Issue.assigned_to == current_user.id)
@@ -61,7 +56,6 @@ def index():
q = q.filter(Issue.status == status_filter) q = q.filter(Issue.status == status_filter)
issues = q.paginate(page=page, per_page=25, error_out=False) issues = q.paginate(page=page, per_page=25, error_out=False)
return render_template('issues/list.html', return render_template('issues/list.html',
issues=issues, issues=issues,
severity_filter=severity_filter, severity_filter=severity_filter,
@@ -75,13 +69,11 @@ def index():
def view(issue_id): def view(issue_id):
issue = Issue.query.get_or_404(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: if current_user.role == 'inspector' and issue.assigned_to != current_user.id:
flash('Access denied. You can only view issues assigned to you.', 'danger') flash('Access denied. You can only view issues assigned to you.', 'danger')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
form = IssueUpdateForm(obj=issue) form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all() 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.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
form.status.data = form.status.data or issue.status form.status.data = form.status.data or issue.status
@@ -92,7 +84,6 @@ def view(issue_id):
issue.status = form.status.data issue.status = form.status.data
# Only admin/supervisor can reassign; inspectors can only update status
if current_user.role in ['admin', 'supervisor']: if current_user.role in ['admin', 'supervisor']:
issue.assigned_to = form.assigned_to.data or None issue.assigned_to = form.assigned_to.data or None
@@ -101,10 +92,8 @@ def view(issue_id):
elif form.status.data != 'resolved': elif form.status.data != 'resolved':
issue.resolved_at = None issue.resolved_at = None
# Save result notes (overwrite with latest value)
issue.result_notes = form.result_notes.data or None issue.result_notes = form.result_notes.data or None
# Append any newly uploaded result photos
from app.routes.inspections import _save_photo from app.routes.inspections import _save_photo
new_photos = [] new_photos = []
for file_obj in request.files.getlist('result_photos'): for file_obj in request.files.getlist('result_photos'):
@@ -115,7 +104,6 @@ def view(issue_id):
existing = issue.result_photos or [] existing = issue.result_photos or []
issue.result_photos = existing + new_photos 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 '' comment_body = form.update_notes.data.strip() if form.update_notes.data else ''
if comment_body: if comment_body:
comment = IssueComment( comment = IssueComment(
@@ -133,12 +121,11 @@ def view(issue_id):
) )
# ── Notifications ──────────────────────────────────────────────── # ── Notifications ────────────────────────────────────────────────
issue_link = url_for('issues.view', issue_id=issue.id) issue_link = url_for('issues.view', issue_id=issue.id)
new_assigned_to = issue.assigned_to new_assigned_to = issue.assigned_to
# Always exclude the actor from receiving their own notifications actor_id = current_user.id
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: if old_status != issue.status and new_assigned_to:
assignee = User.query.get(new_assigned_to) assignee = User.query.get(new_assigned_to)
if assignee and assignee.id != actor_id: if assignee and assignee.id != actor_id:
@@ -153,10 +140,11 @@ def view(issue_id):
), ),
link = issue_link, link = issue_link,
issue_id = issue.id, issue_id = issue.id,
event_type = EVENT_ISSUE_STATUS,
send_email = True, 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: if (old_assigned_to != new_assigned_to) and new_assigned_to:
new_assignee = User.query.get(new_assigned_to) new_assignee = User.query.get(new_assigned_to)
if new_assignee and new_assignee.id != actor_id: if new_assignee and new_assignee.id != actor_id:
@@ -170,10 +158,11 @@ def view(issue_id):
), ),
link = issue_link, link = issue_link,
issue_id = issue.id, issue_id = issue.id,
event_type = EVENT_ISSUE_ASSIGNED,
send_email = True, 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: if old_assigned_to and old_assigned_to != new_assigned_to:
old_assignee = User.query.get(old_assigned_to) old_assignee = User.query.get(old_assigned_to)
if old_assignee and old_assignee.id != actor_id: if old_assignee and old_assignee.id != actor_id:
@@ -186,10 +175,11 @@ def view(issue_id):
), ),
link = issue_link, link = issue_link,
issue_id = issue.id, issue_id = issue.id,
event_type = EVENT_ISSUE_ASSIGNED,
send_email = True, 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: if comment_body and new_assigned_to:
commentee = User.query.get(new_assigned_to) commentee = User.query.get(new_assigned_to)
if commentee and commentee.id != actor_id: if commentee and commentee.id != actor_id:
@@ -202,11 +192,11 @@ def view(issue_id):
), ),
link = issue_link, link = issue_link,
issue_id = issue.id, issue_id = issue.id,
event_type = EVENT_ISSUE_COMMENT,
send_email = True, send_email = True,
) )
# 5. Notify all followers of any update (status change, comment, or reassignment) # 5. Notify followers — consolidated message, exclude actor + assignees
# Exclude the actor and the assignee (already notified above).
exclude_ids = {actor_id} exclude_ids = {actor_id}
if new_assigned_to: if new_assigned_to:
exclude_ids.add(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}') changes.append(f'new comment added by {current_user.username}')
if changes: if changes:
follower_body = (
f'Issue #{issue.id} in {issue.area.name} was updated by '
f'{current_user.username}: {"; ".join(changes)}.'
)
_notify_followers( _notify_followers(
issue = issue, issue = issue,
title = f'Issue #{issue.id} Updated', title = f'Issue #{issue.id} Updated',
body = follower_body, body = (
f'Issue #{issue.id} in {issue.area.name} was updated by '
f'{current_user.username}: {"; ".join(changes)}.'
),
exclude_user_ids = exclude_ids, exclude_user_ids = exclude_ids,
) )
@@ -242,7 +231,7 @@ def view(issue_id):
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(url_for('issues.view', issue_id=issue_id))
is_following = issue.is_followed_by(current_user) 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', return render_template('issues/view.html',
issue=issue, issue=issue,
form=form, form=form,
@@ -256,7 +245,6 @@ def view(issue_id):
@login_required @login_required
def follow(issue_id): def follow(issue_id):
issue = Issue.query.get_or_404(issue_id) issue = Issue.query.get_or_404(issue_id)
if not issue.is_followed_by(current_user): if not issue.is_followed_by(current_user):
follower = IssueFollower(issue_id=issue.id, user_id=current_user.id) follower = IssueFollower(issue_id=issue.id, user_id=current_user.id)
db.session.add(follower) 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') flash('You are now following this issue and will receive notifications for any updates.', 'success')
else: else:
flash('You are already following this issue.', 'info') flash('You are already following this issue.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id)) 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']) @bp.route('/<int:issue_id>/unfollow', methods=['POST'])
@login_required @login_required
def unfollow(issue_id): 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() follower = issue.followers.filter_by(user_id=current_user.id).first()
if follower: if follower:
db.session.delete(follower) db.session.delete(follower)
@@ -290,11 +276,10 @@ def unfollow(issue_id):
flash('You have unfollowed this issue.', 'info') flash('You have unfollowed this issue.', 'info')
else: else:
flash('You are not following this issue.', 'info') flash('You are not following this issue.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(url_for('issues.view', issue_id=issue_id))
# ── Standalone create (not from an inspection) ──────────────────────────────── # ── Standalone create ─────────────────────────────────────────────────────────
@bp.route('/new', methods=['GET', 'POST']) @bp.route('/new', methods=['GET', 'POST'])
@login_required @login_required
@@ -326,7 +311,6 @@ def create():
issue.id, issue.severity, issue.area_id, issue.assigned_to, current_user.username issue.id, issue.severity, issue.area_id, issue.assigned_to, current_user.username
) )
# Notify the assignee of the new issue
if issue.assigned_to: if issue.assigned_to:
assignee = User.query.get(issue.assigned_to) assignee = User.query.get(issue.assigned_to)
if assignee and assignee.id != current_user.id: if assignee and assignee.id != current_user.id:
@@ -341,6 +325,7 @@ def create():
), ),
link = url_for('issues.view', issue_id=issue.id), link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id, issue_id = issue.id,
event_type = EVENT_ISSUE_ASSIGNED,
send_email = True, send_email = True,
) )
db.session.commit() db.session.commit()
+130 -8
View File
@@ -1,21 +1,24 @@
# app/routes/notifications.py # app/routes/notifications.py
import logging 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 flask_login import login_required, current_user
from app import db from app import db
from app.models.notification import Notification from app.models.notification import (
Notification, NotificationPreference, ALL_EVENT_TYPES
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint('notifications', __name__, url_prefix='/notifications') bp = Blueprint('notifications', __name__, url_prefix='/notifications')
# ── Bell feed (navbar dropdown) ───────────────────────────────────────────────
@bp.route('/feed') @bp.route('/feed')
@login_required @login_required
def feed(): def feed():
"""Return the 20 most recent notifications for the current user as JSON. """Return the 20 most recent notifications for the current user as JSON."""
Used by the navbar bell icon to populate the dropdown.
"""
notifs = ( notifs = (
Notification.query Notification.query
.filter_by(user_id=current_user.id) .filter_by(user_id=current_user.id)
@@ -41,10 +44,42 @@ def feed():
return jsonify({'notifications': items, 'unread_count': unread_count}) 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']) @bp.route('/<int:notif_id>/mark-read', methods=['POST'])
@login_required @login_required
def mark_read(notif_id): def mark_read(notif_id):
"""Mark a single notification as read."""
notif = Notification.query.get_or_404(notif_id) notif = Notification.query.get_or_404(notif_id)
if notif.user_id != current_user.id: if notif.user_id != current_user.id:
abort(403) abort(403)
@@ -57,10 +92,11 @@ def mark_read(notif_id):
return jsonify({'ok': True}) return jsonify({'ok': True})
# ── Mark all read ─────────────────────────────────────────────────────────────
@bp.route('/mark-all-read', methods=['POST']) @bp.route('/mark-all-read', methods=['POST'])
@login_required @login_required
def mark_all_read(): def mark_all_read():
"""Mark all unread notifications for the current user as read."""
updated = ( updated = (
Notification.query Notification.query
.filter_by(user_id=current_user.id, is_read=False) .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', 'NOTIFICATIONS ALL READ | user=%s | count=%s',
current_user.username, updated, 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})
+71 -75
View File
@@ -14,58 +14,35 @@
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
<style> <style>
/* ── Notification bell styles ── */ /* ── Notification bell styles ── */
.notif-bell-wrapper { .notif-bell-wrapper { position: relative; }
position: relative;
}
.notif-badge { .notif-badge {
position: absolute; position: absolute;
top: 2px; top: 2px; right: 2px;
right: 2px;
font-size: 0.6rem; font-size: 0.6rem;
min-width: 16px; min-width: 16px; height: 16px; line-height: 16px;
height: 16px; padding: 0 4px; border-radius: 8px;
line-height: 16px;
padding: 0 4px;
border-radius: 8px;
pointer-events: none; pointer-events: none;
} }
.notif-dropdown { .notif-dropdown {
width: 360px; width: 380px;
max-height: 480px; max-height: 520px;
overflow-y: auto; overflow-y: auto;
padding: 0; padding: 0;
} }
.notif-item { .notif-item {
border-left: 3px solid transparent; border-left: 3px solid transparent;
transition: background 0.15s; transition: background 0.15s;
cursor: pointer;
} }
.notif-item.unread { .notif-item.unread {
border-left-color: #0d6efd; border-left-color: #0d6efd;
background-color: #f0f6ff; background-color: #f0f6ff;
} }
.notif-item:hover { .notif-item:hover { background-color: #e8f0fe; }
background-color: #e8f0fe; .notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
} .notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-title { .notif-time { font-size: 0.7rem; color: #999; }
font-size: 0.85rem; .notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
font-weight: 600;
margin-bottom: 2px;
}
.notif-body {
font-size: 0.78rem;
color: #555;
white-space: normal;
}
.notif-time {
font-size: 0.7rem;
color: #999;
}
.notif-empty {
padding: 24px;
text-align: center;
color: #aaa;
font-size: 0.85rem;
}
</style> </style>
</head> </head>
<body> <body>
@@ -124,30 +101,56 @@
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span> <span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %} {% endif %}
</a> </a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow" id="notif-dropdown-menu"> <div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom"> id="notif-dropdown-menu">
<span class="fw-semibold" style="font-size:0.9rem;">Notifications</span> <!-- Header -->
<div class="d-flex justify-content-between align-items-center
px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none" <button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" id="mark-all-read-btn" style="font-size:.75rem;">
style="font-size:0.75rem;">
Mark all as read Mark all as read
</button> </button>
</div> </div>
<!-- Items -->
<div id="notif-list"> <div id="notif-list">
<div class="notif-empty">Loading…</div> <div class="notif-empty">Loading…</div>
</div> </div>
<!-- Footer -->
<div class="border-top d-flex justify-content-between px-3 py-2"
style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}"
class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}"
class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div> </div>
</li> </li>
<!-- ── End Notification Bell ── --> <!-- ── End Notification Bell ── -->
<!-- User menu -->
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.username }} <i class="bi bi-person-circle"></i> {{ current_user.username }}
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#">Profile</a></li> <li>
<a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li> <li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul> </ul>
</li> </li>
</ul> </ul>
@@ -179,14 +182,14 @@
(function () { (function () {
'use strict'; 'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}'; const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/'; const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}'; const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}'; const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds const POLL_INTERVAL = 60000; // 60 seconds
const badge = document.getElementById('notif-count-badge'); const badge = document.getElementById('notif-count-badge');
const listEl = document.getElementById('notif-list'); const listEl = document.getElementById('notif-list');
const markAllBtn = document.getElementById('mark-all-read-btn'); const markAllBtn = document.getElementById('mark-all-read-btn');
function updateBadge(count) { function updateBadge(count) {
@@ -201,27 +204,26 @@
function renderNotifications(notifications) { function renderNotifications(notifications) {
if (!notifications.length) { if (!notifications.length) {
listEl.innerHTML = '<div class="notif-empty"><i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>'; listEl.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return; return;
} }
listEl.innerHTML = notifications.map(n => ` listEl.innerHTML = notifications.map(n => `
<a href="${n.link || '#'}" <div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom
class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom ${n.is_read ? '' : 'unread'}" ${n.is_read ? '' : 'unread'}"
data-notif-id="${n.id}" data-notif-id="${n.id}"
data-link="${n.link || ''}"> data-link="${escapeAttr(n.link || '')}">
<div class="notif-title">${escapeHtml(n.title)}</div> <div class="notif-title">${escapeHtml(n.title)}</div>
<div class="notif-body">${escapeHtml(n.body)}</div> <div class="notif-body">${escapeHtml(n.body)}</div>
<div class="notif-time">${escapeHtml(n.created_at)}</div> <div class="notif-time">${escapeHtml(n.created_at)}</div>
</a> </div>
`).join(''); `).join('');
// Mark as read on click
listEl.querySelectorAll('.notif-item').forEach(el => { listEl.querySelectorAll('.notif-item').forEach(el => {
el.addEventListener('click', function (e) { el.addEventListener('click', function () {
const id = this.dataset.notifId; const id = this.dataset.notifId;
const link = this.dataset.link; const link = this.dataset.link;
e.preventDefault();
markRead(id, () => { markRead(id, () => {
this.classList.remove('unread'); this.classList.remove('unread');
if (link) window.location.href = link; if (link) window.location.href = link;
@@ -232,30 +234,28 @@
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g, '&amp;') return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/</g, '&lt;') .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
} }
function escapeAttr(str) { return escapeHtml(str); }
function fetchNotifications() { function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' }) fetch(FEED_URL, { credentials: 'same-origin' })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
updateBadge(data.unread_count); updateBadge(data.unread_count);
// Only re-render if dropdown is open to avoid disrupting user
const dropdownEl = document.getElementById('notifDropdown');
const isOpen = dropdownEl.getAttribute('aria-expanded') === 'true';
if (isOpen) renderNotifications(data.notifications);
// Store for rendering when opened
window._jqcNotifications = data.notifications; window._jqcNotifications = data.notifications;
const dropdownEl = document.getElementById('notifDropdown');
if (dropdownEl.getAttribute('aria-expanded') === 'true') {
renderNotifications(data.notifications);
}
}) })
.catch(() => {}); // Silently fail — non-critical .catch(() => {});
} }
function markRead(id, callback) { function markRead(id, callback) {
fetch(`${MARK_READ_BASE}${id}/mark-read`, { fetch(`${MARK_READ_BASE}${id}/mark-read`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' }, headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin', credentials: 'same-origin',
}) })
@@ -264,7 +264,6 @@
.catch(() => { if (callback) callback(); }); .catch(() => { if (callback) callback(); });
} }
// Render stored notifications when dropdown opens
document.getElementById('notifDropdown').addEventListener('show.bs.dropdown', function () { document.getElementById('notifDropdown').addEventListener('show.bs.dropdown', function () {
if (window._jqcNotifications) { if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications); renderNotifications(window._jqcNotifications);
@@ -273,18 +272,16 @@
} }
}); });
// Mark all read button
markAllBtn.addEventListener('click', function (e) { markAllBtn.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
fetch(MARK_ALL_URL, { fetch(MARK_ALL_URL, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN }, headers: { 'X-CSRFToken': CSRF_TOKEN },
credentials: 'same-origin', credentials: 'same-origin',
}) })
.then(r => r.json()) .then(r => r.json())
.then(() => { .then(() => {
updateBadge(0); updateBadge(0);
// Mark all items visually as read
listEl.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread')); listEl.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
if (window._jqcNotifications) { if (window._jqcNotifications) {
window._jqcNotifications.forEach(n => n.is_read = true); window._jqcNotifications.forEach(n => n.is_read = true);
@@ -293,7 +290,6 @@
.catch(() => {}); .catch(() => {});
}); });
// Initial fetch + periodic polling
fetchNotifications(); fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL); setInterval(fetchNotifications, POLL_INTERVAL);
})(); })();
+127
View File
@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}Notifications{% endblock %}
{% block content %}
<div class="row mb-3 align-items-center">
<div class="col">
<h4 class="mb-0">
<i class="bi bi-bell-fill text-primary me-2"></i>Notifications
{% if unread_count > 0 %}
<span class="badge bg-danger ms-1" style="font-size:.6rem;vertical-align:middle;">
{{ unread_count }} unread
</span>
{% endif %}
</h4>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('notifications.preferences') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-gear"></i> Preferences
</a>
{% if unread_count > 0 %}
<form method="post" action="{{ url_for('notifications.mark_all_read') }}" class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-check2-all"></i> Mark all read
</button>
</form>
{% endif %}
</div>
</div>
{# ── Filter tabs ── #}
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {% if filter_read == 'all' %}active{% endif %}"
href="{{ url_for('notifications.index', filter='all') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link {% if filter_read == 'unread' %}active{% endif %}"
href="{{ url_for('notifications.index', filter='unread') }}">Unread</a>
</li>
<li class="nav-item">
<a class="nav-link {% if filter_read == 'read' %}active{% endif %}"
href="{{ url_for('notifications.index', filter='read') }}">Read</a>
</li>
</ul>
{# ── Notification list ── #}
{% if notifications.items %}
<div class="card shadow-sm">
<ul class="list-group list-group-flush">
{% for n in notifications.items %}
<li class="list-group-item px-3 py-3
{% if not n.is_read %}list-group-item-light border-start border-primary border-3{% endif %}">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
{% if not n.is_read %}
<span class="badge bg-primary" style="font-size:.65rem;">New</span>
{% endif %}
<span class="fw-semibold" style="font-size:.9rem;">{{ n.title }}</span>
</div>
<p class="mb-1 text-secondary" style="font-size:.85rem;">{{ n.body }}</p>
<small class="text-muted">
<i class="bi bi-clock me-1"></i>{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
</small>
</div>
<div class="d-flex gap-2 ms-3 flex-shrink-0">
{% if n.link %}
<a href="{{ n.link }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-right"></i> View
</a>
{% endif %}
{% if not n.is_read %}
<form method="post"
action="{{ url_for('notifications.mark_read', notif_id=n.id) }}"
class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-secondary"
title="Mark as read">
<i class="bi bi-check2"></i>
</button>
</form>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{# ── Pagination ── #}
{% if notifications.pages > 1 %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center">
<li class="page-item {% if not notifications.has_prev %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('notifications.index', page=notifications.prev_num, filter=filter_read) }}">
&laquo; Prev
</a>
</li>
{% for p in notifications.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == notifications.page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('notifications.index', page=p, filter=filter_read) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not notifications.has_next %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('notifications.index', page=notifications.next_num, filter=filter_read) }}">
Next &raquo;
</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash fs-1 d-block mb-3"></i>
<p class="mb-0">No notifications found.</p>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}Notification Preferences{% endblock %}
{% block content %}
<div class="row mb-3 align-items-center">
<div class="col">
<h4 class="mb-0">
<i class="bi bi-gear-fill text-secondary me-2"></i>Notification Preferences
</h4>
<p class="text-muted mb-0 mt-1" style="font-size:.85rem;">
Control how and when you receive notifications for each event type.
</p>
</div>
<div class="col-auto">
<a href="{{ url_for('notifications.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-bell"></i> View Notifications
</a>
</div>
</div>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card shadow-sm">
<div class="card-header bg-light">
<div class="row fw-semibold text-muted" style="font-size:.8rem;">
<div class="col-md-4">Event</div>
<div class="col-md-2 text-center">Email Alerts</div>
<div class="col-md-2 text-center">Digest Mode</div>
<div class="col-md-3 text-center">Digest Frequency</div>
<div class="col-md-1"></div>
</div>
</div>
<ul class="list-group list-group-flush">
{% for event_type, label in event_types.items() %}
{% set pref = prefs_map.get(event_type) %}
{% set email_on = pref.email_enabled if pref else True %}
{% set digest_on = pref.digest_mode if pref else False %}
{% set freq = pref.digest_frequency if pref else 'daily' %}
<li class="list-group-item px-3 py-3" id="row-{{ event_type }}">
<div class="row align-items-center">
{# Event label #}
<div class="col-md-4">
<span class="fw-semibold" style="font-size:.9rem;">{{ label }}</span>
</div>
{# Email toggle #}
<div class="col-md-2 text-center">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input email-toggle"
type="checkbox"
name="email_{{ event_type }}"
id="email_{{ event_type }}"
value="1"
data-event="{{ event_type }}"
{% if email_on %}checked{% endif %}>
<label class="form-check-label visually-hidden"
for="email_{{ event_type }}">Email</label>
</div>
</div>
{# Digest mode toggle #}
<div class="col-md-2 text-center">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input digest-toggle"
type="checkbox"
name="digest_{{ event_type }}"
id="digest_{{ event_type }}"
value="1"
data-event="{{ event_type }}"
{% if digest_on %}checked{% endif %}
{% if not email_on %}disabled{% endif %}>
<label class="form-check-label visually-hidden"
for="digest_{{ event_type }}">Digest</label>
</div>
</div>
{# Digest frequency #}
<div class="col-md-3 text-center">
<select class="form-select form-select-sm freq-select"
name="freq_{{ event_type }}"
id="freq_{{ event_type }}"
style="width:auto;margin:auto;"
{% if not email_on or not digest_on %}disabled{% endif %}>
<option value="hourly" {% if freq == 'hourly' %}selected{% endif %}>Hourly</option>
<option value="daily" {% if freq == 'daily' %}selected{% endif %}>Daily</option>
</select>
</div>
{# Status label #}
<div class="col-md-1 text-end">
<span class="badge status-badge
{% if not email_on %}bg-secondary
{% elif digest_on %}bg-warning text-dark
{% else %}bg-success{% endif %}"
style="font-size:.65rem;"
id="badge-{{ event_type }}">
{% if not email_on %}Off
{% elif digest_on %}Digest
{% else %}Live{% endif %}
</span>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2-circle me-1"></i>Save Preferences
</button>
<a href="{{ url_for('notifications.index') }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
<div class="card mt-4 border-0 bg-light">
<div class="card-body py-2 px-3">
<p class="mb-1" style="font-size:.8rem;"><strong>Email Alerts:</strong>
Send an immediate email every time this event occurs.</p>
<p class="mb-1" style="font-size:.8rem;"><strong>Digest Mode:</strong>
Hold notifications and deliver them in a single batched email on your chosen schedule.</p>
<p class="mb-0" style="font-size:.8rem;"><strong>Off:</strong>
In-app notifications still appear in the bell — only email is suppressed.</p>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
document.querySelectorAll('.email-toggle').forEach(function (emailChk) {
emailChk.addEventListener('change', function () {
var event = this.dataset.event;
var digestChk = document.getElementById('digest_' + event);
var freqSelect = document.getElementById('freq_' + event);
var badge = document.getElementById('badge_' + event) ||
document.getElementById('badge-' + event);
var emailOn = this.checked;
var digestOn = digestChk.checked;
// Cascade: disabling email disables digest and frequency
digestChk.disabled = !emailOn;
freqSelect.disabled = !emailOn || !digestOn;
if (!emailOn) {
digestChk.checked = false;
digestOn = false;
}
updateBadge(badge, emailOn, digestOn);
});
});
document.querySelectorAll('.digest-toggle').forEach(function (digestChk) {
digestChk.addEventListener('change', function () {
var event = this.dataset.event;
var freqSelect = document.getElementById('freq_' + event);
var badge = document.getElementById('badge_' + event) ||
document.getElementById('badge-' + event);
var emailChk = document.getElementById('email_' + event);
var emailOn = emailChk.checked;
var digestOn = this.checked;
freqSelect.disabled = !emailOn || !digestOn;
updateBadge(badge, emailOn, digestOn);
});
});
function updateBadge(badge, emailOn, digestOn) {
if (!badge) return;
badge.className = badge.className
.replace(/bg-\S+/g, '')
.replace(/text-\S+/g, '')
.trim();
if (!emailOn) {
badge.classList.add('bg-secondary');
badge.textContent = 'Off';
} else if (digestOn) {
badge.classList.add('bg-warning', 'text-dark');
badge.textContent = 'Digest';
} else {
badge.classList.add('bg-success');
badge.textContent = 'Live';
}
}
})();
</script>
{% endblock %}
+274 -44
View File
@@ -2,17 +2,43 @@
app/utils/notifications.py app/utils/notifications.py
~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~
Central helper for creating in-app notifications and dispatching email alerts. Central helper for creating in-app notifications and dispatching email alerts.
Usage
-----
from app.utils.notifications import notify
notify(
recipient = some_user,
title = 'Issue #12 Updated',
body = 'Status changed to In Progress by admin.',
link = url_for('issues.view', issue_id=12),
issue_id = 12,
event_type = EVENT_ISSUE_STATUS, # controls preference lookup
send_email = True,
)
Email delivery is best-effort: a failure to send will be logged but will NOT
raise an exception or roll back the DB transaction.
Digest emails are sent by calling send_pending_digests(frequency) from the
/notifications/send-digest route, which is triggered by a server cron job.
""" """
import logging import logging
from flask import current_app, render_template_string from flask import current_app, render_template_string
from flask_mail import Message from flask_mail import Message
from app import db, mail from app import db, mail
from app.models.notification import Notification from app.models.notification import (
Notification, NotificationPreference,
ALL_EVENT_TYPES,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_EMAIL_HTML = """\
# ── Email templates ────────────────────────────────────────────────────────────
_EMAIL_HTML_SINGLE = """\
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;"> <body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
@@ -29,13 +55,16 @@ _EMAIL_HTML = """\
{% endif %} {% endif %}
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;"> <hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
<p style="font-size:12px;color:#888;"> <p style="font-size:12px;color:#888;">
Janitorial QC System — automated notification. Do not reply to this email. Janitorial QC System — automated notification. Do not reply to this email.<br>
<a href="{{ base_url }}/notifications/preferences" style="color:#888;">
Manage notification preferences
</a>
</p> </p>
</body> </body>
</html> </html>
""" """
_EMAIL_TEXT = """\ _EMAIL_TEXT_SINGLE = """\
{{ title }} {{ title }}
{{ body }} {{ body }}
@@ -45,8 +74,93 @@ View: {{ base_url }}{{ link }}
-- --
Janitorial QC System — automated notification. Janitorial QC System — automated notification.
Manage preferences: {{ base_url }}/notifications/preferences
""" """
_EMAIL_HTML_DIGEST = """\
<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
<h2 style="color:#0d6efd;">Your {{ frequency|title }} JQC Notification Digest</h2>
<p>You have <strong>{{ notifications|length }}</strong> new notification(s):</p>
<hr style="border:none;border-top:1px solid #eee;">
{% for n in notifications %}
<div style="margin-bottom:20px;padding:12px;background:#f8f9fa;border-radius:6px;
border-left:4px solid #0d6efd;">
<p style="margin:0 0 4px;font-weight:bold;">{{ n.title }}</p>
<p style="margin:0 0 8px;font-size:.9em;color:#555;">{{ n.body }}</p>
{% if n.link %}
<a href="{{ base_url }}{{ n.link }}"
style="font-size:.85em;color:#0d6efd;text-decoration:none;">
View Details →
</a>
{% endif %}
<p style="margin:6px 0 0;font-size:.75em;color:#999;">
{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
</p>
</div>
{% endfor %}
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
<p style="font-size:12px;color:#888;">
Janitorial QC System — automated digest. Do not reply to this email.<br>
<a href="{{ base_url }}/notifications/preferences" style="color:#888;">
Manage notification preferences
</a>
</p>
</body>
</html>
"""
_EMAIL_TEXT_DIGEST = """\
Your {{ frequency|title }} JQC Notification Digest
{{ notifications|length }} new notification(s):
{% for n in notifications %}
---
{{ n.title }}
{{ n.body }}
{% if n.link %}View: {{ base_url }}{{ n.link }}{% endif %}
{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
{% endfor %}
--
Janitorial QC System — automated digest.
Manage preferences: {{ base_url }}/notifications/preferences
"""
# ── Preference helpers ─────────────────────────────────────────────────────────
def _get_preference(user_id, event_type):
"""Return the NotificationPreference for a user+event, or None if not set."""
if not event_type:
return None
return NotificationPreference.query.filter_by(
user_id=user_id, event_type=event_type
).first()
def _email_enabled_for(user, event_type):
"""Return True if the user wants an immediate email for this event type."""
pref = _get_preference(user.id, event_type)
if pref is None:
return True # Default: email on, immediate
if not pref.email_enabled:
return False # User opted out of email entirely for this event
if pref.digest_mode:
return False # User prefers digest — suppress immediate email
return True
def _digest_mode_for(user, event_type):
"""Return True if this notification should be held for digest delivery."""
pref = _get_preference(user.id, event_type)
if pref is None:
return False
return pref.email_enabled and pref.digest_mode
# ── Core notify function ───────────────────────────────────────────────────────
def notify( def notify(
recipient, recipient,
@@ -55,66 +169,182 @@ def notify(
link: str = None, link: str = None,
issue_id: int = None, issue_id: int = None,
inspection_id: int = None, inspection_id: int = None,
event_type: str = None,
send_email: bool = True, send_email: bool = True,
): ):
"""Create an in-app Notification record and optionally send an email.""" """Create an in-app Notification record and optionally send an email.
# ── 1. Persist in-app notification ─────────────────────────────────────
Parameters
----------
recipient : User ORM instance
title : Short notification headline
body : Full notification message
link : Relative URL for the 'View Details' button/link
issue_id : FK to issues.id (optional)
inspection_id: FK to inspections.id (optional)
event_type : One of the EVENT_* constants from models.notification
Used to look up the user's preference for this event.
send_email : Master switch — set False to suppress all email (overrides prefs)
"""
# Determine digest flag before creating the record
hold_for_digest = send_email and bool(event_type) and _digest_mode_for(recipient, event_type)
# ── 1. Persist in-app notification ──────────────────────────────────────
notif = Notification( notif = Notification(
user_id = recipient.id, user_id = recipient.id,
title = title, title = title,
body = body, body = body,
link = link, link = link,
issue_id = issue_id, issue_id = issue_id,
inspection_id = inspection_id, inspection_id = inspection_id,
is_read = False, is_read = False,
digest_pending = hold_for_digest,
) )
db.session.add(notif) db.session.add(notif)
# NOTE: The caller is responsible for calling db.session.commit(). # NOTE: Caller is responsible for db.session.commit()
logger.info( logger.info(
'NOTIFICATION CREATED | user=%s | title=%s | issue_id=%s | inspection_id=%s', 'NOTIFICATION CREATED | user=%s | event=%s | title=%s | digest=%s',
recipient.username, title, issue_id, inspection_id, recipient.username, event_type, title, hold_for_digest,
) )
# ── 2. Send email (best-effort) ───────────────────────────────────────── # ── 2. Send immediate email if applicable ────────────────────────────────
if send_email and recipient.email and current_app.config.get('MAIL_SERVER'): if send_email and not hold_for_digest:
should_send = (
event_type is None or _email_enabled_for(recipient, event_type)
)
if should_send and recipient.email and current_app.config.get('MAIL_SERVER'):
_send_single_email(recipient, title, body, link)
def _send_single_email(recipient, title, body, link):
"""Dispatch a single immediate notification email. Fire-and-forget."""
try:
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
sender = current_app.config.get(
'MAIL_DEFAULT_SENDER',
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
)
html_body = render_template_string(
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
)
text_body = render_template_string(
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
)
msg = Message(
subject = f'[JQC] {title}',
sender = sender,
recipients = [recipient.email],
body = text_body,
html = html_body,
)
mail.send(msg)
logger.info(
'NOTIFICATION EMAIL SENT | to=%s | subject=%s',
recipient.email, msg.subject,
)
except Exception as exc:
logger.error(
'NOTIFICATION EMAIL FAILED | to=%s | error=%s',
recipient.email, exc,
)
# ── Digest delivery ────────────────────────────────────────────────────────────
def send_pending_digests(frequency: str = 'daily'):
"""Send digest emails for all users who have pending digest notifications.
Called from the /notifications/send-digest route, which is hit by cron.
Parameters
----------
frequency : 'hourly' or 'daily' — matches digest_frequency in preferences
"""
if not current_app.config.get('MAIL_SERVER'):
logger.warning('DIGEST SKIPPED | MAIL_SERVER not configured')
return 0
# Find all users with pending digest notifications
from app.models.user import User
pending_user_ids = (
db.session.query(Notification.user_id)
.filter_by(digest_pending=True)
.distinct()
.all()
)
pending_user_ids = [row[0] for row in pending_user_ids]
sent_count = 0
for user_id in pending_user_ids:
user = User.query.get(user_id)
if not user or not user.email:
continue
# Collect only the notifications that match this frequency for this user
# A notification is included in a frequency's digest if at least one of
# the user's digest preferences matches that frequency.
# Simple approach: include all pending if user has any pref with this frequency.
has_freq_pref = NotificationPreference.query.filter_by(
user_id=user_id,
digest_mode=True,
digest_frequency=frequency,
email_enabled=True,
).first()
if not has_freq_pref:
continue
notifications = Notification.query.filter_by(
user_id=user_id,
digest_pending=True,
).order_by(Notification.created_at.asc()).all()
if not notifications:
continue
try: try:
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
sender = current_app.config.get(
html_body = render_template_string(
_EMAIL_HTML,
title=title,
body=body,
link=link,
base_url=base_url,
)
text_body = render_template_string(
_EMAIL_TEXT,
title=title,
body=body,
link=link,
base_url=base_url,
)
sender = current_app.config.get(
'MAIL_DEFAULT_SENDER', 'MAIL_DEFAULT_SENDER',
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'), current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
) )
html_body = render_template_string(
_EMAIL_HTML_DIGEST,
notifications=notifications,
frequency=frequency,
base_url=base_url,
)
text_body = render_template_string(
_EMAIL_TEXT_DIGEST,
notifications=notifications,
frequency=frequency,
base_url=base_url,
)
msg = Message( msg = Message(
subject = f'[JQC] {title}', subject = f'[JQC] Your {frequency.title()} Notification Digest '
f'({len(notifications)} update{"s" if len(notifications) != 1 else ""})',
sender = sender, sender = sender,
recipients = [recipient.email], recipients = [user.email],
body = text_body, body = text_body,
html = html_body, html = html_body,
) )
mail.send(msg) mail.send(msg)
# Clear the pending flag on all notifications just sent
for n in notifications:
n.digest_pending = False
db.session.commit()
sent_count += 1
logger.info( logger.info(
'NOTIFICATION EMAIL SENT | to=%s | subject=%s', 'DIGEST EMAIL SENT | to=%s | frequency=%s | count=%s',
recipient.email, msg.subject, user.email, frequency, len(notifications),
) )
except Exception as exc: except Exception as exc:
logger.error( logger.error(
'NOTIFICATION EMAIL FAILED | to=%s | error=%s', 'DIGEST EMAIL FAILED | to=%s | frequency=%s | error=%s',
recipient.email, exc, user.email, frequency, exc,
) )
return sent_count
+10 -8
View File
@@ -44,16 +44,18 @@ class Config:
SESSION_COOKIE_SAMESITE = 'Lax' SESSION_COOKIE_SAMESITE = 'Lax'
# ── Mail ──────────────────────────────────────────────────────────────── # ── Mail ────────────────────────────────────────────────────────────────
MAIL_SERVER = os.environ.get('MAIL_SERVER') # ── Application base URL (used in email links) ─────────────────────────
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587) APP_BASE_URL = os.environ.get('APP_BASE_URL', '')
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ('true', 'on', '1')
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local') MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local')
# ── Application base URL (used in email "View Details" links) ─────────── # ── Digest email secret token (used to authenticate cron trigger) ────────
# Set this to your production domain, e.g. https://qc.yourcompany.com DIGEST_SECRET = os.environ.get('DIGEST_SECRET')
APP_BASE_URL = os.environ.get('APP_BASE_URL', '')
MAIL_SERVER = os.environ.get('MAIL_SERVER')
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ('true', 'on', '1')
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
class DevelopmentConfig(Config): class DevelopmentConfig(Config):