diff --git a/CLAUDE.md b/CLAUDE.md index 923dabe..e7fa6a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -406,6 +406,19 @@ inspection_schedules: id, name VARCHAR(255), template_id (FK→inspection_templa Recurring inspection generator. `POST /inspection-schedules/run` (token-protected cron) walks active schedules where `next_run_at <= now`, creates one `in_progress` Inspection per due schedule (assigned to `inspector_id`, dated now), notifies the inspector (`event_type='inspection_scheduled'`), then advances `next_run_at`. Managed at `/inspection-schedules` by admin/director/project_manager. Purely additive — a schedule is an automated `inspections.start()`. +### IssueWorkOrder (phase36) + +``` +issue_work_orders: id, issue_id (FK→issues CASCADE), vendor_name VARCHAR(150), + vendor_email VARCHAR(255), token VARCHAR(64) UNIQUE, + status ENUM(sent/acknowledged/completed), message TEXT, + vendor_note TEXT, sent_at, acknowledged_at, completed_at DATETIME, + created_by (FK→users SET NULL), created_at DATETIME + INDEX ix_wo_issue (issue_id) +``` + +Vendor work-order dispatch. Staff (admin/director/PM) send an issue to an external contractor via `POST /issues//work-order`, which creates a row with an unguessable `token` and emails the contractor a link. The contractor uses the public, login-less pages (`GET/POST /work-orders/`) to **Acknowledge** and **Complete** the work — the token is the sole authorization. On completion the parent issue moves to `pending_verification` so staff sign off. Each vendor action notifies the issue's reporter/assignee/followers (`event_type='work_order_update'`). Builds on the free-text `vendor_*` Issue fields (phase26). + ### AuditLog ``` @@ -491,6 +504,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were ** | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | | `inspection_schedules` | `/inspection-schedules` | phase34 — recurring inspection CRUD (`@project_manager_required`) + `POST /run-now` (manual) + `POST /run` (token-protected cron materialiser) | +| `work_orders` | `/work-orders` | phase36 — **public, login-less** vendor pages: `GET /` (contractor view) + `POST /` (acknowledge/complete). Token is the authorization. Staff dispatch is `POST /issues//work-order` on the `issues` blueprint (`@project_manager_required`). | | `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` — admin-only push to iOS via Notification rows (phase29) | | `devices` | `/admin/devices` | `GET /` (registered device list), `POST /notify` — notify users on outdated app versions (reads `api_device_tokens`) | @@ -694,6 +708,7 @@ EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed' EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' EVENT_SCORE_ALERT = 'score_alert' ← Phase 27 EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled' ← phase34 +EVENT_WORK_ORDER = 'work_order_update' ← phase36 ``` ### Cron Endpoints (all require `token=DIGEST_SECRET`) @@ -761,7 +776,7 @@ limiter = Limiter( ## 17. Alembic Migration Chain -**Current HEAD:** `phase35_user_mfa` (33 migrations total). +**Current HEAD:** `phase36_issue_work_orders` (34 migrations total). **Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`. @@ -791,7 +806,18 @@ limiter = Limiter( → phase32_device_token_columns → phase33_tenant_settings → phase34_inspection_schedules - → phase35_user_mfa ← HEAD + → phase35_user_mfa + → phase36_issue_work_orders ← HEAD +``` + +### phase36_issue_work_orders + +Creates the `issue_work_orders` table backing the vendor work-order workflow (see §5 model + the `work_orders` blueprint). A work order dispatches an existing Issue to an external contractor by email; the contractor opens a tokenized public link (no account) to acknowledge and complete it. Guarded by an `INFORMATION_SCHEMA` table-existence check — safe to re-run. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn ``` ### phase35_user_mfa @@ -1289,6 +1315,7 @@ set -a; . /etc/jqc/control.env; set +a | 85 | **Apex host serves the public landing page; the landing route lives at `/welcome`, NOT `/`** | The dashboard owns `/` (login-gated) on tenant hosts, so the landing page cannot register a second `/` route (same collision class as rule 84). Instead the tenant middleware detects the apex host (`TENANT_BASE_DOMAIN` + `www.`) and calls `landing.index` directly for `/`, redirecting other non-exempt apex paths to `/`. `/welcome`, `/signup`, `/static/` are tenant-exempt. **The apex check runs BEFORE the `MULTI_TENANT_ENABLED` gate** — it must work in single-tenant mode too, otherwise the app serves its default database (tenant-zero) for the apex host and the landing page never shows. Requires the Nginx apex block to **proxy** (not 301-redirect) to port 8000 with `Host` passed through, and `TENANT_BASE_DOMAIN` set correctly in the app environment. | | 86 | **Free plan is free-forever, not a trial** | `signup.index()` passes `trial_days=0` for `plan_code == 'free'`; `create_tenant()` then sets `subscription_status='active'` (no `trial_ends_at`) so `_billing_gate()` never blocks it. Paid plans keep the 14-day trial (`trial_days=14`). The welcome email adapts via `trial_note` and hides the trial row when `trial_ends_at` is blank. Do not reintroduce a hardcoded `trial_days=14` in the signup path. | | 87 | **MFA is opt-in, TOTP-based, with hashed one-time recovery codes** | `app/utils/mfa.py` (data plane) and `control/mfa.py` (panel) are pure-logic mirrors — keep them in sync (same rule class as `time_utils`). The login challenge (`/auth/mfa`, panel `/mfa`) fires for ANY account with `mfa_enabled=1`; `login_user()`/`session['sa_id']` is deferred until the code passes. Recovery codes are stored ONLY as werkzeug hashes and are single-use (consumed on match). Disable requires a current TOTP code OR the password. **Lock-out escape hatch:** because MFA is per-account opt-in, the recovery path is the primary unlock; the operational last resort is a DB update `UPDATE users SET mfa_enabled=0, mfa_secret=NULL, mfa_recovery_codes=NULL WHERE username=...` (or the same on `superadmins`). Do not store `mfa_secret`/recovery codes in plaintext, and do not skip the deferred-login pattern. | +| 89 | **Vendor work-order pages are public and token-authorized — the token IS the credential** | `GET/POST /work-orders/` have NO `@login_required`; the unguessable `secrets.token_urlsafe(32)` token is the sole authorization, so never render one in any staff-visible page, log line, or list except in the contractor's own emailed link. Rate-limited (`60/hr` view, `20/hr` update). The public page shows only scoped issue details (facility, area, description, severity, staff message) — never internal notes/comments/assignees. State transitions are one-way and guarded (`sent→acknowledged→completed`); a completed order ignores further actions. Completing an order sets the parent issue to `pending_verification` (staff still sign off — the vendor cannot self-resolve). In MT mode the link resolves to the right tenant by Host, so the route is NOT tenant-exempt. | | 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. | --- @@ -1647,7 +1674,7 @@ Ask: Does this change break any other code path that uses the modified function, **Rule 13 — List every file changed** with the exact location of each change (function name and what was modified). **Rule 14 — Migrations are required for any schema change.** -Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase35_user_mfa`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL. +Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase36_issue_work_orders`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL. Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`. diff --git a/app/__init__.py b/app/__init__.py index f3121a0..8a43b99 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -202,6 +202,7 @@ def create_app(config_name='default'): from app.routes import customers # Phase 5 — Customer management from app.routes import scheduled_reports # Phase 6 — Scheduled reports from app.routes import inspection_schedules # phase34 — recurring inspections + from app.routes import work_orders # phase36 — vendor work orders (public tokenized) from app.routes import support # Support chat + admin tickets from app.routes import broadcast # Admin broadcast messages from app.routes import devices # Admin device management @@ -223,6 +224,7 @@ def create_app(config_name='default'): app.register_blueprint(customers.bp) app.register_blueprint(scheduled_reports.bp) app.register_blueprint(inspection_schedules.bp) + app.register_blueprint(work_orders.bp) app.register_blueprint(support.bp) app.register_blueprint(broadcast.bp) app.register_blueprint(devices.bp) diff --git a/app/models/__init__.py b/app/models/__init__.py index 67ab3d1..d685e78 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,4 +6,5 @@ from app.models.issue import Issue from app.models.project import Project, CustomerAssignment from app.models.api_token import RefreshToken, DeviceToken from app.models.notification_matrix import NotificationMatrix -from app.models.inspection_schedule import InspectionSchedule \ No newline at end of file +from app.models.inspection_schedule import InspectionSchedule +from app.models.work_order import IssueWorkOrder \ No newline at end of file diff --git a/app/models/notification.py b/app/models/notification.py index 3a27143..d3b57c5 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -33,6 +33,10 @@ EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all # notifies the assigned inspector (phase34). EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled' +# Fired when an external contractor acknowledges or completes a vendor work +# order via the tokenized public link (phase36). +EVENT_WORK_ORDER = 'work_order_update' + ALL_EVENT_TYPES = { EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', EVENT_ISSUE_STATUS: 'Issue status changed', @@ -43,6 +47,7 @@ ALL_EVENT_TYPES = { EVENT_SLA_ALERT: 'SLA at-risk / breached alerts', EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)', EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)', + EVENT_WORK_ORDER: 'Contractor updated a work order', # Customer-facing — only relevant for customer role accounts EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)', EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)', diff --git a/app/models/work_order.py b/app/models/work_order.py new file mode 100644 index 0000000..55f7cce --- /dev/null +++ b/app/models/work_order.py @@ -0,0 +1,69 @@ +""" +app/models/work_order.py +------------------------ +Vendor work orders (phase36). + +A work order dispatches an existing Issue to an external contractor by email. +The contractor opens a tokenized public link (no account required) to see the +scoped issue details and to Acknowledge and then mark the work Completed. Staff +see the work-order status on the issue and are notified on each vendor action. + +Builds on the existing free-text `vendor_*` fields on Issue (phase26) — a work +order is the "send it and track it" layer on top of that. +""" + +import secrets + +from app import db +from app.utils.time_utils import now_eastern + + +class IssueWorkOrder(db.Model): + __tablename__ = 'issue_work_orders' + + id = db.Column(db.Integer, primary_key=True) + issue_id = db.Column( + db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), + nullable=False, index=True + ) + vendor_name = db.Column(db.String(150), nullable=False) + vendor_email = db.Column(db.String(255), nullable=False) + + # Unguessable public access token (URL path segment). Never exposed to + # anyone but the vendor who receives the emailed link. + token = db.Column(db.String(64), nullable=False, unique=True, index=True) + + status = db.Column( + db.Enum('sent', 'acknowledged', 'completed'), + nullable=False, default='sent' + ) + message = db.Column(db.Text, nullable=True) # staff → vendor instructions + vendor_note = db.Column(db.Text, nullable=True) # vendor → staff completion note + + sent_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + acknowledged_at = db.Column(db.DateTime, nullable=True) + completed_at = db.Column(db.DateTime, nullable=True) + + created_by = db.Column( + db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True + ) + created_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + + # Relationships + issue = db.relationship('Issue', backref=db.backref( + 'work_orders', lazy='dynamic', cascade='all, delete-orphan', + order_by='IssueWorkOrder.created_at.desc()')) + creator = db.relationship('User', foreign_keys=[created_by]) + + @staticmethod + def new_token(): + """Return a fresh unguessable token (43 url-safe chars ≈ 256 bits).""" + return secrets.token_urlsafe(32) + + @property + def status_label(self): + return {'sent': 'Sent', 'acknowledged': 'Acknowledged', + 'completed': 'Completed'}.get(self.status, self.status) + + def __repr__(self): + return f'' diff --git a/app/routes/issues.py b/app/routes/issues.py index 3e2f500..3d6844a 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -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 \ No newline at end of file + return resp + + +# ── Vendor work order dispatch (phase36) ───────────────────────────────────── + +@bp.route('//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)) \ No newline at end of file diff --git a/app/routes/work_orders.py b/app/routes/work_orders.py new file mode 100644 index 0000000..031e6dc --- /dev/null +++ b/app/routes/work_orders.py @@ -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/` → 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)) diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html index 826abf8..2e6ec73 100644 --- a/app/templates/issues/view.html +++ b/app/templates/issues/view.html @@ -404,6 +404,53 @@ {% endif %} + {# ── Vendor Work Orders (phase36) ───────────────────────────────────── #} + {% if current_user.role in ['admin','director','project_manager'] %} +
+
+
Contractor Work Orders
+
+
+ {% set wos = issue.work_orders.all() %} + {% if wos %} +
    + {% for wo in wos %} + {% set b = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %} +
  • + {{ wo.vendor_name }} + {{ wo.vendor_email }} + + {{ wo.status_label }} +
  • + {% endfor %} +
+ {% endif %} +
+ +
+ + +
+
+ + +
+
+ + +
+ +
Emails the contractor a private link to acknowledge & complete — no account needed.
+
+
+
+ {% endif %} + {# /col-lg-4 #} diff --git a/app/templates/work_orders/email.html b/app/templates/work_orders/email.html new file mode 100644 index 0000000..246e108 --- /dev/null +++ b/app/templates/work_orders/email.html @@ -0,0 +1,42 @@ + + + + + +
+ + + + +
+ New Work Order +
+

Hello {{ work_order.vendor_name }},

+

You've been assigned a work order at {{ facility_name }}.

+ + + +
+
What needs doing
+
{{ issue.description }}
+
Severity: {{ issue.severity }}
+ {% if work_order.message %} +
+ Note: {{ work_order.message }} +
+ {% endif %} +
+ +

Open your work order to acknowledge it and mark it complete when done:

+

+ + Open Work Order + +

+

{{ link }}

+
+ This link is private to you — please don't forward it. +
+
+ + diff --git a/app/templates/work_orders/view.html b/app/templates/work_orders/view.html new file mode 100644 index 0000000..51821ed --- /dev/null +++ b/app/templates/work_orders/view.html @@ -0,0 +1,100 @@ + + + + + + Work Order — {{ facility.name if facility else 'JQC' }} + + + + + +
+ +
+ +
+
Contractor Work Order
+
{{ facility.name if facility else 'Facility' }}
+
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endwith %} + +
+
+
+ {{ issue.severity }} + {% set badge = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %} + {{ wo.status_label }} +
+ + + + {% if area %}{% endif %} + +
Facility{{ facility.name if facility else '—' }}
Area{{ area.name }}
Reported{{ issue.reported_at.strftime('%b %d, %Y') if issue.reported_at else '—' }}
+ +
+
What needs doing
+
{{ issue.description }}
+
+ + {% if wo.message %} +
+
Note from the team
+
{{ wo.message }}
+
+ {% endif %} +
+
+ + {% if wo.status == 'completed' %} +
+ + You marked this complete on {{ wo.completed_at.strftime('%b %d, %Y') if wo.completed_at else 'file' }}. + The team will verify the work. No further action is needed. +
+ {% else %} + {% if wo.status == 'sent' %} +
+ + + +
+ {% endif %} + +
+
+
+ + + + + +
+
+
+ {% endif %} + +

This link is private to you. Please don't share it.

+
+ + diff --git a/migrations/versions/phase36_issue_work_orders.py b/migrations/versions/phase36_issue_work_orders.py new file mode 100644 index 0000000..8078a22 --- /dev/null +++ b/migrations/versions/phase36_issue_work_orders.py @@ -0,0 +1,61 @@ +"""phase36 — vendor work orders + +Creates the issue_work_orders table backing the vendor work-order workflow +(see §5 model + the work_orders blueprint). A work order dispatches an issue to +an external contractor via a tokenized public link; the contractor acknowledges +and completes it without an account. + +Idempotent: guarded by an INFORMATION_SCHEMA table-existence check so it is safe +to re-run across every tenant DB (CLAUDE.md rule 14). +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase36_issue_work_orders' +down_revision = 'phase35_user_mfa' +branch_labels = None +depends_on = None + + +def _table_exists(bind, table: str) -> bool: + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name = :t" + ), {'t': table}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _table_exists(bind, 'issue_work_orders'): + op.execute(sa.text(""" + CREATE TABLE issue_work_orders ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + issue_id INT NOT NULL, + vendor_name VARCHAR(150) NOT NULL, + vendor_email VARCHAR(255) NOT NULL, + token VARCHAR(64) NOT NULL, + status ENUM('sent','acknowledged','completed') + NOT NULL DEFAULT 'sent', + message TEXT NULL, + vendor_note TEXT NULL, + sent_at DATETIME NOT NULL, + acknowledged_at DATETIME NULL, + completed_at DATETIME NULL, + created_by INT NULL, + created_at DATETIME NOT NULL, + CONSTRAINT fk_wo_issue FOREIGN KEY (issue_id) + REFERENCES issues(id) ON DELETE CASCADE, + CONSTRAINT fk_wo_creator FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_wo_token (token), + INDEX ix_wo_issue (issue_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + +def downgrade(): + bind = op.get_bind() + if _table_exists(bind, 'issue_work_orders'): + op.execute(sa.text('DROP TABLE issue_work_orders')) diff --git a/tests/test_work_orders.py b/tests/test_work_orders.py new file mode 100644 index 0000000..4815b20 --- /dev/null +++ b/tests/test_work_orders.py @@ -0,0 +1,138 @@ +""" +tests/test_work_orders.py +------------------------- +End-to-end tests for the phase36 vendor work-order workflow. + + * a manager dispatches an issue to a contractor (issue moves to in_progress) + * the contractor opens the tokenized public page (no login) and acknowledges + * the contractor marks it complete → issue moves to pending_verification + * staff (the reporter) get notified on each vendor action + * an unknown token 404s +""" + +import pytest + + +@pytest.fixture +def client(app): + from app import db, limiter + limiter.enabled = False + app.config['WTF_CSRF_ENABLED'] = False + app.config['SQLALCHEMY_ECHO'] = False + with app.app_context(): + db.drop_all() + db.create_all() + yield app.test_client() + db.session.remove() + limiter.enabled = True + + +def _seed(): + from app import db + from app.models.user import User + from app.models.facility import Facility + from app.models.issue import Issue + + admin = User(username='mgr', full_name='Manager', email='mgr@x.com', + role='admin', active=True, password_set=True) + admin.set_password('pw-correct1') + reporter = User(username='insp', full_name='Reporter', email='insp@x.com', + role='inspector', active=True, password_set=True) + reporter.set_password('pw-correct1') + fac = Facility(name='Main Office', active=True) + db.session.add_all([admin, reporter, fac]) + db.session.commit() + + issue = Issue(facility_id=fac.id, severity='high', description='Leaking faucet', + status='open', reported_by=reporter.id) + db.session.add(issue) + db.session.commit() + return admin.id, reporter.id, issue.id + + +def _login(client, username): + return client.post('/auth/login', data={'username': username, 'password': 'pw-correct1'}) + + +def test_full_work_order_lifecycle(client): + from app import db + from app.models.work_order import IssueWorkOrder + from app.models.issue import Issue + from app.models.notification import Notification + + admin_id, reporter_id, issue_id = _seed() + + # 1. Manager dispatches the work order. + _login(client, 'mgr') + resp = client.post(f'/issues/{issue_id}/work-order', + data={'vendor_name': 'Ace Plumbing', + 'vendor_email': 'ace@plumbing.com', + 'message': 'Please fix ASAP'}) + assert resp.status_code == 302 # redirect back to issue view + + wo = IssueWorkOrder.query.filter_by(issue_id=issue_id).first() + assert wo is not None and wo.status == 'sent' + assert db.session.get(Issue, issue_id).status == 'in_progress' + token = wo.token + client.get('/auth/logout') + + # 2. Contractor opens the public page (no login) and acknowledges. + assert client.get(f'/work-orders/{token}').status_code == 200 + ack = client.post(f'/work-orders/{token}', data={'action': 'acknowledge'}) + assert ack.status_code == 302 + assert db.session.get(IssueWorkOrder, wo.id).status == 'acknowledged' + + # Reporter was notified. + n1 = Notification.query.filter_by(user_id=reporter_id, + event_type='work_order_update').count() + assert n1 >= 1 + + # 3. Contractor marks it complete with a note. + done = client.post(f'/work-orders/{token}', + data={'action': 'complete', 'vendor_note': 'Replaced washer'}) + assert done.status_code == 302 + wo2 = db.session.get(IssueWorkOrder, wo.id) + assert wo2.status == 'completed' + assert wo2.vendor_note == 'Replaced washer' + # Issue is now awaiting staff verification. + assert db.session.get(Issue, issue_id).status == 'pending_verification' + # Reporter notified again. + assert Notification.query.filter_by(user_id=reporter_id, + event_type='work_order_update').count() >= 2 + + +def test_unknown_token_404(client): + _seed() + assert client.get('/work-orders/definitely-not-a-real-token').status_code == 404 + + +def test_dispatch_requires_valid_email(client): + from app.models.work_order import IssueWorkOrder + _, _, issue_id = _seed() + _login(client, 'mgr') + client.post(f'/issues/{issue_id}/work-order', + data={'vendor_name': 'Ace', 'vendor_email': 'not-an-email'}) + assert IssueWorkOrder.query.count() == 0 # rejected, nothing created + + +def test_completed_order_ignores_further_actions(client): + from app import db + from app.models.work_order import IssueWorkOrder + + _, _, issue_id = _seed() + _login(client, 'mgr') + client.post(f'/issues/{issue_id}/work-order', + data={'vendor_name': 'Ace', 'vendor_email': 'ace@x.com'}) + token = IssueWorkOrder.query.first().token + client.get('/auth/logout') + + client.post(f'/work-orders/{token}', data={'action': 'complete'}) + wo = IssueWorkOrder.query.first() + assert wo.status == 'completed' + completed_at = wo.completed_at + + # A second 'acknowledge' must not resurrect a completed order. + client.post(f'/work-orders/{token}', data={'action': 'acknowledge'}) + wo = db.session.get(IssueWorkOrder, wo.id) + assert wo.status == 'completed' + assert wo.completed_at == completed_at