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
+17
View File
@@ -203,6 +203,22 @@ def index():
for r in perf_rows
]
# ── My open issues (inspector dashboard widget) ───────────────────────────
# Issues assigned to the current inspector that are not yet resolved,
# ordered by SLA urgency (breached first, then at-risk, then ok).
my_issues = []
if is_inspector:
my_issues = (
Issue.query
.filter(
Issue.assigned_to == current_user.id,
Issue.status.in_(['open', 'in_progress']),
)
.order_by(Issue.reported_at.asc())
.limit(10)
.all()
)
# ── Facilities list for the trend-by-facility chart selector ────────────
if is_privileged or is_project_manager:
all_facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
@@ -232,6 +248,7 @@ def index():
customer_facilities = customer_facilities,
pending_followups = pending_followups,
all_facilities = all_facilities,
my_issues = my_issues,
)
+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:
+37
View File
@@ -180,6 +180,8 @@ def view(issue_id):
if not facility or facility.id not in cids:
flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
if request.method == 'POST':
abort(403)
form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
@@ -542,6 +544,41 @@ def verify(issue_id):
return redirect(url_for('issues.view', issue_id=issue_id))
@bp.route('/bulk-verify', methods=['POST'])
@login_required
@supervisor_required
def bulk_verify():
"""Verify multiple pending-verification issues in a single action."""
issue_ids = request.form.getlist('issue_ids', type=int)
if not issue_ids:
flash('No issues selected.', 'warning')
return redirect(url_for('issues.verification_queue'))
verified_count = 0
for issue_id in issue_ids:
issue = db.session.get(Issue, issue_id)
if issue is None or issue.status not in ('resolved', 'pending_verification'):
continue
issue.status = 'resolved'
issue.verified_by = current_user.id
issue.verified_at = now_eastern()
if not issue.resolved_at:
issue.resolved_at = now_eastern()
verified_count += 1
if verified_count:
db.session.commit()
for issue_id in issue_ids:
issue = db.session.get(Issue, issue_id)
if issue and issue.verified_by == current_user.id:
log_action(ACTION_UPDATE, 'Issue', issue_id,
f'#{issue_id}',
f'bulk_verified_by={current_user.username}')
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
return redirect(url_for('issues.verification_queue'))
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
@login_required
def request_verification(issue_id):
+21 -1
View File
@@ -41,6 +41,7 @@ from app.models.user import User
from app.utils.decorators import supervisor_required, admin_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils.sla import sla_status
logger = logging.getLogger(__name__)
@@ -149,10 +150,29 @@ def _build_report_data(report: ScheduledReport, start: datetime, end: datetime)
.order_by(func.avg(Inspection.overall_score).desc()).all()
if report.report_type == 'issues':
data['issues'] = _iq(Issue.query.filter(
all_issues = _iq(Issue.query.filter(
Issue.status != 'resolved',
)).order_by(Issue.severity.desc(), Issue.reported_at.asc()).all()
data['issues'] = all_issues
# Group by facility with per-issue SLA status for the enhanced email template
fac_map = {}
sla_breached = sla_at_risk = 0
for issue in all_issues:
fac = issue.resolved_facility
fname = fac.name if fac else '(No Facility)'
s = sla_status(issue)
if s == 'breached':
sla_breached += 1
elif s == 'at_risk':
sla_at_risk += 1
fac_map.setdefault(fname, []).append((issue, s))
data['issues_by_facility'] = sorted(fac_map.items())
data['sla_breached'] = sla_breached
data['sla_at_risk'] = sla_at_risk
return data