1114 lines
48 KiB
Python
1114 lines
48 KiB
Python
import os
|
|
import logging
|
|
from datetime import datetime
|
|
from app.utils.time_utils import now_eastern
|
|
from flask import (Blueprint, render_template, redirect, url_for,
|
|
flash, request, current_app, jsonify, abort, Response)
|
|
from flask_login import login_required, current_user
|
|
from app import db
|
|
from app.models.issue import Issue, IssueComment, IssueFollower
|
|
from app.models.facility import Facility, Area
|
|
from app.models.user import User
|
|
from app.models.notification import (
|
|
EVENT_ISSUE_ASSIGNED, EVENT_ISSUE_STATUS,
|
|
EVENT_ISSUE_COMMENT, EVENT_ISSUE_FOLLOW,
|
|
EVENT_CUSTOMER_ISSUE_UPDATED,
|
|
)
|
|
from app.utils.forms import IssueForm, IssueUpdateForm
|
|
from app.utils.decorators import supervisor_required
|
|
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
|
from app.utils.pdf_export import generate_issues_list_pdf
|
|
from app.utils.scope import get_customer_scope, get_inspector_scope
|
|
from app.utils.sla import sla_status
|
|
from sqlalchemy.orm import joinedload, contains_eager
|
|
|
|
bp = Blueprint('issues', __name__, url_prefix='/issues')
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Shared helper ─────────────────────────────────────────────────────────────
|
|
|
|
def _notify_followers(issue, title, body, exclude_user_ids=None):
|
|
"""Dispatch a notification to every follower of the given issue."""
|
|
exclude = set(exclude_user_ids or [])
|
|
issue_link = url_for('issues.view', issue_id=issue.id)
|
|
for follower in issue.followers.all():
|
|
if follower.user_id in exclude:
|
|
continue
|
|
notify(
|
|
recipient = follower.user,
|
|
title = title,
|
|
body = body,
|
|
link = issue_link,
|
|
issue_id = issue.id,
|
|
event_type = EVENT_ISSUE_FOLLOW,
|
|
send_email = True,
|
|
)
|
|
|
|
|
|
# ── List ──────────────────────────────────────────────────────────────────────
|
|
|
|
class _SLAFilteredPage:
|
|
"""Minimal pagination-compatible wrapper used when an SLA filter is active.
|
|
|
|
The SLA status is a computed value (not a DB column), so it cannot be
|
|
filtered at the query level. When sla_filter is set we load all matching
|
|
rows, apply the Python-side filter, and wrap the result in this object so
|
|
the template can use the same interface (.items, .pages, .page, .iter_pages)
|
|
without any template changes. Pagination is suppressed (single page) since
|
|
the full filtered set is always returned.
|
|
"""
|
|
def __init__(self, items):
|
|
self.items = items
|
|
self.page = 1
|
|
self.pages = 1
|
|
|
|
def iter_pages(self, **kwargs):
|
|
return iter([1])
|
|
|
|
|
|
@bp.route('/export-list-pdf')
|
|
@login_required
|
|
def export_list_pdf():
|
|
"""Generate and stream a PDF of the currently filtered issue list."""
|
|
q = (
|
|
Issue.query
|
|
.outerjoin(Area, Issue.area_id == Area.id)
|
|
.options(
|
|
contains_eager(Issue.area),
|
|
joinedload(Issue.facility),
|
|
joinedload(Issue.assigned_user),
|
|
)
|
|
.order_by(Issue.reported_at.desc())
|
|
)
|
|
|
|
if current_user.role == 'inspector':
|
|
fids = get_inspector_scope(current_user)
|
|
if not fids:
|
|
q = q.filter(False)
|
|
else:
|
|
q = q.filter(db.or_(
|
|
Issue.facility_id.in_(fids),
|
|
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
|
|
))
|
|
elif current_user.role == 'customer':
|
|
customer_facility_ids = get_customer_scope(current_user)
|
|
if not customer_facility_ids:
|
|
q = q.filter(False)
|
|
else:
|
|
q = q.filter(db.or_(
|
|
Issue.facility_id.in_(customer_facility_ids),
|
|
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(customer_facility_ids)),
|
|
))
|
|
|
|
issue_id_filter = request.args.get('issue_id', '').strip()
|
|
severity_filter = request.args.get('severity', '')
|
|
status_filter = request.args.get('status', '')
|
|
sla_filter = request.args.get('sla', '')
|
|
facility_filter = request.args.get('facility_id', '')
|
|
contract_filter = request.args.get('contract_id', '')
|
|
date_from_filter = request.args.get('date_from', '')
|
|
date_to_filter = request.args.get('date_to', '')
|
|
reporter_filter = request.args.get('reporter_id', '')
|
|
handler_filter = request.args.get('handler_type', '')
|
|
unassigned_filter = request.args.get('unassigned', '')
|
|
|
|
if issue_id_filter.isdigit():
|
|
q = q.filter(Issue.id == int(issue_id_filter))
|
|
if handler_filter in ('internal', 'facility', 'vendor'):
|
|
q = q.filter(Issue.handler_type == handler_filter)
|
|
if unassigned_filter:
|
|
q = q.filter(Issue.assigned_to.is_(None))
|
|
if severity_filter:
|
|
q = q.filter(Issue.severity == severity_filter)
|
|
if status_filter:
|
|
q = q.filter(Issue.status == status_filter)
|
|
if date_from_filter:
|
|
try:
|
|
q = q.filter(Issue.reported_at >= datetime.strptime(date_from_filter, '%Y-%m-%d'))
|
|
except ValueError:
|
|
date_from_filter = ''
|
|
if date_to_filter:
|
|
try:
|
|
_dt = datetime.strptime(date_to_filter, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
|
q = q.filter(Issue.reported_at <= _dt)
|
|
except ValueError:
|
|
date_to_filter = ''
|
|
if contract_filter.isdigit():
|
|
_contract_fids = [
|
|
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
|
]
|
|
q = q.filter(db.or_(
|
|
Issue.facility_id.in_(_contract_fids),
|
|
Area.facility_id.in_(_contract_fids),
|
|
)) if _contract_fids else q.filter(False)
|
|
if facility_filter:
|
|
fid = int(facility_filter)
|
|
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
|
|
if reporter_filter.isdigit():
|
|
q = q.filter(Issue.reported_by == int(reporter_filter))
|
|
|
|
all_issues = q.all()
|
|
if sla_filter:
|
|
all_issues = [i for i in all_issues if sla_status(i) == sla_filter]
|
|
|
|
# Human-readable filter summary for the PDF header
|
|
filter_parts = []
|
|
if issue_id_filter:
|
|
filter_parts.append(f'Issue #: {issue_id_filter}')
|
|
if severity_filter:
|
|
filter_parts.append(f'Severity: {severity_filter.title()}')
|
|
if status_filter:
|
|
filter_parts.append(f'Status: {status_filter.replace("_", " ").title()}')
|
|
if date_from_filter:
|
|
filter_parts.append(f'From: {date_from_filter}')
|
|
if date_to_filter:
|
|
filter_parts.append(f'To: {date_to_filter}')
|
|
if sla_filter:
|
|
filter_parts.append(f'SLA: {sla_filter.replace("_", " ").title()}')
|
|
if contract_filter.isdigit():
|
|
from app.models.project import Project
|
|
p = db.session.get(Project, int(contract_filter))
|
|
if p:
|
|
filter_parts.append(f'Contract: {p.name}')
|
|
if facility_filter:
|
|
f = db.session.get(Facility, int(facility_filter))
|
|
if f:
|
|
filter_parts.append(f'Facility: {f.name}')
|
|
if reporter_filter.isdigit():
|
|
r = db.session.get(User, int(reporter_filter))
|
|
if r:
|
|
filter_parts.append(f'Reporter: {r.display_name}')
|
|
|
|
filter_summary = ' | '.join(filter_parts) if filter_parts else 'All issues'
|
|
|
|
pdf_bytes = generate_issues_list_pdf(all_issues, filter_summary)
|
|
filename = f'issues_list_{now_eastern().strftime("%Y%m%d_%H%M")}.pdf'
|
|
|
|
log_action(ACTION_EXPORT, 'Issue', None, 'Issues List',
|
|
f'format=pdf; filters={filter_summary}; count={len(all_issues)}')
|
|
|
|
return Response(
|
|
pdf_bytes,
|
|
mimetype='application/pdf',
|
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@bp.route('/')
|
|
@login_required
|
|
def index():
|
|
page = request.args.get('page', 1, type=int)
|
|
# outerjoin Area once here so both the customer-scope filter and the
|
|
# facility_filter block can reference Area.facility_id without a cartesian
|
|
# product. Issues with no area_id get NULL for all Area columns (outer join).
|
|
q = (
|
|
Issue.query
|
|
.outerjoin(Area, Issue.area_id == Area.id)
|
|
.options(
|
|
contains_eager(Issue.area),
|
|
joinedload(Issue.facility),
|
|
joinedload(Issue.assigned_user),
|
|
)
|
|
.order_by(Issue.reported_at.desc())
|
|
)
|
|
|
|
if current_user.role == 'inspector':
|
|
fids = get_inspector_scope(current_user)
|
|
if not fids:
|
|
q = q.filter(False)
|
|
else:
|
|
q = q.filter(db.or_(
|
|
Issue.facility_id.in_(fids),
|
|
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
|
|
))
|
|
elif current_user.role == 'customer':
|
|
customer_facility_ids = get_customer_scope(current_user)
|
|
if not customer_facility_ids:
|
|
q = q.filter(False)
|
|
else:
|
|
# Issues may have facility via direct facility_id (new) or via area_id (legacy/flagged)
|
|
q = q.filter(
|
|
db.or_(
|
|
Issue.facility_id.in_(customer_facility_ids),
|
|
db.and_(
|
|
Issue.area_id.isnot(None),
|
|
Area.facility_id.in_(customer_facility_ids),
|
|
)
|
|
)
|
|
)
|
|
|
|
issue_id_filter = request.args.get('issue_id', '').strip()
|
|
severity_filter = request.args.get('severity', '')
|
|
status_filter = request.args.get('status', '')
|
|
sla_filter = request.args.get('sla', '')
|
|
facility_filter = request.args.get('facility_id', '')
|
|
contract_filter = request.args.get('contract_id', '')
|
|
date_from_filter = request.args.get('date_from', '')
|
|
date_to_filter = request.args.get('date_to', '')
|
|
reporter_filter = request.args.get('reporter_id', '')
|
|
handler_filter = request.args.get('handler_type', '')
|
|
unassigned_filter = request.args.get('unassigned', '')
|
|
|
|
if issue_id_filter.isdigit():
|
|
q = q.filter(Issue.id == int(issue_id_filter))
|
|
if handler_filter in ('internal', 'facility', 'vendor'):
|
|
q = q.filter(Issue.handler_type == handler_filter)
|
|
if unassigned_filter:
|
|
q = q.filter(Issue.assigned_to.is_(None))
|
|
if severity_filter:
|
|
q = q.filter(Issue.severity == severity_filter)
|
|
if status_filter:
|
|
q = q.filter(Issue.status == status_filter)
|
|
if date_from_filter:
|
|
try:
|
|
q = q.filter(Issue.reported_at >= datetime.strptime(date_from_filter, '%Y-%m-%d'))
|
|
except ValueError:
|
|
date_from_filter = ''
|
|
if date_to_filter:
|
|
try:
|
|
_dt = datetime.strptime(date_to_filter, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
|
q = q.filter(Issue.reported_at <= _dt)
|
|
except ValueError:
|
|
date_to_filter = ''
|
|
if contract_filter.isdigit():
|
|
_contract_fids = [
|
|
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
|
]
|
|
q = q.filter(db.or_(
|
|
Issue.facility_id.in_(_contract_fids),
|
|
Area.facility_id.in_(_contract_fids),
|
|
)) if _contract_fids else q.filter(False)
|
|
if reporter_filter.isdigit():
|
|
q = q.filter(Issue.reported_by == int(reporter_filter))
|
|
if facility_filter:
|
|
fid = int(facility_filter)
|
|
q = q.filter(
|
|
db.or_(
|
|
Issue.facility_id == fid,
|
|
Area.facility_id == fid,
|
|
)
|
|
)
|
|
# SLA filter — SLA status is computed in Python (not a DB column).
|
|
# When active: load all matching rows, filter in Python, wrap in a
|
|
# single-page compatible object so the template interface is unchanged.
|
|
# When inactive: use standard DB-level pagination (25 per page).
|
|
if sla_filter:
|
|
all_issues = q.all()
|
|
filtered = [i for i in all_issues if sla_status(i) == sla_filter]
|
|
issues_paged = _SLAFilteredPage(filtered)
|
|
logger.debug(
|
|
'ISSUES | index | sla_filter=%s | matched=%s of %s',
|
|
sla_filter, len(filtered), len(all_issues),
|
|
)
|
|
else:
|
|
issues_paged = q.paginate(page=page, per_page=25, error_out=False)
|
|
|
|
# Build a set of issue IDs the current user is following so the template
|
|
# can render the following badge and inline unfollow button without an
|
|
# additional query per row.
|
|
followed_ids = {
|
|
f.issue_id
|
|
for f in IssueFollower.query.filter_by(user_id=current_user.id).all()
|
|
}
|
|
|
|
# Facilities for the filter dropdown — scoped for inspectors/customers,
|
|
# then narrowed to the selected contract when contract_filter is active.
|
|
if current_user.role == 'inspector':
|
|
fids = get_inspector_scope(current_user) or []
|
|
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
|
elif current_user.role == 'customer':
|
|
facility_ids = get_customer_scope(current_user) or []
|
|
_fq = Facility.query.filter(Facility.id.in_(facility_ids), Facility.active == True)
|
|
else:
|
|
_fq = Facility.query.filter_by(active=True)
|
|
|
|
if contract_filter.isdigit():
|
|
_fq = _fq.filter(Facility.project_id == int(contract_filter))
|
|
|
|
facilities = _fq.order_by(Facility.name).all()
|
|
|
|
from app.models.project import Project, CustomerAssignment
|
|
if current_user.role == 'customer':
|
|
assigned_pids = {
|
|
a.project_id for a in
|
|
CustomerAssignment.query.filter_by(user_id=current_user.id).all()
|
|
}
|
|
projects = Project.query.filter(
|
|
Project.active == True, Project.id.in_(assigned_pids)
|
|
).order_by(Project.name).all()
|
|
else:
|
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
|
|
|
# Staff for quick-assign dropdown — same roles as the full issue form
|
|
staff = User.query.filter(
|
|
User.role.in_(['admin', 'director', 'inspector']), User.active == True
|
|
).order_by(User.username).all()
|
|
|
|
# Reporters dropdown — users who have actually filed at least one issue
|
|
reporter_ids = db.session.execute(
|
|
db.select(Issue.reported_by).where(Issue.reported_by.isnot(None)).distinct()
|
|
).scalars().all()
|
|
reporters = User.query.filter(User.id.in_(reporter_ids)).order_by(User.full_name, User.username).all()
|
|
|
|
return render_template('issues/list.html',
|
|
issues=issues_paged,
|
|
issue_id_filter=issue_id_filter,
|
|
severity_filter=severity_filter,
|
|
status_filter=status_filter,
|
|
sla_filter=sla_filter,
|
|
facility_filter=facility_filter,
|
|
contract_filter=contract_filter,
|
|
date_from_filter=date_from_filter,
|
|
date_to_filter=date_to_filter,
|
|
reporter_filter=reporter_filter,
|
|
handler_filter=handler_filter,
|
|
unassigned_filter=unassigned_filter,
|
|
facilities=facilities,
|
|
projects=projects,
|
|
staff=staff,
|
|
reporters=reporters,
|
|
followed_ids=followed_ids)
|
|
|
|
|
|
# ── View / Update ─────────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/<int:issue_id>', methods=['GET', 'POST'])
|
|
@login_required
|
|
def view(issue_id):
|
|
issue = db.session.get(Issue, issue_id)
|
|
if issue is None:
|
|
abort(404)
|
|
|
|
if current_user.role == 'inspector':
|
|
fids = get_inspector_scope(current_user)
|
|
facility = issue.resolved_facility
|
|
if not fids or not facility or facility.id not in fids:
|
|
flash('Access denied.', 'danger')
|
|
return redirect(url_for('issues.index'))
|
|
if current_user.role == 'customer':
|
|
cids = get_customer_scope(current_user) or []
|
|
facility = issue.resolved_facility
|
|
if not facility or facility.id not in cids:
|
|
flash('Access denied.', 'danger')
|
|
return redirect(url_for('issues.index'))
|
|
if request.method == 'POST':
|
|
# Customers may only add a comment, and only on issues they follow or reported
|
|
can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id)
|
|
if not can_comment:
|
|
abort(403)
|
|
comment_body = request.form.get('update_notes', '').strip()
|
|
if not comment_body:
|
|
flash('Comment cannot be empty.', 'warning')
|
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
|
comment = IssueComment(
|
|
issue_id=issue.id,
|
|
user_id=current_user.id,
|
|
status_at_time=issue.status,
|
|
body=comment_body,
|
|
is_customer_visible=True, # customer comments are always visible to all
|
|
)
|
|
db.session.add(comment)
|
|
db.session.commit()
|
|
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
|
f'#{issue.id}',
|
|
'customer comment added')
|
|
flash('Comment posted.', 'success')
|
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
|
|
|
form = IssueUpdateForm(obj=issue)
|
|
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
|
form.status.data = form.status.data or issue.status
|
|
|
|
if form.validate_on_submit():
|
|
old_status = issue.status
|
|
old_assigned_to = issue.assigned_to
|
|
|
|
issue.status = form.status.data
|
|
|
|
if current_user.role in ['admin', 'director']:
|
|
issue.assigned_to = form.assigned_to.data or None
|
|
|
|
if form.status.data == 'resolved' and not issue.resolved_at:
|
|
issue.resolved_at = now_eastern()
|
|
issue.sla_notified = None # clear so alerts fire again if re-opened
|
|
elif form.status.data != 'resolved':
|
|
issue.resolved_at = None
|
|
# Reset SLA notification state whenever re-opening from resolved so
|
|
# the SLA cron re-evaluates from scratch and fires fresh alerts.
|
|
# Without this, sla_notified retains its previous 'at_risk'/'breached'
|
|
# value and the cron skips the issue indefinitely.
|
|
if old_status == 'resolved':
|
|
issue.sla_notified = None
|
|
|
|
issue.result_notes = form.result_notes.data or None
|
|
|
|
# Handler assignment (who handles it) + vendor/facility details —
|
|
# admin, director, project_manager only.
|
|
if current_user.role in ('admin', 'director', 'project_manager'):
|
|
handler = form.handler_type.data or 'internal'
|
|
if handler not in ('internal', 'facility', 'vendor'):
|
|
handler = 'internal'
|
|
issue.handler_type = handler
|
|
|
|
issue.vendor_name = (form.vendor_name.data or '').strip() or None
|
|
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
|
|
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
|
|
|
|
issue.facility_handler_name = (form.facility_handler_name.data or '').strip() or None
|
|
issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
|
|
issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
|
|
|
|
from app.routes.inspections import _save_photo
|
|
new_photos = []
|
|
for file_obj in request.files.getlist('result_photos'):
|
|
path = _save_photo(file_obj, subfolder='issue_result_photos')
|
|
if path:
|
|
new_photos.append(path)
|
|
if new_photos:
|
|
existing = issue.result_photos or []
|
|
issue.result_photos = existing + new_photos
|
|
|
|
comment_body = form.update_notes.data.strip() if form.update_notes.data else ''
|
|
if comment_body:
|
|
comment = IssueComment(
|
|
issue_id = issue.id,
|
|
user_id = current_user.id,
|
|
status_at_time = issue.status,
|
|
body = comment_body,
|
|
is_customer_visible = 'is_customer_visible' in request.form,
|
|
)
|
|
db.session.add(comment)
|
|
|
|
# NOTE: do NOT commit here — the issue, comment, and all notification
|
|
# rows are staged together and committed atomically at the end of the
|
|
# notification block below. Committing early risks partial state if the
|
|
# server crashes between the two commits.
|
|
current_app.logger.info(
|
|
'ISSUE UPDATED | id=%s | status=%s | result_photos_added=%s | comment=%s | updated_by=%s',
|
|
issue.id, issue.status, len(new_photos), bool(comment_body), current_user.username
|
|
)
|
|
|
|
# ── Notifications ────────────────────────────────────────────────
|
|
issue_link = url_for('issues.view', issue_id=issue.id)
|
|
new_assigned_to = issue.assigned_to
|
|
actor_id = current_user.id
|
|
|
|
# 1. Status changed — notify assignee
|
|
if old_status != issue.status and new_assigned_to:
|
|
assignee = db.session.get(User, new_assigned_to)
|
|
if assignee and assignee.id != actor_id:
|
|
notify(
|
|
recipient = assignee,
|
|
title = f'Issue #{issue.id} Status Updated',
|
|
body = (
|
|
f'Issue in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'} 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,
|
|
event_type = EVENT_ISSUE_STATUS,
|
|
send_email = True,
|
|
)
|
|
|
|
# 2. Reassigned — notify new assignee
|
|
if (old_assigned_to != new_assigned_to) and new_assigned_to:
|
|
new_assignee = db.session.get(User, new_assigned_to)
|
|
if new_assignee and new_assignee.id != actor_id:
|
|
notify(
|
|
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 if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}. '
|
|
f'Current status: {issue.status.replace("_", " ").title()}.'
|
|
),
|
|
link = issue_link,
|
|
issue_id = issue.id,
|
|
event_type = EVENT_ISSUE_ASSIGNED,
|
|
send_email = True,
|
|
)
|
|
|
|
# 3. Unassigned — notify previous assignee
|
|
if old_assigned_to and old_assigned_to != new_assigned_to:
|
|
old_assignee = db.session.get(User, old_assigned_to)
|
|
if old_assignee and old_assignee.id != actor_id:
|
|
notify(
|
|
recipient = old_assignee,
|
|
title = f'Issue #{issue.id} Unassigned',
|
|
body = (
|
|
f'You have been removed from Issue #{issue.id} '
|
|
f'in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'} by {current_user.username}.'
|
|
),
|
|
link = issue_link,
|
|
issue_id = issue.id,
|
|
event_type = EVENT_ISSUE_ASSIGNED,
|
|
send_email = True,
|
|
)
|
|
|
|
# 4. Comment added — notify assignee
|
|
if comment_body and new_assigned_to:
|
|
commentee = db.session.get(User, new_assigned_to)
|
|
if commentee and commentee.id != actor_id:
|
|
notify(
|
|
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,
|
|
event_type = EVENT_ISSUE_COMMENT,
|
|
send_email = True,
|
|
)
|
|
|
|
# 5. Notify followers — consolidated message, exclude actor + assignees
|
|
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_assignee_obj = db.session.get(User, new_assigned_to) if new_assigned_to else None
|
|
new_name = _new_assignee_obj.username if _new_assignee_obj else 'Unassigned'
|
|
changes.append(f'reassigned to {new_name}')
|
|
if comment_body:
|
|
changes.append(f'new comment added by {current_user.username}')
|
|
|
|
if changes:
|
|
_notify_followers(
|
|
issue = issue,
|
|
title = f'Issue #{issue.id} Updated',
|
|
body = (
|
|
f'Issue #{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'} was updated by '
|
|
f'{current_user.username}: {"; ".join(changes)}.'
|
|
),
|
|
exclude_user_ids = exclude_ids,
|
|
)
|
|
|
|
# ── Notify via matrix (issue_updated_customer) ───────────────
|
|
facility_id = issue.resolved_facility.id if issue.resolved_facility else None
|
|
if facility_id and changes:
|
|
changes_summary = '; '.join(changes)
|
|
notify_by_matrix(
|
|
event_type = 'issue_updated_customer',
|
|
title = f'Issue #{issue.id} Updated at {issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
|
body = (
|
|
f'Issue #{issue.id} ({issue.severity.title()} severity) '
|
|
f'in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'} was updated: {changes_summary}. '
|
|
f'Current status: {issue.status.replace("_", " ").title()}.'
|
|
),
|
|
link = url_for('issues.view', issue_id=issue.id),
|
|
issue_id = issue.id,
|
|
facility_id = facility_id,
|
|
exclude_user_ids = {current_user.id},
|
|
)
|
|
db.session.commit() # Single atomic commit: issue fields + comment + all notification rows
|
|
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
|
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
|
f'status={issue.status}; handler={issue.handler_type}; assigned_to={issue.assigned_to}')
|
|
flash('Issue updated.', 'success')
|
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
|
|
|
is_following = issue.is_followed_by(current_user)
|
|
if current_user.role == 'customer':
|
|
comments = (issue.comments
|
|
.filter_by(is_customer_visible=True)
|
|
.order_by(IssueComment.created_at.asc()).all())
|
|
else:
|
|
comments = issue.comments.order_by(IssueComment.created_at.asc()).all()
|
|
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 = db.session.get(Issue, issue_id)
|
|
if issue is None:
|
|
abort(404)
|
|
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 = db.session.get(Issue, issue_id)
|
|
if issue is None:
|
|
abort(404)
|
|
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')
|
|
|
|
# Respect an explicit next URL (e.g. return to the list page).
|
|
# Fall back to the issue view if none is provided.
|
|
next_url = request.form.get('next') or url_for('issues.view', issue_id=issue_id)
|
|
return redirect(next_url)
|
|
|
|
|
|
# ── Standalone create ─────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/new', methods=['GET', 'POST'])
|
|
@login_required
|
|
def create():
|
|
if current_user.role not in ('admin', 'director', 'customer'):
|
|
abort(403)
|
|
|
|
from app.models.project import Project, CustomerAssignment
|
|
form = IssueForm()
|
|
|
|
if current_user.role == 'customer':
|
|
cids = get_customer_scope(current_user) or []
|
|
facilities = (Facility.query
|
|
.filter(Facility.id.in_(cids), Facility.active == True)
|
|
.order_by(Facility.name).all()) if cids else []
|
|
assigned_pids = {
|
|
a.project_id for a in
|
|
CustomerAssignment.query.filter_by(user_id=current_user.id).all()
|
|
}
|
|
projects = Project.query.filter(
|
|
Project.active == True, Project.id.in_(assigned_pids)
|
|
).order_by(Project.name).all()
|
|
staff = []
|
|
else:
|
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
|
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
|
|
|
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
|
|
|
# On POST validation error: identify which contract the submitted facility
|
|
# belongs to so the contract selector can be restored on re-render.
|
|
selected_project_id = None
|
|
if form.facility_id.data:
|
|
_fac = db.session.get(Facility, form.facility_id.data)
|
|
if _fac:
|
|
selected_project_id = _fac.project_id
|
|
|
|
if form.validate_on_submit():
|
|
from app.routes.inspections import _save_photo
|
|
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
|
|
|
|
issue = Issue(
|
|
facility_id = form.facility_id.data,
|
|
severity = form.severity.data,
|
|
description = form.description.data,
|
|
photo_path = photo_path,
|
|
status = 'open',
|
|
assigned_to = form.assigned_to.data or None,
|
|
reported_at = now_eastern(),
|
|
reported_by = current_user.id,
|
|
)
|
|
|
|
# "Handled By" — staff only; customer-created issues stay internal.
|
|
if current_user.role != 'customer':
|
|
handler = form.handler_type.data or 'internal'
|
|
if handler not in ('internal', 'facility', 'vendor'):
|
|
handler = 'internal'
|
|
issue.handler_type = handler
|
|
issue.facility_handler_name = (form.facility_handler_name.data or '').strip() or None
|
|
issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
|
|
issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
|
|
issue.vendor_name = (form.vendor_name.data or '').strip() or None
|
|
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
|
|
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
|
|
|
|
db.session.add(issue)
|
|
db.session.commit()
|
|
current_app.logger.info(
|
|
'ISSUE CREATED | id=%s | severity=%s | facility_id=%s | assigned_to=%s | created_by=%s',
|
|
issue.id, issue.severity, issue.facility_id, issue.assigned_to, current_user.username
|
|
)
|
|
log_action(ACTION_CREATE, 'Issue', issue.id,
|
|
f'#{issue.id} {issue.severity} at {issue.facility.name}',
|
|
f'severity={issue.severity}; assigned_to={issue.assigned_to}')
|
|
|
|
if issue.assigned_to:
|
|
assignee = db.session.get(User, issue.assigned_to)
|
|
if assignee and assignee.id != current_user.id:
|
|
notify(
|
|
recipient = assignee,
|
|
title = f'New Issue #{issue.id} Assigned to You',
|
|
body = (
|
|
f'A new {issue.severity.title()}-severity issue has been logged '
|
|
f'at {issue.facility.name} and assigned to you. '
|
|
f'Description: {issue.description[:120]}'
|
|
f'{"…" if len(issue.description) > 120 else ""}'
|
|
),
|
|
link = url_for('issues.view', issue_id=issue.id),
|
|
issue_id = issue.id,
|
|
event_type = EVENT_ISSUE_ASSIGNED,
|
|
send_email = True,
|
|
)
|
|
db.session.commit()
|
|
|
|
# ── Notify via matrix (issue_created) ────────────────────────
|
|
# No exclude_user_ids: the creator should also receive the in-app
|
|
# confirmation so they see it in their notification feed.
|
|
facility = issue.resolved_facility
|
|
if facility:
|
|
notify_by_matrix(
|
|
event_type = 'issue_created',
|
|
title = f'New Issue #{issue.id} at {facility.name}',
|
|
body = (
|
|
f'A new {issue.severity.title()}-severity issue has been logged '
|
|
f'at {facility.name}. '
|
|
f'Description: {issue.description[:120]}'
|
|
f'{"…" if len(issue.description) > 120 else ""}'
|
|
),
|
|
link = url_for('issues.view', issue_id=issue.id),
|
|
issue_id = issue.id,
|
|
facility_id = facility.id,
|
|
)
|
|
db.session.commit()
|
|
flash('Issue created.', 'success')
|
|
return redirect(url_for('issues.index'))
|
|
|
|
return render_template('issues/form.html', form=form, title='Log New Issue',
|
|
projects=projects, selected_project_id=selected_project_id)
|
|
|
|
|
|
# ── Supervisor verify resolved issue ─────────────────────────────────────────
|
|
|
|
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def verify(issue_id):
|
|
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
|
issue = db.session.get(Issue, issue_id)
|
|
if issue is None:
|
|
abort(404)
|
|
|
|
if issue.status not in ('resolved', 'pending_verification'):
|
|
flash('Only resolved or pending-verification issues can be verified.', 'warning')
|
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
|
|
|
note = request.form.get('verification_note', '').strip() or None
|
|
|
|
issue.status = 'resolved'
|
|
issue.verified_by = current_user.id
|
|
issue.verified_at = now_eastern()
|
|
issue.verification_note = note
|
|
if not issue.resolved_at:
|
|
issue.resolved_at = now_eastern()
|
|
|
|
db.session.commit()
|
|
current_app.logger.info(
|
|
'ISSUE VERIFIED | id=%s | by=%s | note=%r',
|
|
issue_id, current_user.username, note,
|
|
)
|
|
log_action(ACTION_UPDATE, 'Issue', issue_id,
|
|
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
|
f'verified_by={current_user.username}')
|
|
flash(f'Issue #{issue_id} verified and closed.', 'success')
|
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
|
|
|
|
|
@bp.route('/bulk-verify', methods=['POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def bulk_verify():
|
|
"""Verify multiple pending-verification issues in a single action."""
|
|
issue_ids = request.form.getlist('issue_ids', type=int)
|
|
if not issue_ids:
|
|
flash('No issues selected.', 'warning')
|
|
return redirect(url_for('issues.verification_queue'))
|
|
|
|
verified_count = 0
|
|
for issue_id in issue_ids:
|
|
issue = db.session.get(Issue, issue_id)
|
|
if issue is None or issue.status not in ('resolved', 'pending_verification'):
|
|
continue
|
|
issue.status = 'resolved'
|
|
issue.verified_by = current_user.id
|
|
issue.verified_at = now_eastern()
|
|
if not issue.resolved_at:
|
|
issue.resolved_at = now_eastern()
|
|
verified_count += 1
|
|
|
|
if verified_count:
|
|
db.session.commit()
|
|
for issue_id in issue_ids:
|
|
issue = db.session.get(Issue, issue_id)
|
|
if issue and issue.verified_by == current_user.id:
|
|
log_action(ACTION_UPDATE, 'Issue', issue_id,
|
|
f'#{issue_id}',
|
|
f'bulk_verified_by={current_user.username}')
|
|
|
|
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
|
|
return redirect(url_for('issues.verification_queue'))
|
|
|
|
|
|
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
|
|
@login_required
|
|
def request_verification(issue_id):
|
|
"""Inspector/assignee marks the issue as pending director verification."""
|
|
issue = db.session.get(Issue, issue_id)
|
|
if issue is None:
|
|
abort(404)
|
|
|
|
if current_user.role == 'customer':
|
|
flash('Access denied.', 'danger')
|
|
return redirect(url_for('issues.index'))
|
|
|
|
# Only the assignee, director, or admin can request verification
|
|
can_act = (
|
|
current_user.role in ['admin', 'director']
|
|
or issue.assigned_to == current_user.id
|
|
)
|
|
if not can_act:
|
|
flash('Access denied.', 'danger')
|
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
|
|
|
if issue.status not in ('in_progress',):
|
|
flash('Issue must be in progress to request verification.', 'warning')
|
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
|
|
|
issue.status = 'pending_verification'
|
|
db.session.commit()
|
|
|
|
current_app.logger.info(
|
|
'ISSUE VERIFICATION REQUESTED | id=%s | by=%s',
|
|
issue_id, current_user.username,
|
|
)
|
|
log_action(ACTION_UPDATE, 'Issue', issue_id,
|
|
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
|
f'status=pending_verification; requested_by={current_user.username}')
|
|
|
|
# Notify via matrix (verification_requested)
|
|
notify_by_matrix(
|
|
event_type = 'verification_requested',
|
|
title = f'Issue #{issue_id} Awaiting Verification',
|
|
body = (
|
|
f'{current_user.username} has marked Issue #{issue_id} '
|
|
f'({issue.severity.title()} severity) in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'} '
|
|
f'as pending your verification.'
|
|
),
|
|
link = url_for('issues.view', issue_id=issue_id),
|
|
issue_id = issue_id,
|
|
exclude_user_ids = {current_user.id},
|
|
)
|
|
db.session.commit()
|
|
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
|
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
|
|
|
# ── Verification queue ────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/verification-queue')
|
|
@login_required
|
|
@supervisor_required
|
|
def verification_queue():
|
|
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
|
from app.models.facility import Facility, Area
|
|
from app.utils.sla import sla_status, sla_hours_remaining
|
|
|
|
# outerjoin so that standalone issues (area_id=NULL, facility_id set directly)
|
|
# are included alongside area-linked issues. An INNER JOIN would silently
|
|
# drop every issue created via /issues/new which carries no area_id.
|
|
pending = (
|
|
Issue.query
|
|
.outerjoin(Area, Issue.area_id == Area.id)
|
|
.filter(Issue.status == 'pending_verification')
|
|
.order_by(Issue.reported_at.asc())
|
|
.all()
|
|
)
|
|
|
|
# Group by facility for display
|
|
from collections import defaultdict
|
|
by_facility = defaultdict(list)
|
|
for issue in pending:
|
|
f = issue.resolved_facility
|
|
if f:
|
|
by_facility[f].append(issue)
|
|
|
|
# Sort facilities alphabetically
|
|
grouped = sorted(by_facility.items(), key=lambda x: x[0].name)
|
|
|
|
current_app.logger.info(
|
|
'VERIFICATION QUEUE VIEWED | user=%s | pending_count=%s',
|
|
current_user.username, len(pending),
|
|
)
|
|
|
|
return render_template(
|
|
'issues/verification_queue.html',
|
|
grouped = grouped,
|
|
total_pending = len(pending),
|
|
sla_status = sla_status,
|
|
sla_hours_remaining = sla_hours_remaining,
|
|
)
|
|
|
|
# ── Delete ────────────────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/<int:issue_id>/delete', methods=['POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def delete(issue_id):
|
|
"""Permanently delete an issue and its associated photos.
|
|
|
|
Restricted to admin and director roles. The deletion is recorded in
|
|
the audit log before the record is removed so there is always a trace.
|
|
"""
|
|
issue = db.session.get(Issue, issue_id)
|
|
if issue is None:
|
|
abort(404)
|
|
|
|
# Snapshot fields needed for logging before deletion
|
|
issue_id_snap = issue.id
|
|
issue_desc = issue.description[:80]
|
|
area_name = issue.area.name if issue.area else '—'
|
|
facility_name = issue.resolved_facility.name if issue.resolved_facility else '—'
|
|
severity = issue.severity
|
|
|
|
# Collect photo paths to clean up from disk after DB delete
|
|
photo_paths = []
|
|
if issue.photo_path:
|
|
photo_paths.append(issue.photo_path)
|
|
if issue.result_photos:
|
|
photo_paths.extend(issue.result_photos)
|
|
|
|
db.session.delete(issue)
|
|
db.session.commit()
|
|
|
|
# Remove orphaned photo files from the active storage backend — best-effort.
|
|
from app.utils import storage
|
|
for rel_path in photo_paths:
|
|
storage.delete(rel_path)
|
|
|
|
current_app.logger.info(
|
|
'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s',
|
|
issue_id_snap, severity, area_name, facility_name, current_user.username,
|
|
)
|
|
log_action(ACTION_DELETE, 'Issue', issue_id_snap,
|
|
f'#{issue_id_snap} {severity} in {area_name}',
|
|
f'facility={facility_name}; description={issue_desc}')
|
|
|
|
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
|
|
return redirect(url_for('issues.index'))
|
|
|
|
|
|
# ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
|
|
|
|
@bp.route('/<int:issue_id>/quick-assign', methods=['POST'])
|
|
@login_required
|
|
def quick_assign(issue_id):
|
|
"""Inline assignee update from the issues list — returns JSON."""
|
|
if current_user.role not in ('admin', 'director'):
|
|
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
|
|
|
|
issue = db.session.get(Issue, issue_id)
|
|
if issue is None:
|
|
abort(404)
|
|
data = request.get_json(silent=True) or {}
|
|
new_user_id = data.get('user_id') # int or None (unassign)
|
|
|
|
old_assigned_to = issue.assigned_to
|
|
|
|
if new_user_id:
|
|
user = db.session.get(User, int(new_user_id))
|
|
if not user:
|
|
return jsonify({'ok': False, 'error': 'User not found'}), 404
|
|
issue.assigned_to = user.id
|
|
label = user.display_name
|
|
else:
|
|
issue.assigned_to = None
|
|
label = '— Unassigned —'
|
|
|
|
db.session.commit()
|
|
|
|
# Notify new assignee if changed
|
|
if new_user_id and old_assigned_to != issue.assigned_to:
|
|
from app.utils.notifications import notify
|
|
from app.models.notification import EVENT_ISSUE_ASSIGNED
|
|
notify(
|
|
recipient = user,
|
|
title = f'Issue #{issue.id} Assigned to You',
|
|
body = (f'You have been assigned Issue #{issue.id} '
|
|
f'({issue.severity} severity) by {current_user.display_name}.'),
|
|
link = url_for('issues.view', issue_id=issue.id),
|
|
issue_id = issue.id,
|
|
event_type = EVENT_ISSUE_ASSIGNED,
|
|
send_email = True,
|
|
)
|
|
|
|
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
|
f'#{issue.id}',
|
|
f'quick-assign: assigned_to={label} by {current_user.username}')
|
|
logger.info('ISSUE QUICK-ASSIGN | issue_id=%s | assigned_to=%s | by=%s',
|
|
issue.id, label, current_user.username)
|
|
|
|
return jsonify({'ok': True, 'label': label})
|
|
|
|
|
|
# ── Export PDF ────────────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/<int:issue_id>/export-pdf')
|
|
@login_required
|
|
def export_pdf(issue_id):
|
|
from flask import make_response
|
|
from app.utils.pdf_export import generate_issue_pdf
|
|
from app.utils.audit import ACTION_EXPORT
|
|
|
|
issue = db.session.get(Issue, issue_id)
|
|
if issue is None:
|
|
abort(404)
|
|
|
|
if current_user.role == 'inspector':
|
|
fids = get_inspector_scope(current_user)
|
|
facility = issue.resolved_facility
|
|
if not fids or not facility or facility.id not in fids:
|
|
abort(403)
|
|
if current_user.role == 'customer':
|
|
cids = get_customer_scope(current_user) or []
|
|
facility = issue.resolved_facility
|
|
if not facility or facility.id not in cids:
|
|
abort(403)
|
|
|
|
pdf_bytes = generate_issue_pdf(issue, current_app.static_folder)
|
|
log_action(ACTION_EXPORT, 'Issue', issue.id,
|
|
f'Issue #{issue.id}',
|
|
f'PDF export by {current_user.username}')
|
|
|
|
resp = make_response(pdf_bytes)
|
|
resp.headers['Content-Type'] = 'application/pdf'
|
|
resp.headers['Content-Disposition'] = f'attachment; filename="issue_{issue.id}.pdf"'
|
|
return resp |