Mar 05 2026: implement Verification queue

This commit is contained in:
2026-03-05 09:12:33 -05:00
parent bfea7b3913
commit fe393b6cba
4 changed files with 232 additions and 3 deletions
+13 -3
View File
@@ -90,13 +90,23 @@ def create_app(config_name='default'):
try: try:
if current_user.is_authenticated: if current_user.is_authenticated:
from app.models.notification import Notification from app.models.notification import Notification
count = Notification.query.filter_by( from app.models.issue import Issue
unread = Notification.query.filter_by(
user_id=current_user.id, is_read=False user_id=current_user.id, is_read=False
).count() ).count()
return {'unread_notification_count': count} # Pending verification count — only computed for supervisor+ roles
pv_count = 0
if current_user.role in ('admin', 'supervisor'):
pv_count = Issue.query.filter_by(
status='pending_verification'
).count()
return {
'unread_notification_count': unread,
'pending_verification_count': pv_count,
}
except Exception: except Exception:
pass pass
return {'unread_notification_count': 0} return {'unread_notification_count': 0, 'pending_verification_count': 0}
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
+41
View File
@@ -516,4 +516,45 @@ def request_verification(issue_id):
flash('Issue marked as pending verification. Supervisors have been notified.', 'info') flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(url_for('issues.view', issue_id=issue_id))
# ── Verification queue ────────────────────────────────────────────────────────
@bp.route('/verification-queue')
@login_required
@supervisor_required
def verification_queue():
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
from app.models.facility import Facility, Area
from app.utils.sla import sla_status, sla_hours_remaining
pending = (
Issue.query
.join(Area, Issue.area_id == Area.id)
.filter(Issue.status == 'pending_verification')
.order_by(Area.facility_id, Issue.reported_at.asc())
.all()
)
# Group by facility for display
from collections import defaultdict
by_facility = defaultdict(list)
for issue in pending:
by_facility[issue.area.facility].append(issue)
# Sort facilities alphabetically
grouped = sorted(by_facility.items(), key=lambda x: x[0].name)
logger.info(
'VERIFICATION QUEUE VIEWED | user=%s | pending_count=%s',
current_user.username, len(pending),
)
return render_template(
'issues/verification_queue.html',
grouped = grouped,
total_pending = len(pending),
sla_status = sla_status,
sla_hours_remaining = sla_hours_remaining,
)
return render_template('issues/form.html', form=form, title='Log New Issue') return render_template('issues/form.html', form=form, title='Log New Issue')
+19
View File
@@ -84,9 +84,28 @@
<a class="nav-link" href="{{ url_for('scheduled_reports.index') }}">Scheduled Reports</a> <a class="nav-link" href="{{ url_for('scheduled_reports.index') }}">Scheduled Reports</a>
</li> </li>
{% endif %} {% endif %}
{% if current_user.role in ['admin','supervisor'] %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('scheduled_reports.index') }}">Scheduled Reports</a>
</li>
{% endif %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('issues.index') }}">Issues</a> <a class="nav-link" href="{{ url_for('issues.index') }}">Issues</a>
</li> </li>
{% if current_user.role in ['admin', 'supervisor'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1"
href="{{ url_for('issues.verification_queue') }}">
Verify
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="badge bg-info text-dark"
style="font-size:.65rem;line-height:1;">
{{ pending_verification_count }}
</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'admin' %} {% if current_user.role == 'admin' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a> <a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a>
@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}Verification Queue{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="bi bi-patch-check text-info me-2"></i>Verification Queue</h2>
<p class="text-muted mb-0">
Issues awaiting supervisor sign-off before they are fully closed.
</p>
</div>
<div class="d-flex gap-2 align-items-center">
{% if total_pending > 0 %}
<span class="badge bg-info text-dark fs-6">{{ total_pending }} pending</span>
{% else %}
<span class="badge bg-success fs-6"><i class="bi bi-check-all me-1"></i>All clear</span>
{% endif %}
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> All Issues
</a>
</div>
</div>
{% if not grouped %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-patch-check fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-0 fs-5">No issues are currently awaiting verification.</p>
<p class="small mt-1">When inspectors request sign-off, their issues will appear here.</p>
</div>
</div>
{% else %}
{# ── Per-facility groups ── #}
{% for facility, issues in grouped %}
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-light">
<span class="fw-semibold">
<i class="bi bi-building me-1 text-muted"></i>{{ facility.name }}
</span>
<span class="badge bg-info text-dark">
{{ issues|length }} issue{{ 's' if issues|length != 1 else '' }}
</span>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light" style="font-size:.82rem;">
<tr>
<th width="60">ID</th>
<th width="90">Severity</th>
<th>Area / Description</th>
<th>Requested By</th>
<th>Reported</th>
<th>SLA</th>
<th width="220">Verify</th>
</tr>
</thead>
<tbody>
{% for issue in issues %}
{% set sla = sla_status(issue) %}
{% set hrs = sla_hours_remaining(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
{# ID #}
<td class="text-muted small align-middle">
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
</td>
{# Severity #}
<td class="align-middle">
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
{# Area / Description #}
<td class="align-middle">
<div class="fw-semibold small">{{ issue.area.name }}</div>
<div class="text-muted small">
{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}
</div>
</td>
{# Requested by / assignee #}
<td class="align-middle small">
{% if issue.assigned_user %}
<i class="bi bi-person-circle text-muted me-1"></i>{{ issue.assigned_user.username }}
{% else %}
<span class="text-muted">— unassigned —</span>
{% endif %}
</td>
{# Reported date #}
<td class="align-middle small text-muted">
{{ issue.reported_at.strftime('%Y-%m-%d') }}<br>
<span style="font-size:.75rem;">{{ issue.reported_at.strftime('%H:%M') }}</span>
</td>
{# SLA indicator #}
<td class="align-middle">
{% if sla == 'breached' %}
<span class="badge bg-danger">
<i class="bi bi-alarm me-1"></i>Breached
</span>
{% elif sla == 'at_risk' %}
<span class="badge bg-warning text-dark">
<i class="bi bi-hourglass-split me-1"></i>
{% if hrs is not none %}{{ hrs|abs|round(1) }}h left{% else %}At Risk{% endif %}
</span>
{% else %}
<span class="badge bg-secondary">
{% if hrs is not none %}{{ hrs|round(1) }}h left{% else %}OK{% endif %}
</span>
{% endif %}
</td>
{# Inline verify form #}
<td class="align-middle">
<form method="POST"
action="{{ url_for('issues.verify', issue_id=issue.id) }}"
class="d-flex gap-1 align-items-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text"
name="verification_note"
class="form-control form-control-sm"
placeholder="Note (optional)"
style="width:130px;">
<button type="submit" class="btn btn-sm btn-success flex-shrink-0"
title="Verify &amp; close this issue"
onclick="return confirm('Verify and close Issue #{{ issue.id }}?')">
<i class="bi bi-patch-check"></i>
</button>
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary flex-shrink-0"
title="View full issue">
<i class="bi bi-eye"></i>
</a>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
{# ── Legend ── #}
<div class="card border-0 bg-light mt-2">
<div class="card-body py-2 px-3 d-flex gap-4 flex-wrap" style="font-size:.78rem;">
<span><span class="badge bg-danger me-1">Breached</span>Past SLA deadline</span>
<span><span class="badge bg-warning text-dark me-1">At Risk</span>&gt;75% of SLA window elapsed</span>
<span><span class="badge bg-secondary me-1">OK</span>Within SLA</span>
</div>
</div>
{% endif %}
{% endblock %}