diff --git a/fusion_helpdesk/__manifest__.py b/fusion_helpdesk/__manifest__.py index 942c9ce4..bb02ce30 100644 --- a/fusion_helpdesk/__manifest__.py +++ b/fusion_helpdesk/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Helpdesk Reporter', - 'version': '19.0.1.7.1', + 'version': '19.0.2.0.0', 'category': 'Productivity', 'summary': 'One-click in-app bug reporting & feature requesting — ' 'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.', diff --git a/fusion_helpdesk/controllers/main.py b/fusion_helpdesk/controllers/main.py index 857bf99a..24efe2e8 100644 --- a/fusion_helpdesk/controllers/main.py +++ b/fusion_helpdesk/controllers/main.py @@ -101,6 +101,15 @@ class FusionHelpdeskController(http.Controller): company_name=request.env.company.name, is_critical=bool(is_critical), ) + # Piggyback the configured owner contact on every submission so the + # central can keep fusion.helpdesk.client.key.owner_email/name in + # sync without a dedicated endpoint. The central's create override + # pops these keys before super().create() sees them — they're not + # real helpdesk.ticket columns, just a sync side-channel. + if cfg.get('owner_email'): + ticket_vals['x_fc_owner_email'] = cfg['owner_email'] + if cfg.get('owner_name'): + ticket_vals['x_fc_owner_name'] = cfg['owner_name'] # ---- Talk to remote Odoo -------------------------------------- try: @@ -219,6 +228,12 @@ class FusionHelpdeskController(http.Controller): 'client_label': ( ICP.get_param('fusion_helpdesk.client_label') or '' ).strip(), + 'owner_email': ( + ICP.get_param('fusion_helpdesk.owner_email') or '' + ).strip(), + 'owner_name': ( + ICP.get_param('fusion_helpdesk.owner_name') or '' + ).strip(), } def _authenticate(self, cfg): diff --git a/fusion_helpdesk/models/res_config_settings.py b/fusion_helpdesk/models/res_config_settings.py index 91aef7e6..58a5a92f 100644 --- a/fusion_helpdesk/models/res_config_settings.py +++ b/fusion_helpdesk/models/res_config_settings.py @@ -50,3 +50,22 @@ class ResConfigSettings(models.TransientModel): 'can tell which client deployment a ticket came from. ' 'e.g. "ENTECH" → "[ENTECH] My subject"', ) + # Owner contact for the central engagement / approval flow. Optional — + # leaving these blank disables the "Request Owner Approval" button on + # the central side for this client. Both values piggyback on every + # ticket submission (see controllers/main.py::submit) so central always + # has the latest contact without a dedicated sync endpoint. + fhd_owner_email = fields.Char( + string='Owner Email', + config_parameter='fusion_helpdesk.owner_email', + help='Email of the real decision-maker at your company — the ' + 'person who can approve feature requests or bug-fix scope. ' + 'Used when central support hits a ticket that needs sign-off. ' + 'Leave blank if your deployment doesn\'t require approvals.', + ) + fhd_owner_name = fields.Char( + string='Owner Name', + config_parameter='fusion_helpdesk.owner_name', + help='Display name for the owner — shown in the approval email ' + 'greeting and in the chatter attribution after they decide.', + ) diff --git a/fusion_helpdesk/views/res_config_settings_views.xml b/fusion_helpdesk/views/res_config_settings_views.xml index c010ae34..fd5ddbd2 100644 --- a/fusion_helpdesk/views/res_config_settings_views.xml +++ b/fusion_helpdesk/views/res_config_settings_views.xml @@ -43,6 +43,19 @@ + + + + + + + + diff --git a/fusion_helpdesk_central/__init__.py b/fusion_helpdesk_central/__init__.py index a0fdc10f..3b389160 100644 --- a/fusion_helpdesk_central/__init__.py +++ b/fusion_helpdesk_central/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import models +from . import controllers diff --git a/fusion_helpdesk_central/__manifest__.py b/fusion_helpdesk_central/__manifest__.py index 537a849d..6d4056df 100644 --- a/fusion_helpdesk_central/__manifest__.py +++ b/fusion_helpdesk_central/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 { 'name': 'Fusion Helpdesk Central — Client API Keys', - 'version': '19.0.1.2.0', + 'version': '19.0.2.0.0', 'category': 'Productivity', 'summary': 'Admin UI on the central Odoo for issuing per-client API ' 'keys used by fusion_helpdesk client deployments.', @@ -29,9 +29,14 @@ Depends only on `helpdesk`. No client-side install needed. 'security/ir.model.access.csv', 'data/ir_config_parameter_data.xml', 'data/mail_template_ack.xml', + 'data/mail_template_engagement.xml', 'data/helpdesk_tag_critical.xml', + 'data/ir_cron_engagement_reminder.xml', 'views/fusion_helpdesk_client_key_views.xml', 'views/helpdesk_ticket_views.xml', + 'views/engagement_wizard_views.xml', + 'views/engagement_reporting_views.xml', + 'views/portal_templates.xml', ], 'installable': True, 'auto_install': False, diff --git a/fusion_helpdesk_central/controllers/__init__.py b/fusion_helpdesk_central/controllers/__init__.py new file mode 100644 index 00000000..fb0b23d3 --- /dev/null +++ b/fusion_helpdesk_central/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import engagement diff --git a/fusion_helpdesk_central/controllers/engagement.py b/fusion_helpdesk_central/controllers/engagement.py new file mode 100644 index 00000000..0b6bee7a --- /dev/null +++ b/fusion_helpdesk_central/controllers/engagement.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +"""Public portal routes for the owner-approval magic links. + +Owner clicks the Approve / Reject button in the email -> GET lands here. +Page shows ticket title + AI summary + comment box + Confirm button. +POST records the decision via helpdesk.ticket._fc_finalize_engagement. + +No login required; the UUID4 in the URL is the auth. Tokens are single- +use (cleared on finalize), so the second click on the same link shows a +friendly "link no longer valid" page instead of double-recording the +decision. +""" +import logging + +from odoo import _, http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class FusionHelpdeskEngagementController(http.Controller): + + # ------------------------------------------------------------------ + # Token resolution — single source of truth for the GET + POST handlers. + # ------------------------------------------------------------------ + def _resolve(self, token, decision): + """Return (ticket, decision_state) or (None, None) on any problem. + + The "no problem" cases: + - token is non-empty + - decision is one of {'approve', 'reject'} + - a single ticket matches the token AND is in state='pending' + + Anything else -> (None, None), caller renders the friendly + "link no longer valid" page. + """ + if not token or not isinstance(token, str): + return (None, None) + if decision not in ('approve', 'reject'): + return (None, None) + decision_state = 'approved' if decision == 'approve' else 'rejected' + ticket = request.env['helpdesk.ticket'].sudo().search( + [('x_fc_engagement_token', '=', token), + ('x_fc_engagement_state', '=', 'pending')], + limit=1, + ) + if not ticket: + return (None, None) + return (ticket, decision_state) + + # ------------------------------------------------------------------ + # GET — render the confirmation page (or invalid-link page). + # ------------------------------------------------------------------ + @http.route( + '/fusion_helpdesk/engagement//', + type='http', auth='public', methods=['GET'], csrf=False, sitemap=False, + ) + def engagement_show(self, token, decision, **kw): + ticket, decision_state = self._resolve(token, decision) + if not ticket: + return request.render( + 'fusion_helpdesk_central.engagement_invalid', {}, + ) + return request.render( + 'fusion_helpdesk_central.engagement_confirm', + { + 'ticket': ticket, + 'decision': decision, # url-friendly string + 'decision_state': decision_state, # 'approved' / 'rejected' + 'token': token, + }, + ) + + # ------------------------------------------------------------------ + # POST — record the decision, post chatter, clear token. + # ------------------------------------------------------------------ + @http.route( + '/fusion_helpdesk/engagement//', + type='http', auth='public', methods=['POST'], csrf=False, sitemap=False, + ) + def engagement_submit(self, token, decision, **post): + ticket, decision_state = self._resolve(token, decision) + if not ticket: + # Could be a second click on the same link, or a token rotated + # by a re-engagement, or a typo. Same friendly page for all. + return request.render( + 'fusion_helpdesk_central.engagement_invalid', {}, + ) + comment = (post.get('comment') or '').strip() + owner_partner = self._find_or_create_owner_partner(ticket) + try: + ticket._fc_finalize_engagement( + decision_state, owner_partner, comment=comment or None, + ) + except Exception: + _logger.exception( + 'fusion_helpdesk_central: failed to finalize engagement ' + 'for ticket %s (token=%s, decision=%s)', + ticket.id, token, decision_state, + ) + return request.render( + 'fusion_helpdesk_central.engagement_error', {}, + ) + return request.render( + 'fusion_helpdesk_central.engagement_done', + { + 'ticket': ticket, + 'decision_state': decision_state, + }, + ) + + # ------------------------------------------------------------------ + def _find_or_create_owner_partner(self, ticket): + """Resolve the res.partner used to attribute the chatter message. + + Find-or-create by snapshotted email — mirrors the customer-reply + attribution pattern in fusion_helpdesk/controllers/main.py so the + approval chatter shows up under a proper partner name (matters for + the employee's My Tickets thread per the "fully visible" UX). + Falls back to no author (= bot user) if email is empty or the + partner create fails. + """ + email = (ticket.x_fc_engagement_email or '').strip().lower() + name = (ticket.x_fc_engagement_name or '').strip() + if not email: + return None + Partner = request.env['res.partner'].sudo() + # Use exact match on lowercased email — the snapshot was already + # normalised at engagement time. + partner = Partner.search([('email', '=ilike', email)], + order='id asc', limit=1) + if partner: + return partner + try: + return Partner.create({ + 'name': name or email.split('@')[0].title(), + 'email': email, + }) + except Exception: + _logger.warning( + 'fusion_helpdesk_central: could not create owner partner ' + 'for %s on ticket %s; chatter will be attributed to the ' + 'service account.', email, ticket.id, + ) + return None diff --git a/fusion_helpdesk_central/data/ir_config_parameter_data.xml b/fusion_helpdesk_central/data/ir_config_parameter_data.xml index 7b0f2f3c..50b5aef7 100644 --- a/fusion_helpdesk_central/data/ir_config_parameter_data.xml +++ b/fusion_helpdesk_central/data/ir_config_parameter_data.xml @@ -10,4 +10,19 @@ support@nexasystems.ca + + + fusion_helpdesk_central.openai_model + gpt-4o-mini + + + fusion_helpdesk_central.engagement_reminder_days + 3 + + diff --git a/fusion_helpdesk_central/data/ir_cron_engagement_reminder.xml b/fusion_helpdesk_central/data/ir_cron_engagement_reminder.xml new file mode 100644 index 00000000..6d9cdb47 --- /dev/null +++ b/fusion_helpdesk_central/data/ir_cron_engagement_reminder.xml @@ -0,0 +1,30 @@ + + + + + + + Fusion Helpdesk — Owner Engagement Reminder + + code + model._fc_send_engagement_reminders() + + 1 + days + + + + + diff --git a/fusion_helpdesk_central/data/mail_template_engagement.xml b/fusion_helpdesk_central/data/mail_template_engagement.xml new file mode 100644 index 00000000..d139e2c3 --- /dev/null +++ b/fusion_helpdesk_central/data/mail_template_engagement.xml @@ -0,0 +1,169 @@ + + + + + + + + Helpdesk: Owner Approval Request (Single) + + {{ ctx.get('fhc_is_reminder') and 'Reminder: still waiting on your approval' or 'Action needed: please review' }} — "{{ object.name }}" + {{ object.x_fc_engagement_email }} + {{ (object.user_id.email or object.team_id.alias_email or object.company_id.email or '') }} + en_US + + +
+

Hi ,

+ + +

+ Following up on the request below — the original + email was sent a few days ago and we haven't + heard back yet. Same Approve / Reject buttons, + same one click. +

+
+ +

+ +

+
+ +

+ Your team at + has filed a request that needs your sign-off before + our team proceeds. A quick AI-prepared summary is + below; the full thread expands at the bottom if you + want the detail. +

+ +
+
Summary
+
+
+ +
+
Request
+
+
+ + + + + + +
+ + ✓ Approve + + + + ✗ Reject + +
+ +
+ View original request & full thread +
+ +
+
+ +

+ This Approve / Reject link is single-use and will + stop working once you've clicked it. +

+

— Nexa Systems Support

+
+
+
+ + + + Helpdesk: Owner Approval Request (Bulk) + + Action needed: {{ len(ctx.get('fhc_bulk_ticket_ids') or []) }} requests need your sign-off + {{ object.x_fc_engagement_email }} + {{ (object.user_id.email or object.team_id.alias_email or object.company_id.email or '') }} + en_US + + +
+

Hi ,

+ + +

+ +

+
+ +

+ + requests from + need your sign-off. Each can be approved or rejected + independently — clicking a button on one card only + acts on that card. +

+ + + +
+
Request of
+
+
+ +
+ + + + + +
+ + ✓ Approve + + + + ✗ Reject + +
+
+
+ +

+ Each Approve / Reject link is single-use. Tap the + button on the card you want to decide on; the others + stay live in this email until you act on them or we + send a fresh request. +

+

— Nexa Systems Support

+
+
+
+ +
+
diff --git a/fusion_helpdesk_central/models/__init__.py b/fusion_helpdesk_central/models/__init__.py index 61b5123a..0fd8b428 100644 --- a/fusion_helpdesk_central/models/__init__.py +++ b/fusion_helpdesk_central/models/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import fusion_helpdesk_client_key from . import helpdesk_ticket +from . import engagement_wizard diff --git a/fusion_helpdesk_central/models/engagement_wizard.py b/fusion_helpdesk_central/models/engagement_wizard.py new file mode 100644 index 00000000..a5e1138e --- /dev/null +++ b/fusion_helpdesk_central/models/engagement_wizard.py @@ -0,0 +1,380 @@ +# -*- 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.', + ) + ai_summary = fields.Text( + string='AI Summary', + help='OpenAI-generated brief. Edit before sending if you want to ' + 'tweak the framing. Empty? The wizard fell back to manual — ' + 'type your own brief, send normally.', + ) + + 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): + 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) + summary = self._generate_summary(ticket) + vals.update({ + 'ticket_id': ticket.id, + 'ai_summary': summary, + 'ai_unavailable': not bool(summary), + }) + return vals + + def _default_get_bulk(self, vals, ticket_ids): + tickets = self.env['helpdesk.ticket'].browse(ticket_ids).exists() + self._validate_bulk_targets(tickets) + # One summary per ticket, fanned out in parallel so the modal doesn't + # block for N * 15s. If the fan-out itself times out we still open + # the wizard — the user just has to fill in summaries manually. + summaries = self._generate_summaries_parallel(tickets) + any_ok = any(s for s in summaries.values()) + vals.update({ + 'ticket_ids': [(6, 0, tickets.ids)], + 'line_ids': [ + (0, 0, {'ticket_id': t.id, + 'ai_summary': summaries.get(t.id, '')}) + for t in tickets + ], + 'ai_unavailable': not any_ok, + }) + 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): + """Single-ticket summary. Returns '' on any failure — the wizard + treats empty as "AI unavailable" and shows the manual fallback.""" + 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)) + return call_openai_chat(api_key, model, prompt) + + def _generate_summaries_parallel(self, tickets): + """{ticket_id: summary_or_empty} for the bulk wizard. + + Submits N calls in parallel via a thread pool. Each call has its own + 15s timeout; the whole batch is capped at _BULK_AI_TIMEOUT so a slow + single call doesn't hold up the rest. Anything still pending at the + cap returns ''.""" + 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 {t.id: '' for t in tickets} + 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 t in tickets: + name, desc, msgs = self._summary_inputs(t) + inputs[t.id] = truncate_for_openai( + build_summary_prompt(name, desc, msgs)) + + results = {t.id: '' for t in tickets} + with concurrent.futures.ThreadPoolExecutor( + max_workers=_BULK_AI_WORKERS) as pool: + 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, + ) + return results + + # ------------------------------------------------------------------ + # 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, + ) + ai_summary = fields.Text( + string='AI Summary', + help='Per-ticket summary — edit before send.', + ) diff --git a/fusion_helpdesk_central/models/fusion_helpdesk_client_key.py b/fusion_helpdesk_central/models/fusion_helpdesk_client_key.py index e4637f44..7d1718f2 100644 --- a/fusion_helpdesk_central/models/fusion_helpdesk_client_key.py +++ b/fusion_helpdesk_central/models/fusion_helpdesk_client_key.py @@ -33,6 +33,24 @@ class FusionHelpdeskClientKey(models.Model): string='Notes', help='Optional. Stamp deployment URL, contact, install date.', ) + # Owner contact for the engagement / approval flow. Auto-refreshed + # from each incoming ticket's payload (see helpdesk_ticket.create + # override) so support always has the current owner without manual + # sync. Manual overrides on this row stick until the next ticket + # carries different values. + owner_email = fields.Char( + string='Owner Email', + help='Email of the client\'s real decision-maker (the person paying ' + 'the bill, not the Odoo "Manager" role). Used to send approval ' + 'requests when central support hits a feature that needs ' + 'sign-off. Auto-populated from the client\'s entech settings ' + 'on every ticket submission.', + ) + owner_name = fields.Char( + string='Owner Name', + help='Display name for the owner — used in email greeting and the ' + 'chatter attribution when they approve / reject.', + ) bot_user_id = fields.Many2one( 'res.users', string='Bot User', readonly=True, ondelete='restrict', diff --git a/fusion_helpdesk_central/models/helpdesk_ticket.py b/fusion_helpdesk_central/models/helpdesk_ticket.py index a57fc547..d4d8af30 100644 --- a/fusion_helpdesk_central/models/helpdesk_ticket.py +++ b/fusion_helpdesk_central/models/helpdesk_ticket.py @@ -1,15 +1,28 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 -"""Central-side helpdesk.ticket extensions for the customer follow-up flow. +"""Central-side helpdesk.ticket extensions for the customer follow-up flow +and the owner-approval engagement flow. -Adds the `x_fc_client_label` deployment tag (set by the in-app reporter so -the embedded inbox can scope per client) and sends a branded acknowledgement -email — carrying the portal magic link — when an in-app ticket is created. +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 import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import email_normalize _logger = logging.getLogger(__name__) @@ -24,13 +37,311 @@ class HelpdeskTicket(models.Model): '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), + help='Hours between engagement-sent and owner decision. Stored so ' + 'the Owner Engagements pivot can aggregate without recomputing.', + ) + + # 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() + for ticket in stale: + try: + template.with_context( + fhc_is_reminder=True, + fhc_personal_note='', + ).send_mail(ticket.id, force_send=False) + ticket.x_fc_engagement_reminded_at = now + except Exception: # noqa: BLE001 — reminder must never break cron loop + _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).', + len(stale), + ) + return len(stale) + + def _fc_finalize_engagement(self, decision, owner_partner, comment=None): + """Apply the owner's decision: post chatter (public), clear token, + write state + decided_at. Called from the public portal controller + after a magic link is clicked + confirmed. + + 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(), + 'x_fc_engagement_token': False, + }) + + # ------------------------------------------------------------------ + # 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). diff --git a/fusion_helpdesk_central/security/ir.model.access.csv b/fusion_helpdesk_central/security/ir.model.access.csv index 7bda33a7..1316d269 100644 --- a/fusion_helpdesk_central/security/ir.model.access.csv +++ b/fusion_helpdesk_central/security/ir.model.access.csv @@ -1,2 +1,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_fhc_client_key_admin,fusion.helpdesk.client.key.admin,model_fusion_helpdesk_client_key,base.group_system,1,1,1,1 +access_fhc_engagement_wizard_user,fusion.helpdesk.engagement.wizard.user,model_fusion_helpdesk_engagement_wizard,base.group_user,1,1,1,1 +access_fhc_engagement_wizard_line_user,fusion.helpdesk.engagement.wizard.line.user,model_fusion_helpdesk_engagement_wizard_line,base.group_user,1,1,1,1 diff --git a/fusion_helpdesk_central/tests/__init__.py b/fusion_helpdesk_central/tests/__init__.py index 2bded2f8..e2f2ecbb 100644 --- a/fusion_helpdesk_central/tests/__init__.py +++ b/fusion_helpdesk_central/tests/__init__.py @@ -1,2 +1,4 @@ # -*- coding: utf-8 -*- from . import test_identity +from . import test_utils +from . import test_engagement diff --git a/fusion_helpdesk_central/tests/test_engagement.py b/fusion_helpdesk_central/tests/test_engagement.py new file mode 100644 index 00000000..5e58d707 --- /dev/null +++ b/fusion_helpdesk_central/tests/test_engagement.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +"""Integration-ish tests for the owner-approval engagement flow. + +These need an env (helpdesk.ticket, client_key, wizard, portal controller), +so they run as TransactionCase. OpenAI is mocked at the utils boundary — +no live API calls in CI. HTTP requests use the standard Odoo test client. +""" +from datetime import timedelta +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, HttpCase, tagged + + +def _patch_openai(return_value='• summary bullet one\n• bullet two'): + """Mock the OpenAI client used by the wizard, returning a deterministic + summary so tests don't depend on network or API keys.""" + return patch( + 'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.' + 'call_openai_chat', + return_value=return_value, + ) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestEngagementBase(TransactionCase): + """Shared fixtures: a client_key with an owner, an ENTECH ticket.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # client_key.create() requires an actual bot user; set bot login + # to the admin so the model can find it during setUp. + cls.env['ir.config_parameter'].sudo().set_param( + 'fusion_helpdesk.bot_login', cls.env.user.login, + ) + cls.client_key = cls.env['fusion.helpdesk.client.key'].create({ + 'client_label': 'TESTCLIENT', + 'owner_email': 'owner@testclient.com', + 'owner_name': 'Test Owner', + }) + cls.team = cls.env['helpdesk.team'].create({ + 'name': 'Test Team for engagement', + }) + + def _make_ticket(self, **overrides): + vals = { + 'name': '[TESTCLIENT] Bug Report: Test ticket for engagement', + 'team_id': self.team.id, + 'x_fc_client_label': 'TESTCLIENT', + 'description': '

Steps to reproduce: do X, see Y.

', + } + vals.update(overrides) + return self.env['helpdesk.ticket'].create(vals) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestOwnerContactSync(TestEngagementBase): + + def test_sync_owner_contacts_from_payload(self): + # Simulate the client-side submit piggyback: x_fc_owner_email + + # x_fc_owner_name on create vals. Central must consume them + # (pop from vals) and upsert the client_key row. + self._make_ticket( + x_fc_owner_email='newowner@testclient.com', + x_fc_owner_name='New Owner', + ) + self.client_key.invalidate_recordset() + self.assertEqual(self.client_key.owner_email, 'newowner@testclient.com') + self.assertEqual(self.client_key.owner_name, 'New Owner') + + def test_sync_no_owner_payload_leaves_client_key_alone(self): + # No piggyback keys → existing client_key contacts must NOT be + # nuked. (We had a bug like this in the customer-followup ship.) + original_email = self.client_key.owner_email + self._make_ticket() + self.client_key.invalidate_recordset() + self.assertEqual(self.client_key.owner_email, original_email) + + def test_sync_unknown_client_label_is_silently_skipped(self): + # If a ticket arrives for a client_label we don't have a row for, + # we must not create one (would bypass API-key issuance). Just + # log and move on without raising. + self._make_ticket( + x_fc_client_label='UNKNOWN_CLIENT', + x_fc_owner_email='wat@example.com', + ) + # No client_key row was created for UNKNOWN_CLIENT + unknown = self.env['fusion.helpdesk.client.key'].search( + [('client_label', '=', 'UNKNOWN_CLIENT')]) + self.assertFalse(unknown) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestEngagementReset(TestEngagementBase): + + def test_reset_engagement_sets_all_fields(self): + t = self._make_ticket() + t._fc_reset_engagement('o@x.com', 'Owner', 'Summary text') + self.assertEqual(t.x_fc_engagement_state, 'pending') + self.assertEqual(t.x_fc_engagement_email, 'o@x.com') + self.assertEqual(t.x_fc_engagement_name, 'Owner') + self.assertEqual(t.x_fc_ai_summary, 'Summary text') + self.assertTrue(t.x_fc_engagement_token) + self.assertTrue(t.x_fc_engagement_sent_at) + self.assertFalse(t.x_fc_engagement_reminded_at) + self.assertFalse(t.x_fc_engagement_decided_at) + + def test_re_engagement_rotates_token_and_clears_decision(self): + t = self._make_ticket() + t._fc_reset_engagement('o@x.com', 'Owner', 'summary 1') + original_token = t.x_fc_engagement_token + # Simulate the owner having decided… + t.write({ + 'x_fc_engagement_state': 'rejected', + 'x_fc_engagement_decided_at': fields.Datetime.now(), + 'x_fc_engagement_reminded_at': fields.Datetime.now(), + }) + # …then re-engage. State must reset, token must rotate. + t._fc_reset_engagement('o@x.com', 'Owner', 'summary 2') + self.assertEqual(t.x_fc_engagement_state, 'pending') + self.assertNotEqual(t.x_fc_engagement_token, original_token) + self.assertFalse(t.x_fc_engagement_reminded_at) + self.assertFalse(t.x_fc_engagement_decided_at) + + def test_token_is_unique_per_call(self): + t = self._make_ticket() + tokens = set() + for _ in range(20): + t._fc_reset_engagement('o@x.com', 'Owner', '') + tokens.add(t.x_fc_engagement_token) + self.assertEqual(len(tokens), 20) + + def test_finalize_posts_chatter_and_clears_token(self): + t = self._make_ticket() + t._fc_reset_engagement('o@x.com', 'Owner', 's') + partner = self.env['res.partner'].create({ + 'name': 'Owner', 'email': 'o@x.com', + }) + before_count = self.env['mail.message'].search_count( + [('res_id', '=', t.id), ('model', '=', 'helpdesk.ticket')]) + t._fc_finalize_engagement('approved', partner, comment='LGTM') + after_count = self.env['mail.message'].search_count( + [('res_id', '=', t.id), ('model', '=', 'helpdesk.ticket')]) + self.assertGreater(after_count, before_count) + self.assertEqual(t.x_fc_engagement_state, 'approved') + self.assertFalse(t.x_fc_engagement_token) + self.assertTrue(t.x_fc_engagement_decided_at) + + def test_turnaround_hours_computed(self): + t = self._make_ticket() + now = fields.Datetime.now() + t.write({ + 'x_fc_engagement_sent_at': now - timedelta(hours=5), + 'x_fc_engagement_decided_at': now, + }) + self.assertAlmostEqual( + t.x_fc_engagement_turnaround_hours, 5.0, places=1, + ) + + def test_turnaround_zero_when_not_decided(self): + t = self._make_ticket() + t.write({ + 'x_fc_engagement_sent_at': fields.Datetime.now() - timedelta(hours=2), + }) + self.assertEqual(t.x_fc_engagement_turnaround_hours, 0.0) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestEngagementWizard(TestEngagementBase): + + def test_single_send_via_wizard(self): + t = self._make_ticket() + with _patch_openai(): + wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context( + default_ticket_id=t.id, + active_id=t.id, + active_model='helpdesk.ticket', + ).create({}) + self.assertEqual(wizard.mode, 'single') + self.assertIn('summary bullet', wizard.ai_summary) + wizard.personal_note = 'please review' + result = wizard.action_send() + # action returns the standard close-modal action + self.assertEqual(result.get('type'), 'ir.actions.act_window_close') + self.assertEqual(t.x_fc_engagement_state, 'pending') + self.assertEqual(t.x_fc_engagement_email, 'owner@testclient.com') + self.assertTrue(t.x_fc_engagement_token) + + def test_single_send_uses_current_client_key_owner(self): + # The wizard must read the FRESH owner contact from client_key, + # not a stale snapshot — if the client_key is updated between + # default_get and Send, Send wins. + t = self._make_ticket() + with _patch_openai(): + wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context( + default_ticket_id=t.id, + ).create({}) + self.client_key.owner_email = 'changed@testclient.com' + wizard.action_send() + self.assertEqual(t.x_fc_engagement_email, 'changed@testclient.com') + + def test_wizard_rejects_ticket_without_client_label(self): + t = self._make_ticket(x_fc_client_label=False) + with _patch_openai(), self.assertRaises(UserError): + self.env['fusion.helpdesk.engagement.wizard'].with_context( + default_ticket_id=t.id, + ).create({}) + + def test_wizard_rejects_when_owner_contact_missing(self): + self.client_key.write({'owner_email': False, 'owner_name': False}) + t = self._make_ticket() + with _patch_openai(), self.assertRaises(UserError): + self.env['fusion.helpdesk.engagement.wizard'].with_context( + default_ticket_id=t.id, + ).create({}) + + def test_wizard_marks_ai_unavailable_when_summary_empty(self): + t = self._make_ticket() + with _patch_openai(return_value=''): + wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context( + default_ticket_id=t.id, + ).create({}) + self.assertTrue(wizard.ai_unavailable) + self.assertEqual(wizard.ai_summary, '') + + def test_bulk_send_creates_one_engagement_per_ticket(self): + ts = self.env['helpdesk.ticket'] + for i in range(3): + ts |= self._make_ticket(name='[TESTCLIENT] Bug %s' % i) + with _patch_openai(): + wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context( + default_ticket_ids=ts.ids, + active_ids=ts.ids, + active_model='helpdesk.ticket', + fhc_bulk=True, + ).create({}) + self.assertEqual(wizard.mode, 'bulk') + self.assertEqual(len(wizard.line_ids), 3) + wizard.action_send() + for t in ts: + self.assertEqual(t.x_fc_engagement_state, 'pending') + self.assertTrue(t.x_fc_engagement_token) + # Each ticket must have its OWN token + tokens = {t.x_fc_engagement_token for t in ts} + self.assertEqual(len(tokens), 3) + + def test_bulk_rejects_mixed_clients(self): + t1 = self._make_ticket() + # Need another client_key for the mix to be valid otherwise the + # owner-contact check fires first. + self.env['fusion.helpdesk.client.key'].create({ + 'client_label': 'OTHERCLIENT', + 'owner_email': 'other@x.com', 'owner_name': 'Other', + }) + t2 = self._make_ticket( + name='[OTHERCLIENT] x', x_fc_client_label='OTHERCLIENT') + with _patch_openai(), self.assertRaises(UserError): + self.env['fusion.helpdesk.engagement.wizard'].with_context( + default_ticket_ids=[t1.id, t2.id], + fhc_bulk=True, + ).create({}) + + def test_bulk_rejects_already_pending_in_selection(self): + t1 = self._make_ticket() + t1._fc_reset_engagement('o@x.com', 'Owner', '') # already pending + t2 = self._make_ticket(name='[TESTCLIENT] B') + with _patch_openai(), self.assertRaises(UserError): + self.env['fusion.helpdesk.engagement.wizard'].with_context( + default_ticket_ids=[t1.id, t2.id], + fhc_bulk=True, + ).create({}) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestReminderCron(TestEngagementBase): + + def test_reminder_fires_for_stale_pending_only(self): + # 1 stale (should be reminded), 1 recent (no reminder), 1 already + # reminded (no second reminder), 1 already-decided (no reminder). + old = fields.Datetime.now() - timedelta(days=10) + recent = fields.Datetime.now() - timedelta(hours=2) + + stale = self._make_ticket() + stale._fc_reset_engagement('o@x.com', 'Owner', '') + stale.x_fc_engagement_sent_at = old + + too_recent = self._make_ticket(name='[TESTCLIENT] too recent') + too_recent._fc_reset_engagement('o@x.com', 'Owner', '') + too_recent.x_fc_engagement_sent_at = recent + + already_reminded = self._make_ticket(name='[TESTCLIENT] already') + already_reminded._fc_reset_engagement('o@x.com', 'Owner', '') + already_reminded.write({ + 'x_fc_engagement_sent_at': old, + 'x_fc_engagement_reminded_at': old, + }) + + decided = self._make_ticket(name='[TESTCLIENT] decided') + decided._fc_reset_engagement('o@x.com', 'Owner', '') + decided.write({ + 'x_fc_engagement_sent_at': old, + 'x_fc_engagement_state': 'approved', + }) + + # Default ICP is 3 days, so >=10 days qualifies. + sent = self.env['helpdesk.ticket']._fc_send_engagement_reminders() + self.assertEqual(sent, 1) + self.assertTrue(stale.x_fc_engagement_reminded_at) + self.assertFalse(too_recent.x_fc_engagement_reminded_at) + # already_reminded's reminded_at must not have moved + self.assertEqual( + already_reminded.x_fc_engagement_reminded_at, old, + ) + + def test_reminder_disabled_when_days_zero(self): + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_helpdesk_central.engagement_reminder_days', '0') + t = self._make_ticket() + t._fc_reset_engagement('o@x.com', 'Owner', '') + t.x_fc_engagement_sent_at = fields.Datetime.now() - timedelta(days=30) + sent = self.env['helpdesk.ticket']._fc_send_engagement_reminders() + self.assertEqual(sent, 0) + self.assertFalse(t.x_fc_engagement_reminded_at) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestEngagementPortal(HttpCase): + """HTTP-layer tests for the public approve/reject portal pages.""" + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_helpdesk.bot_login', self.env.user.login, + ) + self.client_key = self.env['fusion.helpdesk.client.key'].create({ + 'client_label': 'PORTALCLIENT', + 'owner_email': 'owner@portalclient.com', + 'owner_name': 'Portal Owner', + }) + self.team = self.env['helpdesk.team'].create({ + 'name': 'Test team portal', + }) + + def _make_pending_ticket(self): + t = self.env['helpdesk.ticket'].create({ + 'name': '[PORTALCLIENT] Bug Report: portal smoke', + 'team_id': self.team.id, + 'x_fc_client_label': 'PORTALCLIENT', + 'description': '

nothing fancy

', + }) + t._fc_reset_engagement('owner@portalclient.com', 'Portal Owner', 'sm') + # Make sure cursor sees it for the public request + self.env.cr.commit() + return t + + def test_get_with_valid_token_renders_confirm(self): + t = self._make_pending_ticket() + try: + r = self.url_open( + '/fusion_helpdesk/engagement/%s/approve' % t.x_fc_engagement_token, + timeout=10, + ) + self.assertEqual(r.status_code, 200) + self.assertIn('Confirm Approval', r.text) + self.assertIn(t.name, r.text) + finally: + t.unlink() + self.env.cr.commit() + + def test_get_with_bad_token_renders_invalid(self): + r = self.url_open( + '/fusion_helpdesk/engagement/bogus-token/approve', timeout=10, + ) + self.assertEqual(r.status_code, 200) + self.assertIn('Link no longer valid', r.text) + + def test_get_with_bad_decision_renders_invalid(self): + t = self._make_pending_ticket() + try: + r = self.url_open( + '/fusion_helpdesk/engagement/%s/sideways' + % t.x_fc_engagement_token, timeout=10, + ) + self.assertEqual(r.status_code, 200) + self.assertIn('Link no longer valid', r.text) + finally: + t.unlink() + self.env.cr.commit() + + def test_post_records_decision_and_invalidates_token(self): + t = self._make_pending_ticket() + token = t.x_fc_engagement_token + try: + r = self.url_open( + '/fusion_helpdesk/engagement/%s/approve' % token, + data={'comment': 'looks good'}, timeout=10, + ) + self.assertEqual(r.status_code, 200) + self.assertIn('Approval recorded', r.text) + t.invalidate_recordset() + self.assertEqual(t.x_fc_engagement_state, 'approved') + self.assertFalse(t.x_fc_engagement_token) + # Second click on the same URL must now show the invalid page. + r2 = self.url_open( + '/fusion_helpdesk/engagement/%s/approve' % token, timeout=10, + ) + self.assertIn('Link no longer valid', r2.text) + finally: + t.unlink() + self.env.cr.commit() diff --git a/fusion_helpdesk_central/tests/test_utils.py b/fusion_helpdesk_central/tests/test_utils.py new file mode 100644 index 00000000..dd301adc --- /dev/null +++ b/fusion_helpdesk_central/tests/test_utils.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +"""Pure-helper tests for fusion_helpdesk_central.utils. + +No Odoo env, no network — these run in any environment with `markupsafe` +installed. Mirrors the pattern in fusion_helpdesk/tests/test_utils.py. +""" +from unittest.mock import patch + +from odoo.tests import TransactionCase, tagged + +from odoo.addons.fusion_helpdesk_central.utils import ( + SUMMARY_PROMPT, + OPENAI_PROMPT_MAX_CHARS, + build_summary_prompt, + call_openai_chat, + format_engagement_chatter, + truncate_for_openai, +) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestBuildSummaryPrompt(TransactionCase): + + def test_includes_title_description_and_messages(self): + prompt = build_summary_prompt( + 'My ticket', + 'Detailed description here', + [{'author': 'Alice', 'date': '2026-05-27 10:00:00', + 'body_plain': 'first reply'}], + ) + self.assertIn('My ticket', prompt) + self.assertIn('Detailed description here', prompt) + self.assertIn('Alice', prompt) + self.assertIn('first reply', prompt) + + def test_empty_messages_becomes_explicit_marker(self): + # The model needs to know "no replies yet" — a blank section would + # invite hallucination. + prompt = build_summary_prompt('t', 'd', []) + self.assertIn('(no replies yet)', prompt) + + def test_empty_title_and_description_use_fallbacks(self): + prompt = build_summary_prompt('', '', []) + self.assertIn('(untitled)', prompt) + self.assertIn('(no description)', prompt) + + def test_empty_message_body_is_marked_not_dropped(self): + prompt = build_summary_prompt('t', 'd', [ + {'author': 'A', 'date': 'X', 'body_plain': ''}, + ]) + # An empty body must still produce a line so author + date context + # survives; '(empty)' marker keeps the model honest. + self.assertIn('(empty)', prompt) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestTruncateForOpenAI(TransactionCase): + + def test_no_truncation_when_under_limit(self): + prompt = 'x' * 100 + self.assertEqual(truncate_for_openai(prompt, max_chars=200), prompt) + + def test_truncation_appends_marker_and_respects_cap(self): + prompt = 'x' * 500 + out = truncate_for_openai(prompt, max_chars=200) + self.assertLessEqual(len(out), 200) + self.assertIn('truncated', out.lower()) + + def test_default_cap_is_8000(self): + # Regression guard — flipping this default has real $$ implications. + self.assertEqual(OPENAI_PROMPT_MAX_CHARS, 8000) + + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestFormatEngagementChatter(TransactionCase): + + def test_approve_without_comment(self): + out = str(format_engagement_chatter('approved', 'Kris')) + self.assertIn('Approved', out) + self.assertIn('Kris', out) + self.assertIn('✓', out) + # No blockquote when no comment. + self.assertNotIn('blockquote', out) + + def test_reject_with_comment(self): + out = str(format_engagement_chatter( + 'rejected', 'Kris', comment='not in scope this quarter')) + self.assertIn('Rejected', out) + self.assertIn('✗', out) + self.assertIn('blockquote', out) + self.assertIn('not in scope this quarter', out) + + def test_comment_html_escaped(self): + # XSS guard: a malicious comment must not inject script tags. + out = str(format_engagement_chatter( + 'approved', 'Kris', comment='')) + self.assertNotIn('