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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user