1141 lines
47 KiB
Python
1141 lines
47 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
|
||
from app.utils.pdf_export import generate_inspection_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':
|
||
photo_file = request.files.get(key)
|
||
path = _save_photo(photo_file, subfolder='inspection_photos')
|
||
if path:
|
||
responses[fid] = path
|
||
else:
|
||
existing_path = (
|
||
existing_responses.get(str(fid))
|
||
or existing_responses.get(fid)
|
||
or ''
|
||
)
|
||
responses[fid] = existing_path
|
||
|
||
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', '')
|
||
|
||
if 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())
|
||
|
||
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()
|
||
|
||
return render_template('inspections/list.html',
|
||
inspections=inspections,
|
||
facilities=facilities,
|
||
projects=projects,
|
||
status_filter=status_filter,
|
||
facility_filter=facility_filter,
|
||
follow_up_filter=follow_up_filter,
|
||
contract_filter=contract_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 —')]
|
||
|
||
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)
|
||
inspection = Inspection(
|
||
template_id = template.id,
|
||
facility_id = form.facility_id.data,
|
||
area_id = None,
|
||
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])
|
||
|
||
|
||
# ── 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.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()
|
||
|
||
# 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('/<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.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('/<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.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('/<int:inspection_id>')
|
||
@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('/<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.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,
|
||
exclude_user_ids = {current_user.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 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)
|
||
|
||
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('/<int:inspection_id>/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('/<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
|
||
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('/<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'))
|
||
|
||
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 = []
|
||
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')) |