July 4 - Update Vendor Work-Order Workflow
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user