# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 """Central-side helpdesk.ticket extensions for the customer follow-up flow and the owner-approval engagement flow. Adds: - `x_fc_client_label` deployment tag (set by the in-app reporter so the embedded inbox can scope per client). - Branded acknowledgement email on create for in-app tickets. - Auto-tag with Critical when priority=3 + has client_label. - The full owner-approval engagement field set: state, token, snapshotted owner email/name, AI summary, sent / reminded / decided timestamps, and a stored computed turnaround for the reporting pivot. - Upserts the client_key row's owner_email/owner_name from each incoming ticket payload so the central always has the current owner contact without a dedicated sync endpoint. """ import logging import uuid from datetime import timedelta from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.tools import email_normalize _logger = logging.getLogger(__name__) class HelpdeskTicket(models.Model): _inherit = 'helpdesk.ticket' x_fc_client_label = fields.Char( string='Client Deployment', index=True, copy=False, help='Deployment tag (e.g. ENTECH) set by the fusion_helpdesk in-app ' 'reporter. Scopes the embedded "My Tickets" inbox per client and ' 'lets support filter tickets by originating deployment.', ) # ------------------------------------------------------------------ # Owner-approval engagement fields # ------------------------------------------------------------------ x_fc_engagement_state = fields.Selection( selection=[ ('none', 'None'), ('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected'), ], string='Owner Approval', default='none', copy=False, index=True, help='State of the owner-approval engagement: ' 'none = never requested, pending = email sent / awaiting click, ' 'approved / rejected = owner has decided.', ) x_fc_engagement_email = fields.Char( string='Engaged Owner Email', copy=False, help='Snapshot of the owner email reached for THIS engagement. ' 'Survives later edits to fusion.helpdesk.client.key.owner_email ' 'so audit history stays correct.', ) x_fc_engagement_name = fields.Char( string='Engaged Owner Name', copy=False, ) x_fc_engagement_token = fields.Char( string='Engagement Token', copy=False, index=True, help='UUID4 in the magic link. Single-use — cleared after the ' 'owner confirms a decision in the portal.', ) x_fc_engagement_sent_at = fields.Datetime( string='Engagement Sent At', copy=False, readonly=True, ) x_fc_engagement_reminded_at = fields.Datetime( string='Engagement Reminded At', copy=False, readonly=True, help='Set by the daily reminder cron. We send at most one ' 'reminder per engagement to avoid spamming the owner.', ) x_fc_engagement_decided_at = fields.Datetime( string='Engagement Decided At', copy=False, readonly=True, ) x_fc_ai_summary = fields.Text( string='AI Summary', copy=False, help='OpenAI-generated brief shown to the owner in the approval ' 'email. Editable in the wizard before sending; frozen after.', ) x_fc_engagement_findings = fields.Text( string='Engagement Findings', copy=False, help='The support engineer\'s reply / analysis (typed in the ' 'wizard\'s Findings field). Sent to the owner in the ' 'approval email alongside the original request and the AI ' 'summary so they see the back-and-forth context, not just ' 'a paraphrase. Frozen at send-time.', ) x_fc_engagement_turnaround_hours = fields.Float( string='Owner Turnaround (h)', compute='_compute_engagement_turnaround', store=True, copy=False, digits=(8, 2), aggregator='avg', # Pivot default is SUM for Float — meaningless here help='Hours between engagement-sent and owner decision. Stored so ' 'the Owner Engagements pivot can aggregate without recomputing. ' 'Aggregated as average across rows so the pivot reads "avg ' 'turnaround per ticket", not "summed wait-time".', ) # ------------------------------------------------------------------ # Owner-contact display + follower shortcut (read from client_key, # never stored — single source of truth stays the client_key row). # ------------------------------------------------------------------ x_fc_owner_display = fields.Char( string='Owner Contact', compute='_compute_owner_display', help='The client deployment\'s decision-maker, pulled live from ' 'their fusion.helpdesk.client.key row. Empty when no owner ' 'is configured for this client.', ) x_fc_owner_email_resolved = fields.Char( compute='_compute_owner_display', help='Internal helper field — drives view visibility for the ' 'Add-as-Follower button. Email-only slice of ' 'x_fc_owner_display.', ) x_fc_owner_is_follower = fields.Boolean( compute='_compute_owner_is_follower', help='True when the configured owner is already subscribed to ' 'this ticket\'s thread. Used to swap the button for a ' '"following" badge.', ) # message_post-friendly index for the reminder cron + token resolution. _engagement_state_idx = models.Index( '(x_fc_engagement_state, x_fc_engagement_sent_at)' ) # ------------------------------------------------------------------ @api.depends('x_fc_client_label') def _compute_owner_display(self): for rec in self: email, name = (False, False) if rec.x_fc_client_label: email, name = rec._fc_owner_contact() rec.x_fc_owner_email_resolved = email or '' if email and name: rec.x_fc_owner_display = '%s <%s>' % (name, email) elif email: rec.x_fc_owner_display = email else: rec.x_fc_owner_display = '' @api.depends('x_fc_client_label', 'message_partner_ids', 'message_partner_ids.email') def _compute_owner_is_follower(self): for rec in self: email = (rec.x_fc_owner_email_resolved or '').strip().lower() if not email: rec.x_fc_owner_is_follower = False continue rec.x_fc_owner_is_follower = any( (p.email or '').strip().lower() == email for p in rec.message_partner_ids ) # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ @api.model_create_multi def create(self, vals_list): # Sync owner contact from payload BEFORE creating tickets so the # client_key row reflects the latest contact even if ticket-create # itself fails (e.g. validation error elsewhere). self._fc_sync_owner_contacts(vals_list) tickets = super().create(vals_list) tickets._fc_send_ack_email() tickets._fc_auto_tag_critical() return tickets @api.model def _fc_sync_owner_contacts(self, vals_list): """Upsert fusion.helpdesk.client.key.owner_email/name from incoming ticket vals so the central always has the latest owner contact. Pulls keys 'x_fc_owner_email' / 'x_fc_owner_name' which the fusion_helpdesk client controller piggybacks on every submit. These are NOT real helpdesk.ticket fields — they're stripped here before super().create() sees them so Odoo doesn't choke on unknown columns. """ ClientKey = self.env['fusion.helpdesk.client.key'].sudo() for vals in vals_list: # Pop the piggyback keys regardless of whether we use them. owner_email = (vals.pop('x_fc_owner_email', None) or '').strip() owner_name = (vals.pop('x_fc_owner_name', None) or '').strip() label = (vals.get('x_fc_client_label') or '').strip() if not label or not (owner_email or owner_name): continue row = ClientKey.search([('client_label', '=', label)], limit=1) if not row: # Don't auto-create a client_key row from a ticket — that # would bypass API-key issuance. Just log and move on. _logger.info( 'fusion_helpdesk_central: ticket carried owner contact ' 'for unknown client_label "%s"; skipping sync.', label, ) continue updates = {} if owner_email and owner_email != (row.owner_email or ''): updates['owner_email'] = owner_email if owner_name and owner_name != (row.owner_name or ''): updates['owner_name'] = owner_name if updates: row.write(updates) @api.depends('x_fc_engagement_sent_at', 'x_fc_engagement_decided_at') def _compute_engagement_turnaround(self): for rec in self: sent = rec.x_fc_engagement_sent_at decided = rec.x_fc_engagement_decided_at if sent and decided and decided > sent: delta = decided - sent rec.x_fc_engagement_turnaround_hours = ( delta.total_seconds() / 3600.0 ) else: rec.x_fc_engagement_turnaround_hours = 0.0 # ------------------------------------------------------------------ # Owner engagement plumbing # ------------------------------------------------------------------ def _fc_owner_contact(self): """Return (email, name) for this ticket's client_key owner contact, or (False, False) if the client_key is missing / unconfigured. Single source of truth for the wizard + the form button's enable check — we never read directly from the ticket's snapshot fields for *new* engagements (those snapshot AT engagement time). """ self.ensure_one() if not self.x_fc_client_label: return (False, False) row = self.env['fusion.helpdesk.client.key'].sudo().search( [('client_label', '=', self.x_fc_client_label)], limit=1, ) if not row: return (False, False) return (row.owner_email or False, row.owner_name or False) def _fc_new_engagement_token(self): """Allocate a fresh single-use token. Centralised so tests can monkeypatch it for deterministic assertions.""" return uuid.uuid4().hex def _fc_reset_engagement(self, owner_email, owner_name, ai_summary, findings=''): """Stamp a fresh pending engagement on this ticket — invalidates any previous token + clears decided/reminded timestamps so the cron and the reporting view see a clean slate. Owner email is normalised here (lowercase, rejected if not a valid single address) so a typo'd contact like "kris@x; jim@y" can't end up as the snapshot. If normalisation fails, we still proceed using the raw value — the email will probably bounce but state is consistent and re-engaging fixes it. `findings` is the support engineer's reply text from the wizard — stored on the ticket so the mail template can show it as the "My Reply" section without context-magic, and so it survives as audit history once the engagement is decided. After writing the state, posts a PUBLIC chatter message (subtype mt_comment) so the message propagates to the employee's My Tickets inbox on the client side — without it, the engagement email goes out internally (logged as an auto-generated Note subtype) and the entech-side inbox shows nothing happened. The employee deserves to know we're waiting on their owner. """ self.ensure_one() normalised = email_normalize(owner_email or '') or (owner_email or '') owner_name_clean = (owner_name or '').strip() self.write({ 'x_fc_engagement_state': 'pending', 'x_fc_engagement_email': normalised, 'x_fc_engagement_name': owner_name_clean, 'x_fc_engagement_token': self._fc_new_engagement_token(), 'x_fc_engagement_sent_at': fields.Datetime.now(), 'x_fc_engagement_reminded_at': False, 'x_fc_engagement_decided_at': False, 'x_fc_ai_summary': ai_summary or '', 'x_fc_engagement_findings': findings or '', }) self._fc_post_engagement_notice(owner_name_clean, findings or '') def _fc_post_engagement_notice(self, owner_name, findings): """Public chatter message posted when an engagement is sent. Two purposes: - Tells the employee (via the entech My Tickets inbox, which only reads public comments) that their request has been escalated for approval and who's deciding. - Captures our reply (findings) on the public thread so the employee can see what support said — same content the owner receives in the engagement email, just without the AI summary (which is a decision-aid for the owner, not the employee). Posted via message_post with subtype_xmlid='mail.mt_comment' so it survives the entech-side _public_messages filter (message_type='comment' + non-internal subtype). """ from markupsafe import Markup, escape self.ensure_one() name_html = escape(owner_name or 'the owner') body = ( '
⏳ Awaiting owner approval from %s. ' 'Their decision will appear here when they reply.
' ) % name_html text = (findings or '').strip() if text: body += ( 'Our reply:
' '%s' ) % escape(text).replace('\n', '