1403 lines
62 KiB
Python
1403 lines
62 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, issue_manager_required,
|
|
return_url)
|
|
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,
|
|
customer_body=None, skip_customers=False):
|
|
"""Dispatch a notification to every follower of the given issue.
|
|
|
|
Customer followers are handled separately so a staff-only (internal) comment
|
|
never leaks to them: pass ``skip_customers=True`` to omit customer followers
|
|
entirely, or ``customer_body`` to send them a customer-safe message in place
|
|
of ``body``. Non-customer followers always receive ``body``.
|
|
"""
|
|
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
|
|
is_customer = bool(follower.user) and follower.user.role == 'customer'
|
|
if is_customer:
|
|
if skip_customers:
|
|
continue
|
|
f_body = customer_body if customer_body is not None else body
|
|
else:
|
|
f_body = body
|
|
notify(
|
|
recipient = follower.user,
|
|
title = title,
|
|
body = f_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])
|
|
|
|
|
|
|
|
def _assignee_label(user):
|
|
"""Dropdown label for an assignee.
|
|
|
|
phase49 — external (customer / third-party) inspectors are assignable just
|
|
like our own crew, but are suffixed so whoever is triaging can see at a
|
|
glance that the work is going outside the company. Display only; the
|
|
stored value is still the user id.
|
|
"""
|
|
return (f'{user.display_name} (Customer)'
|
|
if user.is_external_inspector else user.display_name)
|
|
|
|
|
|
@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.is_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.is_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.is_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_(['director', 'inspector', 'external_inspector', 'auditor']),
|
|
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.is_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(_view_url(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(_view_url(issue_id))
|
|
|
|
form = IssueUpdateForm(obj=issue)
|
|
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
|
# Preserve any pre-existing assignee who is no longer in the assignable set
|
|
# (e.g. an admin assigned before admins were removed from the dropdown) so
|
|
# saving the form doesn't silently unassign them.
|
|
if issue.assigned_to and issue.assigned_to not in [u.id for u in staff]:
|
|
current_assignee = db.session.get(User, issue.assigned_to)
|
|
if current_assignee:
|
|
staff.append(current_assignee)
|
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
|
(u.id, _assignee_label(u)) 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', 'auditor']:
|
|
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, auditor only.
|
|
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
|
|
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
|
|
|
|
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
|
|
issue.internal_handler_contact = (form.internal_handler_contact.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)
|
|
|
|
# Whether the comment just added is visible to customers. Internal
|
|
# (staff-only) comments must NEVER reach customer accounts — they only
|
|
# ever hear about comments explicitly shared with them. Only staff reach
|
|
# this branch (customers POST via the earlier customer-only path), so the
|
|
# checkbox governs. `changes` drives staff-facing notifications;
|
|
# `customer_changes` drives every customer-facing dispatch.
|
|
comment_customer_visible = bool(comment_body) and ('is_customer_visible' in request.form)
|
|
|
|
changes = []
|
|
customer_changes = []
|
|
if old_status != issue.status:
|
|
_c = (
|
|
f'status changed from "{old_status.replace("_"," ").title()}" '
|
|
f'to "{issue.status.replace("_"," ").title()}"'
|
|
)
|
|
changes.append(_c)
|
|
customer_changes.append(_c)
|
|
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'
|
|
_c = f'reassigned to {new_name}'
|
|
changes.append(_c)
|
|
customer_changes.append(_c)
|
|
if comment_body:
|
|
changes.append(f'new comment added by {current_user.username}')
|
|
if comment_customer_visible:
|
|
customer_changes.append(f'new comment added by {current_user.username}')
|
|
|
|
if changes:
|
|
_loc = issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'
|
|
customer_relevant = bool(customer_changes)
|
|
_notify_followers(
|
|
issue = issue,
|
|
title = f'Issue #{issue.id} Updated',
|
|
body = (
|
|
f'Issue #{issue.id} in {_loc} was updated by '
|
|
f'{current_user.username}: {"; ".join(changes)}.'
|
|
),
|
|
customer_body = (
|
|
f'Issue #{issue.id} in {_loc} was updated by '
|
|
f'{current_user.username}: {"; ".join(customer_changes)}.'
|
|
) if customer_relevant else None,
|
|
skip_customers = not customer_relevant,
|
|
exclude_user_ids = exclude_ids,
|
|
)
|
|
|
|
# ── Notify via matrix (issue_updated_customer) ───────────────
|
|
# Gated on `customer_changes`: an update whose ONLY change is an internal
|
|
# comment leaves this empty, so no customer notification fires.
|
|
facility_id = issue.resolved_facility.id if issue.resolved_facility else None
|
|
if facility_id and customer_changes:
|
|
changes_summary = '; '.join(customer_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(_view_url(issue_id))
|
|
|
|
is_following = issue.is_followed_by(current_user)
|
|
|
|
# TEMPORARY (Aug 2026) — COMMENTS_VISIBLE_TO_ALL lifts the phase22
|
|
# restriction so customers see every comment on the issue, not only the
|
|
# ones ticked "Share with customer". is_customer_visible is still recorded
|
|
# on every comment, so setting the flag back to false restores the old
|
|
# filtering with nothing to repair. See config.py.
|
|
comments_open = current_app.config.get('COMMENTS_VISIBLE_TO_ALL', False)
|
|
if current_user.role == 'customer' and not comments_open:
|
|
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,
|
|
comments_open=comments_open,
|
|
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(_view_url(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', 'auditor'):
|
|
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_(['director', 'inspector', 'external_inspector', 'auditor'])).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, _assignee_label(u)) 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
|
|
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
|
|
issue.internal_handler_contact = (form.internal_handler_contact.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(return_url(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
|
|
@issue_manager_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(_view_url(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(_view_url(issue_id))
|
|
|
|
|
|
def _view_url(issue_id):
|
|
"""issues.view URL that carries the list `next` through.
|
|
|
|
An update posted from the detail page redirects back to that same detail
|
|
page; without re-attaching `next`, the Back button would lose the filters
|
|
the user arrived with and the next action from this page would too. Only
|
|
added when there is something to carry, so ordinary links stay clean.
|
|
"""
|
|
nxt = request.form.get('next') or request.args.get('next')
|
|
if nxt:
|
|
return url_for('issues.view', issue_id=issue_id, next=nxt)
|
|
return url_for('issues.view', issue_id=issue_id)
|
|
|
|
|
|
@bp.route('/bulk-verify', methods=['POST'])
|
|
@login_required
|
|
@issue_manager_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')
|
|
# Reachable from BOTH the verification queue and the issues list, so honour
|
|
# the caller's `next` and fall back to the queue as before.
|
|
return redirect(return_url(url_for('issues.verification_queue')))
|
|
|
|
|
|
# ── Bulk actions from the issues list ────────────────────────────────────────
|
|
|
|
#: Statuses a bulk status change may set, and what an issue must already be in
|
|
#: for the change to mean anything. Moving an issue to the state it is already
|
|
#: in is a no-op, so it counts as skipped rather than changed.
|
|
_BULK_STATUSES = ('open', 'in_progress', 'resolved', 'pending_verification')
|
|
|
|
|
|
@bp.route('/bulk', methods=['POST'])
|
|
@login_required
|
|
def bulk_action():
|
|
"""Apply one action to every ticked issue on the list page.
|
|
|
|
Partial-failure policy (matches bulk_verify): act on every eligible row,
|
|
skip the rest, and report exact counts — never silently drop rows, and
|
|
never let one ineligible row block the batch.
|
|
|
|
Permission is checked per ACTION here rather than per row: all four actions
|
|
are manager-level, and the roles that hold them have org-wide issue access,
|
|
so there is no per-row scope question to answer. `skipped` therefore only
|
|
ever means "this row was not in a state the action applies to".
|
|
"""
|
|
back = return_url(url_for('issues.index'))
|
|
action = request.form.get('action', '')
|
|
ids = request.form.getlist('issue_ids', type=int)
|
|
|
|
if not ids:
|
|
flash('No issues selected.', 'warning')
|
|
return redirect(back)
|
|
|
|
manager = current_user.role in ('admin', 'director', 'auditor')
|
|
deleter = current_user.role in ('admin', 'director')
|
|
|
|
allowed = {
|
|
'assign': manager,
|
|
'status': manager,
|
|
'verify': manager,
|
|
'delete': deleter,
|
|
}
|
|
if action not in allowed:
|
|
flash('Unknown bulk action.', 'danger')
|
|
return redirect(back)
|
|
if not allowed[action]:
|
|
flash('You do not have permission for that bulk action.', 'danger')
|
|
return redirect(back)
|
|
|
|
issues = [i for i in (db.session.get(Issue, i_id) for i_id in ids) if i is not None]
|
|
missing = len(ids) - len(issues)
|
|
changed = 0
|
|
skipped = missing
|
|
|
|
# ── Assign ───────────────────────────────────────────────────────────
|
|
if action == 'assign':
|
|
raw = request.form.get('assigned_to', '')
|
|
user = None
|
|
if raw and raw != '0':
|
|
user = db.session.get(User, int(raw)) if raw.isdigit() else None
|
|
if user is None:
|
|
flash('That user no longer exists.', 'danger')
|
|
return redirect(back)
|
|
|
|
# Track what actually moved. Re-deriving this after the commit by
|
|
# testing `issue.assigned_to == user.id` would also match the issues
|
|
# that were ALREADY assigned to that person — they were counted as
|
|
# skipped, but would still be emailed "assigned to you" every time
|
|
# anyone ran a bulk assign over them.
|
|
newly_assigned = []
|
|
for issue in issues:
|
|
if issue.assigned_to == (user.id if user else None):
|
|
skipped += 1
|
|
continue
|
|
issue.assigned_to = user.id if user else None
|
|
newly_assigned.append(issue)
|
|
changed += 1
|
|
db.session.commit()
|
|
|
|
if user:
|
|
for issue in newly_assigned:
|
|
notify(
|
|
recipient = user,
|
|
title = f'Issue #{issue.id} assigned to you',
|
|
body = (f'{issue.severity.title()}-severity issue at '
|
|
f'{issue.resolved_facility.name if issue.resolved_facility else "—"}: '
|
|
f'{issue.description[:120]}'),
|
|
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() does not commit — rule 70
|
|
|
|
label = user.display_name if user else 'Unassigned'
|
|
log_action(ACTION_UPDATE, 'Issue', None, f'bulk assign → {label}',
|
|
f'ids={[i.id for i in issues]}; changed={changed}')
|
|
_flash_bulk(changed, skipped, f'assigned to {label}')
|
|
|
|
# ── Status ───────────────────────────────────────────────────────────
|
|
elif action == 'status':
|
|
new_status = request.form.get('status', '')
|
|
if new_status not in _BULK_STATUSES:
|
|
flash('Please choose a status to set.', 'warning')
|
|
return redirect(back)
|
|
|
|
# (issue, old_status) for the audit pass, which must run AFTER the
|
|
# commit — log_action() commits internally (rule 41), so calling it
|
|
# inside this loop would commit each row separately and lose the
|
|
# batch's atomicity.
|
|
moved = []
|
|
for issue in issues:
|
|
if issue.status == new_status:
|
|
skipped += 1
|
|
continue
|
|
old = issue.status
|
|
moved.append((issue, old))
|
|
issue.status = new_status
|
|
# Keep resolved_at consistent with the status, the same way the
|
|
# single-issue update does — a resolved issue with no resolved_at
|
|
# breaks the SLA compliance report and the aging buckets.
|
|
if new_status == 'resolved' and not issue.resolved_at:
|
|
issue.resolved_at = now_eastern()
|
|
elif new_status in ('open', 'in_progress'):
|
|
issue.resolved_at = None
|
|
changed += 1
|
|
db.session.commit()
|
|
for issue, old in moved:
|
|
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
|
f'bulk status {old} → {new_status} by {current_user.username}')
|
|
_flash_bulk(changed, skipped,
|
|
f'set to {new_status.replace("_", " ").title()}')
|
|
|
|
# ── Verify & close ───────────────────────────────────────────────────
|
|
elif action == 'verify':
|
|
verified = []
|
|
for issue in issues:
|
|
if issue.status not in ('resolved', 'pending_verification'):
|
|
skipped += 1
|
|
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.append(issue)
|
|
changed += 1
|
|
db.session.commit()
|
|
for issue in verified: # after the commit — rule 41
|
|
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
|
f'bulk_verified_by={current_user.username}')
|
|
_flash_bulk(changed, skipped, 'verified and closed',
|
|
skip_reason='not awaiting verification')
|
|
|
|
# ── Delete ───────────────────────────────────────────────────────────
|
|
elif action == 'delete':
|
|
from app.utils import storage
|
|
photo_paths = []
|
|
# Snapshot the ids BEFORE deleting — the objects are expired after the
|
|
# commit, and the audit pass has to run after it (rule 41: log_action
|
|
# commits internally, so auditing inside this loop would commit the
|
|
# deletes one at a time and, on a mid-loop failure, leave rows gone
|
|
# with the photo cleanup below never reached).
|
|
deleted_ids = []
|
|
for issue in issues:
|
|
if issue.photo_path:
|
|
photo_paths.append(issue.photo_path)
|
|
for lst in (issue.mobile_photo_paths, issue.result_photos):
|
|
if lst:
|
|
photo_paths.extend(lst)
|
|
deleted_ids.append(issue.id)
|
|
db.session.delete(issue)
|
|
changed += 1
|
|
db.session.commit()
|
|
for issue_id in deleted_ids:
|
|
log_action(ACTION_DELETE, 'Issue', issue_id, f'#{issue_id}',
|
|
f'bulk deleted by {current_user.username}')
|
|
# Files go only after the rows are safely gone — a failure here leaves
|
|
# an orphaned file, which is recoverable; the reverse is not.
|
|
for rel_path in photo_paths:
|
|
storage.delete(rel_path)
|
|
_flash_bulk(changed, skipped, 'permanently deleted')
|
|
|
|
logger.info('ISSUES | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
|
|
action, current_user.username, len(ids), changed, skipped)
|
|
return redirect(back)
|
|
|
|
|
|
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
|
|
"""One consistent result message for every bulk action."""
|
|
if not changed and not skipped:
|
|
flash('Nothing to do.', 'info')
|
|
return
|
|
parts = [f'{changed} issue{"s" if changed != 1 else ""} {verb}']
|
|
if skipped:
|
|
parts.append(f'{skipped} skipped ({skip_reason})')
|
|
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
|
|
|
|
|
|
@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, admin, or auditor can request verification
|
|
can_act = (
|
|
current_user.role in ['admin', 'director', 'auditor']
|
|
or issue.assigned_to == current_user.id
|
|
)
|
|
if not can_act:
|
|
flash('Access denied.', 'danger')
|
|
return redirect(_view_url(issue_id))
|
|
|
|
if issue.status not in ('in_progress',):
|
|
flash('Issue must be in progress to request verification.', 'warning')
|
|
return redirect(_view_url(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(_view_url(issue_id))
|
|
|
|
# ── Verification queue ────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/verification-queue')
|
|
@login_required
|
|
@issue_manager_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(return_url(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', 'auditor'):
|
|
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.is_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 |