July 4 - Update Vendor Work-Order Workflow
This commit is contained in:
@@ -406,6 +406,19 @@ inspection_schedules: id, name VARCHAR(255), template_id (FK→inspection_templa
|
||||
|
||||
Recurring inspection generator. `POST /inspection-schedules/run` (token-protected cron) walks active schedules where `next_run_at <= now`, creates one `in_progress` Inspection per due schedule (assigned to `inspector_id`, dated now), notifies the inspector (`event_type='inspection_scheduled'`), then advances `next_run_at`. Managed at `/inspection-schedules` by admin/director/project_manager. Purely additive — a schedule is an automated `inspections.start()`.
|
||||
|
||||
### IssueWorkOrder (phase36)
|
||||
|
||||
```
|
||||
issue_work_orders: id, issue_id (FK→issues CASCADE), vendor_name VARCHAR(150),
|
||||
vendor_email VARCHAR(255), token VARCHAR(64) UNIQUE,
|
||||
status ENUM(sent/acknowledged/completed), message TEXT,
|
||||
vendor_note TEXT, sent_at, acknowledged_at, completed_at DATETIME,
|
||||
created_by (FK→users SET NULL), created_at DATETIME
|
||||
INDEX ix_wo_issue (issue_id)
|
||||
```
|
||||
|
||||
Vendor work-order dispatch. Staff (admin/director/PM) send an issue to an external contractor via `POST /issues/<id>/work-order`, which creates a row with an unguessable `token` and emails the contractor a link. The contractor uses the public, login-less pages (`GET/POST /work-orders/<token>`) to **Acknowledge** and **Complete** the work — the token is the sole authorization. On completion the parent issue moves to `pending_verification` so staff sign off. Each vendor action notifies the issue's reporter/assignee/followers (`event_type='work_order_update'`). Builds on the free-text `vendor_*` Issue fields (phase26).
|
||||
|
||||
### AuditLog
|
||||
|
||||
```
|
||||
@@ -491,6 +504,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
|
||||
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
||||
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
|
||||
| `inspection_schedules` | `/inspection-schedules` | phase34 — recurring inspection CRUD (`@project_manager_required`) + `POST /run-now` (manual) + `POST /run` (token-protected cron materialiser) |
|
||||
| `work_orders` | `/work-orders` | phase36 — **public, login-less** vendor pages: `GET /<token>` (contractor view) + `POST /<token>` (acknowledge/complete). Token is the authorization. Staff dispatch is `POST /issues/<id>/work-order` on the `issues` blueprint (`@project_manager_required`). |
|
||||
| `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
||||
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` — admin-only push to iOS via Notification rows (phase29) |
|
||||
| `devices` | `/admin/devices` | `GET /` (registered device list), `POST /notify` — notify users on outdated app versions (reads `api_device_tokens`) |
|
||||
@@ -694,6 +708,7 @@ EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
|
||||
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
|
||||
EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled' ← phase34
|
||||
EVENT_WORK_ORDER = 'work_order_update' ← phase36
|
||||
```
|
||||
|
||||
### Cron Endpoints (all require `token=DIGEST_SECRET`)
|
||||
@@ -761,7 +776,7 @@ limiter = Limiter(
|
||||
|
||||
## 17. Alembic Migration Chain
|
||||
|
||||
**Current HEAD:** `phase35_user_mfa` (33 migrations total).
|
||||
**Current HEAD:** `phase36_issue_work_orders` (34 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`.
|
||||
|
||||
@@ -791,7 +806,18 @@ limiter = Limiter(
|
||||
→ phase32_device_token_columns
|
||||
→ phase33_tenant_settings
|
||||
→ phase34_inspection_schedules
|
||||
→ phase35_user_mfa ← HEAD
|
||||
→ phase35_user_mfa
|
||||
→ phase36_issue_work_orders ← HEAD
|
||||
```
|
||||
|
||||
### phase36_issue_work_orders
|
||||
|
||||
Creates the `issue_work_orders` table backing the vendor work-order workflow (see §5 model + the `work_orders` blueprint). A work order dispatches an existing Issue to an external contractor by email; the contractor opens a tokenized public link (no account) to acknowledge and complete it. Guarded by an `INFORMATION_SCHEMA` table-existence check — safe to re-run.
|
||||
|
||||
**Deploy order:**
|
||||
```bash
|
||||
flask db upgrade
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
### phase35_user_mfa
|
||||
@@ -1289,6 +1315,7 @@ set -a; . /etc/jqc/control.env; set +a
|
||||
| 85 | **Apex host serves the public landing page; the landing route lives at `/welcome`, NOT `/`** | The dashboard owns `/` (login-gated) on tenant hosts, so the landing page cannot register a second `/` route (same collision class as rule 84). Instead the tenant middleware detects the apex host (`TENANT_BASE_DOMAIN` + `www.`) and calls `landing.index` directly for `/`, redirecting other non-exempt apex paths to `/`. `/welcome`, `/signup`, `/static/` are tenant-exempt. **The apex check runs BEFORE the `MULTI_TENANT_ENABLED` gate** — it must work in single-tenant mode too, otherwise the app serves its default database (tenant-zero) for the apex host and the landing page never shows. Requires the Nginx apex block to **proxy** (not 301-redirect) to port 8000 with `Host` passed through, and `TENANT_BASE_DOMAIN` set correctly in the app environment. |
|
||||
| 86 | **Free plan is free-forever, not a trial** | `signup.index()` passes `trial_days=0` for `plan_code == 'free'`; `create_tenant()` then sets `subscription_status='active'` (no `trial_ends_at`) so `_billing_gate()` never blocks it. Paid plans keep the 14-day trial (`trial_days=14`). The welcome email adapts via `trial_note` and hides the trial row when `trial_ends_at` is blank. Do not reintroduce a hardcoded `trial_days=14` in the signup path. |
|
||||
| 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. |
|
||||
|
||||
---
|
||||
@@ -1647,7 +1674,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 (`phase35_user_mfa`). 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 (`phase36_issue_work_orders`). 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