# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 """Wizard that drives the owner-approval engagement flow. Two modes, one wizard: - Single-ticket: opened from the ticket form button. `ticket_id` is set, `ticket_ids` is empty. One AI summary, one email, one engagement. - Bulk: opened from the list-view server action. `ticket_ids` is set, `ticket_id` is empty. One AI summary per ticket (via a child transient model), one combined email with one card per ticket, each card with its own approve/reject tokens. The wizard generates the AI summary on `default_get` so the user sees a ready-to-edit brief the moment the modal opens, then sends mail + writes the engagement state on `action_send`. """ import concurrent.futures import logging from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.tools import html2plaintext from odoo.addons.fusion_helpdesk_central.utils import ( build_summary_prompt, call_openai_chat, truncate_for_openai, ) _logger = logging.getLogger(__name__) # Parallel OpenAI calls for bulk mode. Five is enough to keep the wizard # snappy without slamming OpenAI's rate limits on a single deployment. _BULK_AI_WORKERS = 5 _BULK_AI_TIMEOUT = 30 # seconds; overall cap for the parallel summary fan-out class FusionHelpdeskEngagementWizard(models.TransientModel): _name = 'fusion.helpdesk.engagement.wizard' _description = 'Fusion Helpdesk — Owner Engagement Wizard' # Mode: single vs bulk. The view branches on `mode`; it's a computed # store=False field so we don't need to set it on default_get manually # in every action. mode = fields.Selection( [('single', 'Single ticket'), ('bulk', 'Bulk')], compute='_compute_mode', store=False, ) ticket_id = fields.Many2one( 'helpdesk.ticket', string='Ticket', ondelete='cascade', ) ticket_ids = fields.Many2many( 'helpdesk.ticket', string='Tickets (bulk)', ) line_ids = fields.One2many( 'fusion.helpdesk.engagement.wizard.line', 'wizard_id', string='Per-Ticket Summaries', ) personal_note = fields.Char( string='Personal Note', help='One-line note from you, prepended above the AI summary in the ' 'email body. Optional. Skip if the summary speaks for itself.', ) findings = fields.Text( string='Your Findings', help='Your engineering analysis: scope, limitations, recommended ' 'approach, effort, risks — anything the original reporter ' 'would not have known. The AI weighs these MORE HEAVILY than ' 'the ticket description when generating the owner summary. ' 'Optional but strongly recommended: without it, the summary ' 'is just the AI restating the user\'s report.', ) ai_summary = fields.Text( string='Summary to Send', help='Brief shown to the owner in the approval email. Either ' 'generated from your findings + ticket (click "Generate ' 'Summary") or written by hand. Edit freely before sending.', ) owner_email_display = fields.Char( string='Owner Email', compute='_compute_owner_display', store=False, ) owner_name_display = fields.Char( string='Owner Name', compute='_compute_owner_display', store=False, ) ai_unavailable = fields.Boolean( string='AI unavailable', store=False, help='True when OpenAI returned no summary on wizard open. The view ' 'shows a soft banner so the user knows to write a manual brief.', ) # ------------------------------------------------------------------ @api.depends('ticket_id', 'ticket_ids') def _compute_mode(self): for w in self: w.mode = 'bulk' if w.ticket_ids else 'single' @api.depends('ticket_id', 'ticket_ids') def _compute_owner_display(self): for w in self: ticket = w.ticket_id or (w.ticket_ids[:1] if w.ticket_ids else None) if ticket: email, name = ticket._fc_owner_contact() else: email, name = (False, False) w.owner_email_display = email or '' w.owner_name_display = name or '' # ------------------------------------------------------------------ # Wizard open: pull tickets from context, generate AI summary(ies). # ------------------------------------------------------------------ @api.model def default_get(self, fields_list): vals = super().default_get(fields_list) ctx = self.env.context or {} ticket_id = ctx.get('default_ticket_id') or ctx.get('active_id') ticket_ids = ctx.get('default_ticket_ids') or ctx.get('active_ids') or [] active_model = ctx.get('active_model') # Disambiguate single vs bulk by what the caller actually selected. # The list-view server action passes active_ids; the form button # passes a single active_id via a deliberate context key. if ctx.get('fhc_bulk') and ticket_ids: return self._default_get_bulk(vals, ticket_ids) if active_model == 'helpdesk.ticket' and ticket_ids and not ticket_id: # Edge: opened from list selection without our explicit context # key. If exactly one, treat as single; otherwise bulk. if len(ticket_ids) == 1: ticket_id = ticket_ids[0] else: return self._default_get_bulk(vals, ticket_ids) if ticket_id: return self._default_get_single(vals, ticket_id) return vals def _default_get_single(self, vals, ticket_id): # No AI on open — user writes findings first, then clicks # "Generate Summary" to fire OpenAI. Summary starts empty so the # view's Send button can be disabled until either Generate runs # or the user types one manually. ticket = self.env['helpdesk.ticket'].browse(ticket_id) if not ticket.exists(): raise UserError(_('Ticket %s no longer exists.') % ticket_id) self._validate_engagement_target(ticket) vals.update({ 'ticket_id': ticket.id, 'ai_summary': '', 'findings': '', 'ai_unavailable': False, }) return vals def _default_get_bulk(self, vals, ticket_ids): # Same as single: no AI on open. User fills findings per ticket # then hits "Generate Summary" on each line (or "Generate All" — # see action_generate_all_summaries). tickets = self.env['helpdesk.ticket'].browse(ticket_ids).exists() self._validate_bulk_targets(tickets) vals.update({ 'ticket_ids': [(6, 0, tickets.ids)], 'line_ids': [ (0, 0, {'ticket_id': t.id, 'findings': '', 'ai_summary': ''}) for t in tickets ], 'ai_unavailable': False, }) return vals # ------------------------------------------------------------------ # Validation gates — run BEFORE we waste an OpenAI call. # ------------------------------------------------------------------ def _validate_engagement_target(self, ticket): if not ticket.x_fc_client_label: raise UserError(_( 'This ticket is not tagged with a client deployment, so the ' 'central has no owner contact to send to. Owner-approval is ' 'only available on in-app tickets.' )) email, _name = ticket._fc_owner_contact() if not email: raise UserError(_( 'No owner contact configured for client "%s". Ask the client ' 'to fill it in under Settings → Fusion Helpdesk → Owner ' 'Approval, then file any ticket from that deployment so the ' 'central learns the contact.' ) % (ticket.x_fc_client_label,)) def _validate_bulk_targets(self, tickets): if not tickets: raise UserError(_('No tickets selected.')) labels = {t.x_fc_client_label for t in tickets} labels.discard(False) labels.discard('') if len(labels) != 1: raise UserError(_( 'Cannot bulk-engage tickets across different deployments — ' 'one owner per engagement. Selected labels: %s.' ) % (', '.join(sorted(labels)) or '(none)')) blockers = tickets.filtered( lambda t: t.x_fc_engagement_state in ('pending', 'approved') ) if blockers: raise UserError(_( '%(n)s of the selected tickets already have a pending or ' 'approved engagement. Re-engage them individually from the ' 'ticket form. Tickets: %(ids)s' ) % {'n': len(blockers), 'ids': ', '.join('#%s' % t.id for t in blockers)}) # Reuse single validation for the owner-contact check (any one # ticket suffices since they share the same client_label). self._validate_engagement_target(tickets[0]) # ------------------------------------------------------------------ # AI summary generation # ------------------------------------------------------------------ def _summary_inputs(self, ticket): """Pull the data we feed to OpenAI: title + plain-text description + plain-text public messages (oldest first). Internal notes excluded.""" msgs = self.env['mail.message'].search([ ('model', '=', 'helpdesk.ticket'), ('res_id', '=', ticket.id), ('message_type', 'in', ('comment', 'email')), ('subtype_id.internal', '=', False), ], order='id asc') msg_data = [{ 'author': m.author_id.name or m.email_from or 'unknown', 'date': fields.Datetime.to_string(m.date) if m.date else '', 'body_plain': html2plaintext(m.body or '') or '', } for m in msgs] return ( ticket.name or '', html2plaintext(ticket.description or '') or '', msg_data, ) def _generate_summary(self, ticket, findings=''): """Single-ticket summary. Returns '' on any failure — the wizard treats empty as "AI unavailable" and shows the manual fallback. `findings` is the user's free-text analysis from the wizard. The prompt explicitly tells the model to weight it more than the original user report — see SUMMARY_PROMPT in utils.py.""" ICP = self.env['ir.config_parameter'].sudo() api_key = (ICP.get_param( 'fusion_helpdesk_central.openai_api_key') or '').strip() if not api_key: return '' model = (ICP.get_param( 'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip() name, desc, msgs = self._summary_inputs(ticket) prompt = truncate_for_openai( build_summary_prompt(name, desc, msgs, findings=findings) ) return call_openai_chat(api_key, model, prompt) def _generate_summaries_parallel(self, ticket_findings): """{ticket_id: summary_or_empty} for the bulk wizard. `ticket_findings` is a dict {ticket_id: (ticket_recordset, findings_str)} so each parallel call uses its own per-ticket findings. """ ICP = self.env['ir.config_parameter'].sudo() api_key = (ICP.get_param( 'fusion_helpdesk_central.openai_api_key') or '').strip() if not api_key: return {tid: '' for tid in ticket_findings} model = (ICP.get_param( 'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip() # Build inputs serially (DB-bound, fast) before fanning out the # HTTP calls in parallel. inputs = {} for tid, (ticket, findings) in ticket_findings.items(): name, desc, msgs = self._summary_inputs(ticket) inputs[tid] = truncate_for_openai( build_summary_prompt(name, desc, msgs, findings=findings)) results = {tid: '' for tid in ticket_findings} # Cancel pending futures on overall timeout so a slow OpenAI day # doesn't block the wizard for ceil(N/workers) * 15s. pool = concurrent.futures.ThreadPoolExecutor( max_workers=_BULK_AI_WORKERS) try: futures = { pool.submit(call_openai_chat, api_key, model, p): tid for tid, p in inputs.items() } try: for fut in concurrent.futures.as_completed( futures, timeout=_BULK_AI_TIMEOUT): tid = futures[fut] try: results[tid] = fut.result() or '' except Exception as e: # noqa: BLE001 — log + continue _logger.warning( 'fusion_helpdesk_central: bulk AI summary for ' 'ticket %s failed: %s', tid, e, ) except concurrent.futures.TimeoutError: _logger.warning( 'fusion_helpdesk_central: bulk AI summary fan-out timed ' 'out after %ss; remaining tickets will get empty ' 'summaries.', _BULK_AI_TIMEOUT, ) finally: # py3.9+: cancel_futures stops queued tasks from starting; # in-flight urlopen calls still finish their 15s per-call cap # and drop their result on the floor. pool.shutdown(wait=False, cancel_futures=True) return results # ------------------------------------------------------------------ # User-driven AI trigger — explicit "Generate" buttons in the view. # ------------------------------------------------------------------ def action_generate_summary(self): """Single mode: fire OpenAI with the current findings, drop the result into ai_summary. Returns an action to keep the wizard open (replaces the current view instead of closing it). """ self.ensure_one() if self.mode != 'single' or not self.ticket_id: raise UserError(_( 'Generate Summary only works in single-ticket mode. Use ' 'the per-line Generate buttons on the bulk wizard.' )) summary = self._generate_summary( self.ticket_id, findings=self.findings or '', ) self.ai_summary = summary self.ai_unavailable = not bool(summary) return { 'type': 'ir.actions.act_window', 'res_model': 'fusion.helpdesk.engagement.wizard', 'res_id': self.id, 'view_mode': 'form', 'target': 'new', } def action_generate_all_summaries(self): """Bulk mode: fire OpenAI per-ticket in parallel using each line's own findings. Lines that already have a non-empty ai_summary get regenerated too (the user clicked the button — they meant it). """ self.ensure_one() if self.mode != 'bulk' or not self.line_ids: raise UserError(_( 'Generate All only works in bulk mode.' )) ticket_findings = { line.ticket_id.id: (line.ticket_id, line.findings or '') for line in self.line_ids } results = self._generate_summaries_parallel(ticket_findings) for line in self.line_ids: line.ai_summary = results.get(line.ticket_id.id, '') any_ok = any(results.values()) self.ai_unavailable = not any_ok return { 'type': 'ir.actions.act_window', 'res_model': 'fusion.helpdesk.engagement.wizard', 'res_id': self.id, 'view_mode': 'form', 'target': 'new', } # ------------------------------------------------------------------ # Send: write engagement state + queue mail # ------------------------------------------------------------------ def action_send(self): self.ensure_one() if self.mode == 'bulk': return self._action_send_bulk() return self._action_send_single() def _action_send_single(self): ticket = self.ticket_id if not ticket: raise UserError(_('Wizard has no ticket attached.')) # Re-resolve owner from client_key in case it changed between # default_get and Send (small window, but the source of truth wins). email, name = ticket._fc_owner_contact() if not email: raise UserError(_('Owner contact disappeared since you opened ' 'the wizard. Refresh and try again.')) ticket._fc_reset_engagement(email, name, self.ai_summary or '') template = self.env.ref( 'fusion_helpdesk_central.mail_template_engagement', raise_if_not_found=False, ) if not template: raise UserError(_('Engagement mail template not found — was ' 'fusion_helpdesk_central installed cleanly?')) # Pass the personal note + is_reminder=False into the template's # rendering context. Use `with_context(**data)` per CLAUDE.md — # `ctx=...` won't reach the template body. template.with_context( fhc_personal_note=self.personal_note or '', fhc_is_reminder=False, ).send_mail(ticket.id, force_send=False) return {'type': 'ir.actions.act_window_close'} def _action_send_bulk(self): if not self.ticket_ids: raise UserError(_('Wizard has no tickets attached.')) # Snapshot owner from the first ticket — bulk is locked to a single # client_label by validation, so they all share an owner. email, name = self.ticket_ids[0]._fc_owner_contact() if not email: raise UserError(_('Owner contact disappeared since you opened ' 'the wizard. Refresh and try again.')) summary_by_id = {line.ticket_id.id: line.ai_summary or '' for line in self.line_ids} for ticket in self.ticket_ids: ticket._fc_reset_engagement( email, name, summary_by_id.get(ticket.id, ''), ) template = self.env.ref( 'fusion_helpdesk_central.mail_template_engagement_bulk', raise_if_not_found=False, ) if not template: raise UserError(_('Bulk engagement mail template not found — ' 'was fusion_helpdesk_central installed cleanly?')) # The bulk template renders once against the FIRST ticket but reads # the full set from context. Each ticket already has its own token # (snapped above), so the template iterates self.env['helpdesk.ticket'] # by the ids we pass in. template.with_context( fhc_personal_note=self.personal_note or '', fhc_is_reminder=False, fhc_bulk_ticket_ids=self.ticket_ids.ids, ).send_mail(self.ticket_ids[0].id, force_send=False) return {'type': 'ir.actions.act_window_close'} class FusionHelpdeskEngagementWizardLine(models.TransientModel): _name = 'fusion.helpdesk.engagement.wizard.line' _description = 'Fusion Helpdesk — Per-Ticket Bulk Engagement Line' wizard_id = fields.Many2one( 'fusion.helpdesk.engagement.wizard', required=True, ondelete='cascade', ) ticket_id = fields.Many2one( 'helpdesk.ticket', required=True, ondelete='cascade', ) ticket_name = fields.Char( related='ticket_id.name', readonly=True, ) findings = fields.Text( string='Your Findings', help='Per-ticket engineering findings. The Generate button on this ' 'line uses these as the most authoritative input for the AI ' 'summary.', ) ai_summary = fields.Text( string='Summary to Send', help='Per-ticket summary — generated from findings + ticket, or ' 'written by hand. Edit before send.', )