feat(fusion_helpdesk): identity keystone helpers, seen model, admin group
Phase 1-2: build_ticket_vals/scope/unread helpers (utils.py), fusion.helpdesk.ticket.seen read-tracking model, group_reporter_admin. 10 unit tests pass on local Community. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Helpdesk Reporter',
|
'name': 'Fusion Helpdesk Reporter',
|
||||||
'version': '19.0.1.3.0',
|
'version': '19.0.1.4.0',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'One-click in-app bug reporting & feature requesting — '
|
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||||
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
||||||
@@ -27,6 +27,7 @@ module bundle. No dependencies on the rest of Fusion Plating.
|
|||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
'depends': ['base', 'web', 'mail'],
|
'depends': ['base', 'web', 'mail'],
|
||||||
'data': [
|
'data': [
|
||||||
|
'security/fusion_helpdesk_groups.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/ir_config_parameter_data.xml',
|
'data/ir_config_parameter_data.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import res_config_settings
|
from . import res_config_settings
|
||||||
|
from . import fusion_helpdesk_ticket_seen
|
||||||
|
|||||||
66
fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py
Normal file
66
fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Per-user read-tracking for the embedded ticket inbox.
|
||||||
|
|
||||||
|
Stores ONLY metadata — which central ticket a user has seen and up to
|
||||||
|
which message id. No ticket content is replicated locally; this exists
|
||||||
|
purely so the systray unread badge can work without re-fetching the
|
||||||
|
whole inbox on every page load. Tickets themselves remain a live RPC
|
||||||
|
view of the central Odoo.
|
||||||
|
"""
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionHelpdeskTicketSeen(models.Model):
|
||||||
|
_name = 'fusion.helpdesk.ticket.seen'
|
||||||
|
_description = 'Fusion Helpdesk — per-user read tracking (metadata only)'
|
||||||
|
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users', required=True, index=True, ondelete='cascade',
|
||||||
|
default=lambda self: self.env.uid,
|
||||||
|
)
|
||||||
|
central_ticket_id = fields.Integer(
|
||||||
|
string='Central Ticket ID', required=True, index=True,
|
||||||
|
help='helpdesk.ticket id on the central Odoo.',
|
||||||
|
)
|
||||||
|
last_seen_message_id = fields.Integer(
|
||||||
|
string='Last Seen Message ID', default=0,
|
||||||
|
help='Highest central mail.message id this user has viewed for '
|
||||||
|
'the ticket. Drives the unread badge.',
|
||||||
|
)
|
||||||
|
|
||||||
|
_user_ticket_uniq = models.Constraint(
|
||||||
|
'UNIQUE(user_id, central_ticket_id)',
|
||||||
|
'One seen-row per user per ticket.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _mark_seen(self, central_ticket_id, last_message_id):
|
||||||
|
"""Upsert the current user's last-seen marker for a ticket.
|
||||||
|
|
||||||
|
Monotonic — never moves the marker backwards (a stale client
|
||||||
|
reporting an older id can't resurrect an unread badge)."""
|
||||||
|
rec = self.search([
|
||||||
|
('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', '=', central_ticket_id),
|
||||||
|
], limit=1)
|
||||||
|
if rec:
|
||||||
|
if (last_message_id or 0) > rec.last_seen_message_id:
|
||||||
|
rec.last_seen_message_id = last_message_id
|
||||||
|
else:
|
||||||
|
self.create({
|
||||||
|
'central_ticket_id': central_ticket_id,
|
||||||
|
'last_seen_message_id': last_message_id or 0,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _seen_map(self, central_ticket_ids):
|
||||||
|
"""Return {central_ticket_id: last_seen_message_id} for the
|
||||||
|
current user across the given ticket ids."""
|
||||||
|
rows = self.search([
|
||||||
|
('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', 'in', list(central_ticket_ids)),
|
||||||
|
])
|
||||||
|
return {r.central_ticket_id: r.last_seen_message_id for r in rows}
|
||||||
18
fusion_helpdesk/security/fusion_helpdesk_groups.xml
Normal file
18
fusion_helpdesk/security/fusion_helpdesk_groups.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
<!--
|
||||||
|
Deployment-level admin for the embedded ticket inbox. Members see
|
||||||
|
ALL tickets filed from this deployment (scoped by x_fc_client_label)
|
||||||
|
in the "My Tickets" tab; non-members see only their own. The gate is
|
||||||
|
enforced server-side in the controller via has_group().
|
||||||
|
Odoo 19: res.groups has NO `users`/`category_id` fields — keep minimal.
|
||||||
|
-->
|
||||||
|
<record id="group_reporter_admin" model="res.groups">
|
||||||
|
<field name="name">Helpdesk Reporter Admin</field>
|
||||||
|
<field name="comment">Can view all tickets filed from this deployment in the in-app helpdesk inbox.</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -1 +1,2 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1
|
||||||
|
|||||||
|
3
fusion_helpdesk/tests/__init__.py
Normal file
3
fusion_helpdesk/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import test_utils
|
||||||
|
from . import test_seen
|
||||||
27
fusion_helpdesk/tests/test_seen.py
Normal file
27
fusion_helpdesk/tests/test_seen.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Tests for fusion.helpdesk.ticket.seen read-tracking."""
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||||
|
class TestSeen(TransactionCase):
|
||||||
|
|
||||||
|
def test_mark_seen_upserts_and_is_monotonic(self):
|
||||||
|
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||||
|
Seen._mark_seen(central_ticket_id=42, last_message_id=100)
|
||||||
|
Seen._mark_seen(central_ticket_id=42, last_message_id=120)
|
||||||
|
Seen._mark_seen(central_ticket_id=42, last_message_id=90) # stale, ignored
|
||||||
|
rec = Seen.search([
|
||||||
|
('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', '=', 42),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(rec), 1, "should upsert, not duplicate")
|
||||||
|
self.assertEqual(rec.last_seen_message_id, 120, "monotonic — never moves back")
|
||||||
|
|
||||||
|
def test_seen_map(self):
|
||||||
|
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||||
|
Seen._mark_seen(1, 10)
|
||||||
|
Seen._mark_seen(2, 20)
|
||||||
|
self.assertEqual(Seen._seen_map([1, 2, 3]), {1: 10, 2: 20})
|
||||||
91
fusion_helpdesk/tests/test_utils.py
Normal file
91
fusion_helpdesk/tests/test_utils.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Unit tests for the pure helpers in fusion_helpdesk.utils.
|
||||||
|
|
||||||
|
These need no live central Odoo — they pin the identity keystone, the
|
||||||
|
scoping security boundary, the public-message filter and the unread
|
||||||
|
maths as plain data transformations.
|
||||||
|
"""
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
from odoo.addons.fusion_helpdesk.utils import (
|
||||||
|
build_ticket_vals,
|
||||||
|
build_scope_domain,
|
||||||
|
is_public_message,
|
||||||
|
compute_unread_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||||
|
class TestBuildTicketVals(TransactionCase):
|
||||||
|
|
||||||
|
def test_identity_fields_present(self):
|
||||||
|
vals = build_ticket_vals(
|
||||||
|
kind='bug', subject='X', body_html='<p>b</p>',
|
||||||
|
team_id=1, client_label='ENTECH',
|
||||||
|
reporter_name='John Doe', reporter_email='john@entech.com',
|
||||||
|
company_name='ENTECH Inc',
|
||||||
|
)
|
||||||
|
self.assertEqual(vals['partner_email'], 'john@entech.com')
|
||||||
|
self.assertEqual(vals['partner_name'], 'John Doe')
|
||||||
|
self.assertEqual(vals['x_fc_client_label'], 'ENTECH')
|
||||||
|
self.assertEqual(vals['partner_company_name'], 'ENTECH Inc')
|
||||||
|
self.assertEqual(vals['team_id'], 1)
|
||||||
|
self.assertIn('X', vals['name'])
|
||||||
|
self.assertIn('[ENTECH]', vals['name'])
|
||||||
|
|
||||||
|
def test_no_email_omits_partner_email(self):
|
||||||
|
vals = build_ticket_vals(
|
||||||
|
kind='feature', subject='Y', body_html='<p>b</p>',
|
||||||
|
team_id=False, client_label='', reporter_name='Jane',
|
||||||
|
reporter_email='', company_name='',
|
||||||
|
)
|
||||||
|
self.assertNotIn('partner_email', vals) # never send an empty email
|
||||||
|
self.assertNotIn('team_id', vals) # omit falsy team
|
||||||
|
self.assertNotIn('x_fc_client_label', vals) # omit empty label
|
||||||
|
self.assertEqual(vals['partner_name'], 'Jane')
|
||||||
|
self.assertIn('Feature Request', vals['name'])
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||||
|
class TestScopeDomain(TransactionCase):
|
||||||
|
|
||||||
|
def test_regular_scope_binds_email_and_label(self):
|
||||||
|
dom = build_scope_domain(label='ENTECH', email='john@entech.com', is_admin=False)
|
||||||
|
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||||
|
self.assertIn(('partner_email', '=ilike', 'john@entech.com'), dom)
|
||||||
|
|
||||||
|
def test_admin_scope_binds_label_only(self):
|
||||||
|
dom = build_scope_domain(label='ENTECH', email='a@entech.com', is_admin=True)
|
||||||
|
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||||
|
self.assertFalse(any(t[0] == 'partner_email' for t in dom))
|
||||||
|
|
||||||
|
def test_empty_label_never_matches_everything(self):
|
||||||
|
dom = build_scope_domain(label='', email='', is_admin=True)
|
||||||
|
# label term must be present and must NOT be an empty string
|
||||||
|
label_terms = [t for t in dom if t[0] == 'x_fc_client_label']
|
||||||
|
self.assertEqual(len(label_terms), 1)
|
||||||
|
self.assertNotEqual(label_terms[0][2], '')
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||||
|
class TestMessageFilterAndUnread(TransactionCase):
|
||||||
|
|
||||||
|
def test_internal_note_is_not_public(self):
|
||||||
|
self.assertFalse(is_public_message({'subtype_is_internal': True}))
|
||||||
|
self.assertTrue(is_public_message({'subtype_is_internal': False}))
|
||||||
|
self.assertTrue(is_public_message({})) # default visible
|
||||||
|
|
||||||
|
def test_unread_count(self):
|
||||||
|
tickets = [
|
||||||
|
{'id': 1, 'last_support_msg_id': 10}, # seen 10 -> read
|
||||||
|
{'id': 2, 'last_support_msg_id': 5}, # seen 3 -> unread
|
||||||
|
{'id': 3, 'last_support_msg_id': 0}, # no support msg
|
||||||
|
]
|
||||||
|
seen = {1: 10, 2: 3}
|
||||||
|
self.assertEqual(compute_unread_count(tickets, seen), 1)
|
||||||
|
|
||||||
|
def test_unread_count_unseen_ticket_counts(self):
|
||||||
|
tickets = [{'id': 9, 'last_support_msg_id': 4}]
|
||||||
|
self.assertEqual(compute_unread_count(tickets, {}), 1)
|
||||||
83
fusion_helpdesk/utils.py
Normal file
83
fusion_helpdesk/utils.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Pure helpers for fusion_helpdesk.
|
||||||
|
|
||||||
|
No Odoo environment, no `request` — just data in, data out. Everything
|
||||||
|
here is unit-testable in isolation, which is what lets us validate the
|
||||||
|
identity keystone, the server-side scoping boundary, the public-message
|
||||||
|
filter and the unread maths without a live central Odoo to talk to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Sentinel used so a missing label/email can never widen a domain to
|
||||||
|
# "match everything". An empty string in `=`/`=ilike` would match rows
|
||||||
|
# whose field is also empty; '__none__' will simply match nothing.
|
||||||
|
_NO_MATCH = '__none__'
|
||||||
|
|
||||||
|
|
||||||
|
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
||||||
|
reporter_name, reporter_email, company_name):
|
||||||
|
"""Construct the `helpdesk.ticket` create vals for a forwarded report.
|
||||||
|
|
||||||
|
The identity fields (`partner_email`, `partner_name`,
|
||||||
|
`partner_company_name`) drive native helpdesk find-or-create of the
|
||||||
|
customer partner + follower subscription on the central Odoo, and
|
||||||
|
`x_fc_client_label` tags the deployment for the scoped inbox.
|
||||||
|
"""
|
||||||
|
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
||||||
|
prefix = ('[%s] ' % client_label) if client_label else ''
|
||||||
|
vals = {
|
||||||
|
'name': '%s%s: %s' % (prefix, kind_label, subject or '(untitled)'),
|
||||||
|
'description': body_html,
|
||||||
|
'partner_name': reporter_name or '',
|
||||||
|
}
|
||||||
|
if team_id:
|
||||||
|
vals['team_id'] = team_id
|
||||||
|
if reporter_email:
|
||||||
|
vals['partner_email'] = reporter_email
|
||||||
|
if company_name:
|
||||||
|
vals['partner_company_name'] = company_name
|
||||||
|
if client_label:
|
||||||
|
vals['x_fc_client_label'] = client_label
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
def build_scope_domain(label, email, is_admin):
|
||||||
|
"""Server-side ticket scope for the embedded inbox.
|
||||||
|
|
||||||
|
`x_fc_client_label` is ALWAYS bound (defense in depth) so neither a
|
||||||
|
regular user nor a deployment admin can ever read another
|
||||||
|
deployment's tickets — even though the shared bot can technically see
|
||||||
|
every ticket on the central Odoo. Regular users are additionally
|
||||||
|
bound to their own `partner_email`.
|
||||||
|
"""
|
||||||
|
domain = [('x_fc_client_label', '=', label or _NO_MATCH)]
|
||||||
|
if not is_admin:
|
||||||
|
domain.append(('partner_email', '=ilike', email or _NO_MATCH))
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_message(msg):
|
||||||
|
"""True when a message is customer-visible (not an internal note).
|
||||||
|
|
||||||
|
`msg` is a plain dict carrying a `subtype_is_internal` flag resolved
|
||||||
|
from the central `mail.message.subtype`. Internal notes must never be
|
||||||
|
shown to a client in the embedded inbox.
|
||||||
|
"""
|
||||||
|
return not msg.get('subtype_is_internal', False)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_unread_count(tickets, seen_by_id):
|
||||||
|
"""Number of tickets with a support reply the user hasn't seen.
|
||||||
|
|
||||||
|
`tickets` is a list of dicts each carrying `id` and
|
||||||
|
`last_support_msg_id` (id of the latest customer-visible support
|
||||||
|
message, 0 if none). `seen_by_id` maps central ticket id -> last
|
||||||
|
message id the user has seen (absent => 0 baseline).
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
for ticket in tickets:
|
||||||
|
last = ticket.get('last_support_msg_id') or 0
|
||||||
|
if last and last > (seen_by_id.get(ticket['id']) or 0):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
Reference in New Issue
Block a user