06/09 Add Inspector Performance export to excel function
This commit is contained in:
+273
-9
@@ -577,15 +577,12 @@ def export_issues():
|
|||||||
|
|
||||||
# ── Inspector Performance ─────────────────────────────────────────────────────
|
# ── Inspector Performance ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/inspector-performance')
|
def _build_inspector_stats(start, end):
|
||||||
@login_required
|
"""Return (inspector_stats, team_avg_score) for the given date range.
|
||||||
@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)
|
|
||||||
|
|
||||||
# ── 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(
|
total_rows = db.session.query(
|
||||||
Inspection.inspector_id,
|
Inspection.inspector_id,
|
||||||
func.count(Inspection.id).label('total'),
|
func.count(Inspection.id).label('total'),
|
||||||
@@ -675,7 +672,6 @@ def inspector_performance():
|
|||||||
|
|
||||||
inspector_stats.sort(key=lambda x: (x['avg_score'] is None, -(x['avg_score'] or 0)))
|
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]
|
_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
|
team_avg_score = round(sum(_scores) / len(_scores), 1) if _scores else None
|
||||||
for s in inspector_stats:
|
for s in inspector_stats:
|
||||||
@@ -684,6 +680,19 @@ def inspector_performance():
|
|||||||
else:
|
else:
|
||||||
s['vs_avg'] = None
|
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 ─────────────────────────────────────────────
|
# ── Individual drill-down ─────────────────────────────────────────────
|
||||||
selected_inspector = None
|
selected_inspector = None
|
||||||
selected_kpis = None
|
selected_kpis = None
|
||||||
@@ -740,3 +749,258 @@ def inspector_performance():
|
|||||||
recent_inspections=recent_inspections,
|
recent_inspections=recent_inspections,
|
||||||
selected_id=selected_id,
|
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}"'},
|
||||||
|
)
|
||||||
@@ -35,6 +35,15 @@
|
|||||||
<h2><i class="bi bi-person-lines-fill"></i> Inspector Performance</h2>
|
<h2><i class="bi bi-person-lines-fill"></i> Inspector Performance</h2>
|
||||||
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
|
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('reports.export_inspector_performance',
|
||||||
|
start=start.strftime('%Y-%m-%d'),
|
||||||
|
end=end.strftime('%Y-%m-%d'),
|
||||||
|
**({'inspector_id': selected_id} if selected_id else {})) }}"
|
||||||
|
class="btn btn-sm btn-success">
|
||||||
|
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export Excel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Date filter ── #}
|
{# ── Date filter ── #}
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ gunicorn
|
|||||||
reportlab
|
reportlab
|
||||||
pytz
|
pytz
|
||||||
pyJWT
|
pyJWT
|
||||||
|
openpyxl
|
||||||
|
|||||||
Reference in New Issue
Block a user