1947 lines
85 KiB
Python
1947 lines
85 KiB
Python
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.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/<subfolder>/<uuid>.<ext>' 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
|
||
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/<int:facility_id>')
|
||
@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/<int:project_id>')
|
||
@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])
|
||
|
||
|
||
# ── AJAX: forms available on a given contract (phase52) ──────────────────────
|
||
|
||
@bp.route('/templates_for_project/<int:project_id>')
|
||
@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('/<int:inspection_id>/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 scheduled inspection, if any: one-time
|
||
# schedules deactivate; recurring ones roll their due date forward
|
||
# and reset reminder flags. Staged in the same atomic commit below.
|
||
if inspection.scheduled_inspection_id:
|
||
from app.models.scheduled_inspection import ScheduledInspection
|
||
sched = db.session.get(ScheduledInspection, inspection.scheduled_inspection_id)
|
||
if sched is not None:
|
||
sched.fulfill()
|
||
current_app.logger.info(
|
||
'SCHED INSP | fulfilled | schedule=%s | inspection=%s | next_due=%s',
|
||
sched.id, inspection.id,
|
||
sched.next_due_date if sched.active else 'deactivated',
|
||
)
|
||
|
||
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('/<int:inspection_id>/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('/<int:inspection_id>/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('/<int:inspection_id>')
|
||
@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 (phase53) 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('/<int:inspection_id>/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)]
|
||
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('/<int:inspection_id>/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 phase53 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('/<int:inspection_id>/flag-followup', methods=['POST'])
|
||
@login_required
|
||
def flag_followup(inspection_id):
|
||
"""Mark an inspection as requiring a follow-up re-inspection.
|
||
|
||
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.
|
||
|
||
Customers may only *request*: they cannot clear the flag (see
|
||
clear_followup, still admin/director) 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 (phase53) ────────────────────────────────────────────────
|
||
# 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 (rule 73). 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('/<int:inspection_id>/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
|
||
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('/<int:inspection_id>/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 (phase53). 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 (phase53). 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('/<int:inspection_id>/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'))) |