import os import logging from app.utils.time_utils import now_eastern from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, abort) from flask_login import login_required, current_user from app import db from app.models.issue import Issue, IssueComment, IssueFollower from app.models.facility import Facility, Area from app.models.user import User from app.models.notification import ( EVENT_ISSUE_ASSIGNED, EVENT_ISSUE_STATUS, EVENT_ISSUE_COMMENT, EVENT_ISSUE_FOLLOW, EVENT_CUSTOMER_ISSUE_UPDATED, ) from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.decorators import supervisor_required from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.scope import get_customer_scope, get_inspector_scope from app.utils.sla import sla_status from sqlalchemy.orm import joinedload, contains_eager bp = Blueprint('issues', __name__, url_prefix='/issues') logger = logging.getLogger(__name__) # ── Shared helper ───────────────────────────────────────────────────────────── def _notify_followers(issue, title, body, exclude_user_ids=None): """Dispatch a notification to every follower of the given issue.""" exclude = set(exclude_user_ids or []) issue_link = url_for('issues.view', issue_id=issue.id) for follower in issue.followers.all(): if follower.user_id in exclude: continue notify( recipient = follower.user, title = title, body = body, link = issue_link, issue_id = issue.id, event_type = EVENT_ISSUE_FOLLOW, send_email = True, ) # ── List ────────────────────────────────────────────────────────────────────── class _SLAFilteredPage: """Minimal pagination-compatible wrapper used when an SLA filter is active. The SLA status is a computed value (not a DB column), so it cannot be filtered at the query level. When sla_filter is set we load all matching rows, apply the Python-side filter, and wrap the result in this object so the template can use the same interface (.items, .pages, .page, .iter_pages) without any template changes. Pagination is suppressed (single page) since the full filtered set is always returned. """ def __init__(self, items): self.items = items self.page = 1 self.pages = 1 def iter_pages(self, **kwargs): return iter([1]) @bp.route('/') @login_required def index(): page = request.args.get('page', 1, type=int) # outerjoin Area once here so both the customer-scope filter and the # facility_filter block can reference Area.facility_id without a cartesian # product. Issues with no area_id get NULL for all Area columns (outer join). q = ( Issue.query .outerjoin(Area, Issue.area_id == Area.id) .options( contains_eager(Issue.area), joinedload(Issue.facility), joinedload(Issue.assigned_user), ) .order_by(Issue.reported_at.desc()) ) if current_user.role == 'inspector': fids = get_inspector_scope(current_user) if not fids: q = q.filter(False) else: q = q.filter(db.or_( Issue.facility_id.in_(fids), db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)), )) elif current_user.role == 'customer': customer_facility_ids = get_customer_scope(current_user) if not customer_facility_ids: q = q.filter(False) else: # Issues may have facility via direct facility_id (new) or via area_id (legacy/flagged) q = q.filter( db.or_( Issue.facility_id.in_(customer_facility_ids), db.and_( Issue.area_id.isnot(None), Area.facility_id.in_(customer_facility_ids), ) ) ) issue_id_filter = request.args.get('issue_id', '').strip() severity_filter = request.args.get('severity', '') status_filter = request.args.get('status', '') sla_filter = request.args.get('sla', '') facility_filter = request.args.get('facility_id', '') contract_filter = request.args.get('contract_id', '') 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 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, ) ) # SLA filter — SLA status is computed in Python (not a DB column). # When active: load all matching rows, filter in Python, wrap in a # single-page compatible object so the template interface is unchanged. # When inactive: use standard DB-level pagination (25 per page). if sla_filter: all_issues = q.all() filtered = [i for i in all_issues if sla_status(i) == sla_filter] issues_paged = _SLAFilteredPage(filtered) logger.debug( 'ISSUES | index | sla_filter=%s | matched=%s of %s', sla_filter, len(filtered), len(all_issues), ) else: issues_paged = q.paginate(page=page, per_page=25, error_out=False) # Build a set of issue IDs the current user is following so the template # can render the following badge and inline unfollow button without an # additional query per row. followed_ids = { f.issue_id for f in IssueFollower.query.filter_by(user_id=current_user.id).all() } # Facilities for the filter dropdown — scoped for inspectors/customers, # then narrowed to the selected contract when contract_filter is active. if current_user.role == 'inspector': fids = get_inspector_scope(current_user) or [] _fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) elif current_user.role == 'customer': facility_ids = get_customer_scope(current_user) or [] _fq = Facility.query.filter(Facility.id.in_(facility_ids), Facility.active == True) else: _fq = Facility.query.filter_by(active=True) if contract_filter.isdigit(): _fq = _fq.filter(Facility.project_id == int(contract_filter)) facilities = _fq.order_by(Facility.name).all() from app.models.project import Project, CustomerAssignment if current_user.role == 'customer': assigned_pids = { a.project_id for a in CustomerAssignment.query.filter_by(user_id=current_user.id).all() } projects = Project.query.filter( Project.active == True, Project.id.in_(assigned_pids) ).order_by(Project.name).all() else: projects = Project.query.filter_by(active=True).order_by(Project.name).all() # Staff for quick-assign dropdown — same roles as the full issue form staff = User.query.filter( User.role.in_(['admin', 'director', 'inspector']), User.active == True ).order_by(User.username).all() 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, facilities=facilities, projects=projects, staff=staff, followed_ids=followed_ids) # ── View / Update ───────────────────────────────────────────────────────────── @bp.route('/', methods=['GET', 'POST']) @login_required def view(issue_id): issue = db.session.get(Issue, issue_id) if issue is None: abort(404) if current_user.role == 'inspector': fids = get_inspector_scope(current_user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: flash('Access denied.', 'danger') return redirect(url_for('issues.index')) if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] facility = issue.resolved_facility if not facility or facility.id not in cids: flash('Access denied.', 'danger') return redirect(url_for('issues.index')) if request.method == 'POST': # Customers may only add a comment, and only on issues they follow or reported can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id) if not can_comment: abort(403) comment_body = request.form.get('update_notes', '').strip() if not comment_body: flash('Comment cannot be empty.', 'warning') return redirect(url_for('issues.view', issue_id=issue_id)) comment = IssueComment( issue_id=issue.id, user_id=current_user.id, status_at_time=issue.status, body=comment_body, is_customer_visible=True, # customer comments are always visible to all ) db.session.add(comment) db.session.commit() log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}', 'customer comment added') flash('Comment posted.', 'success') return redirect(url_for('issues.view', issue_id=issue_id)) form = IssueUpdateForm(obj=issue) staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all() form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] form.status.data = form.status.data or issue.status if form.validate_on_submit(): old_status = issue.status old_assigned_to = issue.assigned_to issue.status = form.status.data if current_user.role in ['admin', 'director']: issue.assigned_to = form.assigned_to.data or None if form.status.data == 'resolved' and not issue.resolved_at: issue.resolved_at = now_eastern() issue.sla_notified = None # clear so alerts fire again if re-opened elif form.status.data != 'resolved': issue.resolved_at = None # Reset SLA notification state whenever re-opening from resolved so # the SLA cron re-evaluates from scratch and fires fresh alerts. # Without this, sla_notified retains its previous 'at_risk'/'breached' # value and the cron skips the issue indefinitely. if old_status == 'resolved': issue.sla_notified = None issue.result_notes = form.result_notes.data or None 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(url_for('issues.view', issue_id=issue_id)) is_following = issue.is_followed_by(current_user) if current_user.role == 'customer': comments = (issue.comments .filter_by(is_customer_visible=True) .order_by(IssueComment.created_at.asc()).all()) else: comments = issue.comments.order_by(IssueComment.created_at.asc()).all() return render_template('issues/view.html', issue=issue, form=form, comments=comments, is_following=is_following) # ── Follow ──────────────────────────────────────────────────────────────────── @bp.route('//follow', methods=['POST']) @login_required def follow(issue_id): issue = db.session.get(Issue, issue_id) if issue is None: abort(404) if not issue.is_followed_by(current_user): follower = IssueFollower(issue_id=issue.id, user_id=current_user.id) db.session.add(follower) db.session.commit() current_app.logger.info( 'ISSUE FOLLOW | issue_id=%s | user=%s', issue.id, current_user.username, ) flash('You are now following this issue and will receive notifications for any updates.', 'success') else: flash('You are already following this issue.', 'info') return redirect(url_for('issues.view', issue_id=issue_id)) # ── Unfollow ────────────────────────────────────────────────────────────────── @bp.route('//unfollow', methods=['POST']) @login_required def unfollow(issue_id): issue = db.session.get(Issue, issue_id) if issue is None: abort(404) follower = issue.followers.filter_by(user_id=current_user.id).first() if follower: db.session.delete(follower) db.session.commit() current_app.logger.info( 'ISSUE UNFOLLOW | issue_id=%s | user=%s', issue.id, current_user.username, ) flash('You have unfollowed this issue.', 'info') else: flash('You are not following this issue.', 'info') # Respect an explicit next URL (e.g. return to the list page). # Fall back to the issue view if none is provided. next_url = request.form.get('next') or url_for('issues.view', issue_id=issue_id) return redirect(next_url) # ── Standalone create ───────────────────────────────────────────────────────── @bp.route('/new', methods=['GET', 'POST']) @login_required def create(): if current_user.role not in ('admin', 'director', 'customer'): abort(403) from app.models.project import Project, CustomerAssignment form = IssueForm() if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] facilities = (Facility.query .filter(Facility.id.in_(cids), Facility.active == True) .order_by(Facility.name).all()) if cids else [] assigned_pids = { a.project_id for a in CustomerAssignment.query.filter_by(user_id=current_user.id).all() } projects = Project.query.filter( Project.active == True, Project.id.in_(assigned_pids) ).order_by(Project.name).all() staff = [] else: facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all() staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all() form.facility_id.choices = [(f.id, f.name) for f in facilities] form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) 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, ) 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) ──────────────────────── 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, exclude_user_ids = {current_user.id}, ) db.session.commit() flash('Issue created.', 'success') return redirect(url_for('issues.index')) return render_template('issues/form.html', form=form, title='Log New Issue', projects=projects, selected_project_id=selected_project_id) # ── Supervisor verify resolved issue ───────────────────────────────────────── @bp.route('//verify', methods=['POST']) @login_required @supervisor_required def verify(issue_id): """Supervisor sign-off: confirms resolution is satisfactory and closes the issue.""" issue = db.session.get(Issue, issue_id) if issue is None: abort(404) if issue.status not in ('resolved', 'pending_verification'): flash('Only resolved or pending-verification issues can be verified.', 'warning') return redirect(url_for('issues.view', issue_id=issue_id)) note = request.form.get('verification_note', '').strip() or None issue.status = 'resolved' issue.verified_by = current_user.id issue.verified_at = now_eastern() issue.verification_note = note if not issue.resolved_at: issue.resolved_at = now_eastern() db.session.commit() current_app.logger.info( 'ISSUE VERIFIED | id=%s | by=%s | note=%r', issue_id, current_user.username, note, ) log_action(ACTION_UPDATE, 'Issue', issue_id, f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}', f'verified_by={current_user.username}') flash(f'Issue #{issue_id} verified and closed.', 'success') return redirect(url_for('issues.view', issue_id=issue_id)) @bp.route('/bulk-verify', methods=['POST']) @login_required @supervisor_required def bulk_verify(): """Verify multiple pending-verification issues in a single action.""" issue_ids = request.form.getlist('issue_ids', type=int) if not issue_ids: flash('No issues selected.', 'warning') return redirect(url_for('issues.verification_queue')) verified_count = 0 for issue_id in issue_ids: issue = db.session.get(Issue, issue_id) if issue is None or issue.status not in ('resolved', 'pending_verification'): continue issue.status = 'resolved' issue.verified_by = current_user.id issue.verified_at = now_eastern() if not issue.resolved_at: issue.resolved_at = now_eastern() verified_count += 1 if verified_count: db.session.commit() for issue_id in issue_ids: issue = db.session.get(Issue, issue_id) if issue and issue.verified_by == current_user.id: log_action(ACTION_UPDATE, 'Issue', issue_id, f'#{issue_id}', f'bulk_verified_by={current_user.username}') flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success') return redirect(url_for('issues.verification_queue')) @bp.route('//request-verification', methods=['POST']) @login_required def request_verification(issue_id): """Inspector/assignee marks the issue as pending director verification.""" issue = db.session.get(Issue, issue_id) if issue is None: abort(404) if current_user.role == 'customer': flash('Access denied.', 'danger') return redirect(url_for('issues.index')) # Only the assignee, director, or admin can request verification can_act = ( current_user.role in ['admin', 'director'] or issue.assigned_to == current_user.id ) if not can_act: flash('Access denied.', 'danger') return redirect(url_for('issues.view', issue_id=issue_id)) if issue.status not in ('in_progress',): flash('Issue must be in progress to request verification.', 'warning') return redirect(url_for('issues.view', issue_id=issue_id)) issue.status = 'pending_verification' db.session.commit() current_app.logger.info( 'ISSUE VERIFICATION REQUESTED | id=%s | by=%s', issue_id, current_user.username, ) log_action(ACTION_UPDATE, 'Issue', issue_id, f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}', f'status=pending_verification; requested_by={current_user.username}') # Notify via matrix (verification_requested) notify_by_matrix( event_type = 'verification_requested', title = f'Issue #{issue_id} Awaiting Verification', body = ( f'{current_user.username} has marked Issue #{issue_id} ' f'({issue.severity.title()} severity) in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'} ' f'as pending your verification.' ), link = url_for('issues.view', issue_id=issue_id), issue_id = issue_id, exclude_user_ids = {current_user.id}, ) db.session.commit() flash('Issue marked as pending verification. Supervisors have been notified.', 'info') return redirect(url_for('issues.view', issue_id=issue_id)) # ── Verification queue ──────────────────────────────────────────────────────── @bp.route('/verification-queue') @login_required @supervisor_required def verification_queue(): """Supervisor queue of all issues awaiting verification, grouped by facility.""" from app.models.facility import Facility, Area from app.utils.sla import sla_status, sla_hours_remaining # outerjoin so that standalone issues (area_id=NULL, facility_id set directly) # are included alongside area-linked issues. An INNER JOIN would silently # drop every issue created via /issues/new which carries no area_id. pending = ( Issue.query .outerjoin(Area, Issue.area_id == Area.id) .filter(Issue.status == 'pending_verification') .order_by(Issue.reported_at.asc()) .all() ) # Group by facility for display from collections import defaultdict by_facility = defaultdict(list) for issue in pending: f = issue.resolved_facility if f: by_facility[f].append(issue) # Sort facilities alphabetically grouped = sorted(by_facility.items(), key=lambda x: x[0].name) current_app.logger.info( 'VERIFICATION QUEUE VIEWED | user=%s | pending_count=%s', current_user.username, len(pending), ) return render_template( 'issues/verification_queue.html', grouped = grouped, total_pending = len(pending), sla_status = sla_status, sla_hours_remaining = sla_hours_remaining, ) # ── Delete ──────────────────────────────────────────────────────────────────── @bp.route('//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 — best-effort, never block on failure static_folder = current_app.root_path for rel_path in photo_paths: abs_path = os.path.normpath(os.path.join(static_folder, 'static', rel_path)) try: if os.path.isfile(abs_path): os.remove(abs_path) except OSError: pass current_app.logger.info( 'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s', issue_id_snap, severity, area_name, facility_name, current_user.username, ) log_action(ACTION_DELETE, 'Issue', issue_id_snap, f'#{issue_id_snap} {severity} in {area_name}', f'facility={facility_name}; description={issue_desc}') flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success') return redirect(url_for('issues.index')) # ── Quick-assign (AJAX) ─────────────────────────────────────────────────────── @bp.route('//quick-assign', methods=['POST']) @login_required def quick_assign(issue_id): """Inline assignee update from the issues list — returns JSON.""" if current_user.role not in ('admin', 'director'): return jsonify({'ok': False, 'error': 'Permission denied'}), 403 issue = db.session.get(Issue, issue_id) if issue is None: abort(404) data = request.get_json(silent=True) or {} new_user_id = data.get('user_id') # int or None (unassign) old_assigned_to = issue.assigned_to if new_user_id: user = db.session.get(User, int(new_user_id)) if not user: return jsonify({'ok': False, 'error': 'User not found'}), 404 issue.assigned_to = user.id label = user.display_name else: issue.assigned_to = None label = '— Unassigned —' db.session.commit() # Notify new assignee if changed if new_user_id and old_assigned_to != issue.assigned_to: from app.utils.notifications import notify from app.models.notification import EVENT_ISSUE_ASSIGNED notify( recipient = user, title = f'Issue #{issue.id} Assigned to You', body = (f'You have been assigned Issue #{issue.id} ' f'({issue.severity} severity) by {current_user.display_name}.'), link = url_for('issues.view', issue_id=issue.id), issue_id = issue.id, event_type = EVENT_ISSUE_ASSIGNED, send_email = True, ) log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}', f'quick-assign: assigned_to={label} by {current_user.username}') logger.info('ISSUE QUICK-ASSIGN | issue_id=%s | assigned_to=%s | by=%s', issue.id, label, current_user.username) return jsonify({'ok': True, 'label': label}) # ── Export PDF ──────────────────────────────────────────────────────────────── @bp.route('//export-pdf') @login_required def export_pdf(issue_id): from flask import make_response from app.utils.pdf_export import generate_issue_pdf from app.utils.audit import ACTION_EXPORT issue = db.session.get(Issue, issue_id) if issue is None: abort(404) if current_user.role == 'inspector': fids = get_inspector_scope(current_user) facility = issue.resolved_facility if not fids or not facility or facility.id not in fids: abort(403) if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] facility = issue.resolved_facility if not facility or facility.id not in cids: abort(403) pdf_bytes = generate_issue_pdf(issue, current_app.static_folder) log_action(ACTION_EXPORT, 'Issue', issue.id, f'Issue #{issue.id}', f'PDF export by {current_user.username}') resp = make_response(pdf_bytes) resp.headers['Content-Type'] = 'application/pdf' resp.headers['Content-Disposition'] = f'attachment; filename="issue_{issue.id}.pdf"' return resp