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 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, ) from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT 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 filename = f"{uuid.uuid4().hex}.{ext}" dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) os.makedirs(dest_dir, exist_ok=True) file_obj.save(os.path.join(dest_dir, filename)) return f"uploads/{subfolder}/{filename}" 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.role == '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 current_user.role != 'inspector': q = q.filter(Inspection.inspector_id == int(inspector_filter)) inspections = q.paginate(page=page, per_page=20, error_out=False) 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': 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 current_user.role != 'inspector': inspectors = (User.query .filter(User.role == 'inspector', 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 def start(): form = StartInspectionForm() templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all() # Scope projects to inspector's assigned contracts if current_user.role == '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.template_id.choices = [(t.id, t.name) for t in templates] 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 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) # Inspector facility scope check — prevent crafted POST from selecting # a facility outside their assigned contracts. if current_user.role == '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): facilities = (Facility.query .filter_by(active=True, project_id=project_id) .order_by(Facility.name) .all()) return jsonify([{'id': f.id, 'name': f.name} for f in facilities]) # ── 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.role == 'inspector' and inspection.inspector_id != current_user.id: flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) if inspection.status == 'completed': return redirect(url_for('inspections.view', inspection_id=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, exclude_user_ids = {current_user.id}, ) 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(url_for('inspections.view', inspection_id=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)) staff_for_flag_issue = User.query.filter( User.role.in_(['admin', 'director', 'inspector', 'project_manager']), User.active == True, ).order_by(User.full_name, User.username).all() 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.role == '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.role == '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) if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: 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), } return render_template('inspections/view.html', inspection=inspection, form_fields=form_fields, form_data=form_data, issues=issues, comparison=comparison) # ── Flag issue during inspection ────────────────────────────────────────────── @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.role == 'inspector' and inspection.inspector_id != current_user.id: flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) form = IssueForm() staff = User.query.filter(User.role.in_(['director', 'inspector'])).order_by(User.username).all() form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)] form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) 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)) 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.role == '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 current_user.role != '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 current_user.role != '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) if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: 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 ────────────────────────────────────────── @bp.route('//flag-followup', methods=['POST']) @login_required @supervisor_required def flag_followup(inspection_id): """Mark an inspection as requiring a follow-up re-inspection.""" inspection = db.session.get(Inspection, inspection_id) if inspection is None: abort(404) note = request.form.get('follow_up_note', '').strip() or None inspection.follow_up_required = True inspection.follow_up_note = note db.session.commit() # Notify the original inspector so they see it on the iPad inspector = db.session.get(User, inspection.inspector_id) if inspector and inspector.id != current_user.id: note_suffix = f' Note: {note}' if note else '' notify( recipient = inspector, title = f'Follow-Up Required: Inspection #{inspection_id}', body = ( f'{current_user.username} has requested a follow-up re-inspection ' f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}' ), link = url_for('inspections.view', inspection_id=inspection_id), inspection_id = inspection_id, event_type = EVENT_INSPECTION_DONE, send_email = True, ) db.session.commit() current_app.logger.info( 'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r', inspection_id, current_user.username, note, ) log_action(ACTION_UPDATE, 'Inspection', inspection_id, f'{inspection.template.name} @ {inspection.facility.name}', f'follow_up_required=True; note={note!r}') flash('Follow-up inspection required flag set.', 'warning') return redirect(url_for('inspections.view', inspection_id=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 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(url_for('inspections.view', inspection_id=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')) 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 = [] 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/'): photo_paths.append(val) except (json.JSONDecodeError, TypeError): pass for issue in inspection.issues.all(): if issue.photo_path: photo_paths.append(issue.photo_path) db.session.delete(inspection) db.session.commit() for rel_path in photo_paths: abs_path = os.path.normpath( os.path.join(current_app.config['UPLOAD_FOLDER'], '..', 'static', rel_path) ) try: if os.path.isfile(abs_path): os.remove(abs_path) except OSError: pass 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(url_for('inspections.index'))