Feb 02 2026: implement issue following preferences
This commit is contained in:
@@ -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}>')
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
return str.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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);
|
||||
})();
|
||||
|
||||
@@ -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) }}">
|
||||
« 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 »
|
||||
</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
@@ -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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user