Files
JQC_multi_tenant/app/routes/issues.py
T

1689 lines
74 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, IssueLink
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, project_manager_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.tenancy.gates import quota_soft_check
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])
# ── Issue read access ─────────────────────────────────────────────────────────
# One definition of "may this person open this issue", used by the detail view,
# by the linked-issues panel, and by the link picker's search. They must not
# drift: the picker is what a person searches, but the panel is what actually
# renders another issue's description, and the POST is the real boundary.
#
# All three are WITHIN one tenant. Cross-tenant isolation is not their job and
# never can be — RoutingSession has already bound the session to g.tenant's
# database, so an id from another tenant simply does not resolve here.
def _viewer_facility_scope(user):
"""Facility ids this user is confined to, or None when unrestricted.
Returns a LIST (possibly empty) for the two scoped role groups and None for
everyone else. Empty list and None mean opposite things — [] is "no access
to anything", None is "no restriction" — so callers must test `is None`
rather than truthiness (CLAUDE.md rule 57's failure mode).
"""
if user.is_inspector: # rule 87 — never role == 'inspector'
return get_inspector_scope(user) or []
if user.role == 'customer': # rule 99 — capability check, exact match
return get_customer_scope(user) or []
return None
def _issue_in_scope(issue, scope_ids):
"""Whether one issue falls inside an already-resolved facility scope.
Takes the scope rather than the user so a caller filtering a list of issues
resolves it once instead of re-querying the assignment tables per row.
"""
if scope_ids is None:
return True
facility = issue.resolved_facility
return facility is not None and facility.id in scope_ids
def _issue_readable_by(issue, user):
"""Single-issue convenience wrapper around the two helpers above."""
return _issue_in_scope(issue, _viewer_facility_scope(user))
def _assignee_label(user):
"""Dropdown label for an assignee.
MT-15 — external (customer / third-party) inspectors are assignable just
like the tenant's 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', '')
if issue_id_filter.isdigit():
q = q.filter(Issue.id == int(issue_id_filter))
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 = [
fid for (fid,) in db.session.query(Facility.id)
.filter(Facility.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.isdigit():
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.isdigit():
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_type_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 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 = [
fid for (fid,) in db.session.query(Facility.id)
.filter(Facility.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.isdigit():
fid = int(facility_filter)
q = q.filter(
db.or_(
Issue.facility_id == fid,
Area.facility_id == fid,
)
)
if handler_type_filter:
if handler_type_filter == 'internal':
# NULL handler_type means 'internal' (the default)
q = q.filter(db.or_(
Issue.handler_type == 'internal',
Issue.handler_type.is_(None),
))
else:
q = q.filter(Issue.handler_type == handler_type_filter)
if unassigned_filter:
q = q.filter(Issue.assigned_to.is_(None))
# 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 = {
iid for (iid,) in
db.session.query(IssueFollower.issue_id)
.filter(IssueFollower.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_type_filter=handler_type_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)
# Scope gate — see _issue_readable_by(). This was two inline blocks that the
# linked-issues panel would have had to reproduce a third time; it is now
# one definition so the panel cannot end up more permissive than the page.
if not _issue_readable_by(issue, current_user):
flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
if current_user.role == 'customer':
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
# Vendor / contractor assignment — admin, director, project_manager only
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
issue.vendor_name = form.vendor_name.data.strip() or None
issue.vendor_contact = form.vendor_contact.data.strip() or None
issue.vendor_notes = form.vendor_notes.data.strip() or None
# Handler type (phase39; NOT NULL since phase44)
# Coerce empty/unknown to 'internal' explicitly. This is NOT
# preventing a crash: handler_type carries a Python-side
# default='internal', and SQLAlchemy applies a column default when
# the attribute is None — so the previous `or None` would have been
# silently rescued to 'internal' rather than raising. The point is to
# not depend on that fairly obscure behaviour, and to state the
# intended value at the point of assignment. The membership check
# also backstops a crafted POST, though SelectField.pre_validate
# already rejects out-of-choice values.
ht = form.handler_type.data or 'internal'
if ht not in ('internal', 'facility', 'vendor'):
ht = 'internal'
issue.handler_type = ht
if ht == 'facility':
issue.facility_handler_name = form.facility_handler_name.data.strip() or None
issue.facility_handler_contact = form.facility_handler_contact.data.strip() or None
issue.facility_handler_notes = form.facility_handler_notes.data.strip() or None
else:
issue.facility_handler_name = None
issue.facility_handler_contact = None
issue.facility_handler_notes = None
# Janitorial staff handler (phase44). Written unconditionally, the
# same way vendor_* above is: the work-order dispatch route also
# writes vendor_name, so clearing non-active handler fields here
# would discard data set elsewhere.
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)
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}; 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,
# Already filtered to links whose far end this viewer
# may open — see _readable_links().
issue_links=_readable_links(issue, current_user),
link_types=IssueLink.TYPE_CHOICES,
can_manage_links=_can_manage_links(issue, current_user))
# ── Issue links ───────────────────────────────────────────────────────────────
# Connect a duplicate to its original, or two issues about the same thing, so
# whoever picks one up can reach the other. Links are purely navigational: they
# never touch status, SLA, assignee or followers on either issue.
def _can_manage_links(issue, user):
"""Who may add or remove a link on this issue.
Deliberately the SAME set as the page's `can_edit` (the Update Issue panel):
admin / director / auditor, or the person the issue is assigned to. Keeping
the two identical means the panel's buttons and this gate cannot disagree —
the alternative is a second, slightly different rule that nobody remembers.
Widening it (to project_manager, or to the reporter) is a one-line change
here, but change `can_edit` in issues/view.html at the same time.
"""
return (user.role in ('admin', 'director', 'auditor')
or issue.assigned_to == user.id)
def _readable_links(issue, user):
"""Links on this issue whose FAR END the viewer may also open.
A link is a pointer to another issue's id, description and facility, so an
unfiltered panel would let a customer read an issue at a facility they have
no assignment to simply because one of our staff linked it. The scope is
resolved once for the whole list rather than per row.
Returns a list of (link, other_issue, label) ready for the template.
"""
scope = _viewer_facility_scope(user)
visible = []
for link in issue.all_links():
other = link.other_issue(issue.id)
if other is None or not _issue_in_scope(other, scope):
continue
visible.append((link, other, link.label_for(issue.id)))
return visible
@bp.route('/<int:issue_id>/links', methods=['POST'])
@login_required
def add_link(issue_id):
"""Link this issue to another one."""
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if not _issue_readable_by(issue, current_user):
abort(403)
if not _can_manage_links(issue, current_user):
abort(403)
link_type = request.form.get('link_type', '')
if link_type not in (IssueLink.TYPE_DUPLICATE, IssueLink.TYPE_RELATED):
flash('Choose how the two issues are related.', 'warning')
return redirect(_view_url(issue_id))
raw_target = (request.form.get('linked_issue_id') or '').strip().lstrip('#')
if not raw_target.isdigit():
flash('Enter the number of the issue to link, e.g. 412.', 'warning')
return redirect(_view_url(issue_id))
target_id = int(raw_target)
if target_id == issue.id:
flash('An issue cannot be linked to itself.', 'warning')
return redirect(_view_url(issue_id))
target = db.session.get(Issue, target_id)
# A 404 and a 403 are the same message here on purpose: whether an issue
# outside your scope EXISTS is not something the link box should confirm.
if target is None or not _issue_readable_by(target, current_user):
flash(f'Issue #{target_id} was not found.', 'warning')
return redirect(_view_url(issue_id))
if IssueLink.exists_between(issue.id, target.id):
flash(f'Issue #{issue.id} and #{target.id} are already linked.', 'info')
return redirect(_view_url(issue_id))
link = IssueLink(
issue_id = issue.id,
linked_issue_id = target.id,
link_type = link_type,
created_by = current_user.id,
)
db.session.add(link)
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'linked to #{target.id} as {link_type}')
current_app.logger.info(
'ISSUE LINK | issue_id=%s | linked_issue_id=%s | type=%s | user=%s',
issue.id, target.id, link_type, current_user.username,
)
flash(f'Issue #{issue.id} is now linked to #{target.id}.', 'success')
return redirect(_view_url(issue_id))
@bp.route('/<int:issue_id>/links/<int:link_id>/delete', methods=['POST'])
@login_required
def remove_link(issue_id, link_id):
"""Remove a link. Either end of it may do this."""
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if not _issue_readable_by(issue, current_user):
abort(403)
if not _can_manage_links(issue, current_user):
abort(403)
link = db.session.get(IssueLink, link_id)
# The link must actually touch THIS issue. Without the check, anyone able to
# manage links on any one issue could delete a link between two others by
# posting its id here.
if link is None or issue.id not in (link.issue_id, link.linked_issue_id):
flash('That link no longer exists.', 'info')
return redirect(_view_url(issue_id))
other_id = link.linked_issue_id if link.issue_id == issue.id else link.issue_id
db.session.delete(link)
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'unlinked from #{other_id}')
current_app.logger.info(
'ISSUE UNLINK | issue_id=%s | linked_issue_id=%s | user=%s',
issue.id, other_id, current_user.username,
)
flash(f'Removed the link to issue #{other_id}.', 'info')
return redirect(_view_url(issue_id))
@bp.route('/<int:issue_id>/link-search')
@login_required
def link_search(issue_id):
"""JSON candidates for the link picker.
Scoped exactly like the issue list, so an inspector or customer can only
find issues they could already open — searching must not become a way to
enumerate another contract's issues. The results are a convenience; the POST
in add_link() re-checks access and is the real boundary.
"""
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if not _issue_readable_by(issue, current_user):
abort(403)
term = (request.args.get('q') or '').strip().lstrip('#')
if len(term) < 1:
return jsonify({'results': []})
q = (
Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.options(joinedload(Issue.facility), contains_eager(Issue.area))
.filter(Issue.id != issue.id)
)
scope = _viewer_facility_scope(current_user)
if scope is not None:
if not scope:
return jsonify({'results': []})
q = q.filter(db.or_(
Issue.facility_id.in_(scope),
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(scope)),
))
# Exclude issues already linked in either direction — offering them only
# produces an "already linked" flash.
linked_ids = {other.id for _l, other, _lbl in _readable_links(issue, current_user)}
if linked_ids:
q = q.filter(Issue.id.notin_(linked_ids))
if term.isdigit():
# A number is almost always an issue number, so match the id first and
# fall back to the description for things like "Room 204".
q = q.filter(db.or_(Issue.id == int(term),
Issue.description.ilike(f'%{term}%')))
else:
q = q.filter(Issue.description.ilike(f'%{term}%'))
matches = q.order_by(Issue.reported_at.desc()).limit(10).all()
return jsonify({'results': [
{
'id': i.id,
'description': (i.description or '')[:110],
'status': (i.status or '').replace('_', ' ').title(),
'severity': (i.severity or '').title(),
'location': (i.area.name if i.area
else i.resolved_facility.name if i.resolved_facility
else '—'),
'reported_at': i.reported_at.strftime('%Y-%m-%d') if i.reported_at else '',
}
for i in matches
]})
# ── 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
@quota_soft_check('issues')
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.
# (phase44) Previously the create form carried no handler fields at all,
# so a handler chosen here was silently discarded and had to be re-entered
# on the update form.
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,
issue_handler_descriptions=Issue.HANDLER_DESCRIPTIONS)
# ── 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, or admin 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
# ── Vendor work order dispatch (phase36) ─────────────────────────────────────
#
# NOT LINKED FROM THE UI (Aug 2026). The "Contractor Work Orders" card was
# removed from the issue detail page, so nothing posts here any more. The
# endpoint is kept deliberately rather than deleted:
#
# * it is the ONLY way to create a work order, so removing it would strand
# the public contractor pages (/work-orders/<token>), the model, the email
# template and the phase36 migration — a whole feature, not dead code;
# * tests/test_work_orders.py drives the end-to-end flow through it.
#
# To bring the feature back, restore the card in templates/issues/view.html —
# nothing here needs to change. To retire it for good, remove this route, the
# work_orders blueprint, its templates, the model and those tests together,
# and only once no tokenized links are still outstanding with contractors.
@bp.route('/<int:issue_id>/work-order', methods=['POST'])
@login_required
@project_manager_required
def dispatch_work_order(issue_id):
"""Send this issue to an external contractor as a tokenized work order."""
import re as _re
from app.models.work_order import IssueWorkOrder
from app.routes.work_orders import send_work_order_email
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
vendor_name = (request.form.get('vendor_name', '') or '').strip()
vendor_email = (request.form.get('vendor_email', '') or '').strip()
message = (request.form.get('message', '') or '').strip() or None
if not vendor_name or not _re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', vendor_email):
flash('A contractor name and a valid email are required to send a work order.',
'warning')
return redirect(url_for('issues.view', issue_id=issue.id))
wo = IssueWorkOrder(
issue_id=issue.id, vendor_name=vendor_name, vendor_email=vendor_email,
token=IssueWorkOrder.new_token(), status='sent', message=message,
sent_at=now_eastern(), created_by=current_user.id, created_at=now_eastern(),
)
# Keep the issue's free-text vendor fields in sync when they're still blank.
if not issue.vendor_name:
issue.vendor_name = vendor_name
if not issue.vendor_contact:
issue.vendor_contact = vendor_email
if issue.status == 'open':
issue.status = 'in_progress'
db.session.add(wo)
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id, f'Issue #{issue.id}',
f'dispatched work order to {vendor_name} <{vendor_email}>')
send_work_order_email(wo, issue, request.host_url)
flash(f'Work order sent to {vendor_name}.', 'success')
return redirect(url_for('issues.view', issue_id=issue.id))