Mar 03 2026: implemented SLA issue tracking & enhanced Dashboard

This commit is contained in:
2026-03-03 17:26:37 -05:00
parent e25471531c
commit e292213042
8 changed files with 874 additions and 229 deletions
+7
View File
@@ -73,6 +73,13 @@ def create_app(config_name='default'):
app.jinja_env.globals['csrf_token'] = generate_csrf app.jinja_env.globals['csrf_token'] = generate_csrf
app.jinja_env.globals['enumerate'] = enumerate app.jinja_env.globals['enumerate'] = enumerate
# SLA helpers available in all templates
from app.utils.sla import sla_status, sla_deadline, sla_hours_remaining, SLA_HOURS
app.jinja_env.globals['sla_status'] = sla_status
app.jinja_env.globals['sla_deadline'] = sla_deadline
app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS
# ── Inject unread notification count into every template context ────── # ── Inject unread notification count into every template context ──────
# This powers the red badge on the navbar bell icon without requiring # This powers the red badge on the navbar bell icon without requiring
# individual routes to pass the count manually. # individual routes to pass the count manually.
+118 -78
View File
@@ -1,96 +1,136 @@
from flask import Blueprint, render_template from flask import Blueprint, render_template
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db # ADD THIS LINE from app import db
from app.models.inspection import Inspection, InspectionTemplate from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility from app.models.facility import Facility
from app.models.issue import Issue from app.models.issue import Issue
from app.models.user import User from app.models.user import User
from app.utils.sla import sla_status, SLA_HOURS
from sqlalchemy import func from sqlalchemy import func
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
bp = Blueprint('dashboard', __name__) bp = Blueprint('dashboard', __name__)
@bp.route('/') @bp.route('/')
@bp.route('/dashboard') @bp.route('/dashboard')
@login_required @login_required
def index(): def index():
# Get today's date range now = now_eastern()
today_start = now_eastern().replace(hour=0, minute=0, second=0, microsecond=0) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1) today_end = today_start + timedelta(days=1)
thirty_days_ago = now - timedelta(days=30)
# Statistics for today
if current_user.role == 'inspector': is_inspector = current_user.role == 'inspector'
today_inspections = Inspection.query.filter( is_privileged = current_user.role in ['admin', 'supervisor']
Inspection.inspector_id == current_user.id,
Inspection.inspection_date >= today_start, # ── Today's stats ─────────────────────────────────────────────────────
Inspection.inspection_date < today_end base_q = Inspection.query
).count() if is_inspector:
base_q = base_q.filter(Inspection.inspector_id == current_user.id)
completed_today = Inspection.query.filter(
Inspection.inspector_id == current_user.id, today_inspections = base_q.filter(
Inspection.status == 'completed', Inspection.inspection_date >= today_start,
Inspection.inspection_date >= today_start, Inspection.inspection_date < today_end,
Inspection.inspection_date < today_end ).count()
).count()
completed_today = base_q.filter(
open_issues = Issue.query.join(Inspection).filter( Inspection.status == 'completed',
Inspection.inspector_id == current_user.id, Inspection.inspection_date >= today_start,
Issue.status.in_(['open', 'in_progress']) Inspection.inspection_date < today_end,
).count() ).count()
else: # ── Open issues ────────────────────────────────────────────────────────
today_inspections = Inspection.query.filter( open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
Inspection.inspection_date >= today_start, if is_inspector:
Inspection.inspection_date < today_end open_issues_q = open_issues_q.join(
).count() Inspection, Issue.inspection_id == Inspection.id
).filter(Inspection.inspector_id == current_user.id)
completed_today = Inspection.query.filter( open_issues = open_issues_q.count()
Inspection.status == 'completed',
Inspection.inspection_date >= today_start, # ── Average score (last 30 days) ───────────────────────────────────────
Inspection.inspection_date < today_end score_q = db.session.query(func.avg(Inspection.overall_score)).filter(
).count() Inspection.status == 'completed',
open_issues = Issue.query.filter(
Issue.status.in_(['open', 'in_progress'])
).count()
# Calculate average score (last 30 days)
thirty_days_ago = now_eastern() - timedelta(days=30)
avg_score_query = Inspection.query.filter(
Inspection.status == 'completed',
Inspection.overall_score.isnot(None), Inspection.overall_score.isnot(None),
Inspection.inspection_date >= thirty_days_ago Inspection.inspection_date >= thirty_days_ago,
)
if is_inspector:
score_q = score_q.filter(Inspection.inspector_id == current_user.id)
avg_score = score_q.scalar()
# ── Recent inspections ─────────────────────────────────────────────────
recent_q = Inspection.query.order_by(Inspection.inspection_date.desc())
if is_inspector:
recent_q = recent_q.filter(Inspection.inspector_id == current_user.id)
recent_inspections = recent_q.limit(5).all()
# ── System stats (admin/supervisor) ───────────────────────────────────
total_facilities = Facility.query.filter_by(active=True).count() if is_privileged else 0
total_templates = InspectionTemplate.query.count() if is_privileged else 0
total_users = User.query.count() if current_user.role == 'admin' else 0
# ── SLA summary (open + in_progress issues only) ──────────────────────
all_open_issues = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])).all()
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
# ── Score trend (last 30 days, grouped by day) ────────────────────────
trend_rows = (
db.session.query(
func.date(Inspection.inspection_date).label('day'),
func.avg(Inspection.overall_score).label('avg'),
)
.filter(
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
Inspection.inspection_date >= thirty_days_ago,
)
.group_by(func.date(Inspection.inspection_date))
.order_by(func.date(Inspection.inspection_date))
.all()
)
trend_labels = [str(r.day) for r in trend_rows]
trend_data = [round(float(r.avg), 2) for r in trend_rows]
# ── Facility performance (last 30 days, privileged users only) ─────────
facility_perf = []
if is_privileged:
perf_rows = (
db.session.query(
Facility.name,
func.count(Inspection.id).label('count'),
func.avg(Inspection.overall_score).label('avg'),
)
.join(Inspection, Inspection.facility_id == Facility.id)
.filter(
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
Inspection.inspection_date >= thirty_days_ago,
Facility.active == True,
)
.group_by(Facility.id, Facility.name)
.order_by(func.avg(Inspection.overall_score).desc())
.all()
)
facility_perf = [
{'name': r.name, 'count': r.count, 'avg': round(float(r.avg), 1)}
for r in perf_rows
]
return render_template(
'dashboard.html',
today_inspections = today_inspections,
completed_today = completed_today,
open_issues = open_issues,
avg_score = round(avg_score, 2) if avg_score else None,
recent_inspections = recent_inspections,
total_facilities = total_facilities,
total_templates = total_templates,
total_users = total_users,
sla_breached = sla_breached,
sla_at_risk = sla_at_risk,
trend_labels = trend_labels,
trend_data = trend_data,
facility_perf = facility_perf,
) )
if current_user.role == 'inspector':
avg_score_query = avg_score_query.filter(Inspection.inspector_id == current_user.id)
avg_score = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
Inspection.inspection_date >= thirty_days_ago,
*([Inspection.inspector_id == current_user.id] if current_user.role == 'inspector' else [])
).scalar()
# Recent inspections
recent_inspections_query = Inspection.query.order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector':
recent_inspections_query = recent_inspections_query.filter(Inspection.inspector_id == current_user.id)
recent_inspections = recent_inspections_query.limit(5).all()
# System statistics (admin/supervisor only)
total_facilities = Facility.query.filter_by(active=True).count() if current_user.role in ['admin', 'supervisor'] else 0
total_templates = InspectionTemplate.query.count() if current_user.role in ['admin', 'supervisor'] else 0
total_users = User.query.count() if current_user.role == 'admin' else 0
return render_template('dashboard.html',
today_inspections=today_inspections,
completed_today=completed_today,
open_issues=open_issues,
avg_score=round(avg_score, 2) if avg_score else None,
recent_inspections=recent_inspections,
total_facilities=total_facilities,
total_templates=total_templates,
total_users=total_users
)
+12 -2
View File
@@ -14,6 +14,7 @@ from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.notifications import notify from app.utils.notifications import notify
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.sla import sla_status
bp = Blueprint('issues', __name__, url_prefix='/issues') bp = Blueprint('issues', __name__, url_prefix='/issues')
@@ -51,12 +52,19 @@ def index():
severity_filter = request.args.get('severity', '') severity_filter = request.args.get('severity', '')
status_filter = request.args.get('status', '') status_filter = request.args.get('status', '')
sla_filter = request.args.get('sla', '')
if severity_filter: if severity_filter:
q = q.filter(Issue.severity == severity_filter) q = q.filter(Issue.severity == severity_filter)
if status_filter: if status_filter:
q = q.filter(Issue.status == status_filter) q = q.filter(Issue.status == status_filter)
issues = q.paginate(page=page, per_page=25, error_out=False) # SLA filter — applied in Python after DB query since SLA is computed
issues_paged = q.paginate(page=page, per_page=25, error_out=False)
if sla_filter:
filtered_items = [i for i in issues_paged.items if sla_status(i) == sla_filter]
else:
filtered_items = issues_paged.items
# Build a set of issue IDs the current user is following so the template # Build a set of issue IDs the current user is following so the template
# can render the following badge and inline unfollow button without an # can render the following badge and inline unfollow button without an
@@ -67,9 +75,11 @@ def index():
} }
return render_template('issues/list.html', return render_template('issues/list.html',
issues=issues, issues=issues_paged,
issue_items=filtered_items,
severity_filter=severity_filter, severity_filter=severity_filter,
status_filter=status_filter, status_filter=status_filter,
sla_filter=sla_filter,
followed_ids=followed_ids) followed_ids=followed_ids)
+27
View File
@@ -0,0 +1,27 @@
{#
_sla_badge.html — Reusable SLA status badge macro.
Usage:
{% from '_sla_badge.html' import sla_badge %}
{{ sla_badge(issue) }}
#}
{% macro sla_badge(issue) %}
{% set status = sla_status(issue) %}
{% if status == 'breached' %}
<span class="badge sla-breached" title="SLA breached — past deadline">
<i class="bi bi-alarm me-1"></i>SLA Breached
</span>
{% elif status == 'at_risk' %}
{% set hrs = sla_hours_remaining(issue) %}
<span class="badge sla-at-risk" title="SLA at risk — {{ hrs }}h remaining">
<i class="bi bi-alarm me-1"></i>At Risk{% if hrs is not none %} · {{ hrs }}h{% endif %}
</span>
{% elif status == 'ok' %}
{% set hrs = sla_hours_remaining(issue) %}
<span class="badge sla-ok" title="Within SLA — {{ hrs }}h remaining">
<i class="bi bi-check-circle me-1"></i>On Track{% if hrs is not none %} · {{ hrs }}h{% endif %}
</span>
{% endif %}
{# resolved issues show nothing #}
{% endmacro %}
+257 -149
View File
@@ -1,172 +1,280 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="row mb-3 align-items-center">
<div class="col-12"> <div class="col">
<h2>Welcome, {{ current_user.username }}!</h2> <h2 class="mb-0">Welcome, {{ current_user.username }}!</h2>
<p class="text-muted"> <span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}warning{% else %}info{% endif %} mt-1">
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}warning{% else %}info{% endif %}"> {{ current_user.role|title }}
{{ current_user.role|title }} </span>
</span> </div>
</p>
</div>
</div> </div>
<div class="row"> {# ── Top stat cards ─────────────────────────────────────────────────────── #}
<div class="col-md-3 mb-4"> <div class="row g-3 mb-4">
<div class="card text-white bg-primary h-100"> <div class="col-6 col-md-3">
<div class="card-body"> <div class="card text-white bg-primary h-100">
<div class="d-flex justify-content-between align-items-center"> <div class="card-body d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="card-title text-white-50">Today's Inspections</h6> <div class="small text-white-50 fw-semibold">Today's Inspections</div>
<h2 class="mb-0">{{ today_inspections }}</h2> <div class="fs-2 fw-bold">{{ today_inspections }}</div>
</div>
<div>
<i class="bi bi-clipboard-data" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div> </div>
<i class="bi bi-clipboard-data" style="font-size:2.5rem;opacity:.25;"></i>
</div>
</div> </div>
</div>
<div class="col-md-3 mb-4"> <div class="col-6 col-md-3">
<div class="card text-white bg-success h-100"> <div class="card text-white bg-success h-100">
<div class="card-body"> <div class="card-body d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <div>
<div> <div class="small text-white-50 fw-semibold">Completed Today</div>
<h6 class="card-title text-white-50">Completed Today</h6> <div class="fs-2 fw-bold">{{ completed_today }}</div>
<h2 class="mb-0">{{ completed_today }}</h2>
</div>
<div>
<i class="bi bi-check-circle" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div> </div>
<i class="bi bi-check-circle" style="font-size:2.5rem;opacity:.25;"></i>
</div>
</div> </div>
</div>
<div class="col-md-3 mb-4"> <div class="col-6 col-md-3">
<div class="card text-white bg-warning h-100"> <div class="card text-white bg-warning h-100">
<div class="card-body"> <div class="card-body d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <div>
<div> <div class="small text-white-50 fw-semibold">Open Issues</div>
<h6 class="card-title text-white-50">Open Issues</h6> <div class="fs-2 fw-bold">{{ open_issues }}</div>
<h2 class="mb-0">{{ open_issues }}</h2>
</div>
<div>
<i class="bi bi-exclamation-triangle" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div> </div>
<i class="bi bi-exclamation-triangle" style="font-size:2.5rem;opacity:.25;"></i>
</div>
</div> </div>
</div>
<div class="col-md-3 mb-4"> <div class="col-6 col-md-3">
<div class="card text-white bg-info h-100"> <div class="card text-white bg-info h-100">
<div class="card-body"> <div class="card-body d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <div>
<div> <div class="small text-white-50 fw-semibold">Avg Score (30d)</div>
<h6 class="card-title text-white-50">Avg Score (30d)</h6> <div class="fs-2 fw-bold">{{ avg_score if avg_score else '--' }}{% if avg_score %}%{% endif %}</div>
<h2 class="mb-0">{{ avg_score if avg_score else '--' }}%</h2>
</div>
<div>
<i class="bi bi-graph-up" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div> </div>
<i class="bi bi-graph-up" style="font-size:2.5rem;opacity:.25;"></i>
</div>
</div> </div>
</div>
</div> </div>
{% if current_user.role in ['admin', 'supervisor'] %} {# ── SLA Summary ─────────────────────────────────────────────────────────── #}
<div class="row mb-4"> {% if sla_breached > 0 or sla_at_risk > 0 %}
<div class="col-md-4"> <div class="row g-3 mb-4">
<div class="card shadow-sm"> {% if sla_breached > 0 %}
<div class="card-body text-center"> <div class="col-6 col-md-3">
<i class="bi bi-building text-primary" style="font-size: 2.5rem;"></i> <a href="{{ url_for('issues.index', sla='breached') }}" class="text-decoration-none">
<h3 class="mt-2">{{ total_facilities }}</h3> <div class="card border-danger h-100">
<p class="text-muted mb-0">Active Facilities</p> <div class="card-body d-flex justify-content-between align-items-center">
</div> <div>
<div class="small text-danger fw-semibold">SLA Breached</div>
<div class="fs-2 fw-bold text-danger">{{ sla_breached }}</div>
</div>
<i class="bi bi-alarm text-danger" style="font-size:2.5rem;opacity:.3;"></i>
</div> </div>
</div> </div>
<div class="col-md-4"> </a>
<div class="card shadow-sm"> </div>
<div class="card-body text-center"> {% endif %}
<i class="bi bi-file-earmark-text text-success" style="font-size: 2.5rem;"></i> {% if sla_at_risk > 0 %}
<h3 class="mt-2">{{ total_templates }}</h3> <div class="col-6 col-md-3">
<p class="text-muted mb-0">Templates</p> <a href="{{ url_for('issues.index', sla='at_risk') }}" class="text-decoration-none">
</div> <div class="card border-warning h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="small text-warning fw-semibold">SLA At Risk</div>
<div class="fs-2 fw-bold text-warning">{{ sla_at_risk }}</div>
</div>
<i class="bi bi-alarm text-warning" style="font-size:2.5rem;opacity:.3;"></i>
</div> </div>
</div> </div>
{% if current_user.role == 'admin' %} </a>
<div class="col-md-4"> </div>
<div class="card shadow-sm"> {% endif %}
<div class="card-body text-center">
<i class="bi bi-people text-warning" style="font-size: 2.5rem;"></i>
<h3 class="mt-2">{{ total_users }}</h3>
<p class="text-muted mb-0">Users</p>
</div>
</div>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
<div class="row"> {# ── System quick stats (admin/supervisor) ──────────────────────────────── #}
<div class="col-12"> {% if current_user.role in ['admin', 'supervisor'] %}
<div class="card shadow-sm"> <div class="row g-3 mb-4">
<div class="card-header bg-light"> <div class="col-6 col-md-{% if current_user.role == 'admin' %}4{% else %}6{% endif %}">
<h5 class="mb-0"><i class="bi bi-clock-history"></i> Recent Activity</h5> <div class="card shadow-sm text-center">
</div> <div class="card-body py-3">
<div class="card-body"> <i class="bi bi-building text-primary" style="font-size:2rem;"></i>
{% if recent_inspections %} <div class="fs-4 fw-bold mt-1">{{ total_facilities }}</div>
<div class="table-responsive"> <div class="text-muted small">Active Facilities</div>
<table class="table table-hover mb-0"> </div>
<thead class="table-light">
<tr>
<th>Date</th>
<th>Facility</th>
<th>Area</th>
<th>Inspector</th>
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for inspection in recent_inspections %}
<tr>
<td>{{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ inspection.facility.name }}</td>
<td>{{ inspection.area.name if inspection.area else 'N/A' }}</td>
<td>{{ inspection.inspector.username }}</td>
<td>
{% if inspection.overall_score %}
<span class="badge bg-{% if inspection.overall_score >= 90 %}success{% elif inspection.overall_score >= 70 %}warning{% else %}danger{% endif %}">
{{ inspection.overall_score }}%
</span>
{% else %}
<span class="text-muted">--</span>
{% endif %}
</td>
<td>
<span class="badge bg-{% if inspection.status == 'completed' %}success{% elif inspection.status == 'flagged' %}danger{% else %}secondary{% endif %}">
{{ inspection.status|title }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i> No recent inspections to display.
</div>
{% endif %}
</div>
</div>
</div> </div>
</div>
<div class="col-6 col-md-{% if current_user.role == 'admin' %}4{% else %}6{% endif %}">
<div class="card shadow-sm text-center">
<div class="card-body py-3">
<i class="bi bi-file-earmark-text text-success" style="font-size:2rem;"></i>
<div class="fs-4 fw-bold mt-1">{{ total_templates }}</div>
<div class="text-muted small">Templates</div>
</div>
</div>
</div>
{% if current_user.role == 'admin' %}
<div class="col-6 col-md-4">
<div class="card shadow-sm text-center">
<div class="card-body py-3">
<i class="bi bi-people text-warning" style="font-size:2rem;"></i>
<div class="fs-4 fw-bold mt-1">{{ total_users }}</div>
<div class="text-muted small">Users</div>
</div>
</div>
</div>
{% endif %}
</div> </div>
{% endblock %} {% endif %}
{# ── Score trend chart + Facility performance ───────────────────────────── #}
<div class="row g-3 mb-4">
<div class="col-lg-{% if current_user.role in ['admin','supervisor'] and facility_perf %}7{% else %}12{% endif %}">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-graph-up me-1"></i>Inspection Score Trend (Last 30 Days)
</div>
<div class="card-body">
{% if trend_labels %}
<canvas id="trendChart" height="120"></canvas>
{% else %}
<div class="text-center text-muted py-4">
<i class="bi bi-bar-chart-line fs-2 d-block mb-2"></i>
No completed inspections with scores in the last 30 days.
</div>
{% endif %}
</div>
</div>
</div>
{% if current_user.role in ['admin', 'supervisor'] and facility_perf %}
<div class="col-lg-5">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i>Facility Performance (30d)
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Facility</th>
<th class="text-center">Inspections</th>
<th class="text-center">Avg Score</th>
</tr>
</thead>
<tbody>
{% for f in facility_perf %}
<tr>
<td class="small">{{ f.name }}</td>
<td class="text-center small">{{ f.count }}</td>
<td class="text-center">
<span class="badge bg-{% if f.avg >= 90 %}success{% elif f.avg >= 70 %}warning{% else %}danger{% endif %}">
{{ f.avg }}%
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
{# ── Recent activity ─────────────────────────────────────────────────────── #}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-clock-history me-1"></i>Recent Activity
</div>
<div class="card-body p-0">
{% if recent_inspections %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Facility</th>
<th>Area</th>
{% if current_user.role != 'inspector' %}<th>Inspector</th>{% endif %}
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for insp in recent_inspections %}
<tr style="cursor:pointer;" onclick="window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
<td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td>
{% if current_user.role != 'inspector' %}<td>{{ insp.inspector.username }}</td>{% endif %}
<td>
{% if insp.overall_score %}
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
{{ insp.overall_score }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
{{ insp.status|title }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
</div>
{% endif %}
</div>
</div>
{% if trend_labels %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
(function () {
const ctx = document.getElementById('trendChart').getContext('2d');
const labels = {{ trend_labels | tojson }};
const data = {{ trend_data | tojson }};
new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Avg Score (%)',
data,
borderColor: '#2563eb',
backgroundColor: 'rgba(37,99,235,0.08)',
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: '#2563eb',
tension: 0.3,
fill: true,
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => ' ' + ctx.parsed.y + '%' } }
},
scales: {
y: { min: 0, max: 100, ticks: { callback: v => v + '%' }, grid: { color: 'rgba(0,0,0,.05)' } },
x: { grid: { display: false } }
}
}
});
}());
</script>
{% endif %}
{% endblock %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% from '_sla_badge.html' import sla_badge %}
{% block title %}Issues{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
{% if current_user.role in ['admin','supervisor'] %}
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
<i class="bi bi-plus-circle"></i> Log Issue
</a>
{% endif %}
</div>
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">Severity</label>
<select name="severity" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['critical','high','medium','low'] %}
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['open','in_progress','resolved'] %}
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label small mb-1">SLA</label>
<select name="sla" class="form-select form-select-sm">
<option value="">All</option>
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>On Track</option>
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
{% if issue_items %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Reported</th>
<th>Severity</th>
<th>Facility / Area</th>
<th>Description</th>
<th>Status</th>
<th>SLA</th>
<th>Assigned</th>
<th></th>
</tr>
</thead>
<tbody>
{% for issue in issue_items %}
{% set is_following = issue.id in followed_ids %}
<tr>
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>
<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>
<td>
{{ issue.area.facility.name }}<br>
<small class="text-muted">{{ issue.area.name }}</small>
</td>
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
<td>
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>{{ sla_badge(issue) }}</td>
<td>
{% if issue.assigned_user %}{{ issue.assigned_user.username }}
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td class="text-nowrap">
{# Following badge + inline unfollow #}
{% if is_following %}
<span class="badge bg-primary me-1" title="You are following this issue">
<i class="bi bi-bell-fill"></i> Following
</span>
<form method="post"
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
class="d-inline"
title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, severity=severity_filter, status=status_filter) }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
</button>
</form>
{% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit
{% else %}
<i class="bi bi-eye"></i> View
{% endif %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if issues.pages > 1 %}
<div class="d-flex justify-content-center py-3">
<nav><ul class="pagination pagination-sm mb-0">
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
{% if p %}
<li class="page-item {{ 'active' if p == issues.page }}">
<a class="page-link"
href="{{ url_for('issues.index', page=p, severity=severity_filter, status=status_filter) }}">{{ p }}</a>
</li>
{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}
{% endfor %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="alert alert-info m-3"><i class="bi bi-info-circle"></i> No issues found.</div>
{% endif %}
</div>
</div>
{% endblock %}
+225
View File
@@ -0,0 +1,225 @@
{% extends "base.html" %}
{% from '_sla_badge.html' import sla_badge %}
{% block title %}Issue #{{ issue.id }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center
bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning' if issue.severity == 'medium' else 'secondary' }}
text-{{ 'white' if issue.severity in ['critical','high','low'] else 'dark' }}">
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issue #{{ issue.id }} — {{ issue.severity|title }} Severity</h5>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark">{{ issue.status|replace('_',' ')|title }}</span>
{{ sla_badge(issue) }}
</div>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Reported</dt>
<dd class="col-sm-9">{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</dd>
{% set deadline = sla_deadline(issue) %}
{% if deadline and issue.status != 'resolved' %}
<dt class="col-sm-3">SLA Deadline</dt>
<dd class="col-sm-9">
{{ deadline.strftime('%Y-%m-%d %H:%M') }}
&nbsp;{{ sla_badge(issue) }}
</dd>
{% endif %}
<dt class="col-sm-3">Facility</dt>
<dd class="col-sm-9">{{ issue.area.facility.name }}</dd>
<dt class="col-sm-3">Area</dt>
<dd class="col-sm-9">{{ issue.area.name }}</dd>
{% if issue.inspection %}
<dt class="col-sm-3">Inspection</dt>
<dd class="col-sm-9">
<a href="{{ url_for('inspections.view', inspection_id=issue.inspection_id) }}">#{{ issue.inspection_id }}</a>
</dd>
{% endif %}
<dt class="col-sm-3">Assigned To</dt>
<dd class="col-sm-9">{{ issue.assigned_user.username if issue.assigned_user else '— Unassigned —' }}</dd>
{% if issue.resolved_at %}
<dt class="col-sm-3">Resolved</dt>
<dd class="col-sm-9">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</dd>
{% endif %}
</dl>
<hr>
<h6>Description</h6>
<p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p>
{% if issue.photo_path %}
<hr>
<h6>Photo Evidence</h6>
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
<img src="{{ url_for('static', filename=issue.photo_path) }}" class="img-fluid rounded" style="max-height:300px;">
</a>
{% endif %}
{% if issue.result_notes or issue.result_photos %}
<hr>
<h6><i class="bi bi-clipboard2-check text-success"></i> Resolution Details</h6>
{% if issue.result_notes %}
<p class="mb-2" style="white-space:pre-wrap;">{{ issue.result_notes }}</p>
{% endif %}
{% if issue.result_photos %}
<div class="d-flex flex-wrap gap-2 mt-2">
{% for photo in issue.result_photos %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}"
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
alt="Result photo">
</a>
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
</div>
{# ── Update History ─────────────────────────────────────────────────── #}
{% if comments %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="mb-0" id="update-history"><i class="bi bi-clock-history"></i> Update History</h6>
</div>
<ul class="list-group list-group-flush">
{% for c in comments %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-semibold text-dark">
<i class="bi bi-person-circle"></i> {{ c.author.username }}
</span>
<span class="d-flex align-items-center gap-2">
<span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }} rounded-pill" style="font-size:.65rem;">
{{ c.status_at_time|replace('_',' ')|title }}
</span>
<small class="text-muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
</span>
</div>
<p class="mb-0 text-secondary" style="white-space:pre-wrap; font-size:.9rem;">{{ c.body }}</p>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div class="col-lg-4">
{# ── Follow / Unfollow ──────────────────────────────────────────────── #}
<div class="card shadow-sm mb-3">
<div class="card-body d-flex align-items-center justify-content-between py-2">
<div>
<i class="bi bi-bell{{ '-fill text-primary' if is_following else ' text-muted' }} me-1"></i>
<span class="fw-semibold" style="font-size:.9rem;">
{% if is_following %}Following{% else %}Not following{% endif %}
</span>
<span class="text-muted ms-2" style="font-size:.8rem;">
{{ issue.followers.count() }} follower{{ 's' if issue.followers.count() != 1 else '' }}
</span>
</div>
{% if is_following %}
<form method="post" action="{{ url_for('issues.unfollow', issue_id=issue.id) }}" class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-bell-slash"></i> Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('issues.follow', issue_id=issue.id) }}" class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-bell"></i> Follow
</button>
</form>
{% endif %}
</div>
</div>
{# ── Update Form ────────────────────────────────────────────────────── #}
{% set can_edit = current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %}
{% if can_edit %}
<div class="card shadow-sm">
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
{{ form.status.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }}
</div>
{% if current_user.role in ['admin','supervisor'] %}
<div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }}
</div>
{% endif %}
<div class="mb-3">
{{ form.update_notes.label(class="form-label fw-semibold") }}
{{ form.update_notes(class="form-control", rows=3, placeholder="Optional update notes…") }}
</div>
<div class="mb-3">
{{ form.result_notes.label(class="form-label fw-semibold") }}
{{ form.result_notes(class="form-control", rows=3,
placeholder="Describe what was done to resolve this issue…",
value=issue.result_notes or '') }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Result Photos</label>
<input type="file" name="result_photos" id="result_photos"
class="form-control" accept="image/*" multiple>
<div class="form-text">Attach one or more photos showing the resolution.</div>
{% if issue.result_photos %}
<div class="mt-2">
<small class="text-muted">{{ issue.result_photos|length }} photo(s) already uploaded</small>
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary w-100">Save Update</button>
</form>
</div>
</div>
{% endif %}
</div>
</div>
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Issues
</a>
{% block extra_js %}
<script>
(function () {
'use strict';
// ── Immediate bell refresh after a successful update ──────────────────
// After form submit + redirect, Flask flashes 'Issue updated.' and the
// URL stays at /issues/<id>. We detect the flash message presence and
// call the global fetchNotifications() defined in base.html so the bell
// badge updates instantly without waiting for the 60-second poll cycle.
if (document.querySelector('.alert-success')) {
if (typeof fetchNotifications === 'function') {
fetchNotifications();
}
}
// ── Auto-scroll to Update History after save ──────────────────────────
// If there's a success flash AND comments exist, scroll the history
// section into view so the newly added comment is immediately visible.
if (document.querySelector('.alert-success')) {
var historyEl = document.getElementById('update-history');
if (historyEl) {
historyEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
})();
</script>
{% endblock %}
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
"""
sla.py
------
Issue SLA (Service Level Agreement) helpers.
SLA thresholds define the maximum number of hours an issue of a given
severity may remain unresolved before it is considered breached.
Statuses
--------
ok — within the allowed window
at_risk — past 75% of the allowed window but not yet breached
breached — past the deadline
None — issue is already resolved; SLA no longer applies
"""
from datetime import timedelta
from app.utils.time_utils import now_eastern
# ── Configurable thresholds (hours) ──────────────────────────────────────────
SLA_HOURS = {
'critical': 4,
'high': 24,
'medium': 72,
'low': 168, # 7 days
}
# Fraction of the window at which an issue becomes "at risk"
AT_RISK_THRESHOLD = 0.75
def sla_deadline(issue):
"""
Return the datetime by which the issue must be resolved,
or None if the severity is not recognised.
"""
hours = SLA_HOURS.get(issue.severity)
if hours is None:
return None
return issue.reported_at + timedelta(hours=hours)
def sla_status(issue):
"""
Return one of: 'ok', 'at_risk', 'breached', or None.
None is returned when the issue is already resolved — SLA no longer
applies. None is also returned for unrecognised severity values.
"""
if issue.status == 'resolved':
return None
hours = SLA_HOURS.get(issue.severity)
if hours is None:
return None
deadline = issue.reported_at + timedelta(hours=hours)
at_risk_at = issue.reported_at + timedelta(hours=hours * AT_RISK_THRESHOLD)
now = now_eastern()
if now >= deadline:
return 'breached'
if now >= at_risk_at:
return 'at_risk'
return 'ok'
def sla_hours_remaining(issue):
"""
Return the number of hours remaining before the SLA deadline.
Negative values indicate the deadline has already passed.
Returns None for resolved issues or unrecognised severities.
"""
if issue.status == 'resolved':
return None
deadline = sla_deadline(issue)
if deadline is None:
return None
delta = deadline - now_eastern()
return round(delta.total_seconds() / 3600, 1)