diff --git a/app/__init__.py b/app/__init__.py index 5927d06..0a07f02 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -106,6 +106,7 @@ def create_app(config_name='default'): from app.routes import audit # Audit Trail from app.routes import projects # Phase 1/2 — Project 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(dashboard.bp) @@ -118,6 +119,7 @@ def create_app(config_name='default'): app.register_blueprint(audit.bp) app.register_blueprint(projects.bp) app.register_blueprint(customers.bp) + app.register_blueprint(scheduled_reports.bp) # ── Error handler: 413 Request Entity Too Large ─────────────────────── # Nginx can return 413 before Flask sees the request; this handler covers diff --git a/app/models/inspection.py b/app/models/inspection.py index 3835061..42752d1 100644 --- a/app/models/inspection.py +++ b/app/models/inspection.py @@ -64,8 +64,17 @@ class Inspection(db.Model): form_data = db.Column(db.JSON) # filled form field responses {field_id: value} completed_at = db.Column(db.DateTime) - results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan') - issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan') + # ── Re-inspection / follow-up workflow ──────────────────────────────── + 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): return f'' diff --git a/app/models/issue.py b/app/models/issue.py index 637a489..12841e6 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -51,19 +51,25 @@ class Issue(db.Model): severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False) description = db.Column(db.Text, nullable=False) 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')) reported_at = db.Column(db.DateTime, default=now_eastern) resolved_at = db.Column(db.DateTime) result_notes = db.Column(db.Text) 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 # don't fire duplicate notifications. Values: None / 'at_risk' / 'breached' sla_notified = db.Column(db.String(10), nullable=True, default=None) # 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', order_by='IssueComment.created_at', cascade='all, delete-orphan') diff --git a/app/models/scheduled_report.py b/app/models/scheduled_report.py new file mode 100644 index 0000000..ca00fe4 --- /dev/null +++ b/app/models/scheduled_report.py @@ -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'' diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 01fe65e..70a4373 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -94,6 +94,19 @@ def index(): recent_q = recent_q.filter(False) 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) ─────────────────────────────────── total_facilities = Facility.query.filter_by(active=True).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, facility_perf = facility_perf, customer_facilities = customer_facilities, + pending_followups = pending_followups, ) diff --git a/app/routes/inspections.py b/app/routes/inspections.py index b65ee12..bb29504 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -191,12 +191,18 @@ def index(): else: q = q.filter(Inspection.facility_id.in_(customer_facility_ids)) - status_filter = request.args.get('status', '') - facility_filter = request.args.get('facility_id', '') + status_filter = request.args.get('status', '') + facility_filter = request.args.get('facility_id', '') + follow_up_filter = request.args.get('follow_up', '') if status_filter: q = q.filter(Inspection.status == status_filter) if facility_filter.isdigit(): 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) if current_user.role == 'customer': @@ -209,7 +215,8 @@ def index(): inspections=inspections, facilities=facilities, status_filter=status_filter, - facility_filter=facility_filter) + facility_filter=facility_filter, + follow_up_filter=follow_up_filter) # ── Start ───────────────────────────────────────────────────────────────────── @@ -225,6 +232,14 @@ def start(): form.template_id.choices = [(t.id, t.name) for t in templates] 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) 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] @@ -237,14 +252,17 @@ def start(): flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning') return redirect(url_for('inspections.start')) + from flask import session as _session + parent_id = _session.pop('reinspect_parent_id', None) inspection = Inspection( - template_id = template.id, - facility_id = form.facility_id.data, - area_id = form.area_id.data or None, - inspector_id = current_user.id, - inspection_date = now_eastern(), - status = 'in_progress', - notes = form.notes.data or None, + template_id = template.id, + facility_id = form.facility_id.data, + area_id = form.area_id.data or None, + inspector_id = current_user.id, + inspection_date = now_eastern(), + status = 'in_progress', + notes = form.notes.data or None, + parent_inspection_id = parent_id, ) db.session.add(inspection) db.session.commit() @@ -642,6 +660,73 @@ def export_pdf(inspection_id): ) +# ── Flag / clear follow-up required ────────────────────────────────────────── + +@bp.route('//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('//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('//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 ──────────────────────────────────────────────────────────────────── @bp.route('//delete', methods=['POST']) diff --git a/app/routes/issues.py b/app/routes/issues.py index 6714da5..5b916d2 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -117,6 +117,14 @@ def view(issue_id): form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] 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(): old_status = issue.status old_assigned_to = issue.assigned_to @@ -416,4 +424,96 @@ def create(): flash('Issue created.', 'success') return redirect(url_for('issues.index')) + +# ── Supervisor verify resolved issue ───────────────────────────────────────── + +@bp.route('//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('//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') \ No newline at end of file diff --git a/app/routes/reports.py b/app/routes/reports.py index d0ade69..8faeecf 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -220,6 +220,134 @@ def facility_report(facility_id): start=start, end=end) + +# ── Facility Scorecard ──────────────────────────────────────────────────────── + +@bp.route('/facility//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 ──────────────────────────────────────────────────────────────── @bp.route('/export/inspections') diff --git a/app/routes/scheduled_reports.py b/app/routes/scheduled_reports.py new file mode 100644 index 0000000..bbfc989 --- /dev/null +++ b/app/routes/scheduled_reports.py @@ -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('//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('//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('//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=&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}) diff --git a/app/templates/base.html b/app/templates/base.html index cfd2fbb..7da6cc2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -79,6 +79,16 @@ + {% if current_user.role in ['admin', 'supervisor'] %} + + {% endif %} + {% if current_user.role in ['admin','supervisor'] %} + + {% endif %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 77eabbc..9d6cec5 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -14,6 +14,7 @@ {# ── Top stat cards ─────────────────────────────────────────────────────── #}
+
+
+ +{# ── Pending Follow-ups alert (non-customer) ────────────────────────────── #} +{% if pending_followups and pending_followups > 0 and current_user.role != 'customer' %} + +{% endif %} + {# ── SLA Summary ─────────────────────────────────────────────────────────── #} {% if sla_breached > 0 or sla_at_risk > 0 %}
diff --git a/app/templates/facilities/view.html b/app/templates/facilities/view.html index f2d9ea1..67aede2 100644 --- a/app/templates/facilities/view.html +++ b/app/templates/facilities/view.html @@ -8,6 +8,12 @@

{{ facility.name }}

+ {% if current_user.role in ['admin', 'supervisor', 'project_manager', 'customer'] %} + + Scorecard + + {% endif %} {% if current_user.role in ['admin', 'supervisor'] %} Edit @@ -66,15 +72,29 @@
Statistics
-
- diff --git a/app/templates/inspections/list.html b/app/templates/inspections/list.html index 96034f8..6262e24 100644 --- a/app/templates/inspections/list.html +++ b/app/templates/inspections/list.html @@ -71,6 +71,11 @@ {{ ins.status|replace('_',' ')|title }} + {% if ins.follow_up_required and not ins.follow_ups.count() %} + + Follow-up + + {% endif %} {% if ins.status == 'in_progress' or ins.status == 'flagged' %} diff --git a/app/templates/inspections/view.html b/app/templates/inspections/view.html index 951cbb8..50b026e 100644 --- a/app/templates/inspections/view.html +++ b/app/templates/inspections/view.html @@ -137,7 +137,30 @@ + {% if current_user.role not in ['customer'] %} + + Re-inspect + + {% endif %} {% if current_user.role in ['admin','supervisor'] %} + {% if not inspection.follow_up_required %} + + {% else %} +
+ + +
+ {% endif %}
@@ -147,6 +170,48 @@
+ {# ── Follow-up required alert ── #} + {% if inspection.follow_up_required %} +
+ +
+ Follow-up Inspection Required + {% if inspection.follow_up_note %}
{{ inspection.follow_up_note }}{% endif %} + +
+
+ {% endif %} + + {# ── Parent/child inspection links ── #} + {% if inspection.parent %} +
+ + This is a re-inspection of + Inspection #{{ inspection.parent.id }} + ({{ inspection.parent.inspection_date.strftime('%Y-%m-%d') }}, + score: {{ inspection.parent.overall_score|round(1) if inspection.parent.overall_score else 'N/A' }}%). +
+ {% endif %} + {% set followups = inspection.follow_ups.all() %} + {% if followups %} +
+ + Follow-up inspection(s): + {% for fu in followups %} + + #{{ fu.id }} ({{ fu.inspection_date.strftime('%Y-%m-%d') }}, + score: {{ fu.overall_score|round(1) if fu.overall_score else 'N/A' }}%) + {% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} + {# Header #}
@@ -452,4 +517,30 @@ function closeMedia() { } document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia(); }); + +{# ── Flag follow-up modal ── #} + {% endblock %} \ No newline at end of file diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html index a138f9a..794d6c2 100644 --- a/app/templates/issues/list.html +++ b/app/templates/issues/list.html @@ -71,7 +71,7 @@ {{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %} - + {{ issue.status|replace('_',' ')|title }} diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html index a97257c..afc9623 100644 --- a/app/templates/issues/view.html +++ b/app/templates/issues/view.html @@ -8,7 +8,9 @@ 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' }}">
Issue #{{ issue.id }} — {{ issue.severity|title }} Severity
- {{ issue.status|replace('_',' ')|title }} + + {{ issue.status|replace('_',' ')|title }} +
@@ -67,6 +69,35 @@
{% endif %} {% endif %} + + {# ── Verification panel ── #} + {% if issue.verified_at %} +
+
+ + Verified by {{ issue.verifier.username if issue.verifier else 'unknown' }} + on {{ issue.verified_at.strftime('%Y-%m-%d %H:%M') }}. + {% if issue.verification_note %}
{{ issue.verification_note }}{% endif %} +
+ {% elif issue.status == 'pending_verification' %} +
+
+ + Awaiting supervisor verification. + {% if current_user.role in ['admin','supervisor'] %} +
+ +
+ +
+ +
+ {% endif %} +
+ {% endif %}
@@ -172,6 +203,17 @@
+ {% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %} +
+ + +
+ {% endif %}
{% endif %} diff --git a/app/templates/reports/scorecard.html b/app/templates/reports/scorecard.html new file mode 100644 index 0000000..962392f --- /dev/null +++ b/app/templates/reports/scorecard.html @@ -0,0 +1,275 @@ +{% extends "base.html" %} +{% block title %}{{ facility.name }} — Scorecard{% endblock %} +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

{{ facility.name }}

+

Facility Scorecard — last {{ days }} days

+
+
+ {# Period selector #} +
+ {% for d, label in [(30,'30d'),(60,'60d'),(90,'90d'),(180,'180d'),(365,'1yr')] %} + {{ label }} + {% endfor %} +
+ + Full Report + + + Reports + +
+
+ +{# ── KPI row ── #} +
+
+
+
+
Total Inspections
+
{{ total_inspections }}
+
{{ completed_insp }} completed
+
+
+
+
+
+
+
Avg Score
+
{{ avg_score|round(1) if avg_score else '—' }}{% if avg_score %}%{% endif %}
+
{{ days }}-day average
+
+
+
+
+
+
+
SLA Compliance
+
{{ sla_pct|round(1) if sla_pct is not none else '—' }}{% if sla_pct is not none %}%{% endif %}
+
{{ sla_met }}/{{ sla_total }} closed on time
+
+
+
+
+
+
+
Open Issues
+
{{ open_issues|length }}
+
+ {% if pending_verification > 0 %} + {{ pending_verification }} pending verification + {% else %} + across all severities + {% endif %} +
+
+
+
+
+ +
+ + {# ── Score trend chart ── #} +
+
+
+ Score Trend +
+
+ {% if trend_labels %} +
+ +
+ {% else %} +

No completed inspections in this period.

+ {% endif %} +
+
+
+ + {# ── Issue severity breakdown ── #} +
+
+
+ Open Issues by Severity +
+
+ {% for sev, color in [('critical','danger'),('high','danger'),('medium','warning'),('low','secondary')] %} +
+ + {{ sev|title }} + + {{ sev_counts.get(sev, 0) }} +
+ {% endfor %} + {% if pending_verification > 0 %} +
+
+ Pending Verification + {{ pending_verification }} +
+ {% endif %} +
+
+
+ + {# ── Area scores ── #} + {% if area_scores %} +
+
+
+ Score by Area +
+
+ + + + + + {% for a in area_scores %} + + + + + + {% endfor %} + +
AreaAvg ScoreInspections
{{ a.name }} + + {{ a.avg|round(1) }}% + + {{ a.count }}
+
+
+
+ {% endif %} + + {# ── Follow-up required ── #} + {% if followup_required %} +
+
+
+ Follow-up Required +
+
+ + + + + + {% for insp in followup_required %} + + + + + + + {% endfor %} + +
InspectionDateScore
#{{ insp.id }} — {{ insp.template.name }}{{ insp.inspection_date.strftime('%Y-%m-%d') }} + {% if insp.overall_score %} + + {{ insp.overall_score|round(1) }}% + + {% else %}—{% endif %} + + View +
+
+
+
+ {% endif %} + + {# ── Open issues list ── #} + {% if open_issues %} +
+
+
+ Open Issues +
+
+ + + + + + {% for issue in open_issues %} + + + + + + + + + + {% endfor %} + +
IDSeverityAreaDescriptionStatusReported
#{{ issue.id }} + + {{ issue.severity|title }} + + {{ issue.area.name }}{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %} + + {{ issue.status|replace('_',' ')|title }} + + {{ issue.reported_at.strftime('%Y-%m-%d') }} + View +
+
+
+
+ {% endif %} + +
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/app/templates/scheduled_reports/email.html b/app/templates/scheduled_reports/email.html new file mode 100644 index 0000000..897d297 --- /dev/null +++ b/app/templates/scheduled_reports/email.html @@ -0,0 +1,152 @@ + + + +
+

+ 📋 + {{ report.frequency|title }} Report — {{ report.name }} +

+

+ {{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }} + {% if facility %} · {{ facility.name }}{% endif %} +

+
+ +
+ + {# ── Summary / Facility type ── #} + {% if report.report_type in ('summary','facility') %} + + {# KPI row #} + + + + + + + + + + +
+
{{ total_inspections }}
+
Inspections
+
+
{{ completed }}
+
Completed
+
+
{{ open_issues }}
+
Open Issues
+
+
+ {{ '%.1f'|format(avg_score) ~ '%' if avg_score else '—' }} +
+
Avg Score
+
+ + {% if facility_scores %} +

+ Facility Scores +

+ + + + + + + + + + {% for row in facility_scores %} + + + + + + {% endfor %} + +
FacilityInspectionsAvg Score
{{ row.name }}{{ row.count }} + + {{ '%.1f'|format(row.avg) }}% + +
+ {% endif %} + + {% if critical_issues %} +

+ ⚠ Open Critical / High Issues +

+ + + + + + + + + + + {% for i in critical_issues %} + + + + + + + {% endfor %} + +
IssueSeverityFacility / AreaReported
+ #{{ i.id }} + — {{ i.description[:60] }}{% if i.description|length > 60 %}…{% endif %} + {{ i.severity|title }}{{ i.area.facility.name }} / {{ i.area.name }}{{ i.reported_at.strftime('%b %d') }}
+ {% endif %} + + {% elif report.report_type == 'issues' %} + + {% if issues %} +

+ Open Issues ({{ issues|length }}) +

+ + + + + + + + + + + + + {% for i in issues %} + + + + + + + + + {% endfor %} + +
#SeverityFacility / AreaDescriptionStatusReported
+ #{{ i.id }} + + {{ i.severity|title }} + {{ i.area.facility.name }} / {{ i.area.name }}{{ i.description[:80] }}{% if i.description|length > 80 %}…{% endif %}{{ i.status|replace('_',' ')|title }}{{ i.reported_at.strftime('%b %d') }}
+ {% else %} +

No open issues in this period.

+ {% endif %} + + {% endif %} + +
+

+ Janitorial QC System — scheduled report. Do not reply to this email.
+ View full reports dashboard +

+
+ + diff --git a/app/templates/scheduled_reports/email.txt b/app/templates/scheduled_reports/email.txt new file mode 100644 index 0000000..d44e3d7 --- /dev/null +++ b/app/templates/scheduled_reports/email.txt @@ -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 diff --git a/app/templates/scheduled_reports/form.html b/app/templates/scheduled_reports/form.html new file mode 100644 index 0000000..c221c07 --- /dev/null +++ b/app/templates/scheduled_reports/form.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block content %} +
+
+
+
+

{{ title }}

+
+
+
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
Leave blank to include all facilities.
+
+
+ +
+ + +
Comma-separated list of email addresses.
+
+ +
+
+
+ + +
+
+
+ + {% if report %} +
+
+ + +
+
+ {% endif %} + +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/scheduled_reports/index.html b/app/templates/scheduled_reports/index.html new file mode 100644 index 0000000..e5716dc --- /dev/null +++ b/app/templates/scheduled_reports/index.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Scheduled Reports{% endblock %} +{% block content %} +
+

Scheduled Reports

+ + New Schedule + +
+ +{% if reports %} +
+
+
+ + + + + + + + + + {% for r in reports %} + + + + + + + + + + + + {% endfor %} + +
NameTypeFrequencyFacilityRecipientsNext SendLast SentStatus
{{ r.name }}{{ r.report_type|title }}{{ r.frequency|title }}{{ r.facility.name if r.facility else '— All —' }} + + {{ r.recipient_list()|length }} recipient{{ 's' if r.recipient_list()|length != 1 else '' }} + + + {{ r.next_send_at.strftime('%Y-%m-%d %H:%M') if r.next_send_at else '—' }} + + {{ r.last_sent_at.strftime('%Y-%m-%d %H:%M') if r.last_sent_at else 'Never' }} + + {% if r.active %}Active + {% else %}Paused{% endif %} + + + + +
+ + +
+
+ + +
+
+
+
+
+{% else %} +
+
+ +

No scheduled reports configured yet.

+ + Create First Schedule + +
+
+{% endif %} +{% endblock %} diff --git a/migrations/versions/phase6_features.py b/migrations/versions/phase6_features.py new file mode 100644 index 0000000..c6d14a0 --- /dev/null +++ b/migrations/versions/phase6_features.py @@ -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')