July 4 - Update Vendor Work-Order Workflow

This commit is contained in:
2026-07-04 16:25:31 -04:00
parent 03ce083691
commit 8f971a403b
12 changed files with 718 additions and 6 deletions
+30 -3
View File
@@ -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/`.
+2
View File
@@ -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)
+2 -1
View File
@@ -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
+5
View File
@@ -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)',
+69
View File
@@ -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
View File
@@ -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))
+175
View File
@@ -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))
+47
View File
@@ -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 &amp; complete — no account needed.</div>
</form>
</div>
</div>
{% endif %}
</div>{# /col-lg-4 #}
</div>
+42
View File
@@ -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>
+100
View File
@@ -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'))
+138
View File
@@ -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