Compare commits
10 Commits
7cdee2e7ce
...
fb345be375
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb345be375 | ||
|
|
6ce49573ef | ||
|
|
9dceff5baf | ||
|
|
b6e7ad3485 | ||
|
|
304e19ce45 | ||
|
|
96b3441997 | ||
|
|
33fc046fa2 | ||
|
|
960fe67966 | ||
|
|
2899e43d84 | ||
|
|
e71cae6dc9 |
@@ -0,0 +1,477 @@
|
|||||||
|
# Fusion Helpdesk — Customer Follow-up & Embedded Inbox Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Attach real customer identity to every helpdesk ticket and give client-deployment staff an in-app ticket inbox (read replies + follow up without leaving their Odoo), while external customers use the native Enterprise portal + magic link.
|
||||||
|
|
||||||
|
**Architecture:** Keystone = pass `partner_email`/`partner_name`/`x_fc_client_label` in the ticket-create payload; native helpdesk then creates the partner + subscribes the follower. Client module (`fusion_helpdesk`) gains read/reply RPC endpoints + a tabbed dialog + unread badge, all scoped server-side by the logged-in user's identity. Central module (`fusion_helpdesk_central`) adds the `x_fc_client_label` field + a branded acknowledgement email.
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19 (Enterprise on central, Community on client deployments), Python 3.11, OWL 2, XML-RPC client→central, `helpdesk` (Enterprise), `portal.mixin`, `mail.thread.cc`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md`
|
||||||
|
|
||||||
|
**Testability note:** `fusion_helpdesk` depends only on base/web/mail → installable + testable on local Community (`odoo-modsdev-app`, DB `modsdev`). Pure logic (scope-domain, message filtering, vals builder, unread math) is extracted into `fusion_helpdesk/utils.py` and unit-tested with no live remote. `fusion_helpdesk_central` depends on `helpdesk` (Enterprise) → install/test on the deploy target (odoo-nexa) or odoo-trial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**`fusion_helpdesk` (client)**
|
||||||
|
- `utils.py` *(new)* — pure helpers: `build_scope_domain`, `is_public_message`, `build_ticket_vals`, `compute_unread_count`. No Odoo env needed → trivially unit-testable.
|
||||||
|
- `controllers/main.py` *(modify)* — keystone payload in `submit()`; new endpoints `my_tickets`, `ticket_detail`, `ticket_reply`, `unread_count`; a mockable `_rpc(model, method, args, kw)` seam.
|
||||||
|
- `models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py` *(new)* — `fusion.helpdesk.ticket.seen` read-tracking model.
|
||||||
|
- `security/ir.model.access.csv` *(modify)* — ACL for the seen model.
|
||||||
|
- `security/fusion_helpdesk_groups.xml` *(new)* — `group_reporter_admin`.
|
||||||
|
- `static/src/js/fusion_helpdesk_dialog.js` *(modify)* — tabs (New / My Tickets), list, thread, reply.
|
||||||
|
- `static/src/xml/fusion_helpdesk_dialog.xml` *(modify)* — tab markup + list/thread/reply templates + confirmed-email field.
|
||||||
|
- `static/src/js/fusion_helpdesk_systray.js` *(modify)* — unread badge.
|
||||||
|
- `static/src/xml/fusion_helpdesk_systray.xml` *(modify)* — badge markup.
|
||||||
|
- `static/src/scss/fusion_helpdesk.scss` *(modify)* — tab/list/thread/badge styles.
|
||||||
|
- `tests/__init__.py`, `tests/test_utils.py`, `tests/test_seen.py` *(new)*.
|
||||||
|
- `__manifest__.py` *(modify)* — version bump, register groups XML + tests dir + new model.
|
||||||
|
|
||||||
|
**`fusion_helpdesk_central` (central)**
|
||||||
|
- `models/__init__.py`, `models/helpdesk_ticket.py` *(new)* — inherit `helpdesk.ticket`, add `x_fc_client_label`.
|
||||||
|
- `views/helpdesk_ticket_views.xml` *(new)* — list column + search filter for `x_fc_client_label`.
|
||||||
|
- `data/mail_template_ack.xml` *(new)* — branded acknowledgement template.
|
||||||
|
- `data/helpdesk_ack_automation.xml` *(new)* OR create-override in `helpdesk_ticket.py` — send ack on create.
|
||||||
|
- `tests/__init__.py`, `tests/test_identity.py` *(new)* — partner resolution + follower + label.
|
||||||
|
- `__manifest__.py` *(modify)* — version bump, register models/views/data/tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Keystone identity
|
||||||
|
|
||||||
|
### Task 1: Pure `build_ticket_vals` helper (client)
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk/utils.py`; Test `fusion_helpdesk/tests/test_utils.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk/tests/test_utils.py
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_helpdesk.utils import build_ticket_vals
|
||||||
|
|
||||||
|
@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'])
|
||||||
|
|
||||||
|
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 empty email
|
||||||
|
self.assertNotIn('team_id', vals) # omit falsy team
|
||||||
|
self.assertEqual(vals['partner_name'], 'Jane')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect ImportError/FAIL**
|
||||||
|
Run: `docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_helpdesk -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -30`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `build_ticket_vals`**
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk/utils.py
|
||||||
|
"""Pure helpers for fusion_helpdesk — no Odoo env, unit-testable in isolation."""
|
||||||
|
|
||||||
|
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
||||||
|
reporter_name, reporter_email, company_name):
|
||||||
|
"""Construct helpdesk.ticket create vals. Identity fields drive native
|
||||||
|
partner find-or-create + follower subscription on the central Odoo."""
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — expect PASS** (same command as Step 2)
|
||||||
|
- [ ] **Step 5: Commit** `git add fusion_helpdesk/utils.py fusion_helpdesk/tests/ && git commit -m "feat(fusion_helpdesk): pure build_ticket_vals helper (identity keystone)"`
|
||||||
|
|
||||||
|
### Task 2: Wire keystone into `submit()` (client)
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `submit()`, accept new arg `reply_email=None`. Replace the inline `ticket_vals` block with:
|
||||||
|
```python
|
||||||
|
from odoo.addons.fusion_helpdesk.utils import build_ticket_vals
|
||||||
|
# ...
|
||||||
|
user = request.env.user
|
||||||
|
reporter_email = (reply_email or user.email or user.login or '').strip()
|
||||||
|
body_html = '\n'.join(body_parts)
|
||||||
|
ticket_vals = build_ticket_vals(
|
||||||
|
kind=kind, subject=subject, body_html=body_html,
|
||||||
|
team_id=cfg['team_id'], client_label=cfg['client_label'],
|
||||||
|
reporter_name=user.name, reporter_email=reporter_email,
|
||||||
|
company_name=request.env.company.name,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** Keep the existing create + attachment + return logic. Verify `_build_diag_block` still appends.
|
||||||
|
- [ ] **Step 3: Manual sanity** — `docker exec odoo-modsdev-app odoo -d modsdev -u fusion_helpdesk --stop-after-init 2>&1 | tail -20` (module upgrades clean).
|
||||||
|
- [ ] **Step 4: Commit** `git commit -am "feat(fusion_helpdesk): send partner identity in ticket payload"`
|
||||||
|
|
||||||
|
### Task 3: `x_fc_client_label` field on central
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk_central/models/__init__.py`, `models/helpdesk_ticket.py`; Modify `__init__.py`, `__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test** (runs on Enterprise env)
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk_central/tests/test_identity.py
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
||||||
|
class TestTicketIdentity(TransactionCase):
|
||||||
|
def test_label_field_and_partner_resolution(self):
|
||||||
|
team = self.env['helpdesk.team'].search([], limit=1)
|
||||||
|
t = self.env['helpdesk.ticket'].create({
|
||||||
|
'name': 'T1', 'team_id': team.id,
|
||||||
|
'partner_email': 'newperson@example.com',
|
||||||
|
'partner_name': 'New Person',
|
||||||
|
'x_fc_client_label': 'ENTECH',
|
||||||
|
})
|
||||||
|
self.assertEqual(t.x_fc_client_label, 'ENTECH')
|
||||||
|
self.assertTrue(t.partner_id, "native create should resolve partner from email")
|
||||||
|
self.assertIn(t.partner_id, t.message_partner_ids, "customer should be a follower")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement field**
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk_central/models/helpdesk_ticket.py
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
class HelpdeskTicket(models.Model):
|
||||||
|
_inherit = 'helpdesk.ticket'
|
||||||
|
|
||||||
|
x_fc_client_label = fields.Char(
|
||||||
|
string='Client Deployment', index=True, copy=False,
|
||||||
|
help='Deployment tag (e.g. ENTECH) set by the in-app reporter. '
|
||||||
|
'Scopes the embedded "My Tickets" inbox per client.',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
```python
|
||||||
|
# fusion_helpdesk_central/models/__init__.py
|
||||||
|
from . import helpdesk_ticket
|
||||||
|
```
|
||||||
|
- [ ] **Step 3:** `fusion_helpdesk_central/__init__.py` → add `from . import models`. `__manifest__.py` → `version` bump to `19.0.1.1.0`, add `'models'` import is implicit; add `views/helpdesk_ticket_views.xml` to `data`, add `tests` discovery (automatic).
|
||||||
|
- [ ] **Step 4: Run on Enterprise** (deferred to Phase 6 deploy; can't run on local Community).
|
||||||
|
- [ ] **Step 5: Commit** `git commit -am "feat(fusion_helpdesk_central): x_fc_client_label on helpdesk.ticket"`
|
||||||
|
|
||||||
|
### Task 4: Backend list/search exposure (central)
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk_central/views/helpdesk_ticket_views.xml`
|
||||||
|
- [ ] **Step 1:** Inherit the helpdesk ticket list + search to add `x_fc_client_label` (column `optional="show"`, search field + a group-by). Use `group_ids` not `groups_id` if gating (none needed here).
|
||||||
|
```xml
|
||||||
|
<odoo>
|
||||||
|
<record id="fhc_ticket_list_label" model="ir.ui.view">
|
||||||
|
<field name="name">fhc.helpdesk.ticket.list.label</field>
|
||||||
|
<field name="model">helpdesk.ticket</field>
|
||||||
|
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="partner_id" position="after">
|
||||||
|
<field name="x_fc_client_label" optional="show"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="fhc_ticket_search_label" model="ir.ui.view">
|
||||||
|
<field name="name">fhc.helpdesk.ticket.search.label</field>
|
||||||
|
<field name="model">helpdesk.ticket</field>
|
||||||
|
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_search"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="partner_id" position="after">
|
||||||
|
<field name="x_fc_client_label"/>
|
||||||
|
<filter string="Client Deployment" name="group_client_label"
|
||||||
|
context="{'group_by': 'x_fc_client_label'}"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
```
|
||||||
|
> NOTE at execution: verify the exact `inherit_id` external IDs by reading the live views (`helpdesk.helpdesk_ticket_view_tree`, `helpdesk.helpdesk_tickets_view_search`) on odoo-nexa — names differ across versions. Adjust before install.
|
||||||
|
- [ ] **Step 2: Commit** `git commit -am "feat(fusion_helpdesk_central): expose client label in ticket views"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Read APIs + scoping (client)
|
||||||
|
|
||||||
|
### Task 5: Pure scoping + message-filter + unread helpers
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_helpdesk/utils.py`; Modify `fusion_helpdesk/tests/test_utils.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
```python
|
||||||
|
from odoo.addons.fusion_helpdesk.utils import (
|
||||||
|
build_scope_domain, is_public_message, compute_unread_count)
|
||||||
|
|
||||||
|
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_admin_still_bounded_by_label(self):
|
||||||
|
# label is ALWAYS present — no cross-deployment leakage
|
||||||
|
self.assertTrue(build_scope_domain('ENTECH', 'a@x', True))
|
||||||
|
|
||||||
|
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}))
|
||||||
|
|
||||||
|
def test_unread_count(self):
|
||||||
|
tickets = [{'id': 1, 'last_support_msg_id': 10},
|
||||||
|
{'id': 2, 'last_support_msg_id': 5},
|
||||||
|
{'id': 3, 'last_support_msg_id': 0}]
|
||||||
|
seen = {1: 10, 2: 3} # ticket 2 has newer support msg; 1 is read; 3 none
|
||||||
|
self.assertEqual(compute_unread_count(tickets, seen), 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — FAIL**
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
```python
|
||||||
|
def build_scope_domain(label, email, is_admin):
|
||||||
|
"""Server-side ticket scope. label is ALWAYS bound (defense in depth)."""
|
||||||
|
domain = [('x_fc_client_label', '=', label or '__none__')]
|
||||||
|
if not is_admin:
|
||||||
|
domain.append(('partner_email', '=ilike', email or '__none__'))
|
||||||
|
return domain
|
||||||
|
|
||||||
|
def is_public_message(msg):
|
||||||
|
"""True when a message is customer-visible (not an internal note)."""
|
||||||
|
return not msg.get('subtype_is_internal', False)
|
||||||
|
|
||||||
|
def compute_unread_count(tickets, seen_by_id):
|
||||||
|
"""Count tickets whose latest support message id exceeds the user's
|
||||||
|
last-seen id for that ticket (0/absent = unseen baseline)."""
|
||||||
|
n = 0
|
||||||
|
for t in tickets:
|
||||||
|
last = t.get('last_support_msg_id') or 0
|
||||||
|
if last and last > (seen_by_id.get(t['id']) or 0):
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
```
|
||||||
|
- [ ] **Step 4: Run — PASS**; **Step 5: Commit**
|
||||||
|
|
||||||
|
### Task 6: `fusion.helpdesk.ticket.seen` model + ACL
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk/models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py`; Modify `__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`; Test `fusion_helpdesk/tests/test_seen.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test**
|
||||||
|
```python
|
||||||
|
# tests/test_seen.py
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||||
|
class TestSeen(TransactionCase):
|
||||||
|
def test_mark_seen_upserts(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)
|
||||||
|
rec = Seen.search([('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', '=', 42)])
|
||||||
|
self.assertEqual(len(rec), 1)
|
||||||
|
self.assertEqual(rec.last_seen_message_id, 120)
|
||||||
|
|
||||||
|
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})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — FAIL**
|
||||||
|
- [ ] **Step 3: Implement model**
|
||||||
|
```python
|
||||||
|
# models/fusion_helpdesk_ticket_seen.py
|
||||||
|
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,
|
||||||
|
default=lambda s: s.env.uid, ondelete='cascade')
|
||||||
|
central_ticket_id = fields.Integer(required=True, index=True)
|
||||||
|
last_seen_message_id = fields.Integer(default=0)
|
||||||
|
|
||||||
|
_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):
|
||||||
|
rec = self.search([('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', '=', central_ticket_id)], limit=1)
|
||||||
|
if rec:
|
||||||
|
if last_message_id > 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})
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _seen_map(self, central_ticket_ids):
|
||||||
|
rows = self.search([('user_id', '=', self.env.uid),
|
||||||
|
('central_ticket_id', 'in', central_ticket_ids)])
|
||||||
|
return {r.central_ticket_id: r.last_seen_message_id for r in rows}
|
||||||
|
```
|
||||||
|
- [ ] **Step 4:** ACL CSV row:
|
||||||
|
```csv
|
||||||
|
access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1
|
||||||
|
```
|
||||||
|
`models/__init__.py` → `from . import fusion_helpdesk_ticket_seen`; `__init__.py` → `from . import models`; manifest registers nothing extra (models auto).
|
||||||
|
- [ ] **Step 5: Run — PASS**; **Step 6: Commit**
|
||||||
|
|
||||||
|
### Task 7: Admin group
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk/security/fusion_helpdesk_groups.xml`; Modify `__manifest__.py` (add to `data`, FIRST so the group exists before ACLs reference it if needed)
|
||||||
|
- [ ] **Step 1:**
|
||||||
|
```xml
|
||||||
|
<odoo>
|
||||||
|
<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 inbox.</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
```
|
||||||
|
> Odoo 19: NO `users`/`category_id` fields on res.groups. Keep the record minimal.
|
||||||
|
- [ ] **Step 2:** Upgrade clean; **Step 3: Commit**
|
||||||
|
|
||||||
|
### Task 8: Read endpoints (`my_tickets`, `ticket_detail`, `unread_count`)
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add a mockable RPC seam + identity helper:
|
||||||
|
```python
|
||||||
|
def _identity(self):
|
||||||
|
user = request.env.user
|
||||||
|
cfg = self._read_config()
|
||||||
|
return {
|
||||||
|
'email': (user.email or user.login or '').strip(),
|
||||||
|
'label': cfg['client_label'],
|
||||||
|
'is_admin': user.has_group('fusion_helpdesk.group_reporter_admin'),
|
||||||
|
'cfg': cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rpc(self, cfg, model, method, args, kw=None):
|
||||||
|
uid, proxy = self._authenticate(cfg) # existing
|
||||||
|
return proxy.execute_kw(cfg['db'], uid, cfg['password'], model, method, args, kw or {})
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** Implement endpoints (all `type='jsonrpc'`, `auth='user'`). `my_tickets` builds the scoped domain via `build_scope_domain`, `search_read` fields `[id, name, stage_id, write_date]`, plus a per-ticket latest public support message id (read `message_ids` or a dedicated query), then computes `has_unread` via the seen map. `ticket_detail` re-resolves the ticket through the scoped domain (reject if absent), reads public messages only (filter via `is_public_message` using each message's subtype internal flag fetched from central), and calls `_mark_seen`. `unread_count` returns `compute_unread_count(...)`.
|
||||||
|
> Execution detail: fetch message subtype "internal" flag from central by reading `mail.message` fields `[author_id, date, body, message_type, subtype_id]` and resolving `subtype_id.internal` via a second read or by filtering `message_type='comment'` + excluding notes. Confirm the cleanest field set against the live `mail.message` model during execution.
|
||||||
|
- [ ] **Step 3:** Manual: upgrade module; **Step 4: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Reply endpoint (client)
|
||||||
|
|
||||||
|
### Task 9: `ticket_reply`
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||||
|
- [ ] **Step 1:** Endpoint `/fusion_helpdesk/ticket/<int:ticket_id>/reply`, `auth='user'`. Re-resolve ticket via scoped domain (reject if not in scope). Resolve author partner on central by the replier's email (find-or-create via `res.partner` search/create through bot, or pass `author_id` resolved from `partner_email`). Post:
|
||||||
|
```python
|
||||||
|
self._rpc(cfg, 'helpdesk.ticket', 'message_post', [ticket_id], {
|
||||||
|
'body': body_html, # already-safe HTML (escape user text)
|
||||||
|
'message_type': 'comment',
|
||||||
|
'subtype_xmlid': 'mail.mt_comment',
|
||||||
|
'author_id': author_partner_id,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** Escape the user's text to HTML server-side (reuse `_html_escape`). Mark seen after posting.
|
||||||
|
- [ ] **Step 3:** Manual upgrade; **Step 4: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Client UI (dialog tabs, thread, badge)
|
||||||
|
|
||||||
|
### Task 10: Dialog tabs + My Tickets list + thread + reply + confirmed email
|
||||||
|
|
||||||
|
**Files:** Modify `static/src/js/fusion_helpdesk_dialog.js`, `static/src/xml/fusion_helpdesk_dialog.xml`, `static/src/scss/fusion_helpdesk.scss`
|
||||||
|
- [ ] **Step 1:** Add to state: `tab:'new'|'list'|'thread'`, `tickets:[]`, `loadingList`, `current:{id,subject,messages,canReply}`, `replyBody`, `replyEmail` (default from a new `/fusion_helpdesk/whoami` or seeded via session user email — read `user.email` via `useService('user')`/`session`), `scope:'mine'|'all'`, `isAdmin`.
|
||||||
|
- [ ] **Step 2:** Methods: `openList()` → rpc `/fusion_helpdesk/my_tickets` (with `scope`); `openTicket(id)` → rpc detail, switch to thread, refresh list badge; `sendReply()` → rpc reply then reload thread; `setScope()` (admin toggle). Add confirmed **Your email** input on the New tab bound to `state.replyEmail`, passed as `reply_email` in submit payload.
|
||||||
|
- [ ] **Step 3:** Template: a tab header (New | My Tickets); New pane = existing form + email field; List pane = table (ref, subject, stage chip, unread dot) + admin Mine/All toggle; Thread pane = messages (author, date, body, attachments) + reply box + Back. Use `Markup`-safe rendering: render message bodies with `t-out` (OWL) since central returns sanitized HTML.
|
||||||
|
- [ ] **Step 4:** SCSS for tabs/list/thread (follow Odoo kanban hex pattern + dark-mode `$o-webclient-color-scheme` branch per CLAUDE.md).
|
||||||
|
- [ ] **Step 5:** Manual QA locally (dialog opens, tabs switch). **Step 6: Commit**
|
||||||
|
|
||||||
|
### Task 11: Systray unread badge
|
||||||
|
|
||||||
|
**Files:** Modify `static/src/js/fusion_helpdesk_systray.js`, `static/src/xml/fusion_helpdesk_systray.xml`, SCSS
|
||||||
|
- [ ] **Step 1:** On setup, call `/fusion_helpdesk/unread_count`; store `state.unread`. Poll on an interval (e.g. 120s) and on dialog close. Show a badge bubble when `unread > 0`.
|
||||||
|
- [ ] **Step 2:** Badge markup over the icon. **Step 3: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Central acknowledgement email
|
||||||
|
|
||||||
|
### Task 12: Branded acknowledgement template + send-on-create
|
||||||
|
|
||||||
|
**Files:** Create `fusion_helpdesk_central/data/mail_template_ack.xml`; Modify `models/helpdesk_ticket.py`, `__manifest__.py`
|
||||||
|
- [ ] **Step 1:** `mail.template` on `helpdesk.ticket` with subject "We received your request [{{ object.ticket_ref }}]" and a body using the company email layout + a prominent button to `{{ object.get_base_url() }}{{ object.access_url }}` (magic link). Canadian English.
|
||||||
|
- [ ] **Step 2:** Send on create via a create-override (central inherit), gated:
|
||||||
|
```python
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
tickets = super().create(vals_list)
|
||||||
|
tmpl = self.env.ref('fusion_helpdesk_central.mail_template_ticket_ack', raise_if_not_found=False)
|
||||||
|
for t in tickets:
|
||||||
|
if tmpl and t.partner_email and t.x_fc_client_label: # in-app channel only → avoid double-ack with native web form
|
||||||
|
tmpl.send_mail(t.id, force_send=False)
|
||||||
|
return tickets
|
||||||
|
```
|
||||||
|
> Decision: gate on `x_fc_client_label` so only in-app-channel tickets get OUR ack; external web/email customers rely on native confirmation (verify native behavior during deploy; widen the gate if native sends nothing).
|
||||||
|
- [ ] **Step 3:** Register template data in manifest; **Step 4: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Review, fix, deploy, smoke test
|
||||||
|
|
||||||
|
### Task 13: Code review + fix
|
||||||
|
- [ ] Run the code-review skill / pr-review-toolkit `code-reviewer` + `silent-failure-hunter` over the diff. Fix HIGH/MEDIUM findings. Re-run client tests locally. Commit fixes.
|
||||||
|
|
||||||
|
### Task 14: Deploy + test central on odoo-nexa
|
||||||
|
- [ ] Copy/confirm `fusion_helpdesk_central` source is visible to odoo-nexa (`/opt/odoo/custom-addons`).
|
||||||
|
- [ ] Run module tests on nexa: `-u fusion_helpdesk_central --test-enable --test-tags /fusion_helpdesk_central --stop-after-init` (ephemeral http port). Fix failures.
|
||||||
|
- [ ] Upgrade live: `-u fusion_helpdesk_central --stop-after-init` then restart `odoo-nexa-app`.
|
||||||
|
|
||||||
|
### Task 15: Deploy client on odoo-entech
|
||||||
|
- [ ] Look up entech access (memory: DB `admin`; confirm container/SSH via Supabase quick_commands). Confirm entech's `fusion_helpdesk.client_label` (e.g. ENTECH) + remote config points at nexa.
|
||||||
|
- [ ] Ensure `fusion_helpdesk` source present on entech; upgrade `-u fusion_helpdesk --stop-after-init`; restart.
|
||||||
|
|
||||||
|
### Task 16: Smoke test (one ticket)
|
||||||
|
- [ ] From entech: file ONE test ticket via the dialog (or simulate the controller path).
|
||||||
|
- [ ] On nexa: confirm the new ticket has `partner_id` resolved, `partner_email`/`partner_name`/`x_fc_client_label` set, customer is a follower, ack email queued/sent.
|
||||||
|
- [ ] Reply as agent on nexa → confirm notification email to the reporter w/ magic link; confirm the entech dialog "My Tickets" shows the ticket + reply and the badge increments.
|
||||||
|
- [ ] Confirm pre-existing identity-less tickets are untouched (the "lots already submitted" set) and do NOT leak across deployments in the inbox query.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (run before execution)
|
||||||
|
- **Spec coverage:** keystone (T1-3), label field+views (T3-4), scoping (T5,8,9), seen/badge (T6,10,11), admin group (T7), ack email (T12), portal/native (config — verified live, no code), tests (T1,5,6 local + T3 enterprise), deploy+smoke (T14-16). ✓
|
||||||
|
- **Placeholders:** none — code shown for all Python/XML; JS tasks specify state/methods/markup concretely. JS is manually QA'd (OWL unit tests out of scope).
|
||||||
|
- **Type consistency:** `build_scope_domain(label,email,is_admin)`, `is_public_message(msg)`, `compute_unread_count(tickets,seen)`, `_mark_seen(central_ticket_id,last_message_id)`, `_seen_map(ids)`, `x_fc_client_label` — names consistent across tasks. ✓
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
# Fusion Helpdesk — Customer Follow-up & Embedded Ticket Inbox
|
||||||
|
|
||||||
|
- **Date:** 2026-05-27
|
||||||
|
- **Status:** Approved design (ready for implementation plan)
|
||||||
|
- **Branch:** `feat/helpdesk-customer-followup`
|
||||||
|
- **Modules touched:** `fusion_helpdesk` (client deployments), `fusion_helpdesk_central` (central Odoo)
|
||||||
|
- **Target system:** `odoo-nexa` / `erp.nexasystems.ca`, DB `nexamain`, Odoo 19 Enterprise
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
Today, end users at client deployments (ENTECH, MOBILITY, …) file helpdesk tickets through an in-app
|
||||||
|
"Report a Bug / Request a Feature" systray dialog. Those tickets land on the central Odoo Helpdesk but
|
||||||
|
carry **no customer identity**, so:
|
||||||
|
|
||||||
|
- support replies email nobody,
|
||||||
|
- the submitter can't see or follow up on their ticket,
|
||||||
|
- the ticket never appears in any customer portal.
|
||||||
|
|
||||||
|
This design makes ticket follow-up work end to end. It rests on **one keystone fix** (attach the
|
||||||
|
submitter's identity to every ticket) and then exposes **two follow-up surfaces** matched to two
|
||||||
|
audiences:
|
||||||
|
|
||||||
|
1. **In-app embedded inbox** — the systray dialog becomes a small ticket inbox (New + My Tickets). Client
|
||||||
|
staff read replies and follow up **without leaving their own Odoo or logging into the central system**.
|
||||||
|
2. **Native Enterprise portal** — for external web/email customers, the existing Odoo portal + magic-link
|
||||||
|
+ free sign-up does the job; they have no workspace to embed into.
|
||||||
|
|
||||||
|
Scope tier: **Polished** (light branding + acknowledgement email + in-app unread badge). Not a custom
|
||||||
|
portal theme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem & Diagnosis (grounded in the live system)
|
||||||
|
|
||||||
|
### 2.1 Current architecture
|
||||||
|
|
||||||
|
- **`fusion_helpdesk`** (installed on *client* deployments): OWL systray dialog → `POST
|
||||||
|
/fusion_helpdesk/submit` → forwards to central over **XML-RPC as a shared bot account** (API key issued
|
||||||
|
by `fusion_helpdesk_central`). Ticket payload today is only `{name, description, team_id}`. The
|
||||||
|
reporter's name/login is embedded as **HTML text inside the description's "Diagnostic context" table** —
|
||||||
|
not as structured fields.
|
||||||
|
- **`fusion_helpdesk_central`** (installed on *central* Odoo): manages the per-client API keys on the
|
||||||
|
shared bot user. Does **not** touch tickets, portal, notifications.
|
||||||
|
|
||||||
|
### 2.2 The actual bug (verified on `nexamain`, 2026-05-27)
|
||||||
|
|
||||||
|
All **51/51** tickets have `partner_id`, `partner_email`, `partner_name` = NULL (0 coverage). With no
|
||||||
|
customer attached, Odoo has nobody to email, nobody to add as follower, no `/my/tickets` to populate, and
|
||||||
|
no recipient for a magic link.
|
||||||
|
|
||||||
|
### 2.3 The platform already does the hard part
|
||||||
|
|
||||||
|
Installed & enabled on `odoo-nexa`:
|
||||||
|
|
||||||
|
- Modules: `helpdesk` 19.0.1.6, `website_helpdesk`, `website_helpdesk_knowledge`, `helpdesk_account`,
|
||||||
|
`helpdesk_sale`, `portal`, `website`, `auth_signup`.
|
||||||
|
- `auth_signup.invitation_scope = b2c` (free customer sign-up ON), `auth_signup.reset_password = True`.
|
||||||
|
- `web.base.url = https://erp.nexasystems.ca`, `mail.catchall.domain = nexasystems.ca`, 4 working SMTP
|
||||||
|
servers → outbound email works.
|
||||||
|
- Team 1 **"Customer Care"** is already portal-ready: `privacy_visibility = portal`,
|
||||||
|
`use_website_helpdesk_form = true`, `allow_portal_ticket_closing = true`, `use_alias = true`, alias
|
||||||
|
`support` (→ `support@nexasystems.ca`).
|
||||||
|
|
||||||
|
`helpdesk.ticket` model (Enterprise source, verified):
|
||||||
|
|
||||||
|
- `_inherit = ['portal.mixin', 'mail.thread.cc', 'rating.mixin']`; `_mail_thread_customer = True`;
|
||||||
|
`_primary_email = 'partner_email'`; `access_url = '/my/ticket/<id>'` (← that is the magic link).
|
||||||
|
- **`create()` auto-resolves the partner**: when `partner_email` is given and `partner_id` is not, it calls
|
||||||
|
`mail.thread._partner_find_from_emails_single([partner_email], {name, company_id})` to find-or-create the
|
||||||
|
partner and set `partner_id` (`helpdesk_ticket.py` ≈ L564–572).
|
||||||
|
- **`create()` subscribes the customer as a follower** (the "make customer follower" loop, ≈ L600–620),
|
||||||
|
so they receive reply notifications by email.
|
||||||
|
- Portal routes: `/my/tickets` (auth=`user`); `/my/ticket/<int:ticket_id>/<access_token>` (auth=`public`)
|
||||||
|
→ validates token via `_document_check_access` → renders `helpdesk.tickets_followup` (reply composer
|
||||||
|
included); `/my/ticket/close/<id>/<token>` posts a message with `author_id = partner_id`; public web
|
||||||
|
form at `/helpdesk/<team>`.
|
||||||
|
|
||||||
|
**Consequence:** the keystone fix is small — pass `partner_email` + `partner_name` in the create payload and
|
||||||
|
native helpdesk creates the partner, links it, and subscribes it. Replies then email the customer with a
|
||||||
|
magic-link "View Ticket" button automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals / Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- Every new ticket carries the submitter's real identity (`partner_email`, `partner_name`,
|
||||||
|
`x_fc_client_label`).
|
||||||
|
- Agent replies reach the customer **by email** with a working **magic link**.
|
||||||
|
- **In-app staff** can list, read, and reply to their tickets **inside their own Odoo** — no login, no
|
||||||
|
context switch.
|
||||||
|
- **External web/email customers** get the native portal + magic link + free sign-up.
|
||||||
|
- Light branding (logo/colours) + an acknowledgement email on ticket creation.
|
||||||
|
- Hybrid in-app visibility: regular users see their own tickets; a designated admin sees all of their
|
||||||
|
deployment's tickets.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
- No custom portal theme, custom website submission form, KB-deflection, or SLA timeline UI (that was
|
||||||
|
Tier C — deliberately out of scope).
|
||||||
|
- No replication of tickets into the client database — the in-app inbox is a **live RPC view**.
|
||||||
|
- No backfill of the 51 existing identity-less tickets (low value; their only identity is free text).
|
||||||
|
- No changes to the billing module (`fusion_centralize_billing`) — separate work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Audiences & channels (locked decisions)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Channels | **Both** — in-app reporter *and* external web/email |
|
||||||
|
| In-app visibility | **Hybrid** — own by default; designated admin sees all of their deployment's tickets |
|
||||||
|
| Scope tier | **Polished** — light branding + ack email + in-app unread badge |
|
||||||
|
| Acknowledgement email on create | **Yes** (immediate magic link) |
|
||||||
|
| Reporter email at submit | **Confirmed / editable** in the New form |
|
||||||
|
| "See all" gating | **New group** on the client deployment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architecture
|
||||||
|
|
||||||
|
### 5.1 Keystone — identity layer
|
||||||
|
|
||||||
|
- **Client side (`fusion_helpdesk`)**: in `submit()`, add to the create payload:
|
||||||
|
- `partner_name` = `request.env.user.name`
|
||||||
|
- `partner_email` = confirmed value from the form (default `request.env.user.email or .login`, editable)
|
||||||
|
- `x_fc_client_label` = `cfg['client_label']`
|
||||||
|
- **Central side (`fusion_helpdesk_central`)**: add `x_fc_client_label` (Char, indexed) to `helpdesk.ticket`
|
||||||
|
and surface it in the agent backend (list column + search filter) so support can filter by client. Native
|
||||||
|
helpdesk does the partner resolution + follower subscription.
|
||||||
|
|
||||||
|
`x_fc_client_label` is the structured tag that makes deployment-scoped queries (and the admin "see all"
|
||||||
|
view) reliable — far better than parsing the `[ENTECH]` subject prefix.
|
||||||
|
|
||||||
|
### 5.2 Two surfaces
|
||||||
|
|
||||||
|
- **Surface A — in-app embedded inbox** (`fusion_helpdesk`, client deployments). New work.
|
||||||
|
- **Surface B — native Enterprise portal** (`fusion_helpdesk_central` config + light branding). Mostly
|
||||||
|
configuration; near-zero new code.
|
||||||
|
|
||||||
|
### 5.3 Module responsibilities
|
||||||
|
|
||||||
|
**`fusion_helpdesk` (client) — majority of new work**
|
||||||
|
- Controller (`controllers/main.py`): keystone payload change + new endpoints (§6.1).
|
||||||
|
- OWL dialog (`static/src/js/…`, `static/src/xml/…`): New + My Tickets tabs; thread view; reply box.
|
||||||
|
- Systray (`fusion_helpdesk_systray.js`): unread badge.
|
||||||
|
- `res.groups`: `group_reporter_admin` ("Helpdesk Reporter Admin").
|
||||||
|
- Model `fusion.helpdesk.ticket.seen`: per-user read tracking for the badge.
|
||||||
|
- `res.config.settings`: (existing) — no new config required beyond what exists.
|
||||||
|
|
||||||
|
**`fusion_helpdesk_central` (central) — small additions**
|
||||||
|
- `helpdesk.ticket` inherit: `x_fc_client_label` field + backend list/search exposure.
|
||||||
|
- `mail.template`: branded acknowledgement on ticket create (with the magic-link CTA).
|
||||||
|
- Data/doc: confirm the "Customer Care" team portal config (already correct on live — assert via comment or
|
||||||
|
light data, don't fight existing config).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Surface A — In-app embedded inbox (detail)
|
||||||
|
|
||||||
|
### 6.1 Controller endpoints
|
||||||
|
|
||||||
|
All `type='jsonrpc'`, `auth='user'`. **Identity is always derived server-side from `request.env.user`** —
|
||||||
|
never from request parameters. All remote calls go through the existing bot XML-RPC layer.
|
||||||
|
|
||||||
|
| Route | Returns | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /fusion_helpdesk/submit` *(modified)* | `{ok, ticket_id, ticket_url}` | Adds `x_fc_client_label` + `partner_name`; the confirmed form email is sent as `partner_email` (param may be named `reply_email`, but it maps straight to `partner_email`). |
|
||||||
|
| `/fusion_helpdesk/my_tickets` | `[{id, ref, subject, stage, last_update, has_unread}]` | Scoped (§8). Reuses one remote `search_read`. |
|
||||||
|
| `/fusion_helpdesk/ticket/<int:ticket_id>` | `{id, subject, stage, messages:[…], can_reply}` | **Public comments only** — internal notes excluded (§8). Re-checks scope. |
|
||||||
|
| `/fusion_helpdesk/ticket/<int:ticket_id>/reply` | `{ok}` | Re-checks scope; posts `message_post` with `author_id` = replier's partner. |
|
||||||
|
| `/fusion_helpdesk/unread_count` | `{count}` | For the systray badge (§7). |
|
||||||
|
|
||||||
|
### 6.2 Dialog UX
|
||||||
|
|
||||||
|
- The existing dialog gains two tabs:
|
||||||
|
- **New** — today's form, plus a confirmed/editable **"Your email"** field (prefilled from the logged-in
|
||||||
|
user; used as `reply_email`).
|
||||||
|
- **My Tickets** — list of the user's tickets (ref, subject, stage chip, last-update, unread dot). Admins
|
||||||
|
(in `group_reporter_admin`) see a **"Mine / All [LABEL]"** toggle.
|
||||||
|
- Clicking a ticket opens a **thread view**: customer-visible messages (author, timestamp, body,
|
||||||
|
attachments) + a **reply box** (text + attach) + a "Done"/back control. Opening a ticket marks it seen.
|
||||||
|
|
||||||
|
### 6.3 Reply attribution
|
||||||
|
|
||||||
|
- Replies post to central as `message_type='comment'`, `subtype_xmlid='mail.mt_comment'`, with `author_id`
|
||||||
|
= the **replying user's** partner on central (resolved find-or-create by their email). For a user replying
|
||||||
|
to their own ticket that's the ticket's customer; for an admin replying to a colleague's ticket it's the
|
||||||
|
admin's own identity (correct attribution).
|
||||||
|
- A customer reply notifies the assigned agent + followers (native), closing the two-way loop.
|
||||||
|
|
||||||
|
### 6.4 Read tracking & admin group
|
||||||
|
|
||||||
|
- Model `fusion.helpdesk.ticket.seen` (client DB): `user_id` (m2o `res.users`), `central_ticket_id`
|
||||||
|
(Integer), `last_seen_message_id` (Integer) — unique `(user_id, central_ticket_id)`. This is
|
||||||
|
read-tracking **metadata only** (no ticket content is stored) — it preserves the live-RPC-view principle
|
||||||
|
while letting the badge work without re-fetching on every page load.
|
||||||
|
- `group_reporter_admin` — an Odoo group on the client deployment. Membership unlocks the "All [LABEL]"
|
||||||
|
query path **server-side** (the controller checks `has_group` before broadening scope).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Notifications & emails
|
||||||
|
|
||||||
|
- **Agent → customer:** customer is a follower → **native email** with a "View Ticket" magic link
|
||||||
|
(portal.mixin `access_url` + token). Satisfies "they get replies in their email." In-app users also see
|
||||||
|
the reply in My Tickets and the badge increments.
|
||||||
|
- **Acknowledgement on create:** branded `mail.template` sent to the customer with the magic-link CTA so they
|
||||||
|
can track immediately. Fires for any ticket on the portal-enabled team that has a `partner_email`,
|
||||||
|
regardless of channel (in-app, web, email). Per Odoo 19, the template renders the link from the record
|
||||||
|
(`object.access_url` / portal URL); no need to pass it via `ctx` (CLAUDE rule 12). **Implementation note:**
|
||||||
|
verify `website_helpdesk` does not already send its own "ticket received" confirmation for web-form
|
||||||
|
submissions — if it does, gate ours so external customers don't get two acknowledgements.
|
||||||
|
- **Unread badge:** `unread_count` = number of the user's in-scope tickets whose latest customer-visible
|
||||||
|
**support** message id is greater than the local `last_seen_message_id`. Cleared per-ticket on open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security & scoping (the sharp edge)
|
||||||
|
|
||||||
|
The shared bot can read **every** client's tickets on central, so the client-side controller is the
|
||||||
|
security boundary.
|
||||||
|
|
||||||
|
- Endpoints are `auth='user'`; identity is taken from `request.env.user`, never from the browser.
|
||||||
|
- Scoped domain, built server-side:
|
||||||
|
- regular user → `[('x_fc_client_label','=',label), ('partner_email','=ilike', me.email or me.login)]`
|
||||||
|
- admin (`group_reporter_admin`) → `[('x_fc_client_label','=',label)]`
|
||||||
|
- **`x_fc_client_label = <my deployment>` is ALWAYS ANDed in** (defense in depth) so no user — regular or
|
||||||
|
admin — can ever read another deployment's tickets, even if two deployments share a reporter email.
|
||||||
|
- `ticket/<id>` and `…/reply` **re-resolve the ticket through the same scoped domain** before reading or
|
||||||
|
posting; a ticket outside scope returns not-found.
|
||||||
|
- Thread fetch returns **only customer-visible messages** (exclude internal notes — `subtype_id.internal =
|
||||||
|
True`), mirroring what the portal shows. Internal agent discussion never reaches a client.
|
||||||
|
- Reuse the module's existing granular remote-error handling for auth/network failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
SUBMIT (in-app)
|
||||||
|
staff clicks icon → New tab → confirm email → submit
|
||||||
|
client controller adds partner_email + partner_name + x_fc_client_label
|
||||||
|
→ XML-RPC create on central (as bot)
|
||||||
|
→ helpdesk find-or-creates partner_id + subscribes follower
|
||||||
|
→ branded acknowledgement email w/ magic link
|
||||||
|
|
||||||
|
AGENT REPLY (Nexa support)
|
||||||
|
reply as a comment in the ticket chatter on central
|
||||||
|
→ native email to customer w/ "View Ticket" magic link
|
||||||
|
→ in-app users also see it in My Tickets; badge increments
|
||||||
|
|
||||||
|
CUSTOMER FOLLOW-UP (any of three, same thread)
|
||||||
|
in-app dialog reply → RPC message_post (author = replier's partner)
|
||||||
|
portal magic link → native reply on /my/ticket/<id>/<token>
|
||||||
|
email reply → native email-in via support@nexasystems.ca
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Edge cases
|
||||||
|
|
||||||
|
- **Missing/invalid reporter email** — New form prefills + lets the user confirm/edit. If still empty, the
|
||||||
|
ticket is created without a customer (degrades to today's behaviour) and the dialog flags "no follow-up
|
||||||
|
email captured."
|
||||||
|
- **Same email across deployments** — partner is shared (their portal shows all their tickets), but the
|
||||||
|
in-app inbox still scopes by `x_fc_client_label`, so each deployment shows only its own.
|
||||||
|
- **Admin replies to a colleague's ticket** — author = the admin's own partner, not the ticket customer.
|
||||||
|
- **Existing 51 orphan tickets** — left as-is (no reliable identity to backfill).
|
||||||
|
- **Bot key revoked/rotated** (managed by `fusion_helpdesk_central`) — endpoints fail gracefully via the
|
||||||
|
existing typed remote-error responses.
|
||||||
|
- **Internal notes** — never returned to the client (subtype filter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Testing strategy
|
||||||
|
|
||||||
|
- **`fusion_helpdesk_central`** (Enterprise; runs on an Enterprise env such as odoo-trial, like the billing
|
||||||
|
module — local dev is Community and can't install `helpdesk`):
|
||||||
|
- `x_fc_client_label` field exists + is searchable.
|
||||||
|
- Integration: `helpdesk.ticket.create({partner_email, partner_name, x_fc_client_label})` resolves
|
||||||
|
`partner_id` and adds the partner as a follower.
|
||||||
|
- Acknowledgement template renders the magic link from the record.
|
||||||
|
- **`fusion_helpdesk`** (client; XML-RPC layer **mocked** — no live central in unit tests):
|
||||||
|
- Scoping: regular vs admin domain construction; `x_fc_client_label` always ANDed.
|
||||||
|
- `…/reply` rejects a ticket outside the caller's scope.
|
||||||
|
- Thread fetch excludes internal notes.
|
||||||
|
- `unread_count` math against `fusion.helpdesk.ticket.seen`.
|
||||||
|
- Refactor the remote proxy so it is injectable/mockable.
|
||||||
|
- **Manual QA on `odoo-nexa`**: full round-trip — submit → agent reply → email + badge → in-app reply →
|
||||||
|
portal magic link → external sign-up shows `/my/tickets`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope / future
|
||||||
|
|
||||||
|
- Custom portal theme, branded custom web form, KB deflection, SLA/status timeline (Tier C).
|
||||||
|
- Backfilling identity on historical tickets.
|
||||||
|
- Push/websocket live updates in the dialog (polling/refresh is sufficient for v1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. References
|
||||||
|
|
||||||
|
**Current code (this repo)**
|
||||||
|
- `fusion_helpdesk/controllers/main.py` — `submit()`, `_read_config()`, `_authenticate()`,
|
||||||
|
`_build_diag_block()` (XML-RPC forwarder; today sends only `{name, description, team_id}`).
|
||||||
|
- `fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js` — OWL submission dialog.
|
||||||
|
- `fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js` — systray entry (badge target).
|
||||||
|
- `fusion_helpdesk/models/res_config_settings.py` — remote endpoint config params.
|
||||||
|
- `fusion_helpdesk_central/models/fusion_helpdesk_client_key.py` — bot user + API-key management.
|
||||||
|
|
||||||
|
**Live system facts (verified 2026-05-27 on `nexamain`)**
|
||||||
|
- Modules installed: `helpdesk` 19.0.1.6, `website_helpdesk`, `website_helpdesk_knowledge`,
|
||||||
|
`helpdesk_account`, `helpdesk_sale`, `portal`, `website`, `auth_signup`.
|
||||||
|
- `auth_signup.invitation_scope=b2c`; `web.base.url=https://erp.nexasystems.ca`;
|
||||||
|
`mail.catchall.domain=nexasystems.ca`; 4 SMTP servers.
|
||||||
|
- Team 1 "Customer Care": `privacy_visibility=portal`, `use_website_helpdesk_form=t`,
|
||||||
|
`allow_portal_ticket_closing=t`, `use_alias=t`, alias `support`.
|
||||||
|
- 51/51 tickets have NULL `partner_id`/`partner_email`/`partner_name`.
|
||||||
|
|
||||||
|
**Enterprise source (read-only, on container)**
|
||||||
|
- `helpdesk/models/helpdesk_ticket.py` — `_inherit` (portal.mixin, mail.thread.cc, rating.mixin);
|
||||||
|
`access_url='/my/ticket/<id>'`; `create()` partner find-or-create (≈L564–572) + follower subscription
|
||||||
|
(≈L600–620).
|
||||||
|
- `helpdesk/controllers/portal.py` — `/my/tickets`, `/my/ticket/<id>/<access_token>`,
|
||||||
|
`/my/ticket/close/<id>/<token>`.
|
||||||
|
- `website_helpdesk/controllers/main.py` — `/helpdesk/<team>` public web form.
|
||||||
|
|
||||||
|
**Odoo 19 gotchas to respect (from repo CLAUDE.md)**
|
||||||
|
- `res.users` group field is `group_ids` (not `groups_id`).
|
||||||
|
- `message_post(body=…)` HTML must be wrapped in `Markup()`.
|
||||||
|
- `mail.template` `ctx` is `env.context`; pass dynamic data via `with_context(**data)`.
|
||||||
|
- `res.config.settings` Boolean via `config_parameter` doesn't persist `False`.
|
||||||
|
- SQL constraints/indexes use declarative `models.Constraint` / `models.Index`.
|
||||||
@@ -47,6 +47,7 @@ reference files from the container before implementing subscription/account inte
|
|||||||
],
|
],
|
||||||
"data": [
|
"data": [
|
||||||
"security/ir.model.access.csv",
|
"security/ir.model.access.csv",
|
||||||
|
"data/ir_cron.xml",
|
||||||
],
|
],
|
||||||
"installable": True,
|
"installable": True,
|
||||||
"application": False,
|
"application": False,
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ class FusionBillingApi(http.Controller):
|
|||||||
payload = self._read_json()
|
payload = self._read_json()
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return self._json({"error": "invalid json"}, status=400)
|
return self._json({"error": "invalid json"}, status=400)
|
||||||
return self._json(service._api_upsert_customer(payload))
|
result = service._api_upsert_customer(payload)
|
||||||
|
if result.get("status") == "error":
|
||||||
|
return self._json(result, status=400)
|
||||||
|
return self._json(result)
|
||||||
|
|
||||||
@http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False)
|
@http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False)
|
||||||
def post_usage(self, **kw):
|
def post_usage(self, **kw):
|
||||||
@@ -66,7 +69,10 @@ class FusionBillingApi(http.Controller):
|
|||||||
payload = self._read_json()
|
payload = self._read_json()
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return self._json({"error": "invalid json"}, status=400)
|
return self._json({"error": "invalid json"}, status=400)
|
||||||
return self._json(service._api_record_usage(payload), status=202)
|
result = service._api_record_usage(payload)
|
||||||
|
if result.get("status") == "error":
|
||||||
|
return self._json(result, status=400)
|
||||||
|
return self._json(result, status=202)
|
||||||
|
|
||||||
@http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False)
|
@http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False)
|
||||||
def get_plans(self, **kw):
|
def get_plans(self, **kw):
|
||||||
@@ -83,4 +89,7 @@ class FusionBillingApi(http.Controller):
|
|||||||
payload = self._read_json()
|
payload = self._read_json()
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return self._json({"error": "invalid json"}, status=400)
|
return self._json({"error": "invalid json"}, status=400)
|
||||||
return self._json(service._api_create_subscription(payload))
|
result = service._api_create_subscription(payload)
|
||||||
|
if result.get("status") == "error":
|
||||||
|
return self._json(result, status=400)
|
||||||
|
return self._json(result)
|
||||||
|
|||||||
22
fusion_centralize_billing/data/ir_cron.xml
Normal file
22
fusion_centralize_billing/data/ir_cron.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
<record id="cron_fc_rate_usage" model="ir.cron">
|
||||||
|
<field name="name">Fusion Billing: Rate usage before invoicing</field>
|
||||||
|
<field name="model_id" ref="model_fusion_billing_usage"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_rate_open_periods()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">hours</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
|
||||||
|
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
|
||||||
|
<field name="model_id" ref="model_fusion_billing_webhook"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_dispatch()</field>
|
||||||
|
<field name="interval_number">2</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -5,3 +5,4 @@ from . import charge
|
|||||||
from . import usage
|
from . import usage
|
||||||
from . import webhook
|
from . import webhook
|
||||||
from . import reconciliation
|
from . import reconciliation
|
||||||
|
from . import sale_order
|
||||||
|
|||||||
@@ -43,23 +43,30 @@ class FusionBillingCharge(models.Model):
|
|||||||
charge_model = fields.Selection(
|
charge_model = fields.Selection(
|
||||||
[
|
[
|
||||||
("standard", "Standard (per unit)"),
|
("standard", "Standard (per unit)"),
|
||||||
("graduated", "Graduated"),
|
|
||||||
("package", "Package"),
|
("package", "Package"),
|
||||||
("volume", "Volume"),
|
|
||||||
],
|
],
|
||||||
default="standard", required=True,
|
default="standard", required=True,
|
||||||
)
|
)
|
||||||
currency_id = fields.Many2one(
|
currency_id = fields.Many2one(
|
||||||
"res.currency", default=lambda self: self.env.company.currency_id,
|
"res.currency", required=True,
|
||||||
|
default=lambda self: self.env.company.currency_id,
|
||||||
)
|
)
|
||||||
active = fields.Boolean(default=True)
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
_price_non_negative = models.Constraint(
|
||||||
|
"CHECK (price_per_unit >= 0)", "Overage price per unit cannot be negative.",
|
||||||
|
)
|
||||||
|
_unit_batch_positive = models.Constraint(
|
||||||
|
"CHECK (unit_batch > 0)", "Unit batch must be greater than zero.",
|
||||||
|
)
|
||||||
|
|
||||||
def _compute_billable(self, total_quantity):
|
def _compute_billable(self, total_quantity):
|
||||||
"""Return (overage_units, amount) for total period usage under this charge.
|
"""Return (overage_units, amount) for total period usage under this charge.
|
||||||
|
|
||||||
- overage_units = usage above included_quota (never negative)
|
- overage_units = usage above included_quota (never negative)
|
||||||
- 'standard'/'package'/'volume': priced per `unit_batch` block, partial block rounds up.
|
- 'standard': price the overage in (rounded-up) `unit_batch` blocks.
|
||||||
(graduated tiers are out of scope for the core; treated as 'standard'.)
|
- 'package': price whole packages over the RAW quantity (quota ignored for
|
||||||
|
package counting); a partial package rounds up.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0))
|
overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0))
|
||||||
@@ -68,6 +75,6 @@ class FusionBillingCharge(models.Model):
|
|||||||
# whole packages over the RAW quantity (quota ignored for package counting)
|
# whole packages over the RAW quantity (quota ignored for package counting)
|
||||||
blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0
|
blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0
|
||||||
return overage, round(blocks * (self.price_per_unit or 0.0), 2)
|
return overage, round(blocks * (self.price_per_unit or 0.0), 2)
|
||||||
# standard / volume / graduated-fallback: price the overage in (rounded-up) batches
|
# standard: price the overage in (rounded-up) batches
|
||||||
blocks = math.ceil(overage / batch) if overage > 0 else 0
|
blocks = math.ceil(overage / batch) if overage > 0 else 0
|
||||||
return overage, round(blocks * (self.price_per_unit or 0.0), 2)
|
return overage, round(blocks * (self.price_per_unit or 0.0), 2)
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ class FusionBillingReconciliation(models.Model):
|
|||||||
external_amount = fields.Monetary(string="App-actual Amount")
|
external_amount = fields.Monetary(string="App-actual Amount")
|
||||||
delta = fields.Monetary(help="odoo_amount - external_amount.")
|
delta = fields.Monetary(help="odoo_amount - external_amount.")
|
||||||
currency_id = fields.Many2one(
|
currency_id = fields.Many2one(
|
||||||
"res.currency", default=lambda self: self.env.company.currency_id,
|
"res.currency", required=True,
|
||||||
|
default=lambda self: self.env.company.currency_id,
|
||||||
)
|
)
|
||||||
status = fields.Selection(
|
status = fields.Selection(
|
||||||
[
|
[
|
||||||
|
|||||||
32
fusion_centralize_billing/models/sale_order.py
Normal file
32
fusion_centralize_billing/models/sale_order.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrder(models.Model):
|
||||||
|
_inherit = "sale.order"
|
||||||
|
|
||||||
|
def _fc_rate_usage(self, charge, period_start, period_end):
|
||||||
|
"""Aggregate this subscription's usage for `charge`'s metric in the period,
|
||||||
|
compute the overage amount, and upsert a matching overage order line.
|
||||||
|
Returns the amount.
|
||||||
|
|
||||||
|
A zero amount never *creates* a new line (no $0.00 overage clutter); if a
|
||||||
|
line already exists it is still updated so a dropped-to-zero overage clears.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
Usage = self.env['fusion.billing.usage']
|
||||||
|
total = Usage._aggregate(self, charge.metric_id, period_start, period_end)
|
||||||
|
_overage, amount = charge._compute_billable(total)
|
||||||
|
if charge.product_id:
|
||||||
|
line = self.order_line.filtered(lambda l: l.product_id == charge.product_id)
|
||||||
|
if not line and amount == 0:
|
||||||
|
return amount
|
||||||
|
vals = {'product_uom_qty': 1, 'price_unit': amount}
|
||||||
|
if line:
|
||||||
|
line.write(vals)
|
||||||
|
else:
|
||||||
|
self.env['sale.order.line'].create(
|
||||||
|
{'order_id': self.id, 'product_id': charge.product_id.id, **vals})
|
||||||
|
return amount
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1
|
# License OPL-1
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
import secrets
|
import secrets
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from odoo import api, fields, models
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class FusionBillingService(models.Model):
|
class FusionBillingService(models.Model):
|
||||||
@@ -45,6 +48,33 @@ class FusionBillingService(models.Model):
|
|||||||
for rec in self:
|
for rec in self:
|
||||||
rec.account_link_count = len(rec.account_link_ids)
|
rec.account_link_count = len(rec.account_link_ids)
|
||||||
|
|
||||||
|
@api.constrains("webhook_url")
|
||||||
|
def _check_webhook_url(self):
|
||||||
|
"""Reject SSRF-prone webhook targets: a non-empty URL must be https and must
|
||||||
|
not point at localhost or a private / link-local / loopback IP literal. Empty
|
||||||
|
is allowed (no webhook configured)."""
|
||||||
|
for rec in self:
|
||||||
|
url = (rec.webhook_url or "").strip()
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
raise ValidationError("Webhook URL must use https.")
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if not host or host.lower() in ("localhost", "ip6-localhost", "ip6-loopback"):
|
||||||
|
raise ValidationError(
|
||||||
|
"Webhook URL must not target localhost or a private address.")
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
ip = None
|
||||||
|
if ip is not None and (
|
||||||
|
ip.is_private or ip.is_loopback or ip.is_link_local
|
||||||
|
or ip.is_reserved or ip.is_unspecified or ip.is_multicast
|
||||||
|
):
|
||||||
|
raise ValidationError(
|
||||||
|
"Webhook URL must not target a private or loopback address.")
|
||||||
|
|
||||||
def action_generate_api_key(self):
|
def action_generate_api_key(self):
|
||||||
"""Generate a fresh bearer key, store only its hash, return the raw key.
|
"""Generate a fresh bearer key, store only its hash, return the raw key.
|
||||||
|
|
||||||
@@ -64,7 +94,14 @@ class FusionBillingService(models.Model):
|
|||||||
return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1)
|
return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1)
|
||||||
|
|
||||||
def _api_upsert_customer(self, payload):
|
def _api_upsert_customer(self, payload):
|
||||||
|
"""Resolve/create the partner link for an external account.
|
||||||
|
|
||||||
|
Defensive: a non-dict payload or a missing/empty ``external_id`` returns a
|
||||||
|
4xx-shaped error instead of raising (C3).
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {'status': 'error', 'error': 'invalid payload'}
|
||||||
ext = payload.get('external_id')
|
ext = payload.get('external_id')
|
||||||
if not ext:
|
if not ext:
|
||||||
return {'status': 'error', 'error': 'external_id required'}
|
return {'status': 'error', 'error': 'external_id required'}
|
||||||
@@ -73,15 +110,53 @@ class FusionBillingService(models.Model):
|
|||||||
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
|
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
|
||||||
|
|
||||||
def _api_record_usage(self, payload):
|
def _api_record_usage(self, payload):
|
||||||
|
"""Ingest a batch of usage events.
|
||||||
|
|
||||||
|
Authorization (C2/C4): each event must target a subscription sale.order that
|
||||||
|
(a) exists, (b) is actually a subscription, and (c) belongs to a customer THIS
|
||||||
|
service is linked to. Any failing event is rejected and stops processing for
|
||||||
|
that event without writing a usage row.
|
||||||
|
|
||||||
|
Validation (C3): a non-dict payload, a non-list ``events``, missing required
|
||||||
|
keys, or non-numeric ``quantity``/ids return a 4xx-shaped error instead of
|
||||||
|
raising (no 500s).
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
events = payload.get('events') or []
|
if not isinstance(payload, dict):
|
||||||
|
return {'status': 'error', 'error': 'invalid payload'}
|
||||||
|
events = payload.get('events')
|
||||||
|
if events is None:
|
||||||
|
events = []
|
||||||
|
if not isinstance(events, list):
|
||||||
|
return {'status': 'error', 'error': 'events must be a list'}
|
||||||
Usage = self.env['fusion.billing.usage']
|
Usage = self.env['fusion.billing.usage']
|
||||||
|
linked_partners = self.account_link_ids.mapped('partner_id')
|
||||||
accepted = 0
|
accepted = 0
|
||||||
for ev in events:
|
for ev in events:
|
||||||
sub = self.env['sale.order'].browse(int(ev['subscription_external_id']))
|
if not isinstance(ev, dict):
|
||||||
|
return {'status': 'error', 'error': 'invalid event'}
|
||||||
|
for key in ('subscription_external_id', 'metric_code', 'quantity',
|
||||||
|
'period_start', 'period_end'):
|
||||||
|
if ev.get(key) in (None, ''):
|
||||||
|
return {'status': 'error', 'error': 'missing %s' % key}
|
||||||
|
try:
|
||||||
|
sub_id = int(ev['subscription_external_id'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'error': 'invalid subscription_external_id'}
|
||||||
|
try:
|
||||||
|
quantity = float(ev['quantity'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'error': 'invalid quantity'}
|
||||||
|
sub = self.env['sale.order'].browse(sub_id)
|
||||||
|
if not sub.exists() or not sub.is_subscription \
|
||||||
|
or sub.partner_id not in linked_partners:
|
||||||
|
return {'status': 'error', 'error': 'unknown subscription'}
|
||||||
|
try:
|
||||||
Usage._record_usage(
|
Usage._record_usage(
|
||||||
sub, ev['metric_code'], float(ev['quantity']),
|
sub, ev['metric_code'], quantity,
|
||||||
ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key'))
|
ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key'))
|
||||||
|
except ValueError as e:
|
||||||
|
return {'status': 'error', 'error': str(e)}
|
||||||
accepted += 1
|
accepted += 1
|
||||||
return {'status': 'ok', 'accepted': accepted}
|
return {'status': 'ok', 'accepted': accepted}
|
||||||
|
|
||||||
@@ -100,21 +175,49 @@ class FusionBillingService(models.Model):
|
|||||||
The product on each line must have recurring_invoice=True so that
|
The product on each line must have recurring_invoice=True so that
|
||||||
Odoo recognises the order as a subscription with has_recurring_line and
|
Odoo recognises the order as a subscription with has_recurring_line and
|
||||||
action_confirm() reaches subscription_state='3_progress'.
|
action_confirm() reaches subscription_state='3_progress'.
|
||||||
|
|
||||||
|
Validation (C3): a non-dict payload, a missing/unknown customer, a missing
|
||||||
|
``plan_id``, a non-list ``lines``, or a non-numeric product id/quantity
|
||||||
|
return a 4xx-shaped error instead of raising (no 500s).
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {'status': 'error', 'error': 'invalid payload'}
|
||||||
|
if not payload.get('external_customer_id'):
|
||||||
|
return {'status': 'error', 'error': 'external_customer_id required'}
|
||||||
|
if not payload.get('plan_id'):
|
||||||
|
return {'status': 'error', 'error': 'plan_id required'}
|
||||||
|
try:
|
||||||
|
plan_id = int(payload['plan_id'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'error': 'invalid plan_id'}
|
||||||
link = self.env['fusion.billing.account.link'].search([
|
link = self.env['fusion.billing.account.link'].search([
|
||||||
('service_id', '=', self.id),
|
('service_id', '=', self.id),
|
||||||
('external_id', '=', payload.get('external_customer_id')),
|
('external_id', '=', payload.get('external_customer_id')),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if not link:
|
if not link:
|
||||||
return {'status': 'error', 'error': 'unknown customer'}
|
return {'status': 'error', 'error': 'unknown customer'}
|
||||||
order_lines = [(0, 0, {
|
lines = payload.get('lines')
|
||||||
'product_id': line['product_id'],
|
if lines is None:
|
||||||
'product_uom_qty': line.get('quantity', 1),
|
lines = []
|
||||||
}) for line in payload.get('lines', [])]
|
if not isinstance(lines, list):
|
||||||
|
return {'status': 'error', 'error': 'lines must be a list'}
|
||||||
|
order_lines = []
|
||||||
|
for line in lines:
|
||||||
|
if not isinstance(line, dict) or line.get('product_id') in (None, ''):
|
||||||
|
return {'status': 'error', 'error': 'invalid line'}
|
||||||
|
try:
|
||||||
|
product_id = int(line['product_id'])
|
||||||
|
quantity = float(line.get('quantity', 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'error': 'invalid line'}
|
||||||
|
order_lines.append((0, 0, {
|
||||||
|
'product_id': product_id,
|
||||||
|
'product_uom_qty': quantity,
|
||||||
|
}))
|
||||||
sub = self.env['sale.order'].sudo().create({
|
sub = self.env['sale.order'].sudo().create({
|
||||||
'partner_id': link.partner_id.id,
|
'partner_id': link.partner_id.id,
|
||||||
'plan_id': payload['plan_id'],
|
'plan_id': plan_id,
|
||||||
'order_line': order_lines,
|
'order_line': order_lines,
|
||||||
})
|
})
|
||||||
sub.action_confirm()
|
sub.action_confirm()
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ class FusionBillingUsage(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
_idempotency_uniq = models.Constraint(
|
_idempotency_uniq = models.Constraint(
|
||||||
"unique(idempotency_key)", "Usage idempotency key must be unique.",
|
"unique(subscription_id, metric_id, idempotency_key)",
|
||||||
|
"Usage idempotency key must be unique per subscription and metric.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None):
|
def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None):
|
||||||
"""Upsert one aggregated usage row. Same idempotency key updates in place (no double-count)."""
|
"""Upsert one aggregated usage row. Same idempotency key (scoped to the same
|
||||||
|
subscription + metric) updates in place (no double-count)."""
|
||||||
metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1)
|
metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1)
|
||||||
if not metric:
|
if not metric:
|
||||||
raise ValueError("Unknown metric code: %s" % metric_code)
|
raise ValueError("Unknown metric code: %s" % metric_code)
|
||||||
@@ -53,21 +55,55 @@ class FusionBillingUsage(models.Model):
|
|||||||
'idempotency_key': idem,
|
'idempotency_key': idem,
|
||||||
}
|
}
|
||||||
if idem:
|
if idem:
|
||||||
existing = self.search([('idempotency_key', '=', idem)], limit=1)
|
existing = self.search([
|
||||||
|
('subscription_id', '=', subscription.id),
|
||||||
|
('metric_id', '=', metric.id),
|
||||||
|
('idempotency_key', '=', idem),
|
||||||
|
], limit=1)
|
||||||
if existing:
|
if existing:
|
||||||
existing.write({'quantity': quantity})
|
existing.write({'quantity': quantity})
|
||||||
return existing
|
return existing
|
||||||
return self.create(vals)
|
return self.create(vals)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_rate_open_periods(self):
|
||||||
|
"""Hourly cron: for every active charge, aggregate usage and upsert overage lines
|
||||||
|
on the in-progress subscriptions that are on the charge's own plan.
|
||||||
|
|
||||||
|
A charge only rates subscriptions whose ``plan_id`` matches the charge's
|
||||||
|
``plan_id`` — never every subscription against every charge (C1/H4). The
|
||||||
|
billing-period window is the subscription's real open period
|
||||||
|
``[last_invoice_date or start_date, next_invoice_date)`` (H1)."""
|
||||||
|
Charge = self.env['fusion.billing.charge'].search([('active', '=', True)])
|
||||||
|
SaleOrder = self.env['sale.order']
|
||||||
|
for charge in Charge:
|
||||||
|
if not charge.plan_id:
|
||||||
|
continue
|
||||||
|
subs = SaleOrder.search([
|
||||||
|
('is_subscription', '=', True),
|
||||||
|
('subscription_state', '=', '3_progress'),
|
||||||
|
('plan_id', '=', charge.plan_id.id),
|
||||||
|
])
|
||||||
|
for sub in subs:
|
||||||
|
if not sub.next_invoice_date:
|
||||||
|
continue
|
||||||
|
period_end = fields.Datetime.to_datetime(sub.next_invoice_date)
|
||||||
|
period_start = fields.Datetime.to_datetime(
|
||||||
|
sub.last_invoice_date or sub.start_date)
|
||||||
|
if not period_start:
|
||||||
|
continue
|
||||||
|
sub._fc_rate_usage(charge, period_start, period_end)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _aggregate(self, subscription, metric, period_start, period_end):
|
def _aggregate(self, subscription, metric, period_start, period_end):
|
||||||
"""Aggregate stored usage for a subscription+metric within [period_start, period_end)
|
"""Aggregate stored usage for a subscription+metric over the half-open window
|
||||||
|
``[period_start, period_end)``, anchored on each rollup's ``period_start``,
|
||||||
using the metric's aggregation function."""
|
using the metric's aggregation function."""
|
||||||
rows = self.search([
|
rows = self.search([
|
||||||
('subscription_id', '=', subscription.id),
|
('subscription_id', '=', subscription.id),
|
||||||
('metric_id', '=', metric.id),
|
('metric_id', '=', metric.id),
|
||||||
('period_start', '>=', period_start),
|
('period_start', '>=', period_start),
|
||||||
('period_end', '<=', period_end),
|
('period_start', '<', period_end),
|
||||||
])
|
])
|
||||||
qtys = rows.mapped('quantity')
|
qtys = rows.mapped('quantity')
|
||||||
if not qtys:
|
if not qtys:
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class FusionBillingWebhook(models.Model):
|
|||||||
"subscription.terminated / subscription.reactivated / usage.threshold_reached",
|
"subscription.terminated / subscription.reactivated / usage.threshold_reached",
|
||||||
)
|
)
|
||||||
payload = fields.Json()
|
payload = fields.Json()
|
||||||
|
body = fields.Text(
|
||||||
|
help="Canonical JSON body that was signed and is POSTed verbatim "
|
||||||
|
"(so the signature always matches the bytes on the wire).",
|
||||||
|
)
|
||||||
state = fields.Selection(
|
state = fields.Selection(
|
||||||
[
|
[
|
||||||
("pending", "Pending"),
|
("pending", "Pending"),
|
||||||
@@ -59,11 +63,14 @@ class FusionBillingWebhook(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _enqueue(self, service, event_type, payload):
|
def _enqueue(self, service, event_type, payload):
|
||||||
|
# Serialize the canonical body ONCE, store it, and sign that exact string so
|
||||||
|
# the dispatched bytes always match the signature (no re-serialization drift).
|
||||||
body = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
body = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
||||||
return self.create({
|
return self.create({
|
||||||
'service_id': service.id,
|
'service_id': service.id,
|
||||||
'event_type': event_type,
|
'event_type': event_type,
|
||||||
'payload': payload,
|
'payload': payload,
|
||||||
|
'body': body,
|
||||||
'signature': self._sign(service.webhook_secret, body),
|
'signature': self._sign(service.webhook_secret, body),
|
||||||
'state': 'pending',
|
'state': 'pending',
|
||||||
'next_retry_at': fields.Datetime.now(),
|
'next_retry_at': fields.Datetime.now(),
|
||||||
@@ -77,14 +84,18 @@ class FusionBillingWebhook(models.Model):
|
|||||||
('next_retry_at', '<=', now),
|
('next_retry_at', '<=', now),
|
||||||
], limit=100)
|
], limit=100)
|
||||||
for wh in due:
|
for wh in due:
|
||||||
body = json.dumps(wh.payload, sort_keys=True, separators=(',', ':'))
|
# POST the exact bytes that were signed at enqueue time. Fall back to
|
||||||
|
# re-serializing the payload only for legacy rows enqueued before `body`
|
||||||
|
# existed (the signature was computed over the same canonical form).
|
||||||
|
body = wh.body or json.dumps(wh.payload, sort_keys=True, separators=(',', ':'))
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
wh.service_id.webhook_url,
|
wh.service_id.webhook_url,
|
||||||
data=body,
|
data=body,
|
||||||
headers={'Content-Type': 'application/json',
|
headers={'Content-Type': 'application/json',
|
||||||
'X-Fusion-Signature': wh.signature,
|
'X-Fusion-Signature': wh.signature,
|
||||||
'X-Fusion-Event': wh.event_type},
|
'X-Fusion-Event': wh.event_type,
|
||||||
|
'X-Fusion-Event-Id': str(wh.id)},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
ok = 200 <= resp.status_code < 300
|
ok = 200 <= resp.status_code < 300
|
||||||
@@ -98,4 +109,5 @@ class FusionBillingWebhook(models.Model):
|
|||||||
wh.state = 'dead'
|
wh.state = 'dead'
|
||||||
else:
|
else:
|
||||||
wh.state = 'failed'
|
wh.state = 'failed'
|
||||||
wh.next_retry_at = now + timedelta(minutes=2 ** wh.attempts)
|
# Cap the exponential backoff so the interval can't overflow.
|
||||||
|
wh.next_retry_at = now + timedelta(minutes=2 ** min(wh.attempts, 10))
|
||||||
|
|||||||
@@ -61,3 +61,79 @@ class TestApiHandlers(TransactionCase):
|
|||||||
self.assertTrue(sub.is_subscription)
|
self.assertTrue(sub.is_subscription)
|
||||||
self.assertEqual(sub.plan_id, self.plan)
|
self.assertEqual(sub.plan_id, self.plan)
|
||||||
self.assertEqual(sub.subscription_state, '3_progress')
|
self.assertEqual(sub.subscription_state, '3_progress')
|
||||||
|
|
||||||
|
# ── item 4 (C3): malformed input returns a 4xx-shaped error, never raises ──
|
||||||
|
def test_record_usage_missing_metric_code_returns_error(self):
|
||||||
|
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
|
||||||
|
partner = self.env['fusion.billing.account.link'].search(
|
||||||
|
[('external_id', '=', 'client-9')]).partner_id
|
||||||
|
sub = self.env['sale.order'].sudo().create(
|
||||||
|
{'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id})
|
||||||
|
# metric_code intentionally omitted — must return an error dict, not raise
|
||||||
|
res = self.service._api_record_usage({'events': [{
|
||||||
|
'subscription_external_id': str(sub.id),
|
||||||
|
'quantity': 10.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||||
|
}]})
|
||||||
|
self.assertEqual(res['status'], 'error')
|
||||||
|
# no usage row written
|
||||||
|
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
|
||||||
|
self.assertFalse(usage)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestUsageAuthorization(TransactionCase):
|
||||||
|
"""/usage must only accept events for subscriptions the calling service is linked
|
||||||
|
to, and reject unknown / non-subscription targets (items 3/C2/C4)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||||
|
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
|
||||||
|
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||||
|
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||||
|
self.service_a = self.env['fusion.billing.service'].sudo().create(
|
||||||
|
{'name': 'Service A', 'code': 'svc_a'})
|
||||||
|
self.service_b = self.env['fusion.billing.service'].sudo().create(
|
||||||
|
{'name': 'Service B', 'code': 'svc_b'})
|
||||||
|
# Service A owns customer + subscription
|
||||||
|
self.service_a._api_upsert_customer({'external_id': 'cust-a', 'name': 'Cust A'})
|
||||||
|
self.partner_a = self.env['fusion.billing.account.link'].search(
|
||||||
|
[('service_id', '=', self.service_a.id), ('external_id', '=', 'cust-a')]).partner_id
|
||||||
|
self.sub_a = self.env['sale.order'].sudo().create(
|
||||||
|
{'partner_id': self.partner_a.id, 'is_subscription': True, 'plan_id': self.plan.id})
|
||||||
|
self.Usage = self.env['fusion.billing.usage'].sudo()
|
||||||
|
|
||||||
|
def _event(self, sub_id, idem):
|
||||||
|
return {'events': [{
|
||||||
|
'subscription_external_id': str(sub_id), 'metric_code': 'api_calls',
|
||||||
|
'quantity': 42.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||||
|
'idempotency_key': idem,
|
||||||
|
}]}
|
||||||
|
|
||||||
|
def test_cross_service_usage_rejected(self):
|
||||||
|
"""Service B pushing usage onto Service A's subscription is rejected, no row."""
|
||||||
|
res = self.service_b._api_record_usage(self._event(self.sub_a.id, 'cross-1'))
|
||||||
|
self.assertEqual(res['status'], 'error')
|
||||||
|
self.assertEqual(res['error'], 'unknown subscription')
|
||||||
|
self.assertFalse(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
|
||||||
|
|
||||||
|
def test_same_service_usage_accepted(self):
|
||||||
|
"""Positive control: Service A pushing onto its own subscription is accepted."""
|
||||||
|
res = self.service_a._api_record_usage(self._event(self.sub_a.id, 'ok-1'))
|
||||||
|
self.assertEqual(res['status'], 'ok')
|
||||||
|
self.assertEqual(res['accepted'], 1)
|
||||||
|
self.assertTrue(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
|
||||||
|
|
||||||
|
def test_nonexistent_subscription_rejected(self):
|
||||||
|
res = self.service_a._api_record_usage(self._event(999_999_999, 'ghost-1'))
|
||||||
|
self.assertEqual(res['status'], 'error')
|
||||||
|
self.assertEqual(res['error'], 'unknown subscription')
|
||||||
|
|
||||||
|
def test_non_subscription_order_rejected(self):
|
||||||
|
"""A plain (non-subscription) sale.order owned by the linked customer is rejected."""
|
||||||
|
plain = self.env['sale.order'].sudo().create({'partner_id': self.partner_a.id})
|
||||||
|
self.assertFalse(plain.is_subscription)
|
||||||
|
res = self.service_a._api_record_usage(self._event(plain.id, 'plain-1'))
|
||||||
|
self.assertEqual(res['status'], 'error')
|
||||||
|
self.assertEqual(res['error'], 'unknown subscription')
|
||||||
|
self.assertFalse(self.Usage.search([('subscription_id', '=', plain.id)]))
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from psycopg2 import IntegrityError
|
||||||
|
|
||||||
from odoo.tests.common import TransactionCase, tagged
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.tools.misc import mute_logger
|
||||||
|
|
||||||
|
|
||||||
@tagged('post_install', '-at_install')
|
@tagged('post_install', '-at_install')
|
||||||
@@ -43,3 +46,29 @@ class TestChargeMath(TransactionCase):
|
|||||||
# 2,001 units -> 3 packages -> $6.00
|
# 2,001 units -> 3 packages -> $6.00
|
||||||
_, amount = charge._compute_billable(2_001.0)
|
_, amount = charge._compute_billable(2_001.0)
|
||||||
self.assertAlmostEqual(amount, 6.0, places=2)
|
self.assertAlmostEqual(amount, 6.0, places=2)
|
||||||
|
|
||||||
|
# ── item 10 (M7): only the two implemented charge models remain ──
|
||||||
|
def test_charge_model_selection_limited(self):
|
||||||
|
field = self.env['fusion.billing.charge']._fields['charge_model']
|
||||||
|
keys = [k for k, _label in field.selection]
|
||||||
|
self.assertEqual(sorted(keys), ['package', 'standard'])
|
||||||
|
self.assertNotIn('graduated', keys)
|
||||||
|
self.assertNotIn('volume', keys)
|
||||||
|
|
||||||
|
# ── item 11 (L1): currency is required and defaults to company currency ──
|
||||||
|
def test_currency_required_and_defaulted(self):
|
||||||
|
field = self.env['fusion.billing.charge']._fields['currency_id']
|
||||||
|
self.assertTrue(field.required)
|
||||||
|
charge = self._charge()
|
||||||
|
self.assertEqual(charge.currency_id, self.env.company.currency_id)
|
||||||
|
|
||||||
|
# ── item 12 (L2): non-negative price + positive batch DB constraints ──
|
||||||
|
def test_negative_price_rejected(self):
|
||||||
|
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
self._charge(price_per_unit=-1.0)
|
||||||
|
|
||||||
|
def test_zero_batch_rejected(self):
|
||||||
|
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
self._charge(unit_batch=0.0)
|
||||||
|
|||||||
@@ -2,6 +2,66 @@
|
|||||||
from odoo.tests.common import TransactionCase, tagged
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestRatingCron(TransactionCase):
|
||||||
|
"""The rating cron must only rate a subscription against charges on its OWN plan
|
||||||
|
(items 1/C1/H4) and over the subscription's real open billing period (item 5/H1)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||||
|
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||||
|
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
|
||||||
|
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||||
|
self.plan_b = self.env['sale.subscription.plan'].sudo().create(
|
||||||
|
{'name': 'Plan B', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||||
|
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
|
||||||
|
self.recurring_product = self.env['product.product'].sudo().create(
|
||||||
|
{'name': 'Plan seat', 'type': 'service', 'recurring_invoice': True,
|
||||||
|
'list_price': 10.0})
|
||||||
|
self.overage_product = self.env['product.product'].sudo().create(
|
||||||
|
{'name': 'CPU overage', 'type': 'service', 'list_price': 0.0})
|
||||||
|
self.Usage = self.env['fusion.billing.usage'].sudo()
|
||||||
|
|
||||||
|
def _confirmed_sub(self, plan):
|
||||||
|
sub = self.env['sale.order'].sudo().create({
|
||||||
|
'partner_id': self.partner.id, 'plan_id': plan.id,
|
||||||
|
'order_line': [(0, 0, {'product_id': self.recurring_product.id,
|
||||||
|
'product_uom_qty': 1})],
|
||||||
|
})
|
||||||
|
sub.action_confirm()
|
||||||
|
# widen the computed billing window so usage in May is in-period
|
||||||
|
sub.write({'start_date': '2026-05-01', 'next_invoice_date': '2026-06-01'})
|
||||||
|
return sub
|
||||||
|
|
||||||
|
def test_cron_rates_only_matching_plan(self):
|
||||||
|
sub_a = self._confirmed_sub(self.plan_a)
|
||||||
|
sub_b = self._confirmed_sub(self.plan_b)
|
||||||
|
self.assertEqual(sub_a.subscription_state, '3_progress')
|
||||||
|
self.assertEqual(sub_b.subscription_state, '3_progress')
|
||||||
|
# one charge, linked to Plan A only
|
||||||
|
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||||
|
'name': 'CPU overage', 'plan_code': 'plan-a', 'plan_id': self.plan_a.id,
|
||||||
|
'metric_id': self.metric.id, 'product_id': self.overage_product.id,
|
||||||
|
'included_quota': 100.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
|
||||||
|
'charge_model': 'standard'})
|
||||||
|
# usage recorded on BOTH subs, in the open period
|
||||||
|
self.Usage._record_usage(sub_a, 'cpu_seconds', 1100.0,
|
||||||
|
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='a1')
|
||||||
|
self.Usage._record_usage(sub_b, 'cpu_seconds', 1100.0,
|
||||||
|
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='b1')
|
||||||
|
|
||||||
|
self.Usage._cron_rate_open_periods()
|
||||||
|
|
||||||
|
# Plan A sub IS rated (window captured the usage → overage line present)
|
||||||
|
line_a = sub_a.order_line.filtered(lambda l: l.product_id == self.overage_product)
|
||||||
|
self.assertTrue(line_a, "Plan A subscription should be rated by the Plan A charge")
|
||||||
|
self.assertAlmostEqual(line_a.price_unit, 0.10, places=2)
|
||||||
|
# Plan B sub is NOT rated by the Plan A charge
|
||||||
|
line_b = sub_b.order_line.filtered(lambda l: l.product_id == self.overage_product)
|
||||||
|
self.assertFalse(line_b, "Plan B subscription must NOT be rated by the Plan A charge")
|
||||||
|
|
||||||
|
|
||||||
@tagged('post_install', '-at_install')
|
@tagged('post_install', '-at_install')
|
||||||
class TestUsageIngestion(TransactionCase):
|
class TestUsageIngestion(TransactionCase):
|
||||||
|
|
||||||
@@ -50,3 +110,62 @@ class TestUsageIngestion(TransactionCase):
|
|||||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr')
|
self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr')
|
||||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may')
|
self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may')
|
||||||
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0)
|
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0)
|
||||||
|
|
||||||
|
def test_rate_open_period_creates_overage_line(self):
|
||||||
|
product = self.env['product.product'].sudo().create(
|
||||||
|
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
|
||||||
|
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||||
|
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
|
||||||
|
'product_id': product.id, 'included_quota': 100.0,
|
||||||
|
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
|
||||||
|
self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0,
|
||||||
|
'2026-05-01', '2026-06-01', idem='r1')
|
||||||
|
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
|
||||||
|
# 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10
|
||||||
|
self.assertAlmostEqual(amount, 0.10, places=2)
|
||||||
|
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
|
||||||
|
self.assertTrue(line)
|
||||||
|
|
||||||
|
# ── item 6 (H2): half-open aggregation window anchored on period_start ──
|
||||||
|
def test_aggregate_daily_rollups_in_window(self):
|
||||||
|
"""Three DAILY rollups (period_start 05-01/-08/-15, each period_end +1 day)
|
||||||
|
sum correctly for the half-open window ['2026-05-01', '2026-06-01')."""
|
||||||
|
rollups = [
|
||||||
|
('2026-05-01 00:00:00', '2026-05-02 00:00:00', 3.0),
|
||||||
|
('2026-05-08 00:00:00', '2026-05-09 00:00:00', 5.0),
|
||||||
|
('2026-05-15 00:00:00', '2026-05-16 00:00:00', 7.0),
|
||||||
|
]
|
||||||
|
for i, (ps, pe, q) in enumerate(rollups):
|
||||||
|
self.Usage._record_usage(self.sub, 'cpu_seconds', q, ps, pe, idem='daily-%d' % i)
|
||||||
|
total = self.Usage._aggregate(
|
||||||
|
self.sub, self.metric, '2026-05-01 00:00:00', '2026-06-01 00:00:00')
|
||||||
|
self.assertEqual(total, 15.0) # 3 + 5 + 7
|
||||||
|
|
||||||
|
# ── item 7 (H3): idempotency key is scoped per (subscription, metric) ──
|
||||||
|
def test_same_idempotency_key_distinct_subscriptions(self):
|
||||||
|
"""The SAME idempotency key on two DIFFERENT subscriptions creates TWO rows."""
|
||||||
|
sub2 = self.env['sale.order'].sudo().create({
|
||||||
|
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
|
||||||
|
})
|
||||||
|
key = 'shared-idem-key'
|
||||||
|
a = self.Usage._record_usage(self.sub, 'cpu_seconds', 10.0, '2026-05-01', '2026-06-01', idem=key)
|
||||||
|
b = self.Usage._record_usage(sub2, 'cpu_seconds', 20.0, '2026-05-01', '2026-06-01', idem=key)
|
||||||
|
self.assertNotEqual(a, b) # distinct rows, no collision
|
||||||
|
rows = self.Usage.search([('idempotency_key', '=', key)])
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(a.quantity, 10.0)
|
||||||
|
self.assertEqual(b.quantity, 20.0)
|
||||||
|
|
||||||
|
# ── item 2 (C1): zero aggregated usage creates no overage line ──
|
||||||
|
def test_zero_usage_creates_no_line(self):
|
||||||
|
product = self.env['product.product'].sudo().create(
|
||||||
|
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
|
||||||
|
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||||
|
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
|
||||||
|
'product_id': product.id, 'included_quota': 100.0,
|
||||||
|
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
|
||||||
|
# no usage recorded → aggregate is 0 → amount 0 → no line created
|
||||||
|
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
|
||||||
|
self.assertEqual(amount, 0.0)
|
||||||
|
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
|
||||||
|
self.assertFalse(line)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
from odoo.tests.common import TransactionCase, tagged
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
@@ -51,3 +52,48 @@ class TestWebhookEngine(TransactionCase):
|
|||||||
return_value=_Resp()):
|
return_value=_Resp()):
|
||||||
self.Webhook._cron_dispatch()
|
self.Webhook._cron_dispatch()
|
||||||
self.assertEqual(wh.state, 'dead')
|
self.assertEqual(wh.state, 'dead')
|
||||||
|
|
||||||
|
# ── item 8 (H5): dispatch POSTs the stored body verbatim + event-id header ──
|
||||||
|
def test_dispatch_posts_stored_body_and_event_id(self):
|
||||||
|
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-9'})
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status_code = 200
|
||||||
|
text = 'ok'
|
||||||
|
|
||||||
|
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
|
||||||
|
return_value=_Resp()) as mock_post:
|
||||||
|
self.Webhook._cron_dispatch()
|
||||||
|
self.assertTrue(mock_post.called)
|
||||||
|
_args, kwargs = mock_post.call_args
|
||||||
|
# the exact stored body is POSTed (not a re-serialized payload)
|
||||||
|
self.assertEqual(kwargs['data'], wh.body)
|
||||||
|
self.assertEqual(wh.body, json.dumps(
|
||||||
|
{'invoice': 'INV-9'}, sort_keys=True, separators=(',', ':')))
|
||||||
|
# signature matches the bytes on the wire
|
||||||
|
expected = hmac.new(b'whsec_test', wh.body.encode(), hashlib.sha256).hexdigest()
|
||||||
|
self.assertEqual(kwargs['headers']['X-Fusion-Signature'], expected)
|
||||||
|
# event id header present and correct
|
||||||
|
self.assertEqual(kwargs['headers']['X-Fusion-Event-Id'], str(wh.id))
|
||||||
|
|
||||||
|
# ── item 9 (H6): SSRF guard on webhook_url ──
|
||||||
|
def test_webhook_url_rejects_loopback(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.env['fusion.billing.service'].sudo().create({
|
||||||
|
'name': 'Evil', 'code': 'evil', 'webhook_url': 'http://127.0.0.1/x'})
|
||||||
|
|
||||||
|
def test_webhook_url_rejects_private_and_http(self):
|
||||||
|
for bad in ('http://10.0.0.5/hook', # private + non-https
|
||||||
|
'https://192.168.1.10/hook', # private
|
||||||
|
'https://localhost/hook', # localhost host
|
||||||
|
'https://169.254.169.254/latest', # link-local metadata
|
||||||
|
'http://api.example.com/hook'): # non-https
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.env['fusion.billing.service'].sudo().create({
|
||||||
|
'name': 'Bad', 'code': 'bad-%s' % bad, 'webhook_url': bad})
|
||||||
|
|
||||||
|
def test_webhook_url_allows_public_https(self):
|
||||||
|
svc = self.env['fusion.billing.service'].sudo().create({
|
||||||
|
'name': 'Good', 'code': 'good',
|
||||||
|
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook'})
|
||||||
|
self.assertTrue(svc.id)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ from urllib.parse import urljoin, urlparse
|
|||||||
from odoo import _, http
|
from odoo import _, http
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
from odoo.tools import email_normalize
|
||||||
|
|
||||||
|
from odoo.addons.fusion_helpdesk.utils import (
|
||||||
|
build_ticket_vals,
|
||||||
|
build_scope_domain,
|
||||||
|
is_public_message,
|
||||||
|
compute_unread_count,
|
||||||
|
escape_like,
|
||||||
|
)
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,7 +43,7 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
)
|
)
|
||||||
def submit(self, kind, subject, description,
|
def submit(self, kind, subject, description,
|
||||||
error_code=None, attachments=None,
|
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
|
"""Forward a bug report or feature request to the central Odoo
|
||||||
Helpdesk and return {ok, ticket_id, ticket_url, error}.
|
Helpdesk and return {ok, ticket_id, ticket_url, error}.
|
||||||
|
|
||||||
@@ -60,10 +69,6 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---- Build the ticket payload ---------------------------------
|
# ---- 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 = []
|
body_parts = []
|
||||||
if description:
|
if description:
|
||||||
body_parts.append(
|
body_parts.append(
|
||||||
@@ -77,12 +82,22 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
)
|
)
|
||||||
body_parts.append(self._build_diag_block(page_url, user_agent))
|
body_parts.append(self._build_diag_block(page_url, user_agent))
|
||||||
|
|
||||||
ticket_vals = {
|
# Identity keystone: send the reporter's name + email so the central
|
||||||
'name': full_subject,
|
# helpdesk find-or-creates the customer partner and subscribes them as
|
||||||
'description': '\n'.join(body_parts),
|
# a follower — which is what enables reply emails, the magic link, and
|
||||||
}
|
# the scoped "My Tickets" inbox. reply_email is the (editable) value the
|
||||||
if cfg['team_id']:
|
# user confirmed in the dialog; fall back to their Odoo email/login.
|
||||||
ticket_vals['team_id'] = cfg['team_id']
|
user = request.env.user
|
||||||
|
# Normalise the confirmed email (and fall back to the user's own).
|
||||||
|
# Normalising rejects garbage / wildcard-bearing values so the stored
|
||||||
|
# partner_email — which is also the inbox scope key — stays clean.
|
||||||
|
reporter_email = _norm_email(reply_email, user.email, user.login)
|
||||||
|
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 --------------------------------------
|
# ---- Talk to remote Odoo --------------------------------------
|
||||||
try:
|
try:
|
||||||
@@ -133,7 +148,12 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
return _network_error_response(cfg['url'], e)
|
return _network_error_response(cfg['url'], e)
|
||||||
|
|
||||||
# ---- Push attachments -----------------------------------------
|
# ---- Push attachments -----------------------------------------
|
||||||
|
# The ticket already exists; an attachment failure must NOT bubble up
|
||||||
|
# as a 500 (the user would think the whole submission failed and file
|
||||||
|
# a duplicate). Catch network errors too, count failures, and report
|
||||||
|
# them back so the dialog can tell the user which files didn't make it.
|
||||||
attached = 0
|
attached = 0
|
||||||
|
failed = 0
|
||||||
for att in attachments or []:
|
for att in attachments or []:
|
||||||
data_b64 = (att or {}).get('data_b64')
|
data_b64 = (att or {}).get('data_b64')
|
||||||
name = (att or {}).get('name') or 'attachment.bin'
|
name = (att or {}).get('name') or 'attachment.bin'
|
||||||
@@ -152,10 +172,12 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
attached += 1
|
attached += 1
|
||||||
except xmlrpc.client.Fault as e:
|
except (xmlrpc.client.Fault, xmlrpc.client.ProtocolError,
|
||||||
|
socket.timeout, OSError, ssl.SSLError) as e:
|
||||||
|
failed += 1
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
'fusion_helpdesk: attachment "%s" upload failed: %s',
|
'fusion_helpdesk: attachment "%s" upload failed: %s',
|
||||||
name, e.faultString,
|
name, e,
|
||||||
)
|
)
|
||||||
|
|
||||||
ticket_url = urljoin(
|
ticket_url = urljoin(
|
||||||
@@ -163,9 +185,9 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
'odoo/helpdesk/%s' % ticket_id,
|
'odoo/helpdesk/%s' % ticket_id,
|
||||||
)
|
)
|
||||||
_logger.info(
|
_logger.info(
|
||||||
'fusion_helpdesk: created remote ticket #%s (%s attachments) '
|
'fusion_helpdesk: created remote ticket #%s (%s attached, %s failed) '
|
||||||
'on %s for user %s',
|
'on %s for user %s',
|
||||||
ticket_id, attached, cfg['url'],
|
ticket_id, attached, failed, cfg['url'],
|
||||||
request.env.user.login,
|
request.env.user.login,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -173,6 +195,7 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
'ticket_id': ticket_id,
|
'ticket_id': ticket_id,
|
||||||
'ticket_url': ticket_url,
|
'ticket_url': ticket_url,
|
||||||
'attached': attached,
|
'attached': attached,
|
||||||
|
'failed': failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -351,6 +374,318 @@ class FusionHelpdeskController(http.Controller):
|
|||||||
body += '</table>'
|
body += '</table>'
|
||||||
return 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,
|
||||||
|
# Normalised so a self-set wildcard email ('%') can't widen scope.
|
||||||
|
'email': _norm_email(user.email, user.login),
|
||||||
|
'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.
|
||||||
|
|
||||||
|
A ProtocolError on the execute_kw leg (e.g. a 502/503/429 from the
|
||||||
|
central reverse proxy) is NOT an OSError subclass, so we convert it to
|
||||||
|
a _RemoteError here — otherwise it would escape every endpoint's
|
||||||
|
except-tuple and surface as a raw 500 (mislabelled "Network error")."""
|
||||||
|
uid, proxy = self._authenticate(cfg)
|
||||||
|
try:
|
||||||
|
return proxy.execute_kw(
|
||||||
|
cfg['db'], uid, cfg['password'], model, method, args, kw or {},
|
||||||
|
)
|
||||||
|
except xmlrpc.client.ProtocolError as e:
|
||||||
|
_logger.warning('fusion_helpdesk: HTTP %s on %s.%s: %s',
|
||||||
|
e.errcode, model, method, e.errmsg)
|
||||||
|
raise _RemoteError(
|
||||||
|
'remote_http_error',
|
||||||
|
_('The central Helpdesk returned HTTP %(code)s. Please try '
|
||||||
|
'again in a moment.') % {'code': e.errcode},
|
||||||
|
)
|
||||||
|
|
||||||
|
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',
|
||||||
|
'attachment_ids'],
|
||||||
|
'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),
|
||||||
|
'attachment_count': len(m.get('attachment_ids') or []),
|
||||||
|
'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 OWN partner on central so their reply
|
||||||
|
is correctly attributed.
|
||||||
|
|
||||||
|
`ident['email']` is already normalised (no wildcards); we escape it for
|
||||||
|
the =ilike search as belt-and-suspenders. On any failure we log and
|
||||||
|
return False — message_post then attributes the reply to the service
|
||||||
|
account, which is honest. We deliberately do NOT fall back to the
|
||||||
|
ticket's customer: for an admin replying to a colleague's ticket that
|
||||||
|
would silently impersonate the customer."""
|
||||||
|
email = ident['email']
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
pids = self._rpc(cfg, 'res.partner', 'search',
|
||||||
|
[[('email', '=ilike', escape_like(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) as e:
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: could not resolve reply author for %s on '
|
||||||
|
'ticket %s (%s); posting as the service account.',
|
||||||
|
email, ticket.get('id'), e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _mark_ticket_seen(self, ticket_id, messages):
|
||||||
|
"""Best-effort read-tracking. Runs AFTER the remote read/post, so it
|
||||||
|
must never raise — otherwise a local DB hiccup here would turn an
|
||||||
|
already-successful reply into a reported failure, and the user would
|
||||||
|
resubmit (posting a duplicate). Bookkeeping only; log and swallow."""
|
||||||
|
if not messages:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
request.env['fusion.helpdesk.ticket.seen']._mark_seen(
|
||||||
|
ticket_id, max(m['id'] for m in messages))
|
||||||
|
except Exception: # noqa: BLE001 — non-critical bookkeeping
|
||||||
|
_logger.exception(
|
||||||
|
'fusion_helpdesk: mark-seen failed for ticket %s', ticket_id)
|
||||||
|
|
||||||
|
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/<int:ticket_id>',
|
||||||
|
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',
|
||||||
|
'access_token'], '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)
|
||||||
|
self._mark_ticket_seen(ticket_id, messages)
|
||||||
|
t = found[0]
|
||||||
|
# Magic link: the customer's own access-token URL on central, so they
|
||||||
|
# can open the full ticket (incl. attachments) in the portal if needed.
|
||||||
|
portal_url = ''
|
||||||
|
if t.get('access_token'):
|
||||||
|
portal_url = '%s/my/ticket/%s/%s' % (
|
||||||
|
cfg['url'].rstrip('/'), t['id'], t['access_token'])
|
||||||
|
return {'ok': True, 'ticket': {
|
||||||
|
'id': t['id'], 'subject': t['name'],
|
||||||
|
'stage': t['stage_id'][1] if t['stage_id'] else '',
|
||||||
|
'portal_url': portal_url,
|
||||||
|
'messages': messages}}
|
||||||
|
|
||||||
|
@http.route('/fusion_helpdesk/ticket/<int:ticket_id>/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])
|
||||||
|
# We escape the user's text ourselves, then mark it up as paragraphs.
|
||||||
|
# message_post() ESCAPES a plain str body (it expects a Markup for
|
||||||
|
# HTML) — but Markup can't cross XML-RPC, so we pass body_is_html=True
|
||||||
|
# which tells the remote message_post to treat our already-escaped
|
||||||
|
# HTML as Markup. Without this the customer would see literal <p> tags.
|
||||||
|
html = '<p>%s</p>' % _html_escape(text).replace('\n', '<br/>')
|
||||||
|
self._rpc(cfg, 'helpdesk.ticket', 'message_post', [[ticket_id]], {
|
||||||
|
'body': html, 'body_is_html': True, '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)
|
||||||
|
self._mark_ticket_seen(ticket_id, 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):
|
def _html_escape(s):
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
@@ -1,13 +1,20 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or
|
// Fusion Helpdesk — submission + follow-up dialog.
|
||||||
// Feature, fill in subject + description, paste an error code, attach
|
//
|
||||||
// files, and capture a screenshot via the browser's getDisplayMedia
|
// Two tabs:
|
||||||
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit
|
// • New — report a bug / request a feature (the original form),
|
||||||
// which forwards it (XML-RPC) to a central Odoo Helpdesk.
|
// plus a confirmed "Your email" field so support can reply.
|
||||||
|
// • My Tickets — a live RPC view of the user's tickets on the central
|
||||||
|
// Odoo: list → open one → read support's replies → reply
|
||||||
|
// inline, without ever leaving this Odoo or logging in.
|
||||||
|
//
|
||||||
|
// Tickets are NOT copied locally — every list/thread/reply is a live call
|
||||||
|
// to the central Helpdesk, scoped server-side to the logged-in user.
|
||||||
|
|
||||||
import { Component, useState } from "@odoo/owl";
|
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||||
import { Dialog } from "@web/core/dialog/dialog";
|
import { Dialog } from "@web/core/dialog/dialog";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { user } from "@web/core/user";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { _t } from "@web/core/l10n/translation";
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
@@ -18,16 +25,20 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
static components = { Dialog };
|
static components = { Dialog };
|
||||||
static props = {
|
static props = {
|
||||||
close: Function,
|
close: Function,
|
||||||
|
initialTab: { type: String, optional: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
kind: "bug", // 'bug' | 'feature'
|
tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread'
|
||||||
|
// ---- New report ----
|
||||||
|
kind: "bug",
|
||||||
subject: "",
|
subject: "",
|
||||||
description: "",
|
description: "",
|
||||||
errorCode: "",
|
errorCode: "",
|
||||||
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}]
|
replyEmail: user.login || "",
|
||||||
|
attachments: [],
|
||||||
capturing: false,
|
capturing: false,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
error: "",
|
error: "",
|
||||||
@@ -35,21 +46,146 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
ticketId: null,
|
ticketId: null,
|
||||||
ticketUrl: "",
|
ticketUrl: "",
|
||||||
attached: 0,
|
attached: 0,
|
||||||
|
failed: 0,
|
||||||
|
// ---- My Tickets ----
|
||||||
|
isAdmin: false,
|
||||||
|
scope: "mine", // 'mine' | 'all'
|
||||||
|
tickets: [],
|
||||||
|
loadingList: false,
|
||||||
|
listError: "",
|
||||||
|
// ---- Thread ----
|
||||||
|
current: null, // {id, subject, stage, portal_url, messages}
|
||||||
|
loadingThread: false,
|
||||||
|
threadError: "",
|
||||||
|
replyBody: "",
|
||||||
|
sendingReply: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
if (this.state.tab === "list") {
|
||||||
|
await this.loadList();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get dialogTitle() {
|
get dialogTitle() {
|
||||||
return this.state.kind === "bug"
|
if (this.state.tab === "thread" && this.state.current) {
|
||||||
? _t("Report a Bug")
|
return this.state.current.subject;
|
||||||
: _t("Request a Feature");
|
}
|
||||||
|
if (this.state.tab === "list") {
|
||||||
|
return _t("My Tickets");
|
||||||
|
}
|
||||||
|
return this.state.kind === "bug" ? _t("Report a Bug") : _t("Request a Feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Tabs
|
||||||
|
async setTab(tab) {
|
||||||
|
this.state.tab = tab;
|
||||||
|
this.state.error = "";
|
||||||
|
if (tab === "list") {
|
||||||
|
await this.loadList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setKind(kind) {
|
setKind(kind) {
|
||||||
this.state.kind = kind;
|
this.state.kind = kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ==================================================================
|
||||||
// File input → b64
|
// My Tickets — list
|
||||||
|
// ==================================================================
|
||||||
|
async loadList() {
|
||||||
|
if (this.state.loadingList) return;
|
||||||
|
this.state.loadingList = true;
|
||||||
|
this.state.listError = "";
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fusion_helpdesk/my_tickets", {
|
||||||
|
scope: this.state.scope,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.state.listError = res.message || _t("Could not load your tickets.");
|
||||||
|
this.state.tickets = [];
|
||||||
|
} else {
|
||||||
|
this.state.tickets = res.tickets || [];
|
||||||
|
this.state.isAdmin = !!res.is_admin;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("fusion_helpdesk: my_tickets failed", err);
|
||||||
|
this.state.listError = (err && err.message) || _t("Network error.");
|
||||||
|
} finally {
|
||||||
|
this.state.loadingList = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setScope(scope) {
|
||||||
|
if (this.state.scope === scope) return;
|
||||||
|
this.state.scope = scope;
|
||||||
|
await this.loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// My Tickets — thread
|
||||||
|
// ==================================================================
|
||||||
|
async openTicket(ticketId) {
|
||||||
|
this.state.loadingThread = true;
|
||||||
|
this.state.threadError = "";
|
||||||
|
this.state.replyBody = "";
|
||||||
|
try {
|
||||||
|
const res = await rpc(`/fusion_helpdesk/ticket/${ticketId}`, {});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.state.threadError = res.message || _t("Could not open this ticket.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.current = res.ticket;
|
||||||
|
this.state.tab = "thread";
|
||||||
|
// The ticket is now seen server-side; clear its unread flag locally.
|
||||||
|
const row = this.state.tickets.find((t) => t.id === ticketId);
|
||||||
|
if (row) {
|
||||||
|
row.has_unread = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("fusion_helpdesk: open ticket failed", err);
|
||||||
|
this.state.threadError = (err && err.message) || _t("Network error.");
|
||||||
|
} finally {
|
||||||
|
this.state.loadingThread = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async backToList() {
|
||||||
|
this.state.current = null;
|
||||||
|
this.state.tab = "list";
|
||||||
|
await this.loadList(); // refresh stages / unread after viewing
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendReply() {
|
||||||
|
const body = (this.state.replyBody || "").trim();
|
||||||
|
if (!body || this.state.sendingReply || !this.state.current) return;
|
||||||
|
this.state.sendingReply = true;
|
||||||
|
this.state.threadError = "";
|
||||||
|
try {
|
||||||
|
const res = await rpc(
|
||||||
|
`/fusion_helpdesk/ticket/${this.state.current.id}/reply`,
|
||||||
|
{ body }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
this.state.threadError = res.message || _t("Could not send your reply.");
|
||||||
|
} else {
|
||||||
|
this.state.current.messages = res.messages || this.state.current.messages;
|
||||||
|
this.state.replyBody = "";
|
||||||
|
this.notification.add(_t("Reply sent."), { type: "success" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("fusion_helpdesk: send reply failed", err);
|
||||||
|
this.state.threadError = (err && err.message) || _t("Network error.");
|
||||||
|
} finally {
|
||||||
|
this.state.sendingReply = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// New report — files / screenshot (unchanged behaviour)
|
||||||
|
// ==================================================================
|
||||||
async onFilesPicked(ev) {
|
async onFilesPicked(ev) {
|
||||||
const files = Array.from(ev.target.files || []);
|
const files = Array.from(ev.target.files || []);
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
@@ -75,7 +211,6 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reset the input so picking the same file again re-fires onchange.
|
|
||||||
ev.target.value = "";
|
ev.target.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +227,6 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Screenshot capture via getDisplayMedia
|
|
||||||
async onTakeScreenshot() {
|
async onTakeScreenshot() {
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||||
this.notification.add(
|
this.notification.add(
|
||||||
@@ -119,7 +252,6 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
rawSize: blob.size,
|
rawSize: blob.size,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled the picker — silently swallow. Other errors → notify.
|
|
||||||
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
|
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
|
||||||
this.notification.add(
|
this.notification.add(
|
||||||
_t("Screenshot failed: %s").replace("%s", err.message || err),
|
_t("Screenshot failed: %s").replace("%s", err.message || err),
|
||||||
@@ -138,7 +270,6 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
video.srcObject = stream;
|
video.srcObject = stream;
|
||||||
await video.play();
|
await video.play();
|
||||||
// Give the browser one frame to settle the picker chrome.
|
|
||||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth;
|
||||||
@@ -195,8 +326,9 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
return "fa fa-file-o";
|
return "fa fa-file-o";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ==================================================================
|
||||||
// Submit
|
// New report — submit
|
||||||
|
// ==================================================================
|
||||||
async onSubmit() {
|
async onSubmit() {
|
||||||
if (this.state.submitting) return;
|
if (this.state.submitting) return;
|
||||||
const subject = (this.state.subject || "").trim();
|
const subject = (this.state.subject || "").trim();
|
||||||
@@ -213,6 +345,7 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
subject,
|
subject,
|
||||||
description: this.state.description || "",
|
description: this.state.description || "",
|
||||||
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
|
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
|
||||||
|
reply_email: (this.state.replyEmail || "").trim(),
|
||||||
attachments: this.state.attachments.map((a) => ({
|
attachments: this.state.attachments.map((a) => ({
|
||||||
name: a.name,
|
name: a.name,
|
||||||
mimetype: a.mimetype,
|
mimetype: a.mimetype,
|
||||||
@@ -229,13 +362,14 @@ export class FusionHelpdeskDialog extends Component {
|
|||||||
this.state.ticketId = res.ticket_id;
|
this.state.ticketId = res.ticket_id;
|
||||||
this.state.ticketUrl = res.ticket_url;
|
this.state.ticketUrl = res.ticket_url;
|
||||||
this.state.attached = res.attached || 0;
|
this.state.attached = res.attached || 0;
|
||||||
// Reset the editable fields so user can file another if they want.
|
this.state.failed = res.failed || 0;
|
||||||
this.state.subject = "";
|
this.state.subject = "";
|
||||||
this.state.description = "";
|
this.state.description = "";
|
||||||
this.state.errorCode = "";
|
this.state.errorCode = "";
|
||||||
this.state.attachments = [];
|
this.state.attachments = [];
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error("fusion_helpdesk: submit failed", err);
|
||||||
this.state.error = (err && err.message) || _t("Network error.");
|
this.state.error = (err && err.message) || _t("Network error.");
|
||||||
} finally {
|
} finally {
|
||||||
this.state.submitting = false;
|
this.state.submitting = false;
|
||||||
|
|||||||
@@ -1,24 +1,52 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon
|
// Fusion Helpdesk — top systray icon with an unread-reply badge.
|
||||||
// appears to the LEFT of the attendance check-in button. Odoo
|
// Sequence 99 places it just left of the attendance check-in button.
|
||||||
// systray ordering is by sequence ascending (lower = leftmost in the
|
|
||||||
// systray bar). hr_attendance ships at sequence 100, so we use 99.
|
|
||||||
|
|
||||||
import { Component } from "@odoo/owl";
|
import { Component, useState, onWillStart, onWillUnmount } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
|
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
|
||||||
|
|
||||||
|
const POLL_MS = 120000; // refresh the unread badge every 2 minutes
|
||||||
|
|
||||||
class FusionHelpdeskSystray extends Component {
|
class FusionHelpdeskSystray extends Component {
|
||||||
static template = "fusion_helpdesk.SystrayItem";
|
static template = "fusion_helpdesk.SystrayItem";
|
||||||
static props = {};
|
static props = {};
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.dialog = useService("dialog");
|
this.dialog = useService("dialog");
|
||||||
|
this.state = useState({ unread: 0 });
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this._refreshUnread();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll so a reply that lands while the user is working still
|
||||||
|
// surfaces without a page reload. Errors are swallowed server-side
|
||||||
|
// (the endpoint always returns a count) so the badge never breaks.
|
||||||
|
this._timer = setInterval(() => this._refreshUnread(), POLL_MS);
|
||||||
|
onWillUnmount(() => clearInterval(this._timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
async _refreshUnread() {
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fusion_helpdesk/unread_count", {});
|
||||||
|
this.state.unread = (res && res.count) || 0;
|
||||||
|
} catch {
|
||||||
|
// Network/config hiccup — leave the badge as-is, don't throw.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick() {
|
onClick() {
|
||||||
this.dialog.add(FusionHelpdeskDialog, {});
|
// If there are unread replies, drop straight into the inbox;
|
||||||
|
// otherwise open the New report form (the primary action).
|
||||||
|
const initialTab = this.state.unread > 0 ? "list" : "new";
|
||||||
|
this.dialog.add(
|
||||||
|
FusionHelpdeskDialog,
|
||||||
|
{ initialTab },
|
||||||
|
{ onClose: () => this._refreshUnread() }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,3 +170,170 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
|
|||||||
&:hover { color: #d32f2f; }
|
&:hover { color: #d32f2f; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Systray unread badge
|
||||||
|
.o_fhd_systray {
|
||||||
|
.o_fhd_systray_btn { position: relative; }
|
||||||
|
|
||||||
|
.o_fhd_systray_badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: 0;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #d9534f;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbox additions (tabs, list, thread) — share the dialog tokens above.
|
||||||
|
.o_fhd_dialog {
|
||||||
|
.o_fhd_muted { color: $fhd-muted; }
|
||||||
|
|
||||||
|
.o_fhd_tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border-bottom: 1px solid $fhd-border;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
color: $fhd-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover { color: $fhd-text; }
|
||||||
|
|
||||||
|
&.o_fhd_tab_active {
|
||||||
|
color: $fhd-accent;
|
||||||
|
border-bottom-color: $fhd-accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_scope_row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket list
|
||||||
|
.o_fhd_ticket_list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid $fhd-border;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_ticket_row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background-color: $fhd-bg;
|
||||||
|
border-bottom: 1px solid $fhd-border;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
&:hover { background-color: $fhd-hover; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_unread_dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $fhd-accent;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_unread_spacer { width: 9px; flex: 0 0 auto; }
|
||||||
|
|
||||||
|
.o_fhd_ticket_ref {
|
||||||
|
color: $fhd-muted;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_ticket_subject {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_ticket_stage {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: $fhd-hover;
|
||||||
|
border: 1px solid $fhd-border;
|
||||||
|
color: $fhd-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread
|
||||||
|
.o_fhd_thread_head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_open_portal {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $fhd-accent;
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover { text-decoration: underline; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_thread {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
max-height: 45vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_msg {
|
||||||
|
border: 1px solid $fhd-border;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background-color: $fhd-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_msg_head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_msg_author { font-weight: 600; color: $fhd-text; }
|
||||||
|
.o_fhd_msg_date { color: $fhd-muted; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.o_fhd_msg_body {
|
||||||
|
color: $fhd-text;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
p:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_msg_attach {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $fhd-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,23 +4,36 @@
|
|||||||
<t t-name="fusion_helpdesk.Dialog">
|
<t t-name="fusion_helpdesk.Dialog">
|
||||||
<Dialog title="dialogTitle" size="'lg'">
|
<Dialog title="dialogTitle" size="'lg'">
|
||||||
<div class="o_fhd_dialog">
|
<div class="o_fhd_dialog">
|
||||||
<!-- Kind selector -->
|
|
||||||
|
<!-- ===== Tabs ===== -->
|
||||||
|
<div class="o_fhd_tabs">
|
||||||
|
<button type="button" class="o_fhd_tab"
|
||||||
|
t-att-class="{ 'o_fhd_tab_active': state.tab === 'new' }"
|
||||||
|
t-on-click="() => this.setTab('new')">
|
||||||
|
<i class="fa fa-plus-circle me-1"/> New
|
||||||
|
</button>
|
||||||
|
<button type="button" class="o_fhd_tab"
|
||||||
|
t-att-class="{ 'o_fhd_tab_active': state.tab === 'list' || state.tab === 'thread' }"
|
||||||
|
t-on-click="() => this.setTab('list')">
|
||||||
|
<i class="fa fa-ticket me-1"/> My Tickets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== NEW report ===== -->
|
||||||
|
<div t-if="state.tab === 'new'">
|
||||||
<div class="o_fhd_kind_row">
|
<div class="o_fhd_kind_row">
|
||||||
<button type="button"
|
<button type="button" class="o_fhd_kind_chip"
|
||||||
class="o_fhd_kind_chip"
|
|
||||||
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
|
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
|
||||||
t-on-click="() => this.setKind('bug')">
|
t-on-click="() => this.setKind('bug')">
|
||||||
<i class="fa fa-bug me-1"/> Report a Bug
|
<i class="fa fa-bug me-1"/> Report a Bug
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button" class="o_fhd_kind_chip"
|
||||||
class="o_fhd_kind_chip"
|
|
||||||
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
|
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
|
||||||
t-on-click="() => this.setKind('feature')">
|
t-on-click="() => this.setKind('feature')">
|
||||||
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
|
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subject -->
|
|
||||||
<div class="o_fhd_field">
|
<div class="o_fhd_field">
|
||||||
<label>Subject *</label>
|
<label>Subject *</label>
|
||||||
<input type="text" class="form-control"
|
<input type="text" class="form-control"
|
||||||
@@ -29,7 +42,17 @@
|
|||||||
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
|
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<div class="o_fhd_field">
|
||||||
|
<label>
|
||||||
|
Your email
|
||||||
|
<span class="o_fhd_hint">we'll reply here — edit if you'd like replies elsewhere</span>
|
||||||
|
</label>
|
||||||
|
<input type="email" class="form-control"
|
||||||
|
t-att-value="state.replyEmail"
|
||||||
|
t-on-input="(ev) => state.replyEmail = ev.target.value"
|
||||||
|
placeholder="you@example.com"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="o_fhd_field">
|
<div class="o_fhd_field">
|
||||||
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/>
|
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/>
|
||||||
<textarea class="form-control" rows="5"
|
<textarea class="form-control" rows="5"
|
||||||
@@ -38,7 +61,6 @@
|
|||||||
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
|
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error code (bug only) -->
|
|
||||||
<div class="o_fhd_field" t-if="state.kind === 'bug'">
|
<div class="o_fhd_field" t-if="state.kind === 'bug'">
|
||||||
<label>
|
<label>
|
||||||
Error code / traceback
|
Error code / traceback
|
||||||
@@ -50,7 +72,6 @@
|
|||||||
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
|
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attachments -->
|
|
||||||
<div class="o_fhd_field">
|
<div class="o_fhd_field">
|
||||||
<label>Attachments</label>
|
<label>Attachments</label>
|
||||||
<div class="o_fhd_actions_row">
|
<div class="o_fhd_actions_row">
|
||||||
@@ -79,30 +100,129 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Result feedback -->
|
|
||||||
<div t-if="state.error" class="alert alert-danger mt-2">
|
<div t-if="state.error" class="alert alert-danger mt-2">
|
||||||
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
|
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
|
||||||
</div>
|
</div>
|
||||||
<div t-if="state.success" class="alert alert-success mt-2">
|
<div t-if="state.success" class="alert alert-success mt-2">
|
||||||
<i class="fa fa-check-circle me-1"/>
|
<i class="fa fa-check-circle me-1"/>
|
||||||
Thanks — ticket
|
Thanks — ticket
|
||||||
<a t-att-href="state.ticketUrl" target="_blank">
|
<a t-att-href="state.ticketUrl" target="_blank">#<t t-esc="state.ticketId"/></a>
|
||||||
#<t t-esc="state.ticketId"/>
|
created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
|
||||||
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
|
You'll get replies by email, and can follow up under <b>My Tickets</b>.
|
||||||
|
</div>
|
||||||
|
<div t-if="state.success and state.failed" class="alert alert-warning mt-2">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/>
|
||||||
|
<t t-esc="state.failed"/> attachment(s) could not be uploaded.
|
||||||
|
Open the ticket from <b>My Tickets</b> and add them there.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== LIST ===== -->
|
||||||
|
<div t-if="state.tab === 'list'">
|
||||||
|
<div t-if="state.isAdmin" class="o_fhd_scope_row">
|
||||||
|
<button type="button" class="o_fhd_kind_chip"
|
||||||
|
t-att-class="{ 'o_fhd_kind_active': state.scope === 'mine' }"
|
||||||
|
t-on-click="() => this.setScope('mine')">Mine</button>
|
||||||
|
<button type="button" class="o_fhd_kind_chip"
|
||||||
|
t-att-class="{ 'o_fhd_kind_active': state.scope === 'all' }"
|
||||||
|
t-on-click="() => this.setScope('all')">All (deployment)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.loadingList" class="o_fhd_muted text-center p-3">
|
||||||
|
<i class="fa fa-spinner fa-spin me-1"/> Loading your tickets…
|
||||||
|
</div>
|
||||||
|
<div t-elif="state.listError" class="alert alert-danger">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.listError"/>
|
||||||
|
</div>
|
||||||
|
<div t-elif="!state.tickets.length" class="o_fhd_muted text-center p-4">
|
||||||
|
<i class="fa fa-inbox fa-2x d-block mb-2"/>
|
||||||
|
No tickets yet. Use the <b>New</b> tab to report a bug or request a feature.
|
||||||
|
</div>
|
||||||
|
<div t-else="" class="o_fhd_ticket_list">
|
||||||
|
<div t-foreach="state.tickets" t-as="t" t-key="t.id"
|
||||||
|
class="o_fhd_ticket_row" t-on-click="() => this.openTicket(t.id)">
|
||||||
|
<span t-if="t.has_unread" class="o_fhd_unread_dot" title="New reply"/>
|
||||||
|
<span t-else="" class="o_fhd_unread_spacer"/>
|
||||||
|
<span class="o_fhd_ticket_ref" t-esc="'#' + t.ref"/>
|
||||||
|
<span class="o_fhd_ticket_subject" t-esc="t.subject"/>
|
||||||
|
<span class="o_fhd_ticket_stage" t-esc="t.stage"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== THREAD ===== -->
|
||||||
|
<div t-if="state.tab === 'thread'">
|
||||||
|
<div t-if="state.loadingThread" class="o_fhd_muted text-center p-3">
|
||||||
|
<i class="fa fa-spinner fa-spin me-1"/> Loading…
|
||||||
|
</div>
|
||||||
|
<t t-elif="state.current">
|
||||||
|
<div class="o_fhd_thread_head">
|
||||||
|
<span class="o_fhd_ticket_stage" t-esc="state.current.stage"/>
|
||||||
|
<a t-if="state.current.portal_url" class="o_fhd_open_portal"
|
||||||
|
t-att-href="state.current.portal_url" target="_blank">
|
||||||
|
Open full ticket <i class="fa fa-external-link"/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fhd_thread">
|
||||||
|
<div t-if="!state.current.messages.length" class="o_fhd_muted p-2">
|
||||||
|
No messages yet.
|
||||||
|
</div>
|
||||||
|
<div t-foreach="state.current.messages" t-as="m" t-key="m.id"
|
||||||
|
class="o_fhd_msg">
|
||||||
|
<div class="o_fhd_msg_head">
|
||||||
|
<span class="o_fhd_msg_author" t-esc="m.author"/>
|
||||||
|
<span class="o_fhd_msg_date" t-esc="m.date"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fhd_msg_body" t-out="m.body"/>
|
||||||
|
<div t-if="m.attachment_count" class="o_fhd_msg_attach">
|
||||||
|
<i class="fa fa-paperclip me-1"/>
|
||||||
|
<t t-esc="m.attachment_count"/> attachment(s) —
|
||||||
|
open the full ticket to download.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.threadError" class="alert alert-danger mt-2">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.threadError"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fhd_field mt-2">
|
||||||
|
<label>Your reply</label>
|
||||||
|
<textarea class="form-control" rows="3"
|
||||||
|
t-att-value="state.replyBody"
|
||||||
|
t-on-input="(ev) => state.replyBody = ev.target.value"
|
||||||
|
placeholder="Add a follow-up… support will be notified."/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Footer ===== -->
|
||||||
<t t-set-slot="footer">
|
<t t-set-slot="footer">
|
||||||
<button class="btn btn-primary"
|
<t t-if="state.tab === 'new'">
|
||||||
t-on-click="onSubmit"
|
<button class="btn btn-primary" t-on-click="onSubmit"
|
||||||
t-att-disabled="state.submitting or !state.subject.trim()">
|
t-att-disabled="state.submitting or !state.subject.trim()">
|
||||||
<t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t>
|
<t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||||
<t t-else=""><i class="fa fa-paper-plane me-1"/></t>
|
<t t-else=""><i class="fa fa-paper-plane me-1"/></t>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" t-on-click="props.close">
|
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
|
||||||
Close
|
</t>
|
||||||
|
<t t-elif="state.tab === 'thread'">
|
||||||
|
<button class="btn btn-primary" t-on-click="sendReply"
|
||||||
|
t-att-disabled="state.sendingReply or !state.replyBody.trim()">
|
||||||
|
<t t-if="state.sendingReply"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||||
|
<t t-else=""><i class="fa fa-reply me-1"/></t>
|
||||||
|
Send reply
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary" t-on-click="backToList">
|
||||||
|
<i class="fa fa-arrow-left me-1"/> Back
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
|
||||||
|
</t>
|
||||||
</t>
|
</t>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
<div class="o_fhd_systray dropdown">
|
<div class="o_fhd_systray dropdown">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="o_fhd_systray_btn dropdown-toggle"
|
class="o_fhd_systray_btn dropdown-toggle"
|
||||||
title="Report a bug or request a feature"
|
title="Report a bug, request a feature, or follow up on your tickets"
|
||||||
t-on-click="onClick">
|
t-on-click="onClick">
|
||||||
<img src="/fusion_helpdesk/static/description/help_icon.png"
|
<img src="/fusion_helpdesk/static/description/help_icon.png"
|
||||||
alt="Help"
|
alt="Help"
|
||||||
class="o_fhd_systray_img"/>
|
class="o_fhd_systray_img"/>
|
||||||
|
<span t-if="state.unread > 0"
|
||||||
|
class="o_fhd_systray_badge"
|
||||||
|
t-esc="state.unread > 99 ? '99+' : state.unread"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
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})
|
||||||
105
fusion_helpdesk/tests/test_utils.py
Normal file
105
fusion_helpdesk/tests/test_utils.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# -*- 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], '')
|
||||||
|
|
||||||
|
def test_wildcard_email_cannot_widen_scope(self):
|
||||||
|
# IDOR guard: a self-set email of '%' must NOT become a match-all
|
||||||
|
# =ilike term — the wildcard has to be escaped to a literal.
|
||||||
|
dom = build_scope_domain(label='ENTECH', email='%', is_admin=False)
|
||||||
|
email_terms = [t for t in dom if t[0] == 'partner_email']
|
||||||
|
self.assertEqual(len(email_terms), 1)
|
||||||
|
self.assertEqual(email_terms[0][2], '\\%',
|
||||||
|
"'%' must be escaped so ILIKE matches it literally")
|
||||||
|
|
||||||
|
def test_underscore_in_real_email_is_escaped_but_preserved(self):
|
||||||
|
dom = build_scope_domain(label='ENTECH', email='john_doe@x.com', is_admin=False)
|
||||||
|
email_terms = [t for t in dom if t[0] == 'partner_email']
|
||||||
|
self.assertEqual(email_terms[0][2], 'john\\_doe@x.com')
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
98
fusion_helpdesk/utils.py
Normal file
98
fusion_helpdesk/utils.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# -*- 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 escape_like(value):
|
||||||
|
"""Escape SQL LIKE/ILIKE wildcards so a user-supplied value can never
|
||||||
|
widen an `=ilike` match to other rows.
|
||||||
|
|
||||||
|
`res.users.email` is self-writeable and unvalidated, so without this a
|
||||||
|
user could set their email to ``%`` and have ``partner_email =ilike '%'``
|
||||||
|
match EVERY ticket in their deployment (a cross-user IDOR). Escaping the
|
||||||
|
backslash first, then ``%`` and ``_``, makes those characters match
|
||||||
|
literally. Real emails containing ``_`` (e.g. ``john_doe@x.com``) keep
|
||||||
|
working — the underscore is matched as a literal, which is what we want.
|
||||||
|
"""
|
||||||
|
return (value or '').replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
safe_email = escape_like(email)
|
||||||
|
domain.append(('partner_email', '=ilike', safe_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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1
|
# License OPL-1
|
||||||
{
|
{
|
||||||
'name': 'Fusion Helpdesk Central — Client API Keys',
|
'name': 'Fusion Helpdesk Central — Client API Keys',
|
||||||
'version': '19.0.1.0.2',
|
'version': '19.0.1.1.0',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
||||||
'keys used by fusion_helpdesk client deployments.',
|
'keys used by fusion_helpdesk client deployments.',
|
||||||
@@ -28,7 +28,9 @@ Depends only on `helpdesk`. No client-side install needed.
|
|||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/ir_config_parameter_data.xml',
|
'data/ir_config_parameter_data.xml',
|
||||||
|
'data/mail_template_ack.xml',
|
||||||
'views/fusion_helpdesk_client_key_views.xml',
|
'views/fusion_helpdesk_client_key_views.xml',
|
||||||
|
'views/helpdesk_ticket_views.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
55
fusion_helpdesk_central/data/mail_template_ack.xml
Normal file
55
fusion_helpdesk_central/data/mail_template_ack.xml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1
|
||||||
|
|
||||||
|
Branded acknowledgement sent to the customer when an in-app-channel
|
||||||
|
ticket is created. Carries the portal magic link (object.get_portal_url()
|
||||||
|
embeds the access token) so the customer can track + reply without an
|
||||||
|
account. Button colours follow the company email branding, like Odoo's
|
||||||
|
own helpdesk templates.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="mail_template_ticket_ack" model="mail.template">
|
||||||
|
<field name="name">Helpdesk: Ticket Acknowledgement (Fusion)</field>
|
||||||
|
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
|
||||||
|
<field name="subject">We received your request [{{ object.ticket_ref or object.id }}]</field>
|
||||||
|
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||||
|
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<div style="margin:0; padding:0; font-family:Arial, Helvetica, sans-serif; color:#21252b; font-size:14px;">
|
||||||
|
<p>Hello <t t-out="object.partner_name or 'there'"/>,</p>
|
||||||
|
<p>
|
||||||
|
Thanks for reaching out — we've received your request and our
|
||||||
|
support team will be in touch. Here are the details:
|
||||||
|
</p>
|
||||||
|
<table style="margin:12px 0; font-size:14px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:2px 12px 2px 0; color:#6c757d;">Reference</td>
|
||||||
|
<td style="padding:2px 0;"><strong t-out="object.ticket_ref or object.id"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:2px 12px 2px 0; color:#6c757d;">Subject</td>
|
||||||
|
<td style="padding:2px 0;"><t t-out="object.name or ''"/></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:18px 0;">
|
||||||
|
<a t-att-href="object.get_base_url() + object.get_portal_url()"
|
||||||
|
target="_blank"
|
||||||
|
t-attf-style="background-color: {{ object.company_id.email_secondary_color or '#2c89e9' }}; padding:10px 18px; text-decoration:none; color: {{ object.company_id.email_primary_color or '#ffffff' }}; border-radius:5px; font-size:14px; display:inline-block;">
|
||||||
|
View & track your ticket
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#6c757d; font-size:13px;">
|
||||||
|
You can reply directly to this email to add information, follow up
|
||||||
|
from the link above, or sign up for an account from that page to
|
||||||
|
manage all of your requests in one place.
|
||||||
|
</p>
|
||||||
|
<p style="color:#6c757d; font-size:13px;">— <t t-out="object.company_id.name or 'Support'"/></p>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import fusion_helpdesk_client_key
|
from . import fusion_helpdesk_client_key
|
||||||
|
from . import helpdesk_ticket
|
||||||
|
|||||||
57
fusion_helpdesk_central/models/helpdesk_ticket.py
Normal file
57
fusion_helpdesk_central/models/helpdesk_ticket.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Central-side helpdesk.ticket extensions for the customer follow-up flow.
|
||||||
|
|
||||||
|
Adds the `x_fc_client_label` deployment tag (set by the in-app reporter so
|
||||||
|
the embedded inbox can scope per client) and sends a branded acknowledgement
|
||||||
|
email — carrying the portal magic link — when an in-app ticket is created.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HelpdeskTicket(models.Model):
|
||||||
|
_inherit = 'helpdesk.ticket'
|
||||||
|
|
||||||
|
x_fc_client_label = fields.Char(
|
||||||
|
string='Client Deployment', index=True, copy=False,
|
||||||
|
help='Deployment tag (e.g. ENTECH) set by the fusion_helpdesk in-app '
|
||||||
|
'reporter. Scopes the embedded "My Tickets" inbox per client and '
|
||||||
|
'lets support filter tickets by originating deployment.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
tickets = super().create(vals_list)
|
||||||
|
tickets._fc_send_ack_email()
|
||||||
|
return tickets
|
||||||
|
|
||||||
|
def _fc_send_ack_email(self):
|
||||||
|
"""Send the branded acknowledgement (with magic link) to the customer.
|
||||||
|
|
||||||
|
Only fires for in-app-channel tickets (those tagged with a client
|
||||||
|
label) that have a customer email — external web-form submissions
|
||||||
|
rely on the native website confirmation, so this won't double-send.
|
||||||
|
The whole thing is best-effort: a template/mail failure must never
|
||||||
|
block ticket creation, so we log and move on.
|
||||||
|
"""
|
||||||
|
template = self.env.ref(
|
||||||
|
'fusion_helpdesk_central.mail_template_ticket_ack',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not template:
|
||||||
|
return
|
||||||
|
for ticket in self:
|
||||||
|
if not (ticket.x_fc_client_label and ticket.partner_email):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
template.send_mail(ticket.id, force_send=False)
|
||||||
|
except Exception: # noqa: BLE001 — ack must never block create
|
||||||
|
_logger.exception(
|
||||||
|
'fusion_helpdesk_central: acknowledgement email failed '
|
||||||
|
'for ticket %s (%s)', ticket.id, ticket.x_fc_client_label,
|
||||||
|
)
|
||||||
2
fusion_helpdesk_central/tests/__init__.py
Normal file
2
fusion_helpdesk_central/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import test_identity
|
||||||
68
fusion_helpdesk_central/tests/test_identity.py
Normal file
68
fusion_helpdesk_central/tests/test_identity.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Identity-keystone tests for the central helpdesk extensions.
|
||||||
|
|
||||||
|
Runs on an Enterprise environment (helpdesk installed) — e.g. odoo-nexa or
|
||||||
|
odoo-trial. Validates that passing partner_email resolves the customer +
|
||||||
|
follower (native), that the client label is stored, and that the branded
|
||||||
|
acknowledgement only fires for in-app-channel tickets.
|
||||||
|
"""
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
||||||
|
class TestTicketIdentity(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.team = cls.env['helpdesk.team'].search([], limit=1)
|
||||||
|
|
||||||
|
def _ack_mails(self, ticket):
|
||||||
|
return self.env['mail.mail'].search([
|
||||||
|
('model', '=', 'helpdesk.ticket'),
|
||||||
|
('res_id', '=', ticket.id),
|
||||||
|
]).filtered(lambda m: 'received your request' in (m.subject or ''))
|
||||||
|
|
||||||
|
def test_partner_resolution_follower_and_label(self):
|
||||||
|
ticket = self.env['helpdesk.ticket'].create({
|
||||||
|
'name': 'Keystone test',
|
||||||
|
'team_id': self.team.id,
|
||||||
|
'partner_email': 'keystone.newperson@example.com',
|
||||||
|
'partner_name': 'Key Stone',
|
||||||
|
'x_fc_client_label': 'ENTECH',
|
||||||
|
})
|
||||||
|
self.assertEqual(ticket.x_fc_client_label, 'ENTECH')
|
||||||
|
self.assertTrue(
|
||||||
|
ticket.partner_id,
|
||||||
|
"native create() should find-or-create a partner from partner_email")
|
||||||
|
self.assertEqual(ticket.partner_id.email, 'keystone.newperson@example.com')
|
||||||
|
self.assertIn(
|
||||||
|
ticket.partner_id, ticket.message_partner_ids,
|
||||||
|
"the customer must be subscribed as a follower")
|
||||||
|
|
||||||
|
def test_ack_email_for_inapp_ticket(self):
|
||||||
|
ticket = self.env['helpdesk.ticket'].create({
|
||||||
|
'name': 'Ack test',
|
||||||
|
'team_id': self.team.id,
|
||||||
|
'partner_email': 'ack.person@example.com',
|
||||||
|
'partner_name': 'Ack Person',
|
||||||
|
'x_fc_client_label': 'ENTECH',
|
||||||
|
})
|
||||||
|
self.assertTrue(
|
||||||
|
self._ack_mails(ticket),
|
||||||
|
"an in-app ticket with a customer email should get our ack email")
|
||||||
|
|
||||||
|
def test_no_ack_without_client_label(self):
|
||||||
|
# Simulates an external web-form ticket — no client label, so our
|
||||||
|
# acknowledgement must NOT fire (avoids double-acknowledgement).
|
||||||
|
ticket = self.env['helpdesk.ticket'].create({
|
||||||
|
'name': 'External web ticket',
|
||||||
|
'team_id': self.team.id,
|
||||||
|
'partner_email': 'external.web@example.com',
|
||||||
|
'partner_name': 'External Web',
|
||||||
|
})
|
||||||
|
self.assertFalse(
|
||||||
|
self._ack_mails(ticket),
|
||||||
|
"no client label => our acknowledgement should not send")
|
||||||
34
fusion_helpdesk_central/views/helpdesk_ticket_views.xml
Normal file
34
fusion_helpdesk_central/views/helpdesk_ticket_views.xml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1
|
||||||
|
Surface the client-deployment tag in the agent backend so support can
|
||||||
|
see + filter tickets by the deployment they came from.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="fhc_ticket_list_label" model="ir.ui.view">
|
||||||
|
<field name="name">fhc.helpdesk.ticket.list.client_label</field>
|
||||||
|
<field name="model">helpdesk.ticket</field>
|
||||||
|
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//list" position="inside">
|
||||||
|
<field name="x_fc_client_label" optional="show"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fhc_ticket_search_label" model="ir.ui.view">
|
||||||
|
<field name="name">fhc.helpdesk.ticket.search.client_label</field>
|
||||||
|
<field name="model">helpdesk.ticket</field>
|
||||||
|
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_search"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//search" position="inside">
|
||||||
|
<field name="x_fc_client_label"/>
|
||||||
|
<filter string="Client Deployment" name="group_client_label"
|
||||||
|
context="{'group_by': 'x_fc_client_label'}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user