05/25 Improvement 1
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user