import os import json import re import uuid 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, Response, abort) from flask_login import login_required, current_user from app import db, limiter from app.models.inspection import (Inspection, InspectionTemplate, ChecklistItem, InspectionResult) from app.models.facility import Facility, Area from app.models.project import Project from app.models.issue import Issue from app.models.user import User from app.utils.forms import StartInspectionForm, IssueForm from app.utils.decorators import supervisor_required, return_url from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.models.notification import ( EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED, EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED, EVENT_FOLLOWUP_REQUESTED, ) 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.scope import get_customer_scope, get_inspector_scope from sqlalchemy.orm import joinedload bp = Blueprint('inspections', __name__, url_prefix='/inspections') ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} # Magic-byte signatures for allowed image formats. # Checked against the first 8 bytes of the upload to prevent extension spoofing. _IMAGE_MAGIC = ( b'\xff\xd8\xff', # JPEG b'\x89PNG\r\n\x1a\n', # PNG b'GIF87a', # GIF 87a b'GIF89a', # GIF 89a ) INPUT_FIELD_TYPES = { 'text', 'textarea', 'number', 'date', 'email', 'checkbox', 'checkbox_group', 'radio', 'select', 'rating', 'pass_fail', 'signature', 'image', 'table' } def _save_photo(file_obj, subfolder='inspection_photos'): """Save an uploaded photo; return the relative path or None.""" if not file_obj or not file_obj.filename: return None ext = file_obj.filename.rsplit('.', 1)[-1].lower() if ext not in ALLOWED_EXTENSIONS: return None # Validate magic bytes to prevent extension-spoofed uploads. header = file_obj.read(8) file_obj.seek(0) if not any(header.startswith(m) for m in _IMAGE_MAGIC): return None # Write via the active storage backend (local disk or R2). Key format # 'uploads//.' is unchanged across backends. from app.utils import storage return storage.save(file_obj, subfolder) def _collect_form_responses(form_fields, existing_responses=None): """ Walk the submitted form data and collect responses keyed by field ID. Returns a dict: { field_id: value_or_list_or_path } Photo uploads are saved to disk; their path is stored as the value. existing_responses: previously saved form data (from inspection.notes). Used to preserve photo paths when no new file is uploaded on resubmit. """ if existing_responses is None: existing_responses = {} responses = {} for field in form_fields: fid = field['id'] ftype = field['type'] if ftype in ('label', 'section', 'button_submit', 'button_print', 'button_email'): continue # display-only, nothing to capture key = f"field_{fid}" if ftype == 'checkbox': responses[fid] = 'true' if request.form.get(key) else 'false' elif ftype == 'checkbox_group': responses[fid] = request.form.getlist(key) elif ftype == 'image': # Priority 1: a new file was selected and submitted with the form photo_file = request.files.get(key) path = _save_photo(photo_file, subfolder='inspection_photos') if path: responses[fid] = path else: # Priority 2: the named hidden input carries the AJAX-uploaded # server path directly in the POST body — covers the case where # the photo was uploaded via AJAX but no draft save captured it # (e.g., uploaded after the last flag-issue page reload). submitted_path = request.form.get(f'{key}_server_path', '').strip() if submitted_path: responses[fid] = submitted_path else: # Priority 3: fall back to the most recent draft in the DB responses[fid] = ( existing_responses.get(str(fid)) or existing_responses.get(fid) or '' ) elif ftype == 'table': cols = field.get('col_headers') or ['Column 1'] rows = int(field.get('table_rows') or 3) table_data = [] for r in range(rows): row_data = {} for c_idx, col in enumerate(cols): cell_key = f"{key}_r{r}_c{c_idx}" row_data[col] = request.form.get(cell_key, '') table_data.append(row_data) responses[fid] = table_data elif ftype == 'rating': responses[fid] = request.form.get(key, '0') else: responses[fid] = request.form.get(key, '') return responses def _compute_score_from_form(form_fields, responses): """ Derive an overall score from rating fields and checkbox pass/fail fields. Returns a float 0–100 or None if the form has no scoreable fields. """ scoreable = [f for f in form_fields if f['type'] in ('rating', 'checkbox', 'radio', 'pass_fail')] if not scoreable: return None total, earned = 0, 0 for field in scoreable: fid = field['id'] val = responses.get(fid, '') if field['type'] == 'rating': try: v = int(val) if v == 0: continue earned += v total += 5 except (ValueError, TypeError): pass elif field['type'] == 'checkbox': total += 1 if val == 'true': earned += 1 elif field['type'] == 'radio': total += 1 if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'): earned += 1 elif field['type'] == 'pass_fail': if not val: continue total += 1 if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'): earned += 1 return round((earned / total) * 100, 2) if total else None def _validate_required(form_fields, responses): """Return a list of labels for required fields that have empty responses.""" missing = [] for field in form_fields: if not field.get('required'): continue ftype = field['type'] if ftype in ('label', 'section', 'button_submit', 'button_print', 'button_email'): continue val = responses.get(field['id']) empty = ( val is None or val == '' or val == 'false' or val == '0' or val == [] ) if empty: missing.append(field.get('label', 'Untitled field')) return missing # ── List ────────────────────────────────────────────────────────────────────── @bp.route('/') @login_required def index(): page = request.args.get('page', 1, type=int) q = Inspection.query.options( joinedload(Inspection.facility), joinedload(Inspection.template), joinedload(Inspection.inspector), joinedload(Inspection.area), ).order_by(Inspection.inspection_date.desc()) if current_user.is_inspector: fids = get_inspector_scope(current_user) if not fids: q = q.filter(False) else: q = q.filter(Inspection.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(Inspection.facility_id.in_(customer_facility_ids)) status_filter = request.args.get('status', '') facility_filter = request.args.get('facility_id', '') follow_up_filter = request.args.get('follow_up', '') contract_filter = request.args.get('contract_id', '') date_from_filter = request.args.get('date_from', '') date_to_filter = request.args.get('date_to', '') score_min_filter = request.args.get('score_min', '') score_max_filter = request.args.get('score_max', '') inspector_filter = request.args.get('inspector_id', '') inspection_id_filter = request.args.get('inspection_id', '') if inspection_id_filter.isdigit(): q = q.filter(Inspection.id == int(inspection_id_filter)) if status_filter == 'follow_up': q = q.filter( Inspection.follow_up_required == True, Inspection.status == 'completed', ).filter(~Inspection.follow_ups.any()) elif status_filter == 'has_issues': from sqlalchemy import exists as sa_exists q = q.filter(sa_exists().where(Issue.inspection_id == Inspection.id)) elif status_filter: q = q.filter(Inspection.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(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False) if facility_filter.isdigit(): q = q.filter(Inspection.facility_id == int(facility_filter)) if follow_up_filter == '1': q = q.filter( Inspection.follow_up_required == True, Inspection.status == 'completed', ).filter(~Inspection.follow_ups.any()) if date_from_filter: try: q = q.filter(Inspection.inspection_date >= 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(Inspection.inspection_date <= _dt) except ValueError: date_to_filter = '' if score_min_filter: try: q = q.filter(Inspection.overall_score >= float(score_min_filter)) except ValueError: score_min_filter = '' if score_max_filter: try: q = q.filter(Inspection.overall_score <= float(score_max_filter)) except ValueError: score_max_filter = '' if inspector_filter.isdigit() and not current_user.is_inspector: q = q.filter(Inspection.inspector_id == int(inspector_filter)) inspections = q.paginate(page=page, per_page=20, error_out=False) 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': cids = get_customer_scope(current_user) or [] _fq = Facility.query.filter(Facility.id.in_(cids), 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() # Inspector dropdown — shown to all roles except inspector (they only see their own) if not current_user.is_inspector: inspectors = (User.query .filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True) .order_by(User.full_name, User.username).all()) else: inspectors = [] return render_template('inspections/list.html', inspections=inspections, facilities=facilities, projects=projects, inspectors=inspectors, status_filter=status_filter, facility_filter=facility_filter, follow_up_filter=follow_up_filter, contract_filter=contract_filter, date_from_filter=date_from_filter, date_to_filter=date_to_filter, score_min_filter=score_min_filter, score_max_filter=score_max_filter, inspector_filter=inspector_filter, inspection_id_filter=inspection_id_filter, now=now_eastern()) # ── Start ───────────────────────────────────────────────────────────────────── @bp.route('/start', methods=['GET', 'POST']) @login_required @quota_soft_check('inspections') def start(): form = StartInspectionForm() projects = Project.query.filter_by(active=True).order_by(Project.name).all() # Scope projects to inspector's assigned contracts if current_user.is_inspector: from app.models.inspector_assignment import InspectorAssignment assigned_pids = { a.project_id for a in InspectorAssignment.query.filter_by(user_id=current_user.id).all() } projects = [p for p in projects if p.id in assigned_pids] form.project_id.choices = [(p.id, p.name) for p in projects] # Seed facility choices: use submitted project_id, session value, or first project from flask import session as _session if form.is_submitted(): selected_project_id = form.project_id.data elif _session.get('reinspect_facility_id'): # Derive project from the reinspect facility _rf = db.session.get(Facility, _session['reinspect_facility_id']) selected_project_id = _rf.project_id if _rf and _rf.project_id else (projects[0].id if projects else None) else: selected_project_id = projects[0].id if projects else None # phase52 — forms are offered per CONTRACT: shared forms plus any attached # to the selected contract. This is also the POST validation (SelectField # validates against its choices), so a crafted template_id for another # customer's form is rejected here, not merely hidden in the UI. templates = InspectionTemplate.available_query(selected_project_id).all() form.template_id.choices = [(t.id, t.name) for t in templates] if selected_project_id: facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all() else: facilities = [] form.facility_id.choices = [(f.id, f.name) for f in facilities] if not form.facility_id.choices: form.facility_id.choices = [('', '— no facilities —')] # Seed area choices for the currently selected facility selected_facility_id = form.facility_id.data if form.is_submitted() else ( _session.get('reinspect_facility_id') or (facilities[0].id if facilities else None) ) if selected_facility_id: areas = Area.query.filter_by(facility_id=selected_facility_id).order_by(Area.name).all() else: areas = [] form.area_id.choices = [(0, '— No specific area —')] + [(a.id, a.name) for a in areas] if not form.is_submitted(): if _session.get('reinspect_template_id'): form.template_id.data = _session['reinspect_template_id'] if _session.get('reinspect_facility_id'): form.facility_id.data = _session['reinspect_facility_id'] if selected_project_id: form.project_id.data = selected_project_id if form.validate_on_submit(): template = db.session.get(InspectionTemplate, form.template_id.data) if template is None: abort(404) # Belt-and-braces: the choices above already reject a form that is not # available on this contract, but that guard lives in how the list was # built. Re-assert it against the FACILITY actually chosen, so a future # change to the choice-building cannot quietly open a cross-customer # hole here. _fac = db.session.get(Facility, form.facility_id.data) if not template.available_for_project(_fac.project_id if _fac else None): logger_msg = ('INSPECTION START BLOCKED | template=%s not available for ' 'facility=%s | user=%s') current_app.logger.warning(logger_msg, template.id, form.facility_id.data, current_user.username) abort(403) # Inspector facility scope check — prevent crafted POST from selecting # a facility outside their assigned contracts. if current_user.is_inspector: fids = get_inspector_scope(current_user) if not fids or form.facility_id.data not in fids: abort(403) if not template.get_form_schema(): flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning') return redirect(url_for('inspections.start')) from flask import session as _session parent_id = _session.pop('reinspect_parent_id', None) chosen_area_id = form.area_id.data if form.area_id.data and form.area_id.data != 0 else None inspection = Inspection( template_id = template.id, facility_id = form.facility_id.data, area_id = chosen_area_id, inspector_id = current_user.id, inspection_date = now_eastern(), status = 'in_progress', notes = None, parent_inspection_id = parent_id, ) db.session.add(inspection) db.session.commit() log_action(ACTION_CREATE, 'Inspection', inspection.id, f'{inspection.template.name} @ {inspection.facility.name}', f'template_id={inspection.template_id}; facility_id={inspection.facility_id}') flash('Inspection started. Fill in the form below and submit when complete.', 'info') return redirect(url_for('inspections.execute', inspection_id=inspection.id)) return render_template('inspections/start.html', form=form, projects=projects) # ── AJAX: areas for a given facility ───────────────────────────────────────── @bp.route('/areas/') @login_required def areas_for_facility(facility_id): areas = Area.query.filter_by(facility_id=facility_id).order_by(Area.name).all() return jsonify([{'id': a.id, 'name': a.name} for a in areas]) # ── AJAX: facilities for a given project/contract ──────────────────────────── @bp.route('/facilities_for_project/') @login_required def facilities_for_project(project_id): """Active facilities on one contract, for the Contract -> Facility cascade. **Scoped to the caller.** Every page that renders a facility dropdown already limits it to what the viewer may see; this endpoint refills that same dropdown, so without the same scope it would happily list another customer's building names to anyone who guessed a contract id — the leak rule 96 describes on the mobile API. Empty list rather than 403, so it does not confirm whether the contract exists either. """ q = Facility.query.filter_by(active=True, project_id=project_id) if current_user.is_inspector: fids = get_inspector_scope(current_user) or [] q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False) elif current_user.role == 'customer': fids = get_customer_scope(current_user) or [] q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False) facilities = q.order_by(Facility.name).all() return jsonify([{'id': f.id, 'name': f.name} for f in facilities]) # ── AJAX: forms available on a given contract (phase52) ────────────────────── @bp.route('/templates_for_project/') @login_required def templates_for_project(project_id): """Forms usable on this contract — shared ones plus any attached to it. Powers the Contract -> Form cascade on the start-inspection page, the same way facilities_for_project powers Contract -> Facility. **Scoped to the caller's own contracts.** The POST validation in start() is what stops a form being *used* across contracts, but this endpoint would otherwise happily list one customer's bespoke form NAMES to another customer's inspector who simply asked for a contract id — the same leak that rule 96 covers on the mobile API. Empty list rather than 403, so it does not confirm whether the contract exists either. """ if current_user.is_inspector: fids = get_inspector_scope(current_user) or [] allowed = { f.project_id for f in Facility.query.filter(Facility.id.in_(fids)).all() } if fids else set() if project_id not in allowed: logger_msg = ('TEMPLATES_FOR_PROJECT | out-of-scope request | ' 'user=%s | project_id=%s') current_app.logger.warning(logger_msg, current_user.username, project_id) return jsonify([]) elif current_user.role == 'customer': # Customers never start inspections; nothing here is theirs to see. return jsonify([]) templates = InspectionTemplate.available_query(project_id).all() return jsonify([ {'id': t.id, 'name': t.name, 'shared': t.is_shared} for t in templates ]) # ── Execute ─────────────────────────────────────────────────────────────────── @bp.route('//execute', methods=['GET', 'POST']) @login_required def execute(inspection_id): inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) if current_user.is_inspector and inspection.inspector_id != current_user.id: flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) if inspection.status == 'completed': return redirect(_view_url(inspection_id)) template = inspection.template form_fields = template.get_form_schema() form_fields = sorted(form_fields, key=lambda f: (f.get('row', 0), f.get('col', 0))) saved_responses = {} if inspection.notes: try: parsed = json.loads(inspection.notes) if isinstance(parsed, dict) and '_form_data' in parsed: saved_responses = parsed['_form_data'] except (json.JSONDecodeError, TypeError): pass # ── Pre-fill from parent inspection for re-inspections ──────────────── # When a re-inspection is first opened (no saved responses yet) and has # a completed parent, carry forward all input values EXCEPT scoring # fields (rating, pass_fail) so the inspector doesn't re-enter static # data but must re-evaluate every scoreable item fresh. if not saved_responses and inspection.parent_inspection_id: parent = db.session.get(Inspection, inspection.parent_inspection_id) if parent and parent.notes: try: parent_parsed = json.loads(parent.notes) if isinstance(parent_parsed, dict) and '_form_data' in parent_parsed: parent_data = parent_parsed['_form_data'] # Build a set of field IDs whose type should NOT be carried over # - rating / pass_fail: inspector must re-evaluate fresh # - image / signature: parent photos belong to the original inspection exclude_types = {'rating', 'pass_fail', 'image', 'signature'} exclude_ids = set() for f in form_fields: if f.get('type') in exclude_types: exclude_ids.add(str(f.get('id', ''))) saved_responses = { k: v for k, v in parent_data.items() if str(k) not in exclude_ids } # Persist as draft so the pre-filled data survives page reloads _save_responses(inspection, saved_responses) db.session.commit() current_app.logger.info( 'RE-INSPECTION PREFILL | inspection_id=%s | parent_id=%s | ' 'fields_copied=%s | fields_excluded=%s', inspection.id, parent.id, len(saved_responses), len(exclude_ids), ) except (json.JSONDecodeError, TypeError): pass if request.method == 'POST': action = request.form.get('action', 'submit') responses = _collect_form_responses(form_fields, saved_responses) if action == 'submit': missing = _validate_required(form_fields, responses) if missing: _save_draft(inspection, responses) flash( f'Please complete all required fields before submitting: ' f'{", ".join(missing[:5])}{"…" if len(missing) > 5 else ""}', 'warning' ) return redirect(url_for('inspections.execute', inspection_id=inspection_id)) score = _compute_score_from_form(form_fields, responses) inspection.overall_score = score inspection.status = 'completed' inspection.completed_at = now_eastern() # Capture GPS coordinates submitted by the browser Geolocation API try: _lat = request.form.get('submit_lat', '').strip() _lng = request.form.get('submit_lng', '').strip() if _lat and _lng: inspection.submit_latitude = float(_lat) inspection.submit_longitude = float(_lng) except (ValueError, TypeError): pass # Snapshot the current template schema so view() renders correctly # even if the template is later edited or deleted. _save_responses(inspection, responses, snapshot_schema=form_fields) # NOTE: do NOT commit here — inspection fields and all notification # rows are staged together and committed atomically below. inspection_link = url_for('inspections.view', inspection_id=inspection.id) score_display = f'{score:.1f}%' if score is not None else 'N/A' notify_by_matrix( event_type = 'inspection_completed', title = f'Inspection #{inspection.id} Completed', body = ( f'{current_user.username} completed an inspection at ' f'{inspection.facility.name} using the ' f'"{inspection.template.name}" template. ' f'Overall score: {score_display}.' ), link = inspection_link, inspection_id = inspection.id, facility_id = inspection.facility_id, ) # Fulfill the originating schedule, if any: roll a recurring plan's # due date forward and reset its reminder flags. Staged in the same # atomic commit below. (phase43) if inspection.inspection_schedule_id: from app.models.inspection_schedule import InspectionSchedule from app.routes.inspection_schedules import _compute_next_run sched = db.session.get(InspectionSchedule, inspection.inspection_schedule_id) if sched is not None: sched.fulfill(next_run_fn=_compute_next_run) current_app.logger.info( 'INSPECTION SCHEDULE | fulfilled | schedule=%s | inspection=%s | next_due=%s', sched.id, inspection.id, sched.next_run_at, ) db.session.commit() # Single atomic commit: inspection fields + notification rows log_action(ACTION_UPDATE, 'Inspection', inspection.id, f'{inspection.template.name} @ {inspection.facility.name}', f'status=completed; score={score}') flash('Inspection submitted successfully!', 'success') return redirect(_view_url(inspection_id)) else: _save_draft(inspection, responses) db.session.commit() flash('Draft saved. You can continue filling in the form later.', 'success') return redirect(url_for('inspections.execute', inspection_id=inspection_id)) # Scoped to this inspection's contract — see _assignable_staff_for(). # Must match flag_issue()'s choices exactly or the offcanvas silently # fails to save (rule 60). staff_for_flag_issue = _assignable_staff_for(inspection, current_user) return render_template('inspections/execute.html', inspection=inspection, form_fields=form_fields, saved_responses=saved_responses, staff_for_flag_issue=staff_for_flag_issue) def _save_responses(inspection, responses, snapshot_schema=None): """Persist form responses (and optionally the template schema) into inspection.notes.""" existing = {} if inspection.notes: try: existing = json.loads(inspection.notes) except (json.JSONDecodeError, TypeError): existing = {'_inspector_notes': inspection.notes} existing['_form_data'] = responses if snapshot_schema is not None: existing['_template_schema'] = snapshot_schema inspection.notes = json.dumps(existing) def _save_draft(inspection, responses): """Save a draft — same storage as final, status stays in_progress.""" _save_responses(inspection, responses) db.session.commit() # ── AJAX: save draft ────────────────────────────────────────────────────────── @bp.route('//save-draft', methods=['POST']) @login_required def save_draft_ajax(inspection_id): inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) if current_user.is_inspector and inspection.inspector_id != current_user.id: return jsonify({'ok': False, 'error': 'Access denied'}), 403 if inspection.status == 'completed': return jsonify({'ok': False, 'error': 'Inspection already completed'}), 400 data = request.get_json(silent=True) or {} responses = data.get('responses', {}) existing_responses = {} if inspection.notes: try: parsed = json.loads(inspection.notes) if isinstance(parsed, dict) and '_form_data' in parsed: existing_responses = parsed['_form_data'] except (json.JSONDecodeError, TypeError): pass merged = {**existing_responses, **responses} _save_responses(inspection, merged) db.session.commit() current_app.logger.info( 'INSPECTION DRAFT SAVED (flag) | inspection_id=%s | by=%s', inspection_id, current_user.username ) return jsonify({'ok': True}) # ── AJAX: upload a single inspection photo ──────────────────────────────────── @bp.route('//upload-photo', methods=['POST']) @login_required @limiter.limit("30 per minute") def upload_photo_ajax(inspection_id): inspection = db.session.get(Inspection, inspection_id) if inspection is None: return jsonify({'ok': False, 'error': 'Not found'}), 404 if current_user.is_inspector and inspection.inspector_id != current_user.id: return jsonify({'ok': False, 'error': 'Access denied'}), 403 if inspection.status == 'completed': return jsonify({'ok': False, 'error': 'Inspection already completed'}), 400 file_obj = request.files.get('photo') path = _save_photo(file_obj, subfolder='inspection_photos') if not path: return jsonify({'ok': False, 'error': 'Invalid file or unsupported format'}), 400 current_app.logger.info( 'INSPECTION PHOTO UPLOADED (AJAX) | inspection_id=%s | path=%s | by=%s', inspection_id, path, current_user.username ) return jsonify({'ok': True, 'path': path}) # ── View ────────────────────────────────────────────────────────────────────── @bp.route('/') @login_required def view(inspection_id): inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) # Read access matches the LIST (rule 58) — an inspector may open anything # at their contracted facilities, not only what they performed. Editing # someone else's inspection is still refused (execute / save-draft / # upload-photo / flag-issue keep the authorship check). if current_user.is_inspector and not _inspector_may_read(inspection, current_user): flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] if inspection.facility_id not in cids: flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) template = inspection.template # Prefer the schema snapshotted at submit time so that edits to the template # after this inspection was completed do not corrupt the historical view. form_fields = None if inspection.notes: try: _snap = json.loads(inspection.notes) if isinstance(_snap, dict) and '_template_schema' in _snap: form_fields = sorted(_snap['_template_schema'], key=lambda f: (f.get('row', 0), f.get('col', 0))) except (json.JSONDecodeError, TypeError): pass if form_fields is None: form_fields = sorted(template.get_form_schema(), key=lambda f: (f.get('row', 0), f.get('col', 0))) form_data = {} if inspection.notes: try: parsed = json.loads(inspection.notes) if isinstance(parsed, dict): form_data = parsed.get('_form_data', {}) except (json.JSONDecodeError, TypeError): pass issues = inspection.issues.order_by(Issue.reported_at.desc()).all() # ── Score comparison against parent inspection ──────────────────────── comparison = None if inspection.parent and inspection.parent.status == 'completed': parent = inspection.parent parent_form_data = {} if parent.notes: try: parsed_p = json.loads(parent.notes) if isinstance(parsed_p, dict): parent_form_data = parsed_p.get('_form_data', {}) except (json.JSONDecodeError, TypeError): pass scoreable_types = ('rating', 'checkbox', 'radio', 'pass_fail') # Collect all text/textarea fields per row, keyed by (col, fid) # so we can pick the leftmost non-empty value as the item name. _row_text_candidates = {} # row → [(col, fid), ...] _preceding_label = {} # field_id → nearest section/label text _last_label_text = '' for _f in form_fields: _ftype = _f.get('type') _frow = _f.get('row', 0) _fcol = _f.get('col', 0) _fid = str(_f.get('id', '')) if _ftype == 'label': _last_label_text = ( _f.get('text_content') or _f.get('label') or _f.get('text') or _last_label_text ) elif _ftype in ('text', 'textarea', 'select'): if _frow not in _row_text_candidates: _row_text_candidates[_frow] = [] _row_text_candidates[_frow].append((_fcol, _fid)) elif _ftype == 'section': _last_label_text = ( _f.get('label') or _f.get('text_content') or _last_label_text ) elif _ftype in scoreable_types: _preceding_label[_fid] = _last_label_text # Resolve row → item name: leftmost text field with a non-empty value _row_text_val = {} for _frow, _candidates in _row_text_candidates.items(): for _fcol, _fid in sorted(_candidates): _val = ( str(form_data.get(_fid, '')).strip() or str(parent_form_data.get(_fid, '')).strip() ) if _val: _row_text_val[_frow] = _val break def _resolve_label(field, fid): frow = field.get('row', 0) if frow in _row_text_val: return _row_text_val[frow] own = field.get('label') or field.get('placeholder') if own: return own return _preceding_label.get(fid) or fid rows = [] for field in form_fields: ftype = field.get('type') if ftype not in scoreable_types: continue fid = str(field.get('id', '')) label = _resolve_label(field, fid) cur_val = form_data.get(fid, '') par_val = parent_form_data.get(fid, '') def _field_pct(val, ft): if ft == 'rating': try: v = int(val) return None if v == 0 else round(v / 5 * 100, 1) except (ValueError, TypeError): return None if ft == 'checkbox': if val == '' or val is None: return None return 100.0 if val == 'true' else 0.0 if ft == 'radio': return None if not val else 100.0 if ft == 'pass_fail': if not val: return None return 100.0 if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant') else 0.0 return None cur_pct = _field_pct(cur_val, ftype) par_pct = _field_pct(par_val, ftype) if cur_pct is None and par_pct is None: continue delta = None if cur_pct is not None and par_pct is not None: delta = round(cur_pct - par_pct, 1) rows.append({ 'category': _preceding_label.get(fid) or field.get('category') or 'General', 'description': label, 'parent_pct': par_pct, 'current_pct': cur_pct, 'delta': delta, }) # ── Deduplicate rows by item name ───────────────────────────────── # When the template changes between inspections (e.g. a select field on # row 7 becomes a text field on row 11), the same item name can appear # twice — once with only a parent value and once with only a current value. # Merge those pairs into a single row so "Carpet 80%→100%" shows as one line. _seen = {} # label → index in merged_rows merged_rows = [] for r in rows: key = r['description'].strip().lower() if key in _seen: existing = merged_rows[_seen[key]] # Fill in whichever side is missing if existing['parent_pct'] is None and r['parent_pct'] is not None: existing['parent_pct'] = r['parent_pct'] if existing['current_pct'] is None and r['current_pct'] is not None: existing['current_pct'] = r['current_pct'] # Recompute delta after merge if existing['parent_pct'] is not None and existing['current_pct'] is not None: existing['delta'] = round(existing['current_pct'] - existing['parent_pct'], 1) else: existing['delta'] = None else: _seen[key] = len(merged_rows) merged_rows.append(r) # Drop any rows that are still unanswered on both sides after merging rows = [r for r in merged_rows if not (r['parent_pct'] is None and r['current_pct'] is None)] comparison = { 'parent_id': parent.id, 'parent_date': parent.inspection_date, 'parent_score': float(parent.overall_score) if parent.overall_score else None, 'current_score': float(inspection.overall_score) if inspection.overall_score else None, 'score_delta': ( round(float(inspection.overall_score) - float(parent.overall_score), 2) if inspection.overall_score and parent.overall_score else None ), 'rows': rows, 'improved': sum(1 for r in rows if r['delta'] is not None and r['delta'] > 0), 'regressed': sum(1 for r in rows if r['delta'] is not None and r['delta'] < 0), 'unchanged': sum(1 for r in rows if r['delta'] == 0), } followup_assignees = _followup_assignees_for(inspection, current_user) # An inspector viewing SOMEBODY ELSE's inspection gets a read-only page. # Without this the buttons would all render and then fail on click — the # same list-says-yes / page-says-no mismatch this change removes. is_own_inspection = (not current_user.is_inspector or inspection.inspector_id == current_user.id) # Whoever is expected to carry out the follow-up (phase56) may start the # re-inspection even though the original inspection is not theirs. owns_follow_up = bool( inspection.follow_up_required and inspection.follow_up_owner and inspection.follow_up_owner.id == current_user.id ) return render_template('inspections/view.html', followup_assignees=followup_assignees, is_own_inspection=is_own_inspection, owns_follow_up=owns_follow_up, inspection=inspection, form_fields=form_fields, form_data=form_data, issues=issues, comparison=comparison) # ── Flag issue during inspection ────────────────────────────────────────────── #: Internal roles that are NOT contract-scoped — they work across the whole #: organisation, so they are offered regardless of which contract the #: inspection belongs to. Only ever shown to our own people. _ORG_WIDE_ASSIGNEE_ROLES = ('director', 'project_manager', 'auditor') def _assignable_staff_for(inspection, actor): """Users `actor` may assign an issue to, for THIS inspection. The candidate list is scoped by the inspection's CONTRACT, not taken org-wide. Two distinct problems this fixes: 1. **Cross-customer leak.** A Customer Inspector could assign an issue to anyone in the system — including another client's Customer Inspector. The assignee is notified by email and in-app with the facility name and issue description, so this handed one customer's data to another. It is a leak whoever flags the issue, so the contract scope is applied to the two inspector roles for EVERY actor, not just customer ones. 2. An external account should not see our internal org chart at all. For a customer-side actor the list is their co-workers on shared contracts — inspectors assigned to this inspection's contract — and nothing else. Rules applied: * inspector / external_inspector -> only those holding an InspectorAssignment on this inspection's contract (the same rows get_inspector_scope() reads, so the list can never disagree with what the assignee can actually open). * director / project_manager / auditor -> org-wide, but offered ONLY to our own staff. These roles carry no InspectorAssignment rows, so contract-scoping them would remove them entirely and break the normal "escalate to the contract manager" flow. * inactive accounts are never offered. A facility with no contract yields no contract-scoped candidates; that is fail-closed and correct — an external actor then gets an empty list and can only leave the issue unassigned. Used by BOTH the offcanvas dropdown in execute() and the choices that validate the POST in flag_issue(). They MUST stay identical: a value the UI offers but the choices reject fails `validate_on_submit()`, and the offcanvas JS treats the resulting 200 as success — the issue is silently never saved (rule 60's failure mode, which is exactly what the two hand-maintained lists were already doing to project_manager and auditor). """ from app.models.inspector_assignment import InspectorAssignment # `is_customer_account` is used here to WITHHOLD internal staff from an # external account — the narrowing direction, which rule 89 permits. It # must never be used to grant a customer-side account anything. actor_is_external = bool(actor) and actor.is_customer_account project_id = inspection.facility.project_id if inspection.facility else None candidates = [] if project_id: candidates = ( User.query .join(InspectorAssignment, InspectorAssignment.user_id == User.id) .filter( InspectorAssignment.project_id == project_id, User.role.in_(User.INSPECTOR_ROLES), User.active == True, ) .order_by(User.full_name, User.username) .all() ) if not actor_is_external: candidates += ( User.query .filter( User.role.in_(_ORG_WIDE_ASSIGNEE_ROLES), User.active == True, ) .order_by(User.full_name, User.username) .all() ) # The join can repeat a user across assignment rows; dedupe by id, keeping # a stable display order. seen, out = set(), [] for u in candidates: if u.id not in seen: seen.add(u.id) out.append(u) out.sort(key=lambda u: (u.display_name or '').lower()) return out @bp.route('//flag-issue', methods=['GET', 'POST']) @login_required def flag_issue(inspection_id): inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) if current_user.is_inspector and inspection.inspector_id != current_user.id: flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) form = IssueForm() # SAME list the offcanvas rendered — this is what actually validates the # POST, so it is also the security boundary: a crafted assigned_to for # someone outside this contract fails validation rather than being stored. staff = _assignable_staff_for(inspection, current_user) form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)] # Suffix customer-employed inspectors so whoever is triaging can see the # work is going outside the company. Display only. form.assigned_to.choices = [(0, '— Unassigned —')] + [ (u.id, u.display_name + (' (Customer)' if u.is_external_inspector else '')) for u in staff ] if form.validate_on_submit(): photo_path = _save_photo(form.photo.data, subfolder='issue_photos') issue = Issue( inspection_id = inspection_id, facility_id = inspection.facility_id, 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) if form.severity.data in ('high', 'critical') and inspection.status != 'completed': inspection.status = 'flagged' db.session.commit() current_app.logger.info( 'ISSUE FLAGGED | issue_id=%s | inspection_id=%s | severity=%s | assigned_to=%s | by=%s', issue.id, inspection_id, issue.severity, issue.assigned_to, current_user.username ) 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 {issue.severity.title()}-severity issue was flagged during ' f'inspection #{inspection_id} at {inspection.facility.name} ' f'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_by_matrix( event_type = 'issue_flagged', title = f'New Issue #{issue.id} at {inspection.facility.name}', body = ( f'A new {issue.severity.title()}-severity issue has been logged ' f'at {inspection.facility.name} ' f'during inspection #{inspection_id}. ' 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 = inspection.facility_id, ) db.session.commit() flash('Issue logged successfully.', 'success') return redirect(url_for('inspections.execute', inspection_id=inspection_id)) # A failed POST must NOT come back 200. The flag-issue offcanvas treats # `res.ok` as success and reloads the page, so a 200 here means the issue # is silently discarded with the user believing it was logged — the exact # failure rule 60 describes. Returning 400 routes it to the JS error branch # so the reason is shown and the form stays open with its input intact. if request.method == 'POST': if form.assigned_to.errors: # Most likely an assignee outside this inspection's contract: # either a stale page rendered before the assignment changed, or a # crafted id. Say something actionable rather than "invalid choice". flash('That person cannot be assigned to an issue on this contract. ' 'Reopen the panel to refresh the list.', 'danger') return render_template('inspections/flag_issue.html', form=form, inspection=inspection), 400 return render_template('inspections/flag_issue.html', form=form, inspection=inspection) # ── Export filtered list to PDF ─────────────────────────────────────────────── @bp.route('/export-list-pdf') @login_required def export_list_pdf(): """Generate and stream a PDF of the currently filtered inspection list.""" q = Inspection.query.options( joinedload(Inspection.facility), joinedload(Inspection.template), joinedload(Inspection.inspector), joinedload(Inspection.area), ).order_by(Inspection.inspection_date.desc()) if current_user.is_inspector: fids = get_inspector_scope(current_user) if not fids: q = q.filter(False) else: q = q.filter(Inspection.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(Inspection.facility_id.in_(customer_facility_ids)) status_filter = request.args.get('status', '') 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', '') score_min_filter = request.args.get('score_min', '') score_max_filter = request.args.get('score_max', '') inspector_filter = request.args.get('inspector_id', '') inspection_id_filter = request.args.get('inspection_id', '') if inspection_id_filter.isdigit(): q = q.filter(Inspection.id == int(inspection_id_filter)) if status_filter == 'follow_up': q = q.filter( Inspection.follow_up_required == True, Inspection.status == 'completed', ).filter(~Inspection.follow_ups.any()) elif status_filter == 'has_issues': from sqlalchemy import exists as sa_exists q = q.filter(sa_exists().where(Issue.inspection_id == Inspection.id)) elif status_filter: q = q.filter(Inspection.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(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False) if facility_filter.isdigit(): q = q.filter(Inspection.facility_id == int(facility_filter)) if date_from_filter: try: q = q.filter(Inspection.inspection_date >= datetime.strptime(date_from_filter, '%Y-%m-%d')) except ValueError: pass if date_to_filter: try: _dt = datetime.strptime(date_to_filter, '%Y-%m-%d').replace(hour=23, minute=59, second=59) q = q.filter(Inspection.inspection_date <= _dt) except ValueError: pass if score_min_filter: try: q = q.filter(Inspection.overall_score >= float(score_min_filter)) except ValueError: pass if score_max_filter: try: q = q.filter(Inspection.overall_score <= float(score_max_filter)) except ValueError: pass if inspector_filter.isdigit() and not current_user.is_inspector: q = q.filter(Inspection.inspector_id == int(inspector_filter)) inspections = q.all() # Build a human-readable filter summary for the PDF header filter_parts = [] if inspection_id_filter.isdigit(): filter_parts.append(f'Inspection #: {inspection_id_filter}') if status_filter: label = {'follow_up': 'Flagged Follow-up', 'has_issues': 'Has Issues'}.get( status_filter, status_filter.replace('_', ' ').title() ) filter_parts.append(f'Status: {label}') if contract_filter.isdigit(): 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 date_from_filter: filter_parts.append(f'From: {date_from_filter}') if date_to_filter: filter_parts.append(f'To: {date_to_filter}') if score_min_filter: filter_parts.append(f'Min score: {score_min_filter}%') if score_max_filter: filter_parts.append(f'Max score: {score_max_filter}%') if inspector_filter.isdigit() and not current_user.is_inspector: u = db.session.get(User, int(inspector_filter)) if u: filter_parts.append(f'Inspector: {u.display_name}') filter_summary = ' | '.join(filter_parts) if filter_parts else 'All inspections' pdf_bytes = generate_inspections_list_pdf(inspections, filter_summary) filename = f'inspections_list_{now_eastern().strftime("%Y%m%d_%H%M")}.pdf' log_action(ACTION_EXPORT, 'Inspection', None, 'Inspections List', f'format=pdf; filters={filter_summary}; count={len(inspections)}') return Response( pdf_bytes, mimetype='application/pdf', headers={'Content-Disposition': f'attachment; filename="{filename}"'}, ) # ── Export to PDF ───────────────────────────────────────────────────────────── @bp.route('//export-pdf') @login_required def export_pdf(inspection_id): """Generate and stream a PDF report for the given inspection.""" inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) # Read access matches the LIST (rule 58) — an inspector may open anything # at their contracted facilities, not only what they performed. Editing # someone else's inspection is still refused (execute / save-draft / # upload-photo / flag-issue keep the authorship check). if current_user.is_inspector and not _inspector_may_read(inspection, current_user): flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] if inspection.facility_id not in cids: flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) template = inspection.template # Use the snapshot saved at submission time (same source as view()) so the # PDF reflects the exact template the inspector saw, not a later revision. form_fields = None form_data = {} if inspection.notes: try: _snap = json.loads(inspection.notes) if isinstance(_snap, dict): if '_template_schema' in _snap: form_fields = sorted( _snap['_template_schema'], key=lambda f: (f.get('row', 0), f.get('col', 0)), ) form_data = _snap.get('_form_data') or {} except (json.JSONDecodeError, TypeError): pass if form_fields is None: form_fields = sorted(template.get_form_schema(), key=lambda f: (f.get('row', 0), f.get('col', 0))) issues = inspection.issues.order_by(Issue.reported_at.desc()).all() static_folder = os.path.join(current_app.root_path, 'static') for field in form_fields: if field.get('type') == 'image': fid = str(field.get('id', '')) val = form_data.get(fid, '') resolved = os.path.join(static_folder, val) if val else '' current_app.logger.info( 'PDF export image field | fid=%s | val=%r | exists=%s | resolved=%r', fid, val, os.path.exists(resolved) if resolved else False, resolved ) pdf_bytes = generate_inspection_pdf( inspection = inspection, form_fields = form_fields, form_data = form_data, issues = issues, static_folder = static_folder, ) filename = (f'inspection_{inspection.id}_' f'{inspection.inspection_date.strftime("%Y%m%d")}.pdf') current_app.logger.info( 'PDF export | inspection_id=%s | inspector=%s | by=%s', inspection.id, inspection.inspector.username, current_user.username ) log_action(ACTION_EXPORT, 'Inspection', inspection.id, f'{inspection.template.name} @ {inspection.facility.name}', f'format=pdf') return Response( pdf_bytes, mimetype='application/pdf', headers={'Content-Disposition': f'attachment; filename="{filename}"'}, ) # ── Flag / clear follow-up required ────────────────────────────────────────── def _view_url(inspection_id): """inspections.view URL that carries the list `next` through. Actions posted from the detail page redirect back to that same page; re-attaching `next` is what keeps its Back button (and the next action) pointed at the filtered list the user arrived from. """ nxt = request.form.get('next') or request.args.get('next') if nxt: return url_for('inspections.view', inspection_id=inspection_id, next=nxt) return url_for('inspections.view', inspection_id=inspection_id) def _inspector_may_read(inspection, user): """May this inspector OPEN someone else's inspection? Yes, when it happened at a facility on one of their contracts — the same scope `index()` uses (rule 58: an inspector's scope covers all data in their contracted facilities, not just their own work). This used to test authorship instead, and the two disagreed: the list showed every inspection at the inspector's facilities, then clicking one said "Access denied". It also blocked the phase56 follow-up assignee from opening the parent inspection they had just been asked to re-inspect — the button they needed was on a page they could not reach. READ only. Editing someone else's inspection is still refused: execute, save-draft, upload-photo and flag-issue all keep the authorship check. """ fids = get_inspector_scope(user) if fids is None: # not an inspector — no scoping applies here return True return bool(fids) and inspection.facility_id in fids def _followup_assignees_for(inspection, actor): """Inspectors who may be handed this inspection's follow-up. Contract-scoped, for the same reason the flag-issue list is (rule 93): a Customer Director must never see — let alone assign work to — another client's inspector, and one of our own directors picking the wrong name would leak this facility to an outsider. Only the two INSPECTOR roles are offered: a follow-up is an inspection, and directors/PMs/auditors hold no InspectorAssignment, so they cannot be scoped to a contract and could not open the re-inspection anyway. A facility with no contract yields nobody — fail-closed, leaving the follow-up with the original inspector. """ from app.models.inspector_assignment import InspectorAssignment project_id = inspection.facility.project_id if inspection.facility else None if not project_id: return [] users = ( User.query .join(InspectorAssignment, InspectorAssignment.user_id == User.id) .filter( InspectorAssignment.project_id == project_id, User.role.in_(User.INSPECTOR_ROLES), User.active == True, ) .order_by(User.full_name, User.username) .all() ) seen, out = set(), [] for u in users: # the join repeats across assignments if u.id not in seen: seen.add(u.id) out.append(u) return out def _collect_inspection_photos(inspection): """Relative storage keys owned by an inspection, for cleanup after delete. Two sources: image field values inside the submitted form data (stored as `uploads/...` strings in the notes JSON), and the primary photo of each issue flagged during the inspection. Shared by the single and bulk delete paths so they cannot drift — a miss here leaves orphaned files in storage forever, and it is invisible. """ paths = [] if inspection.notes: try: notes_data = json.loads(inspection.notes) form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {} for val in form_data.values(): if isinstance(val, str) and val.startswith('uploads/'): paths.append(val) except (json.JSONDecodeError, TypeError): pass for issue in inspection.issues.all(): if issue.photo_path: paths.append(issue.photo_path) return paths # ── Bulk actions from the inspections list ─────────────────────────────────── @bp.route('/bulk', methods=['POST']) @login_required def bulk_action(): """Apply one action to every ticked inspection on the list page. Partial-failure policy: act on every eligible row, skip the rest, and report exact counts. Permission is checked per ACTION (all are admin/director level except the PDF export, which anyone who can see the list may run); `skipped` therefore means "this row was not in a state the action applies to". """ back = return_url(url_for('inspections.index')) action = request.form.get('action', '') ids = request.form.getlist('inspection_ids', type=int) if not ids: flash('No inspections selected.', 'warning') return redirect(back) supervisor = current_user.role in ('admin', 'director') allowed = { 'export': True, # read-only, already scoped below 'delete': supervisor, 'flag_followup': supervisor, 'clear_followup': supervisor, } 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) q = Inspection.query.options( joinedload(Inspection.facility), joinedload(Inspection.template), joinedload(Inspection.inspector), ).filter(Inspection.id.in_(ids)) # Re-apply the viewer's facility scope to the SELECTED ids. The list page # only ever shows in-scope rows, but the id list arrives in the POST body # and must not be trusted — a crafted request could otherwise name any # inspection in the system. if current_user.is_inspector: fids = get_inspector_scope(current_user) or [] q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False) elif current_user.role == 'customer': fids = get_customer_scope(current_user) or [] q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False) inspections = q.order_by(Inspection.inspection_date.desc()).all() out_of_scope = len(ids) - len(inspections) changed = 0 skipped = out_of_scope # ── Export selected to PDF ─────────────────────────────────────────── if action == 'export': if not inspections: flash('None of the selected inspections are available to you.', 'warning') return redirect(back) from flask import Response pdf = generate_inspections_list_pdf( inspections, f'Selected inspections ({len(inspections)})', ) log_action(ACTION_EXPORT, 'Inspection', None, 'bulk PDF export', f'ids={[i.id for i in inspections]}') return Response( pdf, mimetype='application/pdf', headers={'Content-Disposition': 'attachment; filename="selected_inspections.pdf"'}, ) # ── Delete ─────────────────────────────────────────────────────────── if action == 'delete': from app.utils import storage photo_paths = [] # Snapshot (id, label) BEFORE deleting: the objects are expired after # the commit, and the audit pass must run after it. log_action() # commits internally (rule 41), so auditing inside this loop would # commit the deletes one at a time — and a mid-loop failure would # leave rows gone with the photo cleanup below never reached. deleted = [] for insp in inspections: photo_paths.extend(_collect_inspection_photos(insp)) deleted.append(( insp.id, f'{insp.template.name if insp.template else "—"} @ ' f'{insp.facility.name if insp.facility else "—"}', )) db.session.delete(insp) changed += 1 db.session.commit() for insp_id, label in deleted: log_action(ACTION_DELETE, 'Inspection', insp_id, label, f'bulk deleted by {current_user.username}') # Files only after the rows are gone — an orphaned file is recoverable, # a deleted file belonging to a surviving row is not. for rel_path in photo_paths: storage.delete(rel_path) _flash_bulk(changed, skipped, 'permanently deleted') # ── Request follow-up ──────────────────────────────────────────────── elif action == 'flag_followup': note = request.form.get('follow_up_note', '').strip() or None # Only the rows this run actually flagged. Re-deriving it afterwards # from `follow_up_requested_by == current_user.id` would also match # inspections this same user flagged on an EARLIER run and that were # skipped here as already-flagged — re-notifying their inspectors. flagged = [] for insp in inspections: # Same two guards as the single-inspection route: nothing to follow # up on before submission, and a repeat request must not overwrite # the pending one's note or attribution. if insp.status != 'completed' or insp.follow_up_required: skipped += 1 continue insp.follow_up_required = True insp.follow_up_note = note insp.follow_up_requested_by = current_user.id insp.follow_up_requested_at = now_eastern() flagged.append(insp) changed += 1 db.session.commit() for insp in flagged: body = (f'{current_user.display_name} has requested a follow-up ' f're-inspection of "{insp.template.name if insp.template else "—"}" ' f'at {insp.facility.name if insp.facility else "—"}.' + (f' Note: {note}' if note else '')) inspector = db.session.get(User, insp.inspector_id) if inspector and inspector.id != current_user.id: notify( recipient = inspector, title = f'Follow-Up Required: Inspection #{insp.id}', body = body, link = url_for('inspections.view', inspection_id=insp.id), inspection_id = insp.id, event_type = EVENT_INSPECTION_DONE, send_email = True, ) # Through the matrix, not straight to managers — rule 73, so # per-contract recipients fire here exactly as they do for a # single request. notify_by_matrix( event_type = EVENT_FOLLOWUP_REQUESTED, title = f'Follow-Up Requested: Inspection #{insp.id}', body = body, link = url_for('inspections.view', inspection_id=insp.id), inspection_id = insp.id, facility_id = insp.facility_id, exclude_user_ids = {current_user.id, inspector.id if inspector else None} - {None}, ) db.session.commit() # notify() does not commit — rule 70 # Audited after the commit (rule 41) — log_action commits internally. for insp in flagged: log_action(ACTION_UPDATE, 'Inspection', insp.id, f'{insp.template.name if insp.template else "—"}', f'bulk follow_up_required=True by {current_user.username}') _flash_bulk(changed, skipped, 'flagged for follow-up', skip_reason='not submitted, or already flagged') # ── Clear follow-up ────────────────────────────────────────────────── elif action == 'clear_followup': cleared = [] for insp in inspections: if not insp.follow_up_required: skipped += 1 continue insp.follow_up_required = False insp.follow_up_note = None insp.follow_up_requested_by = None insp.follow_up_requested_at = None insp.follow_up_assigned_to = None cleared.append(insp) changed += 1 db.session.commit() for insp in cleared: # after the commit — rule 41 log_action(ACTION_UPDATE, 'Inspection', insp.id, f'{insp.template.name if insp.template else "—"}', f'bulk follow_up cleared by {current_user.username}') _flash_bulk(changed, skipped, 'cleared of the follow-up flag', skip_reason='not flagged') current_app.logger.info( 'INSPECTIONS | 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} inspection{"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('//flag-followup', methods=['POST']) @login_required def flag_followup(inspection_id): """Mark an inspection as requiring a follow-up re-inspection. phase49: no longer @supervisor_required. Open to admin/director AND to customers for their own facilities — a client unhappy with a result can ask for a re-inspection directly rather than going through support. Every other role is refused, so inspectors and auditors are no worse off than before. Customers may only *request*: they cannot clear the flag (clear_followup is still @supervisor_required) nor run the re-inspection itself. """ inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) is_customer = current_user.role == 'customer' if is_customer: # Same facility scope as view() — a customer must not be able to reach # another client's inspection with a crafted POST. if inspection.facility_id not in (get_customer_scope(current_user) or []): abort(403) # Nothing to follow up on until the inspection has been submitted. if inspection.status != 'completed': flash('You can only request a follow-up on a completed inspection.', 'warning') return redirect(_view_url(inspection_id)) # Don't let a repeat request overwrite the note/attribution of a pending # one — the flag is already raised and staff are already on it. if inspection.follow_up_required: flash('A follow-up has already been requested for this inspection.', 'info') return redirect(_view_url(inspection_id)) elif current_user.role not in ('admin', 'director'): abort(403) note = request.form.get('follow_up_note', '').strip() or None # ── Assignee (phase56) ──────────────────────────────────────────────── # Optional. Blank keeps the original behaviour: the follow-up belongs to # the inspection's own inspector. Validated against the contract-scoped # list rather than trusted, so a crafted id cannot hand work to another # customer's inspector (and tell them this facility's name in the email). assignee_id = request.form.get('follow_up_assigned_to', type=int) or None if assignee_id: allowed = {u.id for u in _followup_assignees_for(inspection, current_user)} if assignee_id not in allowed: current_app.logger.warning( 'FOLLOW-UP | out-of-contract assignee blocked | inspection=%s | ' 'assignee=%s | by=%s', inspection_id, assignee_id, current_user.username) flash('That inspector is not assigned to this facility\'s contract.', 'danger') return redirect(_view_url(inspection_id)) inspection.follow_up_required = True inspection.follow_up_note = note inspection.follow_up_requested_by = current_user.id inspection.follow_up_requested_at = now_eastern() inspection.follow_up_assigned_to = assignee_id db.session.commit() note_suffix = f' Note: {note}' if note else '' who = (f'The customer ({current_user.display_name})' if is_customer else current_user.display_name) assigned_suffix = '' if inspection.follow_up_assignee: assigned_suffix = (f' It has been assigned to ' f'{inspection.follow_up_assignee.display_name}.') body = ( f'{who} has requested a follow-up re-inspection ' f'of "{inspection.template.name}" at {inspection.facility.name}.' f'{assigned_suffix}{note_suffix}' ) # Notify whoever now OWNS the follow-up — the assignee when one was named, # otherwise the original inspector (Inspection.follow_up_owner). Notifying # the original inspector for work that has been handed to someone else is # noise, and worse, it implies they are expected to do it. inspector = inspection.follow_up_owner if inspector and inspector.id != current_user.id: notify( recipient = inspector, title = f'Follow-Up Required: Inspection #{inspection_id}', body = body, link = url_for('inspections.view', inspection_id=inspection_id), inspection_id = inspection_id, event_type = EVENT_INSPECTION_DONE, send_email = True, ) db.session.commit() # Route to the staff who action follow-ups. Going through notify_by_matrix # rather than notifying managers directly keeps recipients admin-configurable # and lets per-contract recipients fire too. This matters most for a customer # request: without it only the inspector would hear about it and nobody would # be accountable for scheduling the re-inspection. notify_by_matrix( event_type = EVENT_FOLLOWUP_REQUESTED, title = f'Follow-Up Requested: Inspection #{inspection_id}', body = body, link = url_for('inspections.view', inspection_id=inspection_id), inspection_id = inspection_id, facility_id = inspection.facility_id, exclude_user_ids = {current_user.id, inspector.id if inspector else None} - {None}, ) db.session.commit() current_app.logger.info( 'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s (%s) | note=%r', inspection_id, current_user.username, current_user.role, note, ) log_action(ACTION_UPDATE, 'Inspection', inspection_id, f'{inspection.template.name} @ {inspection.facility.name}', f'follow_up_required=True; by_role={current_user.role}; note={note!r}') if is_customer: flash('Follow-up re-inspection requested. The team has been notified.', 'success') else: flash('Follow-up inspection required flag set.', 'warning') return redirect(_view_url(inspection_id)) @bp.route('//clear-followup', methods=['POST']) @login_required @supervisor_required def clear_followup(inspection_id): """Clear the follow-up required flag once actioned.""" inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) inspection.follow_up_required = False inspection.follow_up_note = None # phase49 — clear the attribution with the flag. Leaving it behind would # make the next unattributed follow-up appear to have been requested by # whoever raised the previous one. inspection.follow_up_requested_by = None inspection.follow_up_requested_at = None inspection.follow_up_assigned_to = None db.session.commit() log_action(ACTION_UPDATE, 'Inspection', inspection_id, f'{inspection.template.name} @ {inspection.facility.name}', 'follow_up_required=False (cleared)') flash('Follow-up flag cleared.', 'success') return redirect(_view_url(inspection_id)) # ── Start a re-inspection (linked to parent) ────────────────────────────────── @bp.route('//reinspect') @login_required def reinspect(inspection_id): """Pre-fill the Start Inspection form with the same template/facility, linking the new inspection to the parent via parent_inspection_id.""" from flask import session parent = db.session.get(Inspection, inspection_id) if parent is None: abort(404) if current_user.role == 'customer': flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) # An inspector may re-inspect their OWN work, or work they have been # handed the follow-up for (phase56). Anything else at a contracted # facility is readable but not theirs to redo — starting a re-inspection # of a colleague's inspection uninvited only creates confusion about who # is doing it. if current_user.is_inspector: if parent.follow_up_required and parent.follow_up_owner: # A live follow-up has exactly ONE owner (phase56). Even the # original inspector does not start it once it has been handed to # someone else — that is the whole point of assigning it, and two # people turning up is the failure being designed out. may = parent.follow_up_owner.id == current_user.id else: # No follow-up outstanding: re-inspecting your own work is fine, # someone else's is not yours to redo uninvited. may = parent.inspector_id == current_user.id if not may: flash('That re-inspection has been assigned to someone else.', 'warning') return redirect(_view_url(inspection_id)) session['reinspect_parent_id'] = parent.id session['reinspect_template_id'] = parent.template_id session['reinspect_facility_id'] = parent.facility_id flash( f'Starting re-inspection of #{parent.id} — ' f'{parent.template.name} @ {parent.facility.name}.', 'info', ) return redirect(url_for('inspections.start')) # ── Delete ──────────────────────────────────────────────────────────────────── @bp.route('//delete', methods=['POST']) @login_required @supervisor_required def delete(inspection_id): inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) insp_id = inspection.id insp_date = inspection.inspection_date.strftime('%Y-%m-%d %H:%M') facility_name = inspection.facility.name template_name = inspection.template.name inspector_name = inspection.inspector.username photo_paths = _collect_inspection_photos(inspection) db.session.delete(inspection) db.session.commit() # Remove orphaned photo files from the active storage backend — best-effort. # (Also fixes the previous double-'static' path that normalized to # app/static/static/... and never actually deleted anything.) from app.utils import storage for rel_path in photo_paths: storage.delete(rel_path) current_app.logger.info( 'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | ' 'date=%s | inspector=%s | deleted_by=%s', insp_id, facility_name, template_name, insp_date, inspector_name, current_user.username ) log_action(ACTION_DELETE, 'Inspection', insp_id, f'{template_name} @ {facility_name}', f'date={insp_date}; inspector={inspector_name}') flash( f'Inspection #{insp_id} ({template_name} — {facility_name}, {insp_date}) ' f'has been permanently deleted.', 'success' ) return redirect(return_url(url_for('inspections.index')))