# -*- 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_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".', ) # message_post-friendly index for the reminder cron + token resolution. _engagement_state_idx = models.Index( '(x_fc_engagement_state, x_fc_engagement_sent_at)' ) # ------------------------------------------------------------------ # 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): """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. """ self.ensure_one() normalised = email_normalize(owner_email or '') or (owner_email or '') self.write({ 'x_fc_engagement_state': 'pending', 'x_fc_engagement_email': normalised, 'x_fc_engagement_name': (owner_name or '').strip(), '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 '', }) def action_open_engagement_wizard(self): """Form-button handler: open the wizard targeting this single ticket. Validation lives on the wizard's default_get so the error path is symmetrical with the bulk action — same UserError messages, same soft fallback when AI is unavailable.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Request Owner Approval'), 'res_model': 'fusion.helpdesk.engagement.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_ticket_id': self.id, 'active_id': self.id, 'active_model': 'helpdesk.ticket', }, } @api.model def action_open_engagement_wizard_bulk(self): """Server-action handler: open the wizard targeting the list-view selection. Bound from a server action XML record. Reads ids from the env context (`active_ids`) — the action ensures it's only callable from a list/kanban with selection.""" ticket_ids = self.env.context.get('active_ids') or [] if not ticket_ids: raise UserError(_('Select at least one ticket first.')) return { 'type': 'ir.actions.act_window', 'name': _('Request Owner Approval (Bulk)'), 'res_model': 'fusion.helpdesk.engagement.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_ticket_ids': ticket_ids, 'active_ids': ticket_ids, 'active_model': 'helpdesk.ticket', 'fhc_bulk': True, }, } @api.model def _fc_send_engagement_reminders(self): """Cron entry-point: re-send one reminder for stale pending engagements. N days configurable via ICP `engagement_reminder_days` (default 3, 0 = disabled). Single-shot per engagement — `reminded_at` set after send so we never spam. Same token, same magic links, so the owner can click whichever email is in front of them. Idempotent on its own: a second cron run within the same day won't re-find anything because `reminded_at` is now non-NULL. """ ICP = self.env['ir.config_parameter'].sudo() try: N = int(ICP.get_param( 'fusion_helpdesk_central.engagement_reminder_days') or 3) except (TypeError, ValueError): N = 3 if N <= 0: _logger.info('fusion_helpdesk_central: reminder cron disabled ' '(engagement_reminder_days <= 0); skipping.') return 0 cutoff = fields.Datetime.now() - timedelta(days=N) stale = self.search([ ('x_fc_engagement_state', '=', 'pending'), ('x_fc_engagement_sent_at', '<=', cutoff), ('x_fc_engagement_reminded_at', '=', False), ]) if not stale: return 0 template = self.env.ref( 'fusion_helpdesk_central.mail_template_engagement', raise_if_not_found=False, ) if not template: _logger.warning( 'fusion_helpdesk_central: reminder cron found %s stale ' 'engagements but the mail template is missing; aborting.', len(stale), ) return 0 now = fields.Datetime.now() sent = 0 for ticket in stale: # Per-row savepoint: a DB failure on one ticket (constraint hit, # mail-server hiccup that propagates as an OperationalError, etc.) # would otherwise leave the whole cron transaction in an aborted # state — every subsequent row's `reminded_at` write would fail # silently with InFailedSqlTransaction. CLAUDE.md rule #14: use # `cr.savepoint()` not `cr.commit()` inside the loop (commits # raise inside TransactionCase). try: with self.env.cr.savepoint(): template.with_context( fhc_is_reminder=True, fhc_personal_note='', ).send_mail(ticket.id, force_send=False) ticket.x_fc_engagement_reminded_at = now sent += 1 except Exception: # noqa: BLE001 — never break the batch _logger.exception( 'fusion_helpdesk_central: reminder send failed for ' 'ticket %s; will retry next run.', ticket.id, ) _logger.info( 'fusion_helpdesk_central: reminder cron sent %s reminder(s) ' 'out of %s candidate(s).', sent, len(stale), ) return sent def _fc_finalize_engagement(self, decision, owner_partner, comment=None): """Apply the owner's decision: post chatter (public), write state + decided_at. Called from the public portal controller AFTER the controller has already atomically claimed (cleared) the token via UPDATE...RETURNING — so we don't clear it again here; doing so would race with a re-engagement that happened to rotate the token between our write and the controller's claim. Chatter is posted as a public comment (subtype mail.mt_comment) so it propagates to the employee's My Tickets thread per the "fully visible" UX choice in the spec. """ from odoo.addons.fusion_helpdesk_central.utils import ( format_engagement_chatter, ) self.ensure_one() body = format_engagement_chatter( decision, self.x_fc_engagement_name, comment, ) author_id = owner_partner.id if owner_partner else False self.message_post( body=body, message_type='comment', subtype_xmlid='mail.mt_comment', author_id=author_id, ) self.write({ 'x_fc_engagement_state': decision, 'x_fc_engagement_decided_at': fields.Datetime.now(), }) # ------------------------------------------------------------------ # Existing customer-followup hooks (unchanged behaviour) # ------------------------------------------------------------------ def _fc_auto_tag_critical(self): """Auto-apply the Critical tag on in-app tickets that were filed with priority='3' (Urgent — the client-side "Mark as Critical" toggle). Scoped to tickets carrying `x_fc_client_label` so support staff who manually set priority='3' on their own internal tickets aren't silently tagged. Best-effort: if the data XML hasn't loaded the tag yet (e.g. partial install), skip without raising — the ticket is already filed with priority='3' which is the load-bearing signal.""" critical = self.filtered( lambda t: t.priority == '3' and t.x_fc_client_label ) if not critical: return tag = self.env.ref( 'fusion_helpdesk_central.tag_critical', raise_if_not_found=False, ) if not tag: _logger.warning( 'fusion_helpdesk_central: tag_critical not found, skipping ' 'auto-tag on %s critical ticket(s).', len(critical), ) return critical.write({'tag_ids': [(4, tag.id)]}) def _fc_send_ack_email(self): """Send the branded acknowledgement (with magic link) to the customer. Only fires for in-app-channel tickets (those tagged with a client label) that have a customer email — external web-form submissions rely on the native website confirmation, so this won't double-send. The whole thing is best-effort: a template/mail failure must never block ticket creation, so we log and move on. """ template = self.env.ref( 'fusion_helpdesk_central.mail_template_ticket_ack', raise_if_not_found=False, ) if not template: return for ticket in self: if not (ticket.x_fc_client_label and ticket.partner_email): continue try: template.send_mail(ticket.id, force_send=False) except Exception: # noqa: BLE001 — ack must never block create _logger.exception( 'fusion_helpdesk_central: acknowledgement email failed ' 'for ticket %s (%s)', ticket.id, ticket.x_fc_client_label, )