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
+52 -1
View File
@@ -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,7 +33,7 @@ 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)
@@ -22,7 +41,39 @@ class Notification(db.Model):
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'<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.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
+21 -36
View File
@@ -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,15 +20,7 @@ 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)
"""
"""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():
@@ -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())
# 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(
@@ -135,10 +123,9 @@ 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
# 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,
body = (
f'Issue #{issue.id} in {issue.area.name} was updated by '
f'{current_user.username}: {"; ".join(changes)}.'
),
exclude_user_ids = exclude_ids,
)
@@ -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))
@@ -278,7 +265,6 @@ def follow(issue_id):
@login_required
def unfollow(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()
+129 -7
View File
@@ -1,21 +1,24 @@
# app/routes/notifications.py
import logging
from flask import Blueprint, jsonify, request, abort
from flask import (Blueprint, jsonify, request, abort,
render_template, redirect, url_for, flash, current_app)
from flask_login import login_required, current_user
from app import db
from app.models.notification import Notification
from app.models.notification import (
Notification, NotificationPreference, ALL_EVENT_TYPES
)
logger = logging.getLogger(__name__)
bp = Blueprint('notifications', __name__, url_prefix='/notifications')
# ── Bell feed (navbar dropdown) ───────────────────────────────────────────────
@bp.route('/feed')
@login_required
def feed():
"""Return the 20 most recent notifications for the current user as JSON.
Used by the navbar bell icon to populate the dropdown.
"""
"""Return the 20 most recent notifications for the current user as JSON."""
notifs = (
Notification.query
.filter_by(user_id=current_user.id)
@@ -41,10 +44,42 @@ def feed():
return jsonify({'notifications': items, 'unread_count': unread_count})
# ── Full notification history page ────────────────────────────────────────────
@bp.route('/')
@login_required
def index():
"""Full paginated notification history with read/unread filter."""
page = request.args.get('page', 1, type=int)
filter_read = request.args.get('filter', 'all') # 'all' | 'unread' | 'read'
q = Notification.query.filter_by(user_id=current_user.id)
if filter_read == 'unread':
q = q.filter_by(is_read=False)
elif filter_read == 'read':
q = q.filter_by(is_read=True)
notifications = q.order_by(Notification.created_at.desc()).paginate(
page=page, per_page=25, error_out=False
)
unread_count = Notification.query.filter_by(
user_id=current_user.id, is_read=False
).count()
return render_template(
'notifications/index.html',
notifications=notifications,
filter_read=filter_read,
unread_count=unread_count,
)
# ── Mark single notification read ─────────────────────────────────────────────
@bp.route('/<int:notif_id>/mark-read', methods=['POST'])
@login_required
def mark_read(notif_id):
"""Mark a single notification as read."""
notif = Notification.query.get_or_404(notif_id)
if notif.user_id != current_user.id:
abort(403)
@@ -57,10 +92,11 @@ def mark_read(notif_id):
return jsonify({'ok': True})
# ── Mark all read ─────────────────────────────────────────────────────────────
@bp.route('/mark-all-read', methods=['POST'])
@login_required
def mark_all_read():
"""Mark all unread notifications for the current user as read."""
updated = (
Notification.query
.filter_by(user_id=current_user.id, is_read=False)
@@ -71,4 +107,90 @@ def mark_all_read():
'NOTIFICATIONS ALL READ | user=%s | count=%s',
current_user.username, updated,
)
# 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})
+61 -65
View File
@@ -14,58 +14,35 @@
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles ── */
.notif-bell-wrapper {
position: relative;
}
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px;
right: 2px;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px;
height: 16px;
line-height: 16px;
padding: 0 4px;
border-radius: 8px;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 360px;
max-height: 480px;
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: #0d6efd;
background-color: #f0f6ff;
}
.notif-item:hover {
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-time {
font-size: 0.7rem;
color: #999;
}
.notif-empty {
padding: 24px;
text-align: center;
color: #aaa;
font-size: 0.85rem;
}
.notif-item:hover { 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-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
</style>
</head>
<body>
@@ -124,30 +101,56 @@
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow" id="notif-dropdown-menu">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:0.9rem;">Notifications</span>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu">
<!-- 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"
id="mark-all-read-btn"
style="font-size:0.75rem;">
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<!-- Items -->
<div id="notif-list">
<div class="notif-empty">Loading…</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>
</li>
<!-- ── End Notification Bell ── -->
<!-- User menu -->
<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 }}
</a>
<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><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>
</li>
</ul>
@@ -201,27 +204,26 @@
function renderNotifications(notifications) {
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;
}
listEl.innerHTML = notifications.map(n => `
<a href="${n.link || '#'}"
class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom ${n.is_read ? '' : 'unread'}"
<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom
${n.is_read ? '' : 'unread'}"
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-body">${escapeHtml(n.body)}</div>
<div class="notif-time">${escapeHtml(n.created_at)}</div>
</a>
</div>
`).join('');
// Mark as read on click
listEl.querySelectorAll('.notif-item').forEach(el => {
el.addEventListener('click', function (e) {
el.addEventListener('click', function () {
const id = this.dataset.notifId;
const link = this.dataset.link;
e.preventDefault();
markRead(id, () => {
this.classList.remove('unread');
if (link) window.location.href = link;
@@ -232,25 +234,23 @@
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
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;
const dropdownEl = document.getElementById('notifDropdown');
if (dropdownEl.getAttribute('aria-expanded') === 'true') {
renderNotifications(data.notifications);
}
})
.catch(() => {}); // Silently fail — non-critical
.catch(() => {});
}
function markRead(id, callback) {
@@ -264,7 +264,6 @@
.catch(() => { if (callback) callback(); });
}
// Render stored notifications when dropdown opens
document.getElementById('notifDropdown').addEventListener('show.bs.dropdown', function () {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
@@ -273,7 +272,6 @@
}
});
// Mark all read button
markAllBtn.addEventListener('click', function (e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
@@ -284,7 +282,6 @@
.then(r => r.json())
.then(() => {
updateBadge(0);
// Mark all items visually as read
listEl.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
if (window._jqcNotifications) {
window._jqcNotifications.forEach(n => n.is_read = true);
@@ -293,7 +290,6 @@
.catch(() => {});
});
// Initial fetch + periodic polling
fetchNotifications();
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 %}
+258 -28
View File
@@ -2,17 +2,43 @@
app/utils/notifications.py
~~~~~~~~~~~~~~~~~~~~~~~~~~
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
from flask import current_app, render_template_string
from flask_mail import Message
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__)
_EMAIL_HTML = """\
# ── Email templates ────────────────────────────────────────────────────────────
_EMAIL_HTML_SINGLE = """\
<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
@@ -29,13 +55,16 @@ _EMAIL_HTML = """\
{% endif %}
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
<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>
</body>
</html>
"""
_EMAIL_TEXT = """\
_EMAIL_TEXT_SINGLE = """\
{{ title }}
{{ body }}
@@ -45,8 +74,93 @@ View: {{ base_url }}{{ link }}
--
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(
recipient,
@@ -55,10 +169,27 @@ def notify(
link: str = None,
issue_id: int = None,
inspection_id: int = None,
event_type: str = None,
send_email: bool = True,
):
"""Create an in-app Notification record and optionally send an email."""
# ── 1. Persist in-app notification ─────────────────────────────────────
"""Create an in-app Notification record and optionally send an email.
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(
user_id = recipient.id,
title = title,
@@ -67,40 +198,39 @@ def notify(
issue_id = issue_id,
inspection_id = inspection_id,
is_read = False,
digest_pending = hold_for_digest,
)
db.session.add(notif)
# NOTE: The caller is responsible for calling db.session.commit().
# NOTE: Caller is responsible for db.session.commit()
logger.info(
'NOTIFICATION CREATED | user=%s | title=%s | issue_id=%s | inspection_id=%s',
recipient.username, title, issue_id, inspection_id,
'NOTIFICATION CREATED | user=%s | event=%s | title=%s | digest=%s',
recipient.username, event_type, title, hold_for_digest,
)
# ── 2. Send email (best-effort) ─────────────────────────────────────────
if send_email and recipient.email and current_app.config.get('MAIL_SERVER'):
# ── 2. Send immediate email if applicable ────────────────────────────────
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('/')
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',
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,
@@ -118,3 +248,103 @@ def notify(
'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:
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_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(
subject = f'[JQC] Your {frequency.title()} Notification Digest '
f'({len(notifications)} update{"s" if len(notifications) != 1 else ""})',
sender = sender,
recipients = [user.email],
body = text_body,
html = html_body,
)
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(
'DIGEST EMAIL SENT | to=%s | frequency=%s | count=%s',
user.email, frequency, len(notifications),
)
except Exception as exc:
logger.error(
'DIGEST EMAIL FAILED | to=%s | frequency=%s | error=%s',
user.email, frequency, exc,
)
return sent_count
+7 -5
View File
@@ -44,16 +44,18 @@ class Config:
SESSION_COOKIE_SAMESITE = 'Lax'
# ── Mail ────────────────────────────────────────────────────────────────
# ── Application base URL (used in email links) ─────────────────────────
APP_BASE_URL = os.environ.get('APP_BASE_URL', '')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local')
# ── Digest email secret token (used to authenticate cron trigger) ────────
DIGEST_SECRET = os.environ.get('DIGEST_SECRET')
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')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local')
# ── Application base URL (used in email "View Details" links) ───────────
# Set this to your production domain, e.g. https://qc.yourcompany.com
APP_BASE_URL = os.environ.get('APP_BASE_URL', '')
class DevelopmentConfig(Config):