Mar 05 2026: implement reinspect comparision, bulk user import
This commit is contained in:
@@ -495,11 +495,79 @@ def view(inspection_id):
|
|||||||
|
|
||||||
issues = inspection.issues.order_by(Issue.reported_at.desc()).all()
|
issues = inspection.issues.order_by(Issue.reported_at.desc()).all()
|
||||||
|
|
||||||
|
# ── Score comparison against parent inspection ────────────────────────
|
||||||
|
# Produces a list of per-item dicts so the template can render a
|
||||||
|
# side-by-side delta table without any additional queries at render time.
|
||||||
|
comparison = None
|
||||||
|
if inspection.parent and inspection.parent.status == 'completed':
|
||||||
|
parent = inspection.parent
|
||||||
|
|
||||||
|
# Index parent results by checklist_item_id
|
||||||
|
parent_results = {
|
||||||
|
r.checklist_item_id: r
|
||||||
|
for r in parent.results.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for result in inspection.results.order_by(
|
||||||
|
InspectionResult.checklist_item_id
|
||||||
|
).all():
|
||||||
|
item = result.checklist_item
|
||||||
|
p_res = parent_results.get(result.checklist_item_id)
|
||||||
|
|
||||||
|
# Normalise scores to a 0–100 percentage for fair comparison
|
||||||
|
def _pct(res, ci):
|
||||||
|
if res is None:
|
||||||
|
return None
|
||||||
|
if ci.scoring_type == 'pass_fail':
|
||||||
|
if res.passed is None:
|
||||||
|
return None
|
||||||
|
return 100.0 if res.passed else 0.0
|
||||||
|
if ci.scoring_type == 'rating_5':
|
||||||
|
return (float(res.score) / 5 * 100) if res.score is not None else None
|
||||||
|
if ci.scoring_type == 'rating_10':
|
||||||
|
return (float(res.score) / 10 * 100) if res.score is not None else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur_pct = _pct(result, item)
|
||||||
|
par_pct = _pct(p_res, item) if p_res else None
|
||||||
|
|
||||||
|
if cur_pct is None and par_pct is None:
|
||||||
|
continue # skip unanswered items on both sides
|
||||||
|
|
||||||
|
delta = None
|
||||||
|
if cur_pct is not None and par_pct is not None:
|
||||||
|
delta = round(cur_pct - par_pct, 1)
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
'category': item.category or 'Uncategorised',
|
||||||
|
'description': item.item_description,
|
||||||
|
'parent_pct': round(par_pct, 1) if par_pct is not None else None,
|
||||||
|
'current_pct': round(cur_pct, 1) if cur_pct is not None else None,
|
||||||
|
'delta': delta,
|
||||||
|
})
|
||||||
|
|
||||||
|
comparison = {
|
||||||
|
'parent_id': parent.id,
|
||||||
|
'parent_date': parent.inspection_date,
|
||||||
|
'parent_score': float(parent.overall_score) if parent.overall_score else None,
|
||||||
|
'current_score': float(inspection.overall_score) if inspection.overall_score else None,
|
||||||
|
'score_delta': (
|
||||||
|
round(float(inspection.overall_score) - float(parent.overall_score), 2)
|
||||||
|
if inspection.overall_score and parent.overall_score else None
|
||||||
|
),
|
||||||
|
'rows': rows,
|
||||||
|
'improved': sum(1 for r in rows if r['delta'] is not None and r['delta'] > 0),
|
||||||
|
'regressed': sum(1 for r in rows if r['delta'] is not None and r['delta'] < 0),
|
||||||
|
'unchanged': sum(1 for r in rows if r['delta'] == 0),
|
||||||
|
}
|
||||||
|
|
||||||
return render_template('inspections/view.html',
|
return render_template('inspections/view.html',
|
||||||
inspection=inspection,
|
inspection=inspection,
|
||||||
form_fields=form_fields,
|
form_fields=form_fields,
|
||||||
form_data=form_data,
|
form_data=form_data,
|
||||||
issues=issues)
|
issues=issues,
|
||||||
|
comparison=comparison)
|
||||||
|
|
||||||
|
|
||||||
# ── Flag issue during inspection ──────────────────────────────────────────────
|
# ── Flag issue during inspection ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -212,6 +212,147 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Score comparison card (re-inspections only) ── #}
|
||||||
|
{% if comparison %}
|
||||||
|
<div class="card shadow-sm mb-3 border-0">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center
|
||||||
|
bg-{{ 'success' if comparison.score_delta and comparison.score_delta > 0
|
||||||
|
else 'danger' if comparison.score_delta and comparison.score_delta < 0
|
||||||
|
else 'secondary' }} text-white">
|
||||||
|
<span class="fw-semibold">
|
||||||
|
<i class="bi bi-arrow-left-right me-1"></i>
|
||||||
|
Score Comparison vs. Inspection #{{ comparison.parent_id }}
|
||||||
|
<span class="small opacity-75 ms-2">
|
||||||
|
{{ comparison.parent_date.strftime('%Y-%m-%d') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="d-flex gap-3 align-items-center">
|
||||||
|
{# Overall delta badge #}
|
||||||
|
{% if comparison.score_delta is not none %}
|
||||||
|
{% if comparison.score_delta > 0 %}
|
||||||
|
<span class="badge bg-white text-success fw-bold fs-6">
|
||||||
|
<i class="bi bi-arrow-up-short"></i>+{{ comparison.score_delta }}%
|
||||||
|
</span>
|
||||||
|
{% elif comparison.score_delta < 0 %}
|
||||||
|
<span class="badge bg-white text-danger fw-bold fs-6">
|
||||||
|
<i class="bi bi-arrow-down-short"></i>{{ comparison.score_delta }}%
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-white text-secondary fw-bold fs-6">No change</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{# Score pills #}
|
||||||
|
<span class="small opacity-75">
|
||||||
|
{{ comparison.parent_score|round(1) if comparison.parent_score else '—' }}%
|
||||||
|
→
|
||||||
|
{{ comparison.current_score|round(1) if comparison.current_score else '—' }}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Summary counters #}
|
||||||
|
<div class="d-flex gap-4 px-4 py-2 border-bottom bg-light" style="font-size:.82rem;">
|
||||||
|
<span class="text-success fw-semibold">
|
||||||
|
<i class="bi bi-arrow-up-short fs-5 align-middle"></i>
|
||||||
|
{{ comparison.improved }} improved
|
||||||
|
</span>
|
||||||
|
<span class="text-danger fw-semibold">
|
||||||
|
<i class="bi bi-arrow-down-short fs-5 align-middle"></i>
|
||||||
|
{{ comparison.regressed }} regressed
|
||||||
|
</span>
|
||||||
|
<span class="text-muted">
|
||||||
|
<i class="bi bi-dash fs-5 align-middle"></i>
|
||||||
|
{{ comparison.unchanged }} unchanged
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Per-item table — collapsible #}
|
||||||
|
<div class="collapse" id="comparisonDetail">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0" style="font-size:.8rem;">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th class="text-center" width="90">Previous</th>
|
||||||
|
<th class="text-center" width="90">Current</th>
|
||||||
|
<th class="text-center" width="90">Change</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in comparison.rows %}
|
||||||
|
<tr class="{{ 'table-success' if row.delta and row.delta > 0
|
||||||
|
else 'table-danger' if row.delta and row.delta < 0
|
||||||
|
else '' }}">
|
||||||
|
<td class="text-muted small">{{ row.category }}</td>
|
||||||
|
<td>{{ row.description[:70] }}{% if row.description|length > 70 %}…{% endif %}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if row.parent_pct is not none %}
|
||||||
|
<span class="badge bg-{{ 'success' if row.parent_pct >= 80 else 'warning text-dark' if row.parent_pct >= 50 else 'danger' }}">
|
||||||
|
{{ row.parent_pct }}%
|
||||||
|
</span>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if row.current_pct is not none %}
|
||||||
|
<span class="badge bg-{{ 'success' if row.current_pct >= 80 else 'warning text-dark' if row.current_pct >= 50 else 'danger' }}">
|
||||||
|
{{ row.current_pct }}%
|
||||||
|
</span>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center fw-semibold">
|
||||||
|
{% if row.delta is not none %}
|
||||||
|
{% if row.delta > 0 %}
|
||||||
|
<span class="text-success">+{{ row.delta }}%</span>
|
||||||
|
{% elif row.delta < 0 %}
|
||||||
|
<span class="text-danger">{{ row.delta }}%</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}<span class="text-muted">N/A</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer bg-white text-center py-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#comparisonDetail"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="bi bi-chevron-down me-1"></i>
|
||||||
|
<span class="show-label">Show per-item breakdown</span>
|
||||||
|
<span class="hide-label d-none">Hide breakdown</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle chevron and label text when collapse opens/closes
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('comparisonDetail');
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('show.bs.collapse', function () {
|
||||||
|
document.querySelector('.show-label').classList.add('d-none');
|
||||||
|
document.querySelector('.hide-label').classList.remove('d-none');
|
||||||
|
document.querySelector('[data-bs-target="#comparisonDetail"] .bi').className =
|
||||||
|
'bi bi-chevron-up me-1';
|
||||||
|
});
|
||||||
|
el.addEventListener('hide.bs.collapse', function () {
|
||||||
|
document.querySelector('.hide-label').classList.add('d-none');
|
||||||
|
document.querySelector('.show-label').classList.remove('d-none');
|
||||||
|
document.querySelector('[data-bs-target="#comparisonDetail"] .bi').className =
|
||||||
|
'bi bi-chevron-down me-1';
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Header #}
|
{# Header #}
|
||||||
<div class="insp-header">
|
<div class="insp-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user