# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """On-call service - finds the next on-call manager and pages them. Triggered when a safety-flagged repair comes in outside business hours (or any time, if the user wants to call us about a stuck stairlift). Per the design spec section "Weekend safety escalation": 1. 911 disclaimer is shown to the client 2. repair.order created with priority=high + Monday-followup activity 3. Page next on-call manager (lowest x_fc_on_call_priority among active users with x_fc_on_call=True) 4. SMS + email sent; tokenized /repair/on-call/ack/ for ack 5. 15-minute escalation cron pages next priority if first doesn't ack 6. All actions logged to repair chatter Phase 2 ships with priority-int sorting; Phase 4 will replace with proper shift scheduling (date ranges per on-call user). """ import logging import secrets from datetime import timedelta from markupsafe import Markup from odoo import _, api, fields, models _logger = logging.getLogger(__name__) class FusionRepairOnCallService(models.AbstractModel): _name = 'fusion.repair.on.call.service' _description = 'Repair On-Call Paging Service' # ------------------------------------------------------------------ # PUBLIC API # ------------------------------------------------------------------ @api.model def find_next_on_call(self, exclude_user_ids=None): """Return the highest-priority active on-call user, or empty recordset.""" exclude_user_ids = exclude_user_ids or [] Users = self.env['res.users'].sudo() return Users.search([ ('x_fc_on_call', '=', True), ('active', '=', True), ('id', 'not in', exclude_user_ids), ], order='x_fc_on_call_priority asc, id asc', limit=1) @api.model def page_on_call(self, repair, force=False): """Page the next on-call manager for the given repair. Skips if outside business hours check disabled OR already paged unless force=True. Returns the paged user or empty recordset. """ repair.ensure_one() if not force and self._is_business_hours(): _logger.info('On-call page skipped for %s - inside business hours', repair.name) return self.env['res.users'] # Don't re-page a repair that's already been paged in this cycle. already_paged = repair.x_fc_on_call_acknowledged_user_ids.ids target = self.find_next_on_call(exclude_user_ids=already_paged) if not target: self._notify_office_no_oncall(repair) return self.env['res.users'] token = secrets.token_urlsafe(20) repair.write({ 'x_fc_on_call_token': token, 'x_fc_on_call_paged_user_id': target.id, 'x_fc_on_call_paged_at': fields.Datetime.now(), }) self._send_page_email(repair, target, token) self._post_chatter(repair, target) return target @api.model def acknowledge(self, repair, user): """Mark a repair's on-call page as acknowledged by `user`.""" repair.ensure_one() repair.x_fc_on_call_acknowledged_user_ids = [(4, user.id)] repair.x_fc_on_call_acknowledged_at = fields.Datetime.now() repair.message_post(body=Markup(_( 'On-call page acknowledged by %s.' )) % (user.name or user.login or '')) @api.model def cron_escalate_unacknowledged(self): """Cron: re-page the next priority for any repair whose first page is older than 15 minutes without acknowledgement.""" ICP = self.env['ir.config_parameter'].sudo() try: window_min = int(ICP.get_param( 'fusion_repairs.on_call_escalate_minutes', '15' )) except (ValueError, TypeError): window_min = 15 cutoff = fields.Datetime.now() - timedelta(minutes=window_min) Repair = self.env['repair.order'].sudo() stale = Repair.search([ ('x_fc_on_call_paged_at', '!=', False), ('x_fc_on_call_paged_at', '<=', cutoff), ('x_fc_on_call_acknowledged_at', '=', False), ('state', 'not in', ('done', 'cancel')), ]) for r in stale: already = r.x_fc_on_call_acknowledged_user_ids.ids + [ r.x_fc_on_call_paged_user_id.id ] self.page_on_call(r, force=True) # ------------------------------------------------------------------ # HELPERS # ------------------------------------------------------------------ @api.model def _is_business_hours(self): """True when within the company resource_calendar's working time.""" cal = self.env.company.resource_calendar_id if not cal: return False # Treat "no calendar" as always after-hours so we always page. now = fields.Datetime.now() try: return bool(cal._work_intervals_batch(now, now)[False]) except Exception: return False @api.model def _send_page_email(self, repair, target, token): try: tpl = self.env.ref( 'fusion_repairs.email_template_on_call_page', raise_if_not_found=False, ) if tpl: tpl.with_context( on_call_token=token, on_call_user=target, ).send_mail(repair.id, force_send=False, email_values={ 'email_to': target.email or target.partner_id.email or '', }) except Exception as e: _logger.warning('On-call page email failed for repair %s: %s', repair.name, e) @api.model def _post_chatter(self, repair, target): repair.message_post(body=Markup(_( 'After-hours safety paged %(name)s ' '(priority %(p)s). Awaiting acknowledgement.' )) % { 'name': target.name or target.login or '', 'p': str(target.x_fc_on_call_priority or 99), }) @api.model def _notify_office_no_oncall(self, repair): _logger.error( 'No on-call user configured (x_fc_on_call=True) - safety repair ' '%s will queue for Monday with no page.', repair.name, ) repair.message_post(body=Markup(_( 'WARNING: No on-call user ' 'configured. This safety repair was queued but no one was paged. ' 'Configure x_fc_on_call on a manager.' )))