This commit is contained in:
gsinghpal
2026-05-27 10:36:37 -04:00
72 changed files with 10278 additions and 132 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
# Editor / OS noise
.DS_Store
*.swp
*.swo
.vscode/
.idea/
# Odoo runtime
*.pyc-tmp
# Local-only diagnostic logs from test runs
_test_*.log

View File

@@ -12,9 +12,26 @@
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
6. **res.groups**: NO `users` field, NO `category_id` field.
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
7. **Search views**: NO `group expand="0"` syntax.
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
9. **SQL constraints & indexes**: Odoo 19 dropped `_sql_constraints = [(name, def, msg), ...]` and the `init()`/raw-SQL pattern. Both still parse but only emit a warning and are silently ignored. Use declarative class attributes instead:
```python
_check_qty_positive = models.Constraint('CHECK (qty > 0)', 'Quantity must be positive.')
_user_time_idx = models.Index('(user_id, event_time DESC)')
```
The attribute name after the leading underscore becomes the SQL object name suffix (`{table}_{suffix}`). `models.Index` accepts `DESC`, `WHERE` predicates, and `USING btree (...)`. Sources: `odoo/orm/model_classes.py` (warns at registry build), `odoo/orm/table_objects.py` (Constraint + Index classes).
10. **`res.users._login` is an instance method in Odoo 19**, not a classmethod as in earlier versions. Signature is `def _login(self, credential, user_agent_env)` — there is no `db` parameter. Override it like any normal instance method (`super()._login(credential, user_agent_env)`). When called via `authenticate()` on an empty recordset, `self` carries the right env. Older recipes that build a separate `api.Environment` from `odoo.modules.registry.Registry(db)` no longer apply. Source: `odoo/addons/base/models/res_users.py:760`.
11. **Inherited `ir.ui.view` records cannot have `groups`/`group_ids` on the record itself.** Odoo 19 raises `ParseError: Inherited view cannot have 'groups' defined on the record. Use 'groups' attributes inside the view definition` at install time. Move the gate to the inner XML nodes — every `<button>`, `<page>`, `<field>`, `<xpath>`, `<group>` etc. supports a `groups="base.group_system"` attribute. For an inherited form with a smart button + admin tab, put `groups=` on the button and the page individually; leave the `<record model="ir.ui.view">` clean.
12. **`mail.template` QWeb/inline_template `ctx` IS `self.env.context`** — not a nested dict you can pass. `MailRenderMixin._render_eval_context()` sets `ctx = self.env.context`, so `ctx.get('foo')` in subject/body resolves to `env.context.get('foo')`. To pass dynamic data to a template, spread keys directly into the context: `tmpl.with_context(**my_data).send_mail(res_id, ...)`. Calling `tmpl.with_context(ctx=my_data)` puts the dict at `env.context['ctx']`, and the template's `ctx.get('foo')` becomes `env.context.get('foo')` → `None` (looks like a silent rendering bug — subject ends up blank).
13. **`ir.cron` dropped `numbercall`** in Odoo 19. Old recipes set `<field name="numbercall">-1</field>` for "run forever"; that now raises `ValueError: Invalid field 'numbercall' in 'ir.cron'` at install time. Just omit the field — recurring crons keep running as long as `active=True`. Source: `odoo/addons/base/models/ir_cron.py` field list.
14. **`cr.commit()` / `cr.rollback()` raise AssertionError inside `TransactionCase`** — they are NOT silent no-ops in Odoo 19. The test cursor explicitly refuses both ("Cannot commit or rollback a cursor from inside a test, this will lead to a broken cursor when trying to rollback the test. Please rollback to a specific savepoint instead..."). For cron/worker code that needs per-row isolation so one bad row doesn't roll back the whole batch, use `with self.env.cr.savepoint(): ...` inside the loop instead of `cr.commit()`. Savepoints work in both prod (under the outer cron transaction) and tests (under the outer test transaction). The cron transaction commits the whole batch when the method returns; in tests everything rolls back cleanly. Source: `odoo/sql_db.py::TestCursor.commit` and `Cursor.savepoint()`.
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
## Card Styling — Copy Odoo's Kanban Pattern
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
@@ -79,8 +96,15 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
## Workflow
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
- Local URL: http://localhost:8069
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
- Local URL: http://localhost:8082
- **Running module tests requires ephemeral ports.** The dev container's main Odoo process holds 8069 and 8072; a `docker exec ... odoo --test-enable` will die with `Address already in use` unless you also pass `--http-port=0 --gevent-port=0`. This is because Odoo 19 forces `http_spawn()` when `--test-enable` is set, even when `--no-http` is passed. Canonical test invocation:
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /<module> \
-u <module> --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
- **`fusion_centralize_billing` tests run on odoo-trial (VM 316).** Local dev is Community and cannot install this module. Use `bash scripts/fcb_test_on_trial.sh` from the repo root. The script uses `--http-port 8070` to avoid the port 8069 conflict with the live odoo-trial-app container. Pass = `FCB_EXIT=0`. Takes ~1-2 min.
- **Python deps not bundled with `odoo:19` image:** `user_agents` (used by `fusion_login_audit`), and likely others. Install ephemerally with `docker exec -u 0 odoo-modsdev-app pip install <pkg> --break-system-packages`. The install is LOST when the container is recreated (e.g. `docker compose up -d` after a compose edit). When this happens, the symptom is `ModuleNotFoundError` deep in the auth or report code. Re-run the pip install. A persistent fix would be a custom Dockerfile or a startup hook on the compose service — not done yet.
- Test before deploying. Edit existing files — don't create unnecessary new ones.
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
@@ -110,3 +134,67 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
- `fusionapps.issues` — known issues and fixes
- `fusionapps.code_snippets` — reference code
- `fusionapps.quick_commands` — deployment and admin commands
## Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
and **`fusion_helpdesk_central`** (runs on the central Odoo = nexa). The client forwards
tickets to central over **XML-RPC**; central find-or-creates the customer partner +
follower; the client shows a server-side-scoped "My Tickets" inbox + systray unread badge.
### Where each runs / how to deploy
- **Central = nexa** (`erp.nexasystems.ca`, VM 315 on pve-worker1, Docker, DB `nexamain`).
Source on host: `/opt/odoo/custom-addons/fusion_helpdesk_central`. Upgrade (brief downtime):
```bash
ssh pve-worker1 "qm guest exec 315 --timeout 590 -- bash -c 'docker stop odoo-nexa-app; docker run --rm --network odoo_odoo-network -v odoo_odoo-data:/var/lib/odoo -v /opt/odoo/custom-addons:/mnt/extra-addons -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons -v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf odoo-nexa:19 odoo -d nexamain -u fusion_helpdesk_central --stop-after-init --http-port=0 --gevent-port=0 > /tmp/up.log 2>&1; docker start odoo-nexa-app'"
```
Use `;` (not `&&`) before `docker start` so the app ALWAYS restarts even if the upgrade
fails. nexa `odoo.conf` has `log_level=warn`, so test/INFO lines are suppressed — verify
the result via DB query, not the upgrade log.
- **Client = entech** (LXC 111 on pve-worker5, **native systemd `odoo.service`**, DB `admin`,
config `/etc/odoo/odoo.conf`, source `/mnt/extra-addons/custom/fusion_helpdesk`). No host
bind mount — get files in with `scp` to pve-worker5 then `pct push 111 <file> <dest>`.
Upgrade as the `odoo` user (NOT root):
```bash
pct exec 111 -- bash -lc "systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo"
```
**Backup dir MUST live OUTSIDE the addons path** (e.g. `/root/`). A dir named `*.bak.*`
*inside* `/mnt/extra-addons/custom` makes Odoo try to load it as a module →
`FileNotFoundError: Invalid module name: fusion_helpdesk.bak.predeploy` → whole registry
load fails. (Learned the hard way; auto-rollback restored it.) Current rollback copy:
`/root/fh_bak_predeploy`.
### REQUIRED prerequisite on the central service account (easy to miss)
The keystone passes `partner_email`, so central find-or-creates the partner. The XML-RPC
service account (**`support@nexasystems.ca`, uid 33** on nexa) MUST have the **Contact
Creation** group (`base.group_partner_manager`). Without it, `helpdesk.ticket.create`
faults with *"not allowed to create 'Contact' (res.partner)"* for any reporter who isn't
already a contact. Granted on nexa 2026-05-27. **Every new client deployment needs this
grant on the central account.**
### Testing lesson
Client logic (scope domain, seen model, vals, `_norm_email`) is unit-tested in
`fusion_helpdesk/tests/` and runs on local Community (`-d modsdev`). **Smoke tests must
call the controller endpoints, not re-implement their logic** — the Phase 6 smoke test
replicated `build_scope_domain` directly and so missed a `NameError` (`_norm_email`
referenced but never imported) that broke every inbox endpoint. Run
`docker exec odoo-modsdev-app python3 -m pyflakes <file>` after editing controllers — it
catches undefined names instantly.
### STATUS (handoff 2026-05-27 — continuing from office)
- **Merged to `main`** as squash commit `6c15a7b1`, pushed to GitHub + Gitea. Feature
branch `feat/helpdesk-customer-followup` deleted (local + remote). Pull `main` at the
office to get the latest.
- **Deployed live**: nexa `fusion_helpdesk_central` **19.0.1.1.0**; entech `fusion_helpdesk`
**19.0.1.4.1**. Both services healthy.
- **Smoke-tested live end-to-end** (entech→nexa): partner resolved + follower + `ENTECH`
label, branded ack email queued, support reply visible in thread, inbox scope finds own
ticket, no cross-deployment leak.
- **Bug fixed this session**: `_norm_email` NameError that broke "My Tickets" AND the "New"
ticket submit path — `_norm_email` added to `fusion_helpdesk/utils.py`, imported in the
controller, regression test added, deployed to entech.
- **ONLY thing left = browser confirmation.** Hard-refresh entech (DevTools → Empty Cache
and Hard Reload), open the systray helpdesk dialog: **My Tickets** should load, and the
**New** tab should file a ticket. Both share the now-fixed `_identity()` path, and the
systray unread badge calls it too. If anything still errors, the traceback is in
`/var/log/odoo/odoo-server.log` on entech (LXC 111 / pve-worker5).

View File

@@ -0,0 +1,80 @@
# fusion_centralize_billing — Session Handoff (2026-05-27)
Resume point for the centralized-billing initiative. Read this first, then continue
from **"Decision pending"** below.
## Where we are
- **Sub-project #1 (core billing engine): DONE and on `main`** (tip `d770c0c3`, pushed to
GitHub + Gitea).
- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
(cross-billing cron → match by `plan_id`; `/usage` authz vs IDOR; input validation →
4xx not 500; correct billing-period window; idempotency scoped to `(sub, metric, key)`;
webhook sign-exact-bytes + event-id + SSRF guard).
- **39 tests green on Odoo 19 Enterprise.**
- Note: the 14 billing commits were rebased off the old login-audit/helpdesk stack and
landed cleanly on `main`.
- **`fusion_login_audit`: also landed on `main`** (2026-05-27). Its 19 commits were rebased
onto `main` and the `feat/fusion-login-audit` branch was deleted. This also restored
Odoo-19 rules #914 in `CLAUDE.md`, which had gone missing on `main` when billing landed
alone (they were authored alongside login_audit and never existed on the old base).
- A concurrent `feat/helpdesk-customer-followup` session still carries pre-landing copies
of the billing + login_audit commits; when it merges, replay its helpdesk-only commits
onto `main`.
- **Reference docs (on `main`):**
- Spec: `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`
- Core plan: `docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md`
## Next: sub-project #2 — NexaCloud adapter + dual-run reconciliation
Per spec §12, each sub-project is its own spec → plan → build cycle. #2 decomposes into
four chunks (dependency order):
| Chunk | What | Risk |
|-------|------|------|
| **2a — Mapping + importer** | Read `nexacloud` DB → create `res.partner` + `account.link`, `product.template` + subscription plans, one subscription `sale.order` per deployment | **Low** — read-only on NexaCloud, writes only into Odoo |
| **2b — Usage metering wiring** | NexaCloud `usage_metering.py` pushes CPU-seconds → Odoo `/usage`; verify aggregation → draft invoice w/ quota + overage + HST | Edits NexaCloud code |
| **2c — Control loop** | NexaCloud consumes Odoo's outbound webhooks (`invoice.payment_failed` → suspend via existing `network_isolation`/`throttle_checker`; `subscription.terminated` → deprovision) | Edits NexaCloud code |
| **2d — Dual-run reconciliation** | `fusion.billing.reconciliation` diffs Odoo-computed vs NexaCloud-actual per customer/period for ≥ 1 cycle before any flip | Safety gate before flipping real billing |
The core engine already built the *receiving* side (`/usage`, webhook engine, charge math).
#2 is about **connecting NexaCloud to it and proving the numbers match before flipping.**
## Decision pending (resume here)
We were in the `superpowers:brainstorming` flow for #2 and stopped at: **which slice to
start with?**
- **(recommended) 2a — Mapping + importer** — lowest risk, foundation for everything else.
- 2d — Reconciliation first (front-load the trust mechanism).
- Full #2 design as one spec, then one plan.
- Just write the #2 plan, no code this session.
## Open questions to resolve before building #2
- **Spec §15 Q2 — NexaCloud billing granularity:** confirm **one subscription per
deployment** (spec leans this way) vs one subscription per customer with deployment line
items.
- **Access / environments needed:**
- Read access to the `nexacloud` DB schema (LXC 102 / its Postgres on LXC 201) to design
the importer mapping.
- A NexaCloud staging or safe path for 2b/2c (they edit live NexaCloud code).
- Test target for the Odoo side stays the odoo-trial Enterprise sandbox.
- **Resolved already:** Stripe is one account (`acct_1ShlA9IkwUB1dVox`) for everything — no
account migration (spec §11 / §15 Q1). Branch strategy — land on `main`, branch new work
off `main`.
## How to run / test
- **Billing tests:** `bash scripts/fcb_test_on_trial.sh` from repo root → pass = `FCB_EXIT=0`
(~12 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db
`trial`) and runs `--test-enable`. Local dev Odoo is Community and **cannot** install this
module.
## Branch hygiene (lesson from this session)
Cut each new feature branch from `main`, and land it before starting the next. For any
cross-branch git surgery, use a **throwaway `git worktree`** — never switch the shared
working dir's branch, because a concurrent session may be working on it.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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. ✓

View File

@@ -0,0 +1,444 @@
# Fusion Login Audit — Design Spec
**Status:** Approved, ready for implementation planning
**Date:** 2026-05-26
**Author:** Brainstormed with the user (Gurpreet) for the Westin Healthcare Odoo 19 deployment
**Target module path:** `K:\Github\Odoo-Modules\fusion_login_audit\`
**Production deploy target:** `/opt/odoo/custom-addons/fusion_login_audit/` on `odoo-westin` (VM 101, worker1, 192.168.1.40)
**Production DB:** `westin-v19` (Odoo 19, PostgreSQL)
## Background and motivation
A spot audit of user `info@gsafinancialconsulting.com` ("GSA Accounting", uid 63) revealed Odoo's built-in login tracking is effectively unusable for compliance:
- `res.users.log` rows are pruned by the daily `_gc_user_logs` cron — only the most recent login per user survives. For GSA Accounting the entire history collapsed to a single row at `2026-04-22 20:24 EDT`.
- `/var/log/odoo` on the production VM is empty because Odoo is configured at `log_level=warn` with stdout-only logging; INFO-level auth lines aren't captured anywhere.
- The container's json log is 444 KB and rotates frequently — nothing about the user remains.
- The existing `network_logger` module records outbound HTTP traffic from Odoo (uid=1 always), not user activity.
Result: today there is **no durable record** of who logged in, when, from where, or how often. A user with `base.group_system` + Technical Features and no 2FA — like GSA Accounting — could be active for months without any reconstructable trail.
This module closes that gap with a dedicated audit table that survives Odoo's GC, captures successful and failed authentications, surfaces results in the user form, and alerts admins on suspicious failure bursts.
## Goals
1. **Durable audit trail** of every password-authenticated login (success and failure) on `westin-v19`.
2. **Per-user visibility** for Settings admins via a tab + smart button on `res.users`.
3. **Failure-burst alerting** to admins on a configurable consecutive-failure threshold.
4. **Geo-enrichment** of IPs out-of-band so authentication latency is unaffected.
5. **Zero risk to the auth path** — an audit-write failure must never block a real login.
## Non-goals (v1)
- Logging every HTTP request / page view (explicitly de-scoped during brainstorming).
- Logging session resume events from auth cookies.
- API-key authentication (`credential['type'] == 'apikey'`) — bypasses `_check_credentials`. Documented as a known gap; addressable in a follow-up.
- OAuth / SSO logins — no OAuth provider configured on westin-v19.
- Self-service "view my own login activity" for end users — visibility is admin-only.
- Auto-disabling users on failed logins — flagged as a self-service DoS vector during brainstorming.
## Architecture overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ Odoo authentication path │
│ │
│ /web/login → res.users._login() → res.users._check_credentials() │
│ ↓ │
│ (on success) │
│ ↓ │
│ res.users._update_last_login() │
│ ↓ │
│ ┌────────────────────┴────────────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ fusion.login.audit (sudo create) Odoo's existing res_users_log │
│ result='success' + IP + UA │
│ │
│ (on AccessDenied) │
│ ↓ │
│ fusion.login.audit (sudo create) │
│ result='failure' + failure_reason + attempted_login │
│ ↓ │
│ _fc_recent_failure_count() >= threshold? │
│ ↓ yes │
│ _fc_send_failure_alert() → mail.mail to base.group_system │
└──────────────────────────────────┬──────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
cron: cron_geo_enrich cron: cron_retention_gc UI surfaces:
every 5 min daily 03:00 UTC - smart button on res.users
- reverse DNS - delete rows older than - "Login Activity" tab
- ip-api.com lookup x_fc_login_audit_ - Settings → Technical →
- 30-day local cache retention_days Login Audit menus
- Settings page section
```
The auth-path hooks are synchronous (must run inside the request). Geolocation, alerting, and retention are out-of-band so they cannot affect login latency.
## Module skeleton
```
fusion_login_audit/
├── __manifest__.py
├── __init__.py
├── models/
│ ├── __init__.py
│ ├── res_users.py # extends res.users with capture hooks + computed fields + smart-button action
│ ├── fusion_login_audit.py # the new audit record model
│ └── res_config_settings.py # alert threshold + window + retention settings
├── data/
│ ├── ir_cron_data.xml # cron_geo_enrich + cron_retention_gc
│ └── mail_template_data.xml # failed-login alert template
├── security/
│ ├── security.xml # record rule: read for base.group_system only
│ └── ir.model.access.csv
├── views/
│ ├── fusion_login_audit_views.xml # list / form / kanban / search
│ ├── res_users_views.xml # tab + smart button
│ ├── res_config_settings_views.xml # Settings section
│ └── menus.xml # Settings → Technical → Login Audit
├── tests/
│ ├── __init__.py
│ ├── test_login_audit.py
│ └── test_security.py
└── static/
└── description/
└── icon.png # copied from C:\Users\gsing\Downloads\fusion logs.png
```
**Manifest highlights**
- `version='19.0.1.0.0'` (project naming convention)
- `license='OPL-1'` (matches `fusion_accounts`)
- `depends=['base', 'mail']`
- `category='Tools'`
- `application=False` (it's a technical addon, not a top-level app)
**Dependencies (Python):** none new. Uses the `user_agents` library already shipped with Odoo. Geolocation calls `http://ip-api.com/json/<ip>` via the standard `requests` library (no API key required, 45 req/min free tier).
**Field naming:** new fields on existing models (`res.users`, `res.config.settings`) use the `x_fc_*` prefix per project CLAUDE.md. The new `fusion.login.audit` model uses unprefixed field names.
## Data model
### `fusion.login.audit` (new model, table `fusion_login_audit`)
| Field | Type | Notes |
|---|---|---|
| `user_id` | Many2one(`res.users`, `ondelete='set null'`) | Null if attempted login didn't match any user |
| `attempted_login` | Char(255), indexed | Always set — even on unknown-user failures |
| `result` | Selection(`success`, `failure`) | Indexed |
| `failure_reason` | Selection(`bad_password`, `unknown_user`, `disabled_user`, `2fa_failed`, `other`) | Null on success |
| `event_time` | Datetime, indexed, default `fields.Datetime.now()` | UTC; displayed in user TZ via standard widget |
| `ip_address` | Char(45) | IPv6-safe length |
| `ip_hostname` | Char(255) | Reverse DNS, populated by geo cron |
| `country_code` | Char(2), indexed | ISO-3166-1 alpha-2; null until cron runs |
| `country_name` | Char(64) | |
| `city` | Char(128) | |
| `geo_state` | Char(64) | Region/state name |
| `geo_lookup_state` | Selection(`pending`, `done`, `private_ip`, `internal`, `failed`) | Drives the geo cron worklist; `internal` = no HTTP request was attached |
| `user_agent_raw` | Char(512) | The full UA header |
| `browser` | Char(64) | e.g. "Chrome 140" — parsed |
| `os` | Char(64) | e.g. "Windows 11" — parsed |
| `device_type` | Selection(`desktop`, `mobile`, `tablet`, `bot`, `unknown`) | From `user_agents` |
| `database` | Char(64) | Multi-DB safety — which DB was logged into |
**Indexes (in addition to the column-level `indexed=True`):**
- `(user_id, event_time DESC)` — per-user history
- `(attempted_login, event_time DESC)` — failure-burst detection by login string
- `(geo_lookup_state, event_time)` — cron worklist
**No `_inherit = ['mail.thread']`** — audit rows are append-only and should not have chatter.
### `res.users` additions (per CLAUDE.md `x_fc_*` convention)
| Field | Type | Notes |
|---|---|---|
| `x_fc_login_audit_ids` | One2many(`fusion.login.audit`, `user_id`) | Backs the tab + smart-button count |
| `x_fc_login_audit_count` | Integer, compute, store=False | Smart-button label |
| `x_fc_last_successful_login` | Datetime, compute, store=True | Indexed; cheap "last seen" lookup |
| `x_fc_last_login_ip` | Char(45), compute, store=True | Surfaces last source IP in the form header |
The `store=True` computes are triggered by the create on `fusion.login.audit` (via `@api.depends('x_fc_login_audit_ids.event_time', 'x_fc_login_audit_ids.result')`).
### `res.config.settings` additions
Booleans / integers only (per CLAUDE.md — no Date fields on settings):
| Field | Default | Notes |
|---|---|---|
| `x_fc_login_audit_retention_days` | 365 | Retention GC cron honors this; 0 = keep forever |
| `x_fc_login_audit_alert_threshold` | 5 | Consecutive failures before alert |
| `x_fc_login_audit_alert_window_min` | 15 | Time window in minutes for "consecutive" |
| `x_fc_login_audit_alert_enabled` | True | Master kill-switch for alert emails |
Each is backed by an `ir.config_parameter` (`fusion_login_audit.retention_days`, etc.) so changes from the Settings page persist.
### Multi-company
`fusion.login.audit` is intentionally **company-agnostic**. Logins happen before any company context is established; synthesizing one would either break the unknown-user case or require a "system company" placeholder. Settings admins see all rows globally.
## Capture flow
### Successful login (`_update_last_login`)
```python
def _update_last_login(self):
result = super()._update_last_login()
try:
self._fc_record_login_event(result='success')
except Exception:
_logger.exception("fusion_login_audit: failed to record success row for %s", self.login)
return result
```
Called by Odoo only after the credential check has passed. Super() runs first so Odoo's own bookkeeping is unaffected.
### Failed login on known user (`_check_credentials`)
```python
def _check_credentials(self, credential, env):
try:
return super()._check_credentials(credential, env)
except AccessDenied:
try:
self._fc_record_login_failure(credential, reason='bad_password')
if self._fc_recent_failure_count(credential) >= self._fc_alert_threshold():
self._fc_send_failure_alert(credential)
except Exception:
_logger.exception("fusion_login_audit: failed to record/alert failure")
raise
```
TOTP failures (from `auth_totp`) also raise `AccessDenied` and are caught here. Distinguish via `credential.get('type') == 'totp'` to set `failure_reason='2fa_failed'`.
### Failed login on unknown user (`_login` classmethod)
```python
@classmethod
def _login(cls, db, credential, user_agent_env):
try:
return super()._login(db, credential, user_agent_env)
except AccessDenied:
try:
cls._fc_record_unknown_user_failure(db, credential, user_agent_env)
except Exception:
_logger.exception("fusion_login_audit: failed to record unknown-user failure")
raise
```
Without this override, unknown-user attempts never reach `_check_credentials` and would silently disappear from the audit. The classmethod sets `user_id=None` and stores the attempted login string.
### Context extraction (`_fc_build_event_vals`)
Single helper shared by all three paths:
```python
def _fc_build_event_vals(self, result, attempted_login, failure_reason=None):
from odoo.http import request
vals = {
'attempted_login': attempted_login,
'result': result,
'failure_reason': failure_reason,
'event_time': fields.Datetime.now(),
'database': self.env.cr.dbname,
'geo_lookup_state': 'pending',
}
if request and request.httprequest:
vals['ip_address'] = request.httprequest.remote_addr # respects proxy_mode
ua_str = request.httprequest.user_agent.string or ''
vals['user_agent_raw'] = ua_str[:512]
from user_agents import parse as ua_parse
ua = ua_parse(ua_str)
vals['browser'] = f"{ua.browser.family} {ua.browser.version_string}"[:64]
vals['os'] = f"{ua.os.family} {ua.os.version_string}"[:64]
vals['device_type'] = (
'mobile' if ua.is_mobile else
'tablet' if ua.is_tablet else
'bot' if ua.is_bot else
'desktop' if ua.is_pc else 'unknown'
)
else:
vals['ip_address'] = 'internal'
vals['user_agent_raw'] = '<no-request>'
vals['geo_lookup_state'] = 'internal' # distinct from private_ip; cron skips both
return vals
```
### Write semantics
- All writes use `self.env['fusion.login.audit'].sudo().create(vals)` — low-privilege users can still generate their own audit rows despite the read-only record rule.
- `mail_create_nolog=True` context to avoid chatter noise.
- The password value is **never** present in `vals` and is hard-stripped from any `credential` dict before logging. A regression test asserts this.
## Async geolocation cron (`cron_geo_enrich`)
**Schedule:** every 5 minutes, `numbercall=-1`, `priority=10`.
**Worker logic:**
1. Select 100 oldest rows where `geo_lookup_state='pending'`.
2. For each row:
- **Private-IP shortcut:** if `ip_address` is in `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1`, or `fe80::/10` → set `geo_lookup_state='private_ip'`, `country_code='--'`, `city='Private network'`.
- **Cache check:** look for any prior row with the same `ip_address` and `country_code IS NOT NULL` and `event_time > now() - interval '30 days'`. If found, copy `country_code` / `country_name` / `city` / `geo_state` / `ip_hostname` locally; set state `done`. No external call.
- **Reverse DNS:** `socket.gethostbyaddr(ip)` with `socket.setdefaulttimeout(1.5)`.
- **HTTP lookup:** `requests.get('http://ip-api.com/json/' + ip, params={'fields': 'status,country,countryCode,regionName,city'}, timeout=3, headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'})`. The call passes through `network_logger` automatically.
- On `status='success'` → fill fields, set state `done`.
- On HTTP error, timeout, or `status='fail'` → set state `failed` (no retry).
3. `self.env.cr.commit()` after each row so one bad IP cannot roll back the batch.
4. **Rate limit defense:** if the response header `X-Rl` is `'0'`, break early and leave remaining rows as `pending` for the next run.
**Privacy:** the only outbound data is the IP itself. No user identifiers, no Odoo URL, no headers beyond `User-Agent: Odoo-FusionLoginAudit/19.0`. All outbound calls are auditable in `network_logger`.
## UI surfaces
### `res.users` form view
- **Smart button** in the button box, gated `groups="base.group_system"`:
```
┌──────────────┐
│ 🔑 N Logins │
└──────────────┘
```
Click → opens `fusion.login.audit` list view filtered to this user (`domain=[('user_id', '=', active_id)]`).
- **New tab "Login Activity"** appended after existing tabs, gated `groups="base.group_system"`:
- Header summary: `x_fc_last_successful_login`, `x_fc_last_login_ip` (readonly).
- Embedded one2many tree on `x_fc_login_audit_ids`, `limit="30"`, columns: `event_time`, `result` (colored badge), `ip_address`, `country_code` (with flag emoji display), `browser`, `os`, `failure_reason`.
- Tree is `create="false" edit="false" delete="false"`.
- "View full history →" button below the tree, same action as the smart button.
### Standalone views for `fusion.login.audit`
- **List view:** `event_time`, `user_id` (clickable), `attempted_login` (only when `user_id IS NULL`), `result` badge, `ip_address`, `country_code`, `city`, `browser`, `device_type`. Default sort `event_time DESC`.
- **Search view:** filters for "Successes", "Failures", "Last 24h", "Last 7d", "Last 30d", "Unknown users (no user_id)"; group-by IP / country / user.
- **Form view:** readonly; collapsible "Raw" section for `user_agent_raw`, `ip_hostname`, `database`, `geo_lookup_state`.
- **Kanban view:** grouped by `result`, color-coded green/red.
### Menus
Under **Settings → Technical → Login Audit**:
- "Login Events" → default list view
- "Failed Logins (24h)" → list view with default `[('result', '=', 'failure'), ('event_time', '>=', context_today() - 1)]`
### Settings page
New "Login Audit" section in **Settings → General Settings** (gated `groups="base.group_system"`):
- "Retention period (days)" — integer, help: "0 = keep forever"
- "Alert threshold" — integer
- "Alert window (minutes)" — integer
- "Send failed-login alerts" — boolean
## Security
### Group
No new group created. Read is bound to existing `base.group_system`. Rationale: brainstorming decision was "Settings admins only" — reusing the existing group avoids an extra checkbox to manage.
### Model access (`ir.model.access.csv`)
| Group | Read | Write | Create | Unlink |
|---|---|---|---|---|
| `base.group_system` | ✓ | ✗ | ✗ | ✗ |
**No write/create/unlink for any group via the UI.** Audit rows are only written via `sudo()` from inside the auth hooks. An audit log admins can mutate is not an audit log.
### Record rule
Single global rule on `fusion.login.audit`: read for `base.group_system` only. The user-form one2many is additionally gated at the view level via `groups="base.group_system"` (not via a more permissive record rule) so non-admins have no read path even if they craft a custom view.
### Field-level
- `failure_reason` stores a category, never the attempted password.
- `_fc_build_event_vals` strips `credential['password']` before any logging or row construction.
- The `credential` dict is never persisted.
- Regression test: no field on `fusion.login.audit` ever contains a known-test-password string.
## Retention
**Cron `cron_retention_gc`** — daily at 03:00 UTC, `numbercall=-1`:
```python
days = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_login_audit.retention_days', 365))
if days > 0:
cutoff = fields.Datetime.now() - timedelta(days=days)
self.env['fusion.login.audit'].sudo().search([
('event_time', '<', cutoff)
]).unlink()
```
Uses `unlink()` rather than raw `DELETE` so any ORM side effects fire. Expected DB load on `westin-v19`: 27 users × ~2 logins/day × 365 days ≈ 20k rows steady state — trivial for Postgres.
## Failed-login alert
**Mail template** in `data/mail_template_data.xml`:
- **Subject:** `[Login Audit] {threshold} failed login attempts for {attempted_login}`
- **Body:** simple HTML table of the last N failure rows for that `attempted_login` — timestamp, IP, country, user-agent summary.
- **Recipients:** all users in `base.group_system` with a non-empty `email`.
- **Send path:** `mail.mail` queue with `auto_delete=True` so the auth response isn't blocked.
**Cooldown:** 60 min per `attempted_login`, enforced via an `ir.config_parameter` keyed by `fusion_login_audit.last_alert:{attempted_login}` storing the last-send timestamp. Prevents a sustained attack from flooding admin inboxes.
**Kill-switch:** if `x_fc_login_audit_alert_enabled = False`, no alerts are sent regardless of threshold.
## Edge cases
| Case | Behavior |
|---|---|
| `request` is None (XML-RPC, internal auth from cron) | Row written with `ip_address='internal'`, `user_agent_raw='<no-request>'`, `geo_lookup_state='internal'` (cron skips) |
| Audit insert errors on a hot DB | Login still succeeds — every auth-path hook is wrapped in `try/except Exception: _logger.exception(...)` |
| User deleted while audit rows remain | `ondelete='set null'` preserves history; `attempted_login` keeps the readable identifier |
| Password reset / `auth_signup` | The reset itself generates no login event; the subsequent login does — matches expectation |
| API key authentication | **Out of scope v1** (bypasses `_check_credentials`); documented |
| OAuth / SSO | Out of scope v1; no provider configured on westin-v19 |
| Portal user (`share=True`) | Logged the same way; smart button remains admin-visible |
| Two requests racing on the same private IP | Each writes its own row; geo cache is best-effort, not transactional |
| `proxy_mode = False` in `odoo.conf` | `remote_addr` will be the reverse-proxy IP — known limitation, fixable by setting `proxy_mode = True` (out of scope) |
## Testing
### `tests/test_login_audit.py` (TransactionCase)
1. Successful login writes a row with `result='success'` and resolved `user_id`.
2. Bad password writes `result='failure'` with `failure_reason='bad_password'` and re-raises `AccessDenied`.
3. Unknown user writes `result='failure'` with `failure_reason='unknown_user'`, `user_id=None`, non-null `attempted_login`.
4. No field on the written row contains the attempted password (regression).
5. Geo cron: pending row gets enriched from local cache when same IP exists within 30 days (no HTTP call made).
6. Retention cron: rows older than `retention_days` are deleted; newer survive.
7. Alert email: 5 failures in 15 min queues exactly one `mail.mail`; a 6th failure within cooldown queues zero.
8. `database` field is populated from `self.env.cr.dbname`.
9. Audit-write exception inside `_update_last_login` does not block the login.
### `tests/test_security.py` (HttpCase)
1. Non-admin user gets `AccessError` on direct `search(fusion.login.audit)`.
2. Non-admin sees the user form view without the smart button or "Login Activity" tab (XML node hidden by `groups`).
3. Settings admin sees both.
## Deployment notes
- **Local install:** copy module to `K:\Github\Odoo-Modules\fusion_login_audit\` (bind-mounted into `odoo-modsdev-app` container). Update via:
```
docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_login_audit --stop-after-init
```
- **Production install:** sync to `/opt/odoo/custom-addons/fusion_login_audit/` on odoo-westin (via `auto_sync.sh` or git pull on the VM). Update via:
```
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -i fusion_login_audit --stop-after-init"
```
- **Icon:** copy `C:\Users\gsing\Downloads\fusion logs.png` to `K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png`.
- **Verify `proxy_mode = True`** in `/opt/odoo/odoo.conf` on odoo-westin before relying on `ip_address` accuracy — otherwise `remote_addr` will be the reverse-proxy IP rather than the real client. Confirmed out of scope for this module, but flag for the operator.
- **Verify outbound to `ip-api.com:80`** is reachable from the odoo-westin VM (Tailscale/firewall) — if blocked, `geo_lookup_state` will simply be `failed` and the rest of the module is unaffected.
## Success criteria
- Logging in as any user creates exactly one `fusion.login.audit` row with `result='success'` and the correct IP/UA.
- Failed login attempts create exactly one row with `result='failure'` and the correct `failure_reason`.
- Unknown-user attempts create a row with `user_id=None` and the typed login string in `attempted_login`.
- The smart button on `res.users` shows the lifetime count and opens the filtered list.
- The "Login Activity" tab shows the last 30 events with correct color coding.
- After 5 failures from the same login string within 15 minutes, exactly one alert email arrives in the inbox of every Settings admin with an `email` set.
- The geo cron populates `country_code`, `city`, `ip_hostname` for public IPs within 10 minutes of the login.
- The retention cron, set to 1 day for a test, deletes rows older than 24 hours and leaves newer ones.
- All tests pass: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable -i fusion_login_audit --stop-after-init`.

View File

@@ -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` ≈ L564572).
- **`create()` subscribes the customer as a follower** (the "make customer follower" loop, ≈ L600620),
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 (≈L564572) + follower subscription
(≈L600620).
- `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`.

View File

@@ -0,0 +1,271 @@
# fusion_centralize_billing — Centralized Billing Engine on Odoo 19
- **Date:** 2026-05-27
- **Status:** Design approved — pending written-spec review
- **Author:** Design session (Claude + Gurpreet)
- **Module:** `fusion_centralize_billing` (target: `K:\Github\Odoo-Modules\fusion_centralize_billing`)
- **Host:** odoo-nexa (Proxmox VM 315, worker1), Odoo 19 **Enterprise**, live DB `nexamain`
## 1. Goal
Make the Odoo Enterprise instance (`odoo-nexa`) the single billing brain for every
NexaSystems service — hosting (NexaCloud), live chat (NexaDesk/Fusion-Chat), the
metered maps API (NexaMaps), plus custom-app retainers, memberships, and one-off
services. It replaces Lago in the role Lago currently plays, and absorbs NexaCloud's
home-grown Stripe billing, so there is one customer ledger, one accounting system,
one place revenue is recognized.
## 2. Current state (recon, 2026-05-27)
Billing is fragmented across **three+ independent engines**:
| System | Bills for | Engine today | Data home |
|---|---|---|---|
| **NexaCloud** (LXC 102, `10.200.0.250`) | VPS/LXC hosting, Coolify apps, CPU-seconds + throttle-removal fees, snapshots, domains | Own Postgres models + **direct Stripe** (`stripe_service.py`, `billing_service.py`, `usage_metering.py`, `invoice_generator.py`) | `nexacloud` DB (LXC 201) |
| **NexaDesk / Fusion-Chat** (VM 314) | Chat plans (monthly/annual), feature + channel add-ons, message/token overage, token wallets | **Lago** v1.44.0 (VM 318) + Stripe (provider code `nexadesk`) | Lago (VM 318, `192.168.1.117`) |
| **NexaMaps** (`fusionapps.maps_*`) | Metered geocoding/routing API: monthly quota + overage per 1k | Own tables; **~189k usage events / month** for 2 clients | Supabase `fusionapps` |
| Services / memberships | Custom apps, consulting, retainers | ad-hoc / manual | — |
**Decisive fact:** `odoo-nexa` is **Odoo 19 Enterprise** and already runs the full
Lago-equivalent stack: `sale_subscription` (+ `_stock`, `_timesheet`,
`_external_tax`), `account_accountant`, `payment_stripe`, `website_sale` +
`website_sale_subscription`, `crm/project/industry_fsm_sale_subscription`, plus
custom `nexa_coa_setup`, `fusion_whitelabels`, `fusion_helpdesk_central`,
`fusion_pdf_preview`. So Odoo already does subscriptions, recurring invoicing, full
accounting/GL, Stripe, HST taxes, customer portal, credit notes, and self-serve
checkout.
**The only capability Lago has that Odoo lacks natively is usage-based metered
billing** (billable metrics → aggregation → quota/overage charges). That, plus the
integration surface, is all we build.
Prior decision on record (Supabase `fusionapps.decisions`): Lago was deployed as the
centralizer for NexaDesk + NexaCloud. This design **supersedes** that — the billing
brain moves into the Odoo Enterprise already owned and operated.
## 3. Decisions locked in this session
1. **Odoo fully replaces Lago.** Build a metered-billing engine inside `fusion_centralize_billing`; decommission Lago VM 318 at the end.
2. **One unified customer, separate invoice per service.** One `res.partner` per real client; each service bills on its own subscription/cycle. No cross-product invoice merging.
3. **Apps drive; Odoo is the billing system of record.** Each app keeps its own signup, provisioning, and entitlement enforcement, and calls Odoo's billing API (the same way it calls Lago today). Odoo invoices, charges Stripe, and emits webhooks back.
4. **Odoo owns the billing catalog; apps own entitlements.** Odoo is SoR for products, prices, recurrence, metric rate/quota/overage, taxes — keyed by a stable `plan_code`. Apps enforce feature limits (max_chatbots, CPU quota, API rate-limit) against the same code.
5. **Pilot = NexaCloud, phased dual-run cutover** (one product at a time, parallel run + reconciliation before flip).
6. **Aggregate-push usage ingestion.** Apps push periodic pre-aggregated counters; Odoo stores rollups and feeds native `sale.subscription` metered lines. No raw-event firehose into Odoo.
## 4. Architecture
```
NexaCloud NexaDesk NexaMaps (apps keep signup + provisioning + entitlements)
│ │ │
│ customers / subscriptions / usage counters (inbound REST, API-key bearer auth)
▼ ▼ ▼
┌──────────────────────────────────────────────┐
│ fusion_centralize_billing (custom Odoo 19 module) │
│ • Service registry (one row per app) │
│ • Identity links (ext acct → res.partner) │
│ • Metric + Charge catalog (quota/overage) │
│ • Usage engine (ingest → aggregate → bill) │
│ • Outbound webhook queue (HMAC + retry) │
└───────────────┬────────────────────────────────┘
│ writes billable qty onto
sale.order(is_subscription) → account.move → payment_stripe (NATIVE Odoo Enterprise)
│ invoicing, HST tax, proration,
│ invoice paid / failed / sub ended dunning, portal, credit notes
outbound webhooks ──► apps suspend / restore / deprovision
```
Principle: **build only the metering + integration layer; inherit all financial
behaviour from native Odoo Enterprise.**
## 5. Data model
### 5.1 New models (`fusion.billing.*`)
| Model | Key fields | Purpose |
|---|---|---|
| `fusion.billing.service` | `name`, `code` (nexacloud/nexadesk/nexamaps), `api_key_hash`, `webhook_url`, `webhook_secret`, `active` | One row per source app — the auth + routing boundary. |
| `fusion.billing.account.link` | `service_id`, `external_id`, `partner_id`, `external_email`; unique `(service_id, external_id)` | Identity resolution: folds each app's account into one `res.partner`. |
| `fusion.billing.metric` | `code`, `name`, `aggregation` (sum/max/last/unique_count), `unit_label`, `rounding` | Billable metric definition. |
| `fusion.billing.charge` | `plan_ref`/`product_id`, `metric_id`, `included_quota`, `price_per_unit`, `unit_batch` (e.g. per 1000), `charge_model` (standard/graduated/package/volume) | Maps a plan + metric → quota & overage pricing. Where "5M quota / $0.10 per 1k" lives. |
| `fusion.billing.usage` | `subscription_id`, `metric_id`, `period_start`, `period_end`, `quantity`, `source`, `idempotency_key`; index `(subscription, metric, period)` | **Aggregated** usage rows (rollups, not raw events). |
| `fusion.billing.webhook` | `service_id`, `event_type`, `payload` (JSON), `state` (pending/sent/failed/dead), `attempts`, `next_retry_at`, `signature` | Outbound event queue, processed by cron with backoff + HMAC. |
| `fusion.billing.reconciliation` | `service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` | Dual-run shadow-mode comparison (Odoo-computed vs app-actual). |
### 5.2 Native models reused as-is
`res.partner` (customer), **`sale.order` with `is_subscription=True`** (the subscription),
`sale.subscription.plan` (recurrence/plan), `sale.order.line` (metered lines),
`account.move` (invoice + credit note), `payment_stripe`/`payment.transaction` (Stripe),
`account.tax` (HST per province), customer portal. Catalog = `product.template` +
`sale.subscription.plan`, tagged with the shared `plan_code`.
New fields on native models use the `x_fc_*` prefix (e.g. `res.partner.x_fc_billing_external_ids`).
> **Odoo 19 modeling note (verified on live `nexamain`, 2026-05-27):** there is **no
> `sale.subscription` model**. A subscription IS a `sale.order` with `is_subscription=True`,
> `plan_id` → `sale.subscription.plan`, plus `subscription_state` / `next_invoice_date` /
> `recurring_monthly`. Every "subscription" reference in this spec means that. The usage
> engine links `fusion.billing.usage.subscription_id` → `sale.order`.
### 5.3 Relationship to `fusion_api` (reuse, don't duplicate)
The existing **`fusion_api`** module (`fusion.api.key` / `.consumer` / `.service` /
`.usage` / `.usage.daily`) centralizes **outbound** provider keys (OpenAI, Anthropic,
Google Maps, Twilio) with cost/usage tracking + rate limiting — i.e. what **Nexa pays
providers** (COGS). It is **complementary**, not a substitute:
`fusion_centralize_billing` tracks what **customers owe Nexa**. Two concrete ties:
(a) feed `fusion.api.usage.daily` cost into margin reporting against billed revenue;
(b) mirror its daily-rollup aggregation pattern for `fusion.billing.usage`. The
customer-facing metered billing and the inbound API remain ours to build.
## 6. Usage engine (aggregate-push)
1. Apps `POST /usage` with periodic counters and an `idempotency_key`
(e.g. `service:metric:subscription:window`). NexaCloud pushes CPU-seconds per
deployment hourly; NexaMaps pushes api_calls per client daily; NexaDesk pushes
messages/tokens. Upsert into `fusion.billing.usage` keyed by `idempotency_key` so
retries never double-bill.
2. A **pre-invoice cron** (runs ahead of each subscription's invoice date) sums the
period's `fusion.billing.usage` per metric, applies the matching
`fusion.billing.charge` (quota → free, overage → priced by `charge_model`), and
writes the billable quantity/amount onto the subscription's draft invoice line
(usage product).
3. Native subscription invoicing issues the invoice, applies HST, and charges Stripe.
Quota resets per period.
At ~189k Maps events/month pushed as daily counters, Odoo stores ≈30 rows per client
per metric per month — trivial volume.
## 7. Inbound API (Lago-shaped, drop-in)
Base path `/api/billing/v1/*`. Odoo 19 routing: `type="http"`, `auth="none"`,
`csrf=False`, manual **Bearer** API-key check against `fusion.billing.service`
(hashed), JSON request/response via `request.make_json_response`, per-service rate
limiting. (`type="jsonrpc"` is for Odoo session RPC — not used here, because external
apps authenticate with bearer tokens, not Odoo sessions.)
Endpoints intentionally mirror `Fusion-Chat/src/lib/billing/lago-client.ts` so the
NexaDesk swap is ≈ one file, and NexaCloud's integration is a thin client:
| Method · Path | Maps to |
|---|---|
| `POST /customers` | upsert `res.partner` + `account.link` (identity resolution) |
| `POST /subscriptions` · `PUT /subscriptions/:id` · `DELETE /subscriptions/:id` | create / change-upgrade / cancel subscription `sale.order` |
| `POST /usage` | batch aggregated counters (hot path → 202 Accepted) |
| `POST /invoices` | one-off invoice (token packs, throttle-removal fee) |
| `GET /invoices` · `GET /invoices/:id` · `POST /invoices/:id/download` | list / fetch / PDF |
| `POST /invoices/:id/retry_payment` · `POST /invoices/:id/void` | payment retry / void |
| `POST /credit_notes` | refund via `account.move` reversal |
| `GET /plans` · `GET /catalog` | apps fetch pricing (as NexaDesk fetches from Lago) |
| `GET /customers/:id/checkout_url` | Stripe payment-method setup |
## 8. Outbound webhooks (control loop)
Odoo → app, HMAC-SHA256 signed, retried with exponential backoff, dead-lettered after
N attempts (reuse the proven pattern in `Fusion-Chat/src/lib/billing/lago-payment-retry-job.ts`):
| Event | App reaction |
|---|---|
| `invoice.payment_failed` (after dunning) | **suspend** — NexaCloud throttle/network-isolate; NexaDesk suspend tenant; NexaMaps disable API key |
| `invoice.payment_succeeded` / `subscription.reactivated` | **restore** service |
| `subscription.terminated` | **deprovision** |
| `usage.threshold_reached` (80% / 100%, optional) | warn / cap |
## 9. NexaCloud pilot
- **Identity & catalog mapping:** `nexacloud.users``res.partner` via `account.link`;
`nexacloud.products`/`plans``product.template` + subscription plans
(`plan_code` = NexaCloud plan id/slug, prices from `price_monthly`/`price_yearly`);
`nexacloud.deployments` + `subscriptions` → one subscription `sale.order` per deployment
(NexaCloud bills per deployment).
- **Metering:** CPU-seconds → `fusion.billing.metric` `cpu_seconds` (sum) + `charge`
(included = plan quota, overage priced). Throttle-removal fee → one-off invoice
(or add-on product). `nexacloud/.../usage_metering.py` pushes counters to `/usage`.
- **Control loop:** `invoice.payment_failed` → NexaCloud suspends using its existing
`network_isolation` / `throttle_checker` / `resource_manager`; `subscription.terminated`
→ NexaCloud deprovisions.
## 10. Dual-run + migration (phased)
1. **Import** NexaCloud customers + active subscriptions into Odoo (script reads the
`nexacloud` DB → creates partners / links / subscriptions / charges).
2. **Shadow mode ≥ 1 billing cycle:** Odoo computes invoices while NexaCloud keeps
charging via its own Stripe. `fusion.billing.reconciliation` diffs Odoo-computed vs
NexaCloud-actual per customer/period; investigate every delta.
3. **Flip** when deltas are within tolerance: NexaCloud calls Odoo's API as SoR and
stops its internal Stripe billing. Past invoices stay archived (PDF / opening
balances) — not re-issued.
4. **Repeat** for NexaDesk (retire Lago for chat) → NexaMaps → then decommission
Lago VM 318.
## 11. Risks & open items
- **🟢 Stripe account unification — RESOLVED (2026-05-27).** All systems share ONE Stripe
account: **`acct_1ShlA9IkwUB1dVox`** (Nexa Systems Inc, CA, live). Verified live:
NexaCloud's direct `sk_live` key resolves to that account, and Lago has three Stripe
providers (`nexasystems`, `nexadesk`, `nexamaps`) that **all** resolve to the same
account. Therefore **no Stripe account migration is needed** — Odoo's `payment_stripe`
connects to that single account and **reuses existing Stripe customers + saved payment
methods** (map each Stripe `provider_customer_id``res.partner`). This removes what
was the biggest migration risk.
- **Idempotency** on usage counters is mandatory (dedupe key) to prevent double billing on retries.
- **Entitlement sync SLA:** on plan change, Odoo webhook informs the app; define how
fast app-side limits must update (and the reconciliation if a webhook is missed).
- **Odoo 19 correctness:** implementation MUST read live reference files from the
container (`docker exec odoo-nexa-app cat …`) before coding subscription/API/account
internals — never from memory (per `K:\Github\CLAUDE.md`).
- **Tax:** HST/GST per Canadian province via `account.tax`; confirm tax codes align
with current Lago `hst_on` usage.
- **Auth hardening:** API keys hashed at rest, per-service scoping, rate limiting,
request audit log; webhook secrets rotated.
## 12. Phasing — spec sequence
Each is its own spec → plan → build cycle:
1. **`fusion_centralize_billing` core** — service registry, identity links, metric/charge catalog,
usage engine, inbound API, outbound webhook engine. *(detailed below — first deliverable)*
2. **NexaCloud adapter + dual-run reconciliation** *(the pilot — coupled to #1)*
3. NexaDesk adapter (swap the Lago client for the Odoo billing client)
4. NexaMaps adapter
5. Lago decommission + memberships/services onboarding + portal polish
## 13. First-deliverable scope (sub-projects #1 + #2)
**In scope**
- `fusion_centralize_billing` module skeleton (manifest, security/ACLs + record rules, README) following the `nexa_coa_setup` layout.
- Models in §5.1; new native fields use `x_fc_*`.
- Aggregate-push usage engine (§6) incl. pre-invoice cron + idempotent upsert.
- Inbound API (§7) with bearer auth, and outbound webhook engine (§8).
- NexaCloud mapping + importer + shadow-mode reconciliation (§9, §10).
- Manifest `depends`: `sale_subscription`, `account_accountant`, `payment_stripe`,
`sale_management` (+ `nexa_coa_setup` if COA dependencies apply).
**Out of scope (YAGNI for now)**
- NexaDesk / NexaMaps adapters (specs #3/#4).
- Raw-event ingestion / per-event audit in Odoo (apps retain raw events).
- Lago decommission (spec #5) — Lago stays running until NexaDesk is migrated.
- Customer-portal redesign — use native portal as-is initially.
## 14. Success criteria (first deliverable)
- A NexaCloud deployment can be created as an Odoo subscription `sale.order` via the API,
with one `res.partner` resolving the NexaCloud user.
- CPU-seconds counters pushed to `/usage` aggregate correctly and produce a draft
invoice with quota + overage applied, taxed (HST), and charged through `payment_stripe`.
- A simulated `invoice.payment_failed` delivers a signed webhook NexaCloud can act on.
- Shadow-mode reconciliation report shows Odoo-computed vs NexaCloud-actual within
tolerance for ≥ 1 cycle before any flip.
- No double billing under usage-counter retries (idempotency verified).
## 15. Open questions for review
1. ~~Stripe: one account across all products, or separate?~~ **ANSWERED (2026-05-27):** one
account `acct_1ShlA9IkwUB1dVox` for everything (NexaCloud direct + Lago's
`nexasystems`/`nexadesk`/`nexamaps` providers). No account migration; reuse existing
Stripe customers + payment methods.
2. NexaCloud billing granularity — confirm **one subscription per deployment** (vs one per customer with deployment line items).
3. Membership model — Odoo native `membership` module, or model memberships as plain recurring subscriptions?
4. Spec/module commit target — confirm branch strategy in `Odoo-Modules` (currently on `feat/fusion-login-audit`).

View File

@@ -0,0 +1,70 @@
# Fusion Centralized Billing (`fusion_centralize_billing`)
Centralized billing engine that makes this Odoo 19 **Enterprise** instance the single
billing brain for every NexaSystems service — **NexaCloud** hosting, **NexaDesk** chat,
**NexaMaps** API, custom apps, and memberships. It replaces Lago and absorbs NexaCloud's
home-grown Stripe billing into one customer ledger and one accounting system.
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
>
> **Status:** **SCAFFOLD.** Models + security + the API auth shell are in place and the
> module installs. The usage engine, full inbound API, and webhook processor are stubs
> to be implemented from the writing-plans output.
## Why this module is small
We build **only** the metering + integration layer. Everything financial — recurring
invoicing, HST tax, proration, dunning, customer portal, credit notes, Stripe — is
**native Odoo Enterprise** (`sale_subscription`, `account_accountant`, `payment_stripe`),
already installed and running.
## Design decisions (locked)
1. Odoo fully replaces Lago (we build the metered-billing engine; Lago is decommissioned last).
2. One unified `res.partner` per client; **separate invoice per service**.
3. **Apps drive**, Odoo is the billing system of record — apps call the inbound API (as they call Lago today); Odoo bills and webhooks back.
4. Odoo owns the **billing catalog**; apps own **feature entitlements** (shared `plan_code`).
5. Pilot = **NexaCloud**, phased dual-run cutover.
6. **Aggregate-push** usage ingestion (periodic counters, not a raw-event firehose).
## Models (`fusion.billing.*`)
| Model | Purpose |
|---|---|
| `fusion.billing.service` | One source app; bearer API key (hashed) + webhook config. |
| `fusion.billing.account.link` | External account id → one `res.partner` (identity resolution). |
| `fusion.billing.metric` | Billable metric + aggregation (sum/max/last/unique). |
| `fusion.billing.charge` | Plan + metric → included quota + overage pricing. |
| `fusion.billing.usage` | Aggregated per-period usage rollups (idempotent). |
| `fusion.billing.webhook` | Outbound lifecycle event queue (HMAC + retry). |
| `fusion.billing.reconciliation` | Dual-run Odoo-vs-app delta during cutover. |
> **Odoo 19 note (verified):** a subscription is a `sale.order` with `is_subscription=True`
> (`plan_id` → `sale.subscription.plan`). There is **no** `sale.subscription` model.
> `fusion.billing.usage.subscription_id` therefore points at `sale.order`.
## Inbound API
Lago-shaped REST under `/api/billing/v1/*`, bearer auth. Endpoints mirror NexaDesk's
existing `lago-client.ts` so migration is a thin client swap. `/health` works today;
the rest return `501` until implemented.
## Relationship to `fusion_api`
`fusion_api` manages **outbound** provider keys (OpenAI, Maps, Twilio) + cost tracking —
i.e. COGS. This module tracks **customer** revenue. Complementary: feed `fusion_api`
cost into margin reporting; reuse its daily-rollup aggregation pattern.
## Dependencies
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
## Local dev
```bash
docker exec odoo-nexa-app odoo -d nexamain -u fusion_centralize_billing --stop-after-init
# tests (once added):
docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init
```
Canadian English, CAD, HST via `account.tax`. New fields on native models use the `x_fc_*` prefix.

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
"name": "Fusion Centralized Billing",
"version": "19.0.1.0.0",
"category": "Accounting/Subscriptions",
"summary": "Centralized billing engine for all NexaSystems services — metered usage, "
"per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.",
"description": """
Fusion Centralized Billing
==========================
Makes this Odoo Enterprise instance the single billing brain for every NexaSystems
service (NexaCloud hosting, NexaDesk chat, NexaMaps API, custom apps, memberships).
It adds ONLY the metering + integration layer; all financial behaviour (invoicing,
HST tax, proration, dunning, portal, credit notes, Stripe) is native Odoo Enterprise.
Capabilities
------------
* Service registry — one record per source app (NexaCloud / NexaDesk / NexaMaps) with
bearer API key + webhook config.
* Identity links — fold each app's external account into one ``res.partner``.
* Metric + Charge catalog — billable metrics with quota + overage pricing, keyed by a
shared ``plan_code`` (apps own feature entitlements; Odoo owns money).
* Usage engine — aggregate-push: apps send periodic counters; a pre-invoice cron feeds
billable quantities onto the subscription ``sale.order``.
* Inbound API — Lago-shaped REST (``/api/billing/v1/*``), bearer auth.
* Outbound webhooks — HMAC-signed lifecycle events (payment failed/succeeded,
subscription terminated) so apps suspend / restore / deprovision.
Design spec: docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md
Status: SCAFFOLD. Model fields are in place; engine/API/webhook bodies are stubs to be
implemented via the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
reference files from the container before implementing subscription/account internals.
""",
"author": "Nexa Systems Inc.",
"website": "https://nexasystems.ca",
"license": "OPL-1",
"depends": [
"account_accountant",
"sale_subscription",
"sale_management",
"payment_stripe",
],
"data": [
"security/ir.model.access.csv",
"data/ir_cron.xml",
],
"installable": True,
"application": False,
"auto_install": False,
}

View File

@@ -0,0 +1 @@
from . import api

View File

@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Inbound, Lago-shaped billing API (spec §7).
Auth: bearer API key matched (by SHA-256 hash) against ``fusion.billing.service``.
Routing: ``type="http"`` + ``auth="none"`` + ``csrf=False`` — external apps present
bearer tokens, not Odoo sessions (so NOT ``type="jsonrpc"``).
STATUS: SCAFFOLD. Only auth + /health are wired. Endpoint bodies are stubs (HTTP 501)
to be implemented from the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
references (sale.order subscription flow, account.move, payment_stripe) before
implementing — do NOT code those internals from memory.
"""
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
API_BASE = "/api/billing/v1"
class FusionBillingApi(http.Controller):
# ── helpers ──────────────────────────────────────────────────────────
def _authenticate(self):
"""Return the active fusion.billing.service for the bearer key, else None."""
auth = request.httprequest.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
return request.env["fusion.billing.service"].sudo()._match_api_key(auth[7:].strip()) or None
def _json(self, payload, status=200):
return request.make_json_response(payload, status=status)
def _read_json(self):
try:
raw = request.httprequest.get_data(as_text=True) or "{}"
return json.loads(raw)
except Exception:
return None
# ── routes ───────────────────────────────────────────────────────────
@http.route(f"{API_BASE}/health", type="http", auth="none", methods=["GET"], csrf=False)
def health(self, **kw):
return self._json({"status": "ok", "service": "fusion_centralize_billing"})
@http.route(f"{API_BASE}/customers", type="http", auth="none", methods=["POST"], csrf=False)
def post_customer(self, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
payload = self._read_json()
if payload is None:
return self._json({"error": "invalid json"}, status=400)
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)
def post_usage(self, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
payload = self._read_json()
if payload is None:
return self._json({"error": "invalid json"}, status=400)
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)
def get_plans(self, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
return self._json(service._api_catalog())
@http.route(f"{API_BASE}/subscriptions", type="http", auth="none", methods=["POST"], csrf=False)
def post_subscription(self, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
payload = self._read_json()
if payload is None:
return self._json({"error": "invalid json"}, status=400)
result = service._api_create_subscription(payload)
if result.get("status") == "error":
return self._json(result, status=400)
return self._json(result)

View 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>

View File

@@ -0,0 +1,8 @@
from . import service
from . import account_link
from . import metric
from . import charge
from . import usage
from . import webhook
from . import reconciliation
from . import sale_order

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import api, fields, models
class FusionBillingAccountLink(models.Model):
"""Identity resolution: maps an app's external account id to one res.partner.
Folds the NexaCloud user / NexaDesk tenant / NexaMaps client for the same
real-world client onto a single partner (the unified customer). See spec §5.1.
"""
_name = "fusion.billing.account.link"
_description = "Fusion Billing — External Account → Partner Link"
_order = "service_id, external_id"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
external_id = fields.Char(
required=True, index=True,
help="The app's own account id (NexaCloud user, NexaDesk tenant, Maps client).",
)
external_email = fields.Char()
partner_id = fields.Many2one(
"res.partner", required=True, ondelete="restrict", index=True,
)
_service_external_uniq = models.Constraint(
"unique(service_id, external_id)",
"An external account can only link to one partner per service.",
)
@api.model
def _resolve_or_create_partner(self, service, external_id, name=None, email=None, extra=None):
"""Return the link for (service, external_id), creating partner+link if needed.
Unifies customers: if a link for this external_id exists, reuse it; else if a
partner with the same email already exists (possibly from another service),
link to it; else create a new partner.
"""
existing = self.search(
[('service_id', '=', service.id), ('external_id', '=', external_id)], limit=1)
if existing:
return existing
partner = self.env['res.partner']
if email:
partner = partner.search([('email', '=', email)], limit=1)
if not partner:
partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})})
return self.create({
'service_id': service.id,
'external_id': external_id,
'external_email': email,
'partner_id': partner.id,
})

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import math
from odoo import api, fields, models
class FusionBillingCharge(models.Model):
"""Maps a plan + metric to quota + overage pricing.
This is where "5,000,000 included / $0.10 per 1k overage" (NexaMaps) or a
NexaCloud CPU-seconds quota lives. Keyed by the shared ``plan_code`` the app
references; Odoo owns the money, the app owns feature entitlements. See spec §5.1.
"""
_name = "fusion.billing.charge"
_description = "Fusion Billing — Metered Charge (quota + overage)"
_order = "plan_code, name"
name = fields.Char(required=True)
plan_code = fields.Char(
required=True, index=True,
help="Shared plan_code the source app references (matches a sale.subscription.plan).",
)
plan_id = fields.Many2one(
"sale.subscription.plan",
help="Optional link to the Odoo recurrence/plan for this charge.",
)
metric_id = fields.Many2one(
"fusion.billing.metric", required=True, ondelete="restrict",
)
product_id = fields.Many2one(
"product.product", help="Usage product invoiced for overage.",
)
included_quota = fields.Float(
default=0.0, help="Units included before overage applies, per period.",
)
price_per_unit = fields.Monetary(help="Overage price per unit_batch.")
unit_batch = fields.Float(
default=1.0, help="Batch size for overage pricing, e.g. 1000 = priced per 1k.",
)
charge_model = fields.Selection(
[
("standard", "Standard (per unit)"),
("package", "Package"),
],
default="standard", required=True,
)
currency_id = fields.Many2one(
"res.currency", required=True,
default=lambda self: self.env.company.currency_id,
)
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):
"""Return (overage_units, amount) for total period usage under this charge.
- overage_units = usage above included_quota (never negative)
- 'standard': price the overage in (rounded-up) `unit_batch` blocks.
- 'package': price whole packages over the RAW quantity (quota ignored for
package counting); a partial package rounds up.
"""
self.ensure_one()
overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0))
batch = self.unit_batch or 1.0
if self.charge_model == 'package':
# 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
return overage, round(blocks * (self.price_per_unit or 0.0), 2)
# standard: price the overage in (rounded-up) batches
blocks = math.ceil(overage / batch) if overage > 0 else 0
return overage, round(blocks * (self.price_per_unit or 0.0), 2)

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingMetric(models.Model):
"""A billable metric (CPU-seconds, API calls, messages, tokens ...).
Defines how raw usage is aggregated within a billing period. See spec §5.1 / §6.
"""
_name = "fusion.billing.metric"
_description = "Fusion Billing — Billable Metric"
_order = "code"
name = fields.Char(required=True)
code = fields.Char(required=True, index=True)
aggregation = fields.Selection(
[
("sum", "Sum"),
("max", "Max"),
("last", "Last value"),
("unique_count", "Unique count"),
],
default="sum", required=True,
)
unit_label = fields.Char(help="e.g. CPU-seconds, API calls, messages, tokens.")
rounding = fields.Float(default=1.0)
active = fields.Boolean(default=True)
_code_uniq = models.Constraint("unique(code)", "Metric code must be unique.")

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingReconciliation(models.Model):
"""Dual-run shadow-mode comparison: Odoo-computed vs the app's actual billing.
During phased cutover (NexaCloud first), Odoo computes invoices while the app
keeps charging. This row records the per-customer, per-period delta so we only
flip once deltas are within tolerance. See spec §10.
"""
_name = "fusion.billing.reconciliation"
_description = "Fusion Billing — Dual-Run Reconciliation"
_order = "period desc, service_id"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
partner_id = fields.Many2one("res.partner", required=True, ondelete="cascade", index=True)
period = fields.Char(required=True, help="Billing period label, e.g. 2026-05.")
odoo_amount = fields.Monetary()
external_amount = fields.Monetary(string="App-actual Amount")
delta = fields.Monetary(help="odoo_amount - external_amount.")
currency_id = fields.Many2one(
"res.currency", required=True,
default=lambda self: self.env.company.currency_id,
)
status = fields.Selection(
[
("match", "Within tolerance"),
("delta", "Delta — investigate"),
("resolved", "Resolved"),
],
default="delta", required=True, index=True,
)
note = fields.Text()

View 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

View File

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import hashlib
import ipaddress
import secrets
from urllib.parse import urlparse
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class FusionBillingService(models.Model):
"""A source app that pushes billing data (NexaCloud / NexaDesk / NexaMaps).
The bearer API key is shown ONCE on generation and stored only as a SHA-256
hash. This record is the auth + routing boundary for the inbound API and the
target for outbound webhooks. See spec §5.1 / §7 / §8.
"""
_name = "fusion.billing.service"
_description = "Fusion Billing — Source Service"
_order = "name"
name = fields.Char(required=True)
code = fields.Char(
required=True, index=True,
help="Stable code the app identifies itself with, e.g. nexacloud / nexadesk / nexamaps.",
)
active = fields.Boolean(default=True)
api_key_hash = fields.Char(
string="API Key (SHA-256)",
help="Hash of the bearer key. The raw key is displayed once at generation time.",
)
webhook_url = fields.Char(help="Endpoint this app exposes to receive billing webhooks.")
webhook_secret = fields.Char(help="Shared secret for HMAC-SHA256 webhook signatures.")
account_link_ids = fields.One2many(
"fusion.billing.account.link", "service_id", string="Customer Links",
)
account_link_count = fields.Integer(compute="_compute_account_link_count")
_code_uniq = models.Constraint("unique(code)", "Service code must be unique.")
@api.depends("account_link_ids")
def _compute_account_link_count(self):
for rec in self:
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):
"""Generate a fresh bearer key, store only its hash, return the raw key.
TODO(spec §7): surface the raw key once in the UI (wizard/notification).
"""
self.ensure_one()
raw = secrets.token_urlsafe(32)
self.api_key_hash = hashlib.sha256(raw.encode()).hexdigest()
return raw
@api.model
def _match_api_key(self, raw_key):
"""Return the active service whose stored hash matches raw_key, else empty recordset."""
if not raw_key:
return self.browse()
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1)
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()
if not isinstance(payload, dict):
return {'status': 'error', 'error': 'invalid payload'}
ext = payload.get('external_id')
if not ext:
return {'status': 'error', 'error': 'external_id required'}
link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
self, ext, name=payload.get('name'), email=payload.get('email'))
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
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()
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']
linked_partners = self.account_link_ids.mapped('partner_id')
accepted = 0
for ev in events:
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(
sub, ev['metric_code'], quantity,
ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key'))
except ValueError as e:
return {'status': 'error', 'error': str(e)}
accepted += 1
return {'status': 'ok', 'accepted': accepted}
def _api_catalog(self):
self.ensure_one()
charges = self.env['fusion.billing.charge'].search([('active', '=', True)])
return {'status': 'ok', 'charges': [{
'plan_code': c.plan_code, 'metric': c.metric_id.code,
'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit,
'unit_batch': c.unit_batch, 'charge_model': c.charge_model,
} for c in charges]}
def _api_create_subscription(self, payload):
"""Create and confirm a subscription sale.order for an external customer.
The product on each line must have recurring_invoice=True so that
Odoo recognises the order as a subscription with has_recurring_line and
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()
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([
('service_id', '=', self.id),
('external_id', '=', payload.get('external_customer_id')),
], limit=1)
if not link:
return {'status': 'error', 'error': 'unknown customer'}
lines = payload.get('lines')
if lines is None:
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({
'partner_id': link.partner_id.id,
'plan_id': plan_id,
'order_line': order_lines,
})
sub.action_confirm()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import api, fields, models
class FusionBillingUsage(models.Model):
"""Aggregated usage rollup for a (subscription, metric, period).
Aggregate-push model: apps send periodic counters (not raw events). The
``idempotency_key`` makes re-sent counters safe — they never double-count.
A pre-invoice cron sums these and feeds billable quantity onto the subscription.
NOTE (Odoo 19, verified): the subscription is a ``sale.order`` with
``is_subscription=True`` — there is no ``sale.subscription`` model. See spec §5.2.
"""
_name = "fusion.billing.usage"
_description = "Fusion Billing — Aggregated Usage (period rollup)"
_order = "period_start desc"
subscription_id = fields.Many2one(
"sale.order", required=True, ondelete="cascade", index=True,
string="Subscription", domain=[("is_subscription", "=", True)],
)
metric_id = fields.Many2one(
"fusion.billing.metric", required=True, ondelete="restrict", index=True,
)
period_start = fields.Datetime(required=True)
period_end = fields.Datetime(required=True)
quantity = fields.Float(default=0.0)
source = fields.Char(default="push")
idempotency_key = fields.Char(
index=True, help="Dedupe key so re-sent counters never double-count.",
)
_idempotency_uniq = models.Constraint(
"unique(subscription_id, metric_id, idempotency_key)",
"Usage idempotency key must be unique per subscription and metric.",
)
@api.model
def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None):
"""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)
if not metric:
raise ValueError("Unknown metric code: %s" % metric_code)
vals = {
'subscription_id': subscription.id,
'metric_id': metric.id,
'period_start': period_start,
'period_end': period_end,
'quantity': quantity,
'idempotency_key': idem,
}
if idem:
existing = self.search([
('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id),
('idempotency_key', '=', idem),
], limit=1)
if existing:
existing.write({'quantity': quantity})
return existing
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
def _aggregate(self, subscription, metric, 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."""
rows = self.search([
('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id),
('period_start', '>=', period_start),
('period_start', '<', period_end),
])
qtys = rows.mapped('quantity')
if not qtys:
return 0.0
agg = metric.aggregation
if agg == 'sum':
return sum(qtys)
if agg == 'max':
return max(qtys)
if agg == 'last':
return rows.sorted('period_start')[-1].quantity
if agg == 'unique_count':
return float(len(set(qtys)))
return sum(qtys)

View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import hashlib
import hmac
import json
import logging
from datetime import timedelta
import requests
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
MAX_ATTEMPTS = 8
class FusionBillingWebhook(models.Model):
"""Outbound webhook queue: lifecycle events delivered to source apps.
Processed by a cron with exponential backoff + HMAC-SHA256 signing, dead-lettered
after N attempts (mirror the proven retry pattern in NexaDesk's
lago-payment-retry-job). Apps react: suspend / restore / deprovision. See spec §8.
TODO(spec §8): cron processor, HMAC signing, backoff schedule.
"""
_name = "fusion.billing.webhook"
_description = "Fusion Billing — Outbound Webhook Event"
_order = "create_date desc"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
event_type = fields.Char(
required=True, index=True,
help="invoice.payment_failed / invoice.payment_succeeded / "
"subscription.terminated / subscription.reactivated / usage.threshold_reached",
)
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(
[
("pending", "Pending"),
("sent", "Sent"),
("failed", "Failed"),
("dead", "Dead-lettered"),
],
default="pending", required=True, index=True,
)
attempts = fields.Integer(default=0)
next_retry_at = fields.Datetime()
signature = fields.Char(help="HMAC-SHA256 of the payload using the service webhook_secret.")
last_error = fields.Text()
@api.model
def _sign(self, secret, body):
return hmac.new((secret or '').encode(), body.encode(), hashlib.sha256).hexdigest()
@api.model
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=(',', ':'))
return self.create({
'service_id': service.id,
'event_type': event_type,
'payload': payload,
'body': body,
'signature': self._sign(service.webhook_secret, body),
'state': 'pending',
'next_retry_at': fields.Datetime.now(),
})
@api.model
def _cron_dispatch(self):
now = fields.Datetime.now()
due = self.search([
('state', 'in', ('pending', 'failed')),
('next_retry_at', '<=', now),
], limit=100)
for wh in due:
# 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:
resp = requests.post(
wh.service_id.webhook_url,
data=body,
headers={'Content-Type': 'application/json',
'X-Fusion-Signature': wh.signature,
'X-Fusion-Event': wh.event_type,
'X-Fusion-Event-Id': str(wh.id)},
timeout=10,
)
ok = 200 <= resp.status_code < 300
except Exception as e: # noqa: BLE001 - record and retry
ok = False
wh.last_error = str(e)[:500]
wh.attempts += 1
if ok:
wh.state = 'sent'
elif wh.attempts >= MAX_ATTEMPTS:
wh.state = 'dead'
else:
wh.state = 'failed'
# Cap the exponential backoff so the interval can't overflow.
wh.next_retry_at = now + timedelta(minutes=2 ** min(wh.attempts, 10))

View File

@@ -0,0 +1,11 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_billing_service_admin,fusion.billing.service admin,model_fusion_billing_service,base.group_system,1,1,1,1
access_fusion_billing_account_link_admin,fusion.billing.account.link admin,model_fusion_billing_account_link,base.group_system,1,1,1,1
access_fusion_billing_metric_admin,fusion.billing.metric admin,model_fusion_billing_metric,base.group_system,1,1,1,1
access_fusion_billing_charge_admin,fusion.billing.charge admin,model_fusion_billing_charge,base.group_system,1,1,1,1
access_fusion_billing_usage_admin,fusion.billing.usage admin,model_fusion_billing_usage,base.group_system,1,1,1,1
access_fusion_billing_webhook_admin,fusion.billing.webhook admin,model_fusion_billing_webhook,base.group_system,1,1,1,1
access_fusion_billing_reconciliation_admin,fusion.billing.reconciliation admin,model_fusion_billing_reconciliation,base.group_system,1,1,1,1
access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_billing_metric,account.group_account_manager,1,1,1,0
access_fusion_billing_charge_acct,fusion.billing.charge accountant,model_fusion_billing_charge,account.group_account_manager,1,1,1,0
access_fusion_billing_reconciliation_acct,fusion.billing.reconciliation accountant,model_fusion_billing_reconciliation,account.group_account_manager,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_billing_service_admin fusion.billing.service admin model_fusion_billing_service base.group_system 1 1 1 1
3 access_fusion_billing_account_link_admin fusion.billing.account.link admin model_fusion_billing_account_link base.group_system 1 1 1 1
4 access_fusion_billing_metric_admin fusion.billing.metric admin model_fusion_billing_metric base.group_system 1 1 1 1
5 access_fusion_billing_charge_admin fusion.billing.charge admin model_fusion_billing_charge base.group_system 1 1 1 1
6 access_fusion_billing_usage_admin fusion.billing.usage admin model_fusion_billing_usage base.group_system 1 1 1 1
7 access_fusion_billing_webhook_admin fusion.billing.webhook admin model_fusion_billing_webhook base.group_system 1 1 1 1
8 access_fusion_billing_reconciliation_admin fusion.billing.reconciliation admin model_fusion_billing_reconciliation base.group_system 1 1 1 1
9 access_fusion_billing_metric_acct fusion.billing.metric accountant model_fusion_billing_metric account.group_account_manager 1 1 1 0
10 access_fusion_billing_charge_acct fusion.billing.charge accountant model_fusion_billing_charge account.group_account_manager 1 1 1 0
11 access_fusion_billing_reconciliation_acct fusion.billing.reconciliation accountant model_fusion_billing_reconciliation account.group_account_manager 1 1 1 0

View File

@@ -0,0 +1,5 @@
from . import test_identity
from . import test_charge
from . import test_usage
from . import test_api
from . import test_webhook

View File

@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestApiHandlers(TransactionCase):
def setUp(self):
super().setUp()
self.service = self.env['fusion.billing.service'].sudo().create(
{'name': 'NexaMaps', 'code': 'nexamaps'})
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'})
def test_api_upsert_customer(self):
res = self.service._api_upsert_customer(
{'external_id': 'client-9', 'name': 'Globex', 'email': 'billing@globex.test'})
self.assertEqual(res['status'], 'ok')
link = self.env['fusion.billing.account.link'].search(
[('service_id', '=', self.service.id), ('external_id', '=', 'client-9')])
self.assertEqual(link.partner_id.name, 'Globex')
def test_api_record_usage_batch(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})
res = self.service._api_record_usage({'events': [{
'subscription_external_id': str(sub.id), 'metric_code': 'api_calls',
'quantity': 1234.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
'idempotency_key': 'maps:client-9:2026-05-01',
}]})
self.assertEqual(res['accepted'], 1)
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
self.assertEqual(usage.quantity, 1234.0)
def test_api_catalog_lists_active_charges(self):
self.env['fusion.billing.charge'].sudo().create({
'name': 'Maps overage', 'plan_code': 'maps-business',
'metric_id': self.env['fusion.billing.metric'].search([('code', '=', 'api_calls')]).id,
'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0})
cat = self.service._api_catalog()
codes = [c['plan_code'] for c in cat['charges']]
self.assertIn('maps-business', codes)
def test_api_create_subscription(self):
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
product = self.env['product.product'].sudo().create(
{'name': 'Maps Business', 'type': 'service', 'recurring_invoice': True,
'list_price': 249.0})
res = self.service._api_create_subscription({
'external_customer_id': 'client-9',
'plan_id': self.plan.id,
'lines': [{'product_id': product.id, 'quantity': 1}],
})
self.assertEqual(res['status'], 'ok')
sub = self.env['sale.order'].browse(res['subscription_id'])
self.assertTrue(sub.is_subscription)
self.assertEqual(sub.plan_id, self.plan)
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)]))

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
from psycopg2 import IntegrityError
from odoo.tests.common import TransactionCase, tagged
from odoo.tools.misc import mute_logger
@tagged('post_install', '-at_install')
class TestChargeMath(TransactionCase):
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
def _charge(self, **kw):
vals = {
'name': 'Maps overage', 'plan_code': 'maps-business',
'metric_id': self.metric.id, 'charge_model': 'standard',
'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
}
vals.update(kw)
return self.env['fusion.billing.charge'].sudo().create(vals)
def test_under_quota_is_free(self):
charge = self._charge()
overage_units, amount = charge._compute_billable(4_000_000.0)
self.assertEqual(overage_units, 0.0)
self.assertEqual(amount, 0.0)
def test_standard_overage_per_1k(self):
charge = self._charge()
# 6,000,000 used - 5,000,000 quota = 1,000,000 overage = 1000 batches * $0.10
overage_units, amount = charge._compute_billable(6_000_000.0)
self.assertEqual(overage_units, 1_000_000.0)
self.assertAlmostEqual(amount, 100.0, places=2)
def test_partial_batch_rounds_up(self):
charge = self._charge(included_quota=0.0)
# 1,500 units, batch 1000 -> 2 batches -> $0.20
_, amount = charge._compute_billable(1_500.0)
self.assertAlmostEqual(amount, 0.20, places=2)
def test_package_model_charges_whole_packages(self):
charge = self._charge(charge_model='package', included_quota=0.0, unit_batch=1000.0, price_per_unit=2.0)
# 2,001 units -> 3 packages -> $6.00
_, amount = charge._compute_billable(2_001.0)
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)

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceApiKey(TransactionCase):
def setUp(self):
super().setUp()
self.Service = self.env['fusion.billing.service'].sudo()
self.service = self.Service.create({'name': 'NexaCloud', 'code': 'nexacloud'})
def test_generate_and_match_api_key(self):
raw = self.service.action_generate_api_key()
self.assertTrue(raw and len(raw) >= 20)
self.assertTrue(self.service.api_key_hash)
self.assertNotEqual(raw, self.service.api_key_hash) # only the hash is stored
matched = self.Service._match_api_key(raw)
self.assertEqual(matched, self.service)
def test_match_api_key_rejects_unknown_and_inactive(self):
raw = self.service.action_generate_api_key()
self.assertFalse(self.Service._match_api_key('nope-not-a-key'))
self.service.active = False
self.assertFalse(self.Service._match_api_key(raw))
@tagged('post_install', '-at_install')
class TestIdentityResolution(TransactionCase):
def setUp(self):
super().setUp()
self.service = self.env['fusion.billing.service'].sudo().create(
{'name': 'NexaDesk', 'code': 'nexadesk'})
self.Link = self.env['fusion.billing.account.link'].sudo()
def test_creates_partner_first_time(self):
link = self.Link._resolve_or_create_partner(
self.service, external_id='tenant-1', name='Acme Inc', email='ar@acme.test')
self.assertTrue(link.partner_id)
self.assertEqual(link.partner_id.name, 'Acme Inc')
self.assertEqual(link.external_id, 'tenant-1')
def test_idempotent_same_external_id(self):
a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test')
b = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme Renamed', 'ar@acme.test')
self.assertEqual(a, b) # same link row
self.assertEqual(a.partner_id, b.partner_id) # same partner
def test_reuses_partner_by_email_across_services(self):
other = self.env['fusion.billing.service'].sudo().create({'name': 'Maps', 'code': 'nexamaps'})
a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test')
b = self.Link._resolve_or_create_partner(other, 'client-9', 'Acme', 'ar@acme.test')
self.assertEqual(a.partner_id, b.partner_id) # one unified customer
self.assertNotEqual(a, b) # but distinct link rows

View File

@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
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')
class TestUsageIngestion(TransactionCase):
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
self.sub = self.env['sale.order'].sudo().create({
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
})
self.Usage = self.env['fusion.billing.usage'].sudo()
def test_record_usage_creates_row(self):
u = self.Usage._record_usage(
self.sub, 'cpu_seconds', 120.0,
'2026-05-01 00:00:00', '2026-06-01 00:00:00', idem='nexacloud:cpu:sub1:2026-05-01')
self.assertEqual(u.quantity, 120.0)
self.assertEqual(u.metric_id, self.metric)
def test_idempotent_key_updates_not_duplicates(self):
k = 'nexacloud:cpu:sub1:2026-05-01'
self.Usage._record_usage(self.sub, 'cpu_seconds', 100.0, '2026-05-01', '2026-06-01', idem=k)
self.Usage._record_usage(self.sub, 'cpu_seconds', 175.0, '2026-05-01', '2026-06-01', idem=k)
rows = self.Usage.search([('idempotency_key', '=', k)])
self.assertEqual(len(rows), 1) # no duplicate
self.assertEqual(rows.quantity, 175.0) # last value wins for the same key
def test_aggregate_sum(self):
for i, q in enumerate([10.0, 20.0, 30.0]):
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
'2026-05-01', '2026-06-01', idem='cpu-%d' % i)
total = self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01')
self.assertEqual(total, 60.0)
def test_aggregate_max(self):
self.metric.aggregation = 'max'
for i, q in enumerate([10.0, 55.0, 30.0]):
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
'2026-05-01', '2026-06-01', idem='m-%d' % i)
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 55.0)
def test_aggregate_excludes_other_periods(self):
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.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)

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
import hashlib
import hmac
import json
from unittest.mock import patch
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestWebhookEngine(TransactionCase):
def setUp(self):
super().setUp()
self.service = self.env['fusion.billing.service'].sudo().create({
'name': 'NexaCloud', 'code': 'nexacloud',
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
'webhook_secret': 'whsec_test',
})
self.Webhook = self.env['fusion.billing.webhook'].sudo()
def test_enqueue_signs_payload(self):
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'})
self.assertEqual(wh.state, 'pending')
body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':'))
expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest()
self.assertEqual(wh.signature, expected)
def test_dispatch_marks_sent_on_2xx(self):
wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'})
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)
self.assertEqual(wh.state, 'sent')
def test_dispatch_retries_then_deadletters(self):
wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'})
wh.write({'attempts': 7}) # already past max
class _Resp:
status_code = 500
text = 'err'
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
return_value=_Resp()):
self.Webhook._cron_dispatch()
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)

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Helpdesk Reporter',
'version': '19.0.1.3.0',
'version': '19.0.1.4.1',
'category': 'Productivity',
'summary': 'One-click in-app bug reporting & feature requesting — '
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
@@ -27,6 +27,7 @@ module bundle. No dependencies on the rest of Fusion Plating.
'license': 'OPL-1',
'depends': ['base', 'web', 'mail'],
'data': [
'security/fusion_helpdesk_groups.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'views/res_config_settings_views.xml',

View File

@@ -23,6 +23,15 @@ from odoo import _, http
from odoo.exceptions import UserError
from odoo.http import request
from odoo.addons.fusion_helpdesk.utils import (
build_ticket_vals,
build_scope_domain,
is_public_message,
compute_unread_count,
escape_like,
_norm_email,
)
_logger = logging.getLogger(__name__)
@@ -34,7 +43,7 @@ class FusionHelpdeskController(http.Controller):
)
def submit(self, kind, subject, description,
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
Helpdesk and return {ok, ticket_id, ticket_url, error}.
@@ -60,10 +69,6 @@ class FusionHelpdeskController(http.Controller):
}
# ---- 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 = []
if description:
body_parts.append(
@@ -77,12 +82,22 @@ class FusionHelpdeskController(http.Controller):
)
body_parts.append(self._build_diag_block(page_url, user_agent))
ticket_vals = {
'name': full_subject,
'description': '\n'.join(body_parts),
}
if cfg['team_id']:
ticket_vals['team_id'] = cfg['team_id']
# Identity keystone: send the reporter's name + email so the central
# helpdesk find-or-creates the customer partner and subscribes them as
# a follower — which is what enables reply emails, the magic link, and
# the scoped "My Tickets" inbox. reply_email is the (editable) value the
# user confirmed in the dialog; fall back to their Odoo email/login.
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 --------------------------------------
try:
@@ -133,7 +148,12 @@ class FusionHelpdeskController(http.Controller):
return _network_error_response(cfg['url'], e)
# ---- 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
failed = 0
for att in attachments or []:
data_b64 = (att or {}).get('data_b64')
name = (att or {}).get('name') or 'attachment.bin'
@@ -152,10 +172,12 @@ class FusionHelpdeskController(http.Controller):
}],
)
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(
'fusion_helpdesk: attachment "%s" upload failed: %s',
name, e.faultString,
name, e,
)
ticket_url = urljoin(
@@ -163,9 +185,9 @@ class FusionHelpdeskController(http.Controller):
'odoo/helpdesk/%s' % ticket_id,
)
_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',
ticket_id, attached, cfg['url'],
ticket_id, attached, failed, cfg['url'],
request.env.user.login,
)
return {
@@ -173,6 +195,7 @@ class FusionHelpdeskController(http.Controller):
'ticket_id': ticket_id,
'ticket_url': ticket_url,
'attached': attached,
'failed': failed,
}
# ------------------------------------------------------------------
@@ -351,6 +374,318 @@ class FusionHelpdeskController(http.Controller):
body += '</table>'
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):
return (

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import res_config_settings
from . import fusion_helpdesk_ticket_seen

View 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}

View 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>

View File

@@ -1 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fhd_seen_user fusion.helpdesk.ticket.seen.user model_fusion_helpdesk_ticket_seen base.group_user 1 1 1 1

View File

@@ -1,13 +1,20 @@
/** @odoo-module **/
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or
// Feature, fill in subject + description, paste an error code, attach
// files, and capture a screenshot via the browser's getDisplayMedia
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit
// which forwards it (XML-RPC) to a central Odoo Helpdesk.
// Fusion Helpdesk — submission + follow-up dialog.
//
// Two tabs:
// • New — report a bug / request a feature (the original form),
// 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 { rpc } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
@@ -18,16 +25,20 @@ export class FusionHelpdeskDialog extends Component {
static components = { Dialog };
static props = {
close: Function,
initialTab: { type: String, optional: true },
};
setup() {
this.notification = useService("notification");
this.state = useState({
kind: "bug", // 'bug' | 'feature'
tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread'
// ---- New report ----
kind: "bug",
subject: "",
description: "",
errorCode: "",
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}]
replyEmail: user.login || "",
attachments: [],
capturing: false,
submitting: false,
error: "",
@@ -35,21 +46,146 @@ export class FusionHelpdeskDialog extends Component {
ticketId: null,
ticketUrl: "",
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() {
return this.state.kind === "bug"
? _t("Report a Bug")
: _t("Request a Feature");
if (this.state.tab === "thread" && this.state.current) {
return this.state.current.subject;
}
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) {
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) {
const files = Array.from(ev.target.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 = "";
}
@@ -92,8 +227,6 @@ export class FusionHelpdeskDialog extends Component {
});
}
// ------------------------------------------------------------------
// Screenshot capture via getDisplayMedia
async onTakeScreenshot() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
this.notification.add(
@@ -119,7 +252,6 @@ export class FusionHelpdeskDialog extends Component {
rawSize: blob.size,
});
} catch (err) {
// User cancelled the picker — silently swallow. Other errors → notify.
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
this.notification.add(
_t("Screenshot failed: %s").replace("%s", err.message || err),
@@ -138,7 +270,6 @@ export class FusionHelpdeskDialog extends Component {
const video = document.createElement("video");
video.srcObject = stream;
await video.play();
// Give the browser one frame to settle the picker chrome.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
@@ -195,8 +326,9 @@ export class FusionHelpdeskDialog extends Component {
return "fa fa-file-o";
}
// ------------------------------------------------------------------
// Submit
// ==================================================================
// New report — submit
// ==================================================================
async onSubmit() {
if (this.state.submitting) return;
const subject = (this.state.subject || "").trim();
@@ -213,6 +345,7 @@ export class FusionHelpdeskDialog extends Component {
subject,
description: this.state.description || "",
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
reply_email: (this.state.replyEmail || "").trim(),
attachments: this.state.attachments.map((a) => ({
name: a.name,
mimetype: a.mimetype,
@@ -229,13 +362,14 @@ export class FusionHelpdeskDialog extends Component {
this.state.ticketId = res.ticket_id;
this.state.ticketUrl = res.ticket_url;
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.description = "";
this.state.errorCode = "";
this.state.attachments = [];
}
} catch (err) {
console.error("fusion_helpdesk: submit failed", err);
this.state.error = (err && err.message) || _t("Network error.");
} finally {
this.state.submitting = false;

View File

@@ -1,24 +1,52 @@
/** @odoo-module **/
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon
// appears to the LEFT of the attendance check-in button. Odoo
// systray ordering is by sequence ascending (lower = leftmost in the
// systray bar). hr_attendance ships at sequence 100, so we use 99.
// Fusion Helpdesk — top systray icon with an unread-reply badge.
// Sequence 99 places it just left of the attendance check-in button.
import { Component } from "@odoo/owl";
import { Component, useState, onWillStart, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
const POLL_MS = 120000; // refresh the unread badge every 2 minutes
class FusionHelpdeskSystray extends Component {
static template = "fusion_helpdesk.SystrayItem";
static props = {};
setup() {
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() {
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() }
);
}
}

View File

@@ -170,3 +170,170 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
&: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;
}
}

View File

@@ -4,105 +4,225 @@
<t t-name="fusion_helpdesk.Dialog">
<Dialog title="dialogTitle" size="'lg'">
<div class="o_fhd_dialog">
<!-- Kind selector -->
<div class="o_fhd_kind_row">
<button type="button"
class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
t-on-click="() => this.setKind('bug')">
<i class="fa fa-bug me-1"/> Report a Bug
<!-- ===== 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_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
t-on-click="() => this.setKind('feature')">
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
<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>
<!-- Subject -->
<div class="o_fhd_field">
<label>Subject *</label>
<input type="text" class="form-control"
t-att-value="state.subject"
t-on-input="(ev) => state.subject = ev.target.value"
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
</div>
<!-- Description -->
<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'"/>
<textarea class="form-control" rows="5"
t-att-value="state.description"
t-on-input="(ev) => state.description = ev.target.value"
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
</div>
<!-- Error code (bug only) -->
<div class="o_fhd_field" t-if="state.kind === 'bug'">
<label>
Error code / traceback
<span class="o_fhd_hint">paste any error message or stack trace</span>
</label>
<textarea class="form-control o_fhd_mono" rows="3"
t-att-value="state.errorCode"
t-on-input="(ev) => state.errorCode = ev.target.value"
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
</div>
<!-- Attachments -->
<div class="o_fhd_field">
<label>Attachments</label>
<div class="o_fhd_actions_row">
<label class="o_fhd_btn o_fhd_btn_secondary">
<i class="fa fa-paperclip me-1"/> Attach files
<input type="file" multiple="multiple" class="d-none"
t-on-change="onFilesPicked"/>
</label>
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
t-on-click="onTakeScreenshot"
t-att-disabled="state.capturing">
<i class="fa fa-camera me-1"/>
<t t-if="state.capturing">Capturing…</t>
<t t-else="">Capture screenshot</t>
<!-- ===== NEW report ===== -->
<div t-if="state.tab === 'new'">
<div class="o_fhd_kind_row">
<button type="button" class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
t-on-click="() => this.setKind('bug')">
<i class="fa fa-bug me-1"/> Report a Bug
</button>
<button type="button" class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
t-on-click="() => this.setKind('feature')">
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
</button>
</div>
<div t-if="state.attachments.length" class="o_fhd_attach_list">
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
class="o_fhd_attach_item">
<i t-att-class="att.iconClass"/>
<span class="o_fhd_attach_name" t-esc="att.name"/>
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
<button type="button" class="o_fhd_attach_remove"
t-on-click="() => this.removeAttachment(att_index)">×</button>
<div class="o_fhd_field">
<label>Subject *</label>
<input type="text" class="form-control"
t-att-value="state.subject"
t-on-input="(ev) => state.subject = ev.target.value"
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
</div>
<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">
<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"
t-att-value="state.description"
t-on-input="(ev) => state.description = ev.target.value"
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
</div>
<div class="o_fhd_field" t-if="state.kind === 'bug'">
<label>
Error code / traceback
<span class="o_fhd_hint">paste any error message or stack trace</span>
</label>
<textarea class="form-control o_fhd_mono" rows="3"
t-att-value="state.errorCode"
t-on-input="(ev) => state.errorCode = ev.target.value"
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
</div>
<div class="o_fhd_field">
<label>Attachments</label>
<div class="o_fhd_actions_row">
<label class="o_fhd_btn o_fhd_btn_secondary">
<i class="fa fa-paperclip me-1"/> Attach files
<input type="file" multiple="multiple" class="d-none"
t-on-change="onFilesPicked"/>
</label>
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
t-on-click="onTakeScreenshot"
t-att-disabled="state.capturing">
<i class="fa fa-camera me-1"/>
<t t-if="state.capturing">Capturing…</t>
<t t-else="">Capture screenshot</t>
</button>
</div>
<div t-if="state.attachments.length" class="o_fhd_attach_list">
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
class="o_fhd_attach_item">
<i t-att-class="att.iconClass"/>
<span class="o_fhd_attach_name" t-esc="att.name"/>
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
<button type="button" class="o_fhd_attach_remove"
t-on-click="() => this.removeAttachment(att_index)">×</button>
</div>
</div>
</div>
<div t-if="state.error" class="alert alert-danger mt-2">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
</div>
<div t-if="state.success" class="alert alert-success mt-2">
<i class="fa fa-check-circle me-1"/>
Thanks — ticket
<a t-att-href="state.ticketUrl" target="_blank">#<t t-esc="state.ticketId"/></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>
<!-- ===== 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>
<!-- Result feedback -->
<div t-if="state.error" class="alert alert-danger mt-2">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
</div>
<div t-if="state.success" class="alert alert-success mt-2">
<i class="fa fa-check-circle me-1"/>
Thanks — ticket
<a t-att-href="state.ticketUrl" target="_blank">
#<t t-esc="state.ticketId"/>
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
<!-- ===== 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">
<button class="btn btn-primary"
t-on-click="onSubmit"
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-else=""><i class="fa fa-paper-plane me-1"/></t>
Submit
</button>
<button class="btn btn-secondary" t-on-click="props.close">
Close
</button>
<t t-if="state.tab === 'new'">
<button class="btn btn-primary" t-on-click="onSubmit"
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-else=""><i class="fa fa-paper-plane me-1"/></t>
Submit
</button>
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
</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 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>
</Dialog>
</t>

View File

@@ -5,11 +5,14 @@
<div class="o_fhd_systray dropdown">
<button type="button"
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">
<img src="/fusion_helpdesk/static/description/help_icon.png"
alt="Help"
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>
</div>
</t>

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_utils
from . import test_seen

View 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})

View File

@@ -0,0 +1,131 @@
# -*- 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,
_norm_email,
)
@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)
@tagged('post_install', '-at_install', 'fusion_helpdesk')
class TestNormEmail(TransactionCase):
def test_valid_email_is_normalised_lowercase(self):
self.assertEqual(_norm_email('John@Entech.COM'), 'john@entech.com')
def test_first_valid_candidate_wins(self):
# confirmed reply email empty -> fall back to the next valid one
self.assertEqual(_norm_email('', 'not an email', 'jane@x.com'), 'jane@x.com')
def test_wildcard_is_rejected(self):
# IDOR guard: a self-set '%' must not survive as a scope key
self.assertEqual(_norm_email('%'), '')
def test_non_email_login_falls_through_to_empty(self):
self.assertEqual(_norm_email('admin', 'also-not-email', ''), '')
def test_controller_namespace_resolves_norm_email(self):
# Regression: _norm_email was called in controllers/main.py
# (submit + _identity) but never imported/defined -> NameError on
# every inbox endpoint. Guard that the name is resolvable there.
from odoo.addons.fusion_helpdesk.controllers import main
self.assertTrue(hasattr(main, '_norm_email'))

117
fusion_helpdesk/utils.py Normal file
View File

@@ -0,0 +1,117 @@
# -*- 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.
"""
from odoo.tools import email_normalize
# 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 _norm_email(*candidates):
"""Return the first candidate that normalises to a valid email, else ''.
Used to derive the inbox scope key from a chain of fallbacks (the
confirmed reply email -> ``user.email`` -> ``user.login``).
``email_normalize`` lowercases the address and returns a falsy value for
anything that is not exactly one valid email — including a self-set
wildcard like ``%`` — so the value fed into ``build_scope_domain`` can
never widen the scope. Pairs with :func:`escape_like` as defense in depth
against the ``partner_email =ilike`` IDOR.
"""
for candidate in candidates:
normalized = email_normalize(candidate or '')
if normalized:
return normalized
return ''
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

View File

@@ -3,7 +3,7 @@
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.1.0.2',
'version': '19.0.1.1.0',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',
@@ -28,7 +28,9 @@ Depends only on `helpdesk`. No client-side install needed.
'data': [
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/mail_template_ack.xml',
'views/fusion_helpdesk_client_key_views.xml',
'views/helpdesk_ticket_views.xml',
],
'installable': True,
'auto_install': False,

View 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 &amp; 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>

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import fusion_helpdesk_client_key
from . import helpdesk_ticket

View 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,
)

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_identity

View 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")

View 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>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Login Audit',
'version': '19.0.1.0.0',
'category': 'Tools',
'summary': 'Durable login audit log with geo-enrichment, retention, and failure alerts.',
'description': """
Fusion Login Audit
==================
Captures every password authentication event (success + failure) in a
dedicated, append-only audit table. Surfaces history on the user form
as a smart button + tab (admins only). Async-enriches IPs with country,
city, and reverse DNS. Emails Settings admins on consecutive-failure
bursts. Daily retention cron honours a configurable horizon.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'license': 'OPL-1',
'depends': ['base', 'mail', 'base_setup'],
'external_dependencies': {
'python': ['user_agents'],
},
'data': [
'security/ir.model.access.csv',
'security/security.xml',
'data/mail_template_data.xml',
'data/ir_cron_data.xml',
'views/fusion_login_audit_views.xml',
'views/res_users_views.xml',
'views/res_config_settings_views.xml',
'views/menus.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="cron_retention_gc" model="ir.cron">
<field name="name">Fusion Login Audit: Retention GC</field>
<field name="model_id" ref="model_fusion_login_audit"/>
<field name="state">code</field>
<field name="code">model._fc_retention_gc()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<record id="cron_geo_enrich" model="ir.cron">
<field name="name">Fusion Login Audit: Geo Enrichment</field>
<field name="model_id" ref="model_fusion_login_audit"/>
<field name="state">code</field>
<field name="code">model._fc_geo_enrich_pending(limit=100)</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
<field name="priority">10</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_failure_burst" model="mail.template">
<field name="name">Fusion Login Audit — Failure Burst Alert</field>
<field name="model_id" ref="base.model_res_users"/>
<field name="subject">[Login Audit] Failed login attempts for {{ ctx.get('attempted_login') }}</field>
<field name="body_html" type="html">
<div>
<p>The login audit detected
<strong t-out="ctx.get('failure_count')"/> failed login attempt(s)
in the last <t t-out="ctx.get('window_min')"/> minute(s) for
<strong t-out="ctx.get('attempted_login')"/>.</p>
<p>Most recent attempts:</p>
<table border="1" cellpadding="4" cellspacing="0"
style="border-collapse: collapse; font-family: sans-serif; font-size: 12px;">
<thead style="background: #f3f4f6;">
<tr>
<th>Time</th>
<th>IP</th>
<th>Country</th>
<th>Browser</th>
<th>OS</th>
</tr>
</thead>
<tbody>
<tr t-foreach="ctx.get('rows', [])" t-as="row">
<td t-out="row['event_time']"/>
<td t-out="row['ip_address']"/>
<td t-out="row.get('country_code') or ''"/>
<td t-out="row.get('browser') or ''"/>
<td t-out="row.get('os') or ''"/>
</tr>
</tbody>
</table>
<p style="color: #6b7280; font-size: 11px;">
Sent by Fusion Login Audit. Tune the threshold and window in
Settings → General Settings → Login Audit.
</p>
</div>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import fusion_login_audit
from . import res_users
from . import res_config_settings

View File

@@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
import ipaddress
import logging
import socket
from datetime import timedelta
import requests
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionLoginAudit(models.Model):
_name = 'fusion.login.audit'
_description = 'Login Audit Event'
_order = 'event_time desc, id desc'
_rec_name = 'attempted_login'
user_id = fields.Many2one(
'res.users', string='User', ondelete='set null', index=True,
help='Null when the attempted login did not match any user.',
)
attempted_login = fields.Char(
string='Attempted Login', size=255, required=True, index=True,
)
result = fields.Selection(
[('success', 'Success'), ('failure', 'Failure')],
string='Result', required=True, index=True,
)
failure_reason = fields.Selection(
[
('bad_password', 'Bad password'),
('unknown_user', 'Unknown user'),
('disabled_user', 'Disabled user'),
('2fa_failed', '2FA failed'),
('other', 'Other'),
],
string='Failure Reason',
)
event_time = fields.Datetime(
string='Event Time', required=True, index=True,
default=fields.Datetime.now,
)
ip_address = fields.Char(string='IP Address', size=45)
ip_hostname = fields.Char(string='Reverse DNS', size=255)
country_code = fields.Char(string='Country Code', size=2, index=True)
country_name = fields.Char(string='Country', size=64)
city = fields.Char(string='City', size=128)
geo_state = fields.Char(string='Region', size=64)
geo_lookup_state = fields.Selection(
[
('pending', 'Pending'),
('done', 'Done'),
('private_ip', 'Private IP'),
('internal', 'Internal (no request)'),
('failed', 'Lookup failed'),
],
string='Geo Lookup State', default='pending', index=True,
)
user_agent_raw = fields.Char(string='User Agent', size=512)
browser = fields.Char(string='Browser', size=64)
os = fields.Char(string='OS', size=64)
device_type = fields.Selection(
[
('desktop', 'Desktop'),
('mobile', 'Mobile'),
('tablet', 'Tablet'),
('bot', 'Bot'),
('unknown', 'Unknown'),
],
string='Device Type', default='unknown',
)
database = fields.Char(string='Database', size=64)
# Odoo 19 replaces the legacy `_sql_constraints = [...]` list with
# declarative `models.Constraint` attributes. The plan template used the
# legacy form, which now only emits a warning and is silently dropped.
_result_failure_reason_consistent = models.Constraint(
"CHECK ((result = 'success' AND failure_reason IS NULL) "
"OR (result = 'failure' AND failure_reason IS NOT NULL))",
'A failure row must have a failure_reason; a success row must not.',
)
# Composite indexes supporting the three hot queries:
# - per-user history (user_id, event_time DESC)
# - failure-burst by login (attempted_login, event_time DESC)
# - geo cron worklist (geo_lookup_state, event_time)
# Odoo 19 ships `models.Index` as the declarative replacement for the
# init()/raw-SQL pattern; the attribute name becomes the index suffix
# (e.g. `_user_time_idx` -> `fusion_login_audit_user_time_idx`).
_user_time_idx = models.Index('(user_id, event_time DESC)')
_login_time_idx = models.Index('(attempted_login, event_time DESC)')
_geo_state_idx = models.Index('(geo_lookup_state, event_time)')
@api.model
def _fc_retention_gc(self):
"""Delete audit rows older than `fusion_login_audit.retention_days`.
Called daily by ir.cron. retention_days=0 means keep forever."""
ICP = self.env['ir.config_parameter'].sudo()
try:
days = int(ICP.get_param(
'fusion_login_audit.retention_days', 365))
except (TypeError, ValueError):
days = 365
if days <= 0:
return 0
cutoff = fields.Datetime.now() - timedelta(days=days)
old = self.sudo().search([('event_time', '<', cutoff)])
count = len(old)
if old:
old.unlink()
return count
_FC_PRIVATE_NETWORKS = (
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fe80::/10'),
)
@api.model
def _fc_is_private_ip(self, ip):
if not ip or ip == 'internal':
return False # 'internal' uses its own state
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return any(addr in net for net in self._FC_PRIVATE_NETWORKS)
@api.model
def _fc_geo_cache_hit(self, ip):
"""Return a dict of geo fields if we've resolved this IP in the last
30 days, else None."""
if not ip:
return None
cutoff = fields.Datetime.now() - timedelta(days=30)
cached = self.sudo().search([
('ip_address', '=', ip),
('geo_lookup_state', '=', 'done'),
('event_time', '>=', cutoff),
], limit=1, order='event_time desc')
if not cached:
return None
return {
'country_code': cached.country_code,
'country_name': cached.country_name,
'city': cached.city,
'geo_state': cached.geo_state,
'ip_hostname': cached.ip_hostname,
}
@api.model
def _fc_geo_reverse_dns(self, ip):
try:
socket.setdefaulttimeout(1.5)
host, _aliases, _ips = socket.gethostbyaddr(ip)
return (host or '')[:255]
except (socket.herror, socket.gaierror, OSError):
return ''
finally:
socket.setdefaulttimeout(None)
@api.model
def _fc_geo_http_lookup(self, ip):
"""Call ip-api.com. Returns (vals_dict, rate_limited_bool).
Falls back to ({}, False) on any error."""
try:
resp = requests.get(
'http://ip-api.com/json/' + ip,
params={'fields': 'status,country,countryCode,regionName,city'},
timeout=3,
headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'},
)
rate_limited = resp.headers.get('X-Rl', '') == '0'
if resp.status_code != 200:
return ({}, rate_limited)
data = resp.json() or {}
if data.get('status') != 'success':
return ({}, rate_limited)
return ({
'country_code': (data.get('countryCode') or '')[:2],
'country_name': (data.get('country') or '')[:64],
'geo_state': (data.get('regionName') or '')[:64],
'city': (data.get('city') or '')[:128],
}, rate_limited)
except (requests.RequestException, ValueError):
_logger.warning("fusion_login_audit: geo lookup failed for %s",
ip, exc_info=True)
return ({}, False)
@api.model
def _fc_geo_enrich_pending(self, limit=100):
"""Cron worker: process up to `limit` pending rows.
Per-row isolation is provided by `cr.savepoint()` rather than
`cr.commit()`/`cr.rollback()` — the latter raises an AssertionError
inside a TransactionCase (Odoo's test cursor refuses commit/rollback).
Savepoints work in both prod and tests; the outer cron transaction
commits the lot once the method returns. One bad IP rolls back only
its own savepoint, so the rest of the batch still lands.
"""
pending = self.sudo().search(
[('geo_lookup_state', '=', 'pending')],
order='event_time asc', limit=limit,
)
if not pending:
return 0
processed = 0
stop_after_this = False
for row in pending:
ip = row.ip_address
try:
with self.env.cr.savepoint():
if self._fc_is_private_ip(ip):
row.write({
'geo_lookup_state': 'private_ip',
'country_code': '--',
'country_name': 'Private network',
'city': 'Private network',
})
processed += 1
continue
cached = self._fc_geo_cache_hit(ip)
if cached:
cached['geo_lookup_state'] = 'done'
row.write(cached)
processed += 1
continue
hostname = self._fc_geo_reverse_dns(ip) if ip and ip != 'internal' else ''
vals, rate_limited = self._fc_geo_http_lookup(ip) if ip and ip != 'internal' else ({}, False)
if vals:
vals['ip_hostname'] = hostname
vals['geo_lookup_state'] = 'done'
row.write(vals)
else:
row.write({
'geo_lookup_state': 'failed',
'ip_hostname': hostname,
})
processed += 1
if rate_limited:
_logger.info("fusion_login_audit: ip-api rate limit "
"hit, stopping batch early")
stop_after_this = True
except Exception:
_logger.exception(
"fusion_login_audit: geo enrich failed for row %s", row.id)
if stop_after_this:
break
return processed

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
x_fc_login_audit_retention_days = fields.Integer(
string='Login Audit Retention (days)',
default=365,
config_parameter='fusion_login_audit.retention_days',
help='Login audit rows older than this are deleted by the nightly '
'cron. Set to 0 to keep forever.',
)
x_fc_login_audit_alert_threshold = fields.Integer(
string='Alert After N Consecutive Failures',
default=5,
config_parameter='fusion_login_audit.alert_threshold',
help='When this many failures for the same attempted login occur '
'within the alert window, Settings admins receive one email.',
)
x_fc_login_audit_alert_window_min = fields.Integer(
string='Alert Window (minutes)',
default=15,
config_parameter='fusion_login_audit.alert_window_min',
)
x_fc_login_audit_alert_enabled = fields.Boolean(
string='Send Failed-Login Alerts',
default=True,
config_parameter='fusion_login_audit.alert_enabled',
)

View File

@@ -0,0 +1,392 @@
# -*- coding: utf-8 -*-
import logging
from odoo import _, api, fields, models
from odoo.exceptions import AccessDenied
# Top-level import (vs lazy inside the method): if the dep is missing — most
# likely because the dev container got recreated and dropped its pip install
# (see CLAUDE.md Workflow) — Odoo crashes at registry load with a clear
# `ModuleNotFoundError`, not deep in the auth path after the first login.
from user_agents import parse as ua_parse
_logger = logging.getLogger(__name__)
class ResUsers(models.Model):
_inherit = 'res.users'
@api.model
def _fc_build_event_vals(
self,
result,
attempted_login,
failure_reason=None,
user_id=None,
_override_ip=None,
_override_ua=None,
_credential=None,
):
"""Build the dict of values for a fusion.login.audit row.
Pulls IP / User-Agent from the live HTTP request when available.
Falls back to ('internal', '<no-request>') for XML-RPC / cron-initiated
auth, with geo_lookup_state='internal' so the geo cron skips them.
An empty IP from an otherwise-live request (rare; misconfigured
reverse proxy) also routes to the 'internal' fallback — an empty
string isn't useful audit data and is arguably suspicious.
The _override_* kwargs exist for tests so we don't have to fake a
full request. They are NOT a public API.
Password safety: `_credential` MAY contain a 'password' key from the
Odoo auth flow. We never read that key, never log it, never put it
in vals. The test `test_build_event_vals_strips_password` locks
this property in via `assertNotIn(secret, repr(vals))`.
"""
vals = {
'attempted_login': (attempted_login or '')[:255],
'result': result,
'failure_reason': failure_reason,
'event_time': fields.Datetime.now(),
'database': self.env.cr.dbname,
'user_id': user_id,
}
ip = _override_ip
ua_str = _override_ua
if ip is None or ua_str is None:
try:
from odoo.http import request
if request and getattr(request, 'httprequest', None):
if ip is None:
ip = request.httprequest.remote_addr
if ua_str is None:
ua_str = request.httprequest.user_agent.string or ''
except Exception:
_logger.debug("fusion_login_audit: no request context", exc_info=True)
if ip and ua_str is not None:
ua_text = ua_str or ''
vals['ip_address'] = ip[:45]
vals['user_agent_raw'] = ua_text[:512]
ua = ua_parse(ua_text)
vals['browser'] = (f"{ua.browser.family} {ua.browser.version_string}".strip())[:64]
vals['os'] = (f"{ua.os.family} {ua.os.version_string}".strip())[:64]
if ua.is_bot:
vals['device_type'] = 'bot'
elif ua.is_mobile:
vals['device_type'] = 'mobile'
elif ua.is_tablet:
vals['device_type'] = 'tablet'
elif ua.is_pc:
vals['device_type'] = 'desktop'
else:
vals['device_type'] = 'unknown'
vals['geo_lookup_state'] = 'pending'
else:
vals['ip_address'] = 'internal'
vals['user_agent_raw'] = '<no-request>'
vals['device_type'] = 'unknown'
vals['geo_lookup_state'] = 'internal'
# _credential is accepted in the signature so callers (T6 _check_credentials,
# T7 _login) can hand the dict in without filtering. The helper deliberately
# touches NO keys from it — see the password-safety note in the docstring.
# `_credential` is intentionally unread here; the parameter exists so future
# work can read `credential.get('type')` for `2fa_failed` discrimination
# only via the explicit failure_reason kwarg, never from the dict directly.
del _credential # explicit no-op — locks down the read surface
return vals
def _fc_record_login_event(self, result, failure_reason=None,
user_id=None, attempted_login=None,
_credential=None):
"""Build vals + create the audit row via sudo. Never raises.
The row is written through an INDEPENDENT cursor
(``registry.cursor()``) so that:
* A failure-path call from ``_check_credentials`` survives the
outer transaction rollback that follows ``AccessDenied``
(the HTTP layer closes the cursor without committing, see
``odoo/service/model.py:retrying``).
* A broken audit table can never block a real user from logging
in: the cursor block is wrapped in try/except; exceptions are
logged and swallowed.
The independent cursor commits on context exit. Note that this
means the row is durable even if the caller's transaction later
rolls back — intentional for audit semantics: a recorded bad
password should NOT disappear because some unrelated downstream
op blew up.
"""
try:
vals = self._fc_build_event_vals(
result=result,
attempted_login=attempted_login
or (self.login if self else None)
or 'unknown',
failure_reason=failure_reason,
user_id=user_id or (self.id if self else None),
_credential=_credential,
)
with self.env.registry.cursor() as audit_cr:
audit_env = api.Environment(audit_cr, self.env.uid, self.env.context)
audit_env['fusion.login.audit'].sudo().with_context(
mail_create_nolog=True
).create(vals)
except Exception:
_logger.exception(
"fusion_login_audit: failed to record %s row for %s",
result, attempted_login or (self.login if self else 'unknown'),
)
def _update_last_login(self):
result = super()._update_last_login()
# Self is the singleton recordset of the user that just logged in.
self._fc_record_login_event(result='success')
return result
def _fc_alert_threshold(self):
ICP = self.env['ir.config_parameter'].sudo()
try:
return max(1, int(ICP.get_param(
'fusion_login_audit.alert_threshold', 5)))
except (TypeError, ValueError):
return 5
def _fc_alert_window_min(self):
ICP = self.env['ir.config_parameter'].sudo()
try:
return max(1, int(ICP.get_param(
'fusion_login_audit.alert_window_min', 15)))
except (TypeError, ValueError):
return 15
def _fc_alert_enabled(self):
ICP = self.env['ir.config_parameter'].sudo()
# CLAUDE.md rule #5: Boolean config_parameter deletes on False.
# An absent key means True (the default). Explicit 'False' or 'false'
# means disabled.
raw = ICP.get_param('fusion_login_audit.alert_enabled', 'True')
return str(raw).strip().lower() != 'false'
def _fc_recent_failure_count(self, attempted_login):
"""Failures for this attempted_login within the alert window."""
from datetime import timedelta
if not attempted_login:
return 0
cutoff = fields.Datetime.now() - timedelta(
minutes=self._fc_alert_window_min())
return self.env['fusion.login.audit'].sudo().search_count([
('attempted_login', '=', attempted_login),
('result', '=', 'failure'),
('event_time', '>=', cutoff),
])
def _fc_send_failure_alert(self, attempted_login):
"""Queue one alert mail unless cooldown is active. Cooldown is
60 minutes, keyed by attempted_login, stored in ir.config_parameter."""
from datetime import timedelta
if not self._fc_alert_enabled():
return
if not attempted_login:
return
ICP = self.env['ir.config_parameter'].sudo()
cd_key = f'fusion_login_audit.last_alert:{attempted_login}'
cd_raw = ICP.get_param(cd_key)
now = fields.Datetime.now()
if cd_raw:
try:
last = fields.Datetime.from_string(cd_raw)
if last and (now - last) < timedelta(minutes=60):
return # cooldown active
except (TypeError, ValueError):
pass
window = self._fc_alert_window_min()
cutoff = now - timedelta(minutes=window)
Audit = self.env['fusion.login.audit'].sudo()
rows = Audit.search([
('attempted_login', '=', attempted_login),
('result', '=', 'failure'),
('event_time', '>=', cutoff),
], order='event_time desc', limit=20)
# Admin recipients: members of base.group_system (the Settings group)
# who have an email set and are not portal/share users. Note:
# CLAUDE.md rule #6 — res.groups has no `users` field in Odoo 19, so
# search res.users by group_ids directly. The __system__ superuser
# (uid=1) is excluded automatically by Odoo's default user filter.
admins = self.env['res.users'].sudo().search([
('group_ids', 'in', self.env.ref('base.group_system').id),
('email', '!=', False),
('share', '=', False),
])
if not admins:
return
tmpl = self.env.ref(
'fusion_login_audit.mail_template_failure_burst',
raise_if_not_found=False)
if not tmpl:
return
# CLAUDE.md rule #12: in mail.template QWeb, `ctx` IS env.context.
# So `ctx.get('foo')` resolves to env.context.get('foo'). Pass data
# by SPREADING keys into the context, not wrapping in a dict.
# `with_context(ctx=ctx_data)` would silently render an empty subject.
ctx_data = {
'attempted_login': attempted_login,
'failure_count': len(rows),
'window_min': window,
'rows': [{
'event_time': fields.Datetime.to_string(r.event_time),
'ip_address': r.ip_address or '',
'country_code': r.country_code or '',
'browser': r.browser or '',
'os': r.os or '',
} for r in rows],
}
for admin in admins:
tmpl.with_context(**ctx_data).send_mail(
admin.id,
email_values={'email_to': admin.email,
'auto_delete': True},
force_send=False,
)
ICP.set_param(cd_key, fields.Datetime.to_string(now))
def _check_credentials(self, credential, env):
try:
return super()._check_credentials(credential, env)
except AccessDenied:
cred_type = (credential or {}).get('type', 'password')
reason = '2fa_failed' if cred_type == 'totp' else 'bad_password'
attempted_login = (credential or {}).get('login') or self.login
self._fc_record_login_event(
result='failure',
failure_reason=reason,
user_id=self.id,
attempted_login=attempted_login,
_credential=credential,
)
try:
if self._fc_recent_failure_count(attempted_login) \
>= self._fc_alert_threshold():
self._fc_send_failure_alert(attempted_login)
except Exception:
_logger.exception(
"fusion_login_audit: failed to send failure alert")
raise
def _login(self, credential, user_agent_env):
"""Catch the unknown-user branch of upstream _login.
In Odoo 19 ``_login`` is an *instance* method (not a classmethod as in
earlier versions). Upstream raises ``AccessDenied`` in three cases:
1. Unknown login string — ``_assert_can_auth`` or the user-lookup
``search()`` returns empty → ``_check_credentials`` never fires →
THIS override is the only chance to record the attempt.
2. Wrong password — user exists, ``_check_credentials`` raises →
our ``_check_credentials`` override already wrote a ``bad_password``
row → re-raise propagates up to here. We MUST NOT write a second
row.
3. 2FA failure — same as #2 but ``failure_reason='2fa_failed'``.
We distinguish #1 from #2/#3 by checking whether the login string
resolves to any user. If it does, ``_check_credentials`` ran (and
already logged); if it doesn't, the user lookup failed and we log
``unknown_user`` here.
``_fc_record_login_event`` writes through an INDEPENDENT cursor
(``self.env.registry.cursor()``), so the audit row survives the
outer transaction rollback that follows the re-raised
``AccessDenied``. Audit-side exceptions never block the re-raise.
"""
try:
return super()._login(credential, user_agent_env)
except AccessDenied:
login = (credential or {}).get('login') or ''
try:
user_exists = bool(self.sudo().search(
[('login', '=', login)], limit=1))
except Exception:
user_exists = False # be permissive — log the row anyway
if not user_exists:
self._fc_record_login_event(
result='failure',
failure_reason='unknown_user',
user_id=False,
attempted_login=login or 'unknown',
_credential=credential,
)
raise
# ──────────────────────────────────────────────────────────────────
# Per-user surface — fields + action method backing the smart button
# and "Login Activity" tab on the user form view.
# ──────────────────────────────────────────────────────────────────
x_fc_login_audit_ids = fields.One2many(
'fusion.login.audit', 'user_id',
string='Login Activity',
)
x_fc_login_audit_count = fields.Integer(
string='Login Audit Count',
compute='_compute_x_fc_login_audit_count',
)
x_fc_last_successful_login = fields.Datetime(
string='Last Successful Login',
compute='_compute_x_fc_last_successful_login',
store=True,
)
x_fc_last_login_ip = fields.Char(
string='Last Login IP', size=45,
compute='_compute_x_fc_last_successful_login',
store=True,
)
@api.depends('x_fc_login_audit_ids')
def _compute_x_fc_login_audit_count(self):
# Odoo 19: read_group → _read_group, returns list of tuples
# (group_key, aggregate_value) when given groupby + aggregates.
Audit = self.env['fusion.login.audit'].sudo()
rows = Audit._read_group(
domain=[('user_id', 'in', self.ids)],
groupby=['user_id'],
aggregates=['__count'],
)
counts = {user.id: count for user, count in rows}
for user in self:
user.x_fc_login_audit_count = counts.get(user.id, 0)
@api.depends('x_fc_login_audit_ids.event_time',
'x_fc_login_audit_ids.result',
'x_fc_login_audit_ids.ip_address')
def _compute_x_fc_last_successful_login(self):
Audit = self.env['fusion.login.audit'].sudo()
for user in self:
row = Audit.search(
[('user_id', '=', user.id), ('result', '=', 'success')],
order='event_time desc', limit=1,
)
user.x_fc_last_successful_login = row.event_time or False
user.x_fc_last_login_ip = row.ip_address or False
def action_fc_view_login_audit(self):
self.ensure_one()
return {
'name': _('Login Activity'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.login.audit',
'view_mode': 'list,form',
'domain': [('user_id', '=', self.id)],
'context': {'create': False, 'edit': False, 'delete': False,
'default_user_id': self.id},
}

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_login_audit_system,fusion.login.audit system,model_fusion_login_audit,base.group_system,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_login_audit_system fusion.login.audit system model_fusion_login_audit base.group_system 1 0 0 0

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="rule_fusion_login_audit_admin_read" model="ir.rule">
<field name="name">fusion.login.audit: admin read only</field>
<field name="model_id" ref="model_fusion_login_audit"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_login_audit
from . import test_security

View File

@@ -0,0 +1,541 @@
# -*- coding: utf-8 -*-
from odoo import fields
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestFusionLoginAuditModel(TransactionCase):
def setUp(self):
# `_fc_record_login_event` uses `registry.cursor()` so that the audit
# row survives the outer rollback that follows AccessDenied (see
# res_users.py for the rationale). Inside a TransactionCase that
# rolls back per test, a fresh cursor on a new connection cannot
# see uncommitted records (the freshly-created test user FKs into
# the audit row), so we put the registry in test mode — that swaps
# `registry.cursor()` for a TestCursor that wraps the test cursor.
super().setUp()
self.registry_enter_test_mode()
# The alert tests below assume at least one admin has an email
# (otherwise the recipient filter empties and no mail is queued).
# In a fresh fusion-dev DB, base.user_admin's email is NULL; the
# superuser (__system__) has an email but is filtered out of normal
# res.users searches. Ensure admin has a usable email.
admin = self.env.ref('base.user_admin')
if not admin.email:
admin.sudo().write({'email': 'admin@test.example.com'})
def test_model_exists_and_creates(self):
"""Audit row can be created with all expected fields."""
Audit = self.env['fusion.login.audit'].sudo()
rec = Audit.create({
'attempted_login': 'demo@example.com',
'result': 'success',
'ip_address': '203.0.113.5',
'user_agent_raw': 'Mozilla/5.0 Test',
'browser': 'Test 1.0',
'os': 'TestOS',
'device_type': 'desktop',
'database': self.env.cr.dbname,
'geo_lookup_state': 'pending',
})
self.assertTrue(rec.id)
self.assertEqual(rec.result, 'success')
self.assertEqual(rec.geo_lookup_state, 'pending')
self.assertEqual(rec.database, self.env.cr.dbname)
self.assertTrue(rec.event_time) # default fires
def test_failure_reason_optional(self):
"""failure_reason is null on success rows."""
rec = self.env['fusion.login.audit'].sudo().create({
'attempted_login': 'demo@example.com',
'result': 'success',
})
self.assertFalse(rec.failure_reason)
def test_geo_state_internal_value(self):
"""`internal` is an accepted geo_lookup_state value (distinct from private_ip)."""
rec = self.env['fusion.login.audit'].sudo().create({
'attempted_login': 'demo@example.com',
'result': 'success',
'geo_lookup_state': 'internal',
})
self.assertEqual(rec.geo_lookup_state, 'internal')
def test_build_event_vals_with_no_request(self):
"""Without a live request, geo_lookup_state is 'internal'."""
ResUsers = self.env['res.users']
vals = ResUsers._fc_build_event_vals(
result='success',
attempted_login='cron@example.com',
)
self.assertEqual(vals['result'], 'success')
self.assertEqual(vals['attempted_login'], 'cron@example.com')
self.assertEqual(vals['ip_address'], 'internal')
self.assertEqual(vals['user_agent_raw'], '<no-request>')
self.assertEqual(vals['geo_lookup_state'], 'internal')
self.assertEqual(vals['database'], self.env.cr.dbname)
def test_build_event_vals_parses_user_agent(self):
"""Parser fills browser/os/device_type from a stub UA dict."""
ResUsers = self.env['res.users']
vals = ResUsers._fc_build_event_vals(
result='success',
attempted_login='ua@example.com',
_override_ip='203.0.113.5',
_override_ua='Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 Chrome/140.0 Safari/537.36',
)
self.assertEqual(vals['ip_address'], '203.0.113.5')
self.assertIn('Chrome', vals['browser'])
self.assertIn('Windows', vals['os'])
self.assertEqual(vals['device_type'], 'desktop')
self.assertEqual(vals['geo_lookup_state'], 'pending')
def test_build_event_vals_strips_password(self):
"""If a credential dict sneaks in, no password leaks into vals."""
ResUsers = self.env['res.users']
vals = ResUsers._fc_build_event_vals(
result='failure',
attempted_login='leak@example.com',
failure_reason='bad_password',
_credential={'login': 'leak@example.com',
'password': 'super-secret-pw',
'type': 'password'},
)
serialized = repr(vals)
self.assertNotIn('super-secret-pw', serialized)
self.assertEqual(vals['failure_reason'], 'bad_password')
def test_update_last_login_writes_audit_row(self):
"""Calling _update_last_login on a user creates a success row."""
user = self.env['res.users'].sudo().create({
'name': 'Audit Tester',
'login': 'audit-tester@example.com',
'password': 'audit-tester-pw-1',
})
Audit = self.env['fusion.login.audit'].sudo()
before = Audit.search_count([('user_id', '=', user.id)])
user._update_last_login()
after = Audit.search_count([('user_id', '=', user.id)])
self.assertEqual(after, before + 1)
row = Audit.search([('user_id', '=', user.id)],
order='event_time desc', limit=1)
self.assertEqual(row.result, 'success')
self.assertEqual(row.attempted_login, user.login)
self.assertFalse(row.failure_reason)
self.assertEqual(row.database, self.env.cr.dbname)
def test_audit_write_failure_does_not_block_login(self):
"""An exception inside the audit write must not propagate."""
from unittest.mock import patch
user = self.env['res.users'].sudo().create({
'name': 'Resilient Tester',
'login': 'resilient-tester@example.com',
'password': 'resilient-tester-pw-1',
})
def boom(self_, vals):
raise RuntimeError('simulated audit DB failure')
with patch.object(type(self.env['fusion.login.audit']),
'create', boom):
# Must not raise.
user._update_last_login()
def test_bad_password_writes_failure_row(self):
"""A wrong password creates a result=failure row with failure_reason='bad_password'."""
from odoo.exceptions import AccessDenied
user = self.env['res.users'].sudo().create({
'name': 'Wrongpw Tester',
'login': 'wrongpw-tester@example.com',
'password': 'wrongpw-tester-pw-1',
})
Audit = self.env['fusion.login.audit'].sudo()
before = Audit.search_count([('attempted_login', '=', user.login),
('result', '=', 'failure')])
# NB: cannot use `self.assertRaises(AccessDenied)` — it opens an extra
# savepoint (see odoo/tests/common.py::_assertRaises) that rolls back
# the audit row written from inside the override.
raised = False
try:
user._check_credentials(
{'login': user.login, 'password': 'definitely-wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
raised = True
self.assertTrue(raised, "AccessDenied not raised on wrong password")
after = Audit.search_count([('attempted_login', '=', user.login),
('result', '=', 'failure')])
self.assertEqual(after, before + 1)
row = Audit.search([('attempted_login', '=', user.login),
('result', '=', 'failure')],
order='event_time desc', limit=1)
self.assertEqual(row.failure_reason, 'bad_password')
self.assertEqual(row.user_id, user)
def test_bad_password_never_appears_in_row(self):
"""The attempted password string never lands in any field."""
from odoo.exceptions import AccessDenied
secret = 'NeverInTheRow-9f3a82'
user = self.env['res.users'].sudo().create({
'name': 'Leak Test',
'login': 'leak-test-2@example.com',
'password': 'leak-test-pw-1',
})
# NB: manual try/except instead of assertRaises — see note above.
raised = False
try:
user._check_credentials(
{'login': user.login, 'password': secret, 'type': 'password'},
{'interactive': False},
)
except AccessDenied:
raised = True
self.assertTrue(raised, "AccessDenied not raised on wrong password")
row = self.env['fusion.login.audit'].sudo().search(
[('attempted_login', '=', user.login),
('result', '=', 'failure')],
order='event_time desc', limit=1)
self.assertTrue(row, "Audit row not created for bad-password attempt")
for fname in ('attempted_login', 'failure_reason', 'user_agent_raw',
'browser', 'os', 'ip_address', 'ip_hostname',
'city', 'country_name', 'country_code', 'geo_state',
'database'):
self.assertNotIn(secret, (row[fname] or ''),
f"Password leaked into field {fname}")
def test_unknown_user_writes_failure_row(self):
"""A login attempt for a username that does not exist gets logged
with user_id=NULL and failure_reason='unknown_user'."""
from odoo.exceptions import AccessDenied
bogus = 'this-user-does-not-exist@example.com'
Audit = self.env['fusion.login.audit'].sudo()
before = Audit.search_count([('attempted_login', '=', bogus)])
# NB: manual try/except instead of assertRaises — see comment in
# test_bad_password_writes_failure_row. _login is an instance method
# in Odoo 19 (not a classmethod as in earlier versions); we call it
# on the empty recordset of res.users, which matches what
# authenticate() does internally.
raised = False
try:
self.env['res.users']._login(
{'login': bogus, 'password': 'whatever',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
raised = True
self.assertTrue(raised, "AccessDenied must propagate after the audit write")
after = Audit.search_count([('attempted_login', '=', bogus)])
self.assertEqual(after, before + 1)
row = Audit.search([('attempted_login', '=', bogus)],
order='event_time desc', limit=1)
self.assertFalse(row.user_id)
self.assertEqual(row.failure_reason, 'unknown_user')
self.assertEqual(row.result, 'failure')
def test_login_known_user_bad_password_single_row(self):
"""When _login is the entry point for an existing user with the
wrong password, only ONE failure row is written (bad_password from
_check_credentials) — NOT two (bad_password + unknown_user). The
unknown_user branch must only fire when the login string does not
resolve to any user.
Regression test for the duplicate-row bug discovered during the
production deploy smoke on westin-v19: a single failed login for
an existing user was creating two audit rows.
"""
from odoo.exceptions import AccessDenied
user = self.env['res.users'].sudo().create({
'name': 'NoDupTester',
'login': 'nodup-tester@example.com',
'password': 'nodup-tester-pw-1',
})
Audit = self.env['fusion.login.audit'].sudo()
before = Audit.search_count([('attempted_login', '=', user.login)])
raised = False
try:
self.env['res.users']._login(
{'login': user.login, 'password': 'wrong-not-the-real-one',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
raised = True
self.assertTrue(raised)
after = Audit.search_count([('attempted_login', '=', user.login)])
self.assertEqual(after - before, 1,
"Exactly one row per failed login attempt — not two")
row = Audit.search([('attempted_login', '=', user.login)],
order='event_time desc', limit=1)
self.assertEqual(row.failure_reason, 'bad_password',
"Existing-user failure must record bad_password, "
"not unknown_user (the user IS in the system)")
def test_computed_last_successful_login(self):
"""x_fc_last_successful_login reflects the latest success row."""
user = self.env['res.users'].sudo().create({
'name': 'Compute Tester',
'login': 'compute-tester@example.com',
'password': 'compute-tester-pw-1',
})
# Use registry cursor so the audit row survives the transactional
# boundary the way the auth-time path does.
with self.env.registry.cursor() as audit_cr:
from odoo import api
audit_env = api.Environment(audit_cr, self.env.uid, self.env.context)
audit_env['fusion.login.audit'].sudo().create({
'user_id': user.id,
'attempted_login': user.login,
'result': 'success',
'database': self.env.cr.dbname,
'ip_address': '198.51.100.42',
})
user.invalidate_recordset(['x_fc_last_successful_login',
'x_fc_login_audit_count',
'x_fc_last_login_ip'])
self.assertTrue(user.x_fc_last_successful_login)
self.assertGreaterEqual(user.x_fc_login_audit_count, 1)
self.assertEqual(user.x_fc_last_login_ip, '198.51.100.42')
def test_action_view_login_audit_returns_window_action(self):
"""The smart-button action returns an act_window scoped to this user."""
user = self.env['res.users'].sudo().create({
'name': 'Action Tester',
'login': 'action-tester@example.com',
'password': 'action-tester-pw-1',
})
action = user.action_fc_view_login_audit()
self.assertEqual(action['res_model'], 'fusion.login.audit')
self.assertEqual(action['type'], 'ir.actions.act_window')
# Domain must filter to this user
self.assertIn(('user_id', '=', user.id), action['domain'])
def test_settings_round_trip(self):
"""Writing settings persists them via ir.config_parameter."""
Settings = self.env['res.config.settings'].sudo()
Settings.create({
'x_fc_login_audit_retention_days': 90,
'x_fc_login_audit_alert_threshold': 3,
'x_fc_login_audit_alert_window_min': 5,
'x_fc_login_audit_alert_enabled': False,
}).execute()
ICP = self.env['ir.config_parameter'].sudo()
self.assertEqual(ICP.get_param('fusion_login_audit.retention_days'), '90')
self.assertEqual(ICP.get_param('fusion_login_audit.alert_threshold'), '3')
self.assertEqual(ICP.get_param('fusion_login_audit.alert_window_min'), '5')
# Odoo's set_param deletes the row when the value is falsy, so a
# Boolean field set to False yields get_param() == False (Python
# bool, the default), not the string 'False'.
self.assertFalse(ICP.get_param('fusion_login_audit.alert_enabled'))
def test_failure_burst_queues_one_email(self):
"""N consecutive failures (within window) queue exactly one mail.mail."""
from odoo.exceptions import AccessDenied
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_login_audit.alert_threshold', '3')
ICP.set_param('fusion_login_audit.alert_window_min', '15')
ICP.set_param('fusion_login_audit.alert_enabled', 'True')
# Clear any cooldown leftover from earlier tests.
ICP.set_param('fusion_login_audit.last_alert:burst@example.com', '')
user = self.env['res.users'].sudo().create({
'name': 'Burst Tester',
'login': 'burst@example.com',
'password': 'burst-tester-pw-1',
})
Mail = self.env['mail.mail'].sudo()
before = Mail.search_count([('subject', 'ilike', 'burst@example.com')])
for _i in range(3):
raised = False
try:
user._check_credentials(
{'login': user.login, 'password': 'wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
raised = True
self.assertTrue(raised)
after = Mail.search_count([('subject', 'ilike', 'burst@example.com')])
self.assertEqual(after, before + 1,
"Exactly one alert mail should be queued")
def test_cooldown_suppresses_second_alert(self):
"""Failures beyond the threshold within the cooldown queue zero more emails."""
from odoo.exceptions import AccessDenied
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_login_audit.alert_threshold', '3')
ICP.set_param('fusion_login_audit.alert_window_min', '15')
ICP.set_param('fusion_login_audit.alert_enabled', 'True')
ICP.set_param('fusion_login_audit.last_alert:cool@example.com', '')
user = self.env['res.users'].sudo().create({
'name': 'Cooldown Tester',
'login': 'cool@example.com',
'password': 'cooldown-tester-pw-1',
})
Mail = self.env['mail.mail'].sudo()
for _i in range(3):
try:
user._check_credentials(
{'login': user.login, 'password': 'wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
pass
count_after_3 = Mail.search_count([('subject', 'ilike', 'cool@example.com')])
for _i in range(2):
try:
user._check_credentials(
{'login': user.login, 'password': 'wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
pass
count_after_5 = Mail.search_count([('subject', 'ilike', 'cool@example.com')])
self.assertEqual(count_after_5, count_after_3,
"Cooldown should suppress additional emails")
def test_alert_disabled_master_switch(self):
"""alert_enabled=False suppresses all alerts regardless of threshold."""
from odoo.exceptions import AccessDenied
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_login_audit.alert_threshold', '1')
ICP.set_param('fusion_login_audit.alert_window_min', '15')
# Use the actual boolean field's storage semantics — see CLAUDE.md rule #5.
# Writing False through the settings form deletes the param; here we
# set the string 'False' explicitly to simulate "disabled".
ICP.set_param('fusion_login_audit.alert_enabled', 'False')
ICP.set_param('fusion_login_audit.last_alert:disabled@example.com', '')
user = self.env['res.users'].sudo().create({
'name': 'Disabled Tester',
'login': 'disabled@example.com',
'password': 'disabled-tester-pw-1',
})
Mail = self.env['mail.mail'].sudo()
before = Mail.search_count([('subject', 'ilike', 'disabled@example.com')])
try:
user._check_credentials(
{'login': user.login, 'password': 'wrong',
'type': 'password'},
{'interactive': False},
)
except AccessDenied:
pass
after = Mail.search_count([('subject', 'ilike', 'disabled@example.com')])
self.assertEqual(after, before, "Disabled alerts should queue nothing")
def test_retention_gc_deletes_old_rows(self):
"""The GC method deletes rows older than retention_days."""
from datetime import timedelta
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_login_audit.retention_days', '30')
now = fields.Datetime.now()
Audit = self.env['fusion.login.audit'].sudo()
old = Audit.create({
'attempted_login': 'gc-old@example.com',
'result': 'success',
'event_time': now - timedelta(days=45),
})
recent = Audit.create({
'attempted_login': 'gc-recent@example.com',
'result': 'success',
'event_time': now - timedelta(days=5),
})
old_id, recent_id = old.id, recent.id
Audit._fc_retention_gc()
self.assertFalse(Audit.browse(old_id).exists(),
"Row older than retention_days should be gone")
self.assertTrue(Audit.browse(recent_id).exists(),
"Row inside retention_days should survive")
def test_retention_zero_keeps_forever(self):
"""retention_days=0 keeps all rows."""
from datetime import timedelta
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_login_audit.retention_days', '0')
now = fields.Datetime.now()
Audit = self.env['fusion.login.audit'].sudo()
ancient = Audit.create({
'attempted_login': 'forever@example.com',
'result': 'success',
'event_time': now - timedelta(days=3650),
})
ancient_id = ancient.id
Audit._fc_retention_gc()
self.assertTrue(Audit.browse(ancient_id).exists(),
"retention_days=0 must keep everything")
def test_geo_private_ip_shortcut(self):
"""Private IPs short-circuit to state='private_ip' without HTTP."""
Audit = self.env['fusion.login.audit'].sudo()
rec = Audit.create({
'attempted_login': 'lan@example.com',
'result': 'success',
'ip_address': '192.168.1.40',
'geo_lookup_state': 'pending',
})
Audit._fc_geo_enrich_pending(limit=10)
rec.invalidate_recordset()
self.assertEqual(rec.geo_lookup_state, 'private_ip')
self.assertEqual(rec.country_code, '--')
def test_geo_cache_hit_avoids_http(self):
"""A second row with the same recent IP copies from cache."""
from unittest.mock import patch
Audit = self.env['fusion.login.audit'].sudo()
# Seed a "done" row from the same IP.
Audit.create({
'attempted_login': 'seed@example.com',
'result': 'success',
'ip_address': '203.0.113.99',
'geo_lookup_state': 'done',
'country_code': 'CA',
'country_name': 'Canada',
'city': 'Toronto',
'geo_state': 'Ontario',
})
target = Audit.create({
'attempted_login': 'hit@example.com',
'result': 'success',
'ip_address': '203.0.113.99',
'geo_lookup_state': 'pending',
})
with patch(
'odoo.addons.fusion_login_audit.models.fusion_login_audit.requests.get'
) as mock_get:
Audit._fc_geo_enrich_pending(limit=10)
mock_get.assert_not_called()
target.invalidate_recordset()
self.assertEqual(target.geo_lookup_state, 'done')
self.assertEqual(target.country_code, 'CA')
self.assertEqual(target.city, 'Toronto')
def test_geo_internal_skipped(self):
"""Rows with geo_lookup_state='internal' are not picked up."""
Audit = self.env['fusion.login.audit'].sudo()
rec = Audit.create({
'attempted_login': 'cron@example.com',
'result': 'success',
'ip_address': 'internal',
'geo_lookup_state': 'internal',
})
# Should be a no-op for 'internal' state (cron only picks 'pending').
Audit._fc_geo_enrich_pending(limit=10)
rec.invalidate_recordset()
self.assertEqual(rec.geo_lookup_state, 'internal')

View File

@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
from odoo.exceptions import AccessError
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestFusionLoginAuditSecurity(TransactionCase):
"""Tests for the layered protection on `fusion.login.audit`:
Layer 1 — ACL (security/ir.model.access.csv): grants read-only access to
`base.group_system` and nothing to any other group. Blocks write/create/
unlink for everyone via the ORM regardless of `sudo()`.
Layer 2 — Record rule (security/security.xml): group-specific rule that
grants admins an unrestricted domain (`[(1,'=',1)]`). The rule does NOT
actively restrict non-admins — Odoo's semantics for a group-scoped rule
is "the rule only applies to users in that group". Non-admins are gated
purely by the ACL, which denies them everything. The rule's value is
documentation + future-proofing (it keeps admin access explicit if the
ACL is ever loosened with a per-group read row; the admin path remains
explicit and self-documenting). It is NOT a security gate the ACL relies on.
Test naming reflects which layer actually does the work:
- test_acl_blocks_* — exercises Layer 1 (ACL alone is sufficient).
- test_admin_can_read_through_acl_and_rule — exercises both layers in
the positive path (admin must satisfy ACL grant
AND the admin-scoped rule's domain).
"""
def setUp(self):
super().setUp()
self.audit_row = self.env['fusion.login.audit'].sudo().create({
'attempted_login': 'sec-test@example.com',
'result': 'success',
'database': self.env.cr.dbname,
})
# Internal non-admin user (active employee, not a Settings admin).
self.regular_user = self.env['res.users'].sudo().create({
'name': 'Regular Tester',
'login': 'regular-tester@example.com',
'password': 'regular-tester-pw-1',
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])],
})
# Portal user (share=True) — must not see audit data either.
self.portal_user = self.env['res.users'].sudo().create({
'name': 'Portal Tester',
'login': 'portal-tester@example.com',
'password': 'portal-tester-pw-1',
'group_ids': [(6, 0, [self.env.ref('base.group_portal').id])],
})
def test_admin_can_read_through_acl_and_rule(self):
"""A Settings admin satisfies both the ACL (grants read) and the
record rule (admin-only domain), so the read succeeds."""
admin = self.env.ref('base.user_admin')
rec = self.audit_row.with_user(admin).read(['attempted_login'])
self.assertEqual(rec[0]['attempted_login'], 'sec-test@example.com')
def test_acl_blocks_read_for_regular_user(self):
"""A `base.group_user` member has no ACL grant on the model. The
ACL alone denies the read; the record rule never gets consulted."""
with self.assertRaises(AccessError):
self.audit_row.with_user(self.regular_user).read(['attempted_login'])
def test_acl_blocks_read_for_portal_user(self):
"""A `base.group_portal` (share=True) user has no ACL grant either.
Audit data must never leak to a portal user — IP and attempted_login
are sensitive."""
with self.assertRaises(AccessError):
self.audit_row.with_user(self.portal_user).read(['attempted_login'])
def test_acl_blocks_write_for_admin(self):
"""Even Settings admins cannot write — the ACL grants no group any
write permission on this model (audit log is append-only). The rule's
`perm_write=False` means 'rule does not constrain this op', so this
denial is the ACL's work alone."""
admin = self.env.ref('base.user_admin')
with self.assertRaises(AccessError):
self.audit_row.with_user(admin).write({'attempted_login': 'tampered'})
def test_acl_blocks_unlink_for_admin(self):
"""Append-only also at the unlink boundary. ACL grants no group
delete permission; the record rule's `perm_unlink=False` exempts
it from gating this op."""
admin = self.env.ref('base.user_admin')
with self.assertRaises(AccessError):
self.audit_row.with_user(admin).unlink()
# Note: a "rule actively blocks non-admins" test was attempted but
# removed once the actual Odoo semantics were verified. A group-scoped
# rule (groups=[base.group_system]) only applies to users in that group.
# Granting a base.group_user member an ACL read row would let them read
# rows — the rule does not filter them. To make the rule truly restrictive
# we would need a global rule (groups=[]) with domain [(0,'=',1)] paired
# with the admin grant. That is a security-model redesign and out of
# scope for T3. The ACL already provides the actual gate.
# ─────────────────────────────────────────────────────────────────────
# T14: view-level visibility checks. The smart button and the "Login
# Activity" tab on res.users are gated by groups="base.group_system"
# on the inner XML nodes (the inherited view record itself cannot
# carry groups — CLAUDE.md rule #11). Verify the gate works by asking
# for the form view as a non-admin and confirming the x_fc_* fields
# are stripped from the arch.
# ─────────────────────────────────────────────────────────────────────
def test_view_hides_button_and_tab_for_non_admin(self):
"""A regular user's get_view() does not contain the x_fc_login_audit_*
fields — they live behind groups="base.group_system" XML attributes."""
view = self.env['res.users'].with_user(self.regular_user).get_view(
view_id=self.env.ref('base.view_users_form').id,
view_type='form',
)
arch = view['arch']
self.assertNotIn('x_fc_login_audit_count', arch,
"Smart-button field must not leak into non-admin view")
self.assertNotIn('x_fc_login_audit_ids', arch,
"Login Activity tab must not leak into non-admin view")
def test_view_shows_button_and_tab_for_admin(self):
"""A Settings admin DOES see both nodes."""
admin = self.env.ref('base.user_admin')
view = self.env['res.users'].with_user(admin).get_view(
view_id=self.env.ref('base.view_users_form').id,
view_type='form',
)
arch = view['arch']
self.assertIn('x_fc_login_audit_count', arch)
self.assertIn('x_fc_login_audit_ids', arch)

View File

@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List -->
<record id="view_fusion_login_audit_list" model="ir.ui.view">
<field name="name">fusion.login.audit.list</field>
<field name="model">fusion.login.audit</field>
<field name="arch" type="xml">
<list create="false" edit="false" delete="false"
default_order="event_time desc"
decoration-success="result=='success'"
decoration-danger="result=='failure'">
<field name="event_time"/>
<field name="user_id"/>
<field name="attempted_login"/>
<field name="result" widget="badge"/>
<field name="failure_reason"/>
<field name="ip_address"/>
<field name="country_code"/>
<field name="city"/>
<field name="browser"/>
<field name="device_type"/>
<field name="database" optional="hide"/>
</list>
</field>
</record>
<!-- Form (readonly) -->
<record id="view_fusion_login_audit_form" model="ir.ui.view">
<field name="name">fusion.login.audit.form</field>
<field name="model">fusion.login.audit</field>
<field name="arch" type="xml">
<form create="false" edit="false" delete="false">
<sheet>
<group>
<group string="Event">
<field name="event_time" readonly="1"/>
<field name="result" readonly="1" widget="badge"/>
<field name="failure_reason" readonly="1"/>
<field name="user_id" readonly="1"/>
<field name="attempted_login" readonly="1"/>
<field name="database" readonly="1"/>
</group>
<group string="Source">
<field name="ip_address" readonly="1"/>
<field name="ip_hostname" readonly="1"/>
<field name="country_code" readonly="1"/>
<field name="country_name" readonly="1"/>
<field name="geo_state" readonly="1"/>
<field name="city" readonly="1"/>
<field name="geo_lookup_state" readonly="1"/>
</group>
</group>
<group string="Client">
<field name="device_type" readonly="1"/>
<field name="browser" readonly="1"/>
<field name="os" readonly="1"/>
<field name="user_agent_raw" readonly="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Search -->
<record id="view_fusion_login_audit_search" model="ir.ui.view">
<field name="name">fusion.login.audit.search</field>
<field name="model">fusion.login.audit</field>
<field name="arch" type="xml">
<search>
<field name="attempted_login"/>
<field name="user_id"/>
<field name="ip_address"/>
<field name="country_code"/>
<filter name="filter_success" string="Successes"
domain="[('result','=','success')]"/>
<filter name="filter_failure" string="Failures"
domain="[('result','=','failure')]"/>
<separator/>
<filter name="filter_24h" string="Last 24 hours"
domain="[('event_time','&gt;=', (context_today() - relativedelta(days=1)).strftime('%Y-%m-%d 00:00:00'))]"/>
<filter name="filter_7d" string="Last 7 days"
domain="[('event_time','&gt;=', (context_today() - relativedelta(days=7)).strftime('%Y-%m-%d 00:00:00'))]"/>
<filter name="filter_30d" string="Last 30 days"
domain="[('event_time','&gt;=', (context_today() - relativedelta(days=30)).strftime('%Y-%m-%d 00:00:00'))]"/>
<separator/>
<filter name="filter_unknown_user" string="Unknown users"
domain="[('user_id','=',False)]"/>
<group>
<filter name="group_user" string="User"
context="{'group_by': 'user_id'}"/>
<filter name="group_country" string="Country"
context="{'group_by': 'country_code'}"/>
<filter name="group_ip" string="IP"
context="{'group_by': 'ip_address'}"/>
</group>
</search>
</field>
</record>
<!-- Window actions -->
<record id="action_fusion_login_audit_all" model="ir.actions.act_window">
<field name="name">Login Events</field>
<field name="res_model">fusion.login.audit</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_login_audit_search"/>
<field name="context">{}</field>
</record>
<record id="action_fusion_login_audit_failures_24h" model="ir.actions.act_window">
<field name="name">Failed Logins (24h)</field>
<field name="res_model">fusion.login.audit</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_login_audit_search"/>
<field name="context">{'search_default_filter_failure': 1, 'search_default_filter_24h': 1}</field>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_fusion_login_audit_root"
name="Login Audit"
parent="base.menu_administration"
groups="base.group_system"
sequence="100"/>
<menuitem id="menu_fusion_login_audit_all"
name="Login Events"
parent="menu_fusion_login_audit_root"
action="action_fusion_login_audit_all"
groups="base.group_system"
sequence="10"/>
<menuitem id="menu_fusion_login_audit_failures"
name="Failed Logins (24h)"
parent="menu_fusion_login_audit_root"
action="action_fusion_login_audit_failures_24h"
groups="base.group_system"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_res_config_settings_form_login_audit" model="ir.ui.view">
<field name="name">res.config.settings.form.login.audit</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@id='user_default_rights']" position="after">
<block title="Login Audit"
name="login_audit_block"
groups="base.group_system">
<setting id="login_audit_retention"
string="Retention (days)"
help="0 = keep forever">
<field name="x_fc_login_audit_retention_days"/>
</setting>
<setting id="login_audit_alert_enabled"
string="Send failed-login alerts"
help="Email Settings admins when consecutive failures cross the threshold">
<field name="x_fc_login_audit_alert_enabled"/>
</setting>
<setting id="login_audit_alert_threshold"
string="Alert threshold (failures)">
<field name="x_fc_login_audit_alert_threshold"/>
</setting>
<setting id="login_audit_alert_window"
string="Alert window (minutes)">
<field name="x_fc_login_audit_alert_window_min"/>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_users_form_inherit_login_audit" model="ir.ui.view">
<field name="name">res.users.form.inherit.fusion_login_audit</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<!-- Odoo 19: groups MUST be on the inherited XML nodes (button + page
below), NOT on the ir.ui.view record itself. Setting `group_ids`
on the record raises ParseError "Inherited view cannot have
'groups' defined on the record. Use 'groups' attributes inside
the view definition". -->
<field name="arch" type="xml">
<!-- Smart button -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_fc_view_login_audit"
type="object"
class="oe_stat_button"
icon="fa-key"
groups="base.group_system">
<field name="x_fc_login_audit_count" widget="statinfo"
string="Logins"/>
</button>
</xpath>
<!-- Login Activity tab appended at the end of the notebook -->
<xpath expr="//notebook" position="inside">
<page string="Login Activity"
name="fc_login_activity"
groups="base.group_system">
<group>
<field name="x_fc_last_successful_login" readonly="1"/>
<field name="x_fc_last_login_ip" readonly="1"/>
</group>
<field name="x_fc_login_audit_ids" readonly="1"
context="{'create': False, 'edit': False, 'delete': False}">
<list create="false" edit="false" delete="false"
limit="30" default_order="event_time desc">
<field name="event_time"/>
<field name="result" decoration-success="result=='success'"
decoration-danger="result=='failure'"
widget="badge"/>
<field name="failure_reason"/>
<field name="ip_address"/>
<field name="country_code"/>
<field name="city"/>
<field name="browser"/>
<field name="os"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Sync fusion_centralize_billing to the odoo-trial Enterprise sandbox (Proxmox VM 316)
# and run its test suite there. The local dev Odoo (odoo-modsdev) is Community and
# CANNOT install this module (needs sale_subscription + account_accountant), so tests
# run on odoo-trial (Odoo 19.0 Enterprise, db=trial), reached via Proxmox guest-exec
# (VM 316 has no direct SSH; only `qm guest exec` through the pve-worker1 host).
#
# Usage: bash scripts/fcb_test_on_trial.sh
# Pass condition: the output ends with `FCB_EXIT=0` (Odoo exits non-zero on test failure).
set -uo pipefail
MODULE=fusion_centralize_billing
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PVE=pve-worker1 # Proxmox host that runs VM 316 (ssh config alias)
VMID=316
echo ">> packing ${MODULE}"
B64=$(tar czf - --exclude='__pycache__' --exclude='*.pyc' -C "${REPO_DIR}" "${MODULE}" | base64 -w0)
echo " payload: ${#B64} b64 bytes"
echo ">> syncing to odoo-trial:/opt/odoo/custom-addons (guest-exec)"
ssh -o ConnectTimeout=40 "${PVE}" "qm guest exec ${VMID} --timeout 90 -- bash -lc 'rm -rf /opt/odoo/custom-addons/${MODULE}; echo ${B64} | base64 -d | tar xzf - -C /opt/odoo/custom-addons/ && echo SYNCED'" \
2>&1 | sed -n 's/.*"out-data" : "\(.*\)",/\1/p' | sed 's/\\n/\n/g'
echo ">> upgrade + test on Enterprise 19 (db=trial, --no-http)"
ssh -o ConnectTimeout=40 "${PVE}" "qm guest exec ${VMID} --timeout 600 -- bash -lc 'docker exec odoo-trial-app odoo -d trial -u ${MODULE} --no-http --http-port 8070 --workers 0 --test-enable --test-tags /${MODULE} --stop-after-init >/tmp/fcb_test.log 2>&1; echo FCB_EXIT=\$?; grep -iE \"FAIL|ERROR|tested in|Ran |assert\" /tmp/fcb_test.log | grep -viE \"fusion_plating|fusion_tasks|not installable|not loaded\" | tail -30'" \
2>&1 | sed -n 's/.*"out-data" : "\(.*\)",/\1/p' | sed 's/\\n/\n/g'