Files

176 lines
6.9 KiB
Python

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