# -*- 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, company_id=None): """Return the highest-priority active on-call user, or empty recordset. Multi-company aware: when `company_id` is supplied, restricts to users who belong to that company. """ exclude_user_ids = exclude_user_ids or [] Users = self.env['res.users'].sudo() domain = [ ('x_fc_on_call', '=', True), ('active', '=', True), ('id', 'not in', exclude_user_ids), ] if company_id: domain.append(('company_ids', 'in', company_id)) return Users.search( domain, 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. - Excludes anyone already acknowledged this cycle. - Excludes the currently paged user (cron escalates to the NEXT priority). - Skips during business hours unless force=True. - Posts truthful chatter (different line on email send failure). """ 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'] # CRITICAL: also exclude the currently-paged user so cron escalation # actually moves to the NEXT priority instead of re-paging the same # person forever. exclude = set(repair.x_fc_on_call_acknowledged_user_ids.ids) if repair.x_fc_on_call_paged_user_id: exclude.add(repair.x_fc_on_call_paged_user_id.id) target = self.find_next_on_call( exclude_user_ids=list(exclude), company_id=repair.company_id.id, ) 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(), }) sent_ok = self._send_page_email(repair, target, token) if sent_ok: self._post_chatter(repair, target) else: # Truthful chatter when SMTP fails so the office can react. repair.message_post(body=Markup(_( 'Safety paged %(name)s but the page email failed to send. ' 'Verify SMTP and retry, or contact the on-call manager directly.' )) % {'name': target.name or target.login or ''}) 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')), ]) # page_on_call now excludes the currently-paged user internally # (see exclude set), so a plain call escalates to the next priority. for r in stale: 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): """Send the page email, return True on success, False on failure. force_send=True because this is the single most time-critical email in the module - mail queue latency would defeat the point. """ try: tpl = self.env.ref( 'fusion_repairs.email_template_on_call_page', raise_if_not_found=False, ) if not tpl: _logger.warning('On-call email template missing - cannot page %s', target.login) return False tpl.with_context( on_call_token=token, on_call_user=target, ).send_mail(repair.id, force_send=True, email_values={ 'email_to': target.email or target.partner_id.email or '', }) return True except Exception as e: _logger.warning('On-call page email failed for repair %s: %s', repair.name, e) return False @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.' ))) # Also send a real email to the company's office notification # recipients so this doesn't get lost in chatter at 11 PM Saturday. company_sudo = repair.company_id.sudo() recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False) emails = [p.email for p in (recipients or []) if p.email] if not emails: return try: self.env['mail.mail'].sudo().create({ 'subject': '[CRITICAL] No on-call user configured - %s' % repair.name, 'body_html': ( '

Safety repair %s was just submitted ' 'but no on-call user is configured ' '(x_fc_on_call=True). No one was paged.

' '

Set the flag on at least one manager so the next ' 'after-hours safety call is paged.

' ) % repair.name, 'email_to': ','.join(emails), }).send() except Exception as e: _logger.warning('Failed to send no-on-call office alert: %s', e)