From 304e19ce456b982c8914334f1e16037405262853 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 03:28:15 -0400 Subject: [PATCH] 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) --- fusion_helpdesk/controllers/main.py | 295 ++++++++++++++++++++++++++-- 1 file changed, 284 insertions(+), 11 deletions(-) diff --git a/fusion_helpdesk/controllers/main.py b/fusion_helpdesk/controllers/main.py index b5ad514f..8b124d83 100644 --- a/fusion_helpdesk/controllers/main.py +++ b/fusion_helpdesk/controllers/main.py @@ -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 += '' 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/', + 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//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 = '

%s

' % _html_escape(text).replace('\n', '
') + 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 (