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 %} -
- - {{ current_user.role|title }} - -
-Active Facilities
-| Date | -Facility | -Area | -Inspector | -Score | -Status | -
|---|---|---|---|---|---|
| {{ 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 }} - - | -
| Facility | +Inspections | +Avg Score | +
|---|---|---|
| {{ f.name }} | +{{ f.count }} | ++ + {{ f.avg }}% + + | +
| Date | +Facility | +Area | + {% if current_user.role != 'inspector' %}Inspector | {% endif %} +Score | +Status | +
|---|---|---|---|---|---|
| {{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }} | +{{ insp.facility.name }} | +{{ insp.area.name if insp.area else '—' }} | + {% if current_user.role != 'inspector' %}{{ insp.inspector.username }} | {% endif %} ++ {% if insp.overall_score %} + + {{ insp.overall_score }}% + + {% else %} + — + {% endif %} + | ++ + {{ insp.status|title }} + + | +
| Reported | +Severity | +Facility / Area | +Description | +Status | +SLA | +Assigned | ++ |
|---|---|---|---|---|---|---|---|
| {{ 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 %} + + | +
{{ issue.description }}
+ + {% if issue.photo_path %} +{{ issue.result_notes }}
+ {% endif %} + {% if issue.result_photos %} +{{ c.body }}
+