feat(fusion_helpdesk): identity keystone in submit + scoped inbox endpoints

submit() now sends partner_email/partner_name/x_fc_client_label. New jsonrpc endpoints my_tickets/ticket_detail/ticket_reply/unread_count, all auth=user with server-side scoping (build_scope_domain), internal-note filtering, and graceful remote-failure handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 03:28:15 -04:00
parent 96b3441997
commit 304e19ce45

View File

@@ -23,6 +23,13 @@ from odoo import _, http
from odoo.exceptions import UserError
from odoo.http import request
from odoo.addons.fusion_helpdesk.utils import (
build_ticket_vals,
build_scope_domain,
is_public_message,
compute_unread_count,
)
_logger = logging.getLogger(__name__)
@@ -34,7 +41,7 @@ class FusionHelpdeskController(http.Controller):
)
def submit(self, kind, subject, description,
error_code=None, attachments=None,
page_url=None, user_agent=None):
page_url=None, user_agent=None, reply_email=None):
"""Forward a bug report or feature request to the central Odoo
Helpdesk and return {ok, ticket_id, ticket_url, error}.
@@ -60,10 +67,6 @@ class FusionHelpdeskController(http.Controller):
}
# ---- Build the ticket payload ---------------------------------
prefix = ('[%s] ' % cfg['client_label']) if cfg['client_label'] else ''
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
full_subject = '%s%s: %s' % (prefix, kind_label, subject or '(untitled)')
body_parts = []
if description:
body_parts.append(
@@ -77,12 +80,19 @@ class FusionHelpdeskController(http.Controller):
)
body_parts.append(self._build_diag_block(page_url, user_agent))
ticket_vals = {
'name': full_subject,
'description': '\n'.join(body_parts),
}
if cfg['team_id']:
ticket_vals['team_id'] = cfg['team_id']
# Identity keystone: send the reporter's name + email so the central
# helpdesk find-or-creates the customer partner and subscribes them as
# a follower — which is what enables reply emails, the magic link, and
# the scoped "My Tickets" inbox. reply_email is the (editable) value the
# user confirmed in the dialog; fall back to their Odoo email/login.
user = request.env.user
reporter_email = (reply_email or user.email or user.login or '').strip()
ticket_vals = build_ticket_vals(
kind=kind, subject=subject, body_html='\n'.join(body_parts),
team_id=cfg['team_id'], client_label=cfg['client_label'],
reporter_name=user.name, reporter_email=reporter_email,
company_name=request.env.company.name,
)
# ---- Talk to remote Odoo --------------------------------------
try:
@@ -351,6 +361,269 @@ class FusionHelpdeskController(http.Controller):
body += '</table>'
return body
# ==================================================================
# Embedded ticket inbox — identity, RPC seam, helpers
# ==================================================================
def _identity(self):
"""Resolve the caller's scope from the SERVER-SIDE session only.
Never trust an email / label / scope sent by the browser — this is
the security boundary that stops one deployment reading another's
tickets through the shared bot account."""
user = request.env.user
cfg = self._read_config()
return {
'cfg': cfg,
'email': (user.email or user.login or '').strip(),
'label': cfg['client_label'],
'is_admin': user.has_group('fusion_helpdesk.group_reporter_admin'),
'name': user.name,
}
def _config_ready(self, cfg):
return all([cfg['url'], cfg['db'], cfg['login'], cfg['password']])
def _rpc(self, cfg, model, method, args, kw=None):
"""Authenticate + execute_kw against the central Odoo as the bot."""
uid, proxy = self._authenticate(cfg)
return proxy.execute_kw(
cfg['db'], uid, cfg['password'], model, method, args, kw or {},
)
def _internal_subtype_map(self, cfg, subtype_ids):
"""{subtype_id: internal_bool} so internal notes can be hidden."""
ids = [s for s in set(subtype_ids) if s]
if not ids:
return {}
rows = self._rpc(cfg, 'mail.message.subtype', 'read',
[ids], {'fields': ['internal']})
return {r['id']: r.get('internal', False) for r in rows}
def _ticket_messages(self, cfg, ticket_ids):
"""Raw comment/email messages for a set of tickets (one RPC)."""
if not ticket_ids:
return []
return self._rpc(
cfg, 'mail.message', 'search_read',
[[('model', '=', 'helpdesk.ticket'),
('res_id', 'in', list(ticket_ids)),
('message_type', 'in', ['comment', 'email'])]],
{'fields': ['id', 'res_id', 'author_id', 'subtype_id']},
)
def _last_support_map(self, cfg, tickets, msgs):
"""{ticket_id: latest customer-visible SUPPORT message id}.
A support message is a public comment NOT authored by the ticket's
own customer (internal notes and the customer's own posts excluded)."""
internal = self._internal_subtype_map(
cfg, [m['subtype_id'][0] for m in msgs if m.get('subtype_id')])
customer = {
t['id']: (t['partner_id'][0] if t['partner_id'] else None)
for t in tickets
}
last = {}
for m in msgs:
st = m.get('subtype_id')
if st and internal.get(st[0]):
continue # internal note — never counts / never shown
author = m['author_id'][0] if m['author_id'] else None
rid = m['res_id']
if author and author == customer.get(rid):
continue # the customer's own reply isn't an unread "support" msg
if m['id'] > last.get(rid, 0):
last[rid] = m['id']
return last
def _public_messages(self, cfg, ticket_id):
"""Customer-visible thread for one ticket, oldest first."""
raw = self._rpc(
cfg, 'mail.message', 'search_read',
[[('model', '=', 'helpdesk.ticket'),
('res_id', '=', ticket_id),
('message_type', 'in', ['comment', 'email'])]],
{'fields': ['id', 'date', 'body', 'author_id', 'subtype_id'],
'order': 'id asc'},
)
internal = self._internal_subtype_map(
cfg, [m['subtype_id'][0] for m in raw if m.get('subtype_id')])
out = []
for m in raw:
st = m.get('subtype_id')
msg = {
'id': m['id'],
'date': m['date'],
'body': m['body'] or '',
'author': (m['author_id'][1] if m['author_id'] else ''),
'author_id': (m['author_id'][0] if m['author_id'] else False),
'subtype_is_internal': internal.get(st[0], False) if st else False,
}
if is_public_message(msg):
out.append(msg)
return out
def _resolve_author(self, cfg, ident, ticket):
"""Find-or-create the replier's partner on central so their reply is
correctly attributed. Falls back to the ticket's customer if partner
creation isn't permitted for the bot."""
email = ident['email']
ticket_partner = ticket['partner_id'][0] if ticket.get('partner_id') else False
if not email:
return ticket_partner
try:
pids = self._rpc(cfg, 'res.partner', 'search',
[[('email', '=ilike', email)]], {'limit': 1})
if pids:
return pids[0]
return self._rpc(cfg, 'res.partner', 'create',
[{'name': ident['name'], 'email': email}])
except (xmlrpc.client.Fault, _RemoteError):
return ticket_partner
def _remote_failure(self, cfg, err):
"""Map a mid-RPC failure to the dialog's response shape."""
if isinstance(err, _RemoteError):
return err.to_response()
if isinstance(err, (socket.timeout, OSError, ssl.SSLError)):
return _network_error_response(cfg['url'], err)
return {'ok': False, 'error': 'remote_error',
'message': _('The central Helpdesk returned an error: %s'
) % str(err)}
# ==================================================================
# Embedded ticket inbox — endpoints (auth='user', server-side scoped)
# ==================================================================
@http.route('/fusion_helpdesk/my_tickets',
type='jsonrpc', auth='user', methods=['POST'])
def my_tickets(self, scope='mine'):
"""List the caller's tickets (scoped). Admins may pass scope='all'
to see every ticket from their deployment."""
ident = self._identity()
cfg = ident['cfg']
if not self._config_ready(cfg):
return {'ok': False, 'error': 'config_missing',
'message': _('Fusion Helpdesk is not configured.')}
view_all = ident['is_admin'] and scope == 'all'
domain = build_scope_domain(ident['label'], ident['email'], view_all)
try:
tickets = self._rpc(
cfg, 'helpdesk.ticket', 'search_read', [domain],
{'fields': ['id', 'name', 'stage_id', 'partner_id',
'write_date', 'ticket_ref'],
'order': 'write_date desc', 'limit': 100})
msgs = self._ticket_messages(cfg, [t['id'] for t in tickets])
except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError) as e:
return self._remote_failure(cfg, e)
last_support = self._last_support_map(cfg, tickets, msgs)
ids = [t['id'] for t in tickets]
seen = request.env['fusion.helpdesk.ticket.seen']._seen_map(ids)
rows = []
for t in tickets:
rid = t['id']
ls = last_support.get(rid, 0)
rows.append({
'id': rid,
'ref': t.get('ticket_ref') or str(rid),
'subject': t['name'],
'stage': t['stage_id'][1] if t['stage_id'] else '',
'last_update': t['write_date'],
'last_support_msg_id': ls,
'has_unread': ls > (seen.get(rid, 0) or 0),
})
return {'ok': True, 'tickets': rows, 'is_admin': ident['is_admin'],
'unread': compute_unread_count(rows, seen)}
@http.route('/fusion_helpdesk/ticket/<int:ticket_id>',
type='jsonrpc', auth='user', methods=['POST'])
def ticket_detail(self, ticket_id, **kw):
"""Full thread for one ticket — re-checks scope, hides internal notes,
marks the ticket seen for the badge."""
ident = self._identity()
cfg = ident['cfg']
if not self._config_ready(cfg):
return {'ok': False, 'error': 'config_missing',
'message': _('Fusion Helpdesk is not configured.')}
domain = build_scope_domain(
ident['label'], ident['email'], ident['is_admin']
) + [('id', '=', ticket_id)]
try:
found = self._rpc(cfg, 'helpdesk.ticket', 'search_read', [domain],
{'fields': ['id', 'name', 'stage_id'], 'limit': 1})
if not found:
return {'ok': False, 'error': 'not_found',
'message': _('Ticket not found or not accessible.')}
messages = self._public_messages(cfg, ticket_id)
except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError) as e:
return self._remote_failure(cfg, e)
if messages:
request.env['fusion.helpdesk.ticket.seen']._mark_seen(
ticket_id, max(m['id'] for m in messages))
t = found[0]
return {'ok': True, 'ticket': {
'id': t['id'], 'subject': t['name'],
'stage': t['stage_id'][1] if t['stage_id'] else '',
'messages': messages}}
@http.route('/fusion_helpdesk/ticket/<int:ticket_id>/reply',
type='jsonrpc', auth='user', methods=['POST'])
def ticket_reply(self, ticket_id, body=None, **kw):
"""Post a customer reply on a scoped ticket, attributed to the replier."""
ident = self._identity()
cfg = ident['cfg']
text = (body or '').strip()
if not text:
return {'ok': False, 'error': 'empty',
'message': _('Your reply is empty.')}
if not self._config_ready(cfg):
return {'ok': False, 'error': 'config_missing',
'message': _('Fusion Helpdesk is not configured.')}
domain = build_scope_domain(
ident['label'], ident['email'], ident['is_admin']
) + [('id', '=', ticket_id)]
try:
found = self._rpc(cfg, 'helpdesk.ticket', 'search_read', [domain],
{'fields': ['id', 'partner_id'], 'limit': 1})
if not found:
return {'ok': False, 'error': 'not_found',
'message': _('Ticket not found or not accessible.')}
author_id = self._resolve_author(cfg, ident, found[0])
html = '<p>%s</p>' % _html_escape(text).replace('\n', '<br/>')
self._rpc(cfg, 'helpdesk.ticket', 'message_post', [[ticket_id]], {
'body': html, 'message_type': 'comment',
'subtype_xmlid': 'mail.mt_comment', 'author_id': author_id,
})
messages = self._public_messages(cfg, ticket_id)
except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError) as e:
return self._remote_failure(cfg, e)
if messages:
request.env['fusion.helpdesk.ticket.seen']._mark_seen(
ticket_id, max(m['id'] for m in messages))
return {'ok': True, 'messages': messages}
@http.route('/fusion_helpdesk/unread_count',
type='jsonrpc', auth='user', methods=['POST'])
def unread_count(self):
"""Badge count: tickets with a support reply newer than last-seen.
Always scoped to the caller's OWN tickets (never the admin-all view)."""
ident = self._identity()
cfg = ident['cfg']
if not self._config_ready(cfg):
return {'ok': True, 'count': 0}
domain = build_scope_domain(ident['label'], ident['email'], False)
try:
tickets = self._rpc(cfg, 'helpdesk.ticket', 'search_read', [domain],
{'fields': ['id', 'partner_id'], 'limit': 100})
msgs = self._ticket_messages(cfg, [t['id'] for t in tickets])
except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError):
return {'ok': True, 'count': 0} # badge must never break the systray
last_support = self._last_support_map(cfg, tickets, msgs)
rows = [{'id': k, 'last_support_msg_id': v}
for k, v in last_support.items()]
seen = request.env['fusion.helpdesk.ticket.seen']._seen_map(
list(last_support.keys()))
return {'ok': True, 'count': compute_unread_count(rows, seen)}
def _html_escape(s):
return (