Findings from the post-feature code review on commit 396170b4. Addresses
the two CRITICAL + one HIGH + two MEDIUM issues; rest are deferred.
CRITICAL #1 — magic-link token race:
Two near-simultaneous POSTs on the same /engagement/<token>/approve
could both SELECT state='pending' under READ COMMITTED, both post
chatter, and let the last writer flip the outcome. Now the POST path
does an atomic UPDATE helpdesk_ticket SET token=NULL WHERE token=%s
AND state='pending' RETURNING id — the loser gets no row back and
renders the friendly invalid-link page. Verified live: 2 concurrent
POSTs → 1 wins, 1 loses, exactly 1 chatter row.
CRITICAL #2 — reminder cron without per-row savepoint:
Per CLAUDE.md rule #14, a DB failure mid-loop aborts the whole
transaction and silently kills the rest of the batch. Wrap each row's
send_mail+write in `with self.env.cr.savepoint()`. Also corrected the
success-count log (was len(stale), now actual sent count).
HIGH #3 — turnaround pivot summed instead of averaged:
fields.Float defaults to SUM aggregator; meaningless for per-ticket
decision delays. Added aggregator='avg' so the pivot reads "avg
turnaround per ticket" not "summed wait time".
HIGH #4 — added test_concurrent_claim_only_one_wins regression test
that fires two real HTTP POSTs against the same token and asserts
exactly one wins + exactly one approval chatter row exists.
MEDIUM #6 — cron nextcall pinned to 09:00 tomorrow so reminders land
in business hours regardless of when the module was last upgraded.
MEDIUM #10 — escalate failed owner-partner-create from WARNING to
ERROR (via _logger.exception) since silent attribution to the bot
account is a real audit-trail confusion.
Deferred (follow-up commits): #5, #7 (executor cleanup), #8, #9,
#11–#14 — none are bugs, all spec-drift or hardening.
410 lines
18 KiB
Python
410 lines
18 KiB
Python
# -*- 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,
|
|
)
|