From 711ecb8d2776acce8c7fe49559a2a510d50e5383 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 9 Jun 2026 11:08:37 -0400 Subject: [PATCH] 06/09 Add Inspector Performance export to excel function --- app/routes/reports.py | 292 +++++++++++++++++- .../reports/inspector_performance.html | 9 + requirements.txt | 1 + 3 files changed, 288 insertions(+), 14 deletions(-) diff --git a/app/routes/reports.py b/app/routes/reports.py index b805390..bc92ad7 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -577,15 +577,12 @@ def export_issues(): # ── Inspector Performance ───────────────────────────────────────────────────── -@bp.route('/inspector-performance') -@login_required -@supervisor_required -def inspector_performance(): - """KPI & performance dashboard for all inspectors — admin/director only.""" - start, end = _date_range() - selected_id = request.args.get('inspector_id', type=int) +def _build_inspector_stats(start, end): + """Return (inspector_stats, team_avg_score) for the given date range. - # ── Per-inspector aggregate stats ───────────────────────────────────── + inspector_stats is a list of dicts sorted by avg_score desc. + team_avg_score is the mean of all inspectors' avg_score values (or None). + """ total_rows = db.session.query( Inspection.inspector_id, func.count(Inspection.id).label('total'), @@ -653,11 +650,11 @@ def inspector_performance(): inspector_stats = [] for u in active_inspectors: - t = total_map.get(u.id, {}) - c = completed_map.get(u.id, {}) - tot = t.get('total', 0) - comp = c.get('completed', 0) - avg = round(float(c['avg_score']), 1) if c.get('avg_score') else None + t = total_map.get(u.id, {}) + c = completed_map.get(u.id, {}) + tot = t.get('total', 0) + comp = c.get('completed', 0) + avg = round(float(c['avg_score']), 1) if c.get('avg_score') else None avg_mins = round(float(c['avg_minutes'])) if c.get('avg_minutes') else None inspector_stats.append({ 'id': u.id, @@ -675,7 +672,6 @@ def inspector_performance(): inspector_stats.sort(key=lambda x: (x['avg_score'] is None, -(x['avg_score'] or 0))) - # Team average score for benchmarking (vs. avg column) _scores = [s['avg_score'] for s in inspector_stats if s['avg_score'] is not None] team_avg_score = round(sum(_scores) / len(_scores), 1) if _scores else None for s in inspector_stats: @@ -684,6 +680,19 @@ def inspector_performance(): else: s['vs_avg'] = None + return inspector_stats, team_avg_score + + +@bp.route('/inspector-performance') +@login_required +@supervisor_required +def inspector_performance(): + """KPI & performance dashboard for all inspectors — admin/director only.""" + start, end = _date_range() + selected_id = request.args.get('inspector_id', type=int) + + inspector_stats, team_avg_score = _build_inspector_stats(start, end) + # ── Individual drill-down ───────────────────────────────────────────── selected_inspector = None selected_kpis = None @@ -739,4 +748,259 @@ def inspector_performance(): trend_data=trend_data, recent_inspections=recent_inspections, selected_id=selected_id, + ) + + +@bp.route('/export/inspector-performance') +@login_required +@supervisor_required +def export_inspector_performance(): + """Download inspector performance summary as an Excel workbook (.xlsx).""" + try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + except ImportError: + abort(501) + + start, end = _date_range() + selected_id = request.args.get('inspector_id', type=int) + + logger.info( + 'REPORTS | export_inspector_performance | user=%s | range=%s to %s', + current_user.username, + start.strftime('%Y-%m-%d'), + end.strftime('%Y-%m-%d'), + ) + log_action( + ACTION_EXPORT, 'InspectorPerformance', None, 'Excel Export', + f'range={start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}', + ) + + inspector_stats, team_avg_score = _build_inspector_stats(start, end) + + # ── Inspection detail rows ──────────────────────────────────────────── + detail_q = db.session.query( + Inspection.id, + Inspection.inspection_date, + Inspection.completed_at, + Inspection.overall_score, + Inspection.status, + Inspection.follow_up_required, + User.full_name.label('inspector_name'), + User.username.label('inspector_username'), + Facility.name.label('facility'), + Area.name.label('area'), + InspectionTemplate.name.label('template'), + ).join(User, Inspection.inspector_id == User.id)\ + .join(Facility, Inspection.facility_id == Facility.id)\ + .outerjoin(Area, Inspection.area_id == Area.id)\ + .join(InspectionTemplate, Inspection.template_id == InspectionTemplate.id)\ + .filter( + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + User.role == 'inspector', + ) + if selected_id: + detail_q = detail_q.filter(Inspection.inspector_id == selected_id) + detail_rows = detail_q.order_by(User.full_name, Inspection.inspection_date.desc()).all() + + # ── Build workbook ──────────────────────────────────────────────────── + wb = Workbook() + + # ── Shared styles ───────────────────────────────────────────────────── + hdr_font = Font(bold=True, color='FFFFFF', size=11) + hdr_fill = PatternFill('solid', fgColor='1A56DB') # blue + sub_fill = PatternFill('solid', fgColor='E8F0FE') # light blue stripe + good_fill = PatternFill('solid', fgColor='D1FAE5') # green + warn_fill = PatternFill('solid', fgColor='FEF3C7') # yellow + bad_fill = PatternFill('solid', fgColor='FEE2E2') # red + center = Alignment(horizontal='center', vertical='center', wrap_text=False) + left = Alignment(horizontal='left', vertical='center') + thin = Side(style='thin', color='D1D5DB') + cell_border = Border(left=thin, right=thin, top=thin, bottom=thin) + + def _apply_header(ws, headers, col_widths): + ws.row_dimensions[1].height = 22 + for col_idx, (text, width) in enumerate(zip(headers, col_widths), start=1): + cell = ws.cell(row=1, column=col_idx, value=text) + cell.font = hdr_font + cell.fill = hdr_fill + cell.alignment = center + cell.border = cell_border + ws.column_dimensions[get_column_letter(col_idx)].width = width + + def _score_fill(score): + if score is None: + return None + if score >= 90: + return good_fill + if score >= 70: + return warn_fill + return bad_fill + + # ── Sheet 1: Performance Summary ────────────────────────────────────── + ws1 = wb.active + ws1.title = 'Performance Summary' + ws1.freeze_panes = 'A2' + + period_label = f'{start.strftime("%b %d, %Y")} — {end.strftime("%b %d, %Y")}' + ws1['A1'] = f'Inspector Performance Summary | {period_label}' + ws1.merge_cells('A1:K1') + title_cell = ws1['A1'] + title_cell.font = Font(bold=True, size=13, color='1A56DB') + title_cell.alignment = left + ws1.row_dimensions[1].height = 28 + + summary_headers = [ + 'Inspector', 'Total\nInspections', 'Completed', 'Completion\nRate (%)', + 'Avg Score\n(%)', 'vs. Team\nAvg', 'Avg Time', + 'Issues\nFlagged', 'Follow-\nUps', 'Facilities', + 'Team Avg\nScore (%)', + ] + summary_widths = [24, 13, 12, 15, 13, 13, 11, 13, 10, 12, 14] + + hdr_row = 2 + ws1.row_dimensions[hdr_row].height = 34 + for col_idx, (text, width) in enumerate(zip(summary_headers, summary_widths), start=1): + cell = ws1.cell(row=hdr_row, column=col_idx, value=text) + cell.font = hdr_font + cell.fill = hdr_fill + cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + cell.border = cell_border + ws1.column_dimensions[get_column_letter(col_idx)].width = width + + for row_idx, s in enumerate(inspector_stats, start=3): + stripe = sub_fill if row_idx % 2 == 0 else None + row_data = [ + s['display_name'], + s['total'], + s['completed'], + s['completion_rate'], + s['avg_score'], + s['vs_avg'], + s['avg_time'] or '—', + s['issues_flagged'], + s['follow_ups'], + s['facilities'], + team_avg_score, + ] + for col_idx, value in enumerate(row_data, start=1): + cell = ws1.cell(row=row_idx, column=col_idx, value=value) + cell.border = cell_border + cell.alignment = center if col_idx > 1 else left + if stripe: + cell.fill = stripe + + # Score colour bands + score_cell = ws1.cell(row=row_idx, column=5) + score_fill = _score_fill(s['avg_score']) + if score_fill: + score_cell.fill = score_fill + score_cell.font = Font(bold=True) + + # vs. avg colour + vs_cell = ws1.cell(row=row_idx, column=6) + if s['vs_avg'] is not None: + vs_cell.fill = good_fill if s['vs_avg'] >= 0 else bad_fill + vs_cell.font = Font(bold=True) + + # Completion rate colour + cr_cell = ws1.cell(row=row_idx, column=4) + rate = s['completion_rate'] + cr_cell.fill = (good_fill if rate >= 90 else warn_fill if rate >= 70 else bad_fill) + + ws1.row_dimensions[row_idx].height = 18 + + # Totals row + if inspector_stats: + tot_row = len(inspector_stats) + 3 + ws1.row_dimensions[tot_row].height = 20 + total_cell = ws1.cell(row=tot_row, column=1, value='TEAM TOTAL / AVG') + total_cell.font = Font(bold=True) + total_cell.border = cell_border + total_cell.alignment = left + total_inspections = sum(s['total'] for s in inspector_stats) + total_completed = sum(s['completed'] for s in inspector_stats) + total_issues = sum(s['issues_flagged'] for s in inspector_stats) + total_followups = sum(s['follow_ups'] for s in inspector_stats) + team_cr = round(total_completed / total_inspections * 100) if total_inspections else 0 + for col_idx, value in enumerate([ + total_inspections, total_completed, team_cr, + team_avg_score, None, None, + total_issues, total_followups, None, None, + ], start=2): + cell = ws1.cell(row=tot_row, column=col_idx, value=value) + cell.font = Font(bold=True) + cell.border = cell_border + cell.alignment = center + cell.fill = PatternFill('solid', fgColor='DBEAFE') + + # ── Sheet 2: Inspection Detail ──────────────────────────────────────── + ws2 = wb.create_sheet('Inspection Detail') + ws2.freeze_panes = 'A2' + + detail_headers = [ + 'ID', 'Inspector', 'Date', 'Completed At', + 'Facility', 'Area', 'Template', + 'Score (%)', 'Status', 'Follow-up\nRequired', + 'Duration\n(min)', + ] + detail_widths = [7, 22, 18, 18, 28, 20, 22, 12, 18, 14, 13] + _apply_header(ws2, detail_headers, detail_widths) + + status_map = { + 'completed': 'Submitted', + 'in_progress': 'In Progress', + 'flagged': 'Flagged', + } + for row_idx, r in enumerate(detail_rows, start=2): + duration = None + if r.completed_at and r.inspection_date: + duration = round((r.completed_at - r.inspection_date).total_seconds() / 60) + inspector_label = (r.inspector_name.strip() if r.inspector_name and r.inspector_name.strip() + else r.inspector_username) + row_data = [ + r.id, + inspector_label, + r.inspection_date.strftime('%Y-%m-%d %H:%M') if r.inspection_date else '', + r.completed_at.strftime('%Y-%m-%d %H:%M') if r.completed_at else '', + r.facility, + r.area or '', + r.template, + r.overall_score, + status_map.get(r.status, r.status), + 'Yes' if r.follow_up_required else 'No', + duration, + ] + stripe = sub_fill if row_idx % 2 == 0 else None + for col_idx, value in enumerate(row_data, start=1): + cell = ws2.cell(row=row_idx, column=col_idx, value=value) + cell.border = cell_border + cell.alignment = center if col_idx != 2 else left + if stripe: + cell.fill = stripe + + score_cell = ws2.cell(row=row_idx, column=8) + sf = _score_fill(r.overall_score) + if sf: + score_cell.fill = sf + score_cell.font = Font(bold=True) + + ws2.row_dimensions[row_idx].height = 16 + + # ── Serialize & return ──────────────────────────────────────────────── + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + + suffix = f'_{selected_id}' if selected_id else '' + filename = ( + f'inspector_performance{suffix}_' + f'{start.strftime("%Y%m%d")}_{end.strftime("%Y%m%d")}.xlsx' + ) + return Response( + buf.read(), + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, ) \ No newline at end of file diff --git a/app/templates/reports/inspector_performance.html b/app/templates/reports/inspector_performance.html index d3b04c5..757c9ff 100644 --- a/app/templates/reports/inspector_performance.html +++ b/app/templates/reports/inspector_performance.html @@ -35,6 +35,15 @@

Inspector Performance

{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}

+
+ + Export Excel + +
{# ── Date filter ── #} diff --git a/requirements.txt b/requirements.txt index d88ecf4..10f14b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ gunicorn reportlab pytz pyJWT +openpyxl