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