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
+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>