Mar 05 2026: implement reinspect label fix 3

This commit is contained in:
2026-03-05 11:02:30 -05:00
parent 22adcf7dfa
commit e27b92a7f8
+41 -91
View File
@@ -80,11 +80,8 @@ def _collect_form_responses(form_fields, existing_responses=None):
photo_file = request.files.get(key) photo_file = request.files.get(key)
path = _save_photo(photo_file, subfolder='inspection_photos') path = _save_photo(photo_file, subfolder='inspection_photos')
if path: if path:
# New file uploaded — use the new path
responses[fid] = path responses[fid] = path
else: else:
# No new file — preserve the previously saved photo path.
# JSON keys are always strings; try both str and original type.
existing_path = ( existing_path = (
existing_responses.get(str(fid)) existing_responses.get(str(fid))
or existing_responses.get(fid) or existing_responses.get(fid)
@@ -108,7 +105,6 @@ def _collect_form_responses(form_fields, existing_responses=None):
responses[fid] = request.form.get(key, '0') responses[fid] = request.form.get(key, '0')
else: else:
# text, textarea, number, date, email, radio, select, signature
responses[fid] = request.form.get(key, '') responses[fid] = request.form.get(key, '')
return responses return responses
@@ -132,11 +128,11 @@ def _compute_score_from_form(form_fields, responses):
try: try:
v = int(val) v = int(val)
if v == 0: if v == 0:
continue # 0 = not answered, skip entirely continue
earned += v earned += v
total += 5 total += 5
except (ValueError, TypeError): except (ValueError, TypeError):
pass # unparseable = not answered, skip pass
elif field['type'] == 'checkbox': elif field['type'] == 'checkbox':
total += 1 total += 1
@@ -144,7 +140,6 @@ def _compute_score_from_form(form_fields, responses):
earned += 1 earned += 1
elif field['type'] == 'radio': elif field['type'] == 'radio':
# Options that look like pass/yes/ok score 1; fail/no/na score 0
total += 1 total += 1
if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'): if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
earned += 1 earned += 1
@@ -232,7 +227,6 @@ def start():
form.template_id.choices = [(t.id, t.name) for t in templates] form.template_id.choices = [(t.id, t.name) for t in templates]
form.facility_id.choices = [(f.id, f.name) for f in facilities] form.facility_id.choices = [(f.id, f.name) for f in facilities]
# Pre-select template/facility when arriving from reinspect()
from flask import session as _session from flask import session as _session
if not form.is_submitted(): if not form.is_submitted():
if _session.get('reinspect_template_id'): if _session.get('reinspect_template_id'):
@@ -247,7 +241,6 @@ def start():
if form.validate_on_submit(): if form.validate_on_submit():
template = InspectionTemplate.query.get_or_404(form.template_id.data) template = InspectionTemplate.query.get_or_404(form.template_id.data)
# Guard: template must have a form built in the form editor
if not template.get_form_schema(): if not template.get_form_schema():
flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning') flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning')
return redirect(url_for('inspections.start')) return redirect(url_for('inspections.start'))
@@ -285,7 +278,7 @@ def areas_for_facility(facility_id):
return jsonify([{'id': a.id, 'name': a.name} for a in areas]) return jsonify([{'id': a.id, 'name': a.name} for a in areas])
# ── Execute — render and submit the template form ───────────────────────────── # ── Execute ───────────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST']) @bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
@login_required @login_required
@@ -301,11 +294,8 @@ def execute(inspection_id):
template = inspection.template template = inspection.template
form_fields = template.get_form_schema() form_fields = template.get_form_schema()
# Sort fields by grid position (row then col) for logical reading order
form_fields = sorted(form_fields, key=lambda f: (f.get('row', 0), f.get('col', 0))) form_fields = sorted(form_fields, key=lambda f: (f.get('row', 0), f.get('col', 0)))
# Load any previously saved draft responses
saved_responses = {} saved_responses = {}
if inspection.notes: if inspection.notes:
try: try:
@@ -317,17 +307,11 @@ def execute(inspection_id):
if request.method == 'POST': if request.method == 'POST':
action = request.form.get('action', 'submit') action = request.form.get('action', 'submit')
# Collect all field responses from the submitted form.
# Pass saved_responses so existing photo paths are preserved
# when no new file is selected on this submission.
responses = _collect_form_responses(form_fields, saved_responses) responses = _collect_form_responses(form_fields, saved_responses)
if action == 'submit': if action == 'submit':
# Validate required fields
missing = _validate_required(form_fields, responses) missing = _validate_required(form_fields, responses)
if missing: if missing:
# Save draft so the inspector doesn't lose their work
_save_draft(inspection, responses) _save_draft(inspection, responses)
flash( flash(
f'Please complete all required fields before submitting: ' f'Please complete all required fields before submitting: '
@@ -336,20 +320,15 @@ def execute(inspection_id):
) )
return redirect(url_for('inspections.execute', inspection_id=inspection_id)) return redirect(url_for('inspections.execute', inspection_id=inspection_id))
# Compute score and mark complete
score = _compute_score_from_form(form_fields, responses) score = _compute_score_from_form(form_fields, responses)
inspection.overall_score = score inspection.overall_score = score
inspection.status = 'completed' inspection.status = 'completed'
inspection.completed_at = now_eastern() inspection.completed_at = now_eastern()
# Persist the final form data alongside any inspector notes
_save_responses(inspection, responses) _save_responses(inspection, responses)
db.session.commit() db.session.commit()
# ── Notify supervisors/admins that an inspection was completed ── supervisors = User.query.filter(User.role.in_(['admin', 'supervisor'])).all()
supervisors = User.query.filter(
User.role.in_(['admin', 'supervisor'])
).all()
inspection_link = url_for('inspections.view', inspection_id=inspection.id) inspection_link = url_for('inspections.view', inspection_id=inspection.id)
score_display = f'{score:.1f}%' if score is not None else 'N/A' score_display = f'{score:.1f}%' if score is not None else 'N/A'
for supervisor in supervisors: for supervisor in supervisors:
@@ -368,7 +347,6 @@ def execute(inspection_id):
event_type = EVENT_INSPECTION_DONE, event_type = EVENT_INSPECTION_DONE,
send_email = True, send_email = True,
) )
# ── Notify customer portal users for this facility ──────────
notify_customers_for_facility( notify_customers_for_facility(
facility_id = inspection.facility_id, facility_id = inspection.facility_id,
event_type = EVENT_CUSTOMER_INSPECTION_DONE, event_type = EVENT_CUSTOMER_INSPECTION_DONE,
@@ -381,7 +359,7 @@ def execute(inspection_id):
link = url_for('inspections.view', inspection_id=inspection.id), link = url_for('inspections.view', inspection_id=inspection.id),
inspection_id = inspection.id, inspection_id = inspection.id,
) )
db.session.commit() # Commit notifications db.session.commit()
log_action(ACTION_UPDATE, 'Inspection', inspection.id, log_action(ACTION_UPDATE, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}', f'{inspection.template.name} @ {inspection.facility.name}',
f'status=completed; score={score}') f'status=completed; score={score}')
@@ -389,7 +367,7 @@ def execute(inspection_id):
flash('Inspection submitted successfully!', 'success') flash('Inspection submitted successfully!', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(url_for('inspections.view', inspection_id=inspection_id))
else: # save draft else:
_save_draft(inspection, responses) _save_draft(inspection, responses)
db.session.commit() db.session.commit()
flash('Draft saved. You can continue filling in the form later.', 'success') flash('Draft saved. You can continue filling in the form later.', 'success')
@@ -414,20 +392,16 @@ def _save_responses(inspection, responses):
def _save_draft(inspection, responses): def _save_draft(inspection, responses):
"""Save a draft of form responses — same storage as final, just status stays in_progress.""" """Save a draft — same storage as final, status stays in_progress."""
_save_responses(inspection, responses) _save_responses(inspection, responses)
db.session.commit() db.session.commit()
# ── AJAX: save draft (used by Flag Issue button before navigating away) ─────── # ── AJAX: save draft ──────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/save-draft', methods=['POST']) @bp.route('/<int:inspection_id>/save-draft', methods=['POST'])
@login_required @login_required
def save_draft_ajax(inspection_id): def save_draft_ajax(inspection_id):
"""
Accepts a JSON body of { field_id: value } and persists it as a draft.
Returns JSON { ok: true } on success so the caller can navigate to flag_issue.
"""
inspection = Inspection.query.get_or_404(inspection_id) inspection = Inspection.query.get_or_404(inspection_id)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
@@ -439,7 +413,6 @@ def save_draft_ajax(inspection_id):
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
responses = data.get('responses', {}) responses = data.get('responses', {})
# Merge with any previously saved responses so photo paths are preserved
existing_responses = {} existing_responses = {}
if inspection.notes: if inspection.notes:
try: try:
@@ -449,10 +422,7 @@ def save_draft_ajax(inspection_id):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
# Only overwrite keys that are present in the submitted data;
# preserve photo paths for image fields not included in the JSON payload.
merged = {**existing_responses, **responses} merged = {**existing_responses, **responses}
_save_responses(inspection, merged) _save_responses(inspection, merged)
db.session.commit() db.session.commit()
@@ -483,7 +453,6 @@ def view(inspection_id):
form_fields = sorted(template.get_form_schema(), form_fields = sorted(template.get_form_schema(),
key=lambda f: (f.get('row', 0), f.get('col', 0))) key=lambda f: (f.get('row', 0), f.get('col', 0)))
# Decode saved responses
form_data = {} form_data = {}
if inspection.notes: if inspection.notes:
try: try:
@@ -496,14 +465,16 @@ def view(inspection_id):
issues = inspection.issues.order_by(Issue.reported_at.desc()).all() issues = inspection.issues.order_by(Issue.reported_at.desc()).all()
# ── Score comparison against parent inspection ──────────────────────── # ── Score comparison against parent inspection ────────────────────────
# Scores live in form_data (field_id -> value) inside inspection.notes, # Scores live in form_data (field_id -> value) inside inspection.notes.
# NOT in InspectionResult checklist rows. We walk the shared form_fields # We walk the shared form_fields schema and compare current vs. parent
# schema and compare current vs. parent responses per scoreable field. # responses per scoreable field, resolving item names via three strategies:
# 1. Leftmost text field value on the same grid row (e.g. "Brick Floor")
# 2. field['label'] if explicitly set
# 3. Nearest preceding section/label element text
comparison = None comparison = None
if inspection.parent and inspection.parent.status == 'completed': if inspection.parent and inspection.parent.status == 'completed':
parent = inspection.parent parent = inspection.parent
# Decode parent form_data from its notes field
parent_form_data = {} parent_form_data = {}
if parent.notes: if parent.notes:
try: try:
@@ -515,33 +486,19 @@ def view(inspection_id):
scoreable_types = ('rating', 'checkbox', 'radio') scoreable_types = ('rating', 'checkbox', 'radio')
# Build a label lookup keyed by field ID using three strategies in priority order: # Collect all text/textarea fields per row, keyed by (col, fid)
# # so we can pick the leftmost non-empty value as the item name.
# 1. Same-row text value: a text/textarea field on the same grid row whose _row_text_candidates = {} # row → [(col, fid), ...]
# submitted value (e.g. "Brick Floor") is the item name. This is the most _preceding_label = {} # field_id → nearest section/label text
# specific and matches the pattern shown in the inspection form.
# 2. field['label']: the field's own label property if explicitly set.
# 3. Preceding label element: the nearest 'label'-type field above in the schema
# (uses text_content, which is what the form builder stores label text in).
#
# We build row→text_value and row→section_label maps in one pass,
# then resolve each scoreable field's display name.
# Map: grid row → text field value from current form_data (item name column)
_row_text_val = {}
# Map: grid row → text field value from parent form_data
_row_text_val_parent = {}
# Map: field_id → nearest preceding label text_content
_preceding_label = {}
_last_label_text = '' _last_label_text = ''
for _f in form_fields: for _f in form_fields:
_ftype = _f.get('type') _ftype = _f.get('type')
_frow = _f.get('row', 0) _frow = _f.get('row', 0)
_fcol = _f.get('col', 0)
_fid = str(_f.get('id', '')) _fid = str(_f.get('id', ''))
if _ftype == 'label': if _ftype == 'label':
# label elements store their text in text_content, not label
_last_label_text = ( _last_label_text = (
_f.get('text_content') _f.get('text_content')
or _f.get('label') or _f.get('label')
@@ -549,15 +506,9 @@ def view(inspection_id):
or _last_label_text or _last_label_text
) )
elif _ftype in ('text', 'textarea'): elif _ftype in ('text', 'textarea'):
# Record the submitted value of text fields indexed by row if _frow not in _row_text_candidates:
_cur_txt = str(form_data.get(_fid, '')).strip() _row_text_candidates[_frow] = []
_par_txt = str(parent_form_data.get(_fid, '')).strip() _row_text_candidates[_frow].append((_fcol, _fid))
if _cur_txt:
_row_text_val[_frow] = _cur_txt
elif _par_txt:
_row_text_val[_frow] = _par_txt
if _par_txt:
_row_text_val_parent[_frow] = _par_txt
elif _ftype == 'section': elif _ftype == 'section':
_last_label_text = ( _last_label_text = (
_f.get('label') or _f.get('text_content') or _last_label_text _f.get('label') or _f.get('text_content') or _last_label_text
@@ -565,16 +516,25 @@ def view(inspection_id):
elif _ftype in scoreable_types: elif _ftype in scoreable_types:
_preceding_label[_fid] = _last_label_text _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): def _resolve_label(field, fid):
frow = field.get('row', 0) frow = field.get('row', 0)
# Priority 1: same-row text field value (the item name)
if frow in _row_text_val: if frow in _row_text_val:
return _row_text_val[frow] return _row_text_val[frow]
# Priority 2: field's own label property
own = field.get('label') or field.get('placeholder') own = field.get('label') or field.get('placeholder')
if own: if own:
return own return own
# Priority 3: nearest preceding label element
return _preceding_label.get(fid) or fid return _preceding_label.get(fid) or fid
rows = [] rows = []
@@ -687,7 +647,6 @@ def flag_issue(inspection_id):
issue.id, inspection_id, issue.severity, issue.assigned_to, current_user.username issue.id, inspection_id, issue.severity, issue.assigned_to, current_user.username
) )
# ── Notify the assignee of the flagged issue ─────────────────────
if issue.assigned_to: if issue.assigned_to:
assignee = User.query.get(issue.assigned_to) assignee = User.query.get(issue.assigned_to)
if assignee and assignee.id != current_user.id: if assignee and assignee.id != current_user.id:
@@ -706,9 +665,8 @@ def flag_issue(inspection_id):
event_type = EVENT_ISSUE_ASSIGNED, event_type = EVENT_ISSUE_ASSIGNED,
send_email = True, send_email = True,
) )
db.session.commit() # Commit notification db.session.commit()
# ── Notify customer portal users for this facility ──────────
notify_customers_for_facility( notify_customers_for_facility(
facility_id = inspection.facility_id, facility_id = inspection.facility_id,
event_type = EVENT_CUSTOMER_ISSUE_UPDATED, event_type = EVENT_CUSTOMER_ISSUE_UPDATED,
@@ -732,7 +690,7 @@ def flag_issue(inspection_id):
form=form, inspection=inspection) form=form, inspection=inspection)
# ── Export to PDF ──────────────────────────────────────────────────────────── # ── Export to PDF ────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/export-pdf') @bp.route('/<int:inspection_id>/export-pdf')
@login_required @login_required
@@ -740,7 +698,6 @@ def export_pdf(inspection_id):
"""Generate and stream a PDF report for the given inspection.""" """Generate and stream a PDF report for the given inspection."""
inspection = Inspection.query.get_or_404(inspection_id) inspection = Inspection.query.get_or_404(inspection_id)
# Inspectors may only export their own inspections
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
@@ -764,10 +721,8 @@ def export_pdf(inspection_id):
pass pass
issues = inspection.issues.order_by(Issue.reported_at.desc()).all() issues = inspection.issues.order_by(Issue.reported_at.desc()).all()
static_folder = os.path.join(current_app.root_path, 'static') static_folder = os.path.join(current_app.root_path, 'static')
# Log image field values to help diagnose missing-photo issues in PDF export
for field in form_fields: for field in form_fields:
if field.get('type') == 'image': if field.get('type') == 'image':
fid = str(field.get('id', '')) fid = str(field.get('id', ''))
@@ -783,7 +738,7 @@ def export_pdf(inspection_id):
form_fields = form_fields, form_fields = form_fields,
form_data = form_data, form_data = form_data,
issues = issues, issues = issues,
static_folder= static_folder, static_folder = static_folder,
) )
filename = (f'inspection_{inspection.id}_' filename = (f'inspection_{inspection.id}_'
@@ -859,7 +814,6 @@ def reinspect(inspection_id):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
# Store parent context in session so start() can pick it up
session['reinspect_parent_id'] = parent.id session['reinspect_parent_id'] = parent.id
session['reinspect_template_id'] = parent.template_id session['reinspect_template_id'] = parent.template_id
session['reinspect_facility_id'] = parent.facility_id session['reinspect_facility_id'] = parent.facility_id
@@ -879,15 +833,12 @@ def reinspect(inspection_id):
def delete(inspection_id): def delete(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id) inspection = Inspection.query.get_or_404(inspection_id)
# Capture identifiers for the log before deletion
insp_id = inspection.id insp_id = inspection.id
insp_date = inspection.inspection_date.strftime('%Y-%m-%d %H:%M') insp_date = inspection.inspection_date.strftime('%Y-%m-%d %H:%M')
facility_name = inspection.facility.name facility_name = inspection.facility.name
template_name = inspection.template.name template_name = inspection.template.name
inspector_name = inspection.inspector.username inspector_name = inspection.inspector.username
# ── Clean up uploaded photos from disk ────────────────────────────────
# Collect photo paths from form data (inspection_photos) and issues
photo_paths = [] photo_paths = []
if inspection.notes: if inspection.notes:
try: try:
@@ -903,19 +854,18 @@ def delete(inspection_id):
if issue.photo_path: if issue.photo_path:
photo_paths.append(issue.photo_path) photo_paths.append(issue.photo_path)
# Delete the inspection record (cascade removes results and issues)
db.session.delete(inspection) db.session.delete(inspection)
db.session.commit() db.session.commit()
# Remove photo files after successful DB commit
for rel_path in photo_paths: for rel_path in photo_paths:
abs_path = os.path.join(current_app.config['UPLOAD_FOLDER'], '..', 'static', rel_path) abs_path = os.path.normpath(
abs_path = os.path.normpath(abs_path) os.path.join(current_app.config['UPLOAD_FOLDER'], '..', 'static', rel_path)
)
try: try:
if os.path.isfile(abs_path): if os.path.isfile(abs_path):
os.remove(abs_path) os.remove(abs_path)
except OSError: except OSError:
pass # Non-fatal: log but don't block the response pass
current_app.logger.info( current_app.logger.info(
'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | ' 'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | '