fix(fusion_helpdesk_central): close magic-link race + cron savepoint + avg pivot
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.
This commit is contained in:
@@ -86,8 +86,11 @@ class HelpdeskTicket(models.Model):
|
||||
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.',
|
||||
'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.
|
||||
@@ -292,28 +295,41 @@ class HelpdeskTicket(models.Model):
|
||||
)
|
||||
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:
|
||||
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
|
||||
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).',
|
||||
len(stale),
|
||||
'fusion_helpdesk_central: reminder cron sent %s reminder(s) '
|
||||
'out of %s candidate(s).', sent, len(stale),
|
||||
)
|
||||
return len(stale)
|
||||
return sent
|
||||
|
||||
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.
|
||||
"""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
|
||||
@@ -336,7 +352,6 @@ class HelpdeskTicket(models.Model):
|
||||
self.write({
|
||||
'x_fc_engagement_state': decision,
|
||||
'x_fc_engagement_decided_at': fields.Datetime.now(),
|
||||
'x_fc_engagement_token': False,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user