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)
|
||||
{
|
||||
'name': 'Fusion Helpdesk Reporter',
|
||||
'version': '19.0.1.3.0',
|
||||
'version': '19.0.1.4.0',
|
||||
'category': 'Productivity',
|
||||
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||
'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',
|
||||
'depends': ['base', 'web', 'mail'],
|
||||
'data': [
|
||||
'security/fusion_helpdesk_groups.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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
|
||||
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