From 77678ed724fb5f0e7469fc116b88916b97a6e24e Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 26 Jun 2026 09:04:34 -0400 Subject: [PATCH] First commit --- .claude/settings.json | 11 + .gitignore | 36 + CLAUDE.md | 1041 +++++++++++ LICENSE | 674 +++++++ README.md | 312 ++++ app/__init__.py | 252 +++ app/add_form_schema.py | 40 + app/api/__init__.py | 53 + app/api/auth.py | 333 ++++ app/api/comments.py | 187 ++ app/api/decorators.py | 109 ++ app/api/errors.py | 87 + app/api/facilities.py | 194 ++ app/api/inspections.py | 601 ++++++ app/api/issues.py | 435 +++++ app/api/jwt_utils.py | 74 + app/api/notifications.py | 165 ++ app/api/photos.py | 100 + app/api/stats.py | 178 ++ app/api/templates.py | 149 ++ app/models/__init__.py | 8 + app/models/api_token.py | 132 ++ app/models/audit.py | 35 + app/models/broadcast.py | 28 + app/models/facility.py | 39 + app/models/inspection.py | 99 + app/models/inspector_assignment.py | 25 + app/models/issue.py | 118 ++ app/models/notification.py | 108 ++ app/models/notification_matrix.py | 228 +++ app/models/project.py | 63 + app/models/scheduled_report.py | 69 + app/models/score_alert.py | 24 + app/models/support.py | 41 + app/models/user.py | 100 + app/routes/__init__.py | 0 app/routes/audit.py | 150 ++ app/routes/auth.py | 519 ++++++ app/routes/broadcast.py | 140 ++ app/routes/customers.py | 785 ++++++++ app/routes/dashboard.py | 427 +++++ app/routes/facilities.py | 238 +++ app/routes/inspections.py | 1349 ++++++++++++++ app/routes/issues.py | 1081 +++++++++++ app/routes/notifications.py | 308 +++ app/routes/projects.py | 668 +++++++ app/routes/reports.py | 1656 +++++++++++++++++ app/routes/scheduled_reports.py | 524 ++++++ app/routes/support.py | 371 ++++ app/routes/templates.py | 443 +++++ app/static/css/ipad_responsive.css | 317 ++++ app/static/css/theme.css | 300 +++ app/templates/_sla_badge.html | 27 + app/templates/admin/broadcast.html | 225 +++ app/templates/audit/index.html | 254 +++ app/templates/audit/view.html | 111 ++ app/templates/auth/forgot_password.html | 79 + app/templates/auth/inspector_assignments.html | 84 + app/templates/auth/login.html | 334 ++++ app/templates/auth/notification_matrix.html | 218 +++ app/templates/auth/profile.html | 224 +++ app/templates/auth/reset_password.html | 151 ++ app/templates/auth/user_form.html | 130 ++ app/templates/auth/users.html | 99 + app/templates/base.html | 463 +++++ app/templates/customers/form.html | 122 ++ app/templates/customers/import.html | 160 ++ app/templates/customers/index.html | 152 ++ app/templates/customers/invite.html | 75 + app/templates/customers/manage.html | 249 +++ app/templates/customers/set_password.html | 179 ++ app/templates/dashboard.html | 483 +++++ app/templates/facilities/area_form.html | 39 + app/templates/facilities/form.html | 63 + app/templates/facilities/list.html | 194 ++ app/templates/facilities/view.html | 210 +++ app/templates/inspections/execute.html | 1094 +++++++++++ app/templates/inspections/flag_issue.html | 44 + app/templates/inspections/list.html | 286 +++ app/templates/inspections/start.html | 153 ++ app/templates/inspections/view.html | 903 +++++++++ app/templates/issues/form.html | 114 ++ app/templates/issues/list.html | 325 ++++ app/templates/issues/verification_queue.html | 236 +++ app/templates/issues/view.html | 482 +++++ app/templates/login.html | 48 + app/templates/notifications/index.html | 164 ++ app/templates/notifications/preferences.html | 347 ++++ app/templates/projects/assignment_form.html | 51 + app/templates/projects/form.html | 56 + app/templates/projects/import.html | 168 ++ app/templates/projects/list.html | 84 + app/templates/projects/view.html | 169 ++ app/templates/reports/_subnav.html | 44 + app/templates/reports/facility.html | 156 ++ app/templates/reports/followup_closure.html | 186 ++ app/templates/reports/index.html | 319 ++++ .../reports/inspector_performance.html | 395 ++++ app/templates/reports/issues_aging.html | 175 ++ app/templates/reports/scorecard.html | 279 +++ app/templates/reports/sla_compliance.html | 149 ++ app/templates/scheduled_reports/email.html | 185 ++ app/templates/scheduled_reports/email.txt | 47 + app/templates/scheduled_reports/form.html | 102 + app/templates/scheduled_reports/index.html | 96 + app/templates/support.html | 39 + .../support/admin_ticket_detail.html | 143 ++ app/templates/support/admin_tickets.html | 114 ++ app/templates/support/chat.html | 279 +++ app/templates/support/my_ticket_detail.html | 94 + app/templates/support/my_tickets.html | 64 + app/templates/templates/edit.html | 246 +++ app/templates/templates/form.html | 44 + app/templates/templates/form_editor.html | 1249 +++++++++++++ app/templates/templates/form_preview.html | 406 ++++ app/templates/templates/item_form.html | 61 + app/templates/templates/list.html | 269 +++ app/templates/templates/view.html | 179 ++ app/utils/__init__.py | 0 app/utils/audit.py | 98 + app/utils/decorators.py | 80 + app/utils/forms.py | 277 +++ app/utils/notifications.py | 650 +++++++ app/utils/pdf_export.py | 1549 +++++++++++++++ app/utils/scope.py | 118 ++ app/utils/sla.py | 345 ++++ app/utils/time_utils.py | 24 + config.py | 87 + docs/JQC_Customer_User_Manual_v2.docx | Bin 0 -> 43786 bytes docs/JQC_Inspector_Manual.docx | Bin 0 -> 22557 bytes docs/JQC_Web_Inspector_Manual.docx | Bin 0 -> 17288 bytes gunicorn_config.py | 12 + migrations/README | 1 + migrations/alembic.ini | 50 + migrations/env.py | 113 ++ migrations/script.py.mako | 24 + .../phase10_customer_password_setup.py | 44 + migrations/versions/phase11_director_role.py | 68 + .../versions/phase12_performance_indexes.py | 77 + migrations/versions/phase13_issue_facility.py | 46 + .../versions/phase14_facility_created_at.py | 31 + .../versions/phase15_audit_log_indexes.py | 62 + .../versions/phase16_notifications_columns.py | 100 + .../phase17_notification_event_type.py | 53 + .../versions/phase18_issue_reported_by.py | 75 + .../versions/phase19_issue_mobile_photos.py | 46 + migrations/versions/phase1_projects_roles.py | 117 ++ .../versions/phase20_inspector_assignments.py | 47 + .../versions/phase21_performance_indexes.py | 67 + .../versions/phase21_template_active.py | 39 + .../versions/phase22_comment_visibility.py | 41 + .../versions/phase23_support_tickets.py | 70 + .../phase24_issue_created_notify_defaults.py | 32 + migrations/versions/phase25_inspection_gps.py | 36 + migrations/versions/phase26_issue_vendor.py | 50 + migrations/versions/phase27_score_alerts.py | 48 + .../versions/phase28_fix_inspection_notify.py | 37 + migrations/versions/phase29_broadcasts.py | 37 + migrations/versions/phase6_features.py | 109 ++ migrations/versions/phase7_mobile_api.py | 91 + .../versions/phase8_notification_matrix.py | 37 + migrations/versions/phase9_user_full_name.py | 29 + .../versions/phase_b_mobile_local_id.py | 79 + requirements.txt | 24 + run.py | 22 + wsgi.py | 7 + 166 files changed, 34842 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/add_form_schema.py create mode 100644 app/api/__init__.py create mode 100644 app/api/auth.py create mode 100644 app/api/comments.py create mode 100644 app/api/decorators.py create mode 100644 app/api/errors.py create mode 100644 app/api/facilities.py create mode 100644 app/api/inspections.py create mode 100644 app/api/issues.py create mode 100644 app/api/jwt_utils.py create mode 100644 app/api/notifications.py create mode 100644 app/api/photos.py create mode 100644 app/api/stats.py create mode 100644 app/api/templates.py create mode 100644 app/models/__init__.py create mode 100644 app/models/api_token.py create mode 100644 app/models/audit.py create mode 100644 app/models/broadcast.py create mode 100644 app/models/facility.py create mode 100644 app/models/inspection.py create mode 100644 app/models/inspector_assignment.py create mode 100644 app/models/issue.py create mode 100644 app/models/notification.py create mode 100644 app/models/notification_matrix.py create mode 100644 app/models/project.py create mode 100644 app/models/scheduled_report.py create mode 100644 app/models/score_alert.py create mode 100644 app/models/support.py create mode 100644 app/models/user.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/audit.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/broadcast.py create mode 100644 app/routes/customers.py create mode 100644 app/routes/dashboard.py create mode 100644 app/routes/facilities.py create mode 100644 app/routes/inspections.py create mode 100644 app/routes/issues.py create mode 100644 app/routes/notifications.py create mode 100644 app/routes/projects.py create mode 100644 app/routes/reports.py create mode 100644 app/routes/scheduled_reports.py create mode 100644 app/routes/support.py create mode 100644 app/routes/templates.py create mode 100644 app/static/css/ipad_responsive.css create mode 100644 app/static/css/theme.css create mode 100644 app/templates/_sla_badge.html create mode 100644 app/templates/admin/broadcast.html create mode 100644 app/templates/audit/index.html create mode 100644 app/templates/audit/view.html create mode 100644 app/templates/auth/forgot_password.html create mode 100644 app/templates/auth/inspector_assignments.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/notification_matrix.html create mode 100644 app/templates/auth/profile.html create mode 100644 app/templates/auth/reset_password.html create mode 100644 app/templates/auth/user_form.html create mode 100644 app/templates/auth/users.html create mode 100644 app/templates/base.html create mode 100644 app/templates/customers/form.html create mode 100644 app/templates/customers/import.html create mode 100644 app/templates/customers/index.html create mode 100644 app/templates/customers/invite.html create mode 100644 app/templates/customers/manage.html create mode 100644 app/templates/customers/set_password.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/facilities/area_form.html create mode 100644 app/templates/facilities/form.html create mode 100644 app/templates/facilities/list.html create mode 100644 app/templates/facilities/view.html create mode 100644 app/templates/inspections/execute.html create mode 100644 app/templates/inspections/flag_issue.html create mode 100644 app/templates/inspections/list.html create mode 100644 app/templates/inspections/start.html create mode 100644 app/templates/inspections/view.html create mode 100644 app/templates/issues/form.html create mode 100644 app/templates/issues/list.html create mode 100644 app/templates/issues/verification_queue.html create mode 100644 app/templates/issues/view.html create mode 100644 app/templates/login.html create mode 100644 app/templates/notifications/index.html create mode 100644 app/templates/notifications/preferences.html create mode 100644 app/templates/projects/assignment_form.html create mode 100644 app/templates/projects/form.html create mode 100644 app/templates/projects/import.html create mode 100644 app/templates/projects/list.html create mode 100644 app/templates/projects/view.html create mode 100644 app/templates/reports/_subnav.html create mode 100644 app/templates/reports/facility.html create mode 100644 app/templates/reports/followup_closure.html create mode 100644 app/templates/reports/index.html create mode 100644 app/templates/reports/inspector_performance.html create mode 100644 app/templates/reports/issues_aging.html create mode 100644 app/templates/reports/scorecard.html create mode 100644 app/templates/reports/sla_compliance.html create mode 100644 app/templates/scheduled_reports/email.html create mode 100644 app/templates/scheduled_reports/email.txt create mode 100644 app/templates/scheduled_reports/form.html create mode 100644 app/templates/scheduled_reports/index.html create mode 100644 app/templates/support.html create mode 100644 app/templates/support/admin_ticket_detail.html create mode 100644 app/templates/support/admin_tickets.html create mode 100644 app/templates/support/chat.html create mode 100644 app/templates/support/my_ticket_detail.html create mode 100644 app/templates/support/my_tickets.html create mode 100644 app/templates/templates/edit.html create mode 100644 app/templates/templates/form.html create mode 100644 app/templates/templates/form_editor.html create mode 100644 app/templates/templates/form_preview.html create mode 100644 app/templates/templates/item_form.html create mode 100644 app/templates/templates/list.html create mode 100644 app/templates/templates/view.html create mode 100644 app/utils/__init__.py create mode 100644 app/utils/audit.py create mode 100644 app/utils/decorators.py create mode 100644 app/utils/forms.py create mode 100644 app/utils/notifications.py create mode 100644 app/utils/pdf_export.py create mode 100644 app/utils/scope.py create mode 100644 app/utils/sla.py create mode 100644 app/utils/time_utils.py create mode 100644 config.py create mode 100644 docs/JQC_Customer_User_Manual_v2.docx create mode 100644 docs/JQC_Inspector_Manual.docx create mode 100644 docs/JQC_Web_Inspector_Manual.docx create mode 100644 gunicorn_config.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/phase10_customer_password_setup.py create mode 100644 migrations/versions/phase11_director_role.py create mode 100644 migrations/versions/phase12_performance_indexes.py create mode 100644 migrations/versions/phase13_issue_facility.py create mode 100644 migrations/versions/phase14_facility_created_at.py create mode 100644 migrations/versions/phase15_audit_log_indexes.py create mode 100644 migrations/versions/phase16_notifications_columns.py create mode 100644 migrations/versions/phase17_notification_event_type.py create mode 100644 migrations/versions/phase18_issue_reported_by.py create mode 100644 migrations/versions/phase19_issue_mobile_photos.py create mode 100644 migrations/versions/phase1_projects_roles.py create mode 100644 migrations/versions/phase20_inspector_assignments.py create mode 100644 migrations/versions/phase21_performance_indexes.py create mode 100644 migrations/versions/phase21_template_active.py create mode 100644 migrations/versions/phase22_comment_visibility.py create mode 100644 migrations/versions/phase23_support_tickets.py create mode 100644 migrations/versions/phase24_issue_created_notify_defaults.py create mode 100644 migrations/versions/phase25_inspection_gps.py create mode 100644 migrations/versions/phase26_issue_vendor.py create mode 100644 migrations/versions/phase27_score_alerts.py create mode 100644 migrations/versions/phase28_fix_inspection_notify.py create mode 100644 migrations/versions/phase29_broadcasts.py create mode 100644 migrations/versions/phase6_features.py create mode 100644 migrations/versions/phase7_mobile_api.py create mode 100644 migrations/versions/phase8_notification_matrix.py create mode 100644 migrations/versions/phase9_user_full_name.py create mode 100644 migrations/versions/phase_b_mobile_local_id.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 wsgi.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..46d0517 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Read(//c/Users/ngoda/.claude/projects/d--Projects-LT-Janitorial-Quality-Control/**)", + "Bash(cd /d \"D:\\\\Projects\\\\LT_Janitorial_Quality_Control\")", + "Bash(python -c \"import docx; print\\('python-docx OK'\\)\")", + "Bash(pip install *)", + "Bash(python docs/convert_manual.py)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85c6595 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Environment & secrets — NEVER commit these +.env +*.env + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environments +venv/ +env/ +.venv/ + +# Flask / instance +instance/ +app/static/uploads/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..72831d9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1041 @@ +# Claude.md — JQC Developer Reference + +> **Audience:** AI assistants and developers working on this codebase. +> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. +> **Last reviewed:** June 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts) + +--- + +## Table of Contents + +1. [Project Overview](#1-project-overview) +2. [Tech Stack](#2-tech-stack) +3. [Repository Layout](#3-repository-layout) +4. [Environment & Configuration](#4-environment--configuration) +5. [Database Models](#5-database-models) +6. [Role & Permission Matrix](#6-role--permission-matrix) +7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory) +8. [Utility Modules](#8-utility-modules) +9. [Mobile API (Phase 7 / Phase A–E)](#9-mobile-api-phase-7--phase-ae) +10. [iPad Native App](#10-ipad-native-app) +11. [Notification System](#11-notification-system) +12. [SLA Engine](#12-sla-engine) +13. [Audit Trail](#13-audit-trail) +14. [PDF Export](#14-pdf-export) +15. [Scheduled Reports](#15-scheduled-reports) +16. [Rate Limiting](#16-rate-limiting) +17. [Alembic Migration Chain](#17-alembic-migration-chain) +18. [Frontend Conventions](#18-frontend-conventions) +19. [Infrastructure](#19-infrastructure) +20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules) +21. [Change Philosophy](#21-change-philosophy) + +--- + +## 1. Project Overview + +**JQC (Janitorial Quality Control)** is a production-grade, full-stack web application that manages: + +- Janitorial service contracts organised as **Contracts (Projects) → Facilities → Areas** +- **Inspection** execution against configurable templates with dynamic form builder +- **Issue** tracking with SLA enforcement, follower subscriptions, and verification workflow +- **Customer portal** with scoped facility visibility and invitation-based onboarding +- **Notification** system (in-app + email) driven by an admin-controlled matrix +- **Reports** — on-demand PDF/CSV/Excel scorecards, scheduled email digests, Issues Aging, SLA Compliance, Follow-up Closure Rate, and per-facility Customer PDF Summary +- **Audit trail** — immutable log of every create/update/delete action +- **Support chat** — Groq AI chatbot for customers with preset FAQ chips; escalation to admin via ticketing system; customers can view and reply to their own tickets; admins manage tickets at `/support/admin/tickets` +- **Mobile API** — JWT-authenticated REST layer for the iPad native app +- **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B + C complete) + +The application is actively deployed in production and maintained by a single developer/administrator. + +--- + +## 2. Tech Stack + +| Layer | Technology | +|---|---| +| Language | Python 3.11+ | +| Web framework | Flask (application factory pattern) | +| ORM | Flask-SQLAlchemy (SQLAlchemy 2.x) | +| Database | MySQL (via PyMySQL driver) | +| Auth (web) | Flask-Login + Flask-WTF CSRF | +| Auth (API) | JWT access tokens + opaque refresh tokens (PyJWT) | +| Rate limiting | Flask-Limiter (Redis-backed in production via `REDIS_URL`; falls back to in-process memory for dev) | +| Migrations | Flask-Migrate / Alembic | +| Email | Flask-Mail (SMTP, background threading) | +| PDF generation | ReportLab | +| Forms | WTForms + Flask-WTF | +| Templating | Jinja2 | +| Frontend | Bootstrap 5, Chart.js, vanilla JS | +| Server | Gunicorn (sync workers) behind Nginx | +| OS | Ubuntu Linux | +| **iPad app** | **SwiftUI + SwiftData, iOS 17+, Xcode 26** | +| **iPad networking** | **URLSession async/await + NWPathMonitor** | +| **iPad auth storage** | **iOS Keychain (Security.framework)** | +| Timezone | All datetimes stored as US/Eastern (naive, via `now_eastern()`) | + +--- + +## 3. Repository Layout + +``` +lt_janitorial_quality_control/ +├── app/ +│ ├── __init__.py # Application factory — limiter, csrf, db, mail, login_manager +│ ├── api/ # Mobile REST API +│ │ ├── __init__.py # api_bp parent blueprint + register_api() +│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/* +│ │ ├── facilities.py # /api/v1/facilities/* (Phase A) +│ │ ├── templates.py # /api/v1/templates/* (Phase A) +│ │ ├── inspections.py # /api/v1/inspections/* (Phase B) +│ │ ├── issues.py # /api/v1/issues/* (Phase B + Phase 19 + Phase E) +│ │ ├── photos.py # /api/v1/photos/upload (Phase B) +│ │ ├── stats.py # /api/v1/stats/dashboard (Phase B stats) +│ │ ├── comments.py # /api/v1/issues//comments (Phase D) +│ │ ├── decorators.py # @jwt_required +│ │ ├── errors.py # JSON error helpers + error handler registration +│ │ └── jwt_utils.py # generate_access_token() +│ ├── models/ +│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B) +│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19) +│ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23) +│ │ └── ... +│ ├── routes/ +│ │ ├── support.py # /support/* — AI chat, ticket submit/list/detail (Phase 23) +│ │ └── ... +│ ├── static/ +│ │ └── uploads/ # UPLOAD_FOLDER root +│ │ ├── inspection_photos/ +│ │ ├── issue_photos/ # photo_path and mobile_photo_paths files +│ │ └── issue_result_photos/ # result_photos files (web-added resolution photos) +│ ├── templates/ +│ │ ├── issues/ +│ │ │ ├── view.html # Shows photo_path + mobile_photo_paths under "Photo Evidence" +│ │ │ └── issues_view.html # Same photo evidence logic +│ │ ├── reports/ +│ │ │ ├── _subnav.html # Shared sub-nav include for all report pages +│ │ │ ├── index.html # Overview & Trends (score trend, facility scores, charts) +│ │ │ ├── facility.html # Per-facility detail report +│ │ │ ├── scorecard.html # Per-facility scorecard (trend, area scores, SLA, open issues) + PDF Summary button +│ │ │ ├── inspector_performance.html # Inspector KPI table + drill-down chart +│ │ │ ├── issues_aging.html # Open issues grouped by age bucket (R1) +│ │ │ ├── sla_compliance.html # SLA compliance by severity and facility (R2) +│ │ │ └── followup_closure.html # Follow-up re-inspection closure rate (R3) +│ │ ├── scheduled_reports/ +│ │ │ └── index.html # Includes _subnav.html for Reports sub-nav +│ │ └── support/ +│ │ ├── chat.html # Customer AI chatbot + FAQ chips + submit-ticket modal +│ │ ├── my_tickets.html # Customer: list of own tickets +│ │ ├── my_ticket_detail.html # Customer: ticket detail + staff replies + follow-up form +│ │ ├── admin_tickets.html # Admin: paginated ticket list with status filter tabs +│ │ └── admin_ticket_detail.html # Admin: ticket detail + reply form + status controls +│ └── utils/ +├── migrations/ +│ └── versions/ +│ └── phase23_support_tickets.py ← HEAD +└── ... +``` + +--- + +## 4. Environment & Configuration + +### Required Environment Variables + +| Variable | Notes | +|---|---| +| `SECRET_KEY` | Flask secret — no fallback; startup fails if absent | +| `DATABASE_URL` | e.g. `mysql+pymysql://user:pass@localhost/jqc` | +| `MAIL_SERVER` | SMTP hostname | +| `MAIL_USERNAME` | SMTP login | +| `MAIL_PASSWORD` | SMTP password | +| `MAIL_PORT` | 465 (SSL) or 587 (STARTTLS) — auto-selects flags | +| `APP_BASE_URL` | Full URL for email links | +| `MAIL_DEFAULT_SENDER` | From address | +| `DIGEST_SECRET` | Authenticates all cron endpoints | +| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. | +| `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. | +| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. | + +### Email SSL Auto-Detection + +```python +MAIL_USE_SSL = _mail_port == 465 +MAIL_USE_TLS = not MAIL_USE_SSL +``` + +**Critical:** Never set both to `True` — Flask-Mail breaks silently. + +### File Uploads + +- `UPLOAD_FOLDER` = `app/static/uploads/` +- `MAX_CONTENT_LENGTH` = 50 MB +- Allowed: `png`, `jpg`, `jpeg`, `gif` + +--- + +## 5. Database Models + +### User + +``` +users: id, username (unique, indexed), full_name, email (unique, indexed), + password_hash, role (ENUM), created_at, active, + password_set, set_password_token (indexed), set_password_token_expires +``` + +**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer` + +**Key property:** `display_name` → `full_name.strip()` or falls back to `username`. + +### Facility / Area + +``` +facilities: id, name, address, contact_person, contact_phone, active, project_id (FK) +areas: id, facility_id (FK), name, area_type +``` + +**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other` + +### Project / CustomerAssignment + +``` +projects: id, name, description, project_manager_id, active, created_at +customer_assignments: id, user_id, project_id, facility_id (nullable) + UniqueConstraint(user_id, project_id, facility_id) +inspector_assignments: id, user_id, project_id, created_at + UniqueConstraint(user_id, project_id, name='uq_inspector_project') + ForeignKey user_id → users(id) ON DELETE CASCADE + ForeignKey project_id → projects(id) ON DELETE CASCADE +``` + +### Inspection + +``` +inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date, + overall_score, status (in_progress/completed/flagged), notes, form_data (JSON), + completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note, + mobile_local_id VARCHAR(64) nullable indexed ← Phase B + submit_latitude DECIMAL(10,7) nullable ← Phase 25 + submit_longitude DECIMAL(10,7) nullable ← Phase 25 +``` + +**`mobile_local_id`:** UUID string generated on the iPad. Used for idempotency — if a submission arrives twice (network retry), the server returns the existing record without creating a duplicate. Set `NULL` for all web-created inspections. + +**Score rule:** Items with `score = 0` mean "unanswered" — excluded from calculation entirely. + +### Issue + +``` +issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity (low/medium/high/critical), + description, photo_path VARCHAR(255), status (open/in_progress/resolved/pending_verification), + assigned_to, reported_by (nullable FK → users, SET NULL on delete), + reported_at, resolved_at, result_notes, result_photos (JSON), + mobile_photo_paths (JSON), ← Phase 19 + verified_by, verified_at, verification_note, sla_notified, + mobile_local_id VARCHAR(64) nullable indexed, ← Phase B + vendor_name VARCHAR(100) nullable, ← Phase 26 + vendor_contact VARCHAR(200) nullable, ← Phase 26 + vendor_notes TEXT nullable ← Phase 26 +``` + +**Photo columns — three distinct fields with different semantics:** + +| Column | Type | Populated by | Displayed as | +|---|---|---|---| +| `photo_path` | `VARCHAR(255)` | Web form upload OR first iPad photo | "Photo Evidence" (primary) | +| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues//photos` — extra evidence photos | "Photo Evidence" (additional) | +| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" | + +**Rule:** Never write iPad evidence photos into `result_photos`. They belong in `mobile_photo_paths` so they appear under "Photo Evidence" on the web, not "Resolution Details". + +**`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue. Nullable for backward compatibility. Used by `GET /api/v1/issues` to return issues the inspector created but hasn't been assigned yet. + +### Notification / NotificationPreference + +``` +notifications: id, user_id, title, body, link, is_read, created_at, issue_id, + inspection_id, event_type VARCHAR(50) NULL, digest_pending +notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency +``` + +### IssueComment + +``` +issue_comments: id, issue_id (FK), user_id (FK), body, created_at, + status_at_time, is_customer_visible (BOOLEAN, default False) ← Phase 22 +``` + +**`is_customer_visible`:** Staff comments are hidden from customers by default (`False`). Staff can tick "Share with customer" at post time to set `True`. Customer-authored comments are always stored as `True`. Customers see only `is_customer_visible=True` comments; staff see all. + +### FacilityScoreAlert + +``` +facility_score_alerts: id, facility_id (FK→facilities CASCADE), sent_at DATETIME, + current_avg DECIMAL(5,2), prior_avg DECIMAL(5,2), delta DECIMAL(5,2) + INDEX ix_fsa_facility_sent (facility_id, sent_at) +``` + +Records each score-trend alert dispatched for a facility. `send_score_alerts()` queries this table to skip re-alerting a facility within the last 24 hours, preventing notification storms on persistent score drops. + +### SupportTicket / SupportTicketReply + +``` +support_tickets: id, customer_id (FK→users SET NULL), facility_id (FK→facilities SET NULL), + subject VARCHAR(200), body TEXT, status VARCHAR(20) DEFAULT 'open', + created_at DATETIME + status values: open / answered / closed + +support_ticket_replies: id, ticket_id (FK→support_tickets CASCADE), user_id (FK→users SET NULL), + body TEXT, created_at DATETIME +``` + +**Flow:** +- Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email) +- Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/`) +- Customer adds follow-up → status reverts to `open` → admins notified again +- Admin can manually set: `open` / `answered` / `closed` +- Closed tickets cannot receive new replies from customers + +### NotificationMatrix + +``` +notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON) + UniqueConstraint(event_type, role_key) +``` + +### AuditLog + +``` +audit_logs: id, user_id (nullable), username (snapshot), user_role (snapshot), + action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address +``` + +### RefreshToken / DeviceToken + +``` +api_refresh_tokens: id, user_id, token_hash (SHA-256, unique), device_id, device_name, + created_at, expires_at, revoked +api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, registered_at + UniqueConstraint(user_id, device_id) +``` + +--- + +## 6. Role & Permission Matrix + +| Area | admin | director | project_manager | inspector | customer | +|---|---|---|---|---|---| +| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped | +| Users | ✅ | ✅ | ❌ | ❌ | ❌ | +| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ | +| Customers | ✅ | ✅ | ❌ | ❌ | ❌ | +| Facilities | ✅ | ✅ | ✅ | read | scoped | +| Contracts | ✅ | ✅ | ✅ | read | scoped | +| Templates | ✅ | ✅ | ❌ | ❌ | ❌ | +| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read | +| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own | +| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ | +| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ | +| Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only | +| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ | +| Support Tickets (manage) | ✅ | ✅ | ❌ | ❌ | own only | +| Reports | ✅ | ✅ | ✅ | ✅ | scoped | +| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ | +| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ | +| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ | + +### Decorator Map + +```python +@admin_required # role == 'admin' only +@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes +@project_manager_required # role in ('admin', 'director', 'project_manager') +@customer_required # role == 'customer' only +``` + +--- + +## 7. Blueprint Prefixes & Route Inventory + +| Blueprint | Prefix | Notable routes | +|---|---|---| +| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` | +| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) | +| `facilities` | `/facilities` | CRUD + area management | +| `projects` | `/projects` | CRUD + customer assignment management | +| `customers` | `/customers` | list, invite, set-password, manage, import CSV | +| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) | +| `templates` | `/templates` | list, create, edit, delete, form editor, preview | +| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign | +| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) | +| `audit` | `/audit` | list (admin only), view, purge | +| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | +| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | +| `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | +| `api` | `/api/v1` | parent blueprint | +| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` | +| `api_facilities` | `/api/v1` | `/facilities`, `/facilities//areas` | +| `api_templates` | `/api/v1` | `/templates`, `/templates/` | +| `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/` | +| `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/`, `PATCH /issues//status`, `PATCH /issues//photos` ← Phase 19 | +| `api_photos` | `/api/v1` | `POST /photos/upload` | +| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` | +| `api_stats` | `/api/v1` | `GET /stats/dashboard` — inspector-scoped KPIs with severity breakdown (Phase B) | +| `api_comments` | `/api/v1` | `GET /issues//comments`, `POST /issues//comments` (Phase D) | + +--- + +## 8. Utility Modules + +### `time_utils.py` +`now_eastern()` — always use this, never `datetime.utcnow()`. + +### `audit.py` +`log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. **This function calls `db.session.commit()` internally.** Calling it before the primary commit will prematurely persist any dirty ORM state in the session. + +### `scope.py` +`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for non-customers. +`get_inspector_scope(user)` — returns `list[int]` facility IDs for inspectors (empty list = no assignments = no access), `None` for non-inspectors. Derived from `InspectorAssignment` rows → project → active facilities. + +### `forms.py` +All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role. + +### `notifications.py` +`notify()`, `notify_by_matrix()`, `notify_customers_for_facility()` — all email sent in background thread. `notify()` stores `event_type` on the `Notification` record (phase17+). `flag_followup` route calls `notify()` for the original inspector. + +### `sla.py` +`sla_status(issue)` → `'ok'` | `'at_risk'` | `'breached'` | `None` (resolved). + +### `pdf_export.py` +ReportLab-based. 12-column grid must be preserved — never collapse in PDF views. + +**Public functions:** +- `generate_inspection_pdf(inspection, form_fields, form_data, issues, static_folder)` — per-inspection PDF +- `generate_issues_list_pdf(issues, filter_summary)` — landscape issues list PDF (from issues list export) +- `generate_inspections_list_pdf(inspections, filter_summary)` — landscape inspections list PDF +- `generate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count)` — customer-facing one-page facility summary PDF (Phase R4) + +**`_build_styles()` registered style names:** `ReportTitle`, `ReportSub`, `SectionHead`, `FieldLabel`, `FieldValue`, `MetaLabel`, `MetaValue`, `IssueDesc`, `FooterStyle`, `SummaryTitle`, `ReportSubtitle`, `Meta`, `ScoreValue`, `ScoreLabel`, `SectionHeader`, `TableHeader`, `TableCell` + +The last eight styles (`SummaryTitle` through `TableCell`) were added for the facility summary PDF and are available for any future customer-facing PDF functions. + +--- + +## 9. Mobile API (Phase 7 / Phase A–E) + +### CSRF Exemption Pattern — Critical + +**`csrf.exempt(api_bp)` does NOT cascade to sub-blueprints.** Each child blueprint must be exempted individually in `app/__init__.py`. The new `api_issues` blueprint (including its `PATCH /issues//photos` route) inherits the exemption already applied to `_api_issues_bp`. **Every new blueprint must add its own `csrf.exempt()` line before `register_api(app)`.** + +### Auth Flow +1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex) +2. Bearer token on every request +3. `POST /api/v1/auth/refresh` → token rotation (old revoked, new issued) +4. `POST /api/v1/auth/logout` → revokes refresh token + +### Phase A Endpoints + +| Endpoint | Auth | Description | +|---|---|---| +| `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user | +| `GET /api/v1/facilities//areas` | jwt_required | Areas for a facility | +| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) | +| `GET /api/v1/templates/` | jwt_required | Full template with form_schema | + +### Phase B Endpoints + +| Endpoint | Auth | Description | +|---|---|---| +| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` | +| `PATCH /api/v1/inspections/` | jwt_required | Update inspection (draft → completed) | +| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id`; accepts `result_photos` list stored in `mobile_photo_paths` | +| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` | + +### Phase C Endpoints + +| Endpoint | Auth | Description | +|---|---|---| +| `GET /api/v1/inspections` | jwt_required | Inspector's own inspection history (paginated) | +| `GET /api/v1/issues` | jwt_required | Issues assigned to OR reported by current user (inspectors); all non-resolved (admin/director/PM) | +| `GET /api/v1/issues/` | jwt_required | Single issue detail | +| `PATCH /api/v1/issues//status` | jwt_required | Update issue status | +| `GET /api/v1/notifications` | jwt_required | Unread notifications; accepts `?since=` | +| `PATCH /api/v1/notifications/mark-read` | jwt_required | Mark list of notification IDs as read | + +### Phase 19 Endpoint + +| Endpoint | Auth | Description | +|---|---|---| +| `PATCH /api/v1/issues//photos` | jwt_required | Attach extra evidence photos to an issue. Accepts `{ "result_photos": ["uploads/..."] }`. Stores in `mobile_photo_paths` (NOT `result_photos`). Idempotent — merges with existing paths, never overwrites. | + +### Phase B (Stats) Endpoint + +| Endpoint | Auth | Description | +|---|---|---| +| `GET /api/v1/stats/dashboard` | jwt_required | Inspector-scoped KPIs: `today_inspections`, `completed_today`, `open_issues`, `avg_score_30d`, `pending_followups`, `sla_breached`, `sla_at_risk`, `severity_breakdown` (dict: critical/high/medium/low). Inspectors scoped to contracted facilities. Admins/directors/PMs get org-wide numbers. Customers get 403. | + +### Phase D (Comments) Endpoints + +| Endpoint | Auth | Description | +|---|---|---| +| `GET /api/v1/issues//comments` | jwt_required | All comments oldest-first. Returns: `id`, `issue_id`, `author_name`, `author_role`, `status_at_time`, `body`, `created_at`. Inspectors limited to contracted facilities. | +| `POST /api/v1/issues//comments` | jwt_required | Add a comment. Body: `{ "body": "..." }`. Fires `notify_by_matrix('issue_comment')`. Calls `log_action()` after commit. | + +### Phase E Additions to Existing Endpoints + +`_issue_payload()` in `issues.py` now returns `area_name` and `assigned_to_name` (both nullable). These populate `LocalIssue.areaNameCache` and `LocalIssue.assignedToName` on the iPad after every `pullAssignedIssues()`. `refreshStatusFromServer()` also refreshes them on demand. + +`stats.py` now returns `severity_breakdown` dict alongside the existing KPIs. Derived from the already-loaded `open_issues_all` list — zero extra DB queries. + +### Issue API — `_issue_payload()` fields + +```python +{ + 'id', 'status', 'severity', 'description', 'assigned_to', + 'facility_id', 'facility_name', 'reported_at', 'resolved_at', + 'mobile_local_id', + 'photo_path', # primary evidence photo (first iPad photo or web upload) + 'mobile_photo_paths', # extra evidence photos from iPad (list) + 'result_photos', # resolution photos added via web form (list) + # Phase A additions: + 'result_notes', # resolution notes entered by web staff + 'verified_at', # ISO 8601 datetime when fix was verified (nullable) + 'verification_note', # note from the verifier (nullable) + 'reported_by_name', # display_name of User who filed the issue (nullable) + # Phase E additions: + 'area_name', # name of the Area the issue was flagged in (nullable) + 'assigned_to_name', # display_name of currently assigned User (nullable) +} +``` + +**iOS reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`. It does NOT read `result_photos` — those are web-only resolution photos.** + +### Issue API Scope Rules + +- **Inspector:** `GET /issues` returns issues where `assigned_to == current_user.id` **OR** `reported_by == current_user.id`. +- **Admin / Director / Project Manager:** `GET /issues` returns all non-resolved issues (default) or filtered by `?status=`. +- `GET /issues/` and `PATCH /issues//status` and `PATCH /issues//photos` all enforce the same combined inspector check. + +### Photo Upload Flow (multi-photo issues) + +``` +1. iPad calls POST /api/v1/photos/upload × N → gets N server_path strings +2. iPad calls POST /api/v1/issues → sends photo_path = paths[0] + result_photos = paths[1:] (stored in mobile_photo_paths) +3. iPad calls PATCH /api/v1/issues//photos → sends result_photos = paths[1:] + (PATCH is belt-and-suspenders for race safety) +``` + +Web template shows `photo_path` + `mobile_photo_paths` together under **"Photo Evidence"**. `result_photos` (resolution photos from web form) appears under **"Resolution Details"**. + +### Facility deduplication + +`pullReferenceData()` deduplicates the `/api/v1/facilities` response by `id` before upserting. The server may return the same facility ID more than once (one row per contract assignment). Without deduplication, the same building appears twice in every picker. The dedup uses a `seenFacilityIds = Set()` filter on the iOS side AND the upsert map (`facilityMap`) on the server side. + +### Idempotency Pattern + +All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt, check for existing record and return `{ 'duplicate': True }` without inserting. Web-created records have `mobile_local_id = NULL`. + +### Score Calculation (Server-Side) + +`app/api/inspections.py::_compute_score()` mirrors `routes/inspections.py::_compute_score_from_form()` exactly. Rating value `0` = unanswered → excluded. Returns `float` 0–100 or `None` if no scoreable fields. + +--- + +## 10. iPad Native App + +See the iOS app's own `CLAUDE.md` for full details. Key integration points: + +- App connects to `jqc.ltservicesinc.com` (primary) or `jqc1.ltservicesinc.com` (secondary) — **server is user-selectable at login and in Settings**. +- Server selection is persisted to `UserDefaults` via `ServerConfig`. Switching server in Settings triggers a logout confirmation alert and clears all server-pulled SwiftData records (`serverId != nil`) before logout. +- All photo evidence from the iPad routes through `mobile_photo_paths` on the server — never through `result_photos`. + +--- + +## 11. Notification System + +### Event Constants (`app/models/notification.py`) +``` +EVENT_ISSUE_ASSIGNED = 'issue_assigned' +EVENT_ISSUE_STATUS = 'issue_status' +EVENT_ISSUE_COMMENT = 'issue_comment' +EVENT_ISSUE_FOLLOW = 'issue_follow_update' +EVENT_INSPECTION_DONE = 'inspection_completed' +EVENT_SLA_ALERT = 'sla_alert' +EVENT_ISSUE_FLAGGED = 'issue_flagged' +EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed' +EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' +EVENT_SCORE_ALERT = 'score_alert' ← Phase 27 +``` + +### Cron Endpoints (all require `token=DIGEST_SECRET`) + +| Endpoint | Purpose | Schedule | +|---|---|---| +| `POST /notifications/send-digest` | Digest email delivery | `0 7 * * *` | +| `POST /notifications/check-sla` | SLA breach/at-risk alerts | `*/30 * * * *` | +| `POST /notifications/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` | +| `POST /notifications/check-score-trends` | Facility score drop alerts (Phase 27) | `0 8 * * *` | + +--- + +## 12. SLA Engine + +| Severity | Window | At-Risk | +|---|---|---| +| critical | 4h | 3h | +| high | 24h | 18h | +| medium | 72h | 54h | +| low | 168h | 126h | + +`issue.sla_notified` prevents duplicate cron notifications. + +--- + +## 13. Audit Trail + +- Admin-only at `/audit/` — director is excluded +- Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT` +- Mobile API routes call `log_action()` for all create/update operations +- Immutable — never updated or deleted through the application + +--- + +## 14. PDF Export + +ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF. + +--- + +## 15. Scheduled Reports + +Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`. +Cron: `POST /scheduled-reports/run?secret=` + +--- + +## 16. Rate Limiting + +```python +limiter = Limiter( + key_func = get_remote_address, + default_limits = [], + storage_uri = os.environ.get('REDIS_URL', 'memory://'), +) +``` + +**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0`. + +--- + +## 17. Alembic Migration Chain + +``` +phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notification_matrix + → phase9_user_full_name → phase10_customer_password_setup → phase11_director_role + → phase12_performance_indexes → phase_b_mobile_local_id + → phase13_issue_facility → phase14_facility_created_at + → phase15_audit_log_indexes → phase16_notifications_columns + → phase17_notification_event_type + → phase18_issue_reported_by + → phase19_issue_mobile_photos + → phase20_inspector_assignments + → phase21_template_active + → phase21_performance_indexes + → phase22_comment_visibility + → phase23_support_tickets + → phase24_notify_defaults + → phase25_inspection_gps + → phase26_issue_vendor + → phase27_score_alerts ← HEAD +``` + +### phase21_performance_indexes + +Adds four composite indexes covering the highest-traffic multi-column query patterns: `(facility_id, inspection_date)` and `(inspector_id, inspection_date)` and `(status, inspection_date)` on `inspections`; `(facility_id, status)` on `issues`. All single-column indexes already exist from phase12. Uses `INFORMATION_SCHEMA.STATISTICS` existence check — safe to re-run. + +**Deploy order for phase21:** +```bash +flask db upgrade +sudo systemctl restart gunicorn +``` + +### phase21_template_active + +Adds `active` boolean column to `inspection_templates` so templates can be deactivated without deletion. Inactive templates are hidden from the inspection-start form but remain accessible in the template management UI. Uses `INFORMATION_SCHEMA` column existence check — safe to re-run. + +### phase23_support_tickets + +Creates `support_tickets` and `support_ticket_replies` tables. Uses table existence check — safe to re-run. + +**Deploy order:** +```bash +flask db upgrade +pip install groq # if not already installed +# Set GROQ_API_KEY in environment / systemd unit +sudo systemctl restart gunicorn +``` + +### phase24_notify_defaults + +Data-only migration. Sets `enabled=True` for `('issue_created', 'admin')` and `('issue_created', 'director')` rows in `notification_matrix` if they already exist. Rows that do not yet exist are seeded at runtime by `MATRIX_DEFAULTS`. No schema change — no existence check needed, `UPDATE` on missing rows is a no-op. + +### phase25_inspection_gps + +Adds `submit_latitude DECIMAL(10,7) NULL` and `submit_longitude DECIMAL(10,7) NULL` to `inspections`. Populated at submit time — by browser Geolocation API (web) or CoreLocation (iPad). Null for all existing rows. Uses `INFORMATION_SCHEMA` column existence check — safe to re-run. + +`inspections/view.html` shows a Google Maps embed (admin/director only) when both columns are non-null. + +**iPad behaviour:** `InspectionLocationManager` begins acquiring a fix when the submit confirm dialog appears. GPS is captured into `LocalInspection.submitLatitude`/`submitLongitude` and sent in the `POST /api/v1/inspections` body. The `PATCH` endpoint does not accept GPS — creation-time capture only. + +### phase26_issue_vendor + +Adds three nullable columns to `issues`: + +| Column | Type | Purpose | +|---|---|---| +| `vendor_name` | `VARCHAR(100)` | External contractor or vendor name | +| `vendor_contact` | `VARCHAR(200)` | Phone or email for the vendor | +| `vendor_notes` | `TEXT` | Notes about what the vendor is handling | + +Displayed in `issues/view.html` and editable via `IssueForm` (`form.html`). Staff-only — not exposed in mobile API. Uses `INFORMATION_SCHEMA` existence check — safe to re-run. + +### phase27_score_alerts + +Creates `facility_score_alerts` table. Used by `send_score_alerts()` in `sla.py` for 24-hour deduplication of score-drop notifications. Uses table existence check — safe to re-run. + +**Deploy order for phases 24–27:** +```bash +flask db upgrade +sudo systemctl restart gunicorn +# Add to cron: +# 0 8 * * * curl -s -X POST https://yourdomain.com/notifications/check-score-trends \ +# -d "token=YOUR_DIGEST_SECRET" +``` + +### phase22_comment_visibility + +Adds `is_customer_visible BOOLEAN NOT NULL DEFAULT FALSE` to `issue_comments`. Existing comments default to staff-only visibility. Uses `INFORMATION_SCHEMA` column existence check — safe to re-run. + +### phase19_issue_mobile_photos + +Adds `mobile_photo_paths JSON NULL` to `issues` table. Stores extra evidence photos submitted from the iPad at issue-creation time, separate from `result_photos` (resolution photos) so they appear under "Photo Evidence" on the web. Uses `INFORMATION_SCHEMA` existence check — safe to re-run. + +**Deploy order for phase19:** +```bash +flask db upgrade # add mobile_photo_paths column +sudo systemctl restart gunicorn +``` + +### MySQL ENUM Change Protocol (3 steps — always follow) +```sql +-- 1. Expand +ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL; +-- 2. Migrate +UPDATE users SET role = 'director' WHERE role = 'supervisor'; +-- 3. Contract +ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL; +``` + +### MySQL Compatibility Rules + +- **`CREATE INDEX IF NOT EXISTS`** — not supported on MySQL < 8.0.12. Always use `INFORMATION_SCHEMA.STATISTICS` check first. +- **`batch_alter_table`** — SQLite-only workaround; do not use for MySQL migrations. +- **Migration deploy order:** Always run `flask db upgrade` before swapping `app/__init__.py` if the new version imports models that reference the new columns. + +### Deprecated SQLAlchemy Patterns +```python +# WRONG +Model.query.get(id) + +# CORRECT +obj = db.session.get(Model, id) +if obj is None: abort(404) +``` + +--- + +## 18. Frontend Conventions + +### Active Nav Tab +Detected via `request.endpoint.startswith('.')` in each nav `` tag. + +### Display Names +Always use `user.display_name` in templates — never `.username` for display purposes. + +### Status Label Map +| DB value | Displayed as | +|---|---| +| `completed` | **Submitted** | +| `in_progress` | In Progress | +| `flagged` | Flagged | +| `open` | Open | +| `resolved` | Resolved | +| `pending_verification` | Pending Verification | + +### Forms +- Flask-WTF CSRF auto-applied to all web forms +- **Never nest `
` tags** — browsers silently discard inner forms + +### Real-Time +**SSE banned.** All "live" updates use polling. + +### Issue Photo Evidence Display (view.html) + +`view.html` shows `photo_path` and `mobile_photo_paths` together under the **"Photo Evidence"** heading using a `d-flex flex-wrap gap-2` grid. `result_photos` (resolution photos) appear separately under **"Resolution Details"**. Do not merge these sections — they have different semantic meaning. + +### Contract → Facility Cascade (filter bars and create form) + +The Contract selector is always a plain HTML ` + + {% for u in users %} + + {% endfor %} + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ + + {{ logs.total }} record{{ 's' if logs.total != 1 else '' }} + {% if filter_user or filter_action or filter_entity_type or filter_date_from or filter_date_to %} + Filtered + {% endif %} + +
+ Page {{ logs.page }} of {{ logs.pages }} + +
+
+
+ {% if logs.items %} +
+ + + + + + + + + + + + + + + {% for entry in logs.items %} + + + + + + + + + + + {% endfor %} + +
TimestampUserRoleActionEntityLabelIP Address
+ {{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }} + + {{ entry.username }} + + + {{ entry.user_role | title }} + + + + {{ entry.action }} + + {{ entry.entity_type }} + {{ entry.entity_label or '—' }} + {% if entry.entity_id %} + #{{ entry.entity_id }} + {% endif %} + + {{ entry.ip_address or '—' }} + + + + +
+
+ {% else %} +
+ + No audit records match the current filters. +
+ {% endif %} +
+ + + {% if logs.pages > 1 %} + + {% endif %} +
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/audit/view.html b/app/templates/audit/view.html new file mode 100644 index 0000000..65800b3 --- /dev/null +++ b/app/templates/audit/view.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} + +{% block title %}Audit Entry #{{ entry.id }}{% endblock %} + +{% block content %} +
+
+ +

Audit Entry #{{ entry.id }}

+
+ +
+ +
+
+
+
+ Event Details +
+
+
+
Action
+
+ + {{ entry.action }} + +
+ +
Entity Type
+
{{ entry.entity_type }}
+ +
Entity ID
+
+ {% if entry.entity_id %}#{{ entry.entity_id }}{% else %}{% endif %} +
+ +
Label
+
{{ entry.entity_label or '—' }}
+ +
Details
+
+ {% if entry.details %} + {{ entry.details }} + {% else %} + + {% endif %} +
+
+
+
+
+ +
+
+
+ Actor & Context +
+
+
+
Username
+
+ {{ entry.username }} + {% if entry.user_id %} + (ID #{{ entry.user_id }}) + {% else %} + Deleted + {% endif %} +
+ +
Role at Time
+
+ + {{ entry.user_role | title }} + +
+ +
Timestamp
+
+ + {{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }} + + Eastern Time +
+ +
IP Address
+
+ {{ entry.ip_address or '—' }} +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html new file mode 100644 index 0000000..84d5f04 --- /dev/null +++ b/app/templates/auth/forgot_password.html @@ -0,0 +1,79 @@ + + + + + + Forgot Password — Janitorial QC + + + + + + +
+
+

Forgot Your Password?

+

Enter the email address on your account and we'll send you a reset link.

+
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} + {% endwith %} + +
+ + +
+ + + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} +
+ + +
+ + + +
+
+ + + + diff --git a/app/templates/auth/inspector_assignments.html b/app/templates/auth/inspector_assignments.html new file mode 100644 index 0000000..f368bd3 --- /dev/null +++ b/app/templates/auth/inspector_assignments.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}Contract Assignments — {{ user.display_name }}{% endblock %} + +{% block content %} +
+
+

Contract Assignments

+

+ Inspector {{ user.display_name }} can only access facilities + belonging to the contracts ticked below. +

+
+ + Back to Users + +
+ +
+ + +
+
+ Active Contracts +
+ {{ assigned_pids|length }} assigned + + +
+
+ + {% if projects %} +
+ {% for project in projects %} + + {% endfor %} +
+ {% else %} +
+ No active contracts exist. Create a contract first. +
+ {% endif %} +
+ + {% if projects %} +
+ + Cancel +
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..eae8488 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,334 @@ +{% extends "base.html" %} + +{% block title %}Login - Janitorial QC{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/notification_matrix.html b/app/templates/auth/notification_matrix.html new file mode 100644 index 0000000..3c59725 --- /dev/null +++ b/app/templates/auth/notification_matrix.html @@ -0,0 +1,218 @@ +{% extends "base.html" %} +{% block title %}Notification Matrix{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + {# ── Page header ── #} +
+
+

Notification Matrix

+

+ Control which roles receive email notifications for each event. + Assignee, followers, and customer portal + recipients are handled automatically where marked. +

+
+ + Back to Users + +
+ + {# ── Legend ── #} +
+ Enabled by default + Disabled by default + implicit Always notified — not controlled here +
+ +
+ + +
+ + + {# Row 1: spanning group headers #} + + + + + + + {# Row 2: individual role headers #} + + {% for role_key, role_label in matrix_roles if role_key != 'custom' and role_key != 'customer' %} + + {% endfor %} + + + + + + {% for event_key, event_label in matrix_events.items() %} + {% set event_state = state.get(event_key, {}) %} + + + + {# Internal role checkboxes (admin, supervisor, inspector, project_manager) #} + {% for role_key, _ in matrix_roles if role_key not in ('custom', 'customer') %} + {% set row = event_state.get(role_key) %} + {% if row is not none %} + {% set checked = row.enabled %} + {% else %} + {% set checked = defaults.get((event_key, role_key), false) %} + {% endif %} + + {% endfor %} + + {# Customer checkbox #} + {% set cust_row = event_state.get('customer') %} + {% if cust_row is not none %} + {% set cust_checked = cust_row.enabled %} + {% else %} + {% set cust_checked = defaults.get((event_key, 'customer'), false) %} + {% endif %} + + + {# Custom emails text input #} + {% set custom_row = event_state.get('custom') %} + {% if custom_row is not none %} + {% set custom_val = custom_row.get_custom_emails() | join(', ') %} + {% else %} + {% set custom_val = '' %} + {% endif %} + + + {% endfor %} + +
EventInternal RecipientsCustomerCustom Recipients
{{ role_label }}CustomerEmail addresses
(comma-separated)
+ {{ event_label }} + {# Show implicit labels where assignee/followers are always notified #} + {% if event_key in ('issue_assigned', 'issue_reassigned', 'issue_unassigned', + 'issue_status', 'issue_comment') %} + assignee + {% endif %} + {% if event_key == 'issue_follow_update' %} + followers + {% endif %} + {% if event_key == 'sla_alert' %} + assignee + followers + {% endif %} + + + + + + +
+
+ +
+ + + Reset + + + + Changes take effect immediately for all subsequent notifications. + +
+
+ + {# ── Notes card ── #} +
+
+

+ Internal: Admin, Supervisor, Inspector, and Project Manager users + receive in-app notifications and emails based on their individual + preference settings. +

+

+ Customer: Customer-portal users are notified only for facilities + they are assigned to via their contract/facility assignments. +

+

+ Custom Recipients: Additional email addresses (e.g. external managers) + receive a plain email. They do not get in-app notifications and are not affected + by individual user preference settings. +

+
+
+ +
+{% endblock %} diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html new file mode 100644 index 0000000..ae96513 --- /dev/null +++ b/app/templates/auth/profile.html @@ -0,0 +1,224 @@ +{% extends "base.html" %} + +{% block title %}My Profile{% endblock %} + +{% block content %} +
+
+

My Profile

+

Manage your account information and review your activity.

+
+
+ +
+ + +
+ + +
+
+
+ + + +
+

{{ current_user.display_name }}

+ {% if current_user.full_name %} +

@{{ current_user.username }}

+ {% endif %} +

{{ current_user.email }}

+ + + {{ current_user.role | title }} + +
+ + + Member since {{ current_user.created_at.strftime('%B %d, %Y') }} + +
+
+ + +
+
+
Activity Summary
+
+
+
+
+
+
{{ total_inspections }}
+ Total Inspections +
+
+
+
+
{{ completed_inspections }}
+ Completed +
+
+
+
+
{{ open_issues }}
+ Open Issues +
+
+
+
+ {% if total_inspections > 0 %} +
+ {{ ((completed_inspections / total_inspections) * 100) | int }}% +
+ {% else %} +
+ {% endif %} + Completion Rate +
+
+
+
+
+ +
+ + +
+ + +
+
+
Edit Profile
+
+
+
+ {{ form.hidden_tag() }} + + +
+ {{ form.full_name.label(class="form-label fw-semibold") }} + {{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }} + {% for error in form.full_name.errors %} +
{{ error }}
+ {% endfor %} +
+ + +
+ {{ form.email.label(class="form-label fw-semibold") }} + {{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""), + placeholder="your@email.com") }} + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+

+ Leave the password fields blank to keep your current password. +

+ + +
+ {{ form.current_password.label(class="form-label fw-semibold") }} + {{ form.current_password(class="form-control" + (" is-invalid" if form.current_password.errors else ""), + autocomplete="current-password") }} + {% for error in form.current_password.errors %} +
{{ error }}
+ {% endfor %} +
+ + +
+ {{ form.new_password.label(class="form-label fw-semibold") }} + {{ form.new_password(class="form-control" + (" is-invalid" if form.new_password.errors else ""), + autocomplete="new-password") }} + {% for error in form.new_password.errors %} +
{{ error }}
+ {% endfor %} +
Minimum 6 characters.
+
+ + +
+ {{ form.confirm_password.label(class="form-label fw-semibold") }} + {{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else ""), + autocomplete="new-password") }} + {% for error in form.confirm_password.errors %} +
{{ error }}
+ {% endfor %} +
+ + +
+
+
+ + +
+
+
Recent Inspections
+ + View All + +
+
+ {% if recent_inspections %} +
+ + + + + + + + + + + + + {% for insp in recent_inspections %} + + + + + + + + + {% endfor %} + +
DateFacilityTemplateScoreStatus
{{ insp.inspection_date.strftime('%Y-%m-%d') }}{{ insp.facility.name if insp.facility else '—' }}{{ insp.template.name if insp.template else '—' }} + {% if insp.overall_score is not none %} + + {{ "%.1f"|format(insp.overall_score) }}% + + {% else %} + + {% endif %} + + + {{ insp.status | replace('_', ' ') | title }} + + + + + +
+
+ {% else %} +
+ + No inspections recorded yet. +
+ {% endif %} +
+
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html new file mode 100644 index 0000000..f3b5a19 --- /dev/null +++ b/app/templates/auth/reset_password.html @@ -0,0 +1,151 @@ + + + + + + Reset Password — Janitorial QC + + + + + + +
+
+

Set a New Password

+

Hi {{ user.display_name }}. Choose a new secure password for your account.

+
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} + {% endwith %} + +
+ + +
+ + + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} + +
+
+
+
+
+ +
+ + 8+ characters + + + Uppercase letter + + + Number + +
+
+ +
+ + + {% for error in form.confirm_password.errors %} +
{{ error }}
+ {% endfor %} + +
+ + +
+ +
+
+ + + + + diff --git a/app/templates/auth/user_form.html b/app/templates/auth/user_form.html new file mode 100644 index 0000000..1d0658d --- /dev/null +++ b/app/templates/auth/user_form.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+ + {# ── Account Status card (edit mode only, not own account) ── #} + {% if user and user.id != current_user.id %} +
+
+
+ Account Status: + + {{ 'Active' if user.active else 'Disabled' }} + +
+ {% if user.active %} + Disabling this account will immediately prevent the user from logging in. + {% else %} + This account is currently disabled — the user cannot log in. + {% endif %} +
+
+
+ + +
+
+
+ {% endif %} + + {# ── Edit form ── #} +
+
+

{{ title }}

+
+
+
+ {{ form.hidden_tag() }} + +
+
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} + {% if form.username.errors %} +
+ {% for error in form.username.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.full_name.label(class="form-label") }} + {{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }} + {% if form.full_name.errors %} +
+ {% for error in form.full_name.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% if form.email.errors %} +
+ {% for error in form.email.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }} + {% if form.password.errors %} +
+ {% for error in form.password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.confirm_password.label(class="form-label") }} + {{ form.confirm_password(class="form-control") }} + {% if form.confirm_password.errors %} +
+ {% for error in form.confirm_password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+ {{ form.role.label(class="form-label") }} + {% if director_editing %} + {# Directors can see the current role but cannot change it #} +
+ {{ (user.role if user else 'Inspector').replace('_', ' ')|title }} +
+
Role assignment requires Administrator access.
+ {% else %} + {{ form.role(class="form-select") }} + {% endif %} +
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/users.html b/app/templates/auth/users.html new file mode 100644 index 0000000..78b24b8 --- /dev/null +++ b/app/templates/auth/users.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}User Management{% endblock %} + +{% block content %} +
+
+

Internal User Management

+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + {% endfor %} + +
UsernameFull NameEmailRoleContractsCreatedStatusActions
{{ user.username }}{{ user.full_name or '—' }}{{ user.email }} + + {{ user.role.replace('_',' ')|title }} + + + {% if user.role == 'inspector' %} + {% set cnt = inspector_contract_counts.get(user.id, 0) %} + {% if cnt > 0 %} + {{ cnt }} contract{{ 's' if cnt != 1 else '' }} + {% else %} + None + {% endif %} + {% else %} + + {% endif %} + {{ user.created_at.strftime('%Y-%m-%d') }} + {% if user.active %} + Active + {% else %} + Disabled + {% endif %} + + + + + {% if user.role == 'inspector' %} + + + + {% endif %} + {% if user.id != current_user.id %} +
+ + +
+
+ + +
+ {% endif %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..733c6cd --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,463 @@ + + + + + + + + + + {% block title %}Janitorial QC System{% endblock %} + + + + + + {% block extra_css %}{% endblock %} + + + + {% if current_user.is_authenticated %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + {% block extra_js %}{% endblock %} + + {% if current_user.is_authenticated %} + + {% endif %} + + \ No newline at end of file diff --git a/app/templates/customers/form.html b/app/templates/customers/form.html new file mode 100644 index 0000000..84ea766 --- /dev/null +++ b/app/templates/customers/form.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+ + {% if customer %} +
+
+
+ Account Status: + + {{ 'Active' if customer.active else 'Disabled' }} + +
+ {% if customer.active %} + Disabling prevents the customer from logging in immediately. + {% else %} + This account is currently disabled — the customer cannot log in. + {% endif %} +
+
+
+ + +
+
+
+ {% endif %} + +
+
+

{{ title }}

+
+
+
+ {{ form.hidden_tag() }} + +
+
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} + {% if form.username.errors %} +
+ {% for e in form.username.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ {{ form.full_name.label(class="form-label") }} + {{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }} + {% if form.full_name.errors %} +
+ {% for e in form.full_name.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% if form.email.errors %} +
+ {% for e in form.email.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control", + placeholder="Leave blank to keep current" if customer else "Min. 8 characters") }} + {% if form.password.errors %} +
+ {% for e in form.password.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ {{ form.confirm_password.label(class="form-label") }} + {{ form.confirm_password(class="form-control") }} + {% if form.confirm_password.errors %} +
+ {% for e in form.confirm_password.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ +
+ + {% if customer %} + + Cancel + + {% else %} + + Cancel + + {% endif %} +
+
+
+
+ +
+
+{% endblock %} diff --git a/app/templates/customers/import.html b/app/templates/customers/import.html new file mode 100644 index 0000000..5cc8bfb --- /dev/null +++ b/app/templates/customers/import.html @@ -0,0 +1,160 @@ +{% extends "base.html" %} +{% block title %}Bulk Customer Import{% endblock %} + +{% block content %} +
+
+

Bulk Customer Import

+

Create multiple customer accounts and assignments from a CSV file.

+
+ +
+ +{# ── Format guide ── #} +
+
+
CSV Format
+
+
+ Required columns +
    +
  • username — unique login name
  • +
  • email — unique email address
  • +
  • password — min 8 characters
  • +
+
+
+ Optional columns +
    +
  • project_name — exact contract name
  • +
  • facility_name — exact facility name within contract (leave blank for all)
  • +
+
+
+ Tips +
    +
  • Repeat a username on multiple rows to assign them to multiple contracts
  • +
  • Leave facility_name blank to grant access to all facilities in the contract
  • +
  • Existing usernames/emails will be flagged as errors before anything is saved
  • +
+
+
+
+
+ +{# ── Upload form ── #} +{% if not preview_rows %} +
+
+ Upload CSV File +
+
+
+ +
+ + +
Maximum recommended file size: 500 KB · UTF-8 encoding.
+
+ +
+
+
+ +{% else %} +{# ── Preview results ── #} +
+
+ + + Preview — {{ preview_rows|length }} row(s) parsed + + + {{ valid_count }} valid + {% set err_count = preview_rows|length - valid_count %} + {% if err_count > 0 %} + {{ err_count }} error(s) + {% endif %} + +
+ +
+
+ + + + + + + + + + + + + {% for row in preview_rows %} + + + + + + + + + {% endfor %} + +
RowUsernameEmailContractFacility ScopeStatus
{{ row.row }}{{ row.username or '—' }}{{ row.email or '—' }}{{ row.project }}{{ row.facility }} + {% if row.status == 'ok' %} + Ready + {% else %} + Error +
    + {% for e in row.errors %}
  • {{ e }}
  • {% endfor %} +
+ {% endif %} +
+
+
+
+ +{# ── Action buttons ── #} +{% if has_errors %} +
+ + Errors found. Fix the issues above and re-upload. + Error rows will be skipped — only valid rows can be imported. + {% if valid_count > 0 %} + You may still import the {{ valid_count }} valid row(s) by clicking below. + {% endif %} +
+{% endif %} + +
+ {% if valid_count > 0 %} +
+ + + + +
+ {% endif %} + + Upload Different File + +
+{% endif %} +{% endblock %} diff --git a/app/templates/customers/index.html b/app/templates/customers/index.html new file mode 100644 index 0000000..d7a38cf --- /dev/null +++ b/app/templates/customers/index.html @@ -0,0 +1,152 @@ +{% extends "base.html" %} +{% block title %}Customer Management{% endblock %} + +{% block content %} +
+
+

Customer Management

+

Manage portal access for all customer accounts.

+
+ +
+ +{% if customers %} + +{% if expired_invitations %} + +{% endif %} +
+
+
+ + + + + + + + + + + + + + + {% for customer in customers %} + {% set assignments = assignment_map[customer.id] %} + {% set facility_ids = scope_map[customer.id] %} + + + + + + + + + + + {% endfor %} + +
UsernameFull NameEmailStatusAssigned ContractsAccessible FacilitiesCreated
+ + + {{ customer.username }} + + + {{ customer.full_name or '—' }}{{ customer.email }} + {% if customer.active %} + Active + {% else %} + Disabled + {% endif %} + + {% if assignments %} + {% set project_names = assignments | map(attribute='project') | map(attribute='name') | unique | list %} + {% for pname in project_names %} + {{ pname }} + {% endfor %} + {% else %} + — None — + {% endif %} + + {% if facility_ids %} + {{ facility_ids|length }} facilit{{ 'y' if facility_ids|length == 1 else 'ies' }} + {% else %} + — None — + {% endif %} + {{ customer.created_at.strftime('%Y-%m-%d') }} + + + + + + +
+ + +
+
+
+
+
+ +{# ── Summary footer ── #} +
+ {{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total + · {{ customers|selectattr('active')|list|length }} active +
+ +{% else %} +
+
+ +

No customer accounts have been created yet.

+ + Create First Customer + +
+
+{% endif %} +{% endblock %} diff --git a/app/templates/customers/invite.html b/app/templates/customers/invite.html new file mode 100644 index 0000000..71f9d09 --- /dev/null +++ b/app/templates/customers/invite.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Create Customer Account{% endblock %} + +{% block content %} +
+
+ +
+
+

+ Create Customer Account +

+
+
+ +

+ Enter the customer's name and email address. An invitation email will be + sent automatically with a secure link where they can choose their own + username and password. The account will be activated once they complete that step. +

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.full_name.label(class="form-label fw-semibold") }} + {{ form.full_name(class="form-control" + (" is-invalid" if form.full_name.errors else ""), + placeholder="e.g. Jane Smith", autofocus=true) }} + {% for error in form.full_name.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ {{ form.email.label(class="form-label fw-semibold") }} + {{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""), + placeholder="jane@example.com") }} + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} +
+ + An invitation email with an account setup link will be sent to this address. +
+
+ +
+ + + Cancel + +
+
+
+
+ +
+
+

+ + What happens next: +

+
    +
  1. An invitation email is sent to the customer with a secure 72-hour link.
  2. +
  3. The customer clicks the link and chooses their own username and password.
  4. +
  5. The account becomes fully active and they can log in immediately.
  6. +
+
+
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/customers/manage.html b/app/templates/customers/manage.html new file mode 100644 index 0000000..713b1dd --- /dev/null +++ b/app/templates/customers/manage.html @@ -0,0 +1,249 @@ +{% extends "base.html" %} +{% block title %}{{ customer.username }} — Customer Portal{% endblock %} + +{% block content %} +
+
+

+ {{ customer.display_name }} + {% if not customer.active %} + Disabled + {% else %} + Active + {% endif %} +

+

{{ customer.email }}{% if customer.full_name %} · @{{ customer.username }}{% endif %}

+
+
+ + Edit Account + +
+ + +
+ + All Customers + +
+
+ +
+ + {# ── Left column: account info + scoped facilities ── #} +
+ +
+
+ Account Details +
+
+
+
Full Name
+
{{ customer.full_name or '—' }}
+
Username
+
{{ customer.username }}
+
Email
+
{{ customer.email }}
+
Status
+
+ + {{ 'Active' if customer.active else 'Disabled' }} + +
+
Password
+
+ {% if customer.password_set %} + Set + {% else %} + Pending setup + {% endif %} +
+
Created
+
{{ customer.created_at.strftime('%Y-%m-%d') }}
+
Assignments
+
{{ assignments|length }}
+
Facilities
+
{{ facilities|length }}
+
+ {% if not customer.password_set %} +
+
+ + +
+ {% endif %} +
+
+ + {# ── Scoped facilities ── #} +
+
+ Accessible Facilities +
+ {% if facilities %} +
+
    + {% for f in facilities %} +
  • + + {{ f.name }} + + {% if f.project %} + {{ f.project.name }} + {% endif %} +
  • + {% endfor %} +
+
+ {% else %} +
+ No facilities accessible yet — add an assignment below. +
+ {% endif %} +
+ +
+ + {# ── Right column: assignments ── #} +
+ + {# ── Current assignments table ── #} +
+
+ Contract Assignments +
+ {% if assignments %} +
+ + + + + + + + + + + {% for a in assignments %} + + + + + + + {% endfor %} + +
ContractFacility ScopeAssigned
+ + {{ a.project.name }} + + + {% if a.facility %} + {{ a.facility.name }} + {% else %} + All facilities + {% endif %} + {{ a.created_at.strftime('%Y-%m-%d') }} +
+ + +
+
+
+ {% else %} +
No assignments yet.
+ {% endif %} +
+ + {# ── Add assignment form ── #} +
+
+ Add Assignment +
+
+
+ + +
+
+ + +
+
+ + +
+ Leave blank to grant access to all facilities in the contract. +
+
+
+ +
+
+
+
+
+ +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/customers/set_password.html b/app/templates/customers/set_password.html new file mode 100644 index 0000000..ae99ed9 --- /dev/null +++ b/app/templates/customers/set_password.html @@ -0,0 +1,179 @@ + + + + + + Set Your Password — Janitorial QC + + + + + + +
+
+

Set Your Password

+

Welcome, {{ user.display_name }}. Choose your username and a secure password to activate your account.

+
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} + {% endwith %} + +
+ + +
+ + + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} +
+ 3–100 characters. You will use this to log in. +
+
+ +
+ + + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} + + {# Strength bar #} +
+
+
+
+
+ + {# Requirements checklist #} +
+ + 8+ characters + + + Uppercase letter + + + Number + +
+
+ +
+ + + {% for error in form.confirm_password.errors %} +
{{ error }}
+ {% endfor %} + +
+ + +
+
+
+ + + + + \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..a74b44a --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,483 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} + +{% block content %} +
+
+

Welcome, {{ current_user.display_name }}!

+ + {{ current_user.role.replace('_',' ')|title }} + +
+
+ +{# ── Inspections section ─────────────────────────────────────────────────── #} +
+ + Inspections +
+
+ + +{# ── Issues section ──────────────────────────────────────────────────────── #} +
+ + Issues +
+
+ + +{# ── SLA Summary ─────────────────────────────────────────────────────────── #} +{% if sla_breached > 0 or sla_at_risk > 0 %} +
+ {% if sla_breached > 0 %} + + {% endif %} + {% if sla_at_risk > 0 %} + + {% endif %} +
+{% endif %} + +{# ── Recent activity ─────────────────────────────────────────────────────── #} +
+
+ Recent Activity +
+
+ {% if recent_inspections %} +
+ + + + + + + {% if current_user.role != 'inspector' %}{% endif %} + + + + + + {% for insp in recent_inspections %} + + + + + {% if current_user.role != 'inspector' %}{% endif %} + + + + {% endfor %} + +
DateFacilityAreaInspectorScoreStatus
{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}{{ insp.facility.name }}{{ insp.area.name if insp.area else '—' }}{{ insp.inspector.display_name }} + {% if insp.overall_score %} + + {{ insp.overall_score }}% + + {% else %} + + {% endif %} + + + {{ 'Submitted' if insp.status == 'completed' else insp.status|title }} + +
+
+ {% else %} +
+ No recent inspections. +
+ {% endif %} +
+
+ + + +{# ── Inspector activity today (admin / director / PM) ───────────────────── #} +{% if inspector_activity %} +
+
+ Inspector Activity Today + View all +
+
+ + + + + + + + + + {% for row in inspector_activity %} + + + + + + {% endfor %} + +
InspectorSubmitted Today
{{ row.name }} + {% if row.count > 0 %} + {{ row.count }} + {% else %} + + {% endif %} + +
+ {% set max_count = inspector_activity | map(attribute='count') | max %} + {% set pct = (row.count / max_count * 100) | int if max_count > 0 else 0 %} +
+
+
+
+
+{% endif %} + +{# ── My open issues (inspector widget) ──────────────────────────────────── #} +{% if my_issues %} +
+
+ My Open Issues + View all +
+
+ + + + + + + + + + + + {% for issue in my_issues %} + {% set sla = sla_status(issue) %} + + + + + + + + {% endfor %} + +
IDSeverityFacility / DescriptionStatusSLA
+ #{{ issue.id }} + + + {{ issue.severity|title }} + + +
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}
+
+ + {{ issue.status|replace('_',' ')|title }} + + + {% if sla == 'breached' %} + Breached + {% elif sla == 'at_risk' %} + {{ sla_hours_remaining(issue)|abs|round(1) }}h left + {% else %} + OK + {% endif %} +
+
+
+{% endif %} + +{# ── Customer portal: scoped facilities panel ───────────────────────────── #} +{% if current_user.role == 'customer' %} +
+
+
+
+ Your Facilities + + + {% if customer_facilities %} + {{ customer_facilities|length }} + {% endif %} +
+
+ {% if customer_facilities %} +
+ {% if customer_facilities|length > 6 %} +
+ +
+ {% endif %} +
+ {% for f in customer_facilities %} +
+
+
{{ f.name }}
+
{{ f.address or '—' }}
+
+ + {{ f.project.name if f.project else '—' }} + +
+ +
+
+ {% endfor %} +
+ {% if customer_facilities|length > 9 %} +
+ +
+ {% endif %} +
+ {% else %} +
+ + No facilities have been assigned to your account yet. Please contact your administrator. +
+ {% endif %} +
+
+
+
+ + +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/facilities/area_form.html b/app/templates/facilities/area_form.html new file mode 100644 index 0000000..802b09b --- /dev/null +++ b/app/templates/facilities/area_form.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

{{ title }} - {{ facility.name }}

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} +
+ +
+ {{ form.area_type.label(class="form-label") }} + {{ form.area_type(class="form-select") }} +
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/facilities/form.html b/app/templates/facilities/form.html new file mode 100644 index 0000000..9d91b00 --- /dev/null +++ b/app/templates/facilities/form.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

{{ title }}

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} +
+ +
+ {{ form.address.label(class="form-label") }} + {{ form.address(class="form-control", rows=3) }} +
+ +
+
+ {{ form.contact_person.label(class="form-label") }} + {{ form.contact_person(class="form-control") }} +
+
+ {{ form.contact_phone.label(class="form-label") }} + {{ form.contact_phone(class="form-control") }} +
+
+ +
+ {{ form.project_id.label(class="form-label") }} + {{ form.project_id(class="form-select") }} +
Link this facility to a contract for customer portal access.
+
+ +
+
+ {{ form.active(class="form-check-input") }} + {{ form.active.label(class="form-check-label") }} +
+
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/facilities/list.html b/app/templates/facilities/list.html new file mode 100644 index 0000000..f69b509 --- /dev/null +++ b/app/templates/facilities/list.html @@ -0,0 +1,194 @@ +{% extends "base.html" %} + +{% block title %}Facilities{% endblock %} + +{% block content %} +
+
+

Facilities

+
+
+ {% if current_user.role in ['admin', 'director'] %} + + Add Facility + + {% endif %} +
+
+ +{% if grouped %} + {% for group_key, group in grouped.items() %} + {# ── Contract group header ────────────────────────────────────────────── #} + {% set collapse_id = 'contract-' ~ loop.index %} +
+
+ + {{ group.facilities|length }} + {% if group.project and current_user.role in ['admin', 'director', 'project_manager'] %} + + + + {% endif %} +
+ + {# ── Collapsible card grid ─────────────────────────────────────────── #} +
+
+ {% for facility in group.facilities %} +
+
+
+
+ + {{ facility.name }} + + {% if not facility.active %} + Inactive + {% endif %} +
+ + {% if facility.address %} +

+ {{ facility.address }} +

+ {% endif %} + +
+ + {{ facility.areas.count() }} areas + +
+
+ +
+
+ {% endfor %} +
+
+
+ {% endfor %} + +{% else %} +
+ No facilities configured yet. +
+{% endif %} + +{% if current_user.role == 'admin' %} + + +{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/facilities/view.html b/app/templates/facilities/view.html new file mode 100644 index 0000000..8bf212e --- /dev/null +++ b/app/templates/facilities/view.html @@ -0,0 +1,210 @@ +{% extends "base.html" %} + +{% block title %}{{ facility.name }}{% endblock %} + +{% block content %} +
+
+

{{ facility.name }}

+
+
+ + Back to Facilities + + {% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %} + + Scorecard + + {% endif %} + {% if current_user.role in ['admin', 'director'] %} + + Edit + + + Add Area + + {% endif %} + {% if current_user.role == 'admin' %} + + {% endif %} +
+
+ +
+
+
+
+
Facility Information
+
+
+ + + + + + + + + + + + + + + + + +
Address:{{ facility.address or 'N/A' }}
Contact Person:{{ facility.contact_person or 'N/A' }}
Contact Phone:{{ facility.contact_phone or 'N/A' }}
Status: + + {{ 'Active' if facility.active else 'Inactive' }} + +
+
+
+
+ +
+
+
+
Statistics
+
+
+
+
+

{{ areas|length }}

+ Areas +
+
+

{{ facility.inspections.count() }}

+ Inspections +
+
+ {%- 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 -%} +

{{ ns.open }}

+ Open Issues +
+
+ +
+
+
+
+ +
+
+
Areas
+
+
+ {% if areas %} +
+ + + + + + + + + + + {% for area in areas %} + + + + + + + {% endfor %} + +
Area NameTypeInspectionsActions
{{ area.name }} + {{ area.area_type|title if area.area_type else 'N/A' }} + {{ area.inspections.count() }} + {% if current_user.role in ['admin', 'director'] %} + + + + {% set area_issue_count = area.issues.count() %} + {% set area_insp_count = area.inspections.count() %} +
+ + +
+ {% endif %} +
+
+ {% else %} +
+ No areas defined for this facility yet. +
+ {% endif %} +
+
+ +{% if current_user.role == 'admin' %} + + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/inspections/execute.html b/app/templates/inspections/execute.html new file mode 100644 index 0000000..4f4dcbd --- /dev/null +++ b/app/templates/inspections/execute.html @@ -0,0 +1,1094 @@ +{% extends "base.html" %} +{% block title %}{{ inspection.template.name }} — Inspection{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+
+ + + + + {# ── Header ── #} +
+
+

{{ inspection.template.name }}

+
+ {{ inspection.facility.name }} + {% if inspection.area %} · {{ inspection.area.name }}{% endif %} +  ·  Inspector: {{ inspection.inspector.display_name }} +
+
+
+ {{ inspection.template.frequency|title }} + +
+
+ + {# ── Form body ── #} +
+ {% if form_fields %} + +
+ {% for field in form_fields %} + {% set fid = field.id %} + {% set saved = saved_responses.get(fid, '') %} + +
+ + {# ── Section label (display only) ── #} + {% if field.type == 'section' %} +
{{ field.label }}
+ + {# ── Static label ── #} + {% elif field.type == 'label' %} + {% set fs_map = {'small':'0.78rem','normal':'0.9rem','large':'1.05rem','x-large':'1.25rem'} %} +
+ {{ field.text_content or '' }} +
+ + {# ── Submit / Print / Email buttons (display in preview; no action needed here) ── #} + {% elif field.type in ('button_submit','button_print','button_email') %} + {# rendered by the sticky footer instead #} + + {# ── Text ── #} + {% elif field.type == 'text' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Textarea ── #} + {% elif field.type == 'textarea' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Number ── #} + {% elif field.type == 'number' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Date ── #} + {% elif field.type == 'date' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Email ── #} + {% elif field.type == 'email' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Checkbox ── #} + {% elif field.type == 'checkbox' %} +
+ + +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Checkbox group ── #} + {% elif field.type == 'checkbox_group' %} + + {% for opt in field.options %} +
+ + +
+ {% endfor %} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Radio ── #} + {% elif field.type == 'radio' %} + + {% for opt in field.options %} +
+ + +
+ {% endfor %} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Pass / Fail ── #} + {% elif field.type == 'pass_fail' %} + + {% set pf_options = field.options if field.options else ['Pass', 'Fail'] %} +
+ {% for opt in pf_options %} + {% set is_pass = opt.lower() in ('pass','yes','ok','good','acceptable','compliant') %} + + {% endfor %} +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Select / Dropdown ── #} + {% elif field.type == 'select' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Rating (stars) ── #} + {% elif field.type == 'rating' %} + +
+ + {% for i in range(1, 6) %} + + {% endfor %} +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Image / Photo upload ── #} + {% elif field.type == 'image' %} + + {# Single
+ {% endfor %} +
+ + {% else %} +
+ +

This template has no form fields. Please add fields in the template editor.

+
+ {% endif %} +
+ + {# ── Sticky footer ── #} + + +
+ +{# ── Flag Issue offcanvas panel ───────────────────────────────────────────── + Replaces the old full-page navigation. The form posts to the existing + flag_issue endpoint via fetch — no page reload, no photo data loss. #} +
+
+
+ Flag Issue +
+ +
+
+

+ Facility: {{ inspection.facility.name }} +

+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/inspections/flag_issue.html b/app/templates/inspections/flag_issue.html new file mode 100644 index 0000000..3f2fabf --- /dev/null +++ b/app/templates/inspections/flag_issue.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Flag Issue{% endblock %} +{% block content %} +
+
+
+
+
Flag Issue During Inspection
+
+
+
+ Inspection: {{ inspection.template.name }}
+ Facility: {{ inspection.facility.name }} +
+
+ + {{ form.facility_id(type="hidden") }} +
+ {{ form.severity.label(class="form-label fw-semibold") }} + {{ form.severity(class="form-select") }} +
+
+ {{ form.description.label(class="form-label fw-semibold") }} + {{ form.description(class="form-control", rows=4, placeholder="Describe the issue in detail…") }} +
+
+ {{ form.photo.label(class="form-label fw-semibold") }} + {{ form.photo(class="form-control") }} +
+
+ {{ form.assigned_to.label(class="form-label fw-semibold") }} + {{ form.assigned_to(class="form-select") }} +
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/inspections/list.html b/app/templates/inspections/list.html new file mode 100644 index 0000000..714db45 --- /dev/null +++ b/app/templates/inspections/list.html @@ -0,0 +1,286 @@ +{% extends "base.html" %} +{% block title %}Inspections{% endblock %} +{% block content %} +
+

Inspections

+ {% if current_user.role != 'customer' %} + + New Inspection + + {% endif %} +
+ +{# Filters #} +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if inspectors %} +
+ + +
+ {% endif %} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear + + Export PDF + +
+
+
+
+
+ +
+
+ {% if inspections.items %} +
+ + + + + + + + + + {% for ins in inspections.items %} + + + + + + + + + + + + + {% endfor %} + +
#DateContractFacilityAreaTemplateInspectorScoreStatus
#{{ ins.id }}{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}{{ ins.facility.name }}{% if ins.area %}{{ ins.area.name }}{% else %}{% endif %}{{ ins.template.name }}{{ ins.inspector.display_name }} + {% if ins.overall_score %} + + {{ ins.overall_score }}% + + {% else %}{% endif %} + + + {{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }} + + {% if ins.status == 'in_progress' %} + {% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %} + {% if hours_open > 24 %} + + Stale + + {% endif %} + {% endif %} + {% if ins.follow_up_required and not ins.follow_ups.count() %} + + Follow-up + + {% endif %} + + {% if ins.status == 'in_progress' or ins.status == 'flagged' %} + Continue + {% else %} + View + {% endif %} + {% if current_user.role in ['admin', 'director'] %} + + {% endif %} +
+
+ {# Pagination #} + {% if inspections.pages > 1 %} +
+ +
+ {% endif %} + {% else %} +
No inspections found.
+ {% endif %} +
+
+{% if current_user.role in ['admin', 'director'] %} + + +{% endif %} +{% endblock %} + +{% block extra_js %} + + + + +{% if current_user.role in ['admin', 'director'] %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/inspections/start.html b/app/templates/inspections/start.html new file mode 100644 index 0000000..2dab55c --- /dev/null +++ b/app/templates/inspections/start.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} +{% block title %}New Inspection{% endblock %} +{% block content %} +
+
+
+
+
New Inspection
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.template_id.label(class="form-label fw-semibold") }} + {{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }} + {% for e in form.template_id.errors %}
{{ e }}
{% endfor %} +
+ +
+ {{ form.project_id.label(class="form-label fw-semibold") }} + {{ form.project_id(class="form-select" + (" is-invalid" if form.project_id.errors else ""), id="projectSelect") }} + {% for e in form.project_id.errors %}
{{ e }}
{% endfor %} +
+ +
+ {{ form.facility_id.label(class="form-label fw-semibold") }} + {{ form.facility_id(class="form-select" + (" is-invalid" if form.facility_id.errors else ""), id="facilitySelect") }} + {% for e in form.facility_id.errors %}
{{ e }}
{% endfor %} +
+ Loading facilities… +
+
+ No active facilities found for this contract. +
+
+ + + +
+ +
+
+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/inspections/view.html b/app/templates/inspections/view.html new file mode 100644 index 0000000..84c815e --- /dev/null +++ b/app/templates/inspections/view.html @@ -0,0 +1,903 @@ +{% extends "base.html" %} +{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+ + {# Action bar #} +
+ + Back to Inspections + +
+ + Export PDF + + + {% if current_user.role not in ['customer'] %} + + Re-inspect + + {% endif %} + {% if current_user.role in ['admin','director'] %} + {% if not inspection.follow_up_required %} + + {% else %} +
+ + +
+ {% endif %} +
+ + +
+ {% endif %} +
+
+ + {# ── 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 %} + + {# ── Score comparison card (re-inspections only) ── #} + {% if comparison %} +
+
+ + + Score Comparison vs. Inspection #{{ comparison.parent_id }} + + {{ comparison.parent_date.strftime('%Y-%m-%d') }} + + + + {# Overall delta badge #} + {% if comparison.score_delta is not none %} + {% if comparison.score_delta > 0 %} + + +{{ comparison.score_delta }}% + + {% elif comparison.score_delta < 0 %} + + {{ comparison.score_delta }}% + + {% else %} + No change + {% endif %} + {% endif %} + {# Score pills #} + + {{ comparison.parent_score|round(1) if comparison.parent_score else '—' }}% + → + {{ comparison.current_score|round(1) if comparison.current_score else '—' }}% + + +
+ +
+ {% endif %} + + {# Header #} +
+
+

{{ inspection.template.name }}

+
+ {{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %} +  ·  Inspector: {{ inspection.inspector.display_name }} +
+
+
+ + {{ inspection.status|replace('_',' ')|title }} + + {% if inspection.overall_score is not none %} +
+ {{ inspection.overall_score }}% +
+ {% endif %} +
+
+ +
+ + {# Meta row #} +
+
+ Start Date + {{ inspection.inspection_date.strftime('%B %d, %Y %H:%M') }} +
+ {% if inspection.completed_at %} +
+ Completed + {{ inspection.completed_at.strftime('%B %d, %Y %H:%M') }} +
+ {% endif %} +
+ Template + {{ inspection.template.name }} +
+
+ Frequency + {{ inspection.template.frequency|title }} +
+
+ + {# ── Submission GPS (admin / director only) ──────────────────────────── #} + {% if current_user.role in ['admin', 'director'] and inspection.submit_latitude and inspection.submit_longitude %} + {% set _lat = inspection.submit_latitude | float %} + {% set _lng = inspection.submit_longitude | float %} +
+
+ Submission Location +
+
+ +
+
+ {{ '%.6f' | format(_lat) }}, {{ '%.6f' | format(_lng) }} + + Open in Maps + +
+
+ {% endif %} + + {# ── Build the filtered field set ────────────────────────────────────── + Strategy: + 1. Find all grid rows that contain at least one rated rating field. + 2. Collect every field whose grid row overlaps a rated row. + 3. Also collect section fields that immediately precede a rated group. + 4. Re-number rows sequentially (1-based) so there are no gaps. + #} + {% if form_fields %} + + {# Pass 1: find all rated rows AND which label IDs have a rated field after them #} + {% set ns = namespace(rated_rows=[], visible_label_ids=[]) %} + + {# 1a: collect rows that have any answered/filled field (not just ratings) #} + {% set _skip_types = ['label', 'section', 'button_submit', 'button_print', 'button_email'] %} + {% for field in form_fields %} + {% if field.type not in _skip_types %} + {% set fval = form_data.get(field.id | string, '') %} + {# A row is "visible" if: rating has score>0, OR any other field has a non-empty value #} + {% if field.type == 'rating' %} + {% set score = fval | int %} + {% if score > 0 %} + {% for r in range(field.row, field.row + field.rowSpan) %} + {% if r not in ns.rated_rows %}{% set ns.rated_rows = ns.rated_rows + [r] %}{% endif %} + {% endfor %} + {% endif %} + {% elif fval and fval != '' and fval != [] %} + {% for r in range(field.row, field.row + field.rowSpan) %} + {% if r not in ns.rated_rows %}{% set ns.rated_rows = ns.rated_rows + [r] %}{% endif %} + {% endfor %} + {% endif %} + {% endif %} + {% endfor %} + + {# 1b: walk fields in order; accumulate label IDs. + When we hit any answered field (rating > 0, pass_fail answered, text filled, etc.), + mark ALL accumulated label IDs as visible and clear the buffer. #} + {% set lbuf = namespace(ids=[]) %} + {% for field in form_fields %} + {% if field.type == 'label' %} + {% set lbuf.ids = lbuf.ids + [field.id] %} + {% elif field.type not in _skip_types %} + {% set fval = form_data.get(field.id | string, '') %} + {% set answered = namespace(v=false) %} + {% if field.type == 'rating' %} + {% if fval | int > 0 %}{% set answered.v = true %}{% endif %} + {% elif fval and fval != '' and fval != [] %} + {% set answered.v = true %} + {% endif %} + {% if answered.v %} + {% for lid in lbuf.ids %} + {% if lid not in ns.visible_label_ids %} + {% set ns.visible_label_ids = ns.visible_label_ids + [lid] %} + {% endif %} + {% endfor %} + {% set lbuf.ids = [] %} + {% endif %} + {% endif %} + {% endfor %} + + {# Pass 2: render — labels only if in visible_label_ids, + data fields only if row overlaps rated_rows, sections always buffered. #} + {% set remap = namespace(out_row=1, last_orig_row=-1, pending_sec=none) %} + +
+ + {% for field in form_fields %} + {% set ftype = field.type %} + + {# Track pending section #} + {% if ftype == 'section' %} + {% set remap.pending_sec = field %} + + {% elif ftype in ('button_submit', 'button_print', 'button_email') %} + {# skip — action buttons not shown in read-only view #} + + {% elif ftype == 'label' %} + {# Only show labels that are ancestors of rated fields #} + {% if field.id in ns.visible_label_ids %} + {# Advance row only when original row actually changes #} + {% if field.row != remap.last_orig_row %} + {% if remap.last_orig_row != -1 %}{% set remap.out_row = remap.out_row + 1 %}{% endif %} + {# Flush pending section before the first field on this new row #} + {% if remap.pending_sec is not none %} +
+
{{ remap.pending_sec.label }}
+
+ {% set remap.out_row = remap.out_row + 1 %} + {% set remap.pending_sec = none %} + {% endif %} + {% set remap.last_orig_row = field.row %} + {% endif %} + {% set fs_map = {'small':'0.72rem','normal':'0.82rem','large':'0.96rem','x-large':'1.1rem'} %} +
+
+ {{ field.text_content or '' }} +
+
+ {% endif %} + + {% else %} + {# Check if this field overlaps any rated row #} + {% set vis = namespace(show=false) %} + {% for r in range(field.row, field.row + field.rowSpan) %} + {% if r in ns.rated_rows %}{% set vis.show = true %}{% endif %} + {% endfor %} + + {% if vis.show %} + {# Advance row only when original row actually changes. + Section flush is also guarded here so a label already placed on + this row at the same out_row cannot be displaced. #} + {% if field.row != remap.last_orig_row %} + {% if remap.last_orig_row != -1 %}{% set remap.out_row = remap.out_row + 1 %}{% endif %} + {% if remap.pending_sec is not none %} +
+
{{ remap.pending_sec.label }}
+
+ {% set remap.out_row = remap.out_row + 1 %} + {% set remap.pending_sec = none %} + {% endif %} + {% set remap.last_orig_row = field.row %} + {% endif %} + + {# Render the field at its original col/colSpan but remapped row #} +
+ + {% set fid = field.id | string %} + {% set val = form_data.get(fid, '') %} + + {% if ftype == 'rating' %} + {{ field.label }} +
+ {% set score = val | int %} + {% if score > 0 %} + {% for i in range(1,6) %}{{ '★' if i <= score else '☆' }}{% endfor %} + {{ score }}/5 + {% else %} + Not rated + {% endif %} +
+ + {% elif ftype == 'image' %} + {{ field.label }} +
+ {% if val %} + + {% else %} + No photo + {% endif %} +
+ + {% elif ftype == 'signature' %} + {{ field.label }} +
+ {% if val and val.startswith('data:') %} + + {% else %} + No signature + {% endif %} +
+ + {% elif ftype == 'pass_fail' %} + {{ field.label }} +
+ {% if val and val.lower() in ('pass','yes','ok','good','acceptable','compliant') %} + {{ val }} + {% elif val %} + {{ val }} + {% else %} + Not answered + {% endif %} +
+ + {% elif ftype == 'checkbox' %} + {{ field.label }} +
+ {% if val == 'yes' %} Yes + {% else %} No{% endif %} +
+ + {% elif ftype == 'checkbox_group' %} + {{ field.label }} +
+ {% if val and val is iterable and val is not string %} + {% for item in val %}{{ item }}{% endfor %} + {% else %}None{% endif %} +
+ + {% elif ftype == 'table' %} + {{ field.label }} +
+ {% if val and val is iterable and val is not string %} + + {% for hdr in (field.col_headers or ['Col']) %}{% endfor %} + {% for row in val %}{% for hdr in (field.col_headers or ['Col']) %}{% endfor %}{% endfor %} +
{{ hdr }}
{{ row.get(hdr,'') }}
+ {% else %}No data{% endif %} +
+ + {% else %} + {# text, number, date, email, radio, select, textarea #} + {{ field.label }} +
{{ val or '—' }}
+ {% endif %} + +
{# /fg-cell #} + {% endif %} + {% endif %} + {% endfor %} + +
{# /form-grid #} + + {% if not ns.rated_rows %} +

No rated fields in this inspection.

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

No form fields found for this template.

+ {% endif %} + + {# Issues #} + {% if issues %} +
+
Issues Logged ({{ issues|length }})
+
+ + + + + + {% for issue in issues %} + + + + + + + + {% endfor %} + +
SeverityAreaDescriptionStatus
{{ issue.severity|title }}{{ issue.area.name }}{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}{{ issue.status|replace('_',' ')|title }}View
+
+ {% endif %} + +
+
+ +{# ── Print-only sign-off block (hidden on screen, visible @media print) ── #} + + + +{# Media lightbox #} +
+
+
+ Photo + +
+ +
+
+{% endblock %} + +{% block extra_js %} + + + + +{# ── Flag follow-up modal ── #} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/issues/form.html b/app/templates/issues/form.html new file mode 100644 index 0000000..817ba11 --- /dev/null +++ b/app/templates/issues/form.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block content %} +
+
+
+
+
{{ title }}
+
+
+
+ + + {# Contract selector — UI only; narrows the facility list via AJAX #} +
+ + +
+ + {# Facility — populated by JS once a contract is chosen #} +
+ {{ form.facility_id.label(class="form-label fw-semibold") }} + {{ form.facility_id(class="form-select", id="facility_id") }} + {% for e in form.facility_id.errors %}
{{ e }}
{% endfor %} +
+ + {# Remaining fields — assigned_to hidden from customer role #} + {% for field in [form.severity, form.description, form.photo] %} +
+ {{ field.label(class="form-label fw-semibold") }} + {{ field(class="form-select" if field.type == 'SelectField' else "form-control", rows=4 if field.type == 'TextAreaField' else none) }} + {% for e in field.errors %}
{{ e }}
{% endfor %} +
+ {% endfor %} + {% if current_user.role != 'customer' %} +
+ {{ form.assigned_to.label(class="form-label fw-semibold") }} + {{ form.assigned_to(class="form-select") }} + {% for e in form.assigned_to.errors %}
{{ e }}
{% endfor %} +
+ {% endif %} + +
+ + Cancel +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html new file mode 100644 index 0000000..de97f07 --- /dev/null +++ b/app/templates/issues/list.html @@ -0,0 +1,325 @@ +{% extends "base.html" %} +{% block title %}Issues{% endblock %} +{% block content %} +
+

Issues

+ {% if current_user.role in ['admin','director','customer'] %} + + Log Issue + + {% endif %} +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear + + Export PDF + +
+
+
+
+ +
+
+ {% if issues.items %} +
+ + + + + + + + + + + + + + + + + + {% for issue in issues.items %} + {% set is_following = issue.id in followed_ids %} + {% set sla = sla_status(issue) %} + + + + + + + + + + + + + + {% endfor %} + +
#ReportedSeverityContractFacility / AreaDescriptionStatusSLAReporterAssigned
#{{ issue.id }}{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }} + + {{ issue.severity|title }} + + + {% set _c = issue.resolved_facility.project if issue.resolved_facility else none %} + {{ _c.name if _c else '—' }} + + {{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+ {{ issue.area.name if issue.area else '—' }} +
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %} + + {{ issue.status|replace('_',' ')|title }} + + + {% if sla == 'breached' %} + Breached + {% elif sla == 'at_risk' %} + {% set hrs = sla_hours_remaining(issue) %} + {{ hrs|abs|round(1) }}h left + {% elif sla == 'ok' %} + OK + {% else %} + + {% endif %} + + {% if issue.reporter %} + {{ issue.reporter.display_name }} + {% else %}{% endif %} + + {% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %} +
+ + +
+ {% else %} + {% if issue.assigned_user %}{{ issue.assigned_user.display_name }} + {% else %}{% endif %} + {% endif %} +
+ {# Following badge + inline unfollow #} + {% if is_following %} + + Following + +
+ + + +
+ {% endif %} + + + {% if current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %} + Edit + {% else %} + View + {% endif %} + + {% if current_user.role in ['admin', 'director'] %} +
+ + +
+ {% endif %} +
+
+ + {% if issues.pages > 1 %} +
+ +
+ {% endif %} + + {% else %} +
No issues found.
+ {% endif %} +
+
+{% endblock %} + +{% block extra_js %} + + +{% if current_user.role in ['admin', 'director'] %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/issues/verification_queue.html b/app/templates/issues/verification_queue.html new file mode 100644 index 0000000..7dad3d3 --- /dev/null +++ b/app/templates/issues/verification_queue.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} +{% block title %}Verification Queue{% endblock %} + +{% block content %} +
+
+

Verification Queue

+

+ Issues awaiting director sign-off before they are fully closed. +

+
+
+ {% if total_pending > 0 %} + {{ total_pending }} pending + {% else %} + All clear + {% endif %} + + All Issues + +
+
+ +{# ── Bulk-verify toolbar — shown when at least one issue is pending ── #} +{% if grouped %} +
+ + {# Issue checkboxes are rendered inside the per-facility tables below; + hidden inputs with their IDs are inserted here by JS on submission. #} +
+
+ + +
+ 0 selected + +
+
+{% endif %} + +{% if not grouped %} +
+
+ +

No issues are currently awaiting verification.

+

When inspectors request sign-off, their issues will appear here.

+
+
+{% else %} + +{# ── Per-facility groups ── #} +{% for facility, issues in grouped %} +
+
+ + {{ facility.name }} + + + {{ issues|length }} issue{{ 's' if issues|length != 1 else '' }} + +
+ +
+ + + + + + + + + + + + + + + {% for issue in issues %} + {% set sla = sla_status(issue) %} + {% set hrs = sla_hours_remaining(issue) %} + + + {# Bulk-select checkbox #} + + + {# ID #} + + + {# Severity #} + + + {# Area / Description #} + + + {# Requested by / assignee #} + + + {# Reported date #} + + + {# SLA indicator #} + + + {# Inline verify form #} + + + {% endfor %} + +
SelectIDSeverityArea / DescriptionRequested ByReportedSLAVerify
+ + + #{{ issue.id }} + + + {{ issue.severity|title }} + + +
{{ issue.area.name if issue.area else '—' }}
+
+ {{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %} +
+
+ {% if issue.assigned_user %} + {{ issue.assigned_user.display_name }} + {% else %} + — unassigned — + {% endif %} + + {{ issue.reported_at.strftime('%Y-%m-%d') }}
+ {{ issue.reported_at.strftime('%H:%M') }} +
+ {% if sla == 'breached' %} + + Breached + + {% elif sla == 'at_risk' %} + + + {% if hrs is not none %}{{ hrs|abs|round(1) }}h left{% else %}At Risk{% endif %} + + {% else %} + + {% if hrs is not none %}{{ hrs|round(1) }}h left{% else %}OK{% endif %} + + {% endif %} + +
+ + + + + + +
+
+
+
+{% endfor %} + +{# ── Legend ── #} +
+
+ BreachedPast SLA deadline + At Risk>75% of SLA window elapsed + OKWithin SLA +
+
+{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html new file mode 100644 index 0000000..826abf8 --- /dev/null +++ b/app/templates/issues/view.html @@ -0,0 +1,482 @@ +{% extends "base.html" %} +{% block title %}Issue #{{ issue.id }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %} + +
+ {# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #} +
+ + {# ── Issue details ──────────────────────────────────────────────────── #} +
+
+
Issue #{{ issue.id }} — {{ issue.severity|title }} Severity
+ + {{ issue.status|replace('_',' ')|title }} + +
+
+
+
Reported
+
{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}
+ +
Reported By
+
+ {% if issue.reporter %} + {{ issue.reporter.display_name }} + {% if issue.reporter.role == 'customer' %} + Customer + {% else %} + {{ issue.reporter.role|replace('_',' ')|title }} + {% endif %} + {% else %} + + {% endif %} +
+ +
Contract
+
+ {% if issue.resolved_facility and issue.resolved_facility.project %} + {{ issue.resolved_facility.project.name }} + {% else %}—{% endif %} +
+ +
Facility
+
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+ +
Area
+
{{ issue.area.name if issue.area else '—' }}
+ + {% if issue.inspection %} +
Inspection
+
+ #{{ issue.inspection_id }} +
+ {% endif %} + +
Assigned To
+
{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}
+ + {% if issue.vendor_name %} +
Contractor
+
+ + {{ issue.vendor_name }} + {% if issue.vendor_contact %} + {{ issue.vendor_contact }} + {% endif %} + {% if issue.vendor_notes %} +
{{ issue.vendor_notes }}
+ {% endif %} +
+ {% endif %} + + {% if issue.resolved_at %} +
Resolved
+
{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}
+ {% endif %} +
+ +
+
Description
+

{{ issue.description }}

+ + {% if issue.photo_path or issue.mobile_photo_paths %} +
+
Photo Evidence
+
+ {% if issue.photo_path %} + + + + {% endif %} + {% for photo in (issue.mobile_photo_paths or []) %} + + + + {% endfor %} +
+ {% endif %} + + {% if issue.result_notes or issue.result_photos %} +
+
Resolution Details
+ {% if issue.result_notes %} +

{{ issue.result_notes }}

+ {% endif %} + {% if issue.result_photos %} +
+ {% for photo in issue.result_photos %} + + Result photo + + {% endfor %} +
+ {% endif %} + {% endif %} + + {# ── Verification panel ── #} + {% if issue.verified_at %} +
+
+ + Verified by {{ issue.verifier.display_name 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 director verification. + {% if current_user.role in ['admin','director'] %} +
+ +
+ +
+ +
+ {% endif %} +
+ {% endif %} +
+
+ + {# ── Comments ───────────────────────────────────────────────────────── #} +
+
+
+ Comments + {{ comments|length }} +
+
+ + {# Comment list #} + {% if comments %} +
+ {% set avatar_colors = ['#4f46e5','#0891b2','#059669','#d97706','#dc2626','#7c3aed','#db2777'] %} + {% for c in comments %} + {% set avatar_color = avatar_colors[c.author.id % (avatar_colors | length)] %} +
+
+ {{ c.author.display_name[0] | upper }} +
+
+
+
+ {{ c.author.display_name }} + {% if c.author.role == 'customer' %} + Customer + {% else %} + {{ c.author.role|replace('_',' ')|title }} + {% endif %} + {# Visibility indicator — staff only #} + {% if current_user.role != 'customer' %} + {% if c.is_customer_visible %} + + Customer visible + + {% else %} + + Staff only + + {% endif %} + {% endif %} +
+
+ + {{ c.status_at_time|replace('_',' ')|title }} + + {{ c.created_at.strftime('%b %d, %Y %H:%M') }} +
+
+

{{ c.body }}

+
+
+ {% endfor %} +
+
+ {% elif current_user.role == 'customer' %} +
+

No comments yet.

+
+
+ {% endif %} + + {# ── Add Comment form ─────────────────────────────────────────────── #} + {% set can_customer_comment = current_user.role == 'customer' and (is_following or issue.reported_by == current_user.id) %} + + {% if can_edit %} + {# Staff comment form with visibility checkbox #} +
+

Add Comment

+
+ + + +
+ +
+
+
+ + +
+ +
+
+
+ + {% elif can_customer_comment %} + {# Customer comment form — visible to all by design #} +
+

Add Comment

+
+ +
+ +
+ +
+
+ + {% elif current_user.role == 'customer' %} +
+

+ Follow this issue to add comments. +

+
+ + {% else %} +
+

+ Only assigned staff can add comments. +

+
+ {% endif %} +
+ +
{# /col-lg-8 #} + + {# ══════════════════════════════════ RIGHT COLUMN ═════════════════════════════════ #} +
+ + {# ── Follow / Unfollow ──────────────────────────────────────────────── #} +
+
+
+ + + {% if is_following %}Following{% else %}Not following{% endif %} + + + {{ issue.followers.count() }} follower{{ 's' if issue.followers.count() != 1 else '' }} + +
+ {% if is_following %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ + {# ── Update Form ────────────────────────────────────────────────────── #} + {% if can_edit %} +
+
Update Issue
+
+
+ +
+ {{ form.status.label(class="form-label fw-semibold") }} + {{ form.status(class="form-select") }} +
+ {% if current_user.role in ['admin','director'] %} +
+ {{ form.assigned_to.label(class="form-label fw-semibold") }} + {{ form.assigned_to(class="form-select") }} +
+ {% endif %} +
+ {{ form.result_notes.label(class="form-label fw-semibold") }} + {{ form.result_notes(class="form-control", rows=3, + placeholder="Describe what was done to resolve this issue…", + value=issue.result_notes or '') }} +
+
+ + +
Attach one or more photos showing the resolution.
+ {% if issue.result_photos %} +
+ {{ issue.result_photos|length }} photo(s) already uploaded +
+ {% endif %} +
+ {% if current_user.role in ['admin','director','project_manager'] %} +
+

+ External Contractor +

+
+ {{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }} + {{ form.vendor_name(class="form-control form-control-sm", + placeholder="Contractor or vendor name", + value=issue.vendor_name or '') }} +
+
+ {{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }} + {{ form.vendor_contact(class="form-control form-control-sm", + placeholder="Phone or email", + value=issue.vendor_contact or '') }} +
+
+ {{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }} + {{ form.vendor_notes(class="form-control form-control-sm", rows=2, + placeholder="Notes about what the contractor is handling…") }} +
+ {% endif %} + +
+ {% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %} +
+ + +
+ {% endif %} +
+
+ {% endif %} + +
{# /col-lg-4 #} +
+ +
+ + Back to Issues + + + Export PDF + + {% if current_user.role in ['admin', 'director'] %} + + {% endif %} +
+ +{% if current_user.role in ['admin', 'director'] %} + +{% endif %} + +{% block extra_js %} + +{% endblock %} +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..31b86e8 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block title %}Login - Janitorial QC{% endblock %} + +{% block content %} +
+
+
+
+

Janitorial QC

+

Quality Control System

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control form-control-lg", placeholder="Enter username") }} + {% if form.username.errors %} +
+ {% for error in form.username.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control form-control-lg", placeholder="Enter password") }} + {% if form.password.errors %} +
+ {% for error in form.password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ + +
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/notifications/index.html b/app/templates/notifications/index.html new file mode 100644 index 0000000..a877c09 --- /dev/null +++ b/app/templates/notifications/index.html @@ -0,0 +1,164 @@ +{% extends "base.html" %} +{% block title %}Notifications{% endblock %} + +{% block content %} +
+
+

+ Notifications + {% if unread_count > 0 %} + + {{ unread_count }} unread + + {% endif %} +

+
+
+ + Preferences + + {% if unread_count > 0 %} +
+ + +
+ {% endif %} +
+
+ +{# ── Filter tabs ── #} + + +{# ── Notification list ── #} +{% if notifications.items %} +
+
    + {% for n in notifications.items %} +
  • +
    +
    +
    + {% if not n.is_read %} + New + {% endif %} + {{ n.title }} +
    +

    {{ n.body }}

    + + {{ n.created_at.strftime('%b %d, %Y %I:%M %p') }} + +
    +
    + {% if n.link %} + + View + + {% endif %} + {% if not n.is_read %} + + {% endif %} +
    +
    +
  • + {% endfor %} +
+
+ +{# ── Pagination ── #} +{% if notifications.pages > 1 %} + +{% endif %} + +{% else %} +
+ +

No notifications found.

+
+{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/notifications/preferences.html b/app/templates/notifications/preferences.html new file mode 100644 index 0000000..b844736 --- /dev/null +++ b/app/templates/notifications/preferences.html @@ -0,0 +1,347 @@ +{% extends "base.html" %} +{% block title %}Notification Preferences{% endblock %} + +{% block content %} +
+
+

+ Notification Preferences +

+

+ Control how and when you receive notifications for each event type. +

+
+ +
+ +
+ + + {# ── Pause all emails banner ── #} + {% set any_email_on = prefs_map.values() | selectattr('email_enabled') | list | length > 0 + or prefs_map | length == 0 %} +
+
+ + Pause all email notifications + — in-app notifications are unaffected +
+ +
+ +
+
+
+
Event
+
Email Alerts
+
Digest Mode
+
Digest Frequency
+
+
+
+ + {# ── Internal staff events ── #} + {% set internal_events = [ + 'issue_assigned', 'issue_status', 'issue_comment', + 'issue_follow_update', 'inspection_completed', 'sla_alert' + ] %} + {# ── Customer portal events ── #} + {% set customer_events = [ + 'customer_inspection_completed', 'customer_issue_updated' + ] %} + +
    +
  • + + Internal Events + +
  • + {% for event_type, label in event_types.items() if event_type in internal_events %} + {% set pref = prefs_map.get(event_type) %} + {% set email_on = pref.email_enabled if pref else True %} + {% set digest_on = pref.digest_mode if pref else False %} + {% set freq = pref.digest_frequency if pref else 'daily' %} + +
  • +
    + + {# Event label #} +
    + {{ label }} +
    + + {# Email toggle #} +
    +
    + + +
    +
    + + {# Digest mode toggle #} +
    +
    + + +
    +
    + + {# Digest frequency #} +
    + +
    + + {# Status label #} +
    + +
    + +
    +
  • + {% endfor %} + +
  • + + Customer Portal Events + +
  • + {% for event_type, label in event_types.items() if event_type in customer_events %} + {% set pref = prefs_map.get(event_type) %} + {% set email_on = pref.email_enabled if pref else True %} + {% set digest_on = pref.digest_mode if pref else False %} + {% set freq = pref.digest_frequency if pref else 'daily' %} + +
  • +
    + + {# Event label #} +
    + {{ label }} + Portal +
    + + {# Email toggle #} +
    +
    + + +
    +
    + + {# Digest mode toggle #} +
    +
    + + +
    +
    + + {# Digest frequency #} +
    + +
    + + {# Status label #} +
    + +
    + +
    +
  • + {% endfor %} +
+
+ +
+ + + Cancel + +
+
+ +
+
+

Email Alerts: + Send an immediate email every time this event occurs.

+

Digest Mode: + Hold notifications and deliver them in a single batched email on your chosen schedule.

+

Off: + In-app notifications still appear in the bell — only email is suppressed.

+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/projects/assignment_form.html b/app/templates/projects/assignment_form.html new file mode 100644 index 0000000..1956d2e --- /dev/null +++ b/app/templates/projects/assignment_form.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

+ {{ title }} +

+ Contract: {{ project.name }} +
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.user_id.label(class="form-label") }} + {{ form.user_id(class="form-select") }} + {% if form.user_id.errors %} +
+ {% for e in form.user_id.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
Only active users with the Customer role are listed.
+
+ +
+ {{ form.facility_id.label(class="form-label") }} + {{ form.facility_id(class="form-select") }} +
+ Select "All facilities in contract" to grant access to every facility + within this contract, or choose a specific facility to restrict access. +
+
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/projects/form.html b/app/templates/projects/form.html new file mode 100644 index 0000000..acdcc29 --- /dev/null +++ b/app/templates/projects/form.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

{{ title }}

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} + {% if form.name.errors %} +
+ {% for e in form.name.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows=3) }} +
+ +
+ {{ form.project_manager_id.label(class="form-label") }} + {{ form.project_manager_id(class="form-select") }} +
Only users with the Project Manager role are listed.
+
+ +
+
+ {{ form.active(class="form-check-input") }} + {{ form.active.label(class="form-check-label") }} +
+
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/projects/import.html b/app/templates/projects/import.html new file mode 100644 index 0000000..fdab1c7 --- /dev/null +++ b/app/templates/projects/import.html @@ -0,0 +1,168 @@ +{% extends "base.html" %} +{% block title %}Import Contracts & Facilities{% endblock %} + +{% block content %} +
+
+

Import Contracts & Facilities

+

Bulk-create contracts and their facilities from an Excel workbook.

+
+ +
+ +{# ── Format guide ── #} +
+
+
Excel Format — two sheets
+
+
+ Sheet: Contracts +
    +
  • contract_name *required
  • +
  • description — optional
  • +
  • active — yes/no (default: yes)
  • +
+
+
+ Sheet: Facilities +
    +
  • contract_name *required — must match a row in Contracts sheet
  • +
  • facility_name *required
  • +
  • address, contact_person, contact_phone — optional
  • +
  • active — yes/no (default: yes)
  • +
+
+
+ Tips +
    +
  • Contracts that already exist (by name) are reused — not duplicated
  • +
  • Facilities that already exist within the same contract are skipped
  • +
  • The Facilities sheet is optional — you can import contracts only
  • +
  • Download the template to see the expected structure
  • +
+
+
+
+
+ +{# ── Upload form (Phase 1) ── #} +{% if not preview_rows %} +
+
+ Upload Excel File +
+
+
+ +
+ + +
Maximum recommended file size: 2 MB.
+
+ +
+
+
+ +{% else %} +{# ── Preview results (Phase 1 response) ── #} +
+
+ + + Preview — {{ preview_rows|length }} row(s) parsed + + + {{ valid_count }} ready + {% set exists_count = preview_rows | selectattr('status', 'equalto', 'exists') | list | length %} + {% if exists_count > 0 %} + {{ exists_count }} existing + {% endif %} + {% set err_count = preview_rows | selectattr('status', 'equalto', 'error') | list | length %} + {% if err_count > 0 %} + {{ err_count }} error(s) + {% endif %} + +
+ +
+
+ + + + + + + + + + + + {% for row in preview_rows %} + + + + + + + + {% endfor %} + +
RowSheetContract NameFacility NameStatus
{{ row.row }}{{ row.sheet }}{{ row.contract_name or '—' }}{{ row.facility_name or '—' }} + {% if row.status == 'ok' %} + Ready + {% elif row.status == 'exists' %} + Exists + {{ row.note }} + {% else %} + Error +
    + {% for e in row.errors %}
  • {{ e }}
  • {% endfor %} +
+ {% endif %} +
+
+
+
+ +{# ── Action buttons (Phase 2 trigger) ── #} +{% if has_errors %} +
+ + Errors found. Fix the issues above and re-upload. + {% if valid_count > 0 %} + You may still import the {{ valid_count }} valid row(s) by clicking below. + {% endif %} +
+{% endif %} + +
+ {% if valid_count > 0 %} +
+ + + + +
+ {% endif %} + + Upload Different File + +
+{% endif %} + +{% endblock %} diff --git a/app/templates/projects/list.html b/app/templates/projects/list.html new file mode 100644 index 0000000..994c732 --- /dev/null +++ b/app/templates/projects/list.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}Contracts{% endblock %} + +{% block content %} +
+
+

Contracts

+
+ {% if current_user.role in ['admin', 'director'] %} + + {% endif %} +
+ +
+ {% for project in projects %} +
+
+
+
+
+ + {{ project.name }} + +
+ {% if not project.active %} + Inactive + {% else %} + Active + {% endif %} +
+ + {% if project.description %} +

{{ project.description }}

+ {% endif %} + +
+ + {{ project.facilities.count() }} facilities + + {% if project.project_manager %} + + {{ project.project_manager.display_name }} + + {% endif %} +
+
+ +
+
+ {% else %} +
+
+ No contracts have been created yet. +
+
+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/projects/view.html b/app/templates/projects/view.html new file mode 100644 index 0000000..097f0a4 --- /dev/null +++ b/app/templates/projects/view.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} +{% block title %}{{ project.name }}{% endblock %} + +{% block content %} +
+
+

+ {{ project.name }} + {% if not project.active %} + Inactive + {% endif %} +

+ {% if project.description %} +

{{ project.description }}

+ {% endif %} +
+
+ {% if current_user.role in ['admin', 'director'] %} + + Edit + + {% endif %} + + All Contracts + +
+
+ +
+ + {# ── Contract Info ── #} +
+
+
+ Contract Details +
+
+
+
Status
+
+ + {{ 'Active' if project.active else 'Inactive' }} + +
+
Contract Manager
+
+ {{ project.project_manager.display_name if project.project_manager else '—' }} +
+
Created
+
{{ project.created_at.strftime('%Y-%m-%d') }}
+
Facilities
+
{{ facilities|length }}
+
+
+
+
+ + {# ── Facilities ── #} +
+
+
+ Facilities +
+
+ {% if facilities %} +
+ + + + + + + + + + + {% for f in facilities %} + + + + + + + {% endfor %} + +
NameAddressStatus
{{ f.name }}{{ f.address or '—' }} + + {{ 'Active' if f.active else 'Inactive' }} + + + + + +
+
+ {% else %} +
No facilities linked to this contract yet.
+ {% endif %} +
+
+
+ + {# ── Customer Assignments ── #} + {% if current_user.role == 'admin' %} +
+
+
+ Customer Assignments + + Add Customer + +
+
+ {% if assignments %} +
+ + + + + + + + + + + + {% for a in assignments %} + + + + + + + + {% endfor %} + +
Customer UsernameEmailFacility ScopeAssigned
{{ a.user.username }}{{ a.user.email }} + {% if a.facility %} + {{ a.facility.name }} + {% else %} + All facilities + {% endif %} + {{ a.created_at.strftime('%Y-%m-%d') }} +
+ + +
+
+
+ {% else %} +
+ No customer users assigned yet. + Add one now. +
+ {% endif %} +
+
+
+ {% endif %} + +
+{% endblock %} diff --git a/app/templates/reports/_subnav.html b/app/templates/reports/_subnav.html new file mode 100644 index 0000000..c3f9200 --- /dev/null +++ b/app/templates/reports/_subnav.html @@ -0,0 +1,44 @@ + diff --git a/app/templates/reports/facility.html b/app/templates/reports/facility.html new file mode 100644 index 0000000..ddde116 --- /dev/null +++ b/app/templates/reports/facility.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} +{% block title %}{{ facility.name }} — Facility Report{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} +
+
+

{{ facility.name }}

+

{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}

+
+ +
+ +{# KPI row #} +{% set completed = inspections | selectattr('status','eq','completed') | list %} +{% set avg = (completed | map(attribute='overall_score') | select | list) %} +
+
+
+
+

Total Inspections

+

{{ inspections|length }}

+
+
+
+
+
+
+

Completed

+

{{ completed|length }}

+
+
+
+
+
+
+

Open Issues

+

{{ open_issues|length }}

+
+
+
+
+
+
+

Avg Score

+ {% if avg %} + {% set avg_val = (avg | map('float') | sum) / avg|length %} +

+ {{ '%.1f'|format(avg_val) }}% +

+ {% else %}

{% endif %} +
+
+
+
+ +{# Area scores chart #} +{% if area_scores %} +
+
Avg Score by Area
+
+
+{% endif %} + +{# Inspection history #} +
+
Inspection History
+
+ {% if inspections %} + + + + + + {% for ins in inspections %} + + + + + + + + + + {% endfor %} + +
DateAreaTemplateInspectorScoreStatus
{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}{{ ins.area.name if ins.area else '—' }}{{ ins.template.name }}{{ ins.inspector.display_name }} + {% if ins.overall_score %} + + {{ ins.overall_score }}% + + {% else %}—{% endif %} + + {{ ins.status|replace('_',' ')|title }}View
+ {% else %} +

No inspections in this date range.

+ {% endif %} +
+
+ +{# Open issues #} +{% if open_issues %} +
+
Open Issues
+
+ + + + {% for issue in open_issues %} + + + + + + + + {% endfor %} + +
SeverityAreaDescriptionReported
{{ issue.severity|title }}{{ issue.area.name }}{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}{{ issue.reported_at.strftime('%Y-%m-%d') }}View
+
+
+{% endif %} +{% endblock %} + +{% block extra_js %} +{% if area_scores %} + + +{% endif %} +{% endblock %} diff --git a/app/templates/reports/followup_closure.html b/app/templates/reports/followup_closure.html new file mode 100644 index 0000000..eff0502 --- /dev/null +++ b/app/templates/reports/followup_closure.html @@ -0,0 +1,186 @@ +{% extends "base.html" %} +{% block title %}Follow-up Closure Rate{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} +{% include 'reports/_subnav.html' %} + +
+
+

Follow-up Closure Rate

+

+ {{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }} +  ·  Inspections flagged for follow-up and whether a re-inspection was completed. +

+
+ + Export Excel + +
+ +{# ── Date filter ── #} +
+
+
+
+ + +
+
+ + +
+
+ + Reset +
+
+
+
+ +{# ── KPI row ── #} +
+
+
+
+
Flagged for Follow-up
+
{{ total }}
+
inspections in period
+
+
+
+
+
+
+
Re-inspected
+
{{ closed }}
+
follow-up completed
+
+
+
+
+
+
+
Closure Rate
+
+ {{ rate|round(1) if rate is not none else '—' }}{% if rate is not none %}%{% endif %} +
+
of flagged inspections re-done
+
+
+
+
+ +{% if total == 0 %} +
+ No inspections were flagged for follow-up in this period. +
+{% else %} + +{# ── By facility ── #} +{% if by_facility %} +
+
+ Closure Rate by Facility +
+
+ + + + + + + + + + + + {% for row in by_facility %} + + + + + + + + {% endfor %} + +
FacilityFlaggedRe-inspectedClosure RateProgress
{{ row.name }}{{ row.total }}{{ row.closed }} + + {{ row.rate }}% + + +
+
+
+
+
+
+{% endif %} + +{# ── Detail table ── #} +
+
+ Inspection Detail + {{ total }} flagged +
+
+ + + + + + + + + + + + + + + + {% for insp in flagged %} + + + + + + + + + + + + {% endfor %} + +
IDDateFacilityTemplateInspectorScoreNoteRe-inspected?
#{{ insp.id }}{{ insp.inspection_date.strftime('%Y-%m-%d') if insp.inspection_date else '—' }}{{ insp.facility.name if insp.facility else '—' }}{{ insp.template.name if insp.template else '—' }}{{ insp.inspector.display_name if insp.inspector else '—' }} + {% if insp.overall_score %} + + {{ insp.overall_score|round(1) }}% + + {% else %}—{% endif %} + + {{ insp.follow_up_note[:60] if insp.follow_up_note else '—' }} + {% if insp.follow_up_note and insp.follow_up_note|length > 60 %}…{% endif %} + + {% if insp._has_followup %} + Yes + {% else %} + No + {% endif %} + + + + +
+
+
+ +{% endif %}{# end total == 0 #} +{% endblock %} diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html new file mode 100644 index 0000000..83154f6 --- /dev/null +++ b/app/templates/reports/index.html @@ -0,0 +1,319 @@ +{% extends "base.html" %} +{% block title %}Reports & Analytics{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} +{% include 'reports/_subnav.html' %} + +{# ── Header + date filter ── #} +
+
+

Reports & Analytics

+

{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}

+
+ +
+ +
+
+
+
+ + +
+
+ + +
+ {% if inspectors %} +
+ + +
+ {% endif %} +
+ + Reset +
+
+
+
+ +{# ── KPI cards ── #} +
+ {% for label, value, color, icon in [ + ('Total Inspections', total_inspections, 'primary', 'bi-clipboard-data'), + ('Completed', completed, 'success', 'bi-check-circle'), + ('Open Issues', flagged, 'danger', 'bi-flag'), + ('Avg Score', (avg_score|string + '%') if avg_score else '—', 'info', 'bi-graph-up'), + ] %} +
+
+
+ +
+

{{ label }}

+

{{ value }}

+
+
+
+
+ {% endfor %} +
+ +{# ── Charts row 1 ── #} +
+
+
+
Score Trend
+
+
+
+
+
+
Issues by Severity
+
+
+
+
+ +{# ── Charts row 2 ── #} +
+
+
+
Avg Score by Facility
+
+
+
+
+
+
Issue Status
+
+
+
+
+ +{# ── Facility period-over-period comparison table ── #} +{% if facility_scores %} +
+
+
Facility Score Comparison
+ vs. prior equal-length period +
+
+ + + + + + + + + + + + {% for row in facility_scores %} + + + + + + + + {% endfor %} + +
FacilityCurrent PeriodPrior PeriodChangeInspections
{{ row.name }} + + {{ '%.1f'|format(row.avg_score|float) }}% + + + {% if row.prior_avg is not none %} + {{ '%.1f'|format(row.prior_avg|float) }}% + {% else %} + + {% endif %} + + {% if row.delta is not none %} + {% if row.delta > 0 %} + + +{{ '%.1f'|format(row.delta|float) }} + + {% elif row.delta < 0 %} + + {{ '%.1f'|format(row.delta|float) }} + + {% else %} + + 0.0 + + {% endif %} + {% else %} + No prior data + {% endif %} + {{ row.count }}
+
+
+{% endif %} + +{# ── Top inspectors table ── #} +{% if top_inspectors %} +
+
+
+
Top Inspectors
+
+ + + + {% for row in top_inspectors %} + + + + + + {% endfor %} + +
InspectorInspectionsAvg Score
{{ row.display_name }}{{ row.count }} + {% if row.avg_score %} + + {{ '%.1f'|format(row.avg_score|float) }}% + + {% else %}—{% endif %} +
+
+
+
+ + {# ── Critical / High open issues ── #} +
+
+
Open Critical / High Issues
+ {% if critical_issues %} +
+ + + + {% for issue in critical_issues %} + + + + + + + {% endfor %} + +
SeverityAreaDescription
{{ issue.severity|title }}{{ issue.area.name }}{{ issue.description[:50] }}{% if issue.description|length > 50 %}…{% endif %}View
+
+ {% else %} +

No open critical or high issues. 🎉

+ {% endif %} +
+
+
+{% endif %} + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/reports/inspector_performance.html b/app/templates/reports/inspector_performance.html new file mode 100644 index 0000000..a85ddf7 --- /dev/null +++ b/app/templates/reports/inspector_performance.html @@ -0,0 +1,395 @@ +{% extends "base.html" %} +{% block title %}Inspector Performance{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} +{% include 'reports/_subnav.html' %} + +{# ── Header ── #} +
+
+

Inspector Performance

+

{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}

+
+ +
+ +{# ── Date filter ── #} +
+
+
+
+ + +
+
+ + +
+ {% if selected_id %} + + {% endif %} +
+ + Reset +
+
+
+
+ +{% if not inspector_stats %} +
+ + No inspections found for the selected date range. +
+{% else %} + +{# ── Comparison charts ── #} +
+
+
+
Avg Score by Inspector
+
+
+
+
+
+
Inspections Completed
+
+
+
+
+ +{# ── Summary table ── #} +
+
+
All Inspectors — Summary
+ {{ inspector_stats|length }} inspector{{ 's' if inspector_stats|length != 1 }} +
+
+ + + + + + + + + + + + + + + + + + {% for s in inspector_stats %} + + + + + + + + + + + + + + {% endfor %} + +
InspectorTotalCompletedCompletion RateAvg Score + vs. Avg + {% if team_avg_score %}({{ '%.1f'|format(team_avg_score) }}%){% endif %} + Avg TimeIssues FlaggedFollow-upsFacilities
+ {{ s.display_name }} + {{ s.total }}{{ s.completed }} +
+
+
+
+ {{ s.completion_rate }}% +
+
+ {% if s.avg_score %} + + {{ '%.1f'|format(s.avg_score) }}% + + {% else %} + + {% endif %} + + {% if s.vs_avg is not none %} + {% if s.vs_avg > 0 %} + +{{ '%.1f'|format(s.vs_avg) }}% + {% elif s.vs_avg < 0 %} + {{ '%.1f'|format(s.vs_avg) }}% + {% else %} + 0.0% + {% endif %} + {% else %} + + {% endif %} + + {{ s.avg_time or '—' }} + + {% if s.issues_flagged > 0 %} + {{ s.issues_flagged }} + {% else %} + 0 + {% endif %} + + {% if s.follow_ups > 0 %} + {{ s.follow_ups }} + {% else %} + 0 + {% endif %} + {{ s.facilities }} + {% if selected_id == s.id %} + Close + {% else %} + Details + {% endif %} +
+
+
+ +{# ── Individual inspector drill-down ── #} +{% if selected_inspector and selected_kpis %} +
+
+
+ {{ selected_inspector.display_name }} +
+ + + +
+
+ + {# KPI stat row #} +
+ {% for label, value, color, icon in [ + ('Total Inspections', selected_kpis.total, 'primary', 'bi-clipboard-data'), + ('Completed', selected_kpis.completed, 'success', 'bi-check-circle'), + ('Avg Score', (('%.1f'|format(selected_kpis.avg_score)) + '%') if selected_kpis.avg_score else '—', 'info', 'bi-graph-up'), + ('Avg Completion Time',selected_kpis.avg_time or '—', 'info', 'bi-stopwatch'), + ('Issues Flagged', selected_kpis.issues_flagged, 'danger', 'bi-flag'), + ('Follow-ups Req.', selected_kpis.follow_ups, 'warning', 'bi-arrow-repeat'), + ('Facilities Covered', selected_kpis.facilities, 'primary', 'bi-building'), + ] %} +
+
+
+

{{ label }}

+
+ + {{ value }} +
+
+
+
+ {% endfor %} +
+ + {# Score trend chart #} +
+
+
+
+
Score Trend — {{ selected_inspector.display_name }}
+
+
+ {% if trend_data %} +
+ {% else %} +

No completed inspections with scores in this period.

+ {% endif %} +
+
+
+
+ + {# Recent inspections #} + {% if recent_inspections %} +
+
+
Recent Inspections
+ {{ recent_inspections|length }} +
+
+ + + + + + + + + + + + + + {% for ins in recent_inspections %} + + + + + + + + + + {% endfor %} + +
DateFacilityAreaTemplateScoreStatus
{{ ins.inspection_date.strftime('%b %d, %Y') }}{{ ins.facility.name if ins.facility else '—' }}{{ ins.area.name if ins.area else '—' }}{{ ins.template.name if ins.template else '—' }} + {% if ins.overall_score %} + + {{ '%.1f'|format(ins.overall_score|float) }}% + + {% else %}—{% endif %} + + {% set sc = ins.status %} + + {{ 'Submitted' if sc == 'completed' else sc.replace('_',' ')|title }} + + + View +
+
+
+ {% endif %} + +
+
+{% endif %} + +{% endif %}{# end if inspector_stats #} + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/app/templates/reports/issues_aging.html b/app/templates/reports/issues_aging.html new file mode 100644 index 0000000..d654a1b --- /dev/null +++ b/app/templates/reports/issues_aging.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} +{% block title %}Issues Aging{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} +{% include 'reports/_subnav.html' %} + +
+
+

Issues Aging

+

All currently open issues grouped by how long they have been waiting.

+
+ + Export Excel + +
+ +{# ── Filters ── #} +
+
+
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+
+ +{# ── KPI row ── #} +
+
+
+
+
Total Open
+
{{ total }}
+
unresolved issues
+
+
+
+
+
+
+
SLA Breached
+
{{ sla_breached }}
+
past resolution deadline
+
+
+
+
+
+
+
SLA At Risk
+
{{ sla_at_risk }}
+
approaching deadline
+
+
+
+
+ +{# ── Bucket accordions ── #} +
+{% for label in bucket_labels %} +{% set bucket = buckets[label] %} +{% set bucket_id = 'bucket-' ~ loop.index %} +{% set is_danger = label in ['>4 weeks', '1–4 weeks'] %} +{% set is_warning = label == '3–7 days' %} +
+

+ +

+
+
+ {% if bucket %} +
+ + + + + + + + + + + + + + + + {% for item in bucket %} + {% set issue = item.issue %} + {% set sla = item.sla %} + + + + + + + + + + + + {% endfor %} + +
#AgeSeverityFacility / AreaDescriptionStatusSLAAssigned
#{{ issue.id }}{{ item.age_h }}h + + {{ issue.severity|title }} + + + {{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+ {{ issue.area.name if issue.area else '—' }} +
{{ issue.description[:70] }}{% if issue.description|length > 70 %}…{% endif %} + + {{ issue.status|replace('_',' ')|title }} + + + {% if sla == 'breached' %}Breached + {% elif sla == 'at_risk' %}At Risk + {% elif sla == 'ok' %}OK + {% else %}{% endif %} + {{ issue.assigned_user.display_name if issue.assigned_user else '—' }} + + + +
+
+ {% else %} +
No issues in this age range.
+ {% endif %} +
+
+
+{% endfor %} +
+ +{% if total == 0 %} +
+ No open issues match the selected filters. +
+{% endif %} + +{% endblock %} diff --git a/app/templates/reports/scorecard.html b/app/templates/reports/scorecard.html new file mode 100644 index 0000000..4912b00 --- /dev/null +++ b/app/templates/reports/scorecard.html @@ -0,0 +1,279 @@ +{% 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 + + + PDF Summary + + + 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/reports/sla_compliance.html b/app/templates/reports/sla_compliance.html new file mode 100644 index 0000000..186850d --- /dev/null +++ b/app/templates/reports/sla_compliance.html @@ -0,0 +1,149 @@ +{% extends "base.html" %} +{% block title %}SLA Compliance{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} +{% include 'reports/_subnav.html' %} + +
+
+

SLA Compliance

+

{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}

+
+ + Export Excel + +
+ +{# ── Filters ── #} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
+
+
+
+ +{% if total == 0 %} +
+ No resolved issues found for this period and filter. +
+{% else %} + +{# ── Overall KPI ── #} +
+
+
+
+
Overall Compliance
+
+ {{ overall_pct|round(1) if overall_pct is not none else '—' }}{% if overall_pct is not none %}%{% endif %} +
+
{{ met }}/{{ total }} resolved on time
+
+
+
+ + {# ── Per-severity compliance cards ── #} + {% for sev, color_cls in [('critical','danger'),('high','danger'),('medium','warning'),('low','secondary')] %} + {% set d = by_severity[sev] %} +
+
+
+
+ {{ sev|title }} +
+
SLA: {{ d.sla_hours }}h window
+
+ {{ d.pct|round(1) if d.pct is not none else '—' }}{% if d.pct is not none %}%{% endif %} +
+
{{ d.met }}/{{ d.total }}
+ {% if d.total > 0 %} +
+
+
+ {% endif %} +
+
+
+ {% endfor %} +
+ +{# ── Facility breakdown ── #} +{% if by_facility %} +
+
+ Compliance by Facility + {{ by_facility|length }} +
+
+ + + + + + + + + + + + {% for row in by_facility %} + {% set pct_val = row.pct or 0 %} + {% set bar_class = 'bg-success' if pct_val >= 90 else 'bg-warning' if pct_val >= 70 else 'bg-danger' %} + + + + + + + + {% endfor %} + +
FacilityResolved IssuesMet SLACompliance %Progress
{{ row.name }}{{ row.total }}{{ row.met }} + {% if row.pct is not none %} + + {{ row.pct }}% + + {% else %}—{% endif %} + +
+
+
+
+
+
+{% endif %} + +{% endif %}{# end total == 0 #} +{% endblock %} diff --git a/app/templates/scheduled_reports/email.html b/app/templates/scheduled_reports/email.html new file mode 100644 index 0000000..a27f621 --- /dev/null +++ b/app/templates/scheduled_reports/email.html @@ -0,0 +1,185 @@ + + + +
+

+ 📋 + {{ 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 }} + {% set rf = i.resolved_facility %} + {{ rf.name if rf else '—' }} / {{ i.area.name if i.area else '—' }} + {{ i.reported_at.strftime('%b %d') }}
+ {% endif %} + + {% elif report.report_type == 'issues' %} + + {# SLA summary bar #} + {% if issues %} + + + + + + + + +
+
{{ sla_breached }}
+
SLA Breached
+
+
{{ sla_at_risk }}
+
At Risk
+
+
{{ issues|length }}
+
Total Open
+
+ + {# Per-facility sections #} + {% for facility_name, fac_issues in issues_by_facility %} +

+ 🏢 {{ facility_name }} + ({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }}) +

+ + + + + + + + + + + + + + {% for i, sla in fac_issues %} + {% set row_bg = '#fef2f2' if sla == 'breached' else '#fefce8' if sla == 'at_risk' else '#fff' %} + + + + + + + + + + {% endfor %} + +
#SeverityAreaDescriptionStatusSLAReported
+ #{{ i.id }} + + {{ i.severity|title }} + {{ i.area.name if i.area else '—' }}{{ i.description[:70] }}{% if i.description|length > 70 %}…{% endif %}{{ i.status|replace('_',' ')|title }} + {{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }} + {{ i.reported_at.strftime('%b %d') }}
+ {% endfor %} + + {% else %} +

No open issues at this time.

+ {% 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..777d824 --- /dev/null +++ b/app/templates/scheduled_reports/email.txt @@ -0,0 +1,47 @@ +{{ 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 %} + {% set rf = i.resolved_facility %}#{{ i.id }} [{{ i.severity|title }}] {{ rf.name if rf else '—' }} — {{ i.description[:80] }} + Link: {{ base_url }}/issues/{{ i.id }} +{% endfor %} +{% endif %} + +{% elif report.report_type == 'issues' %} +OPEN ISSUES ({{ issues|length }}) — Breached: {{ sla_breached }} At Risk: {{ sla_at_risk }} +{% if issues %} +{% for facility_name, fac_issues in issues_by_facility %} + + {{ facility_name }} ({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }}) + {% for i, sla in fac_issues %} #{{ i.id }} [{{ i.severity|title }}] [SLA: {{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }}] {{ i.area.name if i.area else '—' }} + Status: {{ i.status|replace('_',' ')|title }} | {{ i.description[:70] }} + Link: {{ base_url }}/issues/{{ i.id }} + {% endfor %} +{% 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..be5ed1e --- /dev/null +++ b/app/templates/scheduled_reports/index.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}Scheduled Reports{% endblock %} +{% block content %} +{% include 'reports/_subnav.html' %} +
+

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 %} \ No newline at end of file diff --git a/app/templates/support.html b/app/templates/support.html new file mode 100644 index 0000000..2218cb4 --- /dev/null +++ b/app/templates/support.html @@ -0,0 +1,39 @@ + + + + + + JQC App Support — LT Services Inc. + + + + +
+

JQC — Janitorial Quality Control

+

LT Services Inc. — Internal Operations App

+ +

Support

+

This app is an internal tool for LT Services Inc. employees and staff. It is not available for public download or use.

+

If you are an LT Services Inc. employee experiencing an issue with the app, contact your IT administrator:

+ + + +

About This App

+

App name: JQC — Janitorial Quality Control

+

Developer: LT Services Inc.

+

Platform: iPadOS

+

Purpose: Facility inspection management, issue tracking, and quality reporting for internal janitorial operations staff.

+ +

© {{ current_year }} LT Services Inc. All rights reserved.

+
+ + + diff --git a/app/templates/support/admin_ticket_detail.html b/app/templates/support/admin_ticket_detail.html new file mode 100644 index 0000000..dcff2b0 --- /dev/null +++ b/app/templates/support/admin_ticket_detail.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} +{% block title %}Ticket #{{ ticket.id }}{% endblock %} + + +{% block content %} + + +
+ + {# ── Left column: original message + replies ── #} +
+ + {# Original ticket card #} +
+
+ Ticket #{{ ticket.id }}: {{ ticket.subject }} + {% set status_class = {'open': 'danger', 'answered': 'success', 'closed': 'secondary'} %} + + {{ ticket.status | capitalize }} + +
+
+
+ + {{ ticket.customer.display_name if ticket.customer else 'Unknown' }} + {% if ticket.facility %} +  · {{ ticket.facility.name }} + {% endif %} +  ·  + {{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }} +
+

{{ ticket.body }}

+
+
+ + {# Reply thread #} + {% if replies %} +
+
Replies
+
+
+ {% for reply in replies %} +
+
+ + {{ reply.author.display_name if reply.author else 'Support Team' }} +  · {{ reply.created_at.strftime('%b %d, %Y %I:%M %p') }} +
+
{{ reply.body }}
+
+ {% endfor %} +
+
+
+ {% endif %} + + {# Add reply form #} + {% if ticket.status != 'closed' %} +
+
Add Reply
+
+
+ + +
+ +
+ + + The customer will be notified by email. + +
+
+
+ {% else %} +
+ This ticket is closed. Change the status to re-open it. +
+ {% endif %} + +
+ + {# ── Right column: status management ── #} +
+
+
Ticket Status
+
+

Current status:

+

+ + {{ ticket.status | capitalize }} + +

+
+ + + {% if ticket.status != 'open' %} + + {% endif %} + {% if ticket.status != 'answered' %} + + {% endif %} + {% if ticket.status != 'closed' %} + + {% endif %} +
+
+
+ + {# Customer info card #} + {% if ticket.customer %} +
+
Customer
+
+

{{ ticket.customer.display_name }}

+

{{ ticket.customer.email }}

+ {% if ticket.facility %} +
+

{{ ticket.facility.name }}

+ {% if ticket.facility.address %} +

{{ ticket.facility.address }}

+ {% endif %} + {% endif %} +
+
+ {% endif %} + +
+
+{% endblock %} diff --git a/app/templates/support/admin_tickets.html b/app/templates/support/admin_tickets.html new file mode 100644 index 0000000..b591d9b --- /dev/null +++ b/app/templates/support/admin_tickets.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} +{% block title %}Support Tickets{% endblock %} + +{% block content %} +
+
+

Customer Support Tickets

+ {{ tickets.total }} ticket{{ 's' if tickets.total != 1 }} +
+
+ +{# Status filter tabs #} + + +{% if tickets.items %} +
+
+ + + + + + + + + + + + + + {% for ticket in tickets.items %} + {% set status_class = {'open': 'danger', 'answered': 'success', 'closed': 'secondary'} %} + + + + + + + + + + {% endfor %} + +
#CustomerFacilitySubjectStatusSubmitted
{{ ticket.id }}{{ ticket.customer.display_name if ticket.customer else '—' }}{{ ticket.facility.name if ticket.facility else '—' }}{{ ticket.subject }} + + {{ ticket.status | capitalize }} + + + {{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }} + + + View + +
+
+
+ +{# Pagination #} +{% if tickets.pages > 1 %} + +{% endif %} + +{% else %} +
+ + No tickets{% if status_filter %} with status "{{ status_filter }}"{% endif %}. +
+{% endif %} +{% endblock %} diff --git a/app/templates/support/chat.html b/app/templates/support/chat.html new file mode 100644 index 0000000..4450b36 --- /dev/null +++ b/app/templates/support/chat.html @@ -0,0 +1,279 @@ +{% extends "base.html" %} +{% block title %}Support Chat{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+ + {# ── Header ── #} +
+
+

JQC Support Chat

+ Ask a question or browse common topics below +
+ +
+ + {# ── Chat card ── #} +
+ {# Message window #} +
+ + {# FAQ quick-reply chips #} +
+

Common questions:

+
+ {% for faq in faqs %} + + {% endfor %} +
+
+ + {# Input row #} +
+
+ + +
+ {% if not groq_ready %} +
+ + AI assistant is not configured. Please + submit a request + to reach our team. +
+ {% endif %} +
+
+ +

+ Can't find what you need? + Submit a support request + and our team will respond by email. +

+ +
+
+ +{# ── Submit to Support modal ── #} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/support/my_ticket_detail.html b/app/templates/support/my_ticket_detail.html new file mode 100644 index 0000000..e811146 --- /dev/null +++ b/app/templates/support/my_ticket_detail.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% block title %}Support Request #{{ ticket.id }}{% endblock %} + +{% block content %} + + +
+
+ + {# Original request #} +
+
+ Request #{{ ticket.id }}: {{ ticket.subject }} + {% set status_class = {'open': 'warning', 'answered': 'success', 'closed': 'secondary'} %} + {% set status_label = {'open': 'Awaiting reply', 'answered': 'Answered', 'closed': 'Closed'} %} + + {{ status_label.get(ticket.status, ticket.status | capitalize) }} + +
+
+
+ {% if ticket.facility %} + {{ ticket.facility.name }} ·  + {% endif %} + {{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }} +
+

{{ ticket.body }}

+
+
+ + {# Staff replies #} + {% if replies %} +
+
+ Replies from Support Team +
+
+
+ {% for reply in replies %} +
+
+ + {{ reply.author.display_name if reply.author else 'Support Team' }} +  · {{ reply.created_at.strftime('%b %d, %Y %I:%M %p') }} +
+
{{ reply.body }}
+
+ {% endfor %} +
+
+
+ {% else %} +
+ + Your request has been received. Our team will reply shortly. +
+ {% endif %} + + {% if ticket.status != 'closed' %} +
+
Add a Follow-up
+
+
+ +
+ +
+ +
+
+
+ {% else %} +
+ This request is closed. + Start a new chat if you need further help. +
+ {% endif %} + + + +
+
+{% endblock %} diff --git a/app/templates/support/my_tickets.html b/app/templates/support/my_tickets.html new file mode 100644 index 0000000..75b0e67 --- /dev/null +++ b/app/templates/support/my_tickets.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}My Support Requests{% endblock %} + +{% block content %} +
+
+

My Support Requests

+ {{ tickets | length }} request{{ 's' if tickets | length != 1 }} +
+ + New Chat / Ask a Question + +
+ +{% if tickets %} +
+
+ + + + + + + + + + + + + {% set status_class = {'open': 'warning', 'answered': 'success', 'closed': 'secondary'} %} + {% set status_label = {'open': 'Awaiting reply', 'answered': 'Answered', 'closed': 'Closed'} %} + {% for ticket in tickets %} + + + + + + + + + {% endfor %} + +
#SubjectFacilityStatusSubmitted
{{ ticket.id }}{{ ticket.subject }}{{ ticket.facility.name if ticket.facility else '—' }} + + {{ status_label.get(ticket.status, ticket.status | capitalize) }} + + + {{ ticket.created_at.strftime('%b %d, %Y') }} + + + View + +
+
+
+{% else %} +
+ + You haven't submitted any support requests yet.
+ Start a support chat +
+{% endif %} +{% endblock %} diff --git a/app/templates/templates/edit.html b/app/templates/templates/edit.html new file mode 100644 index 0000000..386b1d0 --- /dev/null +++ b/app/templates/templates/edit.html @@ -0,0 +1,246 @@ +{% extends "base.html" %} + +{% block title %}Edit Template — {{ template.name }}{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} + + +
+ + +
+
+ Template Settings +
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="form-label fw-semibold small") }} + {{ form.name(class="form-control form-control-sm") }} + {% if form.name.errors %} +
{{ form.name.errors[0] }}
+ {% endif %} +
+ +
+ {{ form.description.label(class="form-label fw-semibold small") }} + {{ form.description(class="form-control form-control-sm", rows=3) }} +
+ +
+ {{ form.frequency.label(class="form-label fw-semibold small") }} + {{ form.frequency(class="form-select form-select-sm") }} +
+ +
+ +
+
+
+ + +
+
+ + Edit fields & layout in the Form Editor +
+ + Open Editor + +
+
+
+ + +
+
+
Form Layout
+
+ {{ form_fields|length }} field{{ 's' if form_fields|length != 1 else '' }} + + Preview + + + Edit Form + +
+
+ +
+ {% if form_fields %} +
+ {% for f in form_fields %} +
+ {% if f.type == 'section' %} +
{{ f.label }}
+ {% elif f.type == 'table' %} +
{{ f.label }}
+ table · {{ f.col_headers|length if f.col_headers else 3 }} cols × {{ f.table_rows or 3 }} rows + {% elif f.type == 'label' %} +
{{ f.text_content or 'Label text' }}
+ label + {% elif f.type.startswith('button_') %} +
{{ f.btn_label or f.type.replace('button_','') | title }}
+ {{ f.type.replace('_',' ') }} + {% else %} +
+ {{ f.label }}{% if f.required %} *{% endif %} +
+ {{ f.type.replace('_', ' ') }} + {% endif %} +
+ {% endfor %} +
+ + {% else %} +
+ +

No fields yet. Open the Form Editor to start building your inspection form.

+ + Open Form Editor + +
+ {% endif %} +
+
+ +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/templates/form.html b/app/templates/templates/form.html new file mode 100644 index 0000000..152556b --- /dev/null +++ b/app/templates/templates/form.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

{{ title }}

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} +
+ +
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows=3) }} +
+ +
+ {{ form.frequency.label(class="form-label") }} + {{ form.frequency(class="form-select") }} +
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/templates/form_editor.html b/app/templates/templates/form_editor.html new file mode 100644 index 0000000..600b292 --- /dev/null +++ b/app/templates/templates/form_editor.html @@ -0,0 +1,1249 @@ +{% extends "base.html" %} + +{% block title %}Form Editor — {{ template.name }}{% endblock %} + +{% block extra_css %} + + + +{% endblock %} + +{% block content %} +
+ + +
+ + Back + +
+
{{ template.name }}
+
Form Editor · {{ template.frequency|title }}
+
+
+ All changes saved + Preview + +
+ + +
+
Basic
+ {% for t, ic, lb in [ + ('text', 'bi-input-cursor-text', 'Text Input'), + ('textarea', 'bi-text-left', 'Text Area'), + ('number', 'bi-hash', 'Number'), + ('date', 'bi-calendar3', 'Date'), + ('email', 'bi-envelope', 'Email'), + ] %} +
+ {{ lb }} +
+ {% endfor %} +
Choice
+ {% for t, ic, lb in [ + ('checkbox', 'bi-check-square', 'Checkbox'), + ('checkbox_group', 'bi-ui-checks', 'Checkbox Group'), + ('radio', 'bi-ui-radios', 'Radio Group'), + ('select', 'bi-menu-button-wide', 'Dropdown'), + ('pass_fail', 'bi-check2-circle', 'Pass / Fail'), + ] %} +
+ {{ lb }} +
+ {% endfor %} +
Media & Other
+ {% for t, ic, lb in [ + ('image', 'bi-image', 'Image Upload'), + ('signature', 'bi-pen', 'Signature'), + ('rating', 'bi-star', 'Rating (1–5)'), + ('section', 'bi-dash-lg', 'Section Header'), + ('table', 'bi-table', 'Table'), + ] %} +
+ {{ lb }} +
+ {% endfor %} +
Text & Actions
+ {% for t, ic, lb in [ + ('label', 'bi-type', 'Label / Text'), + ('button_submit', 'bi-send-fill', 'Submit Button'), + ('button_print', 'bi-printer-fill', 'Print Button'), + ('button_email', 'bi-envelope-fill', 'Email Button'), + ] %} +
+ {{ lb }} +
+ {% endfor %} +
+ + +
+
+
+ +

Drag fields onto the canvas

+

Fields snap to a 12-column grid.

+
+
+
+
+ + + + +
+
+ +

Select a field to edit its properties.

+
+ +
+ +
+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/templates/form_preview.html b/app/templates/templates/form_preview.html new file mode 100644 index 0000000..8bc37df --- /dev/null +++ b/app/templates/templates/form_preview.html @@ -0,0 +1,406 @@ +{% extends "base.html" %} + +{% block title %}Preview — {{ template.name }}{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+
+
+

{{ template.name }}

+
{{ template.description or 'Inspection form preview' }}
+
+ {{ template.frequency|title }} +
+ +
+
+ + This is a read-only preview. Layout matches the grid editor exactly. +
+ + {% if form_fields %} +
+ {% for field in form_fields %} +
+ + {% if field.type == 'section' %} +
{{ field.label }}
+ + {% elif field.type == 'text' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'textarea' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'number' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'date' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'email' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'checkbox' %} +
+ + +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'checkbox_group' %} + + {% for opt in field.options %} +
+ + +
+ {% endfor %} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'radio' %} + + {% for opt in field.options %} +
+ + +
+ {% endfor %} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'pass_fail' %} + + {% set pf_options = field.options if field.options else ['Pass', 'Fail'] %} +
+ {% for opt in pf_options %} + {% set is_pass = opt.lower() in ('pass','yes','ok','good') %} + {{ opt }} + {% endfor %} +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'select' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'image' %} + +
+ + Upload photo +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'signature' %} + +
Sign here…
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'rating' %} + +
+ {% for i in range(1, 6) %} + + {% endfor %} +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% elif field.type == 'label' %} + {% set fs_map = {'small':'0.78rem','normal':'0.9rem','large':'1.05rem','x-large':'1.25rem'} %} +
{{ field.text_content or 'Label text' }}
+ + {% elif field.type == 'button_submit' %} + + + {% elif field.type == 'button_print' %} + + + {% elif field.type == 'button_email' %} + + + {% elif field.type == 'table' %} + +
+ + + + {% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %} + + {% endfor %} + + + + {% for r in range(field.table_rows or 3) %} + + {% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %} + + {% endfor %} + + {% endfor %} + +
{{ hdr }}
+
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {% endif %} +
+ {% endfor %} +
+ + + + {% else %} +
+ + No fields yet +

+ This template has no form fields. Open the editor to add some. +

+ +
+ {% endif %} +
+
+{% endblock %} + +{% block extra_js %} + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/templates/item_form.html b/app/templates/templates/item_form.html new file mode 100644 index 0000000..82db9e3 --- /dev/null +++ b/app/templates/templates/item_form.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

{{ title }}

+ Template: {{ template.name }} +
+
+
+ {{ form.hidden_tag() }} + +
+
+ {{ form.category.label(class="form-label") }} + {{ form.category(class="form-control", placeholder="e.g., Restrooms, Floors, etc.") }} +
+
+ {{ form.scoring_type.label(class="form-label") }} + {{ form.scoring_type(class="form-select") }} +
+
+ +
+ {{ form.item_description.label(class="form-label") }} + {{ form.item_description(class="form-control", rows=3) }} +
+ +
+
+ {{ form.weight.label(class="form-label") }} + {{ form.weight(class="form-control") }} + 1.0 = standard weight +
+
+ +
+ {{ form.requires_photo(class="form-check-input") }} + {{ form.requires_photo.label(class="form-check-label") }} +
+
+
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/templates/list.html b/app/templates/templates/list.html new file mode 100644 index 0000000..8ae52b9 --- /dev/null +++ b/app/templates/templates/list.html @@ -0,0 +1,269 @@ +{% extends "base.html" %} + +{% block title %}Inspection Templates{% endblock %} + +{% block content %} +
+
+

Inspection Templates

+
+
+ {% if current_user.role in ['admin', 'director'] %} + + Create Template + + {% endif %} +
+
+ +
+ {% for template in templates %} +
+
+
+
+ + {{ template.name }} + + {% if template.active %} + Active + {% else %} + Inactive + {% endif %} +
+

{{ template.description or 'No description' }}

+
+ {{ template.frequency|title }} + + {{ template.checklist_items.count() }} items + +
+
+ + +
+
+ {% else %} +
+
+ No templates created yet. +
+
+ {% endfor %} +
+ +{% if current_user.role in ['admin', 'director'] %} + + + + + +{% endif %} +{% endblock %} + +{% block extra_js %} +{% if current_user.role in ['admin', 'director'] %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/templates/view.html b/app/templates/templates/view.html new file mode 100644 index 0000000..bbb5a71 --- /dev/null +++ b/app/templates/templates/view.html @@ -0,0 +1,179 @@ +{% extends "base.html" %} + +{% block title %}{{ template.name }}{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+
+

{{ template.name }}

+ {% if template.description %} +

{{ template.description }}

+ {% endif %} +
+
+ {% if current_user.role in ['admin', 'director'] %} + + Edit Form + +
+ +
+ {% endif %} + + Preview + + + Back + +
+
+ + +
+
+ + Frequency: {{ template.frequency|title }} +
+
+ + Fields: {{ form_fields|length }} +
+
+ + Inspections: {{ template.inspections.count() }} +
+ {% if template.created_at %} +
+ + Created: {{ template.created_at.strftime('%Y-%m-%d') }} +
+ {% endif %} +
+ + +
+
Form Layout
+ + {% if form_fields %} +
+ {% for f in form_fields %} +
+ {% if f.type == 'section' %} +
{{ f.label }}
+ {% elif f.type == 'table' %} +
{{ f.label }}
+ table · {{ f.col_headers|length if f.col_headers else 3 }} cols × {{ f.table_rows or 3 }} rows + {% elif f.type == 'label' %} +
{{ f.text_content or 'Label text' }}
+ label + {% elif f.type.startswith('button_') %} +
{{ f.btn_label or f.type.replace('button_','') | title }}
+ {{ f.type.replace('_',' ') }} + {% else %} +
+ {{ f.label }}{% if f.required %} *{% endif %} +
+ {{ f.type.replace('_', ' ') }} + {% endif %} +
+ {% endfor %} +
+ + {% else %} +
+ +

No fields defined yet.

+ {% if current_user.role in ['admin', 'director'] %} + + Open Form Editor + + {% endif %} +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/audit.py b/app/utils/audit.py new file mode 100644 index 0000000..4ed2453 --- /dev/null +++ b/app/utils/audit.py @@ -0,0 +1,98 @@ +""" +audit.py +-------- +Centralised helper for writing AuditLog entries. + +Usage (inside any route after db.session.commit()): + + from app.utils.audit import log_action + + log_action( + action = 'CREATE', + entity_type = 'Facility', + entity_id = facility.id, + entity_label = facility.name, + details = f'address={facility.address}', + ) + +``action`` should be one of the ACTION_* constants defined below. +``entity_type`` should match the model class name for consistency. +""" + +import logging +from flask import request +from flask_login import current_user +from app import db +from app.models.audit import AuditLog + +logger = logging.getLogger(__name__) + +# ── Canonical action constants ──────────────────────────────────────────────── +ACTION_CREATE = 'CREATE' +ACTION_UPDATE = 'UPDATE' +ACTION_DELETE = 'DELETE' +ACTION_LOGIN = 'LOGIN' +ACTION_LOGOUT = 'LOGOUT' +ACTION_EXPORT = 'EXPORT' + + +def log_action(action: str, + entity_type: str, + entity_id: int | None = None, + entity_label: str | None = None, + details: str | None = None) -> None: + """ + Write a single AuditLog row. Safe to call from any request context. + + ⚠️ This function calls db.session.commit() internally. + Always call it AFTER the primary db.session.commit() for the business + transaction — never before. Calling it mid-transaction will commit any + dirty ORM state accumulated in the session up to that point. + + Parameters + ---------- + action : One of the ACTION_* constants (or a custom string ≤ 50 chars). + entity_type : Model name — 'User', 'Facility', 'Area', 'Template', + 'Inspection', 'Issue', etc. + entity_id : Primary key of the affected record (optional). + entity_label : Human-readable name / description snapshot (optional). + details : Extra context string, e.g. 'status=open→resolved' (optional). + """ + try: + # Resolve actor — fall back gracefully if called outside request context + if current_user and current_user.is_authenticated: + uid = current_user.id + uname = current_user.username + urole = current_user.role + else: + uid, uname, urole = None, 'system', 'system' + + # Best-effort IP extraction; respects X-Forwarded-For from Nginx + ip = None + try: + ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip() + or request.remote_addr) + except RuntimeError: + pass # outside request context + + entry = AuditLog( + user_id = uid, + username = uname, + user_role = urole, + action = action[:50], + entity_type = entity_type[:50], + entity_id = entity_id, + entity_label = (entity_label or '')[:255], + details = details, + ip_address = (ip or '')[:45], + ) + db.session.add(entry) + db.session.commit() + + except Exception as exc: + # Audit logging must never break the primary request flow + logger.error('AuditLog write failed: %s', exc, exc_info=True) + try: + db.session.rollback() + except Exception: + pass \ No newline at end of file diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 0000000..da52222 --- /dev/null +++ b/app/utils/decorators.py @@ -0,0 +1,80 @@ +from functools import wraps +from flask import flash, redirect, url_for, request +from flask_login import current_user +from urllib.parse import urlparse + + +# ── Open-redirect guard ─────────────────────────────────────────────────────── + +def safe_redirect_url(url: str | None, fallback: str | None = None) -> str: + """Return *url* only if it is a safe relative URL on this host. + + Rejects any URL that carries a network location (netloc) or an explicit + scheme, preventing open-redirect attacks where a crafted link contains + next=https://evil.com. + + Parameters + ---------- + url : The candidate redirect target (may be None). + fallback : Returned when *url* is absent or unsafe. + Defaults to the dashboard index. + """ + if fallback is None: + fallback = url_for('dashboard.index') + if not url: + return fallback + parsed = urlparse(url) + if parsed.netloc or parsed.scheme: + return fallback + return url + +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role != 'admin': + flash('Administrator access required.', 'danger') + return redirect(url_for('dashboard.index')) + return f(*args, **kwargs) + return decorated_function + +def supervisor_required(f): + """Grants access to admin and director roles. + + The decorator is intentionally kept as 'supervisor_required' so that all + existing route decorators (@supervisor_required) continue to work without + any changes to the route files. The access list now reflects the renamed + Director role instead of the retired Supervisor role. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role not in ['admin', 'director']: + flash('Director access required.', 'danger') + return redirect(url_for('dashboard.index')) + return f(*args, **kwargs) + return decorated_function + +def project_manager_required(f): + """Grants access to admin, director, and project_manager roles.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role not in [ + 'admin', 'director', 'project_manager' + ]: + flash('Project Manager access required.', 'danger') + return redirect(url_for('dashboard.index')) + return f(*args, **kwargs) + return decorated_function + +def customer_required(f): + """Restricts access to customer-role users only. + + Internal staff (admin, director, inspector, project_manager) should + never be routed through customer-scoped views — use their own routes. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role != 'customer': + flash('Customer portal access required.', 'danger') + return redirect(url_for('dashboard.index')) + return f(*args, **kwargs) + return decorated_function diff --git a/app/utils/forms.py b/app/utils/forms.py new file mode 100644 index 0000000..2c51328 --- /dev/null +++ b/app/utils/forms.py @@ -0,0 +1,277 @@ +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed, MultipleFileField +from wtforms import (StringField, PasswordField, SelectField, TextAreaField, + DecimalField, BooleanField, IntegerField, HiddenField, + RadioField) +from wtforms.validators import (DataRequired, Email, Length, EqualTo, + Optional, NumberRange, ValidationError) +from app.models.user import User + + +# ── Auth ───────────────────────────────────────────────────────────────────── + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Keep me logged in') + + +class ProfileForm(FlaskForm): + """Self-service profile update form — available to all authenticated users.""" + full_name = StringField('Full Name', validators=[Optional(), Length(max=150)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)]) + current_password = PasswordField('Current Password', validators=[Optional()]) + new_password = PasswordField('New Password', validators=[Optional(), Length(min=6, max=100)]) + confirm_password = PasswordField('Confirm New Password', validators=[EqualTo('new_password', message='Passwords must match.')]) + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + + def validate_email(self, field): + q = User.query.filter_by(email=field.data).first() + if self.user and field.data != self.user.email and q: + raise ValidationError('Email already registered.') + elif not self.user and q: + raise ValidationError('Email already registered.') + + def validate_current_password(self, field): + """Require current password only when the user wants to set a new one.""" + if self.new_password.data: + if not field.data: + raise ValidationError('Please enter your current password to set a new one.') + if self.user and not self.user.check_password(field.data): + raise ValidationError('Current password is incorrect.') + + +class UserForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)]) + full_name = StringField('Full Name', validators=[Optional(), Length(max=150)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)]) + password = PasswordField('Password', validators=[Optional(), Length(min=6, max=100)]) + confirm_password = PasswordField('Confirm Password', validators=[Optional(), EqualTo('password')]) + role = SelectField('Role', choices=[ + ('admin', 'Administrator'), + ('director', 'Director'), + ('inspector', 'Inspector'), + ('project_manager', 'Project Manager'), + # 'customer' is intentionally excluded — customer accounts are managed via /customers + ], validators=[Optional()]) + # NOTE: Optional() here because directors submit no role value (the field is + # hidden in user_form.html for them). Role enforcement is handled in the + # route: directors always keep/default to 'inspector'; only admins may set + # an arbitrary role. DataRequired() would cause validate_on_submit() to + # fail silently for directors, preventing any save at all. + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + + def validate_password(self, field): + """Enforce minimum length only when a new password is actually provided.""" + if field.data and len(field.data) < 6: + raise ValidationError('Password must be at least 6 characters.') + + def validate_confirm_password(self, field): + """Require confirmation to match only when a new password is provided.""" + if self.password.data and field.data != self.password.data: + raise ValidationError('Passwords must match.') + + def validate_username(self, field): + q = User.query.filter_by(username=field.data).first() + if self.user: + if field.data != self.user.username and q: + raise ValidationError('Username already exists.') + elif q: + raise ValidationError('Username already exists.') + + def validate_email(self, field): + q = User.query.filter_by(email=field.data).first() + if self.user: + if field.data != self.user.email and q: + raise ValidationError('Email already registered.') + elif q: + raise ValidationError('Email already registered.') + + +# ── Facility / Area ────────────────────────────────────────────────────────── + +class FacilityForm(FlaskForm): + name = StringField('Facility Name', validators=[DataRequired(), Length(max=255)]) + address = TextAreaField('Address', validators=[Optional()]) + contact_person = StringField('Contact Person', validators=[Optional(), Length(max=100)]) + contact_phone = StringField('Contact Phone', validators=[Optional(), Length(max=20)]) + project_id = SelectField('Contract', coerce=int, validators=[Optional()]) + active = BooleanField('Active', default=True) + + +class AreaForm(FlaskForm): + name = StringField('Area Name', validators=[DataRequired(), Length(max=255)]) + area_type = SelectField('Area Type', choices=[ + ('restroom','Restroom'), ('lobby','Lobby'), ('hallway','Hallway'), + ('office','Office'), ('kitchen','Kitchen'), ('storage','Storage'), + ('floor','Floor'), + ('outdoor','Outdoor'), ('other','Other'), + ], validators=[Optional()]) + facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()]) + + +# ── Templates ──────────────────────────────────────────────────────────────── + +class InspectionTemplateForm(FlaskForm): + name = StringField('Template Name', validators=[DataRequired(), Length(max=255)]) + description = TextAreaField('Description', validators=[Optional()]) + frequency = SelectField('Inspection Frequency', choices=[ + ('daily','Daily'), ('weekly','Weekly'), + ('monthly','Monthly'), ('quarterly','Quarterly'), + ], validators=[DataRequired()]) + + +class ChecklistItemForm(FlaskForm): + category = StringField('Category', validators=[DataRequired(), Length(max=100)]) + item_description = TextAreaField('Item Description', validators=[DataRequired()]) + scoring_type = SelectField('Scoring Type', choices=[ + ('pass_fail','Pass/Fail'), ('rating_5','5-Point Rating'), ('rating_10','10-Point Rating'), + ], validators=[DataRequired()]) + weight = DecimalField('Weight', validators=[Optional(), NumberRange(min=0.1, max=10.0)], default=1.00) + requires_photo = BooleanField('Requires Photo Evidence', default=False) + display_order = IntegerField('Display Order', validators=[Optional()], default=0) + + +# ── Inspections ────────────────────────────────────────────────────────────── + +class StartInspectionForm(FlaskForm): + template_id = SelectField('Template', coerce=int, validators=[DataRequired()]) + project_id = SelectField('Contract', coerce=int, validators=[DataRequired()]) + facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()]) + area_id = SelectField('Area', coerce=int, validators=[Optional()]) + + +class ChecklistResultForm(FlaskForm): + """Dynamically rendered per checklist item — base validators only.""" + score = DecimalField('Score', validators=[Optional(), NumberRange(min=0, max=10)]) + passed = HiddenField('Passed') # 'true' / 'false' / '' + comments = TextAreaField('Comments', validators=[Optional(), Length(max=1000)]) + photo = FileField('Photo', validators=[ + Optional(), + FileAllowed(['jpg','jpeg','png','gif'], 'Images only.') + ]) + + +# ── Issues ─────────────────────────────────────────────────────────────────── + +class IssueForm(FlaskForm): + facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()]) + severity = SelectField('Severity', choices=[ + ('low','Low'), ('medium','Medium'), ('high','High'), ('critical','Critical'), + ], validators=[DataRequired()]) + description = TextAreaField('Description', validators=[DataRequired(), Length(max=2000)]) + photo = FileField('Photo Evidence', validators=[ + Optional(), + FileAllowed(['jpg','jpeg','png','gif'], 'Images only.') + ]) + assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()]) + + +class IssueUpdateForm(FlaskForm): + status = SelectField('Status', choices=[ + ('open','Open'), ('in_progress','In Progress'), + ('pending_verification','Pending Verification'), ('resolved','Resolved'), + ], validators=[DataRequired()]) + assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()]) + update_notes = TextAreaField('Update Notes', validators=[Optional(), Length(max=1000)]) + result_notes = TextAreaField('Result Notes', validators=[Optional(), Length(max=2000)]) + result_photos = MultipleFileField('Result Photos', validators=[ + Optional(), + FileAllowed(['jpg','jpeg','png','gif'], 'Images only.') + ]) + # External contractor / vendor fields (phase26) + vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)]) + vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)]) + vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)]) + +# ── Projects ───────────────────────────────────────────────────────────────── + +class ProjectForm(FlaskForm): + name = StringField('Contract Name', validators=[DataRequired(), Length(max=255)]) + description = TextAreaField('Description', validators=[Optional()]) + project_manager_id = SelectField('Project Manager', coerce=int, validators=[Optional()]) + active = BooleanField('Active', default=True) + + +class CustomerAssignmentForm(FlaskForm): + user_id = SelectField('Customer User', coerce=int, validators=[DataRequired()]) + facility_id = SelectField('Facility Scope', coerce=int, validators=[Optional()]) + + +class CustomerUserForm(FlaskForm): + """Create / edit a customer-role user account. + Used exclusively in the Customer Management UI. + Password is required on create; optional on edit. + """ + username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)]) + full_name = StringField('Full Name', validators=[Optional(), Length(max=150)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)]) + password = PasswordField('Password', validators=[Optional(), Length(min=8)]) + confirm_password = PasswordField('Confirm Password', + validators=[Optional(), EqualTo('password', + message='Passwords must match.')]) + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self._user = user # existing user instance (edit mode) or None (create mode) + + def validate_username(self, field): + from app.models.user import User + existing = User.query.filter_by(username=field.data).first() + if existing and (self._user is None or existing.id != self._user.id): + raise ValidationError('Username already in use.') + + def validate_email(self, field): + from app.models.user import User + existing = User.query.filter_by(email=field.data).first() + if existing and (self._user is None or existing.id != self._user.id): + raise ValidationError('Email address already in use.') + + def validate_password(self, field): + """Password is required when creating a new account.""" + if self._user is None and not field.data: + raise ValidationError('Password is required for new accounts.') + +class CustomerInviteForm(FlaskForm): + """Simplified form for creating a customer account via email invitation. + + Admin enters Full Name and Email only. A username is auto-generated + from the email address. The customer sets their own username and + password via the emailed link. + """ + full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)]) + + def validate_email(self, field): + if User.query.filter_by(email=field.data.strip().lower()).first(): + raise ValidationError('An account with this email address already exists.') + + +class ForgotPasswordForm(FlaskForm): + email = StringField('Email Address', validators=[DataRequired(), Email(), Length(max=255)]) + + +class ResetPasswordForm(FlaskForm): + password = PasswordField('New Password', validators=[DataRequired(), Length(min=8, max=100)]) + confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), + EqualTo('password', message='Passwords must match.')]) + + +class SetPasswordForm(FlaskForm): + """Public form for customer to choose their username and password via emailed link.""" + username = StringField('Choose a Username', validators=[DataRequired(), Length(min=3, max=100)]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=100)]) + confirm_password = PasswordField('Confirm Password', + validators=[DataRequired(), + EqualTo('password', message='Passwords must match.')]) + + def validate_username(self, field): + existing = User.query.filter_by(username=field.data.strip()).first() + if existing: + raise ValidationError('This username is already taken. Please choose another.') \ No newline at end of file diff --git a/app/utils/notifications.py b/app/utils/notifications.py new file mode 100644 index 0000000..f5d5e73 --- /dev/null +++ b/app/utils/notifications.py @@ -0,0 +1,650 @@ +""" +app/utils/notifications.py +~~~~~~~~~~~~~~~~~~~~~~~~~~ +Central helper for creating in-app notifications and dispatching email alerts. + +Usage +----- + from app.utils.notifications import notify + + notify( + recipient = some_user, + title = 'Issue #12 Updated', + body = 'Status changed to In Progress by admin.', + link = url_for('issues.view', issue_id=12), + issue_id = 12, + event_type = EVENT_ISSUE_STATUS, # controls preference lookup + send_email = True, + ) + +Email delivery is best-effort: a failure to send will be logged but will NOT +raise an exception or roll back the DB transaction. + +Digest emails are sent by calling send_pending_digests(frequency) from the +/notifications/send-digest route, which is triggered by a server cron job. +""" + +import logging, threading +from flask import current_app, render_template_string +from flask_mail import Message +from app import db, mail +from app.models.notification import ( + Notification, NotificationPreference, + ALL_EVENT_TYPES, +) + +logger = logging.getLogger(__name__) + + +# ── Email templates ──────────────────────────────────────────────────────────── + +_EMAIL_HTML_SINGLE = """\ + + + +

{{ title }}

+

{{ body }}

+ {% if link %} +

+ + View Details + +

+ {% endif %} +
+

+ Janitorial QC System — automated notification. Do not reply to this email.
+ + Manage notification preferences + +

+ + +""" + +_EMAIL_TEXT_SINGLE = """\ +{{ title }} + +{{ body }} +{% if link %} +View: {{ base_url }}{{ link }} +{% endif %} + +-- +Janitorial QC System — automated notification. +Manage preferences: {{ base_url }}/notifications/preferences +""" + +_EMAIL_HTML_DIGEST = """\ + + + +

Your {{ frequency|title }} JQC Notification Digest

+

You have {{ notifications|length }} new notification(s):

+
+ {% for n in notifications %} +
+

{{ n.title }}

+

{{ n.body }}

+ {% if n.link %} + + View Details → + + {% endif %} +

+ {{ n.created_at.strftime('%b %d, %Y %I:%M %p') }} +

+
+ {% endfor %} +
+

+ Janitorial QC System — automated digest. Do not reply to this email.
+ + Manage notification preferences + +

+ + +""" + +_EMAIL_TEXT_DIGEST = """\ +Your {{ frequency|title }} JQC Notification Digest +{{ notifications|length }} new notification(s): + +{% for n in notifications %} +--- +{{ n.title }} +{{ n.body }} +{% if n.link %}View: {{ base_url }}{{ n.link }}{% endif %} +{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }} +{% endfor %} + +-- +Janitorial QC System — automated digest. +Manage preferences: {{ base_url }}/notifications/preferences +""" + + +# ── Preference helpers ───────────────────────────────────────────────────────── + +def _get_preference(user_id, event_type): + """Return the NotificationPreference for a user+event, or None if not set.""" + if not event_type: + return None + return NotificationPreference.query.filter_by( + user_id=user_id, event_type=event_type + ).first() + + +def _email_enabled_for(user, event_type): + """Return True if the user wants an immediate email for this event type.""" + pref = _get_preference(user.id, event_type) + if pref is None: + return True # Default: email on, immediate + if not pref.email_enabled: + return False # User opted out of email entirely for this event + if pref.digest_mode: + return False # User prefers digest — suppress immediate email + return True + + +def _digest_mode_for(user, event_type): + """Return True if this notification should be held for digest delivery.""" + pref = _get_preference(user.id, event_type) + if pref is None: + return False + return pref.email_enabled and pref.digest_mode + + +# ── Core notify function ─────────────────────────────────────────────────────── + +def notify( + recipient, + title: str, + body: str, + link: str = None, + issue_id: int = None, + inspection_id: int = None, + event_type: str = None, + send_email: bool = True, + respect_preferences: bool = True, +): + """Create an in-app Notification record and optionally send an email. + + Parameters + ---------- + recipient : User ORM instance + title : Short notification headline + body : Full notification message + link : Relative URL for the 'View Details' button/link + issue_id : FK to issues.id (optional) + inspection_id : FK to inspections.id (optional) + event_type : One of the EVENT_* constants from models.notification + Used to look up the user's preference for this event. + send_email : Master switch — set False to suppress all email (overrides prefs) + respect_preferences : When True (default), per-user email preferences gate delivery. + Set False for matrix-routed broadcasts — the matrix is the + authority; individual opt-out should not override admin config. + """ + # Determine digest flag before creating the record. + # Digest mode is only respected when individual preferences are in effect. + hold_for_digest = ( + respect_preferences + and send_email + and bool(event_type) + and _digest_mode_for(recipient, event_type) + ) + + # ── 1. Persist in-app notification ────────────────────────────────────── + notif = Notification( + user_id = recipient.id, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + event_type = event_type, + is_read = False, + digest_pending = hold_for_digest, + ) + db.session.add(notif) + # NOTE: Caller is responsible for db.session.commit() + + logger.info( + 'NOTIFICATION CREATED | user=%s | event=%s | title=%s | digest=%s', + recipient.username, event_type, title, hold_for_digest, + ) + + # ── 2. Send immediate email if applicable ──────────────────────────────── + if not send_email: + logger.info('EMAIL SKIP | user=%s | event=%s | reason=send_email_False', + recipient.username, event_type) + elif hold_for_digest: + logger.info('EMAIL SKIP | user=%s | event=%s | reason=digest_mode', + recipient.username, event_type) + else: + if respect_preferences: + pref_enabled = _email_enabled_for(recipient, event_type) + should_send = (event_type is None or pref_enabled) + if not should_send: + logger.info('EMAIL SKIP | user=%s | event=%s | reason=user_pref_disabled', + recipient.username, event_type) + else: + should_send = True + + mail_server = current_app.config.get('MAIL_SERVER') + if should_send and not recipient.email: + logger.warning('EMAIL SKIP | user=%s | event=%s | reason=no_email_address', + recipient.username, event_type) + elif should_send and not mail_server: + logger.warning('EMAIL SKIP | user=%s | event=%s | reason=MAIL_SERVER_not_configured', + recipient.username, event_type) + elif should_send: + logger.info('EMAIL SEND | user=%s | event=%s | to=%s', + recipient.username, event_type, recipient.email) + _send_single_email(recipient, title, body, link) + + +def _send_single_email(recipient, title, body, link): + """Dispatch a single immediate notification email in a background thread. + + Sending is offloaded to a daemon thread so SMTP latency never blocks the + HTTP response. The Flask application context is pushed explicitly so that + Flask-Mail and config lookups work outside the request context. + """ + # Render templates while still inside the request context + try: + base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') + sender = current_app.config.get( + 'MAIL_DEFAULT_SENDER', + current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'), + ) + html_body = render_template_string( + _EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url, + ) + text_body = render_template_string( + _EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url, + ) + msg = Message( + subject = f'[JQC] {title}', + sender = sender, + recipients = [recipient.email], + body = text_body, + html = html_body, + ) + except Exception as exc: + logger.error('NOTIFICATION EMAIL BUILD FAILED | to=%s | error=%s', recipient.email, exc) + return + + # Capture app instance before leaving the request context + app = current_app._get_current_object() + recipient_email = recipient.email + subject = msg.subject + + def _send(): + with app.app_context(): + try: + mail.send(msg) + logger.info( + 'NOTIFICATION EMAIL SENT | to=%s | subject=%s', + recipient_email, subject, + ) + except Exception as exc: + logger.error( + 'NOTIFICATION EMAIL FAILED | to=%s | error=%s', + recipient_email, exc, + ) + + t = threading.Thread(target=_send, daemon=True) + t.start() + + +# ── Digest delivery ──────────────────────────────────────────────────────────── + + +# ── Customer portal notifications ───────────────────────────────────────────── + +def notify_customers_for_facility( + facility_id: int, + event_type: str, + title: str, + body: str, + link: str = None, + issue_id: int = None, + inspection_id: int = None, +): + """Dispatch in-app + email notifications to all customer users assigned + to the given facility. + + Resolves assignments via CustomerAssignment rows: + - facility-scoped assignment (facility_id matches exactly) + - project-scoped assignment (facility belongs to the project, no facility_id set) + + Respects each customer's NotificationPreference for the supplied event_type. + Best-effort: a failure on one recipient does not block others. + + Parameters + ---------- + facility_id : The facility where the event occurred. + event_type : EVENT_CUSTOMER_INSPECTION_DONE or EVENT_CUSTOMER_ISSUE_UPDATED. + title : Short notification headline. + body : Full notification message. + link : Relative URL for 'View Details'. + issue_id : FK to issues.id (optional). + inspection_id : FK to inspections.id (optional). + """ + try: + from app.models.project import CustomerAssignment + from app.models.facility import Facility + from app.models.user import User + + facility = db.session.get(Facility, facility_id) + if not facility: + logger.warning( + 'notify_customers_for_facility | facility_id=%s not found', facility_id + ) + return + + # Collect distinct customer user IDs that have access to this facility + notified_user_ids = set() + + # 1. Direct facility-scoped assignments + direct = CustomerAssignment.query.filter_by(facility_id=facility_id).all() + for a in direct: + notified_user_ids.add(a.user_id) + + # 2. Project-scoped assignments (no facility_id) — if facility belongs to a project + if facility.project_id: + project_wide = CustomerAssignment.query.filter_by( + project_id=facility.project_id, + facility_id=None, + ).all() + for a in project_wide: + notified_user_ids.add(a.user_id) + + if not notified_user_ids: + logger.debug( + 'notify_customers_for_facility | facility_id=%s | no customer assignments found', + facility_id, + ) + return + + for user_id in notified_user_ids: + user = db.session.get(User, user_id) + if not user or not user.active or user.role != 'customer': + continue + try: + notify( + recipient = user, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + event_type = event_type, + send_email = True, + ) + logger.info( + 'CUSTOMER NOTIFY | user=%s | facility_id=%s | event=%s', + user.username, facility_id, event_type, + ) + except Exception as exc: + logger.error( + 'CUSTOMER NOTIFY FAILED | user=%s | facility_id=%s | event=%s | error=%s', + user_id, facility_id, event_type, exc, + ) + + except Exception as exc: + logger.error( + 'notify_customers_for_facility | unexpected error | facility_id=%s | error=%s', + facility_id, exc, + ) + +def send_pending_digests(frequency: str = 'daily'): + """Send digest emails for all users who have pending digest notifications. + + Called from the /notifications/send-digest route, which is hit by cron. + + Parameters + ---------- + frequency : 'hourly' or 'daily' — matches digest_frequency in preferences + """ + if not current_app.config.get('MAIL_SERVER'): + logger.warning('DIGEST SKIPPED | MAIL_SERVER not configured') + return 0 + + # Find all users with pending digest notifications + from app.models.user import User + pending_user_ids = ( + db.session.query(Notification.user_id) + .filter_by(digest_pending=True) + .distinct() + .all() + ) + pending_user_ids = [row[0] for row in pending_user_ids] + + sent_count = 0 + for user_id in pending_user_ids: + user = db.session.get(User, user_id) + if not user or not user.email: + continue + + # Collect only the notifications that match this frequency for this user + # A notification is included in a frequency's digest if at least one of + # the user's digest preferences matches that frequency. + # Simple approach: include all pending if user has any pref with this frequency. + has_freq_pref = NotificationPreference.query.filter_by( + user_id=user_id, + digest_mode=True, + digest_frequency=frequency, + email_enabled=True, + ).first() + + if not has_freq_pref: + continue + + notifications = Notification.query.filter_by( + user_id=user_id, + digest_pending=True, + ).order_by(Notification.created_at.asc()).all() + + if not notifications: + continue + + try: + base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') + sender = current_app.config.get( + 'MAIL_DEFAULT_SENDER', + current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'), + ) + html_body = render_template_string( + _EMAIL_HTML_DIGEST, + notifications=notifications, + frequency=frequency, + base_url=base_url, + ) + text_body = render_template_string( + _EMAIL_TEXT_DIGEST, + notifications=notifications, + frequency=frequency, + base_url=base_url, + ) + msg = Message( + subject = f'[JQC] Your {frequency.title()} Notification Digest ' + f'({len(notifications)} update{"s" if len(notifications) != 1 else ""})', + sender = sender, + recipients = [user.email], + body = text_body, + html = html_body, + ) + mail.send(msg) + + # Clear the pending flag on all notifications just sent + for n in notifications: + n.digest_pending = False + db.session.commit() + + sent_count += 1 + logger.info( + 'DIGEST EMAIL SENT | to=%s | frequency=%s | count=%s', + user.email, frequency, len(notifications), + ) + except Exception as exc: + logger.error( + 'DIGEST EMAIL FAILED | to=%s | frequency=%s | error=%s', + user.email, frequency, exc, + ) + + return sent_count + +# ── Matrix-driven broadcast helpers ─────────────────────────────────────────── + +def notify_by_matrix( + event_type: str, + title: str, + body: str, + link: str = None, + issue_id: int = None, + inspection_id: int = None, + facility_id: int = None, + exclude_user_ids: set = None, +): + """ + Dispatch in-app + email notifications for a broadcast event according + to the admin-configured notification matrix. + + For each enabled role in the matrix, all active users with that role + are notified (optionally scoped to facility via CustomerAssignment for + the 'customer' role). Custom email addresses are sent a plain email + without creating an in-app Notification record. + + Parameters + ---------- + event_type : One of the MATRIX_EVENTS keys from notification_matrix. + title : Short notification headline. + body : Full notification body. + link : Relative URL for 'View Details'. + issue_id : FK to issues.id (optional). + inspection_id : FK to inspections.id (optional). + facility_id : Used to scope 'customer' role to assigned facility. + exclude_user_ids : Set of user IDs to skip (e.g. the actor themselves). + """ + from app.models.notification_matrix import ( + is_enabled, get_custom_emails_for, MATRIX_ROLES, + ) + from app.models.user import User + + exclude = set(exclude_user_ids or []) + notified = set() # deduplicate across roles + + role_to_db = { + 'admin': 'admin', + 'director': 'director', + 'inspector': 'inspector', + 'project_manager': 'project_manager', + 'customer': 'customer', + } + + logger.info('MATRIX NOTIFY START | event=%s | exclude=%s', event_type, exclude) + + for role_key, _ in MATRIX_ROLES: + if role_key == 'custom': + continue # handled separately below + enabled = is_enabled(event_type, role_key) + logger.info('MATRIX NOTIFY | event=%s | role=%s | enabled=%s', + event_type, role_key, enabled) + if not enabled: + continue + + db_role = role_to_db.get(role_key) + if not db_role: + continue + + users = User.query.filter_by(role=db_role, active=True).all() + logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s', + event_type, role_key, [u.username for u in users]) + + # Scope customer role to facility if provided + if role_key == 'customer' and facility_id: + from app.utils.notifications import notify_customers_for_facility + notify_customers_for_facility( + facility_id = facility_id, + event_type = event_type, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + ) + continue # notify_customers_for_facility handles dedup internally + + for user in users: + if user.id in exclude or user.id in notified: + continue + notify( + recipient = user, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + event_type = event_type, + send_email = True, + respect_preferences = False, # matrix is the authority for broadcasts + ) + notified.add(user.id) + + # ── Custom email recipients ─────────────────────────────────────────── + custom_emails = get_custom_emails_for(event_type) + for email in custom_emails: + _send_custom_email(email, title, body, link) + + logger.info( + 'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s', + event_type, len(notified), len(custom_emails), + ) + + +def _send_custom_email(to_email: str, title: str, body: str, link: str = None): + """Send a plain email to a custom (non-user) address. Best-effort.""" + try: + base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') + sender = current_app.config.get( + 'MAIL_DEFAULT_SENDER', + current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'), + ) + html_body = render_template_string( + _EMAIL_HTML_SINGLE, title=title, body=body, + link=link, base_url=base_url, + ) + text_body = render_template_string( + _EMAIL_TEXT_SINGLE, title=title, body=body, + link=link, base_url=base_url, + ) + msg = Message( + subject = f'[JQC] {title}', + sender = sender, + recipients = [to_email], + body = text_body, + html = html_body, + ) + except Exception as exc: + logger.error('CUSTOM EMAIL BUILD FAILED | to=%s | error=%s', to_email, exc) + return + + app = current_app._get_current_object() + + def _send(): + with app.app_context(): + try: + mail.send(msg) + logger.info('CUSTOM EMAIL SENT | to=%s', to_email) + except Exception as exc: + logger.error('CUSTOM EMAIL FAILED | to=%s | error=%s', to_email, exc) + + import threading + threading.Thread(target=_send, daemon=True).start() \ No newline at end of file diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py new file mode 100644 index 0000000..2533af2 --- /dev/null +++ b/app/utils/pdf_export.py @@ -0,0 +1,1549 @@ +""" +pdf_export.py +------------- +Generates a professional PDF report for a completed inspection. + +Uses ReportLab Platypus (flow-based layout) so the document repaginates +cleanly regardless of how many form fields or issues exist. + +Public API +---------- + generate_inspection_pdf(inspection, form_fields, form_data, issues, + static_folder) -> bytes +""" + +import io +import os +import json +from datetime import datetime + +try: + from PIL import Image as PILImage + _PIL_AVAILABLE = True +except ImportError: + _PIL_AVAILABLE = False + +from reportlab.lib.pagesizes import letter, landscape +from reportlab.lib import colors +from reportlab.lib.units import inch +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, + HRFlowable, KeepTogether, Image as RLImage +) + +# ── Colour palette (matches the web UI) ────────────────────────────────────── +C_DARK = colors.HexColor('#1a1d23') +C_BLUE = colors.HexColor('#2563eb') +C_SLATE = colors.HexColor('#64748b') +C_LIGHT = colors.HexColor('#f1f5f9') +C_BORDER = colors.HexColor('#e2e8f0') +C_GREEN = colors.HexColor('#16a34a') +C_YELLOW = colors.HexColor('#d97706') +C_RED = colors.HexColor('#dc2626') +C_WHITE = colors.white + +SEVERITY_COLORS = { + 'critical': C_RED, + 'high': C_RED, + 'medium': C_YELLOW, + 'low': C_SLATE, +} + +STATUS_COLORS = { + 'completed': C_GREEN, + 'flagged': C_RED, + 'in_progress': C_YELLOW, +} + + +# ── Style sheet ─────────────────────────────────────────────────────────────── + +def _build_styles(): + base = getSampleStyleSheet() + + def add(name, parent='Normal', **kw): + base.add(ParagraphStyle(name=name, parent=base[parent], **kw)) + + add('ReportTitle', parent='Normal', + fontSize=18, textColor=C_WHITE, fontName='Helvetica-Bold', + spaceAfter=2) + add('ReportSub', parent='Normal', + fontSize=9, textColor=colors.HexColor('#94a3b8'), + fontName='Helvetica', spaceAfter=0) + add('SectionHead', parent='Normal', + fontSize=10, textColor=C_DARK, fontName='Helvetica-Bold', + spaceBefore=8, spaceAfter=4) + add('FieldLabel', parent='Normal', + fontSize=7.5, textColor=C_SLATE, fontName='Helvetica', + spaceAfter=1) + add('FieldValue', parent='Normal', + fontSize=8.5, textColor=C_DARK, fontName='Helvetica', + spaceAfter=2) + add('MetaLabel', parent='Normal', + fontSize=7, textColor=C_SLATE, fontName='Helvetica', + spaceAfter=0) + add('MetaValue', parent='Normal', + fontSize=9, textColor=C_DARK, fontName='Helvetica-Bold', + spaceAfter=0) + add('IssueDesc', parent='Normal', + fontSize=8, textColor=C_DARK, fontName='Helvetica', + spaceAfter=2) + add('FooterStyle', parent='Normal', + fontSize=7, textColor=C_SLATE, fontName='Helvetica', + alignment=TA_CENTER) + # ── Summary PDF styles ──────────────────────────────────────────────── + add('SummaryTitle', parent='Normal', + fontSize=18, textColor=C_DARK, fontName='Helvetica-Bold', spaceAfter=2) + add('ReportSubtitle', parent='Normal', + fontSize=11, textColor=C_SLATE, fontName='Helvetica', spaceAfter=2) + add('Meta', parent='Normal', + fontSize=8, textColor=C_SLATE, fontName='Helvetica', spaceAfter=1) + add('ScoreValue', parent='Normal', + fontSize=22, textColor=C_BLUE, fontName='Helvetica-Bold', + alignment=TA_CENTER, spaceAfter=0) + add('ScoreLabel', parent='Normal', + fontSize=7.5, textColor=C_SLATE, fontName='Helvetica', + alignment=TA_CENTER, spaceAfter=0) + add('SectionHeader', parent='Normal', + fontSize=10, textColor=C_DARK, fontName='Helvetica-Bold', + spaceBefore=8, spaceAfter=4) + add('TableHeader', parent='Normal', + fontSize=8.5, textColor=C_WHITE, fontName='Helvetica-Bold', + alignment=TA_CENTER, spaceAfter=0) + add('TableCell', parent='Normal', + fontSize=8.5, textColor=C_DARK, fontName='Helvetica', spaceAfter=0) + + return base + + +STYLES = _build_styles() + + +# ── Image compression helper ────────────────────────────────────────────────── + +# Max pixel dimension for any side of an image embedded in the PDF. +# Phone cameras produce 12–48 MP images; 800px is plenty for a printed report. +_IMG_MAX_PX = 800 +# JPEG quality for re-encoded images (0–95). 55 is visually acceptable for +# a printed report and reduces a typical phone photo by ~90% vs the original. +_IMG_JPEG_QUALITY = 55 + + +def _compress_image(src_path: str) -> io.BytesIO | None: + """ + Load an image from disk, resize it to fit within _IMG_MAX_PX on any side, + re-encode as JPEG at _IMG_JPEG_QUALITY, and return a BytesIO buffer. + + Returns None if PIL is unavailable or the image cannot be processed, + signalling the caller to fall back to the original file path. + """ + if not _PIL_AVAILABLE: + return None + try: + from PIL import ImageOps + img = PILImage.open(src_path) + + # Apply EXIF orientation tag so rotated phone photos appear upright. + img = ImageOps.exif_transpose(img) + + # Strip transparency — JPEG does not support alpha channels. + # Convert palette / RGBA / LA → RGB before saving. + if img.mode in ('RGBA', 'LA', 'P'): + background = PILImage.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # Resize proportionally so neither dimension exceeds _IMG_MAX_PX. + img.thumbnail((_IMG_MAX_PX, _IMG_MAX_PX), PILImage.LANCZOS) + + buf = io.BytesIO() + img.save(buf, format='JPEG', quality=_IMG_JPEG_QUALITY, optimize=True) + buf.seek(0) + return buf + except Exception: + return None + + +# ── Header / footer callbacks ───────────────────────────────────────────────── + +def _on_page(canvas, doc, title, generated_at): + """Draw page header band and footer on every page.""" + w, h = letter + margin = 0.65 * inch + + # ── Dark header band ── + canvas.saveState() + canvas.setFillColor(C_DARK) + canvas.rect(0, h - 1.1 * inch, w, 1.1 * inch, stroke=0, fill=1) + + canvas.setFont('Helvetica-Bold', 13) + canvas.setFillColor(C_WHITE) + canvas.drawString(margin, h - 0.55 * inch, title) + + canvas.setFont('Helvetica', 8) + canvas.setFillColor(colors.HexColor('#94a3b8')) + canvas.drawString(margin, h - 0.78 * inch, 'Janitorial Quality Control System') + + # Page number — right-aligned + page_txt = f'Page {doc.page}' + canvas.setFont('Helvetica', 8) + canvas.setFillColor(colors.HexColor('#94a3b8')) + canvas.drawRightString(w - margin, h - 0.66 * inch, page_txt) + canvas.restoreState() + + # ── Footer ── + canvas.saveState() + canvas.setStrokeColor(C_BORDER) + canvas.line(margin, 0.55 * inch, w - margin, 0.55 * inch) + canvas.setFont('Helvetica', 7) + canvas.setFillColor(C_SLATE) + canvas.drawString(margin, 0.35 * inch, + f'Generated: {generated_at} | Janitorial QC System') + canvas.drawRightString(w - margin, 0.35 * inch, 'CONFIDENTIAL') + canvas.restoreState() + + +# ── Score badge helper ──────────────────────────────────────────────────────── + +def _score_color(score): + if score is None: + return C_SLATE + s = float(score) + if s >= 90: + return C_GREEN + if s >= 70: + return C_YELLOW + return C_RED + + +# ── Meta info table ─────────────────────────────────────────────────────────── + +def _meta_table(inspection): + """Render a 6-column label/value grid covering the key inspection metadata.""" + start_date = inspection.inspection_date.strftime('%B %d, %Y %I:%M %p ET') + completed = (inspection.completed_at.strftime('%B %d, %Y %I:%M %p ET') + if inspection.completed_at else '—') + score_val = (f'{float(inspection.overall_score):.1f}%' + if inspection.overall_score is not None else '—') + status_txt = inspection.status.replace('_', ' ').title() + area_txt = inspection.area.name if inspection.area else '—' + + def lbl(text): + return Paragraph(text, STYLES['MetaLabel']) + + def val(text): + return Paragraph(str(text), STYLES['MetaValue']) + + # Each row: alternating label / value columns (6 cols total) + rows = [ + [lbl('INSPECTOR'), val(inspection.inspector.username), + lbl('START DATE'), val(start_date), + lbl('COMPLETED DATE'), val(completed)], + [lbl('FACILITY'), val(inspection.facility.name), + lbl('AREA'), val(area_txt), + lbl('STATUS / SCORE'), val(f'{status_txt} {score_val}')], + [lbl('TEMPLATE'), val(inspection.template.name), + lbl('FREQUENCY'), val(inspection.template.frequency.title()), + lbl(''), val('')], + ] + + col_w = (letter[0] - 1.3 * inch) / 6 + tbl = Table(rows, colWidths=[col_w] * 6) + tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), C_LIGHT), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 8), + ('RIGHTPADDING', (0, 0), (-1, -1), 8), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING',(0, 0), (-1, -1), 5), + # Label rows get a lighter text treatment (already in STYLES['MetaLabel']) + ('TEXTCOLOR', (0, 0), (-1, -1), C_DARK), + ])) + return tbl + + +# ── Score banner ────────────────────────────────────────────────────────────── + +def _score_banner(inspection): + score = inspection.overall_score + score_txt = f'{float(score):.1f}%' if score is not None else 'N/A' + sc = _score_color(score) + + grade = 'PASS' if (score is not None and float(score) >= 70) else 'FAIL' + grade_color = C_GREEN if grade == 'PASS' else C_RED + + data = [[ + Paragraph(f'Overall Score', + ParagraphStyle('x', fontName='Helvetica-Bold', fontSize=9, + textColor=C_WHITE, alignment=TA_CENTER)), + Paragraph(f'{score_txt}', + ParagraphStyle('x2', fontName='Helvetica-Bold', fontSize=22, + textColor=C_WHITE, alignment=TA_CENTER)), + Paragraph(f'{grade}', + ParagraphStyle('x3', fontName='Helvetica-Bold', fontSize=14, + textColor=C_WHITE, alignment=TA_CENTER)), + ]] + + w = letter[0] - 1.3 * inch + tbl = Table(data, colWidths=[w * 0.35, w * 0.35, w * 0.30]) + tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (1, 0), sc), + ('BACKGROUND', (2, 0), (2, 0), grade_color), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING',(0, 0), (-1, -1), 10), + ('LEFTPADDING', (0, 0), (-1, -1), 8), + ('RIGHTPADDING', (0, 0), (-1, -1), 8), + ])) + return tbl + + +# ── Form fields section ─────────────────────────────────────────────────────── + +def _star_string(score, max_stars=5): + """Return a star string using only the filled ★ glyph. + Hollow ☆ is not supported by Helvetica and renders as a box. + Unselected stars are rendered as grey ★ via ReportLab XML markup. + Returns a plain string for canvas use; use _star_markup() for Paragraph. + """ + filled = min(int(score), max_stars) + return '★' * filled + '★' * (max_stars - filled) + + +_SKIP_TYPES = {'button_submit', 'button_print', 'button_email'} +_GRID_COLS = 12 # matches the web UI 12-column grid + + +def _form_fields_section(form_fields, form_data, static_folder): + """ + Reconstruct the web UI grid layout in PDF form. + + Fields are grouped by their grid row number. Within each row the fields + are placed side-by-side with column widths proportional to their colSpan. + Section headings (type='section') are rendered as full-width banners + between groups of rows, exactly as they appear in the web UI. + """ + story = [] + story.append(Paragraph('Inspection Results', STYLES['SectionHead'])) + story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER, spaceAfter=6)) + + FULL_W = letter[0] - 1.3 * inch # usable page width + UNIT = FULL_W / _GRID_COLS # width of one grid column unit + + # ── Group fields by row number, preserving original order ──────────────── + from collections import defaultdict, OrderedDict + rows_map = OrderedDict() # row_num -> [field, ...] + sections = {} # row_num -> section label that precedes this row + + pending_section = None + for field in form_fields: + ftype = field.get('type', '') + if ftype in ('button_submit', 'button_print', 'button_email'): + continue + if ftype == 'section': + pending_section = field.get('label', '') + continue + + row_num = field.get('row', 0) + if row_num not in rows_map: + rows_map[row_num] = [] + if pending_section is not None: + sections[row_num] = pending_section + pending_section = None + + rows_map[row_num].append(field) + + # ── Pre-compute which rows have at least one visible field ─────────────── + # A row is visible if it contains a text/textarea/label field, OR any other + # field type that has a non-empty value. This is used to suppress section + # banners whose every subordinate row is empty. + _ALWAYS_SHOW_PRE = {'label'} + + def _row_has_content(fields_in_row): + for f in fields_in_row: + ft = f.get('type', '') + fid = str(f.get('id', '')) + v = form_data.get(fid, '') + if ft in _ALWAYS_SHOW_PRE: + return True + if ft == 'rating' and str(v).isdigit() and int(v) > 0: return True + if ft == 'pass_fail' and v: return True + if ft == 'checkbox' and v in ('yes','true'): return True + if ft == 'checkbox_group'and isinstance(v, list) and v: return True + if ft == 'image' and v and os.path.exists(os.path.join(static_folder, v)): return True + if ft == 'signature' and v and str(v).startswith('data:'): return True + if ft == 'table' and isinstance(v, list) and v: return True + if ft in ('number','date','email','radio','select') and v: return True + if ft in ('text','textarea') and v: return True + return False + + # Build a set of section-trigger row numbers that have visible content somewhere + # in their subordinate rows (from their row_num up to the next section's row_num). + section_row_nums = sorted(sections.keys()) + all_row_nums = list(rows_map.keys()) + + def _section_has_visible_rows(sec_row_num): + idx = section_row_nums.index(sec_row_num) + next_sec = section_row_nums[idx + 1] if idx + 1 < len(section_row_nums) else None + for rn in all_row_nums: + if rn < sec_row_num: + continue + if next_sec is not None and rn >= next_sec: + break + if _row_has_content(rows_map[rn]): + return True + return False + + # ── Render row by row ───────────────────────────────────────────────────── + for row_num, fields_in_row in rows_map.items(): + + # Emit section banner only if its subordinate rows have visible content + if row_num in sections: + if not _section_has_visible_rows(row_num): + continue # skip the banner — all rows beneath it are empty + story.append(Spacer(1, 6)) + sec_label = sections[row_num] + story.append(Paragraph( + sec_label, + ParagraphStyle('SecBanner', fontName='Helvetica-Bold', + fontSize=10, textColor=C_DARK, + spaceBefore=8, spaceAfter=3), + )) + story.append(HRFlowable(width='100%', thickness=0.75, + color=C_BORDER, spaceAfter=4)) + + # Build a fixed 12-column table for this grid row. + # Each of the 12 grid columns gets exactly UNIT width. + # Fields occupy their designated columns via SPAN directives — + # this is the ReportLab equivalent of CSS grid-column. + # Unanswered fields are simply left as empty cells; no placeholder + # bookkeeping is needed because the table always has all 12 columns. + NCOLS = _GRID_COLS # 12 + cells_12 = [Paragraph('', STYLES['FieldValue']) for _ in range(NCOLS)] + spans = [] # SPAN TableStyle directives + has_content = False + + for field in fields_in_row: + ftype = field.get('type', '') + col = max(1, int(field.get('col', 1))) # 1-indexed + col_span = max(1, int(field.get('colSpan', 1))) + ci = col - 1 # 0-indexed start + ci_end = min(ci + col_span - 1, NCOLS - 1) # 0-indexed end + cell_w = UNIT * col_span + + if col_span > 1: + spans.append(('SPAN', (ci, 0), (ci_end, 0))) + + # ── Inline label (free-standing text element) ───────────────────── + if ftype == 'label': + fs_map = {'small': 8, 'normal': 9, 'large': 11, 'x-large': 13} + fs = fs_map.get(field.get('font_size', 'normal'), 9) + fw = 'Helvetica-Bold' if field.get('font_weight') == 'bold' else 'Helvetica' + cells_12[ci] = Paragraph( + field.get('text_content', ''), + ParagraphStyle('li', fontName=fw, fontSize=fs, + textColor=C_DARK, leading=fs + 3), + ) + has_content = True + continue + + fid = str(field.get('id', '')) + val = form_data.get(fid, '') + lbl = field.get('label', '') + + # ── Skip fields with no meaningful value — leave cell empty ──────── + def _skip(ft, v): + if ft == 'rating': + return not str(v).isdigit() or int(v) == 0 + if ft == 'image': + ip = os.path.join(static_folder, v) if v else '' + return not v or not os.path.exists(ip) + if ft == 'signature': + return not v or not str(v).startswith('data:') + if ft == 'checkbox': + return v not in ('yes', 'true') + if ft == 'pass_fail': + return not v + if ft in ('checkbox_group', 'table'): + return not v or not isinstance(v, list) or len(v) == 0 + return not v + + if _skip(ftype, val): + continue # cell stays empty; SPAN ensures correct column width + + lbl_p = Paragraph(lbl, STYLES['FieldLabel']) + + # ── Value content ──────────────────────────────────────────────── + if ftype == 'rating': + score_int = int(val) + filled_stars = '' + ('★' * score_int) + '' + empty_stars = ('' + ('★' * (5 - score_int)) + '' + if score_int < 5 else '') + stars = filled_stars + empty_stars + f' {score_int}/5' + val_p = Paragraph(stars, ParagraphStyle( + 'rv', fontName='Helvetica', fontSize=9, leading=12)) + + elif ftype == 'image': + img_path = os.path.join(static_folder, val) + try: + compressed = _compress_image(img_path) + img_src = compressed if compressed is not None else img_path + val_p = RLImage(img_src, + width=min(cell_w - 12, 1.4 * inch), + height=1.0 * inch, + kind='proportional') + except Exception: + continue # leave cell empty + + elif ftype == 'signature': + val_p = Paragraph('[Signature captured]', STYLES['FieldValue']) + + elif ftype == 'checkbox': + val_p = Paragraph('Yes', STYLES['FieldValue']) + + elif ftype == 'pass_fail': + is_pass = str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant') + colour = C_GREEN if is_pass else C_RED + val_p = Paragraph( + f'{str(val)}', + STYLES['FieldValue'], + ) + + elif ftype == 'checkbox_group': + val_p = Paragraph(', '.join(val), STYLES['FieldValue']) + + elif ftype == 'table': + headers = field.get('col_headers', + list(val[0].keys()) if val else []) + tbl_data = ([headers] + + [[r.get(h, '') for h in headers] for r in val]) + val_p = Table(tbl_data) + val_p.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_LIGHT), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 7), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('TOPPADDING', (0, 0), (-1, -1), 2), + ('BOTTOMPADDING', (0, 0), (-1, -1), 2), + ])) + + else: + disp = str(val) if val else '' + val_p = Paragraph(disp, STYLES['FieldValue']) + + # ── Wrap into label-over-value form box ────────────────────────── + box = Table([[lbl_p], [val_p]], colWidths=[cell_w - 4]) + box.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 5), + ('RIGHTPADDING', (0, 0), (-1, -1), 5), + ('TOPPADDING', (0, 0), (0, 0), 2), + ('BOTTOMPADDING',(0, 0), (0, 0), 1), + ('TOPPADDING', (0, 1), (0, 1), 3), + ('BOTTOMPADDING',(0, 1), (0, 1), 4), + ('BOX', (0, 1), (0, 1), 0.5, C_BORDER), + ('BACKGROUND', (0, 1), (0, 1), colors.HexColor('#f8fafc')), + ])) + + cells_12[ci] = box + has_content = True + + # Skip the entire row if no field had meaningful content + if not has_content: + continue + + row_tbl = Table([cells_12], colWidths=[UNIT] * NCOLS, hAlign='LEFT') + row_tbl.setStyle(TableStyle( + [ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 2), + ('RIGHTPADDING', (0, 0), (-1, -1), 2), + ('TOPPADDING', (0, 0), (-1, -1), 2), + ('BOTTOMPADDING',(0, 0), (-1, -1), 2), + ] + spans + )) + story.append(row_tbl) + + return story + + +# ── Issues section ──────────────────────────────────────────────────────────── + +def _issues_section(issues): + if not issues: + return [] + + story = [ + Spacer(1, 10), + Paragraph('Flagged Issues', STYLES['SectionHead']), + HRFlowable(width='100%', thickness=1, color=C_RED, spaceAfter=6), + ] + + headers = ['#', 'Severity', 'Area', 'Description', 'Status'] + col_w = [0.3 * inch, 0.7 * inch, 1.1 * inch, 3.5 * inch, 0.9 * inch] + + rows = [headers] + for i, issue in enumerate(issues, 1): + desc = issue.description[:120] + ('…' if len(issue.description) > 120 else '') + rows.append([ + str(i), + issue.severity.title(), + issue.area.name if issue.area else (issue.resolved_facility.name if issue.resolved_facility else '—'), + desc, + issue.status.replace('_', ' ').title(), + ]) + + tbl = Table(rows, colWidths=col_w, repeatRows=1) + sev_styles = [] + for r, issue in enumerate(issues, 1): + sc = SEVERITY_COLORS.get(issue.severity, C_SLATE) + sev_styles.append(('TEXTCOLOR', (1, r), (1, r), sc)) + sev_styles.append(('FONTNAME', (1, r), (1, r), 'Helvetica-Bold')) + + tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_DARK), + ('TEXTCOLOR', (0, 0), (-1, 0), C_WHITE), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('ROWBACKGROUNDS',(0, 1), (-1, -1), [C_WHITE, C_LIGHT]), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING',(0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 5), + ('RIGHTPADDING', (0, 0), (-1, -1), 5), + *sev_styles, + ])) + story.append(tbl) + return story + + +# ── Notes section ───────────────────────────────────────────────────────────── + +def _notes_section(inspection): + notes_text = None + if inspection.notes: + try: + parsed = json.loads(inspection.notes) + if isinstance(parsed, dict): + notes_text = parsed.get('_inspector_notes') or parsed.get('notes') + except (json.JSONDecodeError, TypeError): + notes_text = inspection.notes + + if not notes_text: + return [] + + return [ + Spacer(1, 10), + Paragraph('Inspector Notes', STYLES['SectionHead']), + HRFlowable(width='100%', thickness=1, color=C_BORDER, spaceAfter=6), + Paragraph(str(notes_text), + ParagraphStyle('Notes', fontName='Helvetica', fontSize=8.5, + textColor=C_DARK, leading=13, + backColor=colors.HexColor('#fffbeb'), + borderPad=6, spaceAfter=6)), + ] + + +# ── Public entry point ──────────────────────────────────────────────────────── + +def generate_inspection_pdf(inspection, form_fields, form_data, issues, + static_folder) -> bytes: + """ + Build and return a PDF byte-string for the given inspection. + + Parameters + ---------- + inspection : Inspection model instance + form_fields : list of field dicts from template.get_form_schema() + form_data : dict of {field_id: value} + issues : list of Issue model instances + static_folder: absolute path to app/static (for resolving photo paths) + """ + buf = io.BytesIO() + generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET') + report_title = f'Inspection Report — {inspection.template.name}' + + doc = SimpleDocTemplate( + buf, + pagesize=letter, + leftMargin=0.65 * inch, + rightMargin=0.65 * inch, + topMargin=1.25 * inch, # leave room for the header band + bottomMargin=0.75 * inch, + title=report_title, + author='Janitorial QC System', + ) + + def _page_cb(canvas, doc): + _on_page(canvas, doc, report_title, generated_at) + + story = [] + + # ── Score banner ── + story.append(_score_banner(inspection)) + story.append(Spacer(1, 8)) + + # ── Meta table ── + story.append(_meta_table(inspection)) + story.append(Spacer(1, 12)) + + # ── Form fields ── + story.extend(_form_fields_section(form_fields, form_data, static_folder)) + + # ── Inspector notes ── + story.extend(_notes_section(inspection)) + + # ── Issues ── + story.extend(_issues_section(issues)) + + # ── Signature line ── + story.append(Spacer(1, 24)) + w = letter[0] - 1.3 * inch + sig_data = [['Inspector Signature', '', 'Date']] + sig_tbl = Table(sig_data, colWidths=[w * 0.45, w * 0.1, w * 0.45]) + sig_tbl.setStyle(TableStyle([ + ('LINEABOVE', (0, 0), (0, 0), 0.75, C_DARK), + ('LINEABOVE', (2, 0), (2, 0), 0.75, C_DARK), + ('FONTNAME', (0, 0), (-1, -1), 'Helvetica'), + ('FONTSIZE', (0, 0), (-1, -1), 8), + ('TEXTCOLOR', (0, 0), (-1, -1), C_SLATE), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING',(0, 0), (-1, -1), 0), + ])) + story.append(sig_tbl) + + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + +# ══════════════════════════════════════════════════════════════════════════════ +# ISSUE PDF +# ══════════════════════════════════════════════════════════════════════════════ + +def generate_issue_pdf(issue, static_folder: str) -> bytes: + """Return a PDF byte-string for a single Issue. + + Parameters + ---------- + issue : Issue model instance (relationships pre-loaded by caller) + static_folder : absolute path to app/static (for resolving photo paths) + """ + buf = io.BytesIO() + generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET') + report_title = f'Issue Report — Issue #{issue.id}' + + doc = SimpleDocTemplate( + buf, + pagesize=letter, + leftMargin=0.65 * inch, + rightMargin=0.65 * inch, + topMargin=1.25 * inch, + bottomMargin=0.75 * inch, + title=report_title, + author='Janitorial QC System', + ) + + def _page_cb(canvas, doc): + _on_page(canvas, doc, report_title, generated_at) + + pw = letter[0] - 1.3 * inch # usable page width + + # ── Severity / status banner ────────────────────────────────────────────── + sev = issue.severity or 'low' + sev_color = SEVERITY_COLORS.get(sev, C_SLATE) + stat_txt = (issue.status or '').replace('_', ' ').title() + + banner_data = [[ + Paragraph(f'{sev.upper()}', + ParagraphStyle('b1', fontName='Helvetica-Bold', fontSize=10, + textColor=C_WHITE, alignment=TA_CENTER)), + Paragraph(f'Issue #{issue.id}', + ParagraphStyle('b2', fontName='Helvetica-Bold', fontSize=16, + textColor=C_WHITE, alignment=TA_CENTER)), + Paragraph(f'{stat_txt}', + ParagraphStyle('b3', fontName='Helvetica-Bold', fontSize=10, + textColor=C_WHITE, alignment=TA_CENTER)), + ]] + banner = Table(banner_data, colWidths=[pw * 0.20, pw * 0.55, pw * 0.25]) + banner.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (0, 0), sev_color), + ('BACKGROUND', (1, 0), (1, 0), C_DARK), + ('BACKGROUND', (2, 0), (2, 0), C_SLATE), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 10), + ('LEFTPADDING', (0, 0), (-1, -1), 8), + ('RIGHTPADDING', (0, 0), (-1, -1), 8), + ])) + + # ── Details grid ───────────────────────────────────────────────────────── + def lbl(t): + return Paragraph(t, STYLES['MetaLabel']) + + def val(t): + return Paragraph(str(t) if t else '—', STYLES['MetaValue']) + + facility_name = issue.resolved_facility.name if issue.resolved_facility else '—' + contract_name = (issue.resolved_facility.project.name + if issue.resolved_facility and issue.resolved_facility.project else '—') + area_name = issue.area.name if issue.area else '—' + reporter_name = issue.reporter.display_name if issue.reporter else '—' + assigned_name = issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' + reported_str = issue.reported_at.strftime('%b %d, %Y %I:%M %p') if issue.reported_at else '—' + resolved_str = issue.resolved_at.strftime('%b %d, %Y %I:%M %p') if issue.resolved_at else '—' + + cw = pw / 4 + detail_rows = [ + [lbl('CONTRACT'), val(contract_name), lbl('FACILITY'), val(facility_name)], + [lbl('AREA'), val(area_name), lbl('REPORTED BY'), val(reporter_name)], + [lbl('ASSIGNED TO'), val(assigned_name), lbl('REPORTED'), val(reported_str)], + [lbl('STATUS'), val(stat_txt), lbl('RESOLVED'), val(resolved_str)], + ] + if issue.inspection_id: + detail_rows.append([lbl('INSPECTION'), val(f'#{issue.inspection_id}'), + lbl(''), val('')]) + + detail_tbl = Table(detail_rows, colWidths=[cw] * 4) + detail_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), C_LIGHT), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 8), + ('RIGHTPADDING', (0, 0), (-1, -1), 8), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ])) + + # ── Section header helper ───────────────────────────────────────────────── + def _section(title, color=C_BORDER): + return [ + Spacer(1, 10), + Paragraph(title, STYLES['SectionHead']), + HRFlowable(width='100%', thickness=1, color=color, spaceAfter=6), + ] + + # ── Photo grid helper ───────────────────────────────────────────────────── + def _photo_grid(paths, max_w=2.4 * inch, max_h=2.0 * inch, cols=3): + valid = [] + for p in paths: + if not p: + continue + abs_p = os.path.join(static_folder, p) + if os.path.exists(abs_p): + valid.append(abs_p) + if not valid: + return [Paragraph('(photos not found on disk)', STYLES['FieldValue'])] + + cells = [] + for abs_p in valid: + try: + compressed = _compress_image(abs_p) + src = compressed if compressed is not None else abs_p + cells.append(RLImage(src, width=max_w, height=max_h, kind='proportional')) + except Exception: + cells.append(Paragraph('(unreadable)', STYLES['FieldValue'])) + + while len(cells) % cols != 0: + cells.append(Paragraph('', STYLES['FieldValue'])) + + rows = [cells[i:i + cols] for i in range(0, len(cells), cols)] + tbl = Table(rows, colWidths=[max_w + 6] * cols) + tbl.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ])) + return [tbl] + + # ── Assemble story ──────────────────────────────────────────────────────── + story = [] + + story.append(banner) + story.append(Spacer(1, 10)) + story.append(detail_tbl) + + # Description + story.extend(_section('Description')) + story.append(Paragraph( + issue.description or '—', + ParagraphStyle('Desc', fontName='Helvetica', fontSize=9, + textColor=C_DARK, leading=14, + backColor=colors.HexColor('#f8fafc'), + borderPad=8, spaceAfter=6), + )) + + # Photo Evidence + evidence_paths = [] + if issue.photo_path: + evidence_paths.append(issue.photo_path) + evidence_paths.extend(issue.mobile_photo_paths or []) + + if evidence_paths: + story.extend(_section('Photo Evidence')) + story.extend(_photo_grid(evidence_paths)) + + # Resolution Details + if issue.result_notes or issue.result_photos: + story.extend(_section('Resolution Details', C_GREEN)) + if issue.result_notes: + story.append(Paragraph( + issue.result_notes, + ParagraphStyle('Res', fontName='Helvetica', fontSize=9, + textColor=C_DARK, leading=14, + backColor=colors.HexColor('#f0fdf4'), + borderPad=8, spaceAfter=6), + )) + if issue.result_photos: + story.extend(_photo_grid(issue.result_photos)) + + # Verification + if issue.verified_at: + story.extend(_section('Verification', C_GREEN)) + verifier_name = issue.verifier.display_name if issue.verifier else '—' + verified_str = issue.verified_at.strftime('%b %d, %Y %I:%M %p') + v_rows = [ + [lbl('VERIFIED BY'), val(verifier_name), lbl('VERIFIED ON'), val(verified_str)], + ] + if issue.verification_note: + v_rows.append([lbl('NOTE'), + Paragraph(issue.verification_note, STYLES['MetaValue']), + lbl(''), val('')]) + v_tbl = Table(v_rows, colWidths=[cw] * 4) + v_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f0fdf4')), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 8), + ('RIGHTPADDING', (0, 0), (-1, -1), 8), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ])) + story.append(v_tbl) + + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + +# ══════════════════════════════════════════════════════════════════════════════ +# SCHEDULED REPORT PDF +# ══════════════════════════════════════════════════════════════════════════════ + +def generate_scheduled_report_pdf(report_name, frequency, start, end, + facility_name=None, data=None): + """Generate a PDF summary for a scheduled report email attachment. + + Parameters + ---------- + report_name : str — the ScheduledReport.name + frequency : str — 'daily' / 'weekly' / 'monthly' + start, end : datetime — the reporting window + facility_name : str | None — scoped facility name, or None for all + data : dict — the assembled report data from _build_report_data() + + Returns + ------- + bytes — the PDF content + """ + if data is None: + data = {} + + buf = io.BytesIO() + doc = SimpleDocTemplate( + buf, pagesize=letter, + leftMargin=0.65 * inch, rightMargin=0.65 * inch, + topMargin=1.0 * inch, bottomMargin=0.65 * inch, + ) + + generated_at = datetime.now().strftime('%Y-%m-%d %H:%M') + title_text = f'{frequency.title()} Report — {report_name}' + + def _page_cb(canvas, doc): + _on_page(canvas, doc, title_text, generated_at) + + story = [] + pw = letter[0] - 1.3 * inch # usable page width + + # ── Sub-header ──────────────────────────────────────────────────────── + period = f'{start.strftime("%b %d, %Y")} — {end.strftime("%b %d, %Y")}' + scope = f'Facility: {facility_name}' if facility_name else 'All Facilities' + story.append(Paragraph(f'{period} · {scope}', STYLES['ReportSub'])) + story.append(Spacer(1, 12)) + + # ── KPI cards (summary / facility report types) ─────────────────────── + total_insp = data.get('total_inspections', 0) + completed = data.get('completed', 0) + open_iss = data.get('open_issues', 0) + avg_score = data.get('avg_score') + + kpi_data = [[ + Paragraph('Inspections', STYLES['FieldLabel']), + Paragraph('Completed', STYLES['FieldLabel']), + Paragraph('Open Issues', STYLES['FieldLabel']), + Paragraph('Avg Score', STYLES['FieldLabel']), + ], [ + Paragraph(f'{total_insp}', STYLES['FieldValue']), + Paragraph(f'{completed}', STYLES['FieldValue']), + Paragraph(f'{open_iss}', STYLES['FieldValue']), + Paragraph( + f'{f"{avg_score:.1f}%" if avg_score else "—"}', + STYLES['FieldValue'], + ), + ]] + kpi_tbl = Table(kpi_data, colWidths=[pw * 0.25] * 4) + kpi_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), C_LIGHT), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('ROUNDEDCORNERS', [6, 6, 6, 6]), + ])) + story.append(kpi_tbl) + story.append(Spacer(1, 16)) + + # ── Facility scores table ───────────────────────────────────────────── + fac_scores = data.get('facility_scores', []) + if fac_scores: + story.append(Paragraph('Facility Scores', STYLES['SectionHead'])) + tbl_data = [['Facility', 'Inspections', 'Avg Score']] + for row in fac_scores: + sc = float(row.avg) if hasattr(row, 'avg') else float(row[1]) + cnt = row.count if hasattr(row, 'count') else row[2] + nm = row.name if hasattr(row, 'name') else row[0] + sc_color = C_GREEN if sc >= 90 else C_YELLOW if sc >= 70 else C_RED + tbl_data.append([ + Paragraph(str(nm), STYLES['FieldValue']), + Paragraph(str(cnt), STYLES['FieldValue']), + Paragraph(f'{sc:.1f}%', STYLES['FieldValue']), + ]) + fac_tbl = Table(tbl_data, colWidths=[pw * 0.50, pw * 0.25, pw * 0.25]) + fac_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_LIGHT), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8), + ('ALIGN', (1, 0), (-1, -1), 'CENTER'), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ])) + story.append(fac_tbl) + story.append(Spacer(1, 14)) + + # ── Critical / High issues ──────────────────────────────────────────── + crit_issues = data.get('critical_issues', []) + if crit_issues: + story.append(Paragraph('Open Critical / High Issues', STYLES['SectionHead'])) + tbl_data = [['#', 'Severity', 'Facility / Area', 'Description', 'Reported']] + for iss in crit_issues: + sev_c = SEVERITY_COLORS.get(iss.severity, C_SLATE) + tbl_data.append([ + Paragraph(f'#{iss.id}', STYLES['FieldValue']), + Paragraph(f'{iss.severity.title()}', STYLES['FieldValue']), + Paragraph(f'{iss.area.facility.name} / {iss.area.name}', STYLES['FieldValue']), + Paragraph(iss.description[:80] + ('…' if len(iss.description) > 80 else ''), STYLES['IssueDesc']), + Paragraph(iss.reported_at.strftime('%b %d'), STYLES['FieldValue']), + ]) + iss_tbl = Table(tbl_data, colWidths=[pw * 0.07, pw * 0.12, pw * 0.25, pw * 0.40, pw * 0.16]) + iss_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#fef2f2')), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 7.5), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('TOPPADDING', (0, 0), (-1, -1), 3), + ('BOTTOMPADDING', (0, 0), (-1, -1), 3), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ])) + story.append(iss_tbl) + story.append(Spacer(1, 14)) + + # ── Open issues list (issues report type) ───────────────────────────── + all_issues = data.get('issues', []) + if all_issues and not crit_issues: + story.append(Paragraph(f'Open Issues ({len(all_issues)})', STYLES['SectionHead'])) + tbl_data = [['#', 'Severity', 'Facility / Area', 'Status', 'Description']] + for iss in all_issues: + sev_c = SEVERITY_COLORS.get(iss.severity, C_SLATE) + tbl_data.append([ + Paragraph(f'#{iss.id}', STYLES['FieldValue']), + Paragraph(f'{iss.severity.title()}', STYLES['FieldValue']), + Paragraph(f'{iss.area.facility.name} / {iss.area.name}', STYLES['FieldValue']), + Paragraph(iss.status.replace('_', ' ').title(), STYLES['FieldValue']), + Paragraph(iss.description[:70] + ('…' if len(iss.description) > 70 else ''), STYLES['IssueDesc']), + ]) + iss_tbl = Table(tbl_data, colWidths=[pw * 0.07, pw * 0.12, pw * 0.25, pw * 0.16, pw * 0.40]) + iss_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_LIGHT), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 7.5), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('TOPPADDING', (0, 0), (-1, -1), 3), + ('BOTTOMPADDING', (0, 0), (-1, -1), 3), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ])) + story.append(iss_tbl) + + # ── Footer note ─────────────────────────────────────────────────────── + story.append(Spacer(1, 20)) + story.append(HRFlowable(width='100%', thickness=0.5, color=C_BORDER)) + story.append(Spacer(1, 6)) + story.append(Paragraph( + 'Janitorial QC System — automated scheduled report', + STYLES['FooterStyle'], + )) + + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + +# ══════════════════════════════════════════════════════════════════════════════ +# ISSUES LIST PDF +# ══════════════════════════════════════════════════════════════════════════════ + +def generate_issues_list_pdf(issues, filter_summary: str = '') -> bytes: + """Return a PDF byte-string for a filtered list of issues. + + Parameters + ---------- + issues : list of Issue model instances + filter_summary : human-readable string describing active filters (optional) + """ + buf = io.BytesIO() + generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET') + report_title = 'Issues List' + + page_size = landscape(letter) + doc = SimpleDocTemplate( + buf, + pagesize=page_size, + leftMargin=0.65 * inch, + rightMargin=0.65 * inch, + topMargin=1.1 * inch, + bottomMargin=0.75 * inch, + title=report_title, + author='Janitorial QC System', + ) + + def _page_cb(canvas, doc): + _on_page(canvas, doc, report_title, generated_at) + + pw = page_size[0] - 1.3 * inch # usable page width (~9.7 in) + + story = [] + + if filter_summary: + story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub'])) + story.append(Paragraph(f'Total records: {len(issues)}', STYLES['ReportSub'])) + story.append(Spacer(1, 10)) + + if not issues: + story.append(Paragraph('No issues match the selected filters.', STYLES['FieldValue'])) + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + # ── Column widths ───────────────────────────────────────────────────────── + # #, Reported, Severity, Contract, Facility / Area, Description, Status, SLA, Assigned + col_w = [ + pw * 0.05, # # + pw * 0.10, # Reported + pw * 0.08, # Severity + pw * 0.13, # Contract + pw * 0.14, # Facility / Area + pw * 0.28, # Description + pw * 0.10, # Status + pw * 0.07, # SLA + pw * 0.05, # Assigned (truncated) + ] + + hdr_style = ParagraphStyle('ILH', fontName='Helvetica-Bold', fontSize=7.5, + textColor=C_WHITE, leading=9) + val_style = ParagraphStyle('ILV', fontName='Helvetica', fontSize=7.5, + textColor=C_DARK, leading=9) + + def _h(text): + return Paragraph(text, hdr_style) + + def _v(text, color=None): + if color: + return Paragraph(f'{text}', val_style) + return Paragraph(text, val_style) + + tbl_data = [[ + _h('#'), _h('Reported'), _h('Severity'), _h('Contract'), + _h('Facility / Area'), _h('Description'), _h('Status'), _h('SLA'), _h('Assigned'), + ]] + + for iss in issues: + reported_str = iss.reported_at.strftime('%Y-%m-%d %H:%M') if iss.reported_at else '—' + + sev = iss.severity or 'low' + sev_color = SEVERITY_COLORS.get(sev, C_SLATE) + + contract_str = '—' + if iss.resolved_facility and iss.resolved_facility.project: + contract_str = iss.resolved_facility.project.name + facility_str = iss.resolved_facility.name if iss.resolved_facility else '—' + area_str = iss.area.name if iss.area else '—' + fac_area_str = f'{facility_str}\n{area_str}' + + desc_str = iss.description or '' + if len(desc_str) > 90: + desc_str = desc_str[:90] + '...' + + status_raw = iss.status or '' + if status_raw == 'resolved': + status_str = 'Resolved' + status_color = C_GREEN + elif status_raw == 'pending_verification': + status_str = 'Pending Verif.' + status_color = colors.HexColor('#0ea5e9') + elif status_raw == 'in_progress': + status_str = 'In Progress' + status_color = C_YELLOW + else: + status_str = 'Open' + status_color = C_RED + + # Compute SLA inline (avoids circular import — same logic as sla.sla_status) + from app.utils.sla import sla_status as _sla_status + sla = _sla_status(iss) + if sla == 'breached': + sla_str, sla_color = 'Breached', C_RED + elif sla == 'at_risk': + sla_str, sla_color = 'At Risk', C_YELLOW + elif sla == 'ok': + sla_str, sla_color = 'OK', C_GREEN + else: + sla_str, sla_color = '—', C_SLATE + + assigned_str = '—' + if iss.assigned_user: + n = iss.assigned_user.display_name + assigned_str = n[:18] + ('...' if len(n) > 18 else '') + + tbl_data.append([ + _v(f'#{iss.id}'), + _v(reported_str), + _v(sev.title(), color=sev_color), + _v(contract_str[:28] + ('...' if len(contract_str) > 28 else '')), + Paragraph(f'{facility_str[:28]}\n{area_str[:24]}', val_style), + _v(desc_str), + _v(status_str, color=status_color), + _v(sla_str, color=sla_color), + _v(assigned_str), + ]) + + tbl = Table(tbl_data, colWidths=col_w, repeatRows=1) + tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_DARK), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ])) + story.append(tbl) + + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + +# ══════════════════════════════════════════════════════════════════════════════ +# INSPECTIONS LIST PDF +# ══════════════════════════════════════════════════════════════════════════════ + +def generate_inspections_list_pdf(inspections, filter_summary: str = '') -> bytes: + """Return a PDF byte-string for a filtered list of inspections. + + Parameters + ---------- + inspections : list of Inspection model instances + filter_summary : human-readable string describing active filters (optional) + """ + buf = io.BytesIO() + generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET') + report_title = 'Inspections List' + + page_size = landscape(letter) + doc = SimpleDocTemplate( + buf, + pagesize=page_size, + leftMargin=0.65 * inch, + rightMargin=0.65 * inch, + topMargin=1.1 * inch, + bottomMargin=0.75 * inch, + title=report_title, + author='Janitorial QC System', + ) + + def _page_cb(canvas, doc): + _on_page(canvas, doc, report_title, generated_at) + + pw = page_size[0] - 1.3 * inch # usable page width (~9.7 in) + + story = [] + + # ── Filter summary line ─────────────────────────────────────────────────── + if filter_summary: + story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub'])) + story.append(Paragraph(f'Total records: {len(inspections)}', STYLES['ReportSub'])) + story.append(Spacer(1, 10)) + + if not inspections: + story.append(Paragraph('No inspections match the selected filters.', STYLES['FieldValue'])) + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + # ── Column widths (total = pw) ──────────────────────────────────────────── + # Date, Contract, Facility, Area, Template, Inspector, Score, Status + col_w = [ + pw * 0.11, # Date + pw * 0.16, # Contract + pw * 0.17, # Facility + pw * 0.10, # Area + pw * 0.16, # Template + pw * 0.12, # Inspector + pw * 0.08, # Score + pw * 0.10, # Status + ] + + # ── Table header ────────────────────────────────────────────────────────── + hdr_style = ParagraphStyle('LH', fontName='Helvetica-Bold', fontSize=7.5, + textColor=C_WHITE, leading=9) + val_style = ParagraphStyle('LV', fontName='Helvetica', fontSize=7.5, + textColor=C_DARK, leading=9) + + def _h(text): + return Paragraph(text, hdr_style) + + def _v(text, color=None): + if color: + return Paragraph(f'{text}', val_style) + return Paragraph(text, val_style) + + tbl_data = [[ + _h('Date'), _h('Contract'), _h('Facility'), _h('Area'), + _h('Template'), _h('Inspector'), _h('Score'), _h('Status'), + ]] + + for ins in inspections: + date_str = ins.inspection_date.strftime('%Y-%m-%d %H:%M') + contract_str = (ins.facility.project.name + if ins.facility and ins.facility.project else '—') + facility_str = ins.facility.name if ins.facility else '—' + area_str = ins.area.name if ins.area else '—' + template_str = ins.template.name if ins.template else '—' + inspector_str = ins.inspector.display_name if ins.inspector else '—' + + if ins.overall_score is not None: + sc = float(ins.overall_score) + score_str = f'{sc:.1f}%' + score_color = C_GREEN if sc >= 90 else C_YELLOW if sc >= 70 else C_RED + else: + score_str = '—' + score_color = C_SLATE + + status_raw = ins.status or '' + if status_raw == 'completed': + status_str = 'Submitted' + status_color = C_GREEN + elif status_raw == 'flagged': + status_str = 'Flagged' + status_color = C_RED + else: + status_str = status_raw.replace('_', ' ').title() + status_color = C_SLATE + + follow_up_suffix = ' (Follow-up)' if ins.follow_up_required else '' + + tbl_data.append([ + _v(date_str), + _v(contract_str[:30] + ('...' if len(contract_str) > 30 else '')), + _v(facility_str[:35] + ('...' if len(facility_str) > 35 else '')), + _v(area_str[:20] + ('...' if len(area_str) > 20 else '')), + _v(template_str[:30] + ('...' if len(template_str) > 30 else '')), + _v(inspector_str[:22] + ('...' if len(inspector_str) > 22 else '')), + _v(score_str, color=score_color), + _v(status_str + follow_up_suffix, color=status_color), + ]) + + tbl = Table(tbl_data, colWidths=col_w, repeatRows=1) + tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_DARK), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ])) + story.append(tbl) + + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + +# ── Facility Customer Summary PDF ───────────────────────────────────────────── + +def generate_facility_summary_pdf(facility, days, start, now, + total_inspections, avg_score, + area_scores, open_issues, resolved_count): + """Customer-facing one-page PDF summary for a facility.""" + styles = _build_styles() + buf = io.BytesIO() + doc = SimpleDocTemplate( + buf, pagesize=letter, + leftMargin=0.75 * inch, rightMargin=0.75 * inch, + topMargin=0.75 * inch, bottomMargin=0.75 * inch, + ) + page_w = letter[0] - 1.5 * inch + story = [] + + story.append(Paragraph(facility.name, styles['SummaryTitle'])) + story.append(Paragraph('Facility Performance Summary', styles['ReportSubtitle'])) + story.append(Paragraph( + 'Period: {} to {} ({} days)'.format( + start.strftime('%b %d, %Y'), now.strftime('%b %d, %Y'), days), + styles['Meta'], + )) + if getattr(facility, 'address', None): + story.append(Paragraph(facility.address, styles['Meta'])) + story.append(Spacer(1, 0.15 * inch)) + story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER)) + story.append(Spacer(1, 0.15 * inch)) + + score_str = '{}%'.format(avg_score) if avg_score is not None else '—' + score_color = (C_GREEN if avg_score and avg_score >= 80 + else C_YELLOW if avg_score and avg_score >= 60 else C_RED) + + def _kpi(label, value, col=C_BLUE): + hex_str = '%06x' % (col.hexval() & 0xFFFFFF) + return [Paragraph('' + '{}'.format(hex_str, value), + styles['ScoreValue']), + Paragraph(label, styles['ScoreLabel'])] + + kpi_tbl = Table([[ + _kpi('Inspections Completed', str(total_inspections)), + _kpi('Avg Score', score_str, score_color), + _kpi('Open Issues', str(len(open_issues)), + C_RED if open_issues else C_GREEN), + _kpi('Resolved This Period', str(resolved_count), C_GREEN), + ]], colWidths=[page_w / 4] * 4) + kpi_tbl.setStyle(TableStyle([ + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('BACKGROUND', (0, 0), (-1, -1), C_LIGHT), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(kpi_tbl) + story.append(Spacer(1, 0.2 * inch)) + + _P = lambda t: Paragraph(t, styles['TableCell']) + _H = lambda t: Paragraph('{}'.format(t), styles['TableHeader']) + + if area_scores: + story.append(Paragraph('Score by Area', styles['SectionHeader'])) + story.append(Spacer(1, 0.05 * inch)) + tbl_data = [[_H('Area'), _H('Avg Score'), _H('Inspections')]] + for a in area_scores: + avg = round(float(a.avg), 1) + col = C_GREEN if avg >= 80 else C_YELLOW if avg >= 60 else C_RED + hex_str = '%06x' % (col.hexval() & 0xFFFFFF) + tbl_data.append([ + _P(a.name), + Paragraph('' + '{}%'.format(hex_str, avg), + styles['TableCell']), + _P(str(a.count)), + ]) + area_tbl = Table(tbl_data, + colWidths=[page_w * 0.55, page_w * 0.25, page_w * 0.20], + repeatRows=1) + area_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_DARK), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('ALIGN', (1, 0), (-1, -1), 'CENTER'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 6), + ('RIGHTPADDING', (0, 0), (-1, -1), 6), + ])) + story.append(area_tbl) + story.append(Spacer(1, 0.2 * inch)) + + story.append(Paragraph('Open Issues', styles['SectionHeader'])) + story.append(Spacer(1, 0.05 * inch)) + if open_issues: + tbl_data = [[_H('Severity'), _H('Area'), _H('Description'), _H('Reported')]] + for issue in open_issues: + sev_col = SEVERITY_COLORS.get(issue.severity, C_SLATE) + hex_str = '%06x' % (sev_col.hexval() & 0xFFFFFF) + desc = issue.description or '' + tbl_data.append([ + Paragraph('' + '{}'.format(hex_str, (issue.severity or '').title()), + styles['TableCell']), + _P(issue.area.name if issue.area else '—'), + _P(desc[:100] + ('...' if len(desc) > 100 else '')), + _P(issue.reported_at.strftime('%Y-%m-%d') if issue.reported_at else '—'), + ]) + iss_tbl = Table(tbl_data, + colWidths=[page_w * 0.14, page_w * 0.20, + page_w * 0.50, page_w * 0.16], + repeatRows=1) + iss_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_DARK), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 6), + ('RIGHTPADDING', (0, 0), (-1, -1), 6), + ])) + story.append(iss_tbl) + else: + story.append(Paragraph('No open issues — all clear.', styles['Meta'])) + + story.append(Spacer(1, 0.2 * inch)) + story.append(HRFlowable(width='100%', thickness=0.5, color=C_BORDER)) + story.append(Spacer(1, 0.05 * inch)) + story.append(Paragraph( + 'Generated {} — Confidential'.format(now.strftime('%B %d, %Y %I:%M %p')), + styles['Meta'], + )) + doc.build(story) + return buf.getvalue() + return buf.getvalue() \ No newline at end of file diff --git a/app/utils/scope.py b/app/utils/scope.py new file mode 100644 index 0000000..2748b47 --- /dev/null +++ b/app/utils/scope.py @@ -0,0 +1,118 @@ +""" +app/utils/scope.py +------------------ +Facility-scoping utilities for the Janitorial QC portal. + + get_customer_scope(user) -> list[int] | None + Facility IDs a customer may access via CustomerAssignment rows. + + get_inspector_scope(user) -> list[int] | None + Facility IDs an inspector may access via InspectorAssignment rows. + Returns [] (empty list) when the inspector has no contract assignments, + meaning they see nothing (strict mode). + +For non-customer / non-inspector roles both functions return None, signalling +that no facility-level scoping is required (full access applies). +""" + +import logging +from app.models.project import CustomerAssignment +from app.models.facility import Facility + +logger = logging.getLogger(__name__) + + +def get_customer_scope(user) -> list[int] | None: + """Return the list of facility IDs accessible to a customer user. + + Parameters + ---------- + user : User + The currently authenticated user. + + Returns + ------- + list[int] + Facility IDs the customer may access. May be empty if no assignments + exist yet — callers should treat an empty list as "no access". + None + Returned for non-customer roles, indicating unrestricted access. + """ + if user.role != 'customer': + return None # no scoping needed for internal staff + + assignments = CustomerAssignment.query.filter_by(user_id=user.id).all() + + if not assignments: + return [] + + # Separate direct facility assignments from project-level assignments + direct_facility_ids = {a.facility_id for a in assignments if a.facility_id} + project_ids = {a.project_id for a in assignments if not a.facility_id} + + facility_ids = set(direct_facility_ids) + + # Single bulk query for all project-scoped facilities — replaces the + # previous per-assignment Facility.query loop (N+1 pattern). + if project_ids: + project_facilities = ( + Facility.query + .filter( + Facility.project_id.in_(project_ids), + Facility.active == True, + ) + .all() + ) + for f in project_facilities: + facility_ids.add(f.id) + + logger.debug( + 'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s', + user.id, user.username, sorted(facility_ids), + ) + + return sorted(facility_ids) + + +def get_inspector_scope(user) -> list[int] | None: + """Return the list of facility IDs accessible to a contract-scoped inspector. + + Parameters + ---------- + user : User + The currently authenticated user. + + Returns + ------- + list[int] + Facility IDs the inspector may access. An empty list means the + inspector has no contract assignments and should see nothing. + None + Returned for non-inspector roles, indicating unrestricted access. + """ + if user.role != 'inspector': + return None + + from app.models.inspector_assignment import InspectorAssignment + + project_ids = [ + a.project_id + for a in InspectorAssignment.query.filter_by(user_id=user.id).all() + ] + + if not project_ids: + return [] # strict: no assignments = no access + + facility_ids = [ + f.id for f in Facility.query.filter( + Facility.project_id.in_(project_ids), + Facility.active == True, + ).all() + ] + + logger.debug( + 'SCOPE | inspector_scope | user_id=%s username=%s facility_ids=%s', + user.id, user.username, sorted(facility_ids), + ) + + return sorted(facility_ids) \ No newline at end of file diff --git a/app/utils/sla.py b/app/utils/sla.py new file mode 100644 index 0000000..ab9f7b9 --- /dev/null +++ b/app/utils/sla.py @@ -0,0 +1,345 @@ +""" +sla.py +------ +Issue SLA (Service Level Agreement) helpers. + +SLA thresholds define the maximum number of hours an issue of a given +severity may remain unresolved before it is considered breached. + +Statuses +-------- +ok — within the allowed window +at_risk — past 75% of the allowed window but not yet breached +breached — past the deadline +None — issue is already resolved; SLA no longer applies +""" + +from datetime import timedelta +from app.utils.time_utils import now_eastern + +# ── Configurable thresholds (hours) ────────────────────────────────────────── +SLA_HOURS = { + 'critical': 4, + 'high': 24, + 'medium': 72, + 'low': 120, # 5 days +} + +# Fraction of the window at which an issue becomes "at risk" +AT_RISK_THRESHOLD = 0.75 + + +def sla_deadline(issue): + """ + Return the datetime by which the issue must be resolved, + or None if the severity is not recognized. + """ + hours = SLA_HOURS.get(issue.severity) + if hours is None: + return None + return issue.reported_at + timedelta(hours=hours) + + +def sla_status(issue): + """ + Return one of: 'ok', 'at_risk', 'breached', or None. + + None is returned when the issue is already resolved — SLA no longer + applies. None is also returned for unrecognized severity values. + """ + if issue.status == 'resolved': + return None + + hours = SLA_HOURS.get(issue.severity) + if hours is None: + return None + + deadline = issue.reported_at + timedelta(hours=hours) + at_risk_at = issue.reported_at + timedelta(hours=hours * AT_RISK_THRESHOLD) + now = now_eastern() + + if now >= deadline: + return 'breached' + if now >= at_risk_at: + return 'at_risk' + return 'ok' + + +def sla_hours_remaining(issue): + """ + Return the number of hours remaining before the SLA deadline. + Negative values indicate the deadline has already passed. + Returns None for resolved issues or unrecognized severities. + """ + if issue.status == 'resolved': + return None + deadline = sla_deadline(issue) + if deadline is None: + return None + delta = deadline - now_eastern() + return round(delta.total_seconds() / 3600, 1) + + +# ── SLA alert dispatcher ────────────────────────────────────────────────────── + +def send_sla_alerts(): + """ + Check all open/in-progress issues for SLA breaches or at-risk status and + dispatch in-app + email notifications to the appropriate recipients. + + Recipient rules: + - Admins always receive alerts + - If the issue is assigned, the assignee also receives an alert + - All followers of the issue also receive an alert + - Deduplication ensures each user gets at most one notification per call + + Deduplication across cron runs: + - Issue.sla_notified tracks the highest alert level already sent + ('at_risk' or 'breached'). A notification is only sent once per level. + - 'breached' supersedes 'at_risk': if a user was already notified + at-risk, they will receive a second notification when it breaches. + + Returns the number of notifications created. + """ + from flask import current_app, url_for + from app import db + from app.models.issue import Issue + from app.models.user import User + from app.utils.notifications import notify, notify_by_matrix + import logging + + logger = logging.getLogger(__name__) + + # yield_per streams rows in batches of 100 rather than loading all open + # issues into memory at once. At current scale this is a no-op difference, + # but it prevents a memory spike if the issue count grows large. + open_issues = Issue.query.filter( + Issue.status.in_(['open', 'in_progress', 'pending_verification']) + ).yield_per(100) + + total_sent = 0 + + for issue in open_issues: + status = sla_status(issue) + + # Only act on at_risk or breached + if status not in ('at_risk', 'breached'): + continue + + # Skip if this level (or higher) was already notified + already = issue.sla_notified + if already == 'breached': + continue # highest level already sent + if already == 'at_risk' and status == 'at_risk': + continue # at_risk already sent, not yet breached + + # Compose message + hrs = sla_hours_remaining(issue) + deadline = sla_deadline(issue) + + facility_name = issue.resolved_facility.name if issue.resolved_facility else "\u2014" + + if status == 'breached': + title = f'🚨 SLA Breached — Issue #{issue.id} ({issue.severity.title()})' + body = ( + f'Issue #{issue.id} at {facility_name} ' + f'has breached its SLA deadline. ' + f'Severity: {issue.severity.title()}. ' + f'Deadline was {deadline.strftime("%Y-%m-%d %H:%M") if deadline else "N/A"}. ' + f'Current status: {issue.status.replace("_", " ").title()}.' + ) + else: # at_risk + title = f'⚠️ SLA At Risk — Issue #{issue.id} ({issue.severity.title()})' + body = ( + f'Issue #{issue.id} at {facility_name} ' + f'is approaching its SLA deadline with approximately ' + f'{abs(hrs):.1f}h remaining. ' + f'Severity: {issue.severity.title()}. ' + f'Deadline: {deadline.strftime("%Y-%m-%d %H:%M") if deadline else "N/A"}. ' + f'Current status: {issue.status.replace("_", " ").title()}.' + ) + + try: + link = url_for('issues.view', issue_id=issue.id) + except RuntimeError: + link = f'/issues/{issue.id}' + + # Always notify the assignee and followers (implicit, not matrix-controlled) + implicit_notified = set() + if issue.assigned_to and issue.assigned_user: + notify( + recipient = issue.assigned_user, + title = title, + body = body, + link = link, + issue_id = issue.id, + event_type = 'sla_alert', + send_email = True, + ) + implicit_notified.add(issue.assigned_user.id) + total_sent += 1 + + for follower_link in issue.followers.all(): + if follower_link.user_id not in implicit_notified: + notify( + recipient = follower_link.user, + title = title, + body = body, + link = link, + issue_id = issue.id, + event_type = 'sla_alert', + send_email = True, + ) + implicit_notified.add(follower_link.user_id) + total_sent += 1 + + # Matrix-controlled broadcast (admin, supervisor, etc.) + notify_by_matrix( + event_type = 'sla_alert', + title = title, + body = body, + link = link, + issue_id = issue.id, + exclude_user_ids = implicit_notified, + ) + total_sent += 1 # approximate — matrix count not returned + + # Mark this issue as notified at the current level + issue.sla_notified = status + logger.info( + 'SLA ALERT SENT | issue_id=%s | status=%s', + issue.id, status, + ) + + if total_sent: + db.session.commit() + + return total_sent + + +# ── Score trend alert dispatcher ────────────────────────────────────────────── + +# Default drop threshold in percentage points that triggers an alert. +SCORE_DROP_THRESHOLD = 5.0 + + +def send_score_alerts(threshold=SCORE_DROP_THRESHOLD): + """ + Compare each active facility's avg inspection score for the last 30 days + against the prior 30-day period. When the score has dropped by more than + *threshold* points, dispatch an in-app + email alert via notify_by_matrix + and record the alert in facility_score_alerts for deduplication. + + A facility is skipped if it already received an alert within the last 24 + hours (prevents repeat storms on persistent low scores). + + Returns the number of alert notifications dispatched. + """ + from datetime import timedelta + from flask import current_app, url_for + from sqlalchemy import func + from app import db + from app.models.facility import Facility + from app.models.inspection import Inspection + from app.models.score_alert import FacilityScoreAlert + from app.utils.notifications import notify_by_matrix + import logging + + logger = logging.getLogger(__name__) + + now = now_eastern() + cur_start = now - timedelta(days=30) + pri_start = now - timedelta(days=60) + pri_end = cur_start + + # Current-period avg score per facility + cur_rows = db.session.query( + Facility.id, + Facility.name, + func.avg(Inspection.overall_score).label('avg'), + ).join(Inspection, Facility.id == Inspection.facility_id)\ + .filter( + Facility.active == True, + Inspection.inspection_date >= cur_start, + Inspection.inspection_date <= now, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + ).group_by(Facility.id, Facility.name).all() + + # Prior-period avg score per facility + pri_rows = db.session.query( + Facility.id, + func.avg(Inspection.overall_score).label('avg'), + ).join(Inspection, Facility.id == Inspection.facility_id)\ + .filter( + Facility.active == True, + Inspection.inspection_date >= pri_start, + Inspection.inspection_date <= pri_end, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + ).group_by(Facility.id).all() + + prior_map = {r.id: float(r.avg) for r in pri_rows} + + # Facilities that already received an alert in the last 24 hours + cutoff = now - timedelta(hours=24) + recent_alerts = db.session.query(FacilityScoreAlert.facility_id)\ + .filter(FacilityScoreAlert.sent_at >= cutoff).all() + already_alerted = {r.facility_id for r in recent_alerts} + + total_sent = 0 + + for row in cur_rows: + fid = row.id + cur_avg = float(row.avg) + pri_avg = prior_map.get(fid) + + if pri_avg is None: + continue # no prior period data — nothing to compare + + delta = cur_avg - pri_avg # negative = score dropped + + if delta >= -threshold: + continue # drop is within acceptable range + + if fid in already_alerted: + logger.debug('SCORE ALERT SKIPPED (already alerted) | facility_id=%s', fid) + continue + + title = f'📉 Score Drop Alert — {row.name}' + body = ( + f'{row.name} avg score has dropped {abs(delta):.1f} points ' + f'(from {pri_avg:.1f}% to {cur_avg:.1f}%) over the last 30 days ' + f'vs. the prior 30-day period.' + ) + + try: + link = url_for('reports.facility_scorecard', facility_id=fid) + except RuntimeError: + link = f'/reports/facility/{fid}/scorecard' + + notify_by_matrix( + event_type = 'score_alert', + title = title, + body = body, + link = link, + ) + total_sent += 1 + + db.session.add(FacilityScoreAlert( + facility_id = fid, + sent_at = now, + current_avg = round(cur_avg, 2), + prior_avg = round(pri_avg, 2), + delta = round(delta, 2), + )) + + logger.info( + 'SCORE ALERT SENT | facility_id=%s | facility=%s | cur=%.1f | prior=%.1f | delta=%.1f', + fid, row.name, cur_avg, pri_avg, delta, + ) + + if total_sent: + db.session.commit() + + return total_sent \ No newline at end of file diff --git a/app/utils/time_utils.py b/app/utils/time_utils.py new file mode 100644 index 0000000..709627c --- /dev/null +++ b/app/utils/time_utils.py @@ -0,0 +1,24 @@ +""" +time_utils.py +------------- +Centralised time helpers for the JQC application. + +All timestamps are stored as Eastern Time (America/New_York) so that +displayed dates reflect the local business timezone without any +conversion layer in templates or reports. +""" + +from datetime import datetime +import pytz + +EASTERN = pytz.timezone('America/New_York') + + +def now_eastern() -> datetime: + """Return the current wall-clock time in US/Eastern (naive datetime). + + Stored as a naive datetime in the database so that existing DateTime + columns require no schema change. The value is always Eastern local + time (auto-adjusts for EDT / EST). + """ + return datetime.now(tz=EASTERN).replace(tzinfo=None) diff --git a/config.py b/config.py new file mode 100644 index 0000000..841847d --- /dev/null +++ b/config.py @@ -0,0 +1,87 @@ +import os +from datetime import timedelta +from dotenv import load_dotenv + +# Load .env from the project root (only takes effect locally; no-op in production +# if variables are already set in the environment) +load_dotenv() + +basedir = os.path.abspath(os.path.dirname(__file__)) + + +def _require_env(key: str) -> str: + """Return the value of a required environment variable, raising if absent.""" + value = os.environ.get(key) + if not value: + raise RuntimeError( + f"Required environment variable '{key}' is not set. " + f"Add it to your .env file (development) or server environment (production)." + ) + return value + + +class Config: + # ── Security ──────────────────────────────────────────────────────────── + # SECRET_KEY must be set externally — no insecure fallback. + SECRET_KEY = _require_env('SECRET_KEY') + + # ── Database ──────────────────────────────────────────────────────────── + # DATABASE_URL must be set externally — no hardcoded credentials. + SQLALCHEMY_DATABASE_URI = _require_env('DATABASE_URL') + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ECHO = False + + # ── File uploads ──────────────────────────────────────────────────────── + UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads') + MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + + # ── Session / cookies ─────────────────────────────────────────────────── + PERMANENT_SESSION_LIFETIME = timedelta(hours=24) + # Secure by default — subclasses must explicitly opt out for local dev. + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' + + # ── Mail ──────────────────────────────────────────────────────────────── + # ── Application base URL (used in email links) ───────────────────────── + APP_BASE_URL = os.environ.get('APP_BASE_URL', '') + MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local') + + # ── Digest email secret token (used to authenticate cron trigger) ──────── + DIGEST_SECRET = os.environ.get('DIGEST_SECRET') + + # ── Google Maps (used for GPS map on inspection view) ──────────────────── + GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '') + + MAIL_SERVER = os.environ.get('MAIL_SERVER') + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + + # ── SSL vs STARTTLS selection ──────────────────────────────────────────── + # Port 465 = implicit SSL → MAIL_USE_SSL=True, MAIL_USE_TLS=False + # Port 587 = STARTTLS → MAIL_USE_SSL=False, MAIL_USE_TLS=True + # The two flags are mutually exclusive; setting both True breaks Flask-Mail. + _mail_port = int(os.environ.get('MAIL_PORT') or 587) + MAIL_PORT = _mail_port + MAIL_USE_SSL = _mail_port == 465 + MAIL_USE_TLS = not MAIL_USE_SSL # STARTTLS only when NOT using implicit SSL + + +class DevelopmentConfig(Config): + DEBUG = True + SQLALCHEMY_ECHO = True + # Allow HTTP cookies during local development (HTTP, not HTTPS) + SESSION_COOKIE_SECURE = False + + +class ProductionConfig(Config): + DEBUG = False + # Inherits SESSION_COOKIE_SECURE = True from Config — no override needed. + + +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig, +} \ No newline at end of file diff --git a/docs/JQC_Customer_User_Manual_v2.docx b/docs/JQC_Customer_User_Manual_v2.docx new file mode 100644 index 0000000000000000000000000000000000000000..5c9c6cb376dfa105f36a1a8b15e65d19eed1fa36 GIT binary patch literal 43786 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 ck$ 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 D39r&f literal 0 HcmV?d00001 diff --git a/docs/JQC_Inspector_Manual.docx b/docs/JQC_Inspector_Manual.docx new file mode 100644 index 0000000000000000000000000000000000000000..d55dc7a7b02262c3e64181b5389b4766e25ef68c GIT binary patch literal 22557 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 Cg4U)0 literal 0 HcmV?d00001 diff --git a/docs/JQC_Web_Inspector_Manual.docx b/docs/JQC_Web_Inspector_Manual.docx new file mode 100644 index 0000000000000000000000000000000000000000..ef3f3d5ce99381079e44795638a878eea586c155 GIT binary patch literal 17288 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 n4Ddg3@^9U~UDx>aUGOg|f3X7kha)CCc literal 0 HcmV?d00001 diff --git a/gunicorn_config.py b/gunicorn_config.py new file mode 100644 index 0000000..c9e2f75 --- /dev/null +++ b/gunicorn_config.py @@ -0,0 +1,12 @@ +import multiprocessing + +bind = "127.0.0.1:8000" +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "sync" +worker_connections = 1000 +timeout = 30 +keepalive = 2 + +errorlog = "/home/jqc/logs/gunicorn-error.log" +accesslog = "/home/jqc/logs/gunicorn-access.log" +loglevel = "info" diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/phase10_customer_password_setup.py b/migrations/versions/phase10_customer_password_setup.py new file mode 100644 index 0000000..1a34b4b --- /dev/null +++ b/migrations/versions/phase10_customer_password_setup.py @@ -0,0 +1,44 @@ +"""Phase 10: Customer password-setup workflow + +Adds three columns to the users table to support the +invitation-based customer account creation flow: + + password_set — False until the customer completes set-password + set_password_token — one-time URL token (64-char hex, nullable) + set_password_token_expires — UTC expiry datetime (nullable) + +Revision ID: phase10_customer_password_setup +Revises: phase9_user_full_name +Create Date: 2026-04-02 +""" +from alembic import op +import sqlalchemy as sa + +revision = 'phase10_customer_password_setup' +down_revision = 'phase9_user_full_name' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = [c['name'] for c in inspector.get_columns('users')] + + if 'password_set' not in columns: + op.add_column('users', + sa.Column('password_set', sa.Boolean, nullable=False, server_default='1')) + + if 'set_password_token' not in columns: + op.add_column('users', + sa.Column('set_password_token', sa.String(64), nullable=True)) + + if 'set_password_token_expires' not in columns: + op.add_column('users', + sa.Column('set_password_token_expires', sa.DateTime, nullable=True)) + + +def downgrade(): + op.drop_column('users', 'set_password_token_expires') + op.drop_column('users', 'set_password_token') + op.drop_column('users', 'password_set') diff --git a/migrations/versions/phase11_director_role.py b/migrations/versions/phase11_director_role.py new file mode 100644 index 0000000..0ea2039 --- /dev/null +++ b/migrations/versions/phase11_director_role.py @@ -0,0 +1,68 @@ +"""Phase 11: Rename supervisor role to director + +Revision ID: phase11_director_role +Revises: phase10_customer_password_setup +Create Date: 2026-04-09 + +Changes +------- +1. Adds 'director' to the users.role ENUM. +2. Migrates all existing role='supervisor' users to role='director'. +3. Removes 'supervisor' from the ENUM once no rows use it. +4. Migrates notification_matrix rows keyed role_key='supervisor' → 'director'. +""" +from alembic import op +import sqlalchemy as sa + +revision = 'phase11_director_role' +down_revision = 'phase10_customer_password_setup' +branch_labels = None +depends_on = None + + +def upgrade(): + # Step 1 — Expand ENUM to include both values (required before UPDATE) + op.execute( + "ALTER TABLE users MODIFY COLUMN role " + "ENUM('admin','supervisor','director','inspector','project_manager','customer') " + "NOT NULL" + ) + + # Step 2 — Migrate all supervisor users to director + op.execute("UPDATE users SET role = 'director' WHERE role = 'supervisor'") + + # Step 3 — Remove 'supervisor' from the ENUM now that no rows reference it + op.execute( + "ALTER TABLE users MODIFY COLUMN role " + "ENUM('admin','director','inspector','project_manager','customer') " + "NOT NULL" + ) + + # Step 4 — Migrate notification_matrix role_key rows + op.execute( + "UPDATE notification_matrix SET role_key = 'director' WHERE role_key = 'supervisor'" + ) + + +def downgrade(): + # Step 1 — Expand ENUM to allow supervisor again + op.execute( + "ALTER TABLE users MODIFY COLUMN role " + "ENUM('admin','supervisor','director','inspector','project_manager','customer') " + "NOT NULL" + ) + + # Step 2 — Revert director users back to supervisor + op.execute("UPDATE users SET role = 'supervisor' WHERE role = 'director'") + + # Step 3 — Remove 'director' from the ENUM + op.execute( + "ALTER TABLE users MODIFY COLUMN role " + "ENUM('admin','supervisor','inspector','project_manager','customer') " + "NOT NULL" + ) + + # Step 4 — Revert notification_matrix role_key rows + op.execute( + "UPDATE notification_matrix SET role_key = 'supervisor' WHERE role_key = 'director'" + ) diff --git a/migrations/versions/phase12_performance_indexes.py b/migrations/versions/phase12_performance_indexes.py new file mode 100644 index 0000000..0af70df --- /dev/null +++ b/migrations/versions/phase12_performance_indexes.py @@ -0,0 +1,77 @@ +"""Phase 12: Add performance indexes on high-filter columns + +Revision ID: phase12_performance_indexes +Revises: phase11_director_role +Create Date: 2026-04-25 + +Rationale +--------- +The following columns are filtered or ordered on every page load but had no +DB index, causing full table scans as row counts grow: + + inspections + - status — filtered on list/dashboard/SLA queries + - facility_id — filtered for customer-scoped views and reports + - inspector_id — filtered for inspector-scoped views + - inspection_date — used in all trend/score queries (ORDER BY, range filter) + + issues + - status — filtered on every issues list/dashboard load + - severity — filtered in dashboard breakdown and issues list + - assigned_to — filtered for inspector-scoped views + - reported_at — used for ordering + +Existence checks use information_schema so the migration is safe to re-run +on any MySQL version (compatible back to 5.7). +""" + +from alembic import op +from sqlalchemy import text + + +def _index_exists(conn, table: str, index_name: str) -> bool: + """Return True if the named index already exists on the given table.""" + result = conn.execute(text( + "SELECT COUNT(*) FROM information_schema.statistics " + "WHERE table_schema = DATABASE() " + " AND table_name = :table " + " AND index_name = :index" + ), {'table': table, 'index': index_name}) + return result.scalar() > 0 + + +revision = 'phase12_performance_indexes' +down_revision = 'phase11_director_role' +branch_labels = None +depends_on = None + + +# (table, index_name, column) +INDEXES = [ + ('inspections', 'ix_inspections_status', 'status'), + ('inspections', 'ix_inspections_facility_id', 'facility_id'), + ('inspections', 'ix_inspections_inspector_id', 'inspector_id'), + ('inspections', 'ix_inspections_inspection_date', 'inspection_date'), + ('issues', 'ix_issues_status', 'status'), + ('issues', 'ix_issues_severity', 'severity'), + ('issues', 'ix_issues_assigned_to', 'assigned_to'), + ('issues', 'ix_issues_reported_at', 'reported_at'), +] + + +def upgrade(): + conn = op.get_bind() + for table, index_name, column in INDEXES: + if not _index_exists(conn, table, index_name): + op.execute(text( + f'CREATE INDEX {index_name} ON {table} ({column})' + )) + + +def downgrade(): + conn = op.get_bind() + for table, index_name, _column in INDEXES: + if _index_exists(conn, table, index_name): + op.execute(text( + f'DROP INDEX {index_name} ON {table}' + )) \ No newline at end of file diff --git a/migrations/versions/phase13_issue_facility.py b/migrations/versions/phase13_issue_facility.py new file mode 100644 index 0000000..e333ef6 --- /dev/null +++ b/migrations/versions/phase13_issue_facility.py @@ -0,0 +1,46 @@ +"""phase13 — add facility_id to issues, make area_id nullable + +Revision ID: phase13_issue_facility +Revises: phase12_performance_indexes +""" + +revision = 'phase13_issue_facility' +down_revision = 'phase_b_mobile_local_id' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # 1. Add facility_id column (nullable FK to facilities) + with op.batch_alter_table('issues') as batch_op: + batch_op.add_column( + sa.Column('facility_id', sa.Integer(), + sa.ForeignKey('facilities.id', ondelete='SET NULL'), + nullable=True) + ) + + # 2. Back-fill facility_id for all existing issues that have an area + op.execute(""" + UPDATE issues + JOIN areas ON issues.area_id = areas.id + SET issues.facility_id = areas.facility_id + WHERE issues.area_id IS NOT NULL + """) + + # 3. Make area_id nullable (was nullable=False) + with op.batch_alter_table('issues') as batch_op: + batch_op.alter_column('area_id', + existing_type=sa.Integer(), + nullable=True) + + +def downgrade(): + # Restore area_id to non-nullable (requires no NULL rows) + with op.batch_alter_table('issues') as batch_op: + batch_op.alter_column('area_id', + existing_type=sa.Integer(), + nullable=False) + batch_op.drop_column('facility_id') \ No newline at end of file diff --git a/migrations/versions/phase14_facility_created_at.py b/migrations/versions/phase14_facility_created_at.py new file mode 100644 index 0000000..fba6e35 --- /dev/null +++ b/migrations/versions/phase14_facility_created_at.py @@ -0,0 +1,31 @@ +"""phase14 — add created_at to facilities + +Adds a nullable DateTime column to the facilities table so that facility +creation time is tracked consistently with every other core model. + +Existing rows receive NULL (unknown creation time) — nullable=True is +intentional for backward compatibility with pre-existing data. + +Revision ID: phase14_facility_created_at +Revises: phase13_issue_facility +""" + +revision = 'phase14_facility_created_at' +down_revision = 'phase13_issue_facility' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + with op.batch_alter_table('facilities') as batch_op: + batch_op.add_column( + sa.Column('created_at', sa.DateTime(), nullable=True) + ) + + +def downgrade(): + with op.batch_alter_table('facilities') as batch_op: + batch_op.drop_column('created_at') diff --git a/migrations/versions/phase15_audit_log_indexes.py b/migrations/versions/phase15_audit_log_indexes.py new file mode 100644 index 0000000..4365763 --- /dev/null +++ b/migrations/versions/phase15_audit_log_indexes.py @@ -0,0 +1,62 @@ +"""phase15 — add indexes on audit_logs action and entity_type + +The audit log filter UI allows filtering by action and entity_type. +Without indexes, every filter invocation performs a full table scan. +As the log grows toward the 180/365-day purge threshold this degrades +noticeably. This migration adds individual indexes on both columns. + +A composite index on (action, entity_type, created_at) would be ideal +for the combined-filter case, but individual indexes are added here to +keep the migration additive and safe for re-run. created_at already +has an index from the model definition. + +Existence checks use information_schema so the migration is safe to +re-run on any MySQL version (compatible back to 5.7). + +Revision ID: phase15_audit_log_indexes +Revises: phase14_facility_created_at +""" + +revision = 'phase15_audit_log_indexes' +down_revision = 'phase14_facility_created_at' +branch_labels = None +depends_on = None + +from alembic import op +from sqlalchemy import text + + +def _index_exists(conn, table: str, index_name: str) -> bool: + """Return True if the named index already exists on the given table.""" + result = conn.execute(text( + "SELECT COUNT(*) FROM information_schema.statistics " + "WHERE table_schema = DATABASE() " + " AND table_name = :table " + " AND index_name = :index" + ), {'table': table, 'index': index_name}) + return result.scalar() > 0 + + +# (table, index_name, column) +INDEXES = [ + ('audit_logs', 'ix_audit_logs_action', 'action'), + ('audit_logs', 'ix_audit_logs_entity_type', 'entity_type'), +] + + +def upgrade(): + conn = op.get_bind() + for table, index_name, column in INDEXES: + if not _index_exists(conn, table, index_name): + op.execute(text( + f'CREATE INDEX {index_name} ON {table} ({column})' + )) + + +def downgrade(): + conn = op.get_bind() + for table, index_name, _column in INDEXES: + if _index_exists(conn, table, index_name): + op.execute(text( + f'DROP INDEX {index_name} ON {table}' + )) diff --git a/migrations/versions/phase16_notifications_columns.py b/migrations/versions/phase16_notifications_columns.py new file mode 100644 index 0000000..b76b30b --- /dev/null +++ b/migrations/versions/phase16_notifications_columns.py @@ -0,0 +1,100 @@ +"""phase16 — ensure digest_pending and inspection_id columns on notifications + +Background +---------- +The `notifications` table was created before the Alembic migration chain was +established (pre-phase1 baseline schema). Two columns added to the model +after the initial creation were never covered by a migration: + + digest_pending BOOLEAN NOT NULL DEFAULT 0 (used by the digest email system) + inspection_id INT NULL FK → inspections.id ON DELETE CASCADE + +Without this migration, any instance whose `notifications` table was created +from the original baseline (rather than from the current model) will raise +`OperationalError: Unknown column 'notifications.digest_pending'` the first +time a notification is created, and the digest email cron will fail entirely. + +All checks use INFORMATION_SCHEMA so the migration is safe to re-run on any +MySQL version ≥ 5.7 (CLAUDE.md rules 16, 17). + +Revision ID: phase16_notifications_columns +Revises: phase15_audit_log_indexes +""" + +revision = 'phase16_notifications_columns' +down_revision = 'phase15_audit_log_indexes' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(conn, table, column): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :table AND COLUMN_NAME = :col" + ), {"table": table, "col": column}) + return result.scalar() > 0 + + +def _index_exists(conn, table, index_name): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :table AND INDEX_NAME = :idx" + ), {"table": table, "idx": index_name}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + # 1. digest_pending — Boolean NOT NULL DEFAULT 0 + # Used by notify() to flag notifications for digest delivery, and by + # send_pending_digests() to find and clear them after delivery. + if not _column_exists(bind, 'notifications', 'digest_pending'): + op.execute(sa.text( + "ALTER TABLE notifications " + "ADD COLUMN digest_pending TINYINT(1) NOT NULL DEFAULT 0" + )) + + # 2. Index on digest_pending — the digest cron filters on this column + if not _index_exists(bind, 'notifications', 'ix_notifications_digest_pending'): + op.execute(sa.text( + "CREATE INDEX ix_notifications_digest_pending " + "ON notifications (digest_pending)" + )) + + # 3. inspection_id — nullable FK to inspections, CASCADE on delete + # Allows the notification bell to link directly to an inspection. + if not _column_exists(bind, 'notifications', 'inspection_id'): + op.execute(sa.text( + "ALTER TABLE notifications " + "ADD COLUMN inspection_id INT NULL, " + "ADD CONSTRAINT fk_notifications_inspection_id " + " FOREIGN KEY (inspection_id) REFERENCES inspections(id) " + " ON DELETE CASCADE" + )) + + +def downgrade(): + bind = op.get_bind() + + if _column_exists(bind, 'notifications', 'inspection_id'): + op.execute(sa.text( + "ALTER TABLE notifications " + "DROP FOREIGN KEY fk_notifications_inspection_id, " + "DROP COLUMN inspection_id" + )) + + if _index_exists(bind, 'notifications', 'ix_notifications_digest_pending'): + op.execute(sa.text( + "DROP INDEX ix_notifications_digest_pending ON notifications" + )) + + if _column_exists(bind, 'notifications', 'digest_pending'): + op.execute(sa.text( + "ALTER TABLE notifications DROP COLUMN digest_pending" + )) diff --git a/migrations/versions/phase17_notification_event_type.py b/migrations/versions/phase17_notification_event_type.py new file mode 100644 index 0000000..4109bac --- /dev/null +++ b/migrations/versions/phase17_notification_event_type.py @@ -0,0 +1,53 @@ +"""phase17 — add event_type column to notifications table + +Background +---------- +The `notifications` table has no `event_type` column, but the mobile API +endpoint GET /api/v1/notifications references `n.event_type`, causing an +AttributeError (500) on every poll — silently breaking iPad notifications. + +This migration adds `event_type VARCHAR(50) NULL` so the column is stored +at creation time and returned correctly to the mobile poller. + +The `notify()` utility is updated separately to pass event_type when creating +Notification records. + +Uses INFORMATION_SCHEMA existence check — safe to re-run (CLAUDE.md rules 16, 17). + +Revision ID: phase17_notification_event_type +Revises: phase16_notifications_columns +""" + +revision = 'phase17_notification_event_type' +down_revision = 'phase16_notifications_columns' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(bind, table, column): + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {'t': table, 'c': column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _column_exists(bind, 'notifications', 'event_type'): + op.execute(sa.text( + "ALTER TABLE notifications " + "ADD COLUMN event_type VARCHAR(50) NULL" + )) + + +def downgrade(): + bind = op.get_bind() + if _column_exists(bind, 'notifications', 'event_type'): + op.execute(sa.text( + "ALTER TABLE notifications DROP COLUMN event_type" + )) diff --git a/migrations/versions/phase18_issue_reported_by.py b/migrations/versions/phase18_issue_reported_by.py new file mode 100644 index 0000000..3281e5e --- /dev/null +++ b/migrations/versions/phase18_issue_reported_by.py @@ -0,0 +1,75 @@ +"""phase18 — add reported_by column to issues table + +Background +---------- +Issues created on the iPad by an inspector have no `assigned_to` value until +a director assigns them via the web portal. The mobile API's list_issues +endpoint filtered inspectors to `assigned_to == user.id`, so their own +newly-submitted issues were invisible on the iPad until assigned. + +This migration adds `reported_by INT NULL FK → users.id` so the API can +return issues the inspector either created OR was assigned to, without +a join to the inspections table. + +The column is nullable for backward compatibility: existing issues created +before this migration will have reported_by = NULL and continue to surface +only via the assigned_to path. + +Uses INFORMATION_SCHEMA existence check — safe to re-run (CLAUDE.md rules 16, 17). + +Revision ID: phase18_issue_reported_by +Revises: phase17_notification_event_type +""" + +revision = 'phase18_issue_reported_by' +down_revision = 'phase17_notification_event_type' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(conn, table, column): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :table AND COLUMN_NAME = :col" + ), {"table": table, "col": column}) + return result.scalar() > 0 + + +def _fk_exists(conn, table, constraint_name): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :table AND CONSTRAINT_NAME = :name" + ), {"table": table, "name": constraint_name}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _column_exists(bind, 'issues', 'reported_by'): + op.execute(sa.text( + "ALTER TABLE issues " + "ADD COLUMN reported_by INT NULL, " + "ADD CONSTRAINT fk_issues_reported_by " + " FOREIGN KEY (reported_by) REFERENCES users(id) " + " ON DELETE SET NULL" + )) + + +def downgrade(): + bind = op.get_bind() + + if _fk_exists(bind, 'issues', 'fk_issues_reported_by'): + op.execute(sa.text( + "ALTER TABLE issues DROP FOREIGN KEY fk_issues_reported_by" + )) + + if _column_exists(bind, 'issues', 'reported_by'): + op.execute(sa.text( + "ALTER TABLE issues DROP COLUMN reported_by" + )) diff --git a/migrations/versions/phase19_issue_mobile_photos.py b/migrations/versions/phase19_issue_mobile_photos.py new file mode 100644 index 0000000..afe5eb4 --- /dev/null +++ b/migrations/versions/phase19_issue_mobile_photos.py @@ -0,0 +1,46 @@ +"""phase19 — add mobile_photo_paths column to issues table + +Background +---------- +Issues created on the iPad can have multiple evidence photos. The first photo +is stored in `photo_path` (existing single-string column). Additional photos +were previously stored in `result_photos` (intended for resolution photos), +causing them to appear under "Resolution Details" on the web instead of +"Photo Evidence". + +This migration adds `mobile_photo_paths JSON NULL` to store the extra +evidence photos from the iPad separately from resolution photos. + +Safe to re-run — uses INFORMATION_SCHEMA existence check. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase19_issue_mobile_photos' +down_revision = 'phase18_issue_reported_by' +branch_labels = None +depends_on = None + + +def _column_exists(bind, table, column): + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {"t": table, "c": column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _column_exists(bind, 'issues', 'mobile_photo_paths'): + op.add_column('issues', sa.Column( + 'mobile_photo_paths', sa.JSON(), nullable=True + )) + + +def downgrade(): + bind = op.get_bind() + if _column_exists(bind, 'issues', 'mobile_photo_paths'): + op.execute(sa.text("ALTER TABLE issues DROP COLUMN mobile_photo_paths")) diff --git a/migrations/versions/phase1_projects_roles.py b/migrations/versions/phase1_projects_roles.py new file mode 100644 index 0000000..c189aaf --- /dev/null +++ b/migrations/versions/phase1_projects_roles.py @@ -0,0 +1,117 @@ +"""Phase 1: Add projects table, customer_assignments table, project_id on facilities, extend user role enum + +Revision ID: phase1_projects_roles +Revises: (set this to your current DB head before running) +Create Date: 2026-03-04 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# --- IMPORTANT: set down_revision to your live DB's current head --- +revision = 'phase1_projects_roles' +down_revision = '0003_add_user_active' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + existing_tables = inspector.get_table_names() + + # ── 1. Create `projects` table ───────────────────────────────────────── + if 'projects' not in existing_tables: + op.create_table( + 'projects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('project_manager_id', sa.Integer(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['project_manager_id'], ['users.id'], name='fk_project_manager'), + sa.PrimaryKeyConstraint('id'), + ) + + # ── 2. Create `customer_assignments` table ───────────────────────────── + if 'customer_assignments' not in existing_tables: + op.create_table( + 'customer_assignments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('facility_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['facility_id'], ['facilities.id'], + name='fk_ca_facility', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], + name='fk_ca_project', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], + name='fk_ca_user', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'project_id', 'facility_id', + name='uq_customer_assignment'), + ) + op.create_index('ix_customer_assignments_user_id', + 'customer_assignments', ['user_id']) + op.create_index('ix_customer_assignments_project_id', + 'customer_assignments', ['project_id']) + op.create_index('ix_customer_assignments_facility_id', + 'customer_assignments', ['facility_id']) + + # ── 3. Add `project_id` column to `facilities` ───────────────────────── + existing_facility_cols = [c['name'] for c in inspector.get_columns('facilities')] + if 'project_id' not in existing_facility_cols: + op.add_column( + 'facilities', + sa.Column('project_id', sa.Integer(), nullable=True) + ) + op.create_foreign_key( + 'fk_facility_project', + 'facilities', 'projects', + ['project_id'], ['id'], + ondelete='SET NULL' + ) + op.create_index('ix_facilities_project_id', 'facilities', ['project_id']) + + # ── 4. Extend `users.role` Enum with new values ──────────────────────── + # MySQL requires ALTER COLUMN to modify an ENUM. + op.alter_column( + 'users', 'role', + existing_type=mysql.ENUM('admin', 'supervisor', 'inspector'), + type_=mysql.ENUM('admin', 'supervisor', 'inspector', 'project_manager', 'customer'), + existing_nullable=False, + nullable=False, + ) + + +def downgrade(): + # ── Reverse order of operations ──────────────────────────────────────── + + # 4. Revert users.role Enum + op.alter_column( + 'users', 'role', + existing_type=mysql.ENUM('admin', 'supervisor', 'inspector', 'project_manager', 'customer'), + type_=mysql.ENUM('admin', 'supervisor', 'inspector'), + existing_nullable=False, + nullable=False, + ) + + # 3. Remove project_id from facilities + bind = op.get_bind() + inspector = sa.inspect(bind) + existing_facility_cols = [c['name'] for c in inspector.get_columns('facilities')] + if 'project_id' in existing_facility_cols: + op.drop_constraint('fk_facility_project', 'facilities', type_='foreignkey') + op.drop_index('ix_facilities_project_id', table_name='facilities') + op.drop_column('facilities', 'project_id') + + # 2. Drop customer_assignments + existing_tables = inspector.get_table_names() + if 'customer_assignments' in existing_tables: + op.drop_table('customer_assignments') + + # 1. Drop projects + if 'projects' in existing_tables: + op.drop_table('projects') diff --git a/migrations/versions/phase20_inspector_assignments.py b/migrations/versions/phase20_inspector_assignments.py new file mode 100644 index 0000000..6f15f79 --- /dev/null +++ b/migrations/versions/phase20_inspector_assignments.py @@ -0,0 +1,47 @@ +"""phase20 — inspector contract assignments + +Adds inspector_assignments table so each inspector can be scoped to one or +more contracts (projects). Inspectors with no assignments see nothing. + +Safe to re-run — uses INFORMATION_SCHEMA existence check. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase20_inspector_assignments' +down_revision = 'phase19_issue_mobile_photos' +branch_labels = None +depends_on = None + + +def _table_exists(bind, table): + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t" + ), {"t": table}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _table_exists(bind, 'inspector_assignments'): + op.create_table( + 'inspector_assignments', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.UniqueConstraint('user_id', 'project_id', name='uq_inspector_project'), + ) + op.create_index('ix_inspector_assignments_user_id', + 'inspector_assignments', ['user_id']) + + +def downgrade(): + bind = op.get_bind() + if _table_exists(bind, 'inspector_assignments'): + op.drop_table('inspector_assignments') diff --git a/migrations/versions/phase21_performance_indexes.py b/migrations/versions/phase21_performance_indexes.py new file mode 100644 index 0000000..1831f76 --- /dev/null +++ b/migrations/versions/phase21_performance_indexes.py @@ -0,0 +1,67 @@ +"""phase21 — composite performance indexes + +Adds composite (multi-column) indexes on the highest-traffic query patterns. +Phase 12 already covers single-column indexes; these target the multi-column +WHERE clauses that appear on every Reports, Inspections list, and Issues list +page load. + + inspections (facility_id, inspection_date) + — facility-scoped date-range queries on every list page and report + + inspections (inspector_id, inspection_date) + — inspector-scoped date-range queries on the Performance page and API stats + + inspections (status, inspection_date) + — "completed inspections in date range" pattern used by all score aggregations + + issues (facility_id, status) + — "open issues at this facility" pattern used by reports and dashboard + +All existence checks use INFORMATION_SCHEMA.STATISTICS — safe to re-run on +any MySQL version (compatible back to 5.7). +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase21_performance_indexes' +down_revision = 'phase21_template_active' +branch_labels = None +depends_on = None + + +def _index_exists(bind, table: str, index_name: str) -> bool: + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.statistics " + "WHERE table_schema = DATABASE() " + " AND table_name = :table " + " AND index_name = :index" + ), {'table': table, 'index': index_name}) + return result.scalar() > 0 + + +# (table, index_name, columns) +INDEXES = [ + ('inspections', 'ix_inspections_facility_date', 'facility_id, inspection_date'), + ('inspections', 'ix_inspections_inspector_date', 'inspector_id, inspection_date'), + ('inspections', 'ix_inspections_status_date', 'status, inspection_date'), + ('issues', 'ix_issues_facility_status', 'facility_id, status'), +] + + +def upgrade(): + bind = op.get_bind() + for table, index_name, columns in INDEXES: + if not _index_exists(bind, table, index_name): + op.execute(sa.text( + f'CREATE INDEX {index_name} ON {table} ({columns})' + )) + + +def downgrade(): + bind = op.get_bind() + for table, index_name, _columns in INDEXES: + if _index_exists(bind, table, index_name): + op.execute(sa.text( + f'DROP INDEX {index_name} ON {table}' + )) diff --git a/migrations/versions/phase21_template_active.py b/migrations/versions/phase21_template_active.py new file mode 100644 index 0000000..361f880 --- /dev/null +++ b/migrations/versions/phase21_template_active.py @@ -0,0 +1,39 @@ +"""phase21 — template active flag + +Adds `active` boolean column to `inspection_templates` so templates can be +deactivated without deletion. Inactive templates are hidden from the +inspection-start form but remain accessible in the template management UI. + +Safe to re-run — uses INFORMATION_SCHEMA column existence check. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase21_template_active' +down_revision = 'phase20_inspector_assignments' +branch_labels = None +depends_on = None + + +def _column_exists(bind, table, column): + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {"t": table, "c": column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _column_exists(bind, 'inspection_templates', 'active'): + op.add_column( + 'inspection_templates', + sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()) + ) + + +def downgrade(): + bind = op.get_bind() + if _column_exists(bind, 'inspection_templates', 'active'): + op.drop_column('inspection_templates', 'active') diff --git a/migrations/versions/phase22_comment_visibility.py b/migrations/versions/phase22_comment_visibility.py new file mode 100644 index 0000000..c748ab1 --- /dev/null +++ b/migrations/versions/phase22_comment_visibility.py @@ -0,0 +1,41 @@ +"""phase22 — add is_customer_visible to issue_comments + +Staff comments default to hidden from customers (is_customer_visible=FALSE). +Staff can tick a checkbox to share a comment with the customer. +Customer comments are always visible (is_customer_visible=TRUE, set at write time). +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase22_comment_visibility' +down_revision = 'phase21_performance_indexes' +branch_labels = None +depends_on = None + + +def _column_exists(bind, table: str, column: str) -> bool: + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() " + " AND table_name = :table " + " AND column_name = :column" + ), {'table': table, 'column': column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _column_exists(bind, 'issue_comments', 'is_customer_visible'): + op.execute(sa.text( + 'ALTER TABLE issue_comments ' + 'ADD COLUMN is_customer_visible BOOLEAN NOT NULL DEFAULT FALSE' + )) + + +def downgrade(): + bind = op.get_bind() + if _column_exists(bind, 'issue_comments', 'is_customer_visible'): + op.execute(sa.text( + 'ALTER TABLE issue_comments DROP COLUMN is_customer_visible' + )) diff --git a/migrations/versions/phase23_support_tickets.py b/migrations/versions/phase23_support_tickets.py new file mode 100644 index 0000000..2d49ce8 --- /dev/null +++ b/migrations/versions/phase23_support_tickets.py @@ -0,0 +1,70 @@ +"""phase23 — support tickets + +Creates two tables: + support_tickets — customer-submitted help requests + support_ticket_replies — admin/staff replies to those tickets +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase23_support_tickets' +down_revision = 'phase22_comment_visibility' +branch_labels = None +depends_on = None + + +def _table_exists(bind, table: str) -> bool: + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name = :t" + ), {'t': table}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _table_exists(bind, 'support_tickets'): + op.execute(sa.text(""" + CREATE TABLE support_tickets ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + customer_id INT NULL, + facility_id INT NULL, + subject VARCHAR(200) NOT NULL, + body TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'open', + created_at DATETIME NOT NULL, + CONSTRAINT fk_st_customer FOREIGN KEY (customer_id) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_st_facility FOREIGN KEY (facility_id) + REFERENCES facilities(id) ON DELETE SET NULL, + INDEX ix_support_tickets_customer (customer_id), + INDEX ix_support_tickets_status (status), + INDEX ix_support_tickets_created (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + if not _table_exists(bind, 'support_ticket_replies'): + op.execute(sa.text(""" + CREATE TABLE support_ticket_replies ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + ticket_id INT NOT NULL, + user_id INT NULL, + body TEXT NOT NULL, + created_at DATETIME NOT NULL, + CONSTRAINT fk_str_ticket FOREIGN KEY (ticket_id) + REFERENCES support_tickets(id) ON DELETE CASCADE, + CONSTRAINT fk_str_user FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE SET NULL, + INDEX ix_support_replies_ticket (ticket_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + +def downgrade(): + bind = op.get_bind() + if _table_exists(bind, 'support_ticket_replies'): + op.execute(sa.text('DROP TABLE support_ticket_replies')) + if _table_exists(bind, 'support_tickets'): + op.execute(sa.text('DROP TABLE support_tickets')) diff --git a/migrations/versions/phase24_issue_created_notify_defaults.py b/migrations/versions/phase24_issue_created_notify_defaults.py new file mode 100644 index 0000000..facda4b --- /dev/null +++ b/migrations/versions/phase24_issue_created_notify_defaults.py @@ -0,0 +1,32 @@ +"""phase24 — enable issue_created notifications for admin and director by default + +Sets enabled=True for ('issue_created', 'admin') and ('issue_created', 'director') +in the notification_matrix table if those rows already exist (created when an admin +previously saved the matrix page). Rows that do not exist are left alone — the +updated MATRIX_DEFAULTS in notification_matrix.py covers those at runtime. +""" + +from alembic import op + +revision = 'phase24_notify_defaults' +down_revision = 'phase23_support_tickets' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + UPDATE notification_matrix + SET enabled = 1 + WHERE event_type = 'issue_created' + AND role_key IN ('admin', 'director') + """) + + +def downgrade(): + op.execute(""" + UPDATE notification_matrix + SET enabled = 0 + WHERE event_type = 'issue_created' + AND role_key IN ('admin', 'director') + """) diff --git a/migrations/versions/phase25_inspection_gps.py b/migrations/versions/phase25_inspection_gps.py new file mode 100644 index 0000000..0e78f36 --- /dev/null +++ b/migrations/versions/phase25_inspection_gps.py @@ -0,0 +1,36 @@ +"""phase25 — add GPS coordinates to inspections + +Adds submit_latitude and submit_longitude columns to the inspections table. +Populated at web submission time via browser Geolocation API. +Null for all existing inspections and mobile submissions (handled separately). +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase25_inspection_gps' +down_revision = 'phase24_notify_defaults' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + has_lat = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + " AND TABLE_NAME = 'inspections' " + " AND COLUMN_NAME = 'submit_latitude'" + )).scalar() + + if not has_lat: + op.add_column('inspections', + sa.Column('submit_latitude', sa.Numeric(10, 7), nullable=True)) + op.add_column('inspections', + sa.Column('submit_longitude', sa.Numeric(10, 7), nullable=True)) + + +def downgrade(): + op.drop_column('inspections', 'submit_longitude') + op.drop_column('inspections', 'submit_latitude') diff --git a/migrations/versions/phase26_issue_vendor.py b/migrations/versions/phase26_issue_vendor.py new file mode 100644 index 0000000..813a9d7 --- /dev/null +++ b/migrations/versions/phase26_issue_vendor.py @@ -0,0 +1,50 @@ +"""phase26 — vendor/contractor columns on issues + +Adds three nullable columns to the issues table: + vendor_name VARCHAR(100) — name of the external contractor or vendor + vendor_contact VARCHAR(200) — phone number or email for the vendor + vendor_notes TEXT — notes about what the vendor is handling + +These columns are populated only when a third-party contractor is +assigned to resolve an issue, separate from the internal assigned_to staff user. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase26_issue_vendor' +down_revision = 'phase25_inspection_gps' +branch_labels = None +depends_on = None + + +def _col_exists(bind, table: str, column: str) -> bool: + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + " AND TABLE_NAME = :t " + " AND COLUMN_NAME = :c" + ), {'t': table, 'c': column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _col_exists(bind, 'issues', 'vendor_name'): + op.add_column('issues', + sa.Column('vendor_name', sa.String(100), nullable=True)) + + if not _col_exists(bind, 'issues', 'vendor_contact'): + op.add_column('issues', + sa.Column('vendor_contact', sa.String(200), nullable=True)) + + if not _col_exists(bind, 'issues', 'vendor_notes'): + op.add_column('issues', + sa.Column('vendor_notes', sa.Text, nullable=True)) + + +def downgrade(): + op.drop_column('issues', 'vendor_notes') + op.drop_column('issues', 'vendor_contact') + op.drop_column('issues', 'vendor_name') diff --git a/migrations/versions/phase27_score_alerts.py b/migrations/versions/phase27_score_alerts.py new file mode 100644 index 0000000..1bd113d --- /dev/null +++ b/migrations/versions/phase27_score_alerts.py @@ -0,0 +1,48 @@ +"""phase27 — facility score alert tracking table + +Creates facility_score_alerts table used by the score-trend cron job to +deduplicate notifications: once an alert fires for a facility, a row is +inserted here. The cron skips the facility if an alert was sent within +the last 24 hours, preventing alert storms on persistent score drops. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase27_score_alerts' +down_revision = 'phase26_issue_vendor' +branch_labels = None +depends_on = None + + +def _table_exists(bind, table: str) -> bool: + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name = :t" + ), {'t': table}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _table_exists(bind, 'facility_score_alerts'): + op.execute(sa.text(""" + CREATE TABLE facility_score_alerts ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + facility_id INT NOT NULL, + sent_at DATETIME NOT NULL, + current_avg DECIMAL(5,2) NOT NULL, + prior_avg DECIMAL(5,2) NOT NULL, + delta DECIMAL(5,2) NOT NULL, + CONSTRAINT fk_fsa_facility FOREIGN KEY (facility_id) + REFERENCES facilities(id) ON DELETE CASCADE, + INDEX ix_fsa_facility_sent (facility_id, sent_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + +def downgrade(): + bind = op.get_bind() + if _table_exists(bind, 'facility_score_alerts'): + op.execute(sa.text('DROP TABLE facility_score_alerts')) diff --git a/migrations/versions/phase28_fix_inspection_notify.py b/migrations/versions/phase28_fix_inspection_notify.py new file mode 100644 index 0000000..94c1716 --- /dev/null +++ b/migrations/versions/phase28_fix_inspection_notify.py @@ -0,0 +1,37 @@ +"""phase28 — restore inspection_completed director notification + +The notification_matrix row for ('inspection_completed', 'director') was set +to enabled=False at some point — likely when an admin saved the notification +matrix page with the director checkbox accidentally unchecked. + +This migration resets that row to enabled=True (matching MATRIX_DEFAULTS) so +the director receives email and in-app notifications whenever an inspector +submits a completed inspection, from both the web UI and the mobile API. + +Also resets ('inspection_completed', 'admin') and ('inspection_completed', 'customer') +to True for the same reason — any of these could have been inadvertently disabled. +Rows that do not yet exist in the DB are left alone; MATRIX_DEFAULTS covers +those at runtime. +""" + +from alembic import op + + +revision = 'phase28_fix_inspection_notify' +down_revision = 'phase27_score_alerts' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + UPDATE notification_matrix + SET enabled = 1 + WHERE event_type = 'inspection_completed' + AND role_key IN ('admin', 'director', 'customer') + """) + + +def downgrade(): + # No safe rollback — we don't know what the values were before. + pass diff --git a/migrations/versions/phase29_broadcasts.py b/migrations/versions/phase29_broadcasts.py new file mode 100644 index 0000000..b75e1f7 --- /dev/null +++ b/migrations/versions/phase29_broadcasts.py @@ -0,0 +1,37 @@ +"""phase29 — broadcasts table for admin push notifications to iOS + +Adds the `broadcasts` table. Each row records an admin-composed message, +the roles it targeted, who sent it, and how many Notification rows were +created. The Notification rows themselves are written at send-time using +the existing notify() utility — no schema changes to that table are needed. +""" + +from alembic import op +import sqlalchemy as sa + + +revision = 'phase29_broadcasts' +down_revision = 'phase28_fix_inspection_notify' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + CREATE TABLE IF NOT EXISTS broadcasts ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + target_roles JSON NOT NULL, + sent_by_id INT NULL, + sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + recipient_count INT NOT NULL DEFAULT 0, + CONSTRAINT fk_broadcast_sender + FOREIGN KEY (sent_by_id) REFERENCES users(id) + ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS broadcasts") 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') diff --git a/migrations/versions/phase7_mobile_api.py b/migrations/versions/phase7_mobile_api.py new file mode 100644 index 0000000..db99aac --- /dev/null +++ b/migrations/versions/phase7_mobile_api.py @@ -0,0 +1,91 @@ +""" +migrations/versions/phase7_mobile_api.py +----------------------------------------- +Phase 7 — Mobile API: JWT refresh tokens and APNs device tokens. + +Revision ID : phase7_mobile_api +Revises : phase6_features +Create Date : 2026-03-17 + +New tables +---------- +api_refresh_tokens + Stores server-side refresh token hashes for mobile sessions. + Enables instant revocation by deleting the row. + +api_device_tokens + Stores APNs device tokens for push notification delivery. + One row per (user_id, device_id) — upserted on every app launch. + +All columns include safe IF NOT EXISTS / IF EXISTS guards so the +migration is idempotent and safe to re-run. +""" + +from alembic import op +import sqlalchemy as sa + +revision = 'phase7_mobile_api' +down_revision = 'phase6_features' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = inspector.get_table_names() + + # ── 1. api_refresh_tokens ───────────────────────────────────────────── + if 'api_refresh_tokens' not in tables: + op.create_table( + 'api_refresh_tokens', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), + sa.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False), + sa.Column('token_hash', sa.String(64), nullable=False, unique=True), + sa.Column('device_id', sa.String(64), nullable=True), + sa.Column('device_name', sa.String(100), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False, + server_default='0'), + ) + op.create_index('ix_api_refresh_tokens_user_id', + 'api_refresh_tokens', ['user_id']) + op.create_index('ix_api_refresh_tokens_token_hash', + 'api_refresh_tokens', ['token_hash'], unique=True) + + # ── 2. api_device_tokens ────────────────────────────────────────────── + if 'api_device_tokens' not in tables: + op.create_table( + 'api_device_tokens', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), + sa.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False), + sa.Column('device_id', sa.String(64), nullable=False), + sa.Column('apns_token', sa.String(200), nullable=False), + sa.Column('device_name', sa.String(100), nullable=True), + sa.Column('app_version', sa.String(20), nullable=True), + sa.Column('registered_at', sa.DateTime(), nullable=False), + ) + op.create_index('ix_api_device_tokens_user_id', + 'api_device_tokens', ['user_id']) + op.create_unique_constraint( + 'uq_device_token_user_device', + 'api_device_tokens', + ['user_id', 'device_id'], + ) + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = inspector.get_table_names() + + if 'api_device_tokens' in tables: + op.drop_table('api_device_tokens') + + if 'api_refresh_tokens' in tables: + op.drop_table('api_refresh_tokens') diff --git a/migrations/versions/phase8_notification_matrix.py b/migrations/versions/phase8_notification_matrix.py new file mode 100644 index 0000000..b86ceb6 --- /dev/null +++ b/migrations/versions/phase8_notification_matrix.py @@ -0,0 +1,37 @@ +"""Phase 8: Notification matrix — admin-controlled per-event recipient settings + +Revision ID: phase8_notification_matrix +Revises: phase7_mobile_api +Create Date: 2026-04-02 +""" +from alembic import op +import sqlalchemy as sa + +revision = 'phase8_notification_matrix' +down_revision = 'phase7_mobile_api' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = inspector.get_table_names() + + if 'notification_matrix' not in tables: + op.create_table( + 'notification_matrix', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('event_type', sa.String(50), nullable=False), + sa.Column('role_key', sa.String(30), nullable=False), + # enabled: whether this role receives notifications for this event + sa.Column('enabled', sa.Boolean, nullable=False, server_default='1'), + # custom_emails: JSON list of extra email addresses (role_key='custom') + sa.Column('custom_emails', sa.Text, nullable=True), + sa.UniqueConstraint('event_type', 'role_key', + name='uq_notif_matrix_event_role'), + ) + + +def downgrade(): + op.drop_table('notification_matrix') diff --git a/migrations/versions/phase9_user_full_name.py b/migrations/versions/phase9_user_full_name.py new file mode 100644 index 0000000..4346881 --- /dev/null +++ b/migrations/versions/phase9_user_full_name.py @@ -0,0 +1,29 @@ +"""Phase 9: Add full_name column to users table + +Revision ID: phase9_user_full_name +Revises: phase8_notification_matrix +Create Date: 2026-04-02 +""" +from alembic import op +import sqlalchemy as sa + +revision = 'phase9_user_full_name' +down_revision = 'phase8_notification_matrix' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = [c['name'] for c in inspector.get_columns('users')] + + if 'full_name' not in columns: + op.add_column( + 'users', + sa.Column('full_name', sa.String(150), nullable=True, default=None), + ) + + +def downgrade(): + op.drop_column('users', 'full_name') diff --git a/migrations/versions/phase_b_mobile_local_id.py b/migrations/versions/phase_b_mobile_local_id.py new file mode 100644 index 0000000..9658fd0 --- /dev/null +++ b/migrations/versions/phase_b_mobile_local_id.py @@ -0,0 +1,79 @@ +"""Add mobile_local_id to inspections and issues for mobile sync idempotency. + +Phase B — iPad offline inspection submission. + +Each inspection or issue submitted from the iPad app carries a UUID generated +on the device (mobile_local_id). The server checks this field before creating +a new record so that network retries never produce duplicate rows. + +Revision ID: phase_b_mobile_local_id +Revises: phase12_performance_indexes +""" + +from alembic import op +import sqlalchemy as sa + +revision = 'phase_b_mobile_local_id' +down_revision = 'phase12_performance_indexes' +branch_labels = None +depends_on = None + + +def _column_exists(conn, table, column): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :table AND COLUMN_NAME = :column" + ), {"table": table, "column": column}) + return result.scalar() > 0 + + +def _index_exists(conn, table, index): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :table AND INDEX_NAME = :index" + ), {"table": table, "index": index}) + return result.scalar() > 0 + + +def upgrade(): + conn = op.get_bind() + + # ── inspections.mobile_local_id ─────────────────────────────────────── + if not _column_exists(conn, 'inspections', 'mobile_local_id'): + op.execute(sa.text( + "ALTER TABLE inspections ADD COLUMN mobile_local_id VARCHAR(64) NULL" + )) + + if not _index_exists(conn, 'inspections', 'idx_inspections_mobile_local_id'): + op.execute(sa.text( + "CREATE INDEX idx_inspections_mobile_local_id ON inspections(mobile_local_id)" + )) + + # ── issues.mobile_local_id ──────────────────────────────────────────── + if not _column_exists(conn, 'issues', 'mobile_local_id'): + op.execute(sa.text( + "ALTER TABLE issues ADD COLUMN mobile_local_id VARCHAR(64) NULL" + )) + + if not _index_exists(conn, 'issues', 'idx_issues_mobile_local_id'): + op.execute(sa.text( + "CREATE INDEX idx_issues_mobile_local_id ON issues(mobile_local_id)" + )) + + +def downgrade(): + conn = op.get_bind() + + if _index_exists(conn, 'inspections', 'idx_inspections_mobile_local_id'): + op.execute(sa.text("DROP INDEX idx_inspections_mobile_local_id ON inspections")) + + if _column_exists(conn, 'inspections', 'mobile_local_id'): + op.execute(sa.text("ALTER TABLE inspections DROP COLUMN mobile_local_id")) + + if _index_exists(conn, 'issues', 'idx_issues_mobile_local_id'): + op.execute(sa.text("DROP INDEX idx_issues_mobile_local_id ON issues")) + + if _column_exists(conn, 'issues', 'mobile_local_id'): + op.execute(sa.text("ALTER TABLE issues DROP COLUMN mobile_local_id")) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a06143 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +# Python >= 3.12 required. +# Several route files use nested f-strings with inner single-quoted expressions +# (e.g. f'...{x if x else '—'}...') — valid syntax in Python 3.12+ only. +# Do not downgrade the interpreter without replacing those expressions first. +Flask +Flask-SQLAlchemy +Flask-Login +Flask-WTF +Flask-Mail +Flask-Migrate +Flask-Limiter +redis +PyMySQL +cryptography +python-dotenv +Pillow +WTForms +email-validator +gunicorn +reportlab +pytz +pyJWT +openpyxl +groq diff --git a/run.py b/run.py new file mode 100644 index 0000000..526ecd1 --- /dev/null +++ b/run.py @@ -0,0 +1,22 @@ +from app import create_app, db +from app.models import User, Facility, Area, InspectionTemplate, ChecklistItem, Inspection, InspectionResult, Issue +import os + +app = create_app(os.getenv('FLASK_ENV') or 'default') + +@app.shell_context_processor +def make_shell_context(): + return { + 'db': db, + 'User': User, + 'Facility': Facility, + 'Area': Area, + 'InspectionTemplate': InspectionTemplate, + 'ChecklistItem': ChecklistItem, + 'Inspection': Inspection, + 'InspectionResult': InspectionResult, + 'Issue': Issue + } + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..a44b18b --- /dev/null +++ b/wsgi.py @@ -0,0 +1,7 @@ +from app import create_app +import os + +app = create_app(os.getenv('FLASK_ENV') or 'production') + +if __name__ == "__main__": + app.run()