""" 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/` → scoped issue details + action buttons `POST /work-orders/` → 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//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('/') @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('/', 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))