05/25 Improvement 1

This commit is contained in:
2026-05-25 12:23:59 -04:00
parent e375f1f70e
commit 9574d15f20
15 changed files with 610 additions and 82 deletions
+47 -8
View File
@@ -1,12 +1,13 @@
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
from app import db, limiter
from app.models.inspection import (Inspection, InspectionTemplate,
ChecklistItem, InspectionResult)
from app.models.facility import Facility, Area
@@ -28,6 +29,15 @@ 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',
@@ -42,6 +52,11 @@ def _save_photo(file_obj, subfolder='inspection_photos'):
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)
@@ -403,7 +418,9 @@ def execute(inspection_id):
inspection.status = 'completed'
inspection.completed_at = now_eastern()
_save_responses(inspection, responses)
# 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.
@@ -437,14 +454,20 @@ def execute(inspection_id):
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)
saved_responses=saved_responses,
staff_for_flag_issue=staff_for_flag_issue)
def _save_responses(inspection, responses):
"""Persist final form responses into inspection.notes as JSON."""
def _save_responses(inspection, responses, snapshot_schema=None):
"""Persist form responses (and optionally the template schema) into inspection.notes."""
existing = {}
if inspection.notes:
try:
@@ -452,6 +475,8 @@ def _save_responses(inspection, responses):
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)
@@ -503,6 +528,7 @@ def save_draft_ajax(inspection_id):
@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:
@@ -544,9 +570,22 @@ def view(inspection_id):
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
template = inspection.template
form_fields = sorted(template.get_form_schema(),
key=lambda f: (f.get('row', 0), f.get('col', 0)))
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: