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.
182 lines
7.9 KiB
Python
182 lines
7.9 KiB
Python
# -*- 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, claim=False):
|
|
"""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'
|
|
|
|
When `claim=True` (POST path), atomically clear the token via
|
|
UPDATE ... RETURNING so two near-simultaneous clicks can't both
|
|
succeed — the second SELECT-then-write would otherwise double-post
|
|
the decision chatter and let the last writer flip the outcome.
|
|
The atomic UPDATE makes single-use a property of the SQL, not of
|
|
the application logic.
|
|
|
|
When `claim=False` (GET path), we just look — no token rotation
|
|
— so re-loading the confirm page before clicking still works.
|
|
"""
|
|
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'
|
|
if claim:
|
|
# Atomic claim: clear the token only if it's still pending. The
|
|
# WHERE clause re-checks state so a re-engagement (which
|
|
# rotates the token) between GET and POST won't be claimed.
|
|
request.env.cr.execute(
|
|
"UPDATE helpdesk_ticket "
|
|
"SET x_fc_engagement_token = NULL "
|
|
"WHERE x_fc_engagement_token = %s "
|
|
" AND x_fc_engagement_state = 'pending' "
|
|
"RETURNING id",
|
|
(token,),
|
|
)
|
|
row = request.env.cr.fetchone()
|
|
if not row:
|
|
return (None, None) # lost the race or token rotated
|
|
ticket = request.env['helpdesk.ticket'].sudo().browse(row[0])
|
|
# Invalidate the ORM cache for the field we just changed via raw SQL.
|
|
ticket.invalidate_recordset(['x_fc_engagement_token'])
|
|
return (ticket, decision_state)
|
|
# GET path — just look, don't claim.
|
|
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/<string:token>/<string:decision>',
|
|
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/<string:token>/<string:decision>',
|
|
type='http', auth='public', methods=['POST'], csrf=False, sitemap=False,
|
|
)
|
|
def engagement_submit(self, token, decision, **post):
|
|
# claim=True does an atomic UPDATE that clears the token only if
|
|
# the row is still pending — wins the race against a second click.
|
|
ticket, decision_state = self._resolve(token, decision, claim=True)
|
|
if not ticket:
|
|
# Could be a second click on the same link, 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:
|
|
# ERROR not WARNING: a failed owner partner-create means the
|
|
# approval chatter line will say "Approved by support@nexasystems.ca"
|
|
# (the bot) instead of the actual owner — a real audit-trail
|
|
# confusion that someone needs to look at.
|
|
_logger.exception(
|
|
'fusion_helpdesk_central: could not create owner partner '
|
|
'for %s on ticket %s; chatter author will fall back to the '
|
|
'service account. Body text still names the owner correctly '
|
|
'via format_engagement_chatter.', email, ticket.id,
|
|
)
|
|
return None
|