From e292213042fb797d9e40f3a0a64a387cd31d939a Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 3 Mar 2026 17:26:37 -0500 Subject: [PATCH] Mar 03 2026: implemented SLA issue tracking & enhanced Dashboard --- app/__init__.py | 7 + app/routes/dashboard.py | 196 ++++++++----- app/routes/issues.py | 14 +- app/templates/_sla_badge.html | 27 ++ app/templates/dashboard.html | 406 ++++++++++++++++---------- app/templates/issues/issues_list.html | 148 ++++++++++ app/templates/issues/issues_view.html | 225 ++++++++++++++ app/utils/sla.py | 80 +++++ 8 files changed, 874 insertions(+), 229 deletions(-) create mode 100644 app/templates/_sla_badge.html create mode 100644 app/templates/issues/issues_list.html create mode 100644 app/templates/issues/issues_view.html create mode 100644 app/utils/sla.py diff --git a/app/__init__.py b/app/__init__.py index e2d1847..606d448 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -73,6 +73,13 @@ def create_app(config_name='default'): app.jinja_env.globals['csrf_token'] = generate_csrf 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 ────── # This powers the red badge on the navbar bell icon without requiring # individual routes to pass the count manually. diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 2d323fd..e56d37b 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,96 +1,136 @@ from flask import Blueprint, render_template 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.facility import Facility from app.models.issue import Issue from app.models.user import User +from app.utils.sla import sla_status, SLA_HOURS from sqlalchemy import func from datetime import datetime, timedelta from app.utils.time_utils import now_eastern bp = Blueprint('dashboard', __name__) + @bp.route('/') @bp.route('/dashboard') @login_required def index(): - # Get today's date range - today_start = now_eastern().replace(hour=0, minute=0, second=0, microsecond=0) - today_end = today_start + timedelta(days=1) - - # Statistics for today - if current_user.role == 'inspector': - today_inspections = Inspection.query.filter( - Inspection.inspector_id == current_user.id, - Inspection.inspection_date >= today_start, - Inspection.inspection_date < today_end - ).count() - - completed_today = Inspection.query.filter( - Inspection.inspector_id == current_user.id, - Inspection.status == 'completed', - Inspection.inspection_date >= today_start, - Inspection.inspection_date < today_end - ).count() - - open_issues = Issue.query.join(Inspection).filter( - Inspection.inspector_id == current_user.id, - Issue.status.in_(['open', 'in_progress']) - ).count() - - else: - today_inspections = Inspection.query.filter( - Inspection.inspection_date >= today_start, - Inspection.inspection_date < today_end - ).count() - - completed_today = Inspection.query.filter( - Inspection.status == 'completed', - Inspection.inspection_date >= today_start, - Inspection.inspection_date < today_end - ).count() - - 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', + now = now_eastern() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_end = today_start + timedelta(days=1) + thirty_days_ago = now - timedelta(days=30) + + is_inspector = current_user.role == 'inspector' + is_privileged = current_user.role in ['admin', 'supervisor'] + + # ── Today's stats ───────────────────────────────────────────────────── + base_q = Inspection.query + if is_inspector: + base_q = base_q.filter(Inspection.inspector_id == current_user.id) + + today_inspections = base_q.filter( + Inspection.inspection_date >= today_start, + Inspection.inspection_date < today_end, + ).count() + + completed_today = base_q.filter( + Inspection.status == 'completed', + Inspection.inspection_date >= today_start, + Inspection.inspection_date < today_end, + ).count() + + # ── Open issues ──────────────────────────────────────────────────────── + open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) + if is_inspector: + open_issues_q = open_issues_q.join( + Inspection, Issue.inspection_id == Inspection.id + ).filter(Inspection.inspector_id == current_user.id) + open_issues = open_issues_q.count() + + # ── Average score (last 30 days) ─────────────────────────────────────── + score_q = db.session.query(func.avg(Inspection.overall_score)).filter( + Inspection.status == 'completed', 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 - ) \ No newline at end of file diff --git a/app/routes/issues.py b/app/routes/issues.py index 1c13ad6..e35f5fc 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -14,6 +14,7 @@ from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.decorators import supervisor_required from app.utils.notifications import notify 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') @@ -51,12 +52,19 @@ def index(): severity_filter = request.args.get('severity', '') status_filter = request.args.get('status', '') + sla_filter = request.args.get('sla', '') if severity_filter: q = q.filter(Issue.severity == severity_filter) if 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 # can render the following badge and inline unfollow button without an @@ -67,9 +75,11 @@ def index(): } return render_template('issues/list.html', - issues=issues, + issues=issues_paged, + issue_items=filtered_items, severity_filter=severity_filter, status_filter=status_filter, + sla_filter=sla_filter, followed_ids=followed_ids) diff --git a/app/templates/_sla_badge.html b/app/templates/_sla_badge.html new file mode 100644 index 0000000..6df30bc --- /dev/null +++ b/app/templates/_sla_badge.html @@ -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' %} + + SLA Breached + + {% elif status == 'at_risk' %} + {% set hrs = sla_hours_remaining(issue) %} + + At Risk{% if hrs is not none %} · {{ hrs }}h{% endif %} + + {% elif status == 'ok' %} + {% set hrs = sla_hours_remaining(issue) %} + + On Track{% if hrs is not none %} · {{ hrs }}h{% endif %} + + {% endif %} + {# resolved issues show nothing #} +{% endmacro %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 443aeb5..f95a6d4 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,172 +1,280 @@ {% extends "base.html" %} - {% block title %}Dashboard{% endblock %} {% block content %} -
-
-

Welcome, {{ current_user.username }}!

-

- - {{ current_user.role|title }} - -

-
+
+
+

Welcome, {{ current_user.username }}!

+ + {{ current_user.role|title }} + +
-
-
-
-
-
-
-
Today's Inspections
-

{{ today_inspections }}

-
-
- -
-
-
+{# ── Top stat cards ─────────────────────────────────────────────────────── #} +
+
+
+
+
+
Today's Inspections
+
{{ today_inspections }}
+ +
- -
-
-
-
-
-
Completed Today
-

{{ completed_today }}

-
-
- -
-
-
+
+
+
+
+
+
Completed Today
+
{{ completed_today }}
+ +
- -
-
-
-
-
-
Open Issues
-

{{ open_issues }}

-
-
- -
-
-
+
+
+
+
+
+
Open Issues
+
{{ open_issues }}
+ +
- -
-
-
-
-
-
Avg Score (30d)
-

{{ avg_score if avg_score else '--' }}%

-
-
- -
-
-
+
+
+
+
+
+
Avg Score (30d)
+
{{ avg_score if avg_score else '--' }}{% if avg_score %}%{% endif %}
+ +
+
-{% if current_user.role in ['admin', 'supervisor'] %} -
-
-
-
- -

{{ total_facilities }}

-

Active Facilities

-
+{# ── SLA Summary ─────────────────────────────────────────────────────────── #} +{% if sla_breached > 0 or sla_at_risk > 0 %} + {% endif %} -
-
-
-
-
Recent Activity
-
-
- {% if recent_inspections %} -
- - - - - - - - - - - - - {% for inspection in recent_inspections %} - - - - - - - - - {% endfor %} - -
DateFacilityAreaInspectorScoreStatus
{{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}{{ inspection.facility.name }}{{ inspection.area.name if inspection.area else 'N/A' }}{{ inspection.inspector.username }} - {% if inspection.overall_score %} - - {{ inspection.overall_score }}% - - {% else %} - -- - {% endif %} - - - {{ inspection.status|title }} - -
-
- {% else %} -
- No recent inspections to display. -
- {% endif %} -
-
+{# ── System quick stats (admin/supervisor) ──────────────────────────────── #} +{% if current_user.role in ['admin', 'supervisor'] %} +
+
+
+
+ +
{{ total_facilities }}
+
Active Facilities
+
+
+
+
+
+ +
{{ total_templates }}
+
Templates
+
+
+
+ {% if current_user.role == 'admin' %} +
+
+
+ +
{{ total_users }}
+
Users
+
+
+
+ {% endif %}
-{% endblock %} \ No newline at end of file +{% endif %} + +{# ── Score trend chart + Facility performance ───────────────────────────── #} +
+
+
+
+ Inspection Score Trend (Last 30 Days) +
+
+ {% if trend_labels %} + + {% else %} +
+ + No completed inspections with scores in the last 30 days. +
+ {% endif %} +
+
+
+ + {% if current_user.role in ['admin', 'supervisor'] and facility_perf %} +
+
+
+ Facility Performance (30d) +
+
+ + + + + + + + + + {% for f in facility_perf %} + + + + + + {% endfor %} + +
FacilityInspectionsAvg Score
{{ f.name }}{{ f.count }} + + {{ f.avg }}% + +
+
+
+
+ {% endif %} +
+ +{# ── Recent activity ─────────────────────────────────────────────────────── #} +
+
+ Recent Activity +
+
+ {% if recent_inspections %} +
+ + + + + + + {% if current_user.role != 'inspector' %}{% endif %} + + + + + + {% for insp in recent_inspections %} + + + + + {% if current_user.role != 'inspector' %}{% endif %} + + + + {% endfor %} + +
DateFacilityAreaInspectorScoreStatus
{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}{{ insp.facility.name }}{{ insp.area.name if insp.area else '—' }}{{ insp.inspector.username }} + {% if insp.overall_score %} + + {{ insp.overall_score }}% + + {% else %} + + {% endif %} + + + {{ insp.status|title }} + +
+
+ {% else %} +
+ No recent inspections. +
+ {% endif %} +
+
+ +{% if trend_labels %} + + +{% endif %} +{% endblock %} diff --git a/app/templates/issues/issues_list.html b/app/templates/issues/issues_list.html new file mode 100644 index 0000000..37431aa --- /dev/null +++ b/app/templates/issues/issues_list.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% from '_sla_badge.html' import sla_badge %} +{% block title %}Issues{% endblock %} +{% block content %} +
+

Issues

+ {% if current_user.role in ['admin','supervisor'] %} + + Log Issue + + {% endif %} +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+
+ +
+
+ {% if issue_items %} +
+ + + + + + + + + + + + + + + {% for issue in issue_items %} + {% set is_following = issue.id in followed_ids %} + + + + + + + + + + + {% endfor %} + +
ReportedSeverityFacility / AreaDescriptionStatusSLAAssigned
{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }} + + {{ issue.severity|title }} + + + {{ issue.area.facility.name }}
+ {{ issue.area.name }} +
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %} + + {{ issue.status|replace('_',' ')|title }} + + {{ sla_badge(issue) }} + {% if issue.assigned_user %}{{ issue.assigned_user.username }} + {% else %}{% endif %} + + {# Following badge + inline unfollow #} + {% if is_following %} + + Following + +
+ + + +
+ {% endif %} + + + {% if current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %} + Edit + {% else %} + View + {% endif %} + +
+
+ + {% if issues.pages > 1 %} +
+ +
+ {% endif %} + + {% else %} +
No issues found.
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/issues/issues_view.html b/app/templates/issues/issues_view.html new file mode 100644 index 0000000..2ce31a3 --- /dev/null +++ b/app/templates/issues/issues_view.html @@ -0,0 +1,225 @@ +{% extends "base.html" %} +{% from '_sla_badge.html' import sla_badge %} +{% block title %}Issue #{{ issue.id }}{% endblock %} +{% block content %} +
+
+
+
+
Issue #{{ issue.id }} — {{ issue.severity|title }} Severity
+
+ {{ issue.status|replace('_',' ')|title }} + {{ sla_badge(issue) }} +
+
+
+
+
Reported
+
{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}
+ + {% set deadline = sla_deadline(issue) %} + {% if deadline and issue.status != 'resolved' %} +
SLA Deadline
+
+ {{ deadline.strftime('%Y-%m-%d %H:%M') }} +  {{ sla_badge(issue) }} +
+ {% endif %} + +
Facility
+
{{ issue.area.facility.name }}
+ +
Area
+
{{ issue.area.name }}
+ + {% if issue.inspection %} +
Inspection
+
+ #{{ issue.inspection_id }} +
+ {% endif %} + +
Assigned To
+
{{ issue.assigned_user.username if issue.assigned_user else '— Unassigned —' }}
+ + {% if issue.resolved_at %} +
Resolved
+
{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}
+ {% endif %} +
+ +
+
Description
+

{{ issue.description }}

+ + {% if issue.photo_path %} +
+
Photo Evidence
+ + + + {% endif %} + + {% if issue.result_notes or issue.result_photos %} +
+
Resolution Details
+ {% if issue.result_notes %} +

{{ issue.result_notes }}

+ {% endif %} + {% if issue.result_photos %} +
+ {% for photo in issue.result_photos %} + + Result photo + + {% endfor %} +
+ {% endif %} + {% endif %} +
+
+ + {# ── Update History ─────────────────────────────────────────────────── #} + {% if comments %} +
+
+
Update History
+
+
    + {% for c in comments %} +
  • +
    + + {{ c.author.username }} + + + + {{ c.status_at_time|replace('_',' ')|title }} + + {{ c.created_at.strftime('%Y-%m-%d %H:%M') }} + +
    +

    {{ c.body }}

    +
  • + {% endfor %} +
+
+ {% endif %} + +
+ +
+ + {# ── Follow / Unfollow ──────────────────────────────────────────────── #} +
+
+
+ + + {% if is_following %}Following{% else %}Not following{% endif %} + + + {{ issue.followers.count() }} follower{{ 's' if issue.followers.count() != 1 else '' }} + +
+ {% if is_following %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ + {# ── Update Form ────────────────────────────────────────────────────── #} + {% set can_edit = current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %} + {% if can_edit %} +
+
Update Issue
+
+
+ +
+ {{ form.status.label(class="form-label fw-semibold") }} + {{ form.status(class="form-select") }} +
+ {% if current_user.role in ['admin','supervisor'] %} +
+ {{ form.assigned_to.label(class="form-label fw-semibold") }} + {{ form.assigned_to(class="form-select") }} +
+ {% endif %} +
+ {{ form.update_notes.label(class="form-label fw-semibold") }} + {{ form.update_notes(class="form-control", rows=3, placeholder="Optional update notes…") }} +
+
+ {{ 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 '') }} +
+
+ + +
Attach one or more photos showing the resolution.
+ {% if issue.result_photos %} +
+ {{ issue.result_photos|length }} photo(s) already uploaded +
+ {% endif %} +
+ +
+
+
+ {% endif %} +
+
+ + + Back to Issues + +{% block extra_js %} + +{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/utils/sla.py b/app/utils/sla.py new file mode 100644 index 0000000..c043d0f --- /dev/null +++ b/app/utils/sla.py @@ -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)