From 30b0b8a8070b26a48610270131d258a84aab436d Mon Sep 17 00:00:00 2001 From: NguyenND Date: Sun, 1 Mar 2026 21:04:22 -0500 Subject: [PATCH] Feb 02 2026: implement issue following function --- app/models/issue.py | 29 ++++++ app/routes/issues.py | 182 ++++++++++++++++++++++++++------- app/templates/issues/view.html | 32 ++++++ 3 files changed, 207 insertions(+), 36 deletions(-) diff --git a/app/models/issue.py b/app/models/issue.py index 6714322..d2eb94a 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -19,6 +19,29 @@ class IssueComment(db.Model): return f'' +# ── Issue Follower ───────────────────────────────────────────────────────────── +# Association table linking users who opt in to receive notifications +# for any updates on a specific issue. + +class IssueFollower(db.Model): + __tablename__ = 'issue_followers' + + id = db.Column(db.Integer, primary_key=True) + issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + __table_args__ = ( + db.UniqueConstraint('issue_id', 'user_id', name='uq_issue_follower'), + ) + + user = db.relationship('User', foreign_keys=[user_id]) + issue = db.relationship('Issue', foreign_keys=[issue_id], back_populates='followers') + + def __repr__(self): + return f'' + + class Issue(db.Model): __tablename__ = 'issues' @@ -40,6 +63,12 @@ class Issue(db.Model): comments = db.relationship('IssueComment', backref='issue', lazy='dynamic', order_by='IssueComment.created_at', cascade='all, delete-orphan') + followers = db.relationship('IssueFollower', back_populates='issue', + cascade='all, delete-orphan', lazy='dynamic') + + def is_followed_by(self, user): + """Return True if the given user is currently following this issue.""" + return self.followers.filter_by(user_id=user.id).first() is not None def __repr__(self): return f'' \ No newline at end of file diff --git a/app/routes/issues.py b/app/routes/issues.py index 32dcb2c..f99666f 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -1,9 +1,9 @@ from app.utils.time_utils import now_eastern from flask import (Blueprint, render_template, redirect, url_for, - flash, request, current_app) + flash, request, current_app, jsonify) from flask_login import login_required, current_user from app import db -from app.models.issue import Issue, IssueComment +from app.models.issue import Issue, IssueComment, IssueFollower from app.models.facility import Facility, Area from app.models.user import User from app.utils.forms import IssueForm, IssueUpdateForm @@ -13,6 +13,33 @@ from app.utils.notifications import notify bp = Blueprint('issues', __name__, url_prefix='/issues') +# ── Shared helper ───────────────────────────────────────────────────────────── + +def _notify_followers(issue, title, body, exclude_user_ids=None): + """Dispatch a notification to every follower of the given issue. + + Parameters + ---------- + issue : Issue ORM instance + title : Notification headline + body : Notification body + exclude_user_ids : Set/list of user IDs to skip (e.g. the actor themselves) + """ + exclude = set(exclude_user_ids or []) + issue_link = url_for('issues.view', issue_id=issue.id) + for follower in issue.followers.all(): + if follower.user_id in exclude: + continue + notify( + recipient = follower.user, + title = title, + body = body, + link = issue_link, + issue_id = issue.id, + send_email = True, + ) + + # ── List ────────────────────────────────────────────────────────────────────── @bp.route('/') @@ -106,82 +133,165 @@ def view(issue_id): ) # ── Notifications ──────────────────────────────────────────────── - issue_link = url_for('issues.view', issue_id=issue.id) - new_assigned_to = issue.assigned_to + 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 if old_status != issue.status and new_assigned_to: assignee = User.query.get(new_assigned_to) - if assignee and assignee.id != current_user.id: + if assignee and assignee.id != actor_id: notify( - recipient = assignee, - title = f'Issue #{issue.id} Status Updated', - body = ( + recipient = assignee, + title = f'Issue #{issue.id} Status Updated', + body = ( f'Issue in {issue.area.name} was updated from ' f'"{old_status.replace("_", " ").title()}" to ' f'"{issue.status.replace("_", " ").title()}" ' f'by {current_user.username}.' ), - link = issue_link, - issue_id = issue.id, - send_email = True, + link = issue_link, + issue_id = issue.id, + send_email = True, ) # 2. Notify newly assigned user when the assignee changes 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 != current_user.id: + if new_assignee and new_assignee.id != actor_id: notify( - recipient = new_assignee, - title = f'Issue #{issue.id} Assigned to You', - body = ( + recipient = new_assignee, + title = f'Issue #{issue.id} Assigned to You', + body = ( f'You have been assigned Issue #{issue.id} ' f'({issue.severity.title()} severity) in {issue.area.name}. ' f'Current status: {issue.status.replace("_", " ").title()}.' ), - link = issue_link, - issue_id = issue.id, - send_email = True, + link = issue_link, + issue_id = issue.id, + send_email = True, ) # 3. Notify the previously assigned user when unassigned 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 != current_user.id: + if old_assignee and old_assignee.id != actor_id: notify( - recipient = old_assignee, - title = f'Issue #{issue.id} Unassigned', - body = ( + recipient = old_assignee, + title = f'Issue #{issue.id} Unassigned', + body = ( f'You have been removed from Issue #{issue.id} ' f'in {issue.area.name} by {current_user.username}.' ), - link = issue_link, - issue_id = issue.id, - send_email = True, + link = issue_link, + issue_id = issue.id, + send_email = True, ) # 4. Notify the assignee when a comment is added (if not the commenter) if comment_body and new_assigned_to: commentee = User.query.get(new_assigned_to) - if commentee and commentee.id != current_user.id: + if commentee and commentee.id != actor_id: notify( - recipient = commentee, - title = f'New Comment on Issue #{issue.id}', - body = ( + recipient = commentee, + title = f'New Comment on Issue #{issue.id}', + body = ( f'{current_user.username} added a comment on Issue #{issue.id}: ' f'"{comment_body[:120]}{"…" if len(comment_body) > 120 else ""}"' ), - link = issue_link, - issue_id = issue.id, - send_email = True, + link = issue_link, + issue_id = issue.id, + send_email = True, ) - db.session.commit() # Commit notifications + # 5. Notify all followers of any update (status change, comment, or reassignment) + # Exclude the actor and the assignee (already notified above). + exclude_ids = {actor_id} + if new_assigned_to: + exclude_ids.add(new_assigned_to) + if old_assigned_to: + exclude_ids.add(old_assigned_to) + + changes = [] + if old_status != issue.status: + changes.append( + f'status changed from "{old_status.replace("_"," ").title()}" ' + f'to "{issue.status.replace("_"," ").title()}"' + ) + if old_assigned_to != new_assigned_to: + new_name = User.query.get(new_assigned_to).username if new_assigned_to else 'Unassigned' + changes.append(f'reassigned to {new_name}') + if comment_body: + 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, + exclude_user_ids = exclude_ids, + ) + + db.session.commit() # Commit all notifications flash('Issue updated.', 'success') return redirect(url_for('issues.view', issue_id=issue_id)) + is_following = issue.is_followed_by(current_user) comments = issue.comments.order_by(IssueComment.created_at.asc()).all() - return render_template('issues/view.html', issue=issue, form=form, comments=comments) + return render_template('issues/view.html', + issue=issue, + form=form, + comments=comments, + is_following=is_following) + + +# ── Follow ──────────────────────────────────────────────────────────────────── + +@bp.route('//follow', methods=['POST']) +@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) + db.session.commit() + current_app.logger.info( + 'ISSUE FOLLOW | issue_id=%s | user=%s', + issue.id, current_user.username, + ) + 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)) + + +# ── Unfollow ────────────────────────────────────────────────────────────────── + +@bp.route('//unfollow', methods=['POST']) +@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) + db.session.commit() + current_app.logger.info( + 'ISSUE UNFOLLOW | issue_id=%s | user=%s', + issue.id, current_user.username, + ) + 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) ──────────────────────────────── @@ -216,7 +326,7 @@ def create(): issue.id, issue.severity, issue.area_id, issue.assigned_to, current_user.username ) - # ── Notify the assignee of the new issue ──────────────────────── + # 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: @@ -233,7 +343,7 @@ def create(): issue_id = issue.id, send_email = True, ) - db.session.commit() # Commit notification + db.session.commit() flash('Issue created.', 'success') return redirect(url_for('issues.index')) diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html index 8a9d8fb..f941c6f 100644 --- a/app/templates/issues/view.html +++ b/app/templates/issues/view.html @@ -100,6 +100,38 @@
+ + {# ── Follow / Unfollow ──────────────────────────────────────────────── #} +
+
+
+ + + {% if is_following %}Following{% else %}Not following{% endif %} + + + {{ issue.followers.count() }} follower{{ 's' if issue.followers.count() != 1 else '' }} + +
+ {% if is_following %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ + {# ── Update Form ────────────────────────────────────────────────────── #} {% set can_edit = current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %} {% if can_edit %}