diff --git a/fusion_helpdesk/__manifest__.py b/fusion_helpdesk/__manifest__.py index 11f38e4d..9f24d860 100644 --- a/fusion_helpdesk/__manifest__.py +++ b/fusion_helpdesk/__manifest__.py @@ -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', diff --git a/fusion_helpdesk/models/__init__.py b/fusion_helpdesk/models/__init__.py index 6084d2ca..e4c753a3 100644 --- a/fusion_helpdesk/models/__init__.py +++ b/fusion_helpdesk/models/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import res_config_settings +from . import fusion_helpdesk_ticket_seen diff --git a/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py b/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py new file mode 100644 index 00000000..9516623e --- /dev/null +++ b/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py @@ -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} diff --git a/fusion_helpdesk/security/fusion_helpdesk_groups.xml b/fusion_helpdesk/security/fusion_helpdesk_groups.xml new file mode 100644 index 00000000..cf12ed69 --- /dev/null +++ b/fusion_helpdesk/security/fusion_helpdesk_groups.xml @@ -0,0 +1,18 @@ + + + + + + Helpdesk Reporter Admin + Can view all tickets filed from this deployment in the in-app helpdesk inbox. + + diff --git a/fusion_helpdesk/security/ir.model.access.csv b/fusion_helpdesk/security/ir.model.access.csv index 97dd8b91..eea1f417 100644 --- a/fusion_helpdesk/security/ir.model.access.csv +++ b/fusion_helpdesk/security/ir.model.access.csv @@ -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 diff --git a/fusion_helpdesk/tests/__init__.py b/fusion_helpdesk/tests/__init__.py new file mode 100644 index 00000000..ef0e8181 --- /dev/null +++ b/fusion_helpdesk/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import test_utils +from . import test_seen diff --git a/fusion_helpdesk/tests/test_seen.py b/fusion_helpdesk/tests/test_seen.py new file mode 100644 index 00000000..e3862232 --- /dev/null +++ b/fusion_helpdesk/tests/test_seen.py @@ -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}) diff --git a/fusion_helpdesk/tests/test_utils.py b/fusion_helpdesk/tests/test_utils.py new file mode 100644 index 00000000..aa47adcd --- /dev/null +++ b/fusion_helpdesk/tests/test_utils.py @@ -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='

b

', + 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='

b

', + 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) diff --git a/fusion_helpdesk/utils.py b/fusion_helpdesk/utils.py new file mode 100644 index 00000000..56a06f9a --- /dev/null +++ b/fusion_helpdesk/utils.py @@ -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