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
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event type constants ───────────────────────────────────────────────────────
|
||||||
|
# These are the canonical keys used across the preference system.
|
||||||
|
# Every call to notify() should pass one of these as event_type.
|
||||||
|
|
||||||
|
EVENT_ISSUE_ASSIGNED = 'issue_assigned'
|
||||||
|
EVENT_ISSUE_STATUS = 'issue_status'
|
||||||
|
EVENT_ISSUE_COMMENT = 'issue_comment'
|
||||||
|
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
|
||||||
|
EVENT_INSPECTION_DONE = 'inspection_completed'
|
||||||
|
|
||||||
|
ALL_EVENT_TYPES = {
|
||||||
|
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||||
|
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||||
|
EVENT_ISSUE_COMMENT: 'New comment on issue',
|
||||||
|
EVENT_ISSUE_FOLLOW: 'Updates on followed issues',
|
||||||
|
EVENT_INSPECTION_DONE: 'Inspection completed',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Notification(db.Model):
|
class Notification(db.Model):
|
||||||
"""Stores in-app notifications for users.
|
"""Stores in-app notifications for users.
|
||||||
|
|
||||||
@@ -14,15 +33,47 @@ class Notification(db.Model):
|
|||||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
body = db.Column(db.Text, nullable=False)
|
body = db.Column(db.Text, nullable=False)
|
||||||
link = db.Column(db.String(512)) # URL the bell-click should navigate to
|
link = db.Column(db.String(512))
|
||||||
is_read = db.Column(db.Boolean, default=False, nullable=False)
|
is_read = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
|
||||||
# Optional FK references — only one will be populated at a time
|
# Optional FK references — only one will be populated at a time
|
||||||
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True)
|
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True)
|
||||||
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True)
|
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True)
|
||||||
|
|
||||||
|
# Digest tracking: set to True when created, cleared after digest email sent
|
||||||
|
digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||||
|
|
||||||
recipient = db.relationship('User', foreign_keys=[user_id], backref='notifications')
|
recipient = db.relationship('User', foreign_keys=[user_id], backref='notifications')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Notification {self.id} user={self.user_id} read={self.is_read}>'
|
return f'<Notification {self.id} user={self.user_id} read={self.is_read}>'
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPreference(db.Model):
|
||||||
|
"""Per-user, per-event notification preferences.
|
||||||
|
|
||||||
|
One row per (user_id, event_type) combination.
|
||||||
|
If no row exists for a user+event, defaults apply (email on, no digest).
|
||||||
|
"""
|
||||||
|
__tablename__ = 'notification_preferences'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
event_type = db.Column(db.String(50), nullable=False)
|
||||||
|
email_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
digest_mode = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
# digest_frequency: 'hourly' or 'daily' — only relevant when digest_mode is True
|
||||||
|
digest_frequency = db.Column(db.String(10), default='daily', nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('user_id', 'event_type', name='uq_notif_pref_user_event'),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.relationship('User', foreign_keys=[user_id],
|
||||||
|
backref=db.backref('notification_preferences', lazy='dynamic'))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (f'<NotificationPreference user={self.user_id} '
|
||||||
|
f'event={self.event_type} email={self.email_enabled} digest={self.digest_mode}>')
|
||||||
@@ -16,6 +16,7 @@ from app.utils.forms import StartInspectionForm, IssueForm
|
|||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
from app.utils.pdf_export import generate_inspection_pdf
|
from app.utils.pdf_export import generate_inspection_pdf
|
||||||
from app.utils.notifications import notify
|
from app.utils.notifications import notify
|
||||||
|
from app.models.notification import EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED
|
||||||
|
|
||||||
bp = Blueprint('inspections', __name__, url_prefix='/inspections')
|
bp = Blueprint('inspections', __name__, url_prefix='/inspections')
|
||||||
|
|
||||||
@@ -328,6 +329,7 @@ def execute(inspection_id):
|
|||||||
),
|
),
|
||||||
link = inspection_link,
|
link = inspection_link,
|
||||||
inspection_id = inspection.id,
|
inspection_id = inspection.id,
|
||||||
|
event_type = EVENT_INSPECTION_DONE,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
db.session.commit() # Commit notifications
|
db.session.commit() # Commit notifications
|
||||||
@@ -456,6 +458,7 @@ def flag_issue(inspection_id):
|
|||||||
),
|
),
|
||||||
link = url_for('issues.view', issue_id=issue.id),
|
link = url_for('issues.view', issue_id=issue.id),
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
event_type = EVENT_ISSUE_ASSIGNED,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
db.session.commit() # Commit notification
|
db.session.commit() # Commit notification
|
||||||
|
|||||||
+30
-45
@@ -6,6 +6,10 @@ from app import db
|
|||||||
from app.models.issue import Issue, IssueComment, IssueFollower
|
from app.models.issue import Issue, IssueComment, IssueFollower
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.notification import (
|
||||||
|
EVENT_ISSUE_ASSIGNED, EVENT_ISSUE_STATUS,
|
||||||
|
EVENT_ISSUE_COMMENT, EVENT_ISSUE_FOLLOW,
|
||||||
|
)
|
||||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
from app.utils.notifications import notify
|
from app.utils.notifications import notify
|
||||||
@@ -16,16 +20,8 @@ bp = Blueprint('issues', __name__, url_prefix='/issues')
|
|||||||
# ── Shared helper ─────────────────────────────────────────────────────────────
|
# ── Shared helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _notify_followers(issue, title, body, exclude_user_ids=None):
|
def _notify_followers(issue, title, body, exclude_user_ids=None):
|
||||||
"""Dispatch a notification to every follower of the given issue.
|
"""Dispatch a notification to every follower of the given issue."""
|
||||||
|
exclude = set(exclude_user_ids or [])
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
issue : Issue ORM instance
|
|
||||||
title : Notification headline
|
|
||||||
body : Notification body
|
|
||||||
exclude_user_ids : Set/list of user IDs to skip (e.g. the actor themselves)
|
|
||||||
"""
|
|
||||||
exclude = set(exclude_user_ids or [])
|
|
||||||
issue_link = url_for('issues.view', issue_id=issue.id)
|
issue_link = url_for('issues.view', issue_id=issue.id)
|
||||||
for follower in issue.followers.all():
|
for follower in issue.followers.all():
|
||||||
if follower.user_id in exclude:
|
if follower.user_id in exclude:
|
||||||
@@ -36,6 +32,7 @@ def _notify_followers(issue, title, body, exclude_user_ids=None):
|
|||||||
body = body,
|
body = body,
|
||||||
link = issue_link,
|
link = issue_link,
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
event_type = EVENT_ISSUE_FOLLOW,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,10 +43,8 @@ def _notify_followers(issue, title, body, exclude_user_ids=None):
|
|||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
|
q = Issue.query.order_by(Issue.reported_at.desc())
|
||||||
|
|
||||||
q = Issue.query.order_by(Issue.reported_at.desc())
|
|
||||||
|
|
||||||
# Inspectors only see issues assigned to them
|
|
||||||
if current_user.role == 'inspector':
|
if current_user.role == 'inspector':
|
||||||
q = q.filter(Issue.assigned_to == current_user.id)
|
q = q.filter(Issue.assigned_to == current_user.id)
|
||||||
|
|
||||||
@@ -61,7 +56,6 @@ def index():
|
|||||||
q = q.filter(Issue.status == status_filter)
|
q = q.filter(Issue.status == status_filter)
|
||||||
|
|
||||||
issues = q.paginate(page=page, per_page=25, error_out=False)
|
issues = q.paginate(page=page, per_page=25, error_out=False)
|
||||||
|
|
||||||
return render_template('issues/list.html',
|
return render_template('issues/list.html',
|
||||||
issues=issues,
|
issues=issues,
|
||||||
severity_filter=severity_filter,
|
severity_filter=severity_filter,
|
||||||
@@ -75,13 +69,11 @@ def index():
|
|||||||
def view(issue_id):
|
def view(issue_id):
|
||||||
issue = Issue.query.get_or_404(issue_id)
|
issue = Issue.query.get_or_404(issue_id)
|
||||||
|
|
||||||
# Access control: inspectors may only view/edit issues assigned to them
|
|
||||||
if current_user.role == 'inspector' and issue.assigned_to != current_user.id:
|
if current_user.role == 'inspector' and issue.assigned_to != current_user.id:
|
||||||
flash('Access denied. You can only view issues assigned to you.', 'danger')
|
flash('Access denied. You can only view issues assigned to you.', 'danger')
|
||||||
return redirect(url_for('issues.index'))
|
return redirect(url_for('issues.index'))
|
||||||
|
|
||||||
form = IssueUpdateForm(obj=issue)
|
form = IssueUpdateForm(obj=issue)
|
||||||
|
|
||||||
staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all()
|
staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all()
|
||||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
|
||||||
form.status.data = form.status.data or issue.status
|
form.status.data = form.status.data or issue.status
|
||||||
@@ -92,7 +84,6 @@ def view(issue_id):
|
|||||||
|
|
||||||
issue.status = form.status.data
|
issue.status = form.status.data
|
||||||
|
|
||||||
# Only admin/supervisor can reassign; inspectors can only update status
|
|
||||||
if current_user.role in ['admin', 'supervisor']:
|
if current_user.role in ['admin', 'supervisor']:
|
||||||
issue.assigned_to = form.assigned_to.data or None
|
issue.assigned_to = form.assigned_to.data or None
|
||||||
|
|
||||||
@@ -101,10 +92,8 @@ def view(issue_id):
|
|||||||
elif form.status.data != 'resolved':
|
elif form.status.data != 'resolved':
|
||||||
issue.resolved_at = None
|
issue.resolved_at = None
|
||||||
|
|
||||||
# Save result notes (overwrite with latest value)
|
|
||||||
issue.result_notes = form.result_notes.data or None
|
issue.result_notes = form.result_notes.data or None
|
||||||
|
|
||||||
# Append any newly uploaded result photos
|
|
||||||
from app.routes.inspections import _save_photo
|
from app.routes.inspections import _save_photo
|
||||||
new_photos = []
|
new_photos = []
|
||||||
for file_obj in request.files.getlist('result_photos'):
|
for file_obj in request.files.getlist('result_photos'):
|
||||||
@@ -115,7 +104,6 @@ def view(issue_id):
|
|||||||
existing = issue.result_photos or []
|
existing = issue.result_photos or []
|
||||||
issue.result_photos = existing + new_photos
|
issue.result_photos = existing + new_photos
|
||||||
|
|
||||||
# Persist a comment entry if the user wrote update notes
|
|
||||||
comment_body = form.update_notes.data.strip() if form.update_notes.data else ''
|
comment_body = form.update_notes.data.strip() if form.update_notes.data else ''
|
||||||
if comment_body:
|
if comment_body:
|
||||||
comment = IssueComment(
|
comment = IssueComment(
|
||||||
@@ -133,12 +121,11 @@ def view(issue_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── Notifications ────────────────────────────────────────────────
|
# ── Notifications ────────────────────────────────────────────────
|
||||||
issue_link = url_for('issues.view', issue_id=issue.id)
|
issue_link = url_for('issues.view', issue_id=issue.id)
|
||||||
new_assigned_to = issue.assigned_to
|
new_assigned_to = issue.assigned_to
|
||||||
# Always exclude the actor from receiving their own notifications
|
actor_id = current_user.id
|
||||||
actor_id = current_user.id
|
|
||||||
|
|
||||||
# 1. Notify the assignee when status changes
|
# 1. Status changed — notify assignee
|
||||||
if old_status != issue.status and new_assigned_to:
|
if old_status != issue.status and new_assigned_to:
|
||||||
assignee = User.query.get(new_assigned_to)
|
assignee = User.query.get(new_assigned_to)
|
||||||
if assignee and assignee.id != actor_id:
|
if assignee and assignee.id != actor_id:
|
||||||
@@ -153,10 +140,11 @@ def view(issue_id):
|
|||||||
),
|
),
|
||||||
link = issue_link,
|
link = issue_link,
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
event_type = EVENT_ISSUE_STATUS,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Notify newly assigned user when the assignee changes
|
# 2. Reassigned — notify new assignee
|
||||||
if (old_assigned_to != new_assigned_to) and new_assigned_to:
|
if (old_assigned_to != new_assigned_to) and new_assigned_to:
|
||||||
new_assignee = User.query.get(new_assigned_to)
|
new_assignee = User.query.get(new_assigned_to)
|
||||||
if new_assignee and new_assignee.id != actor_id:
|
if new_assignee and new_assignee.id != actor_id:
|
||||||
@@ -170,10 +158,11 @@ def view(issue_id):
|
|||||||
),
|
),
|
||||||
link = issue_link,
|
link = issue_link,
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
event_type = EVENT_ISSUE_ASSIGNED,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Notify the previously assigned user when unassigned
|
# 3. Unassigned — notify previous assignee
|
||||||
if old_assigned_to and old_assigned_to != new_assigned_to:
|
if old_assigned_to and old_assigned_to != new_assigned_to:
|
||||||
old_assignee = User.query.get(old_assigned_to)
|
old_assignee = User.query.get(old_assigned_to)
|
||||||
if old_assignee and old_assignee.id != actor_id:
|
if old_assignee and old_assignee.id != actor_id:
|
||||||
@@ -186,10 +175,11 @@ def view(issue_id):
|
|||||||
),
|
),
|
||||||
link = issue_link,
|
link = issue_link,
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
event_type = EVENT_ISSUE_ASSIGNED,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Notify the assignee when a comment is added (if not the commenter)
|
# 4. Comment added — notify assignee
|
||||||
if comment_body and new_assigned_to:
|
if comment_body and new_assigned_to:
|
||||||
commentee = User.query.get(new_assigned_to)
|
commentee = User.query.get(new_assigned_to)
|
||||||
if commentee and commentee.id != actor_id:
|
if commentee and commentee.id != actor_id:
|
||||||
@@ -202,11 +192,11 @@ def view(issue_id):
|
|||||||
),
|
),
|
||||||
link = issue_link,
|
link = issue_link,
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
event_type = EVENT_ISSUE_COMMENT,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Notify all followers of any update (status change, comment, or reassignment)
|
# 5. Notify followers — consolidated message, exclude actor + assignees
|
||||||
# Exclude the actor and the assignee (already notified above).
|
|
||||||
exclude_ids = {actor_id}
|
exclude_ids = {actor_id}
|
||||||
if new_assigned_to:
|
if new_assigned_to:
|
||||||
exclude_ids.add(new_assigned_to)
|
exclude_ids.add(new_assigned_to)
|
||||||
@@ -226,14 +216,13 @@ def view(issue_id):
|
|||||||
changes.append(f'new comment added by {current_user.username}')
|
changes.append(f'new comment added by {current_user.username}')
|
||||||
|
|
||||||
if changes:
|
if changes:
|
||||||
follower_body = (
|
|
||||||
f'Issue #{issue.id} in {issue.area.name} was updated by '
|
|
||||||
f'{current_user.username}: {"; ".join(changes)}.'
|
|
||||||
)
|
|
||||||
_notify_followers(
|
_notify_followers(
|
||||||
issue = issue,
|
issue = issue,
|
||||||
title = f'Issue #{issue.id} Updated',
|
title = f'Issue #{issue.id} Updated',
|
||||||
body = follower_body,
|
body = (
|
||||||
|
f'Issue #{issue.id} in {issue.area.name} was updated by '
|
||||||
|
f'{current_user.username}: {"; ".join(changes)}.'
|
||||||
|
),
|
||||||
exclude_user_ids = exclude_ids,
|
exclude_user_ids = exclude_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -242,7 +231,7 @@ def view(issue_id):
|
|||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||||
|
|
||||||
is_following = issue.is_followed_by(current_user)
|
is_following = issue.is_followed_by(current_user)
|
||||||
comments = issue.comments.order_by(IssueComment.created_at.asc()).all()
|
comments = issue.comments.order_by(IssueComment.created_at.asc()).all()
|
||||||
return render_template('issues/view.html',
|
return render_template('issues/view.html',
|
||||||
issue=issue,
|
issue=issue,
|
||||||
form=form,
|
form=form,
|
||||||
@@ -256,7 +245,6 @@ def view(issue_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def follow(issue_id):
|
def follow(issue_id):
|
||||||
issue = Issue.query.get_or_404(issue_id)
|
issue = Issue.query.get_or_404(issue_id)
|
||||||
|
|
||||||
if not issue.is_followed_by(current_user):
|
if not issue.is_followed_by(current_user):
|
||||||
follower = IssueFollower(issue_id=issue.id, user_id=current_user.id)
|
follower = IssueFollower(issue_id=issue.id, user_id=current_user.id)
|
||||||
db.session.add(follower)
|
db.session.add(follower)
|
||||||
@@ -268,7 +256,6 @@ def follow(issue_id):
|
|||||||
flash('You are now following this issue and will receive notifications for any updates.', 'success')
|
flash('You are now following this issue and will receive notifications for any updates.', 'success')
|
||||||
else:
|
else:
|
||||||
flash('You are already following this issue.', 'info')
|
flash('You are already following this issue.', 'info')
|
||||||
|
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -277,8 +264,7 @@ def follow(issue_id):
|
|||||||
@bp.route('/<int:issue_id>/unfollow', methods=['POST'])
|
@bp.route('/<int:issue_id>/unfollow', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def unfollow(issue_id):
|
def unfollow(issue_id):
|
||||||
issue = Issue.query.get_or_404(issue_id)
|
issue = Issue.query.get_or_404(issue_id)
|
||||||
|
|
||||||
follower = issue.followers.filter_by(user_id=current_user.id).first()
|
follower = issue.followers.filter_by(user_id=current_user.id).first()
|
||||||
if follower:
|
if follower:
|
||||||
db.session.delete(follower)
|
db.session.delete(follower)
|
||||||
@@ -290,11 +276,10 @@ def unfollow(issue_id):
|
|||||||
flash('You have unfollowed this issue.', 'info')
|
flash('You have unfollowed this issue.', 'info')
|
||||||
else:
|
else:
|
||||||
flash('You are not following this issue.', 'info')
|
flash('You are not following this issue.', 'info')
|
||||||
|
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||||
|
|
||||||
|
|
||||||
# ── Standalone create (not from an inspection) ────────────────────────────────
|
# ── Standalone create ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/new', methods=['GET', 'POST'])
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -326,7 +311,6 @@ def create():
|
|||||||
issue.id, issue.severity, issue.area_id, issue.assigned_to, current_user.username
|
issue.id, issue.severity, issue.area_id, issue.assigned_to, current_user.username
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify the assignee of the new issue
|
|
||||||
if issue.assigned_to:
|
if issue.assigned_to:
|
||||||
assignee = User.query.get(issue.assigned_to)
|
assignee = User.query.get(issue.assigned_to)
|
||||||
if assignee and assignee.id != current_user.id:
|
if assignee and assignee.id != current_user.id:
|
||||||
@@ -341,6 +325,7 @@ def create():
|
|||||||
),
|
),
|
||||||
link = url_for('issues.view', issue_id=issue.id),
|
link = url_for('issues.view', issue_id=issue.id),
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
event_type = EVENT_ISSUE_ASSIGNED,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
+130
-8
@@ -1,21 +1,24 @@
|
|||||||
# app/routes/notifications.py
|
# app/routes/notifications.py
|
||||||
import logging
|
import logging
|
||||||
from flask import Blueprint, jsonify, request, abort
|
from flask import (Blueprint, jsonify, request, abort,
|
||||||
|
render_template, redirect, url_for, flash, current_app)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.notification import Notification
|
from app.models.notification import (
|
||||||
|
Notification, NotificationPreference, ALL_EVENT_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
bp = Blueprint('notifications', __name__, url_prefix='/notifications')
|
bp = Blueprint('notifications', __name__, url_prefix='/notifications')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bell feed (navbar dropdown) ───────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/feed')
|
@bp.route('/feed')
|
||||||
@login_required
|
@login_required
|
||||||
def feed():
|
def feed():
|
||||||
"""Return the 20 most recent notifications for the current user as JSON.
|
"""Return the 20 most recent notifications for the current user as JSON."""
|
||||||
Used by the navbar bell icon to populate the dropdown.
|
|
||||||
"""
|
|
||||||
notifs = (
|
notifs = (
|
||||||
Notification.query
|
Notification.query
|
||||||
.filter_by(user_id=current_user.id)
|
.filter_by(user_id=current_user.id)
|
||||||
@@ -41,10 +44,42 @@ def feed():
|
|||||||
return jsonify({'notifications': items, 'unread_count': unread_count})
|
return jsonify({'notifications': items, 'unread_count': unread_count})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Full notification history page ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
"""Full paginated notification history with read/unread filter."""
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
filter_read = request.args.get('filter', 'all') # 'all' | 'unread' | 'read'
|
||||||
|
|
||||||
|
q = Notification.query.filter_by(user_id=current_user.id)
|
||||||
|
|
||||||
|
if filter_read == 'unread':
|
||||||
|
q = q.filter_by(is_read=False)
|
||||||
|
elif filter_read == 'read':
|
||||||
|
q = q.filter_by(is_read=True)
|
||||||
|
|
||||||
|
notifications = q.order_by(Notification.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=25, error_out=False
|
||||||
|
)
|
||||||
|
unread_count = Notification.query.filter_by(
|
||||||
|
user_id=current_user.id, is_read=False
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'notifications/index.html',
|
||||||
|
notifications=notifications,
|
||||||
|
filter_read=filter_read,
|
||||||
|
unread_count=unread_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mark single notification read ─────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/<int:notif_id>/mark-read', methods=['POST'])
|
@bp.route('/<int:notif_id>/mark-read', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def mark_read(notif_id):
|
def mark_read(notif_id):
|
||||||
"""Mark a single notification as read."""
|
|
||||||
notif = Notification.query.get_or_404(notif_id)
|
notif = Notification.query.get_or_404(notif_id)
|
||||||
if notif.user_id != current_user.id:
|
if notif.user_id != current_user.id:
|
||||||
abort(403)
|
abort(403)
|
||||||
@@ -57,10 +92,11 @@ def mark_read(notif_id):
|
|||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mark all read ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/mark-all-read', methods=['POST'])
|
@bp.route('/mark-all-read', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def mark_all_read():
|
def mark_all_read():
|
||||||
"""Mark all unread notifications for the current user as read."""
|
|
||||||
updated = (
|
updated = (
|
||||||
Notification.query
|
Notification.query
|
||||||
.filter_by(user_id=current_user.id, is_read=False)
|
.filter_by(user_id=current_user.id, is_read=False)
|
||||||
@@ -71,4 +107,90 @@ def mark_all_read():
|
|||||||
'NOTIFICATIONS ALL READ | user=%s | count=%s',
|
'NOTIFICATIONS ALL READ | user=%s | count=%s',
|
||||||
current_user.username, updated,
|
current_user.username, updated,
|
||||||
)
|
)
|
||||||
return jsonify({'ok': True, 'marked': updated})
|
|
||||||
|
# Support both AJAX (returns JSON) and form POST (redirects to index)
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or \
|
||||||
|
request.content_type == 'application/json':
|
||||||
|
return jsonify({'ok': True, 'marked': updated})
|
||||||
|
return redirect(url_for('notifications.index'))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Notification preferences ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/preferences', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def preferences():
|
||||||
|
"""Display and save per-event notification preferences."""
|
||||||
|
if request.method == 'POST':
|
||||||
|
for event_type in ALL_EVENT_TYPES:
|
||||||
|
pref = NotificationPreference.query.filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
event_type=event_type,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if pref is None:
|
||||||
|
pref = NotificationPreference(
|
||||||
|
user_id=current_user.id,
|
||||||
|
event_type=event_type,
|
||||||
|
)
|
||||||
|
db.session.add(pref)
|
||||||
|
|
||||||
|
pref.email_enabled = bool(request.form.get(f'email_{event_type}'))
|
||||||
|
pref.digest_mode = bool(request.form.get(f'digest_{event_type}'))
|
||||||
|
pref.digest_frequency = request.form.get(f'freq_{event_type}', 'daily')
|
||||||
|
|
||||||
|
# Guard: digest_mode only meaningful when email is enabled
|
||||||
|
if not pref.email_enabled:
|
||||||
|
pref.digest_mode = False
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(
|
||||||
|
'NOTIFICATION PREFERENCES SAVED | user=%s',
|
||||||
|
current_user.username,
|
||||||
|
)
|
||||||
|
flash('Notification preferences saved.', 'success')
|
||||||
|
return redirect(url_for('notifications.preferences'))
|
||||||
|
|
||||||
|
# Build a dict keyed by event_type for easy template access
|
||||||
|
prefs_map = {}
|
||||||
|
for pref in NotificationPreference.query.filter_by(user_id=current_user.id).all():
|
||||||
|
prefs_map[pref.event_type] = pref
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'notifications/preferences.html',
|
||||||
|
event_types=ALL_EVENT_TYPES,
|
||||||
|
prefs_map=prefs_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Digest trigger (called by cron) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/send-digest', methods=['POST'])
|
||||||
|
def send_digest():
|
||||||
|
"""Trigger digest email delivery. Protected by a shared secret token.
|
||||||
|
|
||||||
|
Called by a cron job, e.g.:
|
||||||
|
# Hourly digest
|
||||||
|
0 * * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \
|
||||||
|
-d "token=YOUR_DIGEST_SECRET&frequency=hourly"
|
||||||
|
|
||||||
|
# Daily digest at 07:00
|
||||||
|
0 7 * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \
|
||||||
|
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
|
||||||
|
"""
|
||||||
|
token = request.form.get('token') or request.args.get('token')
|
||||||
|
frequency = request.form.get('frequency', 'daily')
|
||||||
|
|
||||||
|
expected = current_app.config.get('DIGEST_SECRET')
|
||||||
|
if not expected or token != expected:
|
||||||
|
logger.warning('DIGEST TRIGGER REJECTED | bad or missing token')
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if frequency not in ('hourly', 'daily'):
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
from app.utils.notifications import send_pending_digests
|
||||||
|
sent = send_pending_digests(frequency=frequency)
|
||||||
|
|
||||||
|
logger.info('DIGEST TRIGGERED | frequency=%s | sent=%s', frequency, sent)
|
||||||
|
return jsonify({'ok': True, 'sent': sent, 'frequency': frequency})
|
||||||
+71
-75
@@ -14,58 +14,35 @@
|
|||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
<style>
|
<style>
|
||||||
/* ── Notification bell styles ── */
|
/* ── Notification bell styles ── */
|
||||||
.notif-bell-wrapper {
|
.notif-bell-wrapper { position: relative; }
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.notif-badge {
|
.notif-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px; right: 2px;
|
||||||
right: 2px;
|
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
min-width: 16px;
|
min-width: 16px; height: 16px; line-height: 16px;
|
||||||
height: 16px;
|
padding: 0 4px; border-radius: 8px;
|
||||||
line-height: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.notif-dropdown {
|
.notif-dropdown {
|
||||||
width: 360px;
|
width: 380px;
|
||||||
max-height: 480px;
|
max-height: 520px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.notif-item {
|
.notif-item {
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.notif-item.unread {
|
.notif-item.unread {
|
||||||
border-left-color: #0d6efd;
|
border-left-color: #0d6efd;
|
||||||
background-color: #f0f6ff;
|
background-color: #f0f6ff;
|
||||||
}
|
}
|
||||||
.notif-item:hover {
|
.notif-item:hover { background-color: #e8f0fe; }
|
||||||
background-color: #e8f0fe;
|
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
||||||
}
|
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
||||||
.notif-title {
|
.notif-time { font-size: 0.7rem; color: #999; }
|
||||||
font-size: 0.85rem;
|
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.notif-body {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: #555;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
.notif-time {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
.notif-empty {
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -124,30 +101,56 @@
|
|||||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow" id="notif-dropdown-menu">
|
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
id="notif-dropdown-menu">
|
||||||
<span class="fw-semibold" style="font-size:0.9rem;">Notifications</span>
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center
|
||||||
|
px-3 py-2 border-bottom">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
||||||
id="mark-all-read-btn"
|
id="mark-all-read-btn" style="font-size:.75rem;">
|
||||||
style="font-size:0.75rem;">
|
|
||||||
Mark all as read
|
Mark all as read
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Items -->
|
||||||
<div id="notif-list">
|
<div id="notif-list">
|
||||||
<div class="notif-empty">Loading…</div>
|
<div class="notif-empty">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="border-top d-flex justify-content-between px-3 py-2"
|
||||||
|
style="font-size:.8rem;">
|
||||||
|
<a href="{{ url_for('notifications.index') }}"
|
||||||
|
class="text-decoration-none">
|
||||||
|
<i class="bi bi-list-ul me-1"></i>View all
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('notifications.preferences') }}"
|
||||||
|
class="text-decoration-none text-muted">
|
||||||
|
<i class="bi bi-gear me-1"></i>Preferences
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<!-- ── End Notification Bell ── -->
|
<!-- ── End Notification Bell ── -->
|
||||||
|
|
||||||
|
<!-- User menu -->
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
|
||||||
|
role="button" data-bs-toggle="dropdown">
|
||||||
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li><a class="dropdown-item" href="#">Profile</a></li>
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('notifications.preferences') }}">
|
||||||
|
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -179,14 +182,14 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
||||||
const MARK_READ_BASE = '/notifications/';
|
const MARK_READ_BASE = '/notifications/';
|
||||||
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
||||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||||
const POLL_INTERVAL = 60000; // 60 seconds
|
const POLL_INTERVAL = 60000; // 60 seconds
|
||||||
|
|
||||||
const badge = document.getElementById('notif-count-badge');
|
const badge = document.getElementById('notif-count-badge');
|
||||||
const listEl = document.getElementById('notif-list');
|
const listEl = document.getElementById('notif-list');
|
||||||
const markAllBtn = document.getElementById('mark-all-read-btn');
|
const markAllBtn = document.getElementById('mark-all-read-btn');
|
||||||
|
|
||||||
function updateBadge(count) {
|
function updateBadge(count) {
|
||||||
@@ -201,27 +204,26 @@
|
|||||||
|
|
||||||
function renderNotifications(notifications) {
|
function renderNotifications(notifications) {
|
||||||
if (!notifications.length) {
|
if (!notifications.length) {
|
||||||
listEl.innerHTML = '<div class="notif-empty"><i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
listEl.innerHTML = '<div class="notif-empty">'
|
||||||
|
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
listEl.innerHTML = notifications.map(n => `
|
listEl.innerHTML = notifications.map(n => `
|
||||||
<a href="${n.link || '#'}"
|
<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom
|
||||||
class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom ${n.is_read ? '' : 'unread'}"
|
${n.is_read ? '' : 'unread'}"
|
||||||
data-notif-id="${n.id}"
|
data-notif-id="${n.id}"
|
||||||
data-link="${n.link || ''}">
|
data-link="${escapeAttr(n.link || '')}">
|
||||||
<div class="notif-title">${escapeHtml(n.title)}</div>
|
<div class="notif-title">${escapeHtml(n.title)}</div>
|
||||||
<div class="notif-body">${escapeHtml(n.body)}</div>
|
<div class="notif-body">${escapeHtml(n.body)}</div>
|
||||||
<div class="notif-time">${escapeHtml(n.created_at)}</div>
|
<div class="notif-time">${escapeHtml(n.created_at)}</div>
|
||||||
</a>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// Mark as read on click
|
|
||||||
listEl.querySelectorAll('.notif-item').forEach(el => {
|
listEl.querySelectorAll('.notif-item').forEach(el => {
|
||||||
el.addEventListener('click', function (e) {
|
el.addEventListener('click', function () {
|
||||||
const id = this.dataset.notifId;
|
const id = this.dataset.notifId;
|
||||||
const link = this.dataset.link;
|
const link = this.dataset.link;
|
||||||
e.preventDefault();
|
|
||||||
markRead(id, () => {
|
markRead(id, () => {
|
||||||
this.classList.remove('unread');
|
this.classList.remove('unread');
|
||||||
if (link) window.location.href = link;
|
if (link) window.location.href = link;
|
||||||
@@ -232,30 +234,28 @@
|
|||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return str.replace(/&/g, '&')
|
return str.replace(/&/g,'&').replace(/</g,'<')
|
||||||
.replace(/</g, '<')
|
.replace(/>/g,'>').replace(/"/g,'"');
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
}
|
||||||
|
function escapeAttr(str) { return escapeHtml(str); }
|
||||||
|
|
||||||
function fetchNotifications() {
|
function fetchNotifications() {
|
||||||
fetch(FEED_URL, { credentials: 'same-origin' })
|
fetch(FEED_URL, { credentials: 'same-origin' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
updateBadge(data.unread_count);
|
updateBadge(data.unread_count);
|
||||||
// Only re-render if dropdown is open to avoid disrupting user
|
|
||||||
const dropdownEl = document.getElementById('notifDropdown');
|
|
||||||
const isOpen = dropdownEl.getAttribute('aria-expanded') === 'true';
|
|
||||||
if (isOpen) renderNotifications(data.notifications);
|
|
||||||
// Store for rendering when opened
|
|
||||||
window._jqcNotifications = data.notifications;
|
window._jqcNotifications = data.notifications;
|
||||||
|
const dropdownEl = document.getElementById('notifDropdown');
|
||||||
|
if (dropdownEl.getAttribute('aria-expanded') === 'true') {
|
||||||
|
renderNotifications(data.notifications);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {}); // Silently fail — non-critical
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function markRead(id, callback) {
|
function markRead(id, callback) {
|
||||||
fetch(`${MARK_READ_BASE}${id}/mark-read`, {
|
fetch(`${MARK_READ_BASE}${id}/mark-read`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
|
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
})
|
})
|
||||||
@@ -264,7 +264,6 @@
|
|||||||
.catch(() => { if (callback) callback(); });
|
.catch(() => { if (callback) callback(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render stored notifications when dropdown opens
|
|
||||||
document.getElementById('notifDropdown').addEventListener('show.bs.dropdown', function () {
|
document.getElementById('notifDropdown').addEventListener('show.bs.dropdown', function () {
|
||||||
if (window._jqcNotifications) {
|
if (window._jqcNotifications) {
|
||||||
renderNotifications(window._jqcNotifications);
|
renderNotifications(window._jqcNotifications);
|
||||||
@@ -273,18 +272,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark all read button
|
|
||||||
markAllBtn.addEventListener('click', function (e) {
|
markAllBtn.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
fetch(MARK_ALL_URL, {
|
fetch(MARK_ALL_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': CSRF_TOKEN },
|
headers: { 'X-CSRFToken': CSRF_TOKEN },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
updateBadge(0);
|
updateBadge(0);
|
||||||
// Mark all items visually as read
|
|
||||||
listEl.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
|
listEl.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
|
||||||
if (window._jqcNotifications) {
|
if (window._jqcNotifications) {
|
||||||
window._jqcNotifications.forEach(n => n.is_read = true);
|
window._jqcNotifications.forEach(n => n.is_read = true);
|
||||||
@@ -293,7 +290,6 @@
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial fetch + periodic polling
|
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
setInterval(fetchNotifications, POLL_INTERVAL);
|
setInterval(fetchNotifications, POLL_INTERVAL);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 %}
|
||||||
+274
-44
@@ -2,17 +2,43 @@
|
|||||||
app/utils/notifications.py
|
app/utils/notifications.py
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
Central helper for creating in-app notifications and dispatching email alerts.
|
Central helper for creating in-app notifications and dispatching email alerts.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
from app.utils.notifications import notify
|
||||||
|
|
||||||
|
notify(
|
||||||
|
recipient = some_user,
|
||||||
|
title = 'Issue #12 Updated',
|
||||||
|
body = 'Status changed to In Progress by admin.',
|
||||||
|
link = url_for('issues.view', issue_id=12),
|
||||||
|
issue_id = 12,
|
||||||
|
event_type = EVENT_ISSUE_STATUS, # controls preference lookup
|
||||||
|
send_email = True,
|
||||||
|
)
|
||||||
|
|
||||||
|
Email delivery is best-effort: a failure to send will be logged but will NOT
|
||||||
|
raise an exception or roll back the DB transaction.
|
||||||
|
|
||||||
|
Digest emails are sent by calling send_pending_digests(frequency) from the
|
||||||
|
/notifications/send-digest route, which is triggered by a server cron job.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from flask import current_app, render_template_string
|
from flask import current_app, render_template_string
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from app import db, mail
|
from app import db, mail
|
||||||
from app.models.notification import Notification
|
from app.models.notification import (
|
||||||
|
Notification, NotificationPreference,
|
||||||
|
ALL_EVENT_TYPES,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_EMAIL_HTML = """\
|
|
||||||
|
# ── Email templates ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_EMAIL_HTML_SINGLE = """\
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||||
@@ -29,13 +55,16 @@ _EMAIL_HTML = """\
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||||
<p style="font-size:12px;color:#888;">
|
<p style="font-size:12px;color:#888;">
|
||||||
Janitorial QC System — automated notification. Do not reply to this email.
|
Janitorial QC System — automated notification. Do not reply to this email.<br>
|
||||||
|
<a href="{{ base_url }}/notifications/preferences" style="color:#888;">
|
||||||
|
Manage notification preferences
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_EMAIL_TEXT = """\
|
_EMAIL_TEXT_SINGLE = """\
|
||||||
{{ title }}
|
{{ title }}
|
||||||
|
|
||||||
{{ body }}
|
{{ body }}
|
||||||
@@ -45,8 +74,93 @@ View: {{ base_url }}{{ link }}
|
|||||||
|
|
||||||
--
|
--
|
||||||
Janitorial QC System — automated notification.
|
Janitorial QC System — automated notification.
|
||||||
|
Manage preferences: {{ base_url }}/notifications/preferences
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_EMAIL_HTML_DIGEST = """\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||||
|
<h2 style="color:#0d6efd;">Your {{ frequency|title }} JQC Notification Digest</h2>
|
||||||
|
<p>You have <strong>{{ notifications|length }}</strong> new notification(s):</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #eee;">
|
||||||
|
{% for n in notifications %}
|
||||||
|
<div style="margin-bottom:20px;padding:12px;background:#f8f9fa;border-radius:6px;
|
||||||
|
border-left:4px solid #0d6efd;">
|
||||||
|
<p style="margin:0 0 4px;font-weight:bold;">{{ n.title }}</p>
|
||||||
|
<p style="margin:0 0 8px;font-size:.9em;color:#555;">{{ n.body }}</p>
|
||||||
|
{% if n.link %}
|
||||||
|
<a href="{{ base_url }}{{ n.link }}"
|
||||||
|
style="font-size:.85em;color:#0d6efd;text-decoration:none;">
|
||||||
|
View Details →
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<p style="margin:6px 0 0;font-size:.75em;color:#999;">
|
||||||
|
{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||||
|
<p style="font-size:12px;color:#888;">
|
||||||
|
Janitorial QC System — automated digest. Do not reply to this email.<br>
|
||||||
|
<a href="{{ base_url }}/notifications/preferences" style="color:#888;">
|
||||||
|
Manage notification preferences
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_EMAIL_TEXT_DIGEST = """\
|
||||||
|
Your {{ frequency|title }} JQC Notification Digest
|
||||||
|
{{ notifications|length }} new notification(s):
|
||||||
|
|
||||||
|
{% for n in notifications %}
|
||||||
|
---
|
||||||
|
{{ n.title }}
|
||||||
|
{{ n.body }}
|
||||||
|
{% if n.link %}View: {{ base_url }}{{ n.link }}{% endif %}
|
||||||
|
{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
--
|
||||||
|
Janitorial QC System — automated digest.
|
||||||
|
Manage preferences: {{ base_url }}/notifications/preferences
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Preference helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_preference(user_id, event_type):
|
||||||
|
"""Return the NotificationPreference for a user+event, or None if not set."""
|
||||||
|
if not event_type:
|
||||||
|
return None
|
||||||
|
return NotificationPreference.query.filter_by(
|
||||||
|
user_id=user_id, event_type=event_type
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _email_enabled_for(user, event_type):
|
||||||
|
"""Return True if the user wants an immediate email for this event type."""
|
||||||
|
pref = _get_preference(user.id, event_type)
|
||||||
|
if pref is None:
|
||||||
|
return True # Default: email on, immediate
|
||||||
|
if not pref.email_enabled:
|
||||||
|
return False # User opted out of email entirely for this event
|
||||||
|
if pref.digest_mode:
|
||||||
|
return False # User prefers digest — suppress immediate email
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _digest_mode_for(user, event_type):
|
||||||
|
"""Return True if this notification should be held for digest delivery."""
|
||||||
|
pref = _get_preference(user.id, event_type)
|
||||||
|
if pref is None:
|
||||||
|
return False
|
||||||
|
return pref.email_enabled and pref.digest_mode
|
||||||
|
|
||||||
|
|
||||||
|
# ── Core notify function ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def notify(
|
def notify(
|
||||||
recipient,
|
recipient,
|
||||||
@@ -55,66 +169,182 @@ def notify(
|
|||||||
link: str = None,
|
link: str = None,
|
||||||
issue_id: int = None,
|
issue_id: int = None,
|
||||||
inspection_id: int = None,
|
inspection_id: int = None,
|
||||||
|
event_type: str = None,
|
||||||
send_email: bool = True,
|
send_email: bool = True,
|
||||||
):
|
):
|
||||||
"""Create an in-app Notification record and optionally send an email."""
|
"""Create an in-app Notification record and optionally send an email.
|
||||||
# ── 1. Persist in-app notification ─────────────────────────────────────
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
recipient : User ORM instance
|
||||||
|
title : Short notification headline
|
||||||
|
body : Full notification message
|
||||||
|
link : Relative URL for the 'View Details' button/link
|
||||||
|
issue_id : FK to issues.id (optional)
|
||||||
|
inspection_id: FK to inspections.id (optional)
|
||||||
|
event_type : One of the EVENT_* constants from models.notification
|
||||||
|
Used to look up the user's preference for this event.
|
||||||
|
send_email : Master switch — set False to suppress all email (overrides prefs)
|
||||||
|
"""
|
||||||
|
# Determine digest flag before creating the record
|
||||||
|
hold_for_digest = send_email and bool(event_type) and _digest_mode_for(recipient, event_type)
|
||||||
|
|
||||||
|
# ── 1. Persist in-app notification ──────────────────────────────────────
|
||||||
notif = Notification(
|
notif = Notification(
|
||||||
user_id = recipient.id,
|
user_id = recipient.id,
|
||||||
title = title,
|
title = title,
|
||||||
body = body,
|
body = body,
|
||||||
link = link,
|
link = link,
|
||||||
issue_id = issue_id,
|
issue_id = issue_id,
|
||||||
inspection_id = inspection_id,
|
inspection_id = inspection_id,
|
||||||
is_read = False,
|
is_read = False,
|
||||||
|
digest_pending = hold_for_digest,
|
||||||
)
|
)
|
||||||
db.session.add(notif)
|
db.session.add(notif)
|
||||||
# NOTE: The caller is responsible for calling db.session.commit().
|
# NOTE: Caller is responsible for db.session.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'NOTIFICATION CREATED | user=%s | title=%s | issue_id=%s | inspection_id=%s',
|
'NOTIFICATION CREATED | user=%s | event=%s | title=%s | digest=%s',
|
||||||
recipient.username, title, issue_id, inspection_id,
|
recipient.username, event_type, title, hold_for_digest,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── 2. Send email (best-effort) ─────────────────────────────────────────
|
# ── 2. Send immediate email if applicable ────────────────────────────────
|
||||||
if send_email and recipient.email and current_app.config.get('MAIL_SERVER'):
|
if send_email and not hold_for_digest:
|
||||||
|
should_send = (
|
||||||
|
event_type is None or _email_enabled_for(recipient, event_type)
|
||||||
|
)
|
||||||
|
if should_send and recipient.email and current_app.config.get('MAIL_SERVER'):
|
||||||
|
_send_single_email(recipient, title, body, link)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_single_email(recipient, title, body, link):
|
||||||
|
"""Dispatch a single immediate notification email. Fire-and-forget."""
|
||||||
|
try:
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||||
|
sender = current_app.config.get(
|
||||||
|
'MAIL_DEFAULT_SENDER',
|
||||||
|
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
|
||||||
|
)
|
||||||
|
html_body = render_template_string(
|
||||||
|
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||||
|
)
|
||||||
|
text_body = render_template_string(
|
||||||
|
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||||
|
)
|
||||||
|
msg = Message(
|
||||||
|
subject = f'[JQC] {title}',
|
||||||
|
sender = sender,
|
||||||
|
recipients = [recipient.email],
|
||||||
|
body = text_body,
|
||||||
|
html = html_body,
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
logger.info(
|
||||||
|
'NOTIFICATION EMAIL SENT | to=%s | subject=%s',
|
||||||
|
recipient.email, msg.subject,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
'NOTIFICATION EMAIL FAILED | to=%s | error=%s',
|
||||||
|
recipient.email, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Digest delivery ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_pending_digests(frequency: str = 'daily'):
|
||||||
|
"""Send digest emails for all users who have pending digest notifications.
|
||||||
|
|
||||||
|
Called from the /notifications/send-digest route, which is hit by cron.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
frequency : 'hourly' or 'daily' — matches digest_frequency in preferences
|
||||||
|
"""
|
||||||
|
if not current_app.config.get('MAIL_SERVER'):
|
||||||
|
logger.warning('DIGEST SKIPPED | MAIL_SERVER not configured')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Find all users with pending digest notifications
|
||||||
|
from app.models.user import User
|
||||||
|
pending_user_ids = (
|
||||||
|
db.session.query(Notification.user_id)
|
||||||
|
.filter_by(digest_pending=True)
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
pending_user_ids = [row[0] for row in pending_user_ids]
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for user_id in pending_user_ids:
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if not user or not user.email:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Collect only the notifications that match this frequency for this user
|
||||||
|
# A notification is included in a frequency's digest if at least one of
|
||||||
|
# the user's digest preferences matches that frequency.
|
||||||
|
# Simple approach: include all pending if user has any pref with this frequency.
|
||||||
|
has_freq_pref = NotificationPreference.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
digest_mode=True,
|
||||||
|
digest_frequency=frequency,
|
||||||
|
email_enabled=True,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not has_freq_pref:
|
||||||
|
continue
|
||||||
|
|
||||||
|
notifications = Notification.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
digest_pending=True,
|
||||||
|
).order_by(Notification.created_at.asc()).all()
|
||||||
|
|
||||||
|
if not notifications:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||||
|
sender = current_app.config.get(
|
||||||
html_body = render_template_string(
|
|
||||||
_EMAIL_HTML,
|
|
||||||
title=title,
|
|
||||||
body=body,
|
|
||||||
link=link,
|
|
||||||
base_url=base_url,
|
|
||||||
)
|
|
||||||
text_body = render_template_string(
|
|
||||||
_EMAIL_TEXT,
|
|
||||||
title=title,
|
|
||||||
body=body,
|
|
||||||
link=link,
|
|
||||||
base_url=base_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
sender = current_app.config.get(
|
|
||||||
'MAIL_DEFAULT_SENDER',
|
'MAIL_DEFAULT_SENDER',
|
||||||
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
|
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
|
||||||
)
|
)
|
||||||
|
html_body = render_template_string(
|
||||||
|
_EMAIL_HTML_DIGEST,
|
||||||
|
notifications=notifications,
|
||||||
|
frequency=frequency,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
text_body = render_template_string(
|
||||||
|
_EMAIL_TEXT_DIGEST,
|
||||||
|
notifications=notifications,
|
||||||
|
frequency=frequency,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
msg = Message(
|
msg = Message(
|
||||||
subject = f'[JQC] {title}',
|
subject = f'[JQC] Your {frequency.title()} Notification Digest '
|
||||||
|
f'({len(notifications)} update{"s" if len(notifications) != 1 else ""})',
|
||||||
sender = sender,
|
sender = sender,
|
||||||
recipients = [recipient.email],
|
recipients = [user.email],
|
||||||
body = text_body,
|
body = text_body,
|
||||||
html = html_body,
|
html = html_body,
|
||||||
)
|
)
|
||||||
mail.send(msg)
|
mail.send(msg)
|
||||||
|
|
||||||
|
# Clear the pending flag on all notifications just sent
|
||||||
|
for n in notifications:
|
||||||
|
n.digest_pending = False
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
sent_count += 1
|
||||||
logger.info(
|
logger.info(
|
||||||
'NOTIFICATION EMAIL SENT | to=%s | subject=%s',
|
'DIGEST EMAIL SENT | to=%s | frequency=%s | count=%s',
|
||||||
recipient.email, msg.subject,
|
user.email, frequency, len(notifications),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
'NOTIFICATION EMAIL FAILED | to=%s | error=%s',
|
'DIGEST EMAIL FAILED | to=%s | frequency=%s | error=%s',
|
||||||
recipient.email, exc,
|
user.email, frequency, exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return sent_count
|
||||||
@@ -44,16 +44,18 @@ class Config:
|
|||||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||||
|
|
||||||
# ── Mail ────────────────────────────────────────────────────────────────
|
# ── Mail ────────────────────────────────────────────────────────────────
|
||||||
MAIL_SERVER = os.environ.get('MAIL_SERVER')
|
# ── Application base URL (used in email links) ─────────────────────────
|
||||||
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
|
APP_BASE_URL = os.environ.get('APP_BASE_URL', '')
|
||||||
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ('true', 'on', '1')
|
|
||||||
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
|
||||||
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
|
||||||
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local')
|
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local')
|
||||||
|
|
||||||
# ── Application base URL (used in email "View Details" links) ───────────
|
# ── Digest email secret token (used to authenticate cron trigger) ────────
|
||||||
# Set this to your production domain, e.g. https://qc.yourcompany.com
|
DIGEST_SECRET = os.environ.get('DIGEST_SECRET')
|
||||||
APP_BASE_URL = os.environ.get('APP_BASE_URL', '')
|
|
||||||
|
MAIL_SERVER = os.environ.get('MAIL_SERVER')
|
||||||
|
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
|
||||||
|
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ('true', 'on', '1')
|
||||||
|
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
||||||
|
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
|
|||||||
Reference in New Issue
Block a user