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