Mar 04 2026: Implement some operational features: scheduled reports, issue resolution verification, etc - Phase 6

This commit is contained in:
2026-03-04 17:14:06 -05:00
parent 160f9fec5d
commit 7c0ed93795
22 changed files with 1787 additions and 19 deletions
+2
View File
@@ -106,6 +106,7 @@ def create_app(config_name='default'):
from app.routes import audit # Audit Trail from app.routes import audit # Audit Trail
from app.routes import projects # Phase 1/2 — Project management from app.routes import projects # Phase 1/2 — Project management
from app.routes import customers # Phase 5 — Customer management from app.routes import customers # Phase 5 — Customer management
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp) app.register_blueprint(dashboard.bp)
@@ -118,6 +119,7 @@ def create_app(config_name='default'):
app.register_blueprint(audit.bp) app.register_blueprint(audit.bp)
app.register_blueprint(projects.bp) app.register_blueprint(projects.bp)
app.register_blueprint(customers.bp) app.register_blueprint(customers.bp)
app.register_blueprint(scheduled_reports.bp)
# ── Error handler: 413 Request Entity Too Large ─────────────────────── # ── Error handler: 413 Request Entity Too Large ───────────────────────
# Nginx can return 413 before Flask sees the request; this handler covers # Nginx can return 413 before Flask sees the request; this handler covers
+11 -2
View File
@@ -64,8 +64,17 @@ class Inspection(db.Model):
form_data = db.Column(db.JSON) # filled form field responses {field_id: value} form_data = db.Column(db.JSON) # filled form field responses {field_id: value}
completed_at = db.Column(db.DateTime) completed_at = db.Column(db.DateTime)
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan') # ── Re-inspection / follow-up workflow ────────────────────────────────
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan') parent_inspection_id = db.Column(
db.Integer, db.ForeignKey('inspections.id', ondelete='SET NULL'), nullable=True
)
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
follow_up_note = db.Column(db.Text, nullable=True)
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
def __repr__(self): def __repr__(self):
return f'<Inspection {self.id} - {self.inspection_date}>' return f'<Inspection {self.id} - {self.inspection_date}>'
+8 -2
View File
@@ -51,19 +51,25 @@ class Issue(db.Model):
severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False) severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False)
description = db.Column(db.Text, nullable=False) description = db.Column(db.Text, nullable=False)
photo_path = db.Column(db.String(255)) photo_path = db.Column(db.String(255))
status = db.Column(db.Enum('open', 'in_progress', 'resolved'), default='open') status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open')
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id')) assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'))
reported_at = db.Column(db.DateTime, default=now_eastern) reported_at = db.Column(db.DateTime, default=now_eastern)
resolved_at = db.Column(db.DateTime) resolved_at = db.Column(db.DateTime)
result_notes = db.Column(db.Text) result_notes = db.Column(db.Text)
result_photos = db.Column(db.JSON) # list of relative paths e.g. ["uploads/issue_photos/abc.jpg"] result_photos = db.Column(db.JSON) # list of relative paths e.g. ["uploads/issue_photos/abc.jpg"]
# ── Resolution verification ──────────────────────────────────────────
verified_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
verified_at = db.Column(db.DateTime, nullable=True)
verification_note = db.Column(db.Text, nullable=True)
# Tracks which SLA alert level has already been notified so cron runs # Tracks which SLA alert level has already been notified so cron runs
# don't fire duplicate notifications. Values: None / 'at_risk' / 'breached' # don't fire duplicate notifications. Values: None / 'at_risk' / 'breached'
sla_notified = db.Column(db.String(10), nullable=True, default=None) sla_notified = db.Column(db.String(10), nullable=True, default=None)
# Relationships # Relationships
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues') assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues')
comments = db.relationship('IssueComment', backref='issue', lazy='dynamic', comments = db.relationship('IssueComment', backref='issue', lazy='dynamic',
order_by='IssueComment.created_at', order_by='IssueComment.created_at',
cascade='all, delete-orphan') cascade='all, delete-orphan')
+69
View File
@@ -0,0 +1,69 @@
"""
app/models/scheduled_report.py
-------------------------------
Stores the configuration for automated scheduled report emails.
"""
from app import db
from app.utils.time_utils import now_eastern
class ScheduledReport(db.Model):
"""Configuration record for a recurring emailed report.
report_type options:
summary — overall KPI digest (inspections + issues)
facility — single-facility scorecard
issues — open/in-progress issues list
frequency options: daily | weekly | monthly
recipients: JSON list of email address strings, e.g.
["manager@acme.com", "client@acme.com"]
include_pdf / include_csv: attach respective exports to the email.
"""
__tablename__ = 'scheduled_reports'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
report_type = db.Column(
db.Enum('summary', 'facility', 'issues'),
nullable=False, default='summary'
)
frequency = db.Column(
db.Enum('daily', 'weekly', 'monthly'),
nullable=False
)
facility_id = db.Column(
db.Integer, db.ForeignKey('facilities.id', ondelete='SET NULL'),
nullable=True
)
recipients = db.Column(db.JSON, nullable=False, default=list)
include_pdf = db.Column(db.Boolean, nullable=False, default=False)
include_csv = db.Column(db.Boolean, nullable=False, default=False)
active = db.Column(db.Boolean, nullable=False, default=True)
created_by = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True
)
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
last_sent_at = db.Column(db.DateTime, nullable=True)
next_send_at = db.Column(db.DateTime, nullable=True)
# Relationships
facility = db.relationship('Facility', foreign_keys=[facility_id])
creator = db.relationship('User', foreign_keys=[created_by])
def recipient_list(self):
"""Return recipients as a Python list (safe even if stored as string)."""
if isinstance(self.recipients, list):
return self.recipients
import json
try:
return json.loads(self.recipients)
except Exception:
return []
def __repr__(self):
return f'<ScheduledReport {self.id} {self.name!r} {self.frequency}>'
+14
View File
@@ -94,6 +94,19 @@ def index():
recent_q = recent_q.filter(False) recent_q = recent_q.filter(False)
recent_inspections = recent_q.limit(5).all() recent_inspections = recent_q.limit(5).all()
# ── Pending follow-up inspections ────────────────────────────────────
followup_q = Inspection.query.filter_by(
follow_up_required=True, status='completed'
).filter(Inspection.follow_ups == None) # noqa: E711 — SQLAlchemy usage
if is_inspector:
followup_q = followup_q.filter(Inspection.inspector_id == current_user.id)
elif is_customer:
if customer_facility_ids:
followup_q = followup_q.filter(Inspection.facility_id.in_(customer_facility_ids))
else:
followup_q = followup_q.filter(False)
pending_followups = followup_q.count()
# ── System stats (admin/supervisor) ─────────────────────────────────── # ── System stats (admin/supervisor) ───────────────────────────────────
total_facilities = Facility.query.filter_by(active=True).count() if is_privileged else 0 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_templates = InspectionTemplate.query.count() if is_privileged else 0
@@ -183,4 +196,5 @@ def index():
trend_data = trend_data, trend_data = trend_data,
facility_perf = facility_perf, facility_perf = facility_perf,
customer_facilities = customer_facilities, customer_facilities = customer_facilities,
pending_followups = pending_followups,
) )
+95 -10
View File
@@ -191,12 +191,18 @@ def index():
else: else:
q = q.filter(Inspection.facility_id.in_(customer_facility_ids)) q = q.filter(Inspection.facility_id.in_(customer_facility_ids))
status_filter = request.args.get('status', '') status_filter = request.args.get('status', '')
facility_filter = request.args.get('facility_id', '') facility_filter = request.args.get('facility_id', '')
follow_up_filter = request.args.get('follow_up', '')
if status_filter: if status_filter:
q = q.filter(Inspection.status == status_filter) q = q.filter(Inspection.status == status_filter)
if facility_filter.isdigit(): if facility_filter.isdigit():
q = q.filter(Inspection.facility_id == int(facility_filter)) q = q.filter(Inspection.facility_id == int(facility_filter))
if follow_up_filter == '1':
q = q.filter(
Inspection.follow_up_required == True,
Inspection.status == 'completed',
).filter(~Inspection.follow_ups.any())
inspections = q.paginate(page=page, per_page=20, error_out=False) inspections = q.paginate(page=page, per_page=20, error_out=False)
if current_user.role == 'customer': if current_user.role == 'customer':
@@ -209,7 +215,8 @@ def index():
inspections=inspections, inspections=inspections,
facilities=facilities, facilities=facilities,
status_filter=status_filter, status_filter=status_filter,
facility_filter=facility_filter) facility_filter=facility_filter,
follow_up_filter=follow_up_filter)
# ── Start ───────────────────────────────────────────────────────────────────── # ── Start ─────────────────────────────────────────────────────────────────────
@@ -225,6 +232,14 @@ def start():
form.template_id.choices = [(t.id, t.name) for t in templates] form.template_id.choices = [(t.id, t.name) for t in templates]
form.facility_id.choices = [(f.id, f.name) for f in facilities] form.facility_id.choices = [(f.id, f.name) for f in facilities]
# Pre-select template/facility when arriving from reinspect()
from flask import session as _session
if not form.is_submitted():
if _session.get('reinspect_template_id'):
form.template_id.data = _session['reinspect_template_id']
if _session.get('reinspect_facility_id'):
form.facility_id.data = _session['reinspect_facility_id']
selected_fid = form.facility_id.data or (facilities[0].id if facilities else None) selected_fid = form.facility_id.data or (facilities[0].id if facilities else None)
areas = Area.query.filter_by(facility_id=selected_fid).order_by(Area.name).all() if selected_fid else [] areas = Area.query.filter_by(facility_id=selected_fid).order_by(Area.name).all() if selected_fid else []
form.area_id.choices = [(0, '— No specific area —')] + [(a.id, a.name) for a in areas] form.area_id.choices = [(0, '— No specific area —')] + [(a.id, a.name) for a in areas]
@@ -237,14 +252,17 @@ def start():
flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning') flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning')
return redirect(url_for('inspections.start')) return redirect(url_for('inspections.start'))
from flask import session as _session
parent_id = _session.pop('reinspect_parent_id', None)
inspection = Inspection( inspection = Inspection(
template_id = template.id, template_id = template.id,
facility_id = form.facility_id.data, facility_id = form.facility_id.data,
area_id = form.area_id.data or None, area_id = form.area_id.data or None,
inspector_id = current_user.id, inspector_id = current_user.id,
inspection_date = now_eastern(), inspection_date = now_eastern(),
status = 'in_progress', status = 'in_progress',
notes = form.notes.data or None, notes = form.notes.data or None,
parent_inspection_id = parent_id,
) )
db.session.add(inspection) db.session.add(inspection)
db.session.commit() db.session.commit()
@@ -642,6 +660,73 @@ def export_pdf(inspection_id):
) )
# ── Flag / clear follow-up required ──────────────────────────────────────────
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
@login_required
@supervisor_required
def flag_followup(inspection_id):
"""Mark an inspection as requiring a follow-up re-inspection."""
inspection = Inspection.query.get_or_404(inspection_id)
note = request.form.get('follow_up_note', '').strip() or None
inspection.follow_up_required = True
inspection.follow_up_note = note
db.session.commit()
current_app.logger.info(
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
inspection_id, current_user.username, note,
)
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'follow_up_required=True; note={note!r}')
flash('Follow-up inspection required flag set.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
@bp.route('/<int:inspection_id>/clear-followup', methods=['POST'])
@login_required
@supervisor_required
def clear_followup(inspection_id):
"""Clear the follow-up required flag once actioned."""
inspection = Inspection.query.get_or_404(inspection_id)
inspection.follow_up_required = False
inspection.follow_up_note = None
db.session.commit()
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
'follow_up_required=False (cleared)')
flash('Follow-up flag cleared.', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
# ── Start a re-inspection (linked to parent) ──────────────────────────────────
@bp.route('/<int:inspection_id>/reinspect')
@login_required
def reinspect(inspection_id):
"""Pre-fill the Start Inspection form with the same template/facility,
linking the new inspection to the parent via parent_inspection_id."""
from flask import session
parent = Inspection.query.get_or_404(inspection_id)
if current_user.role == 'customer':
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
# Store parent context in session so start() can pick it up
session['reinspect_parent_id'] = parent.id
session['reinspect_template_id'] = parent.template_id
session['reinspect_facility_id'] = parent.facility_id
flash(
f'Starting re-inspection of #{parent.id}'
f'{parent.template.name} @ {parent.facility.name}.',
'info',
)
return redirect(url_for('inspections.start'))
# ── Delete ──────────────────────────────────────────────────────────────────── # ── Delete ────────────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/delete', methods=['POST']) @bp.route('/<int:inspection_id>/delete', methods=['POST'])
+100
View File
@@ -117,6 +117,14 @@ def view(issue_id):
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
form.status.data = form.status.data or issue.status form.status.data = form.status.data or issue.status
# Extend status choices to include pending_verification
form.status.choices = [
('open', 'Open'),
('in_progress', 'In Progress'),
('pending_verification', 'Pending Verification'),
('resolved', 'Resolved'),
]
if form.validate_on_submit(): if form.validate_on_submit():
old_status = issue.status old_status = issue.status
old_assigned_to = issue.assigned_to old_assigned_to = issue.assigned_to
@@ -416,4 +424,96 @@ def create():
flash('Issue created.', 'success') flash('Issue created.', 'success')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
# ── Supervisor verify resolved issue ─────────────────────────────────────────
@bp.route('/<int:issue_id>/verify', methods=['POST'])
@login_required
@supervisor_required
def verify(issue_id):
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
issue = Issue.query.get_or_404(issue_id)
if issue.status not in ('resolved', 'pending_verification'):
flash('Only resolved or pending-verification issues can be verified.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id))
note = request.form.get('verification_note', '').strip() or None
issue.status = 'resolved'
issue.verified_by = current_user.id
issue.verified_at = now_eastern()
issue.verification_note = note
if not issue.resolved_at:
issue.resolved_at = now_eastern()
db.session.commit()
current_app.logger.info(
'ISSUE VERIFIED | id=%s | by=%s | note=%r',
issue_id, current_user.username, note,
)
log_action(ACTION_UPDATE, 'Issue', issue_id,
f'#{issue_id} in {issue.area.name}',
f'verified_by={current_user.username}')
flash(f'Issue #{issue_id} verified and closed.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
@login_required
def request_verification(issue_id):
"""Inspector/assignee marks the issue as pending supervisor verification."""
issue = Issue.query.get_or_404(issue_id)
if current_user.role == 'customer':
flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
# Only the assignee, supervisor, or admin can request verification
can_act = (
current_user.role in ['admin', 'supervisor']
or issue.assigned_to == current_user.id
)
if not can_act:
flash('Access denied.', 'danger')
return redirect(url_for('issues.view', issue_id=issue_id))
if issue.status not in ('in_progress', 'resolved'):
flash('Issue must be in progress or resolved to request verification.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id))
issue.status = 'pending_verification'
db.session.commit()
current_app.logger.info(
'ISSUE VERIFICATION REQUESTED | id=%s | by=%s',
issue_id, current_user.username,
)
log_action(ACTION_UPDATE, 'Issue', issue_id,
f'#{issue_id} in {issue.area.name}',
f'status=pending_verification; requested_by={current_user.username}')
# Notify supervisors
from app.utils.notifications import notify
from app.models.notification import EVENT_ISSUE_STATUS
supervisors = User.query.filter(User.role.in_(['admin', 'supervisor'])).all()
for sup in supervisors:
if sup.id != current_user.id:
notify(
recipient = sup,
title = f'Issue #{issue_id} Awaiting Verification',
body = (
f'{current_user.username} has marked Issue #{issue_id} '
f'({issue.severity.title()} severity) in {issue.area.name} '
f'as pending your verification.'
),
link = url_for('issues.view', issue_id=issue_id),
issue_id = issue_id,
event_type = EVENT_ISSUE_STATUS,
send_email = True,
)
db.session.commit()
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id))
return render_template('issues/form.html', form=form, title='Log New Issue') return render_template('issues/form.html', form=form, title='Log New Issue')
+128
View File
@@ -220,6 +220,134 @@ def facility_report(facility_id):
start=start, end=end) start=start, end=end)
# ── Facility Scorecard ────────────────────────────────────────────────────────
@bp.route('/facility/<int:facility_id>/scorecard')
@login_required
def facility_scorecard(facility_id):
"""Comprehensive per-facility scorecard: score trend, SLA compliance,
issue breakdown by severity, inspection frequency."""
if current_user.role not in ['admin', 'supervisor', 'project_manager', 'customer']:
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
facility = Facility.query.get_or_404(facility_id)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
if facility_id not in cids:
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
return redirect(url_for('reports.index'))
from app.utils.sla import sla_status, SLA_HOURS
from datetime import timedelta
now = now_eastern()
days = request.args.get('days', 90, type=int)
if days not in (30, 60, 90, 180, 365):
days = 90
start = now - timedelta(days=days)
# ── Score trend (daily) ───────────────────────────────────────────────
trend_rows = db.session.query(
func.date(Inspection.inspection_date).label('day'),
func.avg(Inspection.overall_score).label('avg'),
func.count(Inspection.id).label('count'),
).filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).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]
# ── KPI summary ───────────────────────────────────────────────────────
all_insp = Inspection.query.filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
).all()
completed_insp = [i for i in all_insp if i.status == 'completed']
avg_score = (
round(sum(float(i.overall_score) for i in completed_insp
if i.overall_score is not None)
/ len([i for i in completed_insp if i.overall_score is not None]), 2)
if any(i.overall_score for i in completed_insp) else None
)
# ── Area scores ───────────────────────────────────────────────────────
area_scores = db.session.query(
Area.name,
func.avg(Inspection.overall_score).label('avg'),
func.count(Inspection.id).label('count'),
).join(Inspection, Area.id == Inspection.area_id) .filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).group_by(Area.id, Area.name) .order_by(func.avg(Inspection.overall_score).desc()).all()
# ── Open issues ───────────────────────────────────────────────────────
open_issues = Issue.query.join(Area) .filter(Area.facility_id == facility_id, Issue.status != 'resolved') .order_by(Issue.reported_at.desc()).all()
# SLA compliance for closed issues in window
closed_issues = Issue.query.join(Area).filter(
Area.facility_id == facility_id,
Issue.status == 'resolved',
Issue.reported_at >= start,
).all()
sla_met = sum(1 for i in closed_issues
if i.resolved_at and i.reported_at
and (i.resolved_at - i.reported_at).total_seconds() / 3600
<= SLA_HOURS.get(i.severity, 9999))
sla_total = len(closed_issues)
sla_pct = round(sla_met / sla_total * 100, 1) if sla_total else None
# Issue severity breakdown
sev_counts = {}
for sev in ('critical', 'high', 'medium', 'low'):
sev_counts[sev] = Issue.query.join(Area).filter(
Area.facility_id == facility_id,
Issue.severity == sev,
Issue.status != 'resolved',
).count()
# Pending verification count
pending_verification = Issue.query.join(Area).filter(
Area.facility_id == facility_id,
Issue.status == 'pending_verification',
).count()
# Follow-up required inspections
followup_required = Inspection.query.filter(
Inspection.facility_id == facility_id,
Inspection.follow_up_required == True,
).order_by(Inspection.inspection_date.desc()).limit(10).all()
return render_template('reports/scorecard.html',
facility = facility,
days = days,
start = start,
now = now,
total_inspections = len(all_insp),
completed_insp = len(completed_insp),
avg_score = avg_score,
trend_labels = trend_labels,
trend_data = trend_data,
area_scores = area_scores,
open_issues = open_issues,
sla_pct = sla_pct,
sla_met = sla_met,
sla_total = sla_total,
sev_counts = sev_counts,
pending_verification = pending_verification,
followup_required = followup_required,
)
# ── CSV export ──────────────────────────────────────────────────────────────── # ── CSV export ────────────────────────────────────────────────────────────────
@bp.route('/export/inspections') @bp.route('/export/inspections')
+395
View File
@@ -0,0 +1,395 @@
"""
app/routes/scheduled_reports.py
--------------------------------
CRUD management for ScheduledReport configs + cron-triggered send endpoint.
Admin/supervisor access for management.
The /send route is token-protected (same DIGEST_SECRET) for cron use.
Cron examples
-------------
# Daily at 07:00
0 7 * * * curl -s -X POST https://yourdomain.com/scheduled-reports/send \
-d "token=YOUR_DIGEST_SECRET&frequency=daily"
# Weekly on Monday 07:00
0 7 * * 1 curl -s -X POST https://yourdomain.com/scheduled-reports/send \
-d "token=YOUR_DIGEST_SECRET&frequency=weekly"
# Monthly on the 1st at 07:00
0 7 1 * * curl -s -X POST https://yourdomain.com/scheduled-reports/send \
-d "token=YOUR_DIGEST_SECRET&frequency=monthly"
"""
import csv
import io
import logging
from datetime import datetime, timedelta
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, jsonify, current_app)
from flask_login import login_required, current_user
from flask_mail import Message
from sqlalchemy import func
from app import db, mail
from app.models.scheduled_report import ScheduledReport
from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area
from app.models.issue import Issue
from app.models.user import User
from app.utils.decorators import supervisor_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__)
bp = Blueprint('scheduled_reports', __name__, url_prefix='/scheduled-reports')
# ── Helpers ───────────────────────────────────────────────────────────────────
def _compute_next_send(frequency: str, from_dt: datetime = None) -> datetime:
"""Return the next send datetime for a given frequency."""
now = from_dt or now_eastern()
if frequency == 'daily':
return (now + timedelta(days=1)).replace(hour=7, minute=0, second=0, microsecond=0)
if frequency == 'weekly':
days_ahead = 7 - now.weekday() # next Monday
return (now + timedelta(days=days_ahead)).replace(hour=7, minute=0, second=0, microsecond=0)
# monthly: first of next month
if now.month == 12:
return now.replace(year=now.year + 1, month=1, day=1, hour=7, minute=0, second=0, microsecond=0)
return now.replace(month=now.month + 1, day=1, hour=7, minute=0, second=0, microsecond=0)
def _date_window(frequency: str):
"""Return (start, end) covering the period just elapsed for this frequency."""
end = now_eastern()
if frequency == 'daily':
start = end - timedelta(days=1)
elif frequency == 'weekly':
start = end - timedelta(weeks=1)
else:
start = end - timedelta(days=30)
return start, end
def _build_report_data(report: ScheduledReport, start: datetime, end: datetime) -> dict:
"""Assemble the data dict passed to the email template."""
data = {
'report': report,
'start': start,
'end': end,
'facility': report.facility,
}
fid_filter = [report.facility_id] if report.facility_id else None
def _si(q):
if fid_filter:
return q.filter(Inspection.facility_id.in_(fid_filter))
return q
def _iq(q):
if fid_filter:
return q.join(Area, Issue.area_id == Area.id).filter(Area.facility_id.in_(fid_filter))
return q
if report.report_type in ('summary', 'facility'):
base = _si(Inspection.query.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
))
data['total_inspections'] = base.count()
data['completed'] = base.filter(Inspection.status == 'completed').count()
avg = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
)
data['avg_score'] = round(float(_si(avg).scalar()), 2) if _si(avg).scalar() else None
data['open_issues'] = _iq(Issue.query.filter(
Issue.status.in_(['open', 'in_progress'])
)).count()
data['critical_issues'] = _iq(Issue.query.filter(
Issue.severity.in_(['critical', 'high']),
Issue.status != 'resolved',
)).order_by(Issue.reported_at.desc()).limit(10).all()
data['facility_scores'] = db.session.query(
Facility.name,
func.avg(Inspection.overall_score).label('avg'),
func.count(Inspection.id).label('count'),
).join(Inspection, Facility.id == Inspection.facility_id).filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
)
if fid_filter:
data['facility_scores'] = data['facility_scores'].filter(Facility.id.in_(fid_filter))
data['facility_scores'] = data['facility_scores'].group_by(Facility.id, Facility.name)\
.order_by(func.avg(Inspection.overall_score).desc()).all()
if report.report_type == 'issues':
data['issues'] = _iq(Issue.query.filter(
Issue.status != 'resolved',
)).order_by(Issue.severity.desc(), Issue.reported_at.asc()).all()
return data
def _build_csv(report: ScheduledReport, start: datetime, end: datetime) -> bytes:
"""Return CSV bytes appropriate for the report type."""
buf = io.StringIO()
writer = csv.writer(buf)
fid_filter = [report.facility_id] if report.facility_id else None
if report.report_type == 'issues':
writer.writerow(['ID', 'Reported At', 'Facility', 'Area', 'Severity',
'Description', 'Status', 'Assigned To'])
q = Issue.query
if fid_filter:
q = q.join(Area, Issue.area_id == Area.id).filter(Area.facility_id.in_(fid_filter))
for i in q.filter(Issue.status != 'resolved').order_by(Issue.reported_at.desc()).all():
writer.writerow([
i.id,
i.reported_at.strftime('%Y-%m-%d %H:%M'),
i.area.facility.name,
i.area.name,
i.severity,
i.description.replace('\n', ' '),
i.status,
i.assigned_user.username if i.assigned_user else '',
])
else:
writer.writerow(['ID', 'Date', 'Facility', 'Area', 'Inspector',
'Template', 'Score', 'Status'])
q = Inspection.query.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
)
if fid_filter:
q = q.filter(Inspection.facility_id.in_(fid_filter))
for i in q.order_by(Inspection.inspection_date.desc()).all():
writer.writerow([
i.id,
i.inspection_date.strftime('%Y-%m-%d %H:%M'),
i.facility.name,
i.area.name if i.area else '',
i.inspector.username,
i.template.name,
i.overall_score or '',
i.status,
])
return buf.getvalue().encode('utf-8')
def _send_report(report: ScheduledReport):
"""Build and dispatch the email for a single ScheduledReport."""
if not current_app.config.get('MAIL_SERVER'):
logger.warning('SCHEDULED REPORT SKIPPED | id=%s | no MAIL_SERVER', report.id)
return False
recipients = report.recipient_list()
if not recipients:
logger.warning('SCHEDULED REPORT SKIPPED | id=%s | no recipients', report.id)
return False
frequency = report.frequency
start, end = _date_window(frequency)
data = _build_report_data(report, start, end)
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
html_body = render_template('scheduled_reports/email.html',
base_url=base_url, **data)
text_body = render_template('scheduled_reports/email.txt',
base_url=base_url, **data)
subject = (f'[JQC] {report.frequency.title()} Report — {report.name} '
f'({start.strftime("%b %d")}{end.strftime("%b %d, %Y")})')
sender = current_app.config.get(
'MAIL_DEFAULT_SENDER',
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
)
msg = Message(subject=subject, sender=sender, recipients=recipients,
body=text_body, html=html_body)
if report.include_csv:
csv_bytes = _build_csv(report, start, end)
fname = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.csv'
msg.attach(fname, 'text/csv', csv_bytes)
try:
mail.send(msg)
logger.info('SCHEDULED REPORT SENT | id=%s | name=%r | recipients=%s',
report.id, report.name, recipients)
return True
except Exception as exc:
logger.error('SCHEDULED REPORT FAILED | id=%s | error=%s', report.id, exc)
return False
# ── CRUD ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
@supervisor_required
def index():
reports = ScheduledReport.query.order_by(ScheduledReport.name).all()
return render_template('scheduled_reports/index.html', reports=reports)
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@supervisor_required
def create():
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
report_type = request.form.get('report_type', 'summary')
frequency = request.form.get('frequency', 'weekly')
facility_id = request.form.get('facility_id', type=int) or None
recipients = [e.strip() for e in request.form.get('recipients', '').split(',') if e.strip()]
include_pdf = bool(request.form.get('include_pdf'))
include_csv = bool(request.form.get('include_csv'))
if not name:
flash('Report name is required.', 'warning')
return render_template('scheduled_reports/form.html',
facilities=facilities, title='New Scheduled Report')
if not recipients:
flash('At least one recipient email is required.', 'warning')
return render_template('scheduled_reports/form.html',
facilities=facilities, title='New Scheduled Report')
report = ScheduledReport(
name = name,
report_type = report_type,
frequency = frequency,
facility_id = facility_id,
recipients = recipients,
include_pdf = include_pdf,
include_csv = include_csv,
active = True,
created_by = current_user.id,
created_at = now_eastern(),
next_send_at = _compute_next_send(frequency),
)
db.session.add(report)
db.session.commit()
log_action(ACTION_CREATE, 'ScheduledReport', report.id, report.name,
f'frequency={frequency}; recipients={len(recipients)}')
flash(f'Scheduled report "{report.name}" created.', 'success')
return redirect(url_for('scheduled_reports.index'))
return render_template('scheduled_reports/form.html',
facilities=facilities, title='New Scheduled Report')
@bp.route('/<int:report_id>/edit', methods=['GET', 'POST'])
@login_required
@supervisor_required
def edit(report_id):
report = ScheduledReport.query.get_or_404(report_id)
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
if request.method == 'POST':
report.name = request.form.get('name', '').strip() or report.name
report.report_type = request.form.get('report_type', report.report_type)
report.frequency = request.form.get('frequency', report.frequency)
report.facility_id = request.form.get('facility_id', type=int) or None
report.recipients = [e.strip() for e in request.form.get('recipients', '').split(',') if e.strip()]
report.include_pdf = bool(request.form.get('include_pdf'))
report.include_csv = bool(request.form.get('include_csv'))
report.active = bool(request.form.get('active'))
report.next_send_at = _compute_next_send(report.frequency)
db.session.commit()
log_action(ACTION_UPDATE, 'ScheduledReport', report.id, report.name,
f'frequency={report.frequency}; active={report.active}')
flash(f'Scheduled report "{report.name}" updated.', 'success')
return redirect(url_for('scheduled_reports.index'))
return render_template('scheduled_reports/form.html', report=report,
facilities=facilities, title='Edit Scheduled Report')
@bp.route('/<int:report_id>/delete', methods=['POST'])
@login_required
@supervisor_required
def delete(report_id):
report = ScheduledReport.query.get_or_404(report_id)
name = report.name
rid = report.id
db.session.delete(report)
db.session.commit()
log_action(ACTION_DELETE, 'ScheduledReport', rid, name)
flash(f'Scheduled report "{name}" deleted.', 'success')
return redirect(url_for('scheduled_reports.index'))
@bp.route('/<int:report_id>/send-now', methods=['POST'])
@login_required
@supervisor_required
def send_now(report_id):
"""Manually trigger a single report — useful for testing."""
report = ScheduledReport.query.get_or_404(report_id)
ok = _send_report(report)
if ok:
report.last_sent_at = now_eastern()
db.session.commit()
flash(f'Report "{report.name}" sent successfully.', 'success')
else:
flash(f'Failed to send report "{report.name}". Check application logs.', 'danger')
return redirect(url_for('scheduled_reports.index'))
# ── Cron endpoint ─────────────────────────────────────────────────────────────
@bp.route('/send', methods=['POST'])
def send():
"""Token-protected endpoint called by cron to dispatch due reports.
POST body: token=<DIGEST_SECRET>&frequency=daily|weekly|monthly
"""
token = request.form.get('token') or request.args.get('token')
frequency = request.form.get('frequency', 'daily')
expected = current_app.config.get('DIGEST_SECRET')
if not expected or token != expected:
logger.warning('SCHEDULED REPORTS SEND REJECTED | bad/missing token')
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
if frequency not in ('daily', 'weekly', 'monthly'):
return jsonify({'ok': False, 'error': 'invalid frequency'}), 400
now = now_eastern()
reports = ScheduledReport.query.filter_by(active=True, frequency=frequency).all()
# Only send reports whose next_send_at is due (or not yet set)
due = [r for r in reports if r.next_send_at is None or r.next_send_at <= now]
sent, failed = 0, 0
for report in due:
ok = _send_report(report)
if ok:
report.last_sent_at = now
report.next_send_at = _compute_next_send(frequency, now)
sent += 1
else:
failed += 1
db.session.commit()
logger.info('SCHEDULED REPORTS CRON | frequency=%s | due=%s | sent=%s | failed=%s',
frequency, len(due), sent, failed)
return jsonify({'ok': True, 'frequency': frequency, 'sent': sent, 'failed': failed})
+10
View File
@@ -79,6 +79,16 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.index') }}">Reports</a> <a class="nav-link" href="{{ url_for('reports.index') }}">Reports</a>
</li> </li>
{% 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 %}
{% 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>
+23
View File
@@ -14,6 +14,7 @@
{# ── Top stat cards ─────────────────────────────────────────────────────── #} {# ── Top stat cards ─────────────────────────────────────────────────────── #}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<a href="{{ url_for('inspections.index') }}" class="text-decoration-none">
<div class="card text-white bg-primary h-100"> <div class="card text-white bg-primary h-100">
<div class="card-body d-flex justify-content-between align-items-center"> <div class="card-body d-flex justify-content-between align-items-center">
<div> <div>
@@ -23,8 +24,10 @@
<i class="bi bi-clipboard-data" style="font-size:2.5rem;opacity:.25;"></i> <i class="bi bi-clipboard-data" style="font-size:2.5rem;opacity:.25;"></i>
</div> </div>
</div> </div>
</a>
</div> </div>
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<a href="{{ url_for('inspections.index', status='completed') }}" class="text-decoration-none">
<div class="card text-white bg-success h-100"> <div class="card text-white bg-success h-100">
<div class="card-body d-flex justify-content-between align-items-center"> <div class="card-body d-flex justify-content-between align-items-center">
<div> <div>
@@ -34,8 +37,10 @@
<i class="bi bi-check-circle" style="font-size:2.5rem;opacity:.25;"></i> <i class="bi bi-check-circle" style="font-size:2.5rem;opacity:.25;"></i>
</div> </div>
</div> </div>
</a>
</div> </div>
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<a href="{{ url_for('issues.index', status='open') }}" class="text-decoration-none">
<div class="card text-white bg-warning h-100"> <div class="card text-white bg-warning h-100">
<div class="card-body d-flex justify-content-between align-items-center"> <div class="card-body d-flex justify-content-between align-items-center">
<div> <div>
@@ -45,8 +50,10 @@
<i class="bi bi-exclamation-triangle" style="font-size:2.5rem;opacity:.25;"></i> <i class="bi bi-exclamation-triangle" style="font-size:2.5rem;opacity:.25;"></i>
</div> </div>
</div> </div>
</a>
</div> </div>
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<a href="{{ url_for('reports.index') }}" class="text-decoration-none">
<div class="card text-white bg-info h-100"> <div class="card text-white bg-info h-100">
<div class="card-body d-flex justify-content-between align-items-center"> <div class="card-body d-flex justify-content-between align-items-center">
<div> <div>
@@ -56,9 +63,25 @@
<i class="bi bi-graph-up" style="font-size:2.5rem;opacity:.25;"></i> <i class="bi bi-graph-up" style="font-size:2.5rem;opacity:.25;"></i>
</div> </div>
</div> </div>
</a>
</div> </div>
</div> </div>
{# ── Pending Follow-ups alert (non-customer) ────────────────────────────── #}
{% if pending_followups and pending_followups > 0 and current_user.role != 'customer' %}
<div class="row mb-3">
<div class="col-12">
<a href="{{ url_for('inspections.index', follow_up='1') }}" class="text-decoration-none">
<div class="alert alert-warning d-flex align-items-center mb-0 py-2" role="alert">
<i class="bi bi-arrow-repeat fs-5 me-2"></i>
<strong>{{ pending_followups }}</strong>&nbsp;inspection{{ 's' if pending_followups != 1 else '' }} flagged as requiring a follow-up re-inspection.
<span class="ms-2 text-muted small">Click to view →</span>
</div>
</a>
</div>
</div>
{% endif %}
{# ── SLA Summary ─────────────────────────────────────────────────────────── #} {# ── SLA Summary ─────────────────────────────────────────────────────────── #}
{% if sla_breached > 0 or sla_at_risk > 0 %} {% if sla_breached > 0 or sla_at_risk > 0 %}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
+23 -3
View File
@@ -8,6 +8,12 @@
<h2><i class="bi bi-building"></i> {{ facility.name }}</h2> <h2><i class="bi bi-building"></i> {{ facility.name }}</h2>
</div> </div>
<div class="col-md-4 text-end d-flex gap-2 justify-content-end align-items-start"> <div class="col-md-4 text-end d-flex gap-2 justify-content-end align-items-start">
{% if current_user.role in ['admin', 'supervisor', 'project_manager', 'customer'] %}
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-outline-info">
<i class="bi bi-graph-up-arrow"></i> Scorecard
</a>
{% endif %}
{% if current_user.role in ['admin', 'supervisor'] %} {% if current_user.role in ['admin', 'supervisor'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary"> <a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
@@ -66,15 +72,29 @@
<h5 class="mb-0">Statistics</h5> <h5 class="mb-0">Statistics</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row text-center"> <div class="row text-center g-2">
<div class="col-6"> <div class="col-4">
<h3 class="text-primary">{{ areas|length }}</h3> <h3 class="text-primary">{{ areas|length }}</h3>
<small class="text-muted">Areas</small> <small class="text-muted">Areas</small>
</div> </div>
<div class="col-6"> <div class="col-4">
<h3 class="text-info">{{ facility.inspections.count() }}</h3> <h3 class="text-info">{{ facility.inspections.count() }}</h3>
<small class="text-muted">Inspections</small> <small class="text-muted">Inspections</small>
</div> </div>
<div class="col-4">
{%- set ns = namespace(open=0) -%}
{%- for area in areas -%}
{%- set ns.open = ns.open + area.issues.filter_by(status='open').count() + area.issues.filter_by(status='in_progress').count() -%}
{%- endfor -%}
<h3 class="text-{{ 'danger' if ns.open > 0 else 'success' }}">{{ ns.open }}</h3>
<small class="text-muted">Open Issues</small>
</div>
</div>
<div class="text-center mt-3">
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-sm btn-outline-info">
<i class="bi bi-graph-up-arrow me-1"></i>View Full Scorecard
</a>
</div> </div>
</div> </div>
</div> </div>
+5
View File
@@ -71,6 +71,11 @@
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}"> <span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
{{ ins.status|replace('_',' ')|title }} {{ ins.status|replace('_',' ')|title }}
</span> </span>
{% if ins.follow_up_required and not ins.follow_ups.count() %}
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
<i class="bi bi-arrow-repeat"></i> Follow-up
</span>
{% endif %}
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %} {% if ins.status == 'in_progress' or ins.status == 'flagged' %}
+91
View File
@@ -137,7 +137,30 @@
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary"> <button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-printer"></i> Print <i class="bi bi-printer"></i> Print
</button> </button>
{% if current_user.role not in ['customer'] %}
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-outline-primary"
title="Start a follow-up re-inspection with the same template and facility">
<i class="bi bi-arrow-repeat"></i> Re-inspect
</a>
{% endif %}
{% if current_user.role in ['admin','supervisor'] %} {% if current_user.role in ['admin','supervisor'] %}
{% if not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#followupModal"
title="Flag this inspection as requiring a follow-up">
<i class="bi bi-flag"></i> Flag Follow-up
</button>
{% else %}
<form method="post"
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-warning">
<i class="bi bi-flag-fill"></i> Clear Follow-up
</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}" <form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
onsubmit="return confirm('Delete this inspection permanently?')"> onsubmit="return confirm('Delete this inspection permanently?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -147,6 +170,48 @@
</div> </div>
</div> </div>
{# ── Follow-up required alert ── #}
{% if inspection.follow_up_required %}
<div class="alert alert-warning d-flex align-items-start gap-2 mb-3">
<i class="bi bi-flag-fill mt-1"></i>
<div>
<strong>Follow-up Inspection Required</strong>
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
<div class="mt-2">
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
</a>
</div>
</div>
</div>
{% endif %}
{# ── Parent/child inspection links ── #}
{% if inspection.parent %}
<div class="alert alert-info small mb-3">
<i class="bi bi-arrow-up-circle me-1"></i>
This is a re-inspection of
<a href="{{ url_for('inspections.view', inspection_id=inspection.parent.id) }}"
class="alert-link">Inspection #{{ inspection.parent.id }}</a>
({{ inspection.parent.inspection_date.strftime('%Y-%m-%d') }},
score: {{ inspection.parent.overall_score|round(1) if inspection.parent.overall_score else 'N/A' }}%).
</div>
{% endif %}
{% set followups = inspection.follow_ups.all() %}
{% if followups %}
<div class="alert alert-secondary small mb-3">
<i class="bi bi-arrow-down-circle me-1"></i>
Follow-up inspection(s):
{% for fu in followups %}
<a href="{{ url_for('inspections.view', inspection_id=fu.id) }}" class="alert-link">
#{{ fu.id }} ({{ fu.inspection_date.strftime('%Y-%m-%d') }},
score: {{ fu.overall_score|round(1) if fu.overall_score else 'N/A' }}%)
</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{# Header #} {# Header #}
<div class="insp-header"> <div class="insp-header">
<div> <div>
@@ -452,4 +517,30 @@ function closeMedia() {
} }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia(); });
</script> </script>
{# ── Flag follow-up modal ── #}
<div class="modal fade" id="followupModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label>
<textarea name="follow_up_note" class="form-control" rows="3"
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-flag me-1"></i>Flag Follow-up
</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -71,7 +71,7 @@
</td> </td>
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td> <td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
<td> <td>
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}"> <span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }} {{ issue.status|replace('_',' ')|title }}
</span> </span>
</td> </td>
+43 -1
View File
@@ -8,7 +8,9 @@
bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning' if issue.severity == 'medium' else 'secondary' }} 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' }}"> 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> <h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issue #{{ issue.id }} — {{ issue.severity|title }} Severity</h5>
<span class="badge bg-light text-dark">{{ issue.status|replace('_',' ')|title }}</span> <span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'light text-dark' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="row mb-0"> <dl class="row mb-0">
@@ -67,6 +69,35 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{# ── Verification panel ── #}
{% if issue.verified_at %}
<hr>
<div class="alert alert-success py-2 mb-0">
<i class="bi bi-patch-check-fill me-1"></i>
<strong>Verified</strong> by {{ issue.verifier.username if issue.verifier else 'unknown' }}
on {{ issue.verified_at.strftime('%Y-%m-%d %H:%M') }}.
{% if issue.verification_note %}<br><span class="small">{{ issue.verification_note }}</span>{% endif %}
</div>
{% elif issue.status == 'pending_verification' %}
<hr>
<div class="alert alert-info py-2 mb-0">
<i class="bi bi-hourglass-split me-1"></i>
<strong>Awaiting supervisor verification.</strong>
{% if current_user.role in ['admin','supervisor'] %}
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<input type="text" name="verification_note" class="form-control form-control-sm"
placeholder="Verification note (optional)">
</div>
<button type="submit" class="btn btn-sm btn-success">
<i class="bi bi-patch-check me-1"></i>Verify &amp; Close
</button>
</form>
{% endif %}
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -172,6 +203,17 @@
</div> </div>
<button type="submit" class="btn btn-primary w-100">Save Update</button> <button type="submit" class="btn btn-primary w-100">Save Update</button>
</form> </form>
{% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %}
<form method="POST"
action="{{ url_for('issues.request_verification', issue_id=issue.id) }}"
class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-info w-100"
onclick="return confirm('Mark this issue as pending supervisor verification?')">
<i class="bi bi-hourglass-split me-1"></i> Request Verification
</button>
</form>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
+275
View File
@@ -0,0 +1,275 @@
{% extends "base.html" %}
{% block title %}{{ facility.name }} — Scorecard{% endblock %}
{% block extra_css %}
<style>
.kpi-card { border-left: 4px solid; }
.kpi-blue { border-color: #2563eb; }
.kpi-green { border-color: #16a34a; }
.kpi-yellow { border-color: #d97706; }
.kpi-red { border-color: #dc2626; }
.chart-container { position: relative; height: 260px; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-speedometer2 text-primary me-2"></i>{{ facility.name }}</h2>
<p class="text-muted mb-0">Facility Scorecard — last {{ days }} days</p>
</div>
<div class="d-flex gap-2 align-items-center">
{# Period selector #}
<div class="btn-group btn-group-sm" role="group">
{% for d, label in [(30,'30d'),(60,'60d'),(90,'90d'),(180,'180d'),(365,'1yr')] %}
<a href="{{ url_for('reports.facility_scorecard', facility_id=facility.id, days=d) }}"
class="btn btn-outline-secondary {{ 'active' if days == d else '' }}">{{ label }}</a>
{% endfor %}
</div>
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-text"></i> Full Report
</a>
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Reports
</a>
</div>
</div>
{# ── KPI row ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm kpi-card kpi-blue h-100">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Total Inspections</div>
<div class="fs-2 fw-bold">{{ total_inspections }}</div>
<div class="text-muted small">{{ completed_insp }} completed</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm kpi-card {{ 'kpi-green' if avg_score and avg_score >= 80 else 'kpi-yellow' if avg_score and avg_score >= 60 else 'kpi-red' }} h-100">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Avg Score</div>
<div class="fs-2 fw-bold">{{ avg_score|round(1) if avg_score else '—' }}{% if avg_score %}%{% endif %}</div>
<div class="text-muted small">{{ days }}-day average</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm kpi-card {{ 'kpi-green' if sla_pct and sla_pct >= 90 else 'kpi-yellow' if sla_pct else 'kpi-red' }} h-100">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">SLA Compliance</div>
<div class="fs-2 fw-bold">{{ sla_pct|round(1) if sla_pct is not none else '—' }}{% if sla_pct is not none %}%{% endif %}</div>
<div class="text-muted small">{{ sla_met }}/{{ sla_total }} closed on time</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm kpi-card {{ 'kpi-red' if open_issues|length > 0 else 'kpi-green' }} h-100">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Open Issues</div>
<div class="fs-2 fw-bold">{{ open_issues|length }}</div>
<div class="text-muted small">
{% if pending_verification > 0 %}
<span class="text-warning">{{ pending_verification }} pending verification</span>
{% else %}
across all severities
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
{# ── Score trend chart ── #}
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-graph-up me-1"></i> Score Trend
</div>
<div class="card-body">
{% if trend_labels %}
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
{% else %}
<p class="text-muted text-center py-4 mb-0">No completed inspections in this period.</p>
{% endif %}
</div>
</div>
</div>
{# ── Issue severity breakdown ── #}
<div class="col-md-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-exclamation-triangle me-1"></i> Open Issues by Severity
</div>
<div class="card-body">
{% for sev, color in [('critical','danger'),('high','danger'),('medium','warning'),('low','secondary')] %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="badge bg-{{ color }} {{ 'text-dark' if color == 'warning' else '' }}">
{{ sev|title }}
</span>
<span class="fw-bold fs-5">{{ sev_counts.get(sev, 0) }}</span>
</div>
{% endfor %}
{% if pending_verification > 0 %}
<hr class="my-2">
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-info text-dark">Pending Verification</span>
<span class="fw-bold fs-5">{{ pending_verification }}</span>
</div>
{% endif %}
</div>
</div>
</div>
{# ── Area scores ── #}
{% if area_scores %}
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i> Score by Area
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Area</th><th class="text-center">Avg Score</th><th class="text-center">Inspections</th></tr>
</thead>
<tbody>
{% for a in area_scores %}
<tr>
<td>{{ a.name }}</td>
<td class="text-center">
<span class="badge bg-{{ 'success' if a.avg >= 80 else 'warning text-dark' if a.avg >= 60 else 'danger' }}">
{{ a.avg|round(1) }}%
</span>
</td>
<td class="text-center text-muted small">{{ a.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# ── Follow-up required ── #}
{% if followup_required %}
<div class="col-md-6">
<div class="card shadow-sm border-warning">
<div class="card-header bg-warning text-dark fw-semibold">
<i class="bi bi-arrow-repeat me-1"></i> Follow-up Required
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Inspection</th><th>Date</th><th>Score</th><th></th></tr>
</thead>
<tbody>
{% for insp in followup_required %}
<tr>
<td>#{{ insp.id }} — {{ insp.template.name }}</td>
<td class="text-muted small">{{ insp.inspection_date.strftime('%Y-%m-%d') }}</td>
<td>
{% if insp.overall_score %}
<span class="badge bg-{{ 'success' if insp.overall_score >= 80 else 'warning text-dark' if insp.overall_score >= 60 else 'danger' }}">
{{ insp.overall_score|round(1) }}%
</span>
{% else %}—{% endif %}
</td>
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="btn btn-xs btn-outline-secondary btn-sm">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# ── Open issues list ── #}
{% if open_issues %}
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-bug me-1"></i> Open Issues
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>ID</th><th>Severity</th><th>Area</th><th>Description</th><th>Status</th><th>Reported</th><th></th></tr>
</thead>
<tbody>
{% for issue in open_issues %}
<tr>
<td class="text-muted small">#{{ issue.id }}</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 class="small">{{ issue.area.name }}</td>
<td class="small">{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}</td>
<td>
<span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td class="text-muted small">{{ issue.reported_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
{% if trend_labels %}
(function () {
const ctx = document.getElementById('trendChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: {{ trend_labels | tojson }},
datasets: [{
label: 'Avg Score (%)',
data: {{ trend_data | tojson }},
borderColor: '#2563eb',
backgroundColor: 'rgba(37,99,235,.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { min: 0, max: 100, ticks: { callback: v => v + '%' } }
}
}
});
}());
{% endif %}
</script>
{% endblock %}
+152
View File
@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:640px;margin:auto;">
<div style="background:#1a1d23;padding:20px 28px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:1.1rem;">
<span style="color:#93c5fd;">&#128203;</span>
{{ report.frequency|title }} Report — {{ report.name }}
</h2>
<p style="color:#94a3b8;font-size:.8rem;margin:4px 0 0;">
{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}
{% if facility %} · {{ facility.name }}{% endif %}
</p>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-top:none;
border-radius:0 0 8px 8px;padding:24px;">
{# ── Summary / Facility type ── #}
{% if report.report_type in ('summary','facility') %}
{# KPI row #}
<table width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:24px;">
<tr>
<td align="center" style="padding:12px;background:#f1f5f9;border-radius:8px;">
<div style="font-size:1.6rem;font-weight:700;color:#1d4ed8;">{{ total_inspections }}</div>
<div style="font-size:.75rem;color:#64748b;">Inspections</div>
</td>
<td width="12"></td>
<td align="center" style="padding:12px;background:#f0fdf4;border-radius:8px;">
<div style="font-size:1.6rem;font-weight:700;color:#15803d;">{{ completed }}</div>
<div style="font-size:.75rem;color:#64748b;">Completed</div>
</td>
<td width="12"></td>
<td align="center" style="padding:12px;background:#fef3c7;border-radius:8px;">
<div style="font-size:1.6rem;font-weight:700;color:#b45309;">{{ open_issues }}</div>
<div style="font-size:.75rem;color:#64748b;">Open Issues</div>
</td>
<td width="12"></td>
<td align="center" style="padding:12px;background:#f0f9ff;border-radius:8px;">
<div style="font-size:1.6rem;font-weight:700;color:#0369a1;">
{{ '%.1f'|format(avg_score) ~ '%' if avg_score else '—' }}
</div>
<div style="font-size:.75rem;color:#64748b;">Avg Score</div>
</td>
</tr>
</table>
{% if facility_scores %}
<h3 style="font-size:.9rem;border-bottom:1px solid #e2e8f0;padding-bottom:6px;">
Facility Scores
</h3>
<table width="100%" style="border-collapse:collapse;font-size:.85rem;margin-bottom:20px;">
<thead>
<tr style="background:#f8fafc;">
<th style="text-align:left;padding:6px 8px;">Facility</th>
<th style="text-align:center;padding:6px 8px;">Inspections</th>
<th style="text-align:center;padding:6px 8px;">Avg Score</th>
</tr>
</thead>
<tbody>
{% for row in facility_scores %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:6px 8px;">{{ row.name }}</td>
<td style="padding:6px 8px;text-align:center;">{{ row.count }}</td>
<td style="padding:6px 8px;text-align:center;">
<span style="font-weight:600;color:{{ '#15803d' if row.avg >= 90 else '#b45309' if row.avg >= 70 else '#dc2626' }}">
{{ '%.1f'|format(row.avg) }}%
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if critical_issues %}
<h3 style="font-size:.9rem;border-bottom:1px solid #e2e8f0;padding-bottom:6px;color:#dc2626;">
&#9888; Open Critical / High Issues
</h3>
<table width="100%" style="border-collapse:collapse;font-size:.82rem;margin-bottom:20px;">
<thead>
<tr style="background:#fef2f2;">
<th style="text-align:left;padding:6px 8px;">Issue</th>
<th style="text-align:left;padding:6px 8px;">Severity</th>
<th style="text-align:left;padding:6px 8px;">Facility / Area</th>
<th style="text-align:left;padding:6px 8px;">Reported</th>
</tr>
</thead>
<tbody>
{% for i in critical_issues %}
<tr style="border-bottom:1px solid #fee2e2;">
<td style="padding:6px 8px;">
<a href="{{ base_url }}/issues/{{ i.id }}" style="color:#1d4ed8;">#{{ i.id }}</a>
— {{ i.description[:60] }}{% if i.description|length > 60 %}…{% endif %}
</td>
<td style="padding:6px 8px;font-weight:600;color:#dc2626;">{{ i.severity|title }}</td>
<td style="padding:6px 8px;">{{ i.area.facility.name }} / {{ i.area.name }}</td>
<td style="padding:6px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% elif report.report_type == 'issues' %}
{% if issues %}
<h3 style="font-size:.9rem;border-bottom:1px solid #e2e8f0;padding-bottom:6px;">
Open Issues ({{ issues|length }})
</h3>
<table width="100%" style="border-collapse:collapse;font-size:.82rem;">
<thead>
<tr style="background:#f8fafc;">
<th style="padding:6px 8px;">#</th>
<th style="padding:6px 8px;">Severity</th>
<th style="padding:6px 8px;">Facility / Area</th>
<th style="padding:6px 8px;">Description</th>
<th style="padding:6px 8px;">Status</th>
<th style="padding:6px 8px;">Reported</th>
</tr>
</thead>
<tbody>
{% for i in issues %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:6px 8px;">
<a href="{{ base_url }}/issues/{{ i.id }}" style="color:#1d4ed8;">#{{ i.id }}</a>
</td>
<td style="padding:6px 8px;font-weight:600;color:{{ '#dc2626' if i.severity in ['critical','high'] else '#b45309' if i.severity == 'medium' else '#64748b' }}">
{{ i.severity|title }}
</td>
<td style="padding:6px 8px;">{{ i.area.facility.name }} / {{ i.area.name }}</td>
<td style="padding:6px 8px;">{{ i.description[:80] }}{% if i.description|length > 80 %}…{% endif %}</td>
<td style="padding:6px 8px;">{{ i.status|replace('_',' ')|title }}</td>
<td style="padding:6px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#64748b;">No open issues in this period.</p>
{% endif %}
{% endif %}
<hr style="border:none;border-top:1px solid #e2e8f0;margin:28px 0 16px;">
<p style="font-size:.75rem;color:#94a3b8;">
Janitorial QC System — scheduled report. Do not reply to this email.<br>
<a href="{{ base_url }}/reports" style="color:#94a3b8;">View full reports dashboard</a>
</p>
</div>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{{ report.frequency|title }} Report — {{ report.name }}
{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}{% if facility %} · {{ facility.name }}{% endif %}
{% if report.report_type in ('summary','facility') %}
SUMMARY
-------
Inspections: {{ total_inspections }}
Completed: {{ completed }}
Open Issues: {{ open_issues }}
Avg Score: {{ '%.1f'|format(avg_score) ~ '%' if avg_score else '—' }}
{% if facility_scores %}
FACILITY SCORES
---------------
{% for row in facility_scores %}
{{ row.name }}: {{ '%.1f'|format(row.avg) }}% ({{ row.count }} inspection{{ 's' if row.count != 1 else '' }})
{% endfor %}
{% endif %}
{% if critical_issues %}
OPEN CRITICAL / HIGH ISSUES
----------------------------
{% for i in critical_issues %}
#{{ i.id }} [{{ i.severity|title }}] {{ i.area.facility.name }} — {{ i.description[:80] }}
Link: {{ base_url }}/issues/{{ i.id }}
{% endfor %}
{% endif %}
{% elif report.report_type == 'issues' %}
OPEN ISSUES ({{ issues|length }})
{% if issues %}
{% for i in issues %}
#{{ i.id }} [{{ i.severity|title }}] {{ i.area.facility.name }} / {{ i.area.name }}
Status: {{ i.status|replace('_',' ')|title }} | {{ i.description[:80] }}
Link: {{ base_url }}/issues/{{ i.id }}
{% endfor %}
{% else %}
No open issues.
{% endif %}
{% endif %}
--
Janitorial QC System — automated scheduled report.
View dashboard: {{ base_url }}/reports
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0"><i class="bi bi-calendar-check me-2"></i>{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Report Name</label>
<input type="text" name="name" class="form-control"
value="{{ report.name if report else '' }}" required
placeholder="e.g. Weekly Facility Summary">
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Report Type</label>
<select name="report_type" class="form-select">
{% for val, label in [('summary','Summary KPIs'),('facility','Facility Detail'),('issues','Open Issues')] %}
<option value="{{ val }}" {{ 'selected' if report and report.report_type == val else '' }}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Frequency</label>
<select name="frequency" class="form-select">
{% for val in ['daily','weekly','monthly'] %}
<option value="{{ val }}" {{ 'selected' if report and report.frequency == val else '' }}>
{{ val|title }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Facility <span class="text-muted small">(optional)</span></label>
<select name="facility_id" class="form-select">
<option value="">— All Facilities —</option>
{% for f in facilities %}
<option value="{{ f.id }}"
{{ 'selected' if report and report.facility_id == f.id else '' }}>
{{ f.name }}
</option>
{% endfor %}
</select>
<div class="form-text">Leave blank to include all facilities.</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Recipients</label>
<input type="text" name="recipients" class="form-control"
value="{{ report.recipient_list()|join(', ') if report else '' }}"
placeholder="email1@example.com, email2@example.com"
required>
<div class="form-text">Comma-separated list of email addresses.</div>
</div>
<div class="row mb-3">
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="include_csv"
id="include_csv" value="1"
{{ 'checked' if report and report.include_csv else '' }}>
<label class="form-check-label" for="include_csv">Attach CSV export</label>
</div>
</div>
</div>
{% if report %}
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="active"
id="active" value="1"
{{ 'checked' if report.active else '' }}>
<label class="form-check-label" for="active">Active (send on schedule)</label>
</div>
</div>
{% endif %}
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>
{{ 'Save Changes' if report else 'Create Schedule' }}
</button>
<a href="{{ url_for('scheduled_reports.index') }}" class="btn btn-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Scheduled Reports{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-calendar-check"></i> Scheduled Reports</h2>
<a href="{{ url_for('scheduled_reports.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
</div>
{% if reports %}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th><th>Type</th><th>Frequency</th><th>Facility</th>
<th>Recipients</th><th>Next Send</th><th>Last Sent</th>
<th>Status</th><th width="160"></th>
</tr>
</thead>
<tbody>
{% for r in reports %}
<tr class="{{ 'text-muted' if not r.active else '' }}">
<td><strong>{{ r.name }}</strong></td>
<td><span class="badge bg-secondary">{{ r.report_type|title }}</span></td>
<td>{{ r.frequency|title }}</td>
<td>{{ r.facility.name if r.facility else '— All —' }}</td>
<td>
<span title="{{ r.recipient_list()|join(', ') }}">
{{ r.recipient_list()|length }} recipient{{ 's' if r.recipient_list()|length != 1 else '' }}
</span>
</td>
<td class="small text-muted">
{{ r.next_send_at.strftime('%Y-%m-%d %H:%M') if r.next_send_at else '—' }}
</td>
<td class="small text-muted">
{{ r.last_sent_at.strftime('%Y-%m-%d %H:%M') if r.last_sent_at else 'Never' }}
</td>
<td>
{% if r.active %}<span class="badge bg-success">Active</span>
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
</td>
<td class="text-end">
<a href="{{ url_for('scheduled_reports.edit', report_id=r.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form method="POST"
action="{{ url_for('scheduled_reports.send_now', report_id=r.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-primary" title="Send Now"
onclick="return confirm('Send this report now?')">
<i class="bi bi-send"></i>
</button>
</form>
<form method="POST"
action="{{ url_for('scheduled_reports.delete', report_id=r.id) }}"
class="d-inline"
onsubmit="return confirm('Delete this scheduled report?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash3"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-3">No scheduled reports configured yet.</p>
<a href="{{ url_for('scheduled_reports.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create First Schedule
</a>
</div>
</div>
{% endif %}
{% endblock %}
+109
View File
@@ -0,0 +1,109 @@
"""Phase 6: Scheduled reports, re-inspection workflow, issue verification
Revision ID: phase6_features
Revises: phase1_projects_roles
Create Date: 2026-03-04
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
revision = 'phase6_features'
down_revision = 'phase1_projects_roles' # <-- set to your current DB head
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = inspector.get_table_names()
# ── 1. scheduled_reports ─────────────────────────────────────────────────
if 'scheduled_reports' not in tables:
op.create_table(
'scheduled_reports',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('report_type', sa.Enum('summary', 'facility', 'issues'),
nullable=False, server_default='summary'),
sa.Column('frequency', sa.Enum('daily', 'weekly', 'monthly'),
nullable=False),
sa.Column('facility_id', sa.Integer(),
sa.ForeignKey('facilities.id', ondelete='SET NULL'),
nullable=True),
sa.Column('recipients', sa.JSON(), nullable=False), # list of email strings
sa.Column('include_pdf', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('include_csv', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('created_by', sa.Integer(),
sa.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('last_sent_at', sa.DateTime(), nullable=True),
sa.Column('next_send_at', sa.DateTime(), nullable=True),
)
# ── 2. inspections: parent_inspection_id + follow_up columns ────────────
insp_cols = {c['name'] for c in inspector.get_columns('inspections')}
if 'parent_inspection_id' not in insp_cols:
op.add_column('inspections',
sa.Column('parent_inspection_id', sa.Integer(),
sa.ForeignKey('inspections.id', ondelete='SET NULL'),
nullable=True))
if 'follow_up_required' not in insp_cols:
op.add_column('inspections',
sa.Column('follow_up_required', sa.Boolean(),
nullable=False, server_default='0'))
if 'follow_up_note' not in insp_cols:
op.add_column('inspections',
sa.Column('follow_up_note', sa.Text(), nullable=True))
# ── 3. issues: verification columns + extend status enum ────────────────
issue_cols = {c['name'] for c in inspector.get_columns('issues')}
if 'verified_by' not in issue_cols:
op.add_column('issues',
sa.Column('verified_by', sa.Integer(),
sa.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True))
if 'verified_at' not in issue_cols:
op.add_column('issues',
sa.Column('verified_at', sa.DateTime(), nullable=True))
if 'verification_note' not in issue_cols:
op.add_column('issues',
sa.Column('verification_note', sa.Text(), nullable=True))
# Extend the status ENUM to include 'pending_verification'
# MySQL requires modifying the column definition directly
op.execute(
"ALTER TABLE issues MODIFY COLUMN status "
"ENUM('open','in_progress','resolved','pending_verification') "
"NOT NULL DEFAULT 'open'"
)
def downgrade():
# Revert status enum
op.execute(
"ALTER TABLE issues MODIFY COLUMN status "
"ENUM('open','in_progress','resolved') "
"NOT NULL DEFAULT 'open'"
)
issue_cols = {c['name'] for c in sa.inspect(op.get_bind()).get_columns('issues')}
for col in ('verified_by', 'verified_at', 'verification_note'):
if col in issue_cols:
op.drop_column('issues', col)
insp_cols = {c['name'] for c in sa.inspect(op.get_bind()).get_columns('inspections')}
for col in ('parent_inspection_id', 'follow_up_required', 'follow_up_note'):
if col in insp_cols:
op.drop_column('inspections', col)
op.drop_table('scheduled_reports')