646 lines
28 KiB
Markdown
646 lines
28 KiB
Markdown
# 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:** April 2026 (Phase 12 complete + post-review hardening + UX improvements)
|
||
|
||
---
|
||
|
||
## 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)](#9-mobile-api-phase-7)
|
||
10. [Notification System](#10-notification-system)
|
||
11. [SLA Engine](#11-sla-engine)
|
||
12. [Audit Trail](#12-audit-trail)
|
||
13. [PDF Export](#13-pdf-export)
|
||
14. [Scheduled Reports](#14-scheduled-reports)
|
||
15. [Rate Limiting](#15-rate-limiting)
|
||
16. [Alembic Migration Chain](#16-alembic-migration-chain)
|
||
17. [Frontend Conventions](#17-frontend-conventions)
|
||
18. [Infrastructure](#18-infrastructure)
|
||
19. [Known Constraints & Hard Rules](#19-known-constraints--hard-rules)
|
||
20. [Change Philosophy](#20-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 scorecards and scheduled email digests
|
||
- **Audit trail** — immutable log of every create/update/delete action
|
||
- **Mobile API** — JWT-authenticated REST layer (Phase 7) for a React Native / Expo mobile app
|
||
|
||
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 |
|
||
| Mobile client | React Native + Expo (Expo Go for dev; cloud Mac for iOS builds) |
|
||
| 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
|
||
│ ├── add_form_schema.py # One-off migration helper (safe re-run)
|
||
│ ├── api/ # Mobile REST API (Phase 7)
|
||
│ │ ├── __init__.py # api_bp parent blueprint + register_api()
|
||
│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*; rate-limited login/refresh
|
||
│ │ ├── decorators.py # @jwt_required
|
||
│ │ ├── errors.py # JSON error helpers + error handler registration
|
||
│ │ └── jwt_utils.py # generate_access_token()
|
||
│ ├── models/
|
||
│ │ ├── __init__.py
|
||
│ │ ├── api_token.py # RefreshToken, DeviceToken
|
||
│ │ ├── audit.py # AuditLog
|
||
│ │ ├── facility.py # Facility, Area
|
||
│ │ ├── inspection.py # InspectionTemplate, ChecklistItem, Inspection, InspectionResult
|
||
│ │ ├── issue.py # Issue, IssueComment, IssueFollower
|
||
│ │ ├── notification.py # Notification, NotificationPreference + event constants
|
||
│ │ ├── notification_matrix.py
|
||
│ │ ├── project.py # Project, CustomerAssignment
|
||
│ │ ├── scheduled_report.py # ScheduledReport
|
||
│ │ └── user.py # User (password-setup workflow, display_name)
|
||
│ ├── routes/
|
||
│ │ ├── auth.py # /auth/* — login rate-limited; _safe_next() open-redirect guard
|
||
│ │ ├── customers.py # /customers/* — _safe_referrer() helper; expired invitation banner
|
||
│ │ ├── dashboard.py # / — single open_issues query (len() not .count())
|
||
│ │ ├── facilities.py # /facilities/*
|
||
│ │ ├── inspections.py # /inspections/* — passes now=now_eastern() for stale badge
|
||
│ │ ├── issues.py # /issues/* — quick-assign AJAX; facility filter; SLA column
|
||
│ │ ├── notifications.py # /notifications/* — send-digest, check-sla, cleanup-tokens
|
||
│ │ ├── projects.py # /projects/*
|
||
│ │ ├── reports.py # /reports/*
|
||
│ │ ├── scheduled_reports.py # /scheduled-reports/*
|
||
│ │ ├── templates.py # /templates/*
|
||
│ │ └── audit.py # /audit/*
|
||
│ ├── static/
|
||
│ │ ├── css/ipad_responsive.css
|
||
│ │ └── uploads/
|
||
│ ├── templates/
|
||
│ │ ├── base.html # Active nav tab styling; request.endpoint-based active detection
|
||
│ │ ├── _sla_badge.html
|
||
│ │ └── ...
|
||
│ └── utils/
|
||
│ ├── audit.py # log_action() + ACTION_* constants
|
||
│ ├── decorators.py # @admin_required, @supervisor_required, @project_manager_required, @customer_required
|
||
│ ├── forms.py # All WTForms (AreaForm includes 'floor' type)
|
||
│ ├── notifications.py # notify(), notify_by_matrix(), send_pending_digests()
|
||
│ ├── pdf_export.py # ReportLab PDF generation
|
||
│ ├── scope.py # get_customer_scope()
|
||
│ ├── sla.py # sla_status(), sla_deadline(), sla_hours_remaining(), send_sla_alerts()
|
||
│ └── time_utils.py # now_eastern()
|
||
├── migrations/
|
||
│ └── versions/
|
||
│ ├── phase1_projects_roles.py
|
||
│ ├── phase6_features.py
|
||
│ ├── phase7_mobile_api.py
|
||
│ ├── phase8_notification_matrix.py
|
||
│ ├── phase9_user_full_name.py
|
||
│ ├── phase10_customer_password_setup.py
|
||
│ ├── phase11_director_role.py
|
||
│ └── phase12_performance_indexes.py ← HEAD
|
||
├── config.py
|
||
├── gunicorn_config.py
|
||
├── requirements.txt # Includes Flask-Limiter
|
||
├── run.py
|
||
└── wsgi.py
|
||
```
|
||
|
||
---
|
||
|
||
## 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: `send-digest`, `check-sla`, `cleanup-tokens`, `scheduled-reports/run` |
|
||
| `REDIS_URL` | Optional. e.g. `redis://127.0.0.1:6379/0`. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. Falls back to in-process memory if absent. |
|
||
|
||
### 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`. Use this everywhere in templates — never use `.username` for display.
|
||
|
||
**Expired invitations:** Customer accounts with `password_set=False` and `set_password_token_expires < now_eastern()` are flagged on the `/customers` index with an inline Resend button.
|
||
|
||
**Customer self-service setup:** When a customer clicks their invitation link, `set_password.html` prompts them to choose their own **username** (replacing the auto-generated placeholder) and set a **password**. The `set_password` route saves `form.username.data` to `user.username` before committing. The `SetPasswordForm` validates username uniqueness inline via `validate_username()`.
|
||
|
||
**Token verification:** `User.verify_set_password_token()` guards in order: token present → DB lookup → `password_set=False` check → expiry check → `hmac.compare_digest()` constant-time comparison. The `password_set=False` guard ensures accounts already activated cannot be re-used via a stale token. `compare_digest` is wrapped in `try/except` to safely handle type mismatches.
|
||
|
||
### 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)
|
||
```
|
||
|
||
**UI terminology:** "Project" is displayed as **"Contract"** throughout the UI. All backend identifiers (`project_id`, class `Project`, routes `projects.*`) are unchanged.
|
||
|
||
`facility_id = NULL` → access to all active facilities in that project.
|
||
|
||
### 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
|
||
```
|
||
|
||
**Indexed columns (Phase 12):** `status`, `facility_id`, `inspector_id`, `inspection_date`
|
||
|
||
**Score rule:** Items with `score = 0` mean "unanswered" — excluded from calculation entirely.
|
||
|
||
**Status display:** `completed` renders as **"Submitted"** in the UI. DB value unchanged.
|
||
|
||
**Stale badge:** In-progress inspections older than 24 hours show a `⚠ Stale` badge. Route passes `now=now_eastern()` to the list template.
|
||
|
||
### Issue
|
||
|
||
```
|
||
issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critical),
|
||
description, photo_path, status (open/in_progress/resolved/pending_verification),
|
||
assigned_to, reported_at, resolved_at, result_notes, result_photos (JSON),
|
||
verified_by, verified_at, verification_note, sla_notified
|
||
```
|
||
|
||
**Indexed columns (Phase 12):** `status`, `severity`, `assigned_to`, `reported_at`
|
||
|
||
**Issue status flow:** `open` → `in_progress` → `pending_verification` → `resolved`
|
||
|
||
**Quick-assign:** `POST /issues/<id>/quick-assign` — AJAX endpoint for admin/director. Returns JSON. Sends assignment notification. Logs to audit trail.
|
||
|
||
**Assignable staff roles:** `admin`, `director`, `inspector` — all three appear in the assignment dropdown on the issue view, create, and list quick-assign forms. `admin` was previously absent from the view/create dropdowns; corrected.
|
||
|
||
### Notification / NotificationPreference
|
||
|
||
```
|
||
notifications: id, user_id, title, body, link, is_read, created_at, issue_id, event_type
|
||
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
||
```
|
||
|
||
**Pause all emails:** Preferences page has a "Pause All Emails" toggle that bulk-disables all email toggles client-side.
|
||
|
||
### NotificationMatrix
|
||
|
||
```
|
||
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||
UniqueConstraint(event_type, role_key)
|
||
```
|
||
|
||
### AuditLog
|
||
|
||
```
|
||
audit_logs: id, user_id (nullable, SET NULL on delete), 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)
|
||
```
|
||
|
||
**Token cleanup:**
|
||
1. **Passive** — on every successful API login, expired/revoked tokens for that user are deleted
|
||
2. **Cron** — `POST /notifications/cleanup-tokens?token=<DIGEST_SECRET>` deletes all globally
|
||
|
||
---
|
||
|
||
## 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) | ✅ | ✅ | ✅ | ✅ | read |
|
||
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||
| 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` (rate-limited 20/min), `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||
| `facilities` | `/facilities` | CRUD + area management; list grouped by Contract with collapsible sections; detail page has Back button |
|
||
| `projects` | `/projects` | CRUD + customer assignment management |
|
||
| `customers` | `/customers` | list (expired invitation banner), invite (name + email only), set-password (customer chooses username + password), manage, import CSV |
|
||
| `inspections` | `/inspections` | list (stale badge, `now` passed from route), start (template + facility only — area and notes removed), execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect |
|
||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||
| `issues` | `/issues` | list (SLA column, facility filter, quick-assign dropdown), view, create, update, verify, comment, follow/unfollow, verification queue, delete |
|
||
| `issues` | `/issues` | `POST /<id>/quick-assign` — JSON AJAX, admin/director only |
|
||
| `notifications` | `/notifications` | list, mark-read, mark-all-read, preferences (pause-all toggle), `send-digest` (cron), `check-sla` (cron), `cleanup-tokens` (cron) |
|
||
| `audit` | `/audit` | list (admin only), view, purge |
|
||
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF export |
|
||
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger |
|
||
| `api` | `/api/v1` | parent blueprint |
|
||
| `api_auth` | `/api/v1` | `/auth/login` (10/min), `/auth/refresh` (30/min), `/auth/logout`, `/auth/me`, `/devices/register` |
|
||
|
||
---
|
||
|
||
## 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()`.
|
||
|
||
### `scope.py`
|
||
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff. Uses a single bulk `Facility.project_id.in_(...)` query for project-scoped assignments — never N+1.
|
||
|
||
### `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.
|
||
|
||
### `sla.py`
|
||
`sla_status(issue)` → `'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
|
||
`send_sla_alerts()` — called by cron, deduplicates via `issue.sla_notified`.
|
||
|
||
### `pdf_export.py`
|
||
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.
|
||
|
||
---
|
||
|
||
## 9. Mobile API (Phase 7)
|
||
|
||
### 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
|
||
|
||
### Rate Limits
|
||
| Endpoint | Limit |
|
||
|---|---|
|
||
| `POST /api/v1/auth/login` | 10/min, 3/sec |
|
||
| `POST /api/v1/auth/refresh` | 30/min, 5/sec |
|
||
| `POST /auth/login` (web) | 20/min, 5/sec |
|
||
|
||
### Refresh Token Cleanup
|
||
- Raw token never stored — SHA-256 hash only
|
||
- Passive: expired/revoked tokens purged per-user on each login
|
||
- Cron: `POST /notifications/cleanup-tokens`
|
||
|
||
### CSRF Exemption Pattern
|
||
```python
|
||
csrf.exempt(api_bp) # BEFORE register_api(app) — order matters
|
||
register_api(app)
|
||
```
|
||
|
||
### Phase 2 (planned)
|
||
`api_facilities`, `api_inspections`, `api_issues`, `api_notifications`, `api_photos` — all stubbed in `app/api/__init__.py`.
|
||
|
||
---
|
||
|
||
## 10. Notification System
|
||
|
||
### Event Constants (`app/models/notification.py`)
|
||
```
|
||
inspection_completed, issue_assigned, issue_reassigned, issue_unassigned,
|
||
issue_status, issue_comment, issue_follow_update, issue_flagged, issue_created,
|
||
issue_updated_customer, verification_requested, sla_alert,
|
||
customer_inspection_completed
|
||
```
|
||
|
||
### 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 * * *` |
|
||
|
||
### Implicit Notifications (always sent, not matrix-controlled)
|
||
- Issue **assignee** on assignment/status/comment
|
||
- Issue **followers** on any update
|
||
- SLA **assignee + followers** on SLA events
|
||
|
||
---
|
||
|
||
## 11. SLA Engine
|
||
|
||
| Severity | Window | At-Risk |
|
||
|---|---|---|
|
||
| critical | 4h | 3h |
|
||
| high | 24h | 18h |
|
||
| medium | 72h | 54h |
|
||
| low | 168h | 126h |
|
||
|
||
SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron notifications.
|
||
|
||
**SLA filter on issues list:** When `?sla=` is active, the full matching result set is loaded and filtered in Python, then wrapped in `_SLAFilteredPage` (defined at the top of `routes/issues.py`). This satisfies the template's pagination interface (`.items`, `.page`, `.pages`, `.iter_pages()`) without any template changes. Pagination nav is automatically suppressed via the existing `{% if issues.pages > 1 %}` guard.
|
||
|
||
---
|
||
|
||
## 12. Audit Trail
|
||
|
||
- Admin-only at `/audit/` — director is excluded
|
||
- Actions: `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `EXPORT`
|
||
- Immutable — never updated or deleted through the application (purge UI exists for old records)
|
||
- IP from `X-Forwarded-For` or `request.remote_addr`
|
||
|
||
**`ACTION_EXPORT` coverage:** Both `/reports/export/inspections` and `/reports/export/issues` call `log_action(ACTION_EXPORT, ...)` before streaming the CSV response.
|
||
|
||
**Logging coverage:** All route modules now import `logging` and define a module-level `logger = logging.getLogger(__name__)`. Previously missing from `facilities.py`, `templates.py`, `reports.py`, and `dashboard.py`.
|
||
|
||
---
|
||
|
||
## 13. PDF Export
|
||
|
||
ReportLab — `app/utils/pdf_export.py`. **12-column grid must be preserved** — do not collapse in print/PDF.
|
||
|
||
---
|
||
|
||
## 14. Scheduled Reports
|
||
|
||
Types: `summary`, `facility`, `issues`. Frequencies: `daily`, `weekly`, `monthly`.
|
||
Cron: `POST /scheduled-reports/run?secret=<DIGEST_SECRET>`
|
||
|
||
---
|
||
|
||
## 15. Rate Limiting
|
||
|
||
Initialised in `app/__init__.py` as a module-level extension:
|
||
|
||
```python
|
||
limiter = Limiter(
|
||
key_func = get_remote_address,
|
||
default_limits = [],
|
||
storage_uri = os.environ.get('REDIS_URL', 'memory://'),
|
||
)
|
||
limiter.init_app(app)
|
||
```
|
||
|
||
Import in routes: `from app import limiter`, then `@limiter.limit('N per period')`.
|
||
|
||
**Production:** Set `REDIS_URL=redis://127.0.0.1:6379/0` in the server environment. Counters are then shared across all Gunicorn workers and rate limits are correctly enforced.
|
||
|
||
**Development:** `REDIS_URL` absent → falls back to `memory://` (in-process, single-worker). No Redis install required locally.
|
||
|
||
> **`redis` package** is in `requirements.txt`. Install it with `pip install -r requirements.txt` before setting `REDIS_URL`.
|
||
|
||
---
|
||
|
||
## 16. 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 ← HEAD
|
||
```
|
||
|
||
### MySQL ENUM Change Protocol (3 steps — always follow)
|
||
```sql
|
||
-- 1. Expand to include both values
|
||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL;
|
||
-- 2. Migrate data
|
||
UPDATE users SET role = 'director' WHERE role = 'supervisor';
|
||
-- 3. Contract to remove old value
|
||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
|
||
```
|
||
|
||
### MySQL Compatibility
|
||
`CREATE INDEX IF NOT EXISTS` requires MySQL ≥ 8.0.1. For 5.7 compatibility use `information_schema.statistics` existence checks — see `phase12_performance_indexes.py` for the reusable `_index_exists()` pattern.
|
||
|
||
### Deprecated SQLAlchemy Patterns
|
||
```python
|
||
# WRONG
|
||
Model.query.get(id)
|
||
Model.query.get_or_404(id)
|
||
|
||
# CORRECT
|
||
obj = db.session.get(Model, id)
|
||
if obj is None: abort(404)
|
||
```
|
||
|
||
> All `Model.query.get()` calls have been replaced — including `load_user()` in `models/user.py` and three calls in `utils/notifications.py`.
|
||
|
||
---
|
||
|
||
## 17. Frontend Conventions
|
||
|
||
### Active Nav Tab
|
||
```css
|
||
/* base.html <style> block */
|
||
.navbar-dark .navbar-nav .nav-link.active {
|
||
background-color: rgba(255,255,255,0.18);
|
||
color: #fff !important;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
|
||
}
|
||
```
|
||
Active state detected via `request.endpoint.startswith('<blueprint>.')` in each nav `<a>` 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
|
||
- Manual forms: `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">`
|
||
- **Never nest `<form>` tags** — browsers silently discard inner forms
|
||
|
||
### Real-Time
|
||
**SSE banned.** All "live" updates use polling.
|
||
|
||
---
|
||
|
||
## 18. Infrastructure
|
||
|
||
### Gunicorn
|
||
```python
|
||
bind = "127.0.0.1:8000"
|
||
workers = multiprocessing.cpu_count() * 2 + 1
|
||
worker_class = "sync"
|
||
timeout = 30
|
||
```
|
||
Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
|
||
|
||
### Application Logging
|
||
- `RotatingFileHandler` → `logs/jqc.log` (5 × 5 MB)
|
||
- `StreamHandler` → stdout (journalctl)
|
||
- Format: `[YYYY-MM-DD HH:MM:SS] LEVEL in module: message`
|
||
|
||
### Nginx
|
||
- `client_max_body_size 50M`
|
||
- Passes `X-Forwarded-For`
|
||
|
||
### Recommended Cron Schedule
|
||
```bash
|
||
0 7 * * * curl -s -X POST https://your-domain.com/notifications/send-digest \
|
||
-d "token=SECRET&frequency=daily"
|
||
*/30 * * * * curl -s -X POST https://your-domain.com/notifications/check-sla \
|
||
-d "token=SECRET"
|
||
0 3 * * * curl -s -X POST https://your-domain.com/notifications/cleanup-tokens \
|
||
-d "token=SECRET"
|
||
0 8 * * * curl -s -X POST https://your-domain.com/scheduled-reports/run \
|
||
-d "secret=SECRET"
|
||
```
|
||
|
||
---
|
||
|
||
## 19. Known Constraints & Hard Rules
|
||
|
||
| # | Rule | Rationale |
|
||
|---|---|---|
|
||
| 1 | **No SSE** | Exhausted Gunicorn sync worker pool |
|
||
| 2 | **`now_eastern()` always** | `utcnow()` caused incorrect SLA cutoffs |
|
||
| 3 | **3-step MySQL ENUM changes** | Skipping causes data loss |
|
||
| 4 | **Port 465 → SSL; 587 → STARTTLS** | Both True breaks Flask-Mail |
|
||
| 5 | **`CSRFProtect` as extension instance** | Flask-WTF 1.0+ removed per-route `@csrf_exempt` |
|
||
| 6 | **`supervisor_required` name preserved** | Renaming would touch 30+ route decorators |
|
||
| 7 | **Score 0 = unanswered** | Excluded from calculation — not the same as scoring zero |
|
||
| 8 | **12-column grid in PDF** | Must not collapse in print/PDF |
|
||
| 9 | **No nested `<form>` tags** | Browsers silently discard inner forms |
|
||
| 10 | **`log_action()` after `db.session.commit()`** | Entity ID must exist before audit capture |
|
||
| 11 | **`db.session.get(Model, id)` not `Model.query.get(id)`** | SQLAlchemy 2.x deprecation |
|
||
| 12 | **`filter()` before `limit()`** | SQLAlchemy ordering requirement |
|
||
| 13 | **Bulk queries in customer list** | Per-customer loops cause N+1 |
|
||
| 14 | **Email in background thread** | Never block HTTP response |
|
||
| 15 | **Open-redirect guards** | `_safe_next()` in auth.py; `_safe_referrer()` in customers.py |
|
||
| 16 | **`CREATE INDEX IF NOT EXISTS` not on MySQL < 8.0.1** | Use `information_schema` — see phase12 |
|
||
| 17 | **Set `REDIS_URL` in production** | `memory://` is per-process; multi-worker Gunicorn needs Redis for accurate shared counters |
|
||
| 18 | **"Project" → "Contract" is UI-only** | Backend identifiers unchanged |
|
||
| 19 | **`display_name` not `username` in templates** | Respects full_name; username is login identity only |
|
||
| 20 | **`open_issues` count derived from `len()` not `.count()`** | Avoids a second DB round-trip on the dashboard |
|
||
| 21 | **`supervisor` removed from Python-side ENUM** | Phase 11 migration complete — both DB and model ENUM must stay in sync |
|
||
| 22 | **SLA filter loads full result set (no paginate)** | SLA status is computed in Python; `_SLAFilteredPage` wrapper satisfies the template pagination interface |
|
||
| 23 | **`hmac.compare_digest()` for token comparison** | Prevents timing oracle attacks on `verify_set_password_token` |
|
||
| 24 | **`get_customer_scope()` uses bulk project query** | Single `Facility.project_id.in_(project_ids)` replaces per-assignment loop |
|
||
| 25 | **CSV exports always call `log_action(ACTION_EXPORT, ...)`** | Data exports are compliance-relevant audit events |
|
||
| 26 | **`StartInspectionForm` has no `area_id` or `notes` fields** | Both removed from the start page; `Inspection` constructor receives `area_id=None, notes=None` explicitly |
|
||
| 27 | **Customer set-password page collects username + password** | `SetPasswordForm` includes `username` field; route saves it to `user.username`, replacing the auto-generated placeholder |
|
||
| 28 | **`verify_set_password_token` requires `password_set=False`** | Prevents reuse of a stale token on an already-activated account |
|
||
| 29 | **Facilities list grouped by Contract in route, not template** | `list_facilities()` builds a `grouped` OrderedDict before rendering; template iterates `grouped`, not the flat `facilities` list |
|
||
|
||
---
|
||
|
||
## 20. Change Philosophy
|
||
|
||
1. **Surgical, additive patches** — smallest possible change to achieve the goal
|
||
2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise
|
||
3. **Never remove existing functionality** unless explicitly directed
|
||
4. **Log all create/update/delete actions** via `log_action()`
|
||
5. **Migration existence checks** — all migrations safe to re-run
|
||
6. **Full file contents for 1–3 file changes**; deployment map for larger changesets
|
||
7. **Explicit deploy instructions** — migration steps separated from code steps
|
||
8. **Root cause analysis** on errors — never apply temporary workarounds |