04/27 Updated documents
This commit is contained in:
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user