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/`.
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ def create_app(config_name='default'):
|
||||
from app.routes import customers # Phase 5 — Customer management
|
||||
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
||||
from app.routes import inspection_schedules # phase34 — recurring inspections
|
||||
from app.routes import work_orders # phase36 — vendor work orders (public tokenized)
|
||||
from app.routes import support # Support chat + admin tickets
|
||||
from app.routes import broadcast # Admin broadcast messages
|
||||
from app.routes import devices # Admin device management
|
||||
@@ -223,6 +224,7 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(customers.bp)
|
||||
app.register_blueprint(scheduled_reports.bp)
|
||||
app.register_blueprint(inspection_schedules.bp)
|
||||
app.register_blueprint(work_orders.bp)
|
||||
app.register_blueprint(support.bp)
|
||||
app.register_blueprint(broadcast.bp)
|
||||
app.register_blueprint(devices.bp)
|
||||
|
||||
@@ -6,4 +6,5 @@ from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
@@ -33,6 +33,10 @@ EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all
|
||||
# notifies the assigned inspector (phase34).
|
||||
EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled'
|
||||
|
||||
# Fired when an external contractor acknowledges or completes a vendor work
|
||||
# order via the tokenized public link (phase36).
|
||||
EVENT_WORK_ORDER = 'work_order_update'
|
||||
|
||||
ALL_EVENT_TYPES = {
|
||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||
@@ -43,6 +47,7 @@ ALL_EVENT_TYPES = {
|
||||
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
|
||||
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
||||
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
|
||||
EVENT_WORK_ORDER: 'Contractor updated a work order',
|
||||
# Customer-facing — only relevant for customer role accounts
|
||||
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
app/models/work_order.py
|
||||
------------------------
|
||||
Vendor work orders (phase36).
|
||||
|
||||
A work order dispatches an existing Issue to an external contractor by email.
|
||||
The contractor opens a tokenized public link (no account required) to see the
|
||||
scoped issue details and to Acknowledge and then mark the work Completed. Staff
|
||||
see the work-order status on the issue and are notified on each vendor action.
|
||||
|
||||
Builds on the existing free-text `vendor_*` fields on Issue (phase26) — a work
|
||||
order is the "send it and track it" layer on top of that.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class IssueWorkOrder(db.Model):
|
||||
__tablename__ = 'issue_work_orders'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
issue_id = db.Column(
|
||||
db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True
|
||||
)
|
||||
vendor_name = db.Column(db.String(150), nullable=False)
|
||||
vendor_email = db.Column(db.String(255), nullable=False)
|
||||
|
||||
# Unguessable public access token (URL path segment). Never exposed to
|
||||
# anyone but the vendor who receives the emailed link.
|
||||
token = db.Column(db.String(64), nullable=False, unique=True, index=True)
|
||||
|
||||
status = db.Column(
|
||||
db.Enum('sent', 'acknowledged', 'completed'),
|
||||
nullable=False, default='sent'
|
||||
)
|
||||
message = db.Column(db.Text, nullable=True) # staff → vendor instructions
|
||||
vendor_note = db.Column(db.Text, nullable=True) # vendor → staff completion note
|
||||
|
||||
sent_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
acknowledged_at = db.Column(db.DateTime, nullable=True)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
created_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
|
||||
)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
|
||||
# Relationships
|
||||
issue = db.relationship('Issue', backref=db.backref(
|
||||
'work_orders', lazy='dynamic', cascade='all, delete-orphan',
|
||||
order_by='IssueWorkOrder.created_at.desc()'))
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
|
||||
@staticmethod
|
||||
def new_token():
|
||||
"""Return a fresh unguessable token (43 url-safe chars ≈ 256 bits)."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
return {'sent': 'Sent', 'acknowledged': 'Acknowledged',
|
||||
'completed': 'Completed'}.get(self.status, self.status)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<IssueWorkOrder {self.id} issue={self.issue_id} {self.status}>'
|
||||
+47
-2
@@ -15,7 +15,7 @@ from app.models.notification import (
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||
)
|
||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.decorators import supervisor_required, project_manager_required
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
@@ -1080,4 +1080,49 @@ def export_pdf(issue_id):
|
||||
resp = make_response(pdf_bytes)
|
||||
resp.headers['Content-Type'] = 'application/pdf'
|
||||
resp.headers['Content-Disposition'] = f'attachment; filename="issue_{issue.id}.pdf"'
|
||||
return resp
|
||||
return resp
|
||||
|
||||
|
||||
# ── Vendor work order dispatch (phase36) ─────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:issue_id>/work-order', methods=['POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def dispatch_work_order(issue_id):
|
||||
"""Send this issue to an external contractor as a tokenized work order."""
|
||||
import re as _re
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
from app.routes.work_orders import send_work_order_email
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
|
||||
vendor_name = (request.form.get('vendor_name', '') or '').strip()
|
||||
vendor_email = (request.form.get('vendor_email', '') or '').strip()
|
||||
message = (request.form.get('message', '') or '').strip() or None
|
||||
|
||||
if not vendor_name or not _re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', vendor_email):
|
||||
flash('A contractor name and a valid email are required to send a work order.',
|
||||
'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue.id))
|
||||
|
||||
wo = IssueWorkOrder(
|
||||
issue_id=issue.id, vendor_name=vendor_name, vendor_email=vendor_email,
|
||||
token=IssueWorkOrder.new_token(), status='sent', message=message,
|
||||
sent_at=now_eastern(), created_by=current_user.id, created_at=now_eastern(),
|
||||
)
|
||||
# Keep the issue's free-text vendor fields in sync when they're still blank.
|
||||
if not issue.vendor_name:
|
||||
issue.vendor_name = vendor_name
|
||||
if not issue.vendor_contact:
|
||||
issue.vendor_contact = vendor_email
|
||||
if issue.status == 'open':
|
||||
issue.status = 'in_progress'
|
||||
db.session.add(wo)
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'Issue #{issue.id}',
|
||||
f'dispatched work order to {vendor_name} <{vendor_email}>')
|
||||
send_work_order_email(wo, issue, request.host_url)
|
||||
flash(f'Work order sent to {vendor_name}.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue.id))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
app/routes/work_orders.py
|
||||
-------------------------
|
||||
Vendor work orders (phase36).
|
||||
|
||||
Two surfaces:
|
||||
|
||||
* Public, tokenized, NO login — the contractor's view of a single work order.
|
||||
`GET /work-orders/<token>` → scoped issue details + action buttons
|
||||
`POST /work-orders/<token>` → acknowledge / complete
|
||||
|
||||
In multi-tenant mode this resolves to the right tenant by Host (the emailed
|
||||
link is built from the tenant's own domain), so no tenant exemption is needed.
|
||||
The token is the authorization — unguessable (256-bit), single work order.
|
||||
|
||||
* A staff dispatch helper + email sender, imported by the issues blueprint's
|
||||
`POST /issues/<id>/work-order` route.
|
||||
|
||||
Staff are notified (in-app + email per matrix/prefs) whenever a contractor
|
||||
acknowledges or completes a work order.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, abort, current_app)
|
||||
from flask_mail import Message
|
||||
|
||||
from app import db, mail, limiter
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
from app.models.issue import Issue
|
||||
from app.models.notification import EVENT_WORK_ORDER
|
||||
from app.utils.notifications import notify
|
||||
from app.utils.audit import log_action, ACTION_UPDATE
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('work_orders', __name__, url_prefix='/work-orders')
|
||||
|
||||
|
||||
# ── Email + notification helpers (imported by the issues blueprint) ───────────
|
||||
|
||||
def send_work_order_email(work_order, issue, base_url):
|
||||
"""Email the contractor their tokenized work-order link (background thread)."""
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('WORK ORDER EMAIL SKIPPED | id=%s | no MAIL_SERVER', work_order.id)
|
||||
return
|
||||
try:
|
||||
link = f"{base_url.rstrip('/')}/work-orders/{work_order.token}"
|
||||
facility = issue.resolved_facility
|
||||
fac_name = facility.name if facility else 'a facility'
|
||||
sender = current_app.config.get(
|
||||
'MAIL_DEFAULT_SENDER',
|
||||
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'))
|
||||
html = render_template('work_orders/email.html',
|
||||
work_order=work_order, issue=issue,
|
||||
facility_name=fac_name, link=link)
|
||||
text = (f"You have a new work order for {fac_name}.\n\n"
|
||||
f"Issue: {issue.description}\n"
|
||||
f"Severity: {issue.severity}\n\n"
|
||||
f"{work_order.message or ''}\n\n"
|
||||
f"Open your work order to acknowledge and mark it complete:\n{link}\n")
|
||||
msg = Message(subject=f'[Work Order] {fac_name} — action requested',
|
||||
sender=sender, recipients=[work_order.vendor_email],
|
||||
body=text, html=html)
|
||||
except Exception as exc:
|
||||
logger.error('WORK ORDER EMAIL BUILD FAILED | id=%s | err=%s', work_order.id, exc)
|
||||
return
|
||||
|
||||
app = current_app._get_current_object()
|
||||
to = work_order.vendor_email
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('WORK ORDER EMAIL SENT | to=%s', to)
|
||||
except Exception as exc:
|
||||
logger.error('WORK ORDER EMAIL FAILED | to=%s | err=%s', to, exc)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
|
||||
def _notify_staff(issue, title, body):
|
||||
"""Notify the issue's reporter, assignee, and followers of a vendor action."""
|
||||
from app.models.issue import IssueFollower
|
||||
recipient_ids = set()
|
||||
if issue.reported_by:
|
||||
recipient_ids.add(issue.reported_by)
|
||||
if issue.assigned_to:
|
||||
recipient_ids.add(issue.assigned_to)
|
||||
for f in IssueFollower.query.filter_by(issue_id=issue.id).all():
|
||||
recipient_ids.add(f.user_id)
|
||||
|
||||
from app.models.user import User
|
||||
link = url_for('issues.view', issue_id=issue.id)
|
||||
for uid in recipient_ids:
|
||||
user = db.session.get(User, uid)
|
||||
if user is None or not user.active:
|
||||
continue
|
||||
notify(user, title=title, body=body, link=link,
|
||||
issue_id=issue.id, event_type=EVENT_WORK_ORDER)
|
||||
db.session.commit() # notify() adds rows but leaves the commit to the caller
|
||||
|
||||
|
||||
# ── Public vendor pages (tokenized, no login) ─────────────────────────────────
|
||||
|
||||
def _load_or_404(token):
|
||||
wo = IssueWorkOrder.query.filter_by(token=token).first()
|
||||
if wo is None:
|
||||
abort(404)
|
||||
return wo
|
||||
|
||||
|
||||
@bp.route('/<token>')
|
||||
@limiter.limit('60 per hour')
|
||||
def view(token):
|
||||
wo = _load_or_404(token)
|
||||
issue = db.session.get(Issue, wo.issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
facility = issue.resolved_facility
|
||||
return render_template('work_orders/view.html', wo=wo, issue=issue,
|
||||
facility=facility,
|
||||
area=issue.area if issue.area_id else None)
|
||||
|
||||
|
||||
@bp.route('/<token>', methods=['POST'])
|
||||
@limiter.limit('20 per hour')
|
||||
def update(token):
|
||||
wo = _load_or_404(token)
|
||||
issue = db.session.get(Issue, wo.issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
|
||||
action = request.form.get('action', '')
|
||||
now = now_eastern()
|
||||
|
||||
if action == 'acknowledge' and wo.status == 'sent':
|
||||
wo.status = 'acknowledged'
|
||||
wo.acknowledged_at = now
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'IssueWorkOrder', wo.id,
|
||||
f'Issue #{issue.id}', f'vendor acknowledged ({wo.vendor_name})')
|
||||
_notify_staff(issue,
|
||||
title=f'Contractor acknowledged work order — Issue #{issue.id}',
|
||||
body=f'{wo.vendor_name} acknowledged the work order and is on it.')
|
||||
flash('Thank you — the work order has been acknowledged.', 'success')
|
||||
|
||||
elif action == 'complete' and wo.status in ('sent', 'acknowledged'):
|
||||
note = (request.form.get('vendor_note', '') or '').strip()
|
||||
wo.status = 'completed'
|
||||
wo.completed_at = now
|
||||
if wo.acknowledged_at is None:
|
||||
wo.acknowledged_at = now
|
||||
wo.vendor_note = note or None
|
||||
# Move the issue into the verification queue so staff sign off the fix.
|
||||
if issue.status not in ('resolved',):
|
||||
issue.status = 'pending_verification'
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'IssueWorkOrder', wo.id,
|
||||
f'Issue #{issue.id}', f'vendor completed ({wo.vendor_name})')
|
||||
_notify_staff(issue,
|
||||
title=f'Contractor completed work order — Issue #{issue.id}',
|
||||
body=(f'{wo.vendor_name} marked the work complete. '
|
||||
f'It is now pending your verification.'
|
||||
+ (f'\n\nContractor note: {note}' if note else '')))
|
||||
flash('Thank you — the work has been marked complete. The team will verify it.',
|
||||
'success')
|
||||
else:
|
||||
flash('That action is no longer available for this work order.', 'warning')
|
||||
|
||||
return redirect(url_for('work_orders.view', token=token))
|
||||
@@ -404,6 +404,53 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
|
||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set wos = issue.work_orders.all() %}
|
||||
{% if wos %}
|
||||
<ul class="list-unstyled small mb-3">
|
||||
{% for wo in wos %}
|
||||
{% set b = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %}
|
||||
<li class="d-flex justify-content-between align-items-center border-bottom py-1">
|
||||
<span class="text-truncate me-2">{{ wo.vendor_name }}
|
||||
<span class="text-muted d-block" style="font-size:.75rem;">{{ wo.vendor_email }}</span>
|
||||
</span>
|
||||
<span class="badge bg-{{ b }}">{{ wo.status_label }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('issues.dispatch_work_order', issue_id=issue.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Contractor name</label>
|
||||
<input type="text" name="vendor_name" class="form-control form-control-sm"
|
||||
value="{{ issue.vendor_name or '' }}" placeholder="e.g. Ace Plumbing" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Contractor email</label>
|
||||
<input type="email" name="vendor_email" class="form-control form-control-sm"
|
||||
placeholder="name@contractor.com" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Message <span class="text-muted">(optional)</span></label>
|
||||
<textarea name="message" class="form-control form-control-sm" rows="2"
|
||||
placeholder="Any specific instructions…"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
|
||||
<i class="bi bi-envelope-paper me-1"></i> Send Work Order
|
||||
</button>
|
||||
<div class="form-text">Emails the contractor a private link to acknowledge & complete — no account needed.</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>{# /col-lg-4 #}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f1f5f9;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1f2937;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;">
|
||||
<tr><td style="background:#1a56db;color:#ffffff;padding:20px 28px;font-size:18px;font-weight:700;">
|
||||
New Work Order
|
||||
</td></tr>
|
||||
<tr><td style="padding:24px 28px;">
|
||||
<p style="margin:0 0 12px;">Hello {{ work_order.vendor_name }},</p>
|
||||
<p style="margin:0 0 16px;">You've been assigned a work order at <strong>{{ facility_name }}</strong>.</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;margin-bottom:16px;">
|
||||
<tr><td style="padding:14px 16px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;color:#6b7280;margin-bottom:4px;">What needs doing</div>
|
||||
<div style="margin-bottom:10px;">{{ issue.description }}</div>
|
||||
<div style="font-size:13px;color:#6b7280;">Severity: {{ issue.severity }}</div>
|
||||
{% if work_order.message %}
|
||||
<div style="margin-top:12px;padding-top:12px;border-top:1px solid #eee;font-size:14px;">
|
||||
<strong>Note:</strong> {{ work_order.message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 20px;">Open your work order to acknowledge it and mark it complete when done:</p>
|
||||
<p style="text-align:center;margin:0 0 8px;">
|
||||
<a href="{{ link }}" style="display:inline-block;background:#1a56db;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:8px;font-weight:600;">
|
||||
Open Work Order
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:12px;color:#9aa3af;text-align:center;margin:12px 0 0;word-break:break-all;">{{ link }}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:16px 28px;background:#f8fafc;color:#9aa3af;font-size:12px;">
|
||||
This link is private to you — please don't forward it.
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Order — {{ facility.name if facility else 'JQC' }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background:#f1f5f9; color:#1f2937; }
|
||||
.wo-wrap { max-width:640px; margin:2rem auto; padding:0 1rem; }
|
||||
.sev-critical{background:#dc2626}.sev-high{background:#ea580c}
|
||||
.sev-medium{background:#d97706}.sev-low{background:#64748b}
|
||||
@media (max-width:576px){ .wo-wrap{ margin:1rem auto; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wo-wrap">
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-clipboard-check-fill fs-3 text-primary"></i>
|
||||
<div>
|
||||
<div class="fw-bold">Contractor Work Order</div>
|
||||
<div class="text-muted small">{{ facility.name if facility else 'Facility' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'success' if category == 'success' else 'warning' if category == 'warning' else 'info' }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="badge sev-{{ issue.severity }} text-white text-uppercase">{{ issue.severity }}</span>
|
||||
{% set badge = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %}
|
||||
<span class="badge bg-{{ badge }}">{{ wo.status_label }}</span>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm mb-3">
|
||||
<tr><th class="text-muted" style="width:120px;">Facility</th><td>{{ facility.name if facility else '—' }}</td></tr>
|
||||
{% if area %}<tr><th class="text-muted">Area</th><td>{{ area.name }}</td></tr>{% endif %}
|
||||
<tr><th class="text-muted">Reported</th><td>{{ issue.reported_at.strftime('%b %d, %Y') if issue.reported_at else '—' }}</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-muted small text-uppercase mb-1">What needs doing</div>
|
||||
<div>{{ issue.description }}</div>
|
||||
</div>
|
||||
|
||||
{% if wo.message %}
|
||||
<div class="mt-3 p-2 bg-light rounded">
|
||||
<div class="text-muted small text-uppercase mb-1">Note from the team</div>
|
||||
<div>{{ wo.message }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if wo.status == 'completed' %}
|
||||
<div class="alert alert-success">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>
|
||||
You marked this complete on {{ wo.completed_at.strftime('%b %d, %Y') if wo.completed_at else 'file' }}.
|
||||
The team will verify the work. No further action is needed.
|
||||
</div>
|
||||
{% else %}
|
||||
{% if wo.status == 'sent' %}
|
||||
<form method="POST" action="{{ url_for('work_orders.update', token=wo.token) }}" class="mb-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="acknowledge">
|
||||
<button type="submit" class="btn btn-outline-primary w-100">
|
||||
<i class="bi bi-hand-thumbs-up me-1"></i> Acknowledge — I'll handle this
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('work_orders.update', token=wo.token) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="complete">
|
||||
<label class="form-label fw-semibold">Mark work complete</label>
|
||||
<textarea name="vendor_note" class="form-control mb-2" rows="3"
|
||||
placeholder="Optional: describe what you did…"></textarea>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-check2-circle me-1"></i> Mark as Completed
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-center text-muted small mt-4">This link is private to you. Please don't share it.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
"""phase36 — vendor 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 issue to
|
||||
an external contractor via a tokenized public link; the contractor acknowledges
|
||||
and completes it without an account.
|
||||
|
||||
Idempotent: guarded by an INFORMATION_SCHEMA table-existence check so it is safe
|
||||
to re-run across every tenant DB (CLAUDE.md rule 14).
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'phase36_issue_work_orders'
|
||||
down_revision = 'phase35_user_mfa'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table: str) -> bool:
|
||||
result = bind.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.tables "
|
||||
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||
), {'t': table})
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, 'issue_work_orders'):
|
||||
op.execute(sa.text("""
|
||||
CREATE TABLE issue_work_orders (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
issue_id INT NOT NULL,
|
||||
vendor_name VARCHAR(150) NOT NULL,
|
||||
vendor_email VARCHAR(255) NOT NULL,
|
||||
token VARCHAR(64) NOT NULL,
|
||||
status ENUM('sent','acknowledged','completed')
|
||||
NOT NULL DEFAULT 'sent',
|
||||
message TEXT NULL,
|
||||
vendor_note TEXT NULL,
|
||||
sent_at DATETIME NOT NULL,
|
||||
acknowledged_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
CONSTRAINT fk_wo_issue FOREIGN KEY (issue_id)
|
||||
REFERENCES issues(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_wo_creator FOREIGN KEY (created_by)
|
||||
REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_wo_token (token),
|
||||
INDEX ix_wo_issue (issue_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, 'issue_work_orders'):
|
||||
op.execute(sa.text('DROP TABLE issue_work_orders'))
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
tests/test_work_orders.py
|
||||
-------------------------
|
||||
End-to-end tests for the phase36 vendor work-order workflow.
|
||||
|
||||
* a manager dispatches an issue to a contractor (issue moves to in_progress)
|
||||
* the contractor opens the tokenized public page (no login) and acknowledges
|
||||
* the contractor marks it complete → issue moves to pending_verification
|
||||
* staff (the reporter) get notified on each vendor action
|
||||
* an unknown token 404s
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
from app import db, limiter
|
||||
limiter.enabled = False
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
app.config['SQLALCHEMY_ECHO'] = False
|
||||
with app.app_context():
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
yield app.test_client()
|
||||
db.session.remove()
|
||||
limiter.enabled = True
|
||||
|
||||
|
||||
def _seed():
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility
|
||||
from app.models.issue import Issue
|
||||
|
||||
admin = User(username='mgr', full_name='Manager', email='mgr@x.com',
|
||||
role='admin', active=True, password_set=True)
|
||||
admin.set_password('pw-correct1')
|
||||
reporter = User(username='insp', full_name='Reporter', email='insp@x.com',
|
||||
role='inspector', active=True, password_set=True)
|
||||
reporter.set_password('pw-correct1')
|
||||
fac = Facility(name='Main Office', active=True)
|
||||
db.session.add_all([admin, reporter, fac])
|
||||
db.session.commit()
|
||||
|
||||
issue = Issue(facility_id=fac.id, severity='high', description='Leaking faucet',
|
||||
status='open', reported_by=reporter.id)
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
return admin.id, reporter.id, issue.id
|
||||
|
||||
|
||||
def _login(client, username):
|
||||
return client.post('/auth/login', data={'username': username, 'password': 'pw-correct1'})
|
||||
|
||||
|
||||
def test_full_work_order_lifecycle(client):
|
||||
from app import db
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
from app.models.issue import Issue
|
||||
from app.models.notification import Notification
|
||||
|
||||
admin_id, reporter_id, issue_id = _seed()
|
||||
|
||||
# 1. Manager dispatches the work order.
|
||||
_login(client, 'mgr')
|
||||
resp = client.post(f'/issues/{issue_id}/work-order',
|
||||
data={'vendor_name': 'Ace Plumbing',
|
||||
'vendor_email': 'ace@plumbing.com',
|
||||
'message': 'Please fix ASAP'})
|
||||
assert resp.status_code == 302 # redirect back to issue view
|
||||
|
||||
wo = IssueWorkOrder.query.filter_by(issue_id=issue_id).first()
|
||||
assert wo is not None and wo.status == 'sent'
|
||||
assert db.session.get(Issue, issue_id).status == 'in_progress'
|
||||
token = wo.token
|
||||
client.get('/auth/logout')
|
||||
|
||||
# 2. Contractor opens the public page (no login) and acknowledges.
|
||||
assert client.get(f'/work-orders/{token}').status_code == 200
|
||||
ack = client.post(f'/work-orders/{token}', data={'action': 'acknowledge'})
|
||||
assert ack.status_code == 302
|
||||
assert db.session.get(IssueWorkOrder, wo.id).status == 'acknowledged'
|
||||
|
||||
# Reporter was notified.
|
||||
n1 = Notification.query.filter_by(user_id=reporter_id,
|
||||
event_type='work_order_update').count()
|
||||
assert n1 >= 1
|
||||
|
||||
# 3. Contractor marks it complete with a note.
|
||||
done = client.post(f'/work-orders/{token}',
|
||||
data={'action': 'complete', 'vendor_note': 'Replaced washer'})
|
||||
assert done.status_code == 302
|
||||
wo2 = db.session.get(IssueWorkOrder, wo.id)
|
||||
assert wo2.status == 'completed'
|
||||
assert wo2.vendor_note == 'Replaced washer'
|
||||
# Issue is now awaiting staff verification.
|
||||
assert db.session.get(Issue, issue_id).status == 'pending_verification'
|
||||
# Reporter notified again.
|
||||
assert Notification.query.filter_by(user_id=reporter_id,
|
||||
event_type='work_order_update').count() >= 2
|
||||
|
||||
|
||||
def test_unknown_token_404(client):
|
||||
_seed()
|
||||
assert client.get('/work-orders/definitely-not-a-real-token').status_code == 404
|
||||
|
||||
|
||||
def test_dispatch_requires_valid_email(client):
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
_, _, issue_id = _seed()
|
||||
_login(client, 'mgr')
|
||||
client.post(f'/issues/{issue_id}/work-order',
|
||||
data={'vendor_name': 'Ace', 'vendor_email': 'not-an-email'})
|
||||
assert IssueWorkOrder.query.count() == 0 # rejected, nothing created
|
||||
|
||||
|
||||
def test_completed_order_ignores_further_actions(client):
|
||||
from app import db
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
|
||||
_, _, issue_id = _seed()
|
||||
_login(client, 'mgr')
|
||||
client.post(f'/issues/{issue_id}/work-order',
|
||||
data={'vendor_name': 'Ace', 'vendor_email': 'ace@x.com'})
|
||||
token = IssueWorkOrder.query.first().token
|
||||
client.get('/auth/logout')
|
||||
|
||||
client.post(f'/work-orders/{token}', data={'action': 'complete'})
|
||||
wo = IssueWorkOrder.query.first()
|
||||
assert wo.status == 'completed'
|
||||
completed_at = wo.completed_at
|
||||
|
||||
# A second 'acknowledge' must not resurrect a completed order.
|
||||
client.post(f'/work-orders/{token}', data={'action': 'acknowledge'})
|
||||
wo = db.session.get(IssueWorkOrder, wo.id)
|
||||
assert wo.status == 'completed'
|
||||
assert wo.completed_at == completed_at
|
||||
Reference in New Issue
Block a user