Feb 02 2026: implement issue following function

This commit is contained in:
2026-03-01 21:04:22 -05:00
parent 41d095c57c
commit 30b0b8a807
3 changed files with 207 additions and 36 deletions
+29
View File
@@ -19,6 +19,29 @@ class IssueComment(db.Model):
return f'<IssueComment {self.id} issue={self.issue_id}>' return f'<IssueComment {self.id} issue={self.issue_id}>'
# ── 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'<IssueFollower issue={self.issue_id} user={self.user_id}>'
class Issue(db.Model): class Issue(db.Model):
__tablename__ = 'issues' __tablename__ = 'issues'
@@ -40,6 +63,12 @@ class Issue(db.Model):
comments = db.relationship('IssueComment', backref='issue', lazy='dynamic', comments = db.relationship('IssueComment', backref='issue', lazy='dynamic',
order_by='IssueComment.created_at', order_by='IssueComment.created_at',
cascade='all, delete-orphan') 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): def __repr__(self):
return f'<Issue {self.id} - {self.severity}>' return f'<Issue {self.id} - {self.severity}>'
+120 -10
View File
@@ -1,9 +1,9 @@
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from flask import (Blueprint, render_template, redirect, url_for, 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 flask_login import login_required, current_user
from app import db 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.facility import Facility, Area
from app.models.user import User from app.models.user import User
from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.forms import IssueForm, IssueUpdateForm
@@ -13,6 +13,33 @@ from app.utils.notifications import notify
bp = Blueprint('issues', __name__, url_prefix='/issues') 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 ────────────────────────────────────────────────────────────────────── # ── List ──────────────────────────────────────────────────────────────────────
@bp.route('/') @bp.route('/')
@@ -108,11 +135,13 @@ 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
# 1. Notify the assignee when status changes # 1. Notify the assignee when status changes
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 != current_user.id: if assignee and assignee.id != actor_id:
notify( notify(
recipient = assignee, recipient = assignee,
title = f'Issue #{issue.id} Status Updated', title = f'Issue #{issue.id} Status Updated',
@@ -130,7 +159,7 @@ def view(issue_id):
# 2. Notify newly assigned user when the assignee changes # 2. Notify newly assigned user when the assignee changes
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 != current_user.id: if new_assignee and new_assignee.id != actor_id:
notify( notify(
recipient = new_assignee, recipient = new_assignee,
title = f'Issue #{issue.id} Assigned to You', title = f'Issue #{issue.id} Assigned to You',
@@ -147,7 +176,7 @@ def view(issue_id):
# 3. Notify the previously assigned user when unassigned # 3. Notify the previously assigned user when unassigned
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 != current_user.id: if old_assignee and old_assignee.id != actor_id:
notify( notify(
recipient = old_assignee, recipient = old_assignee,
title = f'Issue #{issue.id} Unassigned', title = f'Issue #{issue.id} Unassigned',
@@ -163,7 +192,7 @@ def view(issue_id):
# 4. Notify the assignee when a comment is added (if not the commenter) # 4. Notify the assignee when a comment is added (if not the commenter)
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 != current_user.id: if commentee and commentee.id != actor_id:
notify( notify(
recipient = commentee, recipient = commentee,
title = f'New Comment on Issue #{issue.id}', title = f'New Comment on Issue #{issue.id}',
@@ -176,12 +205,93 @@ def view(issue_id):
send_email = True, 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') flash('Issue updated.', 'success')
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)
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', 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('/<int:issue_id>/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('/<int:issue_id>/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) ──────────────────────────────── # ── 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 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: 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:
@@ -233,7 +343,7 @@ def create():
issue_id = issue.id, issue_id = issue.id,
send_email = True, send_email = True,
) )
db.session.commit() # Commit notification db.session.commit()
flash('Issue created.', 'success') flash('Issue created.', 'success')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
+32
View File
@@ -100,6 +100,38 @@
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
{# ── Follow / Unfollow ──────────────────────────────────────────────── #}
<div class="card shadow-sm mb-3">
<div class="card-body d-flex align-items-center justify-content-between py-2">
<div>
<i class="bi bi-bell{{ '-fill text-primary' if is_following else ' text-muted' }} me-1"></i>
<span class="fw-semibold" style="font-size:.9rem;">
{% if is_following %}Following{% else %}Not following{% endif %}
</span>
<span class="text-muted ms-2" style="font-size:.8rem;">
{{ issue.followers.count() }} follower{{ 's' if issue.followers.count() != 1 else '' }}
</span>
</div>
{% if is_following %}
<form method="post" action="{{ url_for('issues.unfollow', issue_id=issue.id) }}" class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-bell-slash"></i> Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('issues.follow', issue_id=issue.id) }}" 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-bell"></i> Follow
</button>
</form>
{% endif %}
</div>
</div>
{# ── Update Form ────────────────────────────────────────────────────── #}
{% set can_edit = current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %} {% set can_edit = current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %}
{% if can_edit %} {% if can_edit %}
<div class="card shadow-sm"> <div class="card shadow-sm">