Jul 7 - Implement additional recipient per contract
This commit is contained in:
@@ -296,8 +296,13 @@ 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
|
||||
project_notification_recipients: id, project_id (FK→projects CASCADE), ← phase37
|
||||
user_id (FK→users CASCADE, nullable), email VARCHAR(255) nullable,
|
||||
events TEXT (JSON list of MATRIX_EVENTS keys), created_at
|
||||
```
|
||||
|
||||
**`project_notification_recipients` (phase37):** Per-contract additional notification recipients, layered on top of the global notification matrix. Exactly one of `user_id` (staff → in-app + email via `notify()`) / `email` (external → email only) is set — enforced in the route layer, not by a DB constraint. `notify_by_matrix()` calls `_notify_project_recipients()` AFTER the matrix roles and global custom emails: it resolves the contract via `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`, then notifies every recipient of that contract subscribed to the event. Deduplicated against matrix-role notifications (user IDs) and global custom emails (lowercased). Managed at `/projects/<id>/recipients` (`@supervisor_required`); re-adding an existing recipient replaces its event list (upsert).
|
||||
|
||||
### Inspection
|
||||
|
||||
```
|
||||
@@ -494,7 +499,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
|
||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix`, `/mfa` (login 2FA challenge), `/mfa/setup` + `/mfa/disable` (phase35, `@supervisor_required` enroll/disable) |
|
||||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||
| `facilities` | `/facilities` | CRUD + area management |
|
||||
| `projects` | `/projects` | CRUD + customer assignment management |
|
||||
| `projects` | `/projects` | CRUD + customer assignment management + per-contract notification recipients (`GET /<id>/recipients`, `POST /<id>/recipients/add`, `POST /recipients/<rid>/remove` — `@supervisor_required`, phase37) |
|
||||
| `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 |
|
||||
@@ -776,7 +781,7 @@ limiter = Limiter(
|
||||
|
||||
## 17. Alembic Migration Chain
|
||||
|
||||
**Current HEAD:** `phase36_issue_work_orders` (34 migrations total).
|
||||
**Current HEAD:** `phase37_project_notification_recipients` (35 migrations total).
|
||||
|
||||
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
||||
|
||||
@@ -807,7 +812,18 @@ limiter = Limiter(
|
||||
→ phase33_tenant_settings
|
||||
→ phase34_inspection_schedules
|
||||
→ phase35_user_mfa
|
||||
→ phase36_issue_work_orders ← HEAD
|
||||
→ phase36_issue_work_orders
|
||||
→ phase37_project_notification_recipients ← HEAD
|
||||
```
|
||||
|
||||
### phase37_project_notification_recipients
|
||||
|
||||
Creates the `project_notification_recipients` table backing per-contract additional notification recipients (see §5 model + the `/projects/<id>/recipients` routes). Each row subscribes one recipient — a staff User (in-app + email) or an external email address (email only) — to a chosen set of notification-matrix event types, scoped to events occurring in that contract's facilities. Dispatched by `notify_by_matrix()` → `_notify_project_recipients()`. Guarded by an `INFORMATION_SCHEMA` table-existence check — safe to re-run.
|
||||
|
||||
**Deploy order:**
|
||||
```bash
|
||||
flask db upgrade
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
### phase36_issue_work_orders
|
||||
@@ -1317,6 +1333,7 @@ set -a; . /etc/jqc/control.env; set +a
|
||||
| 87 | **MFA is opt-in, TOTP-based, with hashed one-time recovery codes** | `app/utils/mfa.py` (data plane) and `control/mfa.py` (panel) are pure-logic mirrors — keep them in sync (same rule class as `time_utils`). The login challenge (`/auth/mfa`, panel `/mfa`) fires for ANY account with `mfa_enabled=1`; `login_user()`/`session['sa_id']` is deferred until the code passes. Recovery codes are stored ONLY as werkzeug hashes and are single-use (consumed on match). Disable requires a current TOTP code OR the password. **Lock-out escape hatch:** because MFA is per-account opt-in, the recovery path is the primary unlock; the operational last resort is a DB update `UPDATE users SET mfa_enabled=0, mfa_secret=NULL, mfa_recovery_codes=NULL WHERE username=...` (or the same on `superadmins`). Do not store `mfa_secret`/recovery codes in plaintext, and do not skip the deferred-login pattern. |
|
||||
| 89 | **Vendor work-order pages are public and token-authorized — the token IS the credential** | `GET/POST /work-orders/<token>` have NO `@login_required`; the unguessable `secrets.token_urlsafe(32)` token is the sole authorization, so never render one in any staff-visible page, log line, or list except in the contractor's own emailed link. Rate-limited (`60/hr` view, `20/hr` update). The public page shows only scoped issue details (facility, area, description, severity, staff message) — never internal notes/comments/assignees. State transitions are one-way and guarded (`sent→acknowledged→completed`); a completed order ignores further actions. Completing an order sets the parent issue to `pending_verification` (staff still sign off — the vendor cannot self-resolve). In MT mode the link resolves to the right tenant by Host, so the route is NOT tenant-exempt. |
|
||||
| 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. |
|
||||
| 90 | **Per-contract recipients dispatch INSIDE `notify_by_matrix()` — never call `_notify_project_recipients()` from routes** | phase37. Contract-scoped recipients (`ProjectNotificationRecipient`) are dispatched automatically at the end of `notify_by_matrix()`, after matrix roles + global custom emails, with dedup against both. The contract is resolved from `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`; events fired without any facility context reach matrix recipients only. New `notify_by_matrix()` call sites should pass `facility_id` (or `issue_id`/`inspection_id`) so contract recipients fire. The `score_alert` cron call in `sla.py` now passes `facility_id=fid` for this reason (side effect: if the matrix ever enables `customer` for `score_alert`, customers are facility-scoped instead of org-wide — a strict improvement). Staff recipients use `respect_preferences=False` (contract config is the authority, same as matrix broadcasts). |
|
||||
|
||||
---
|
||||
|
||||
@@ -1674,7 +1691,7 @@ Ask: Does this change break any other code path that uses the modified function,
|
||||
**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified).
|
||||
|
||||
**Rule 14 — Migrations are required for any schema change.**
|
||||
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase36_issue_work_orders`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
|
||||
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase37_project_notification_recipients`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
|
||||
|
||||
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user