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.exceptions import UserError
|
||||||
from odoo.http import request
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +41,7 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
)
|
)
|
||||||
def submit(self, kind, subject, description,
|
def submit(self, kind, subject, description,
|
||||||
error_code=None, attachments=None,
|
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
|
"""Forward a bug report or feature request to the central Odoo
|
||||||
Helpdesk and return {ok, ticket_id, ticket_url, error}.
|
Helpdesk and return {ok, ticket_id, ticket_url, error}.
|
||||||
|
|
||||||
@@ -60,10 +67,6 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---- Build the ticket payload ---------------------------------
|
# ---- 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 = []
|
body_parts = []
|
||||||
if description:
|
if description:
|
||||||
body_parts.append(
|
body_parts.append(
|
||||||
@@ -77,12 +80,19 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
)
|
)
|
||||||
body_parts.append(self._build_diag_block(page_url, user_agent))
|
body_parts.append(self._build_diag_block(page_url, user_agent))
|
||||||
|
|
||||||
ticket_vals = {
|
# Identity keystone: send the reporter's name + email so the central
|
||||||
'name': full_subject,
|
# helpdesk find-or-creates the customer partner and subscribes them as
|
||||||
'description': '\n'.join(body_parts),
|
# a follower — which is what enables reply emails, the magic link, and
|
||||||
}
|
# the scoped "My Tickets" inbox. reply_email is the (editable) value the
|
||||||
if cfg['team_id']:
|
# user confirmed in the dialog; fall back to their Odoo email/login.
|
||||||
ticket_vals['team_id'] = cfg['team_id']
|
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 --------------------------------------
|
# ---- Talk to remote Odoo --------------------------------------
|
||||||
try:
|
try:
|
||||||
@@ -351,6 +361,269 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
body += '</table>'
|
body += '</table>'
|
||||||
return body
|
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):
|
def _html_escape(s):
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user