diff --git a/fusion_helpdesk_central/__manifest__.py b/fusion_helpdesk_central/__manifest__.py index 6d4056df..2666d0f9 100644 --- a/fusion_helpdesk_central/__manifest__.py +++ b/fusion_helpdesk_central/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 { 'name': 'Fusion Helpdesk Central — Client API Keys', - 'version': '19.0.2.0.0', + 'version': '19.0.2.1.0', 'category': 'Productivity', 'summary': 'Admin UI on the central Odoo for issuing per-client API ' 'keys used by fusion_helpdesk client deployments.', diff --git a/fusion_helpdesk_central/models/helpdesk_ticket.py b/fusion_helpdesk_central/models/helpdesk_ticket.py index f17f0867..b1ca7509 100644 --- a/fusion_helpdesk_central/models/helpdesk_ticket.py +++ b/fusion_helpdesk_central/models/helpdesk_ticket.py @@ -93,11 +93,63 @@ class HelpdeskTicket(models.Model): 'turnaround per ticket", not "summed wait-time".', ) + # ------------------------------------------------------------------ + # Owner-contact display + follower shortcut (read from client_key, + # never stored — single source of truth stays the client_key row). + # ------------------------------------------------------------------ + x_fc_owner_display = fields.Char( + string='Owner Contact', + compute='_compute_owner_display', + help='The client deployment\'s decision-maker, pulled live from ' + 'their fusion.helpdesk.client.key row. Empty when no owner ' + 'is configured for this client.', + ) + x_fc_owner_email_resolved = fields.Char( + compute='_compute_owner_display', + help='Internal helper field — drives view visibility for the ' + 'Add-as-Follower button. Email-only slice of ' + 'x_fc_owner_display.', + ) + x_fc_owner_is_follower = fields.Boolean( + compute='_compute_owner_is_follower', + help='True when the configured owner is already subscribed to ' + 'this ticket\'s thread. Used to swap the button for a ' + '"following" badge.', + ) + # message_post-friendly index for the reminder cron + token resolution. _engagement_state_idx = models.Index( '(x_fc_engagement_state, x_fc_engagement_sent_at)' ) + # ------------------------------------------------------------------ + @api.depends('x_fc_client_label') + def _compute_owner_display(self): + for rec in self: + email, name = (False, False) + if rec.x_fc_client_label: + email, name = rec._fc_owner_contact() + rec.x_fc_owner_email_resolved = email or '' + if email and name: + rec.x_fc_owner_display = '%s <%s>' % (name, email) + elif email: + rec.x_fc_owner_display = email + else: + rec.x_fc_owner_display = '' + + @api.depends('x_fc_client_label', 'message_partner_ids', + 'message_partner_ids.email') + def _compute_owner_is_follower(self): + for rec in self: + email = (rec.x_fc_owner_email_resolved or '').strip().lower() + if not email: + rec.x_fc_owner_is_follower = False + continue + rec.x_fc_owner_is_follower = any( + (p.email or '').strip().lower() == email + for p in rec.message_partner_ids + ) + # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ @@ -210,6 +262,39 @@ class HelpdeskTicket(models.Model): 'x_fc_ai_summary': ai_summary or '', }) + def action_add_owner_as_follower(self): + """Find-or-create the owner partner and subscribe them as a follower + on this ticket. One-click "loop the owner in" — different from the + engagement flow which gates on a magic-link decision; this is just + "they should be on the thread". + + Idempotent: if the owner is already following, no-op. Uses the same + find-or-create-by-email pattern as the engagement portal so the + owner partner is consistent between flows. + """ + self.ensure_one() + email, name = self._fc_owner_contact() + if not email: + raise UserError(_( + 'No owner contact configured for client "%s". Ask the client ' + 'to set it in Settings → Fusion Helpdesk → Owner Approval.' + ) % (self.x_fc_client_label or '(unset)',)) + norm = email_normalize(email) or email.strip().lower() + Partner = self.env['res.partner'].sudo() + partner = Partner.search( + [('email', '=ilike', norm)], order='id asc', limit=1, + ) + if not partner: + partner = Partner.create({ + 'name': (name or '').strip() or norm.split('@')[0].title(), + 'email': norm, + }) + if partner.id not in self.message_partner_ids.ids: + self.message_subscribe(partner_ids=[partner.id]) + # Force the compute to refresh on the next form render. + self.invalidate_recordset(['x_fc_owner_is_follower']) + return True + def action_open_engagement_wizard(self): """Form-button handler: open the wizard targeting this single ticket. diff --git a/fusion_helpdesk_central/tests/test_engagement.py b/fusion_helpdesk_central/tests/test_engagement.py index 041e1919..6a2c44df 100644 --- a/fusion_helpdesk_central/tests/test_engagement.py +++ b/fusion_helpdesk_central/tests/test_engagement.py @@ -275,6 +275,74 @@ class TestEngagementWizard(TestEngagementBase): ).create({}) +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestOwnerAsFollower(TestEngagementBase): + """The one-click 'add owner as follower' button — independent of the + engagement flow, just loops the owner into the chatter.""" + + def test_owner_display_renders_name_and_email(self): + t = self._make_ticket() + self.assertEqual( + t.x_fc_owner_display, 'Test Owner ', + ) + self.assertEqual(t.x_fc_owner_email_resolved, 'owner@testclient.com') + self.assertFalse(t.x_fc_owner_is_follower) + + def test_owner_display_blank_when_no_client_label(self): + t = self._make_ticket(x_fc_client_label=False) + self.assertFalse(t.x_fc_owner_display) + self.assertFalse(t.x_fc_owner_email_resolved) + + def test_owner_display_blank_when_client_key_has_no_owner(self): + self.client_key.write({'owner_email': False, 'owner_name': False}) + t = self._make_ticket() + self.assertFalse(t.x_fc_owner_display) + + def test_action_creates_partner_and_subscribes(self): + t = self._make_ticket() + # Pre-condition: no res.partner with that email exists. + existing = self.env['res.partner'].search( + [('email', '=ilike', 'owner@testclient.com')]) + existing.unlink() + t.action_add_owner_as_follower() + # Partner created + partner = self.env['res.partner'].search( + [('email', '=ilike', 'owner@testclient.com')], limit=1) + self.assertTrue(partner) + self.assertEqual(partner.email, 'owner@testclient.com') + # And subscribed + self.assertIn(partner.id, t.message_partner_ids.ids) + self.assertTrue(t.x_fc_owner_is_follower) + + def test_action_reuses_existing_partner(self): + t = self._make_ticket() + existing = self.env['res.partner'].create({ + 'name': 'Pre-existing Owner', + 'email': 'owner@testclient.com', + }) + t.action_add_owner_as_follower() + # No duplicate partner created + count = self.env['res.partner'].search_count( + [('email', '=ilike', 'owner@testclient.com')]) + self.assertEqual(count, 1) + self.assertIn(existing.id, t.message_partner_ids.ids) + + def test_action_is_idempotent_when_already_following(self): + t = self._make_ticket() + t.action_add_owner_as_follower() + followers_before = t.message_partner_ids.ids + t.action_add_owner_as_follower() + followers_after = t.message_partner_ids.ids + # Second call must not duplicate or re-trigger the subscribe + self.assertEqual(followers_before, followers_after) + + def test_action_raises_when_no_owner_configured(self): + self.client_key.write({'owner_email': False}) + t = self._make_ticket() + with self.assertRaises(UserError): + t.action_add_owner_as_follower() + + @tagged('post_install', '-at_install', 'fusion_helpdesk_central') class TestReminderCron(TestEngagementBase): diff --git a/fusion_helpdesk_central/views/helpdesk_ticket_views.xml b/fusion_helpdesk_central/views/helpdesk_ticket_views.xml index 0fa6145f..8c6ca34f 100644 --- a/fusion_helpdesk_central/views/helpdesk_ticket_views.xml +++ b/fusion_helpdesk_central/views/helpdesk_ticket_views.xml @@ -74,6 +74,34 @@ statusbar_visible="pending,approved,rejected"/> + + + + + +