04/27 Updated documents

This commit is contained in:
2026-04-27 13:56:25 -04:00
parent c9bfcee3df
commit b9df76fa46
2 changed files with 35 additions and 9 deletions
+29 -7
View File
@@ -2,7 +2,7 @@
> **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)
> **Last reviewed:** April 2026 (Phase 12 complete + post-review hardening patch)
---
@@ -58,7 +58,7 @@ The application is actively deployed in production and maintained by a single de
| 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 (in-memory storage; swap for Redis in multi-worker) |
| 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 |
@@ -160,6 +160,7 @@ lt_janitorial_quality_control/
| `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
@@ -194,6 +195,8 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
**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.
**Token verification:** `User.verify_set_password_token()` uses `hmac.compare_digest()` as a constant-time comparison guard after the DB lookup and expiry check, preventing timing oracle attacks on the stored token value.
### Facility / Area
```
@@ -246,6 +249,8 @@ issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critica
**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
```
@@ -346,7 +351,7 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
`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.
`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.
@@ -430,6 +435,8 @@ customer_inspection_completed
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
@@ -439,6 +446,10 @@ SLA column shown on issues list. `issue.sla_notified` prevents duplicate cron no
- 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
@@ -460,16 +471,20 @@ Initialised in `app/__init__.py` as a module-level extension:
```python
limiter = Limiter(
key_func = get_remote_address,
key_func = get_remote_address,
default_limits = [],
storage_uri = 'memory://', # ← swap for 'redis://...' in multi-worker
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')`.
> **Multi-worker caveat:** `memory://` is per-process. With multiple Gunicorn workers the effective limit is `N × workers`. Use Redis for shared-state enforcement.
**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`.
---
@@ -505,6 +520,8 @@ 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
@@ -599,10 +616,15 @@ Logs: `/home/jqc/logs/gunicorn-error.log`, `/home/jqc/logs/gunicorn-access.log`
| 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 | **Flask-Limiter `memory://` is per-process** | Multi-worker needs Redis for shared counters |
| 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 |
---