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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ A production-grade web application for managing janitorial service contracts, fa
|
||||
| ORM / Migrations | SQLAlchemy + Alembic |
|
||||
| Auth (web) | Flask-Login + Flask-WTF (CSRF) |
|
||||
| Auth (API) | JWT + opaque refresh tokens |
|
||||
| Rate limiting | Flask-Limiter |
|
||||
| Rate limiting | Flask-Limiter (Redis-backed in production; in-process fallback for dev) |
|
||||
| Email | Flask-Mail (SMTP) |
|
||||
| PDF | ReportLab |
|
||||
| Frontend | Bootstrap 5, Chart.js, Jinja2 |
|
||||
@@ -88,6 +88,9 @@ MAIL_DEFAULT_SENDER=noreply@example.com
|
||||
# Application
|
||||
APP_BASE_URL=https://your-domain.com
|
||||
DIGEST_SECRET=<random-secret-for-cron-auth>
|
||||
|
||||
# Rate limiting (optional — recommended for production multi-worker deployments)
|
||||
# REDIS_URL=redis://127.0.0.1:6379/0
|
||||
```
|
||||
|
||||
> **Email SSL:** Port 465 uses implicit SSL; port 587 uses STARTTLS. The application auto-detects based on `MAIL_PORT` — never set both flags to True.
|
||||
@@ -118,7 +121,7 @@ gunicorn -c gunicorn_config.py wsgi:app
|
||||
|
||||
The included `gunicorn_config.py` binds to `127.0.0.1:8000` with sync workers. Log files are written to `/home/jqc/logs/`.
|
||||
|
||||
> **Rate limiting note:** Flask-Limiter uses in-process memory storage by default. With multiple Gunicorn workers each process maintains its own counter. For accurate shared-state enforcement across workers, set `storage_uri = 'redis://localhost:6379'` in `app/__init__.py` and install Redis.
|
||||
> **Rate limiting:** Flask-Limiter uses Redis when `REDIS_URL` is set in the environment, falling back to in-process memory for local development. In production with multiple Gunicorn workers, set `REDIS_URL=redis://127.0.0.1:6379/0` so rate-limit counters are shared across all workers. The `redis` package is included in `requirements.txt`.
|
||||
|
||||
### Nginx (recommended configuration)
|
||||
|
||||
@@ -286,6 +289,7 @@ flask db downgrade
|
||||
| `APP_BASE_URL` | `""` | Base URL for links in emails |
|
||||
| `DIGEST_SECRET` | — | Authenticates all cron endpoints |
|
||||
| `MAX_CONTENT_LENGTH` | `50MB` | Maximum upload size per request |
|
||||
| `REDIS_URL` | — | Redis connection URI for shared rate-limit storage; optional but recommended in production |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user