Compare commits
208 Commits
phase3-lan
...
fb345be375
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb345be375 | ||
|
|
6ce49573ef | ||
|
|
9dceff5baf | ||
|
|
b6e7ad3485 | ||
|
|
304e19ce45 | ||
|
|
96b3441997 | ||
|
|
33fc046fa2 | ||
|
|
960fe67966 | ||
|
|
2899e43d84 | ||
|
|
e71cae6dc9 | ||
|
|
7cdee2e7ce | ||
|
|
b3b3e61f01 | ||
|
|
b47af5aea4 | ||
|
|
4e8638e3a5 | ||
|
|
e7f6e46d09 | ||
|
|
4ecc1a3aaa | ||
|
|
b3bc6ce342 | ||
|
|
e532b30d11 | ||
|
|
08e528e5be | ||
|
|
096de38315 | ||
|
|
dec557c88d | ||
|
|
773608f17b | ||
|
|
c07a79c260 | ||
|
|
73d7052f7b | ||
|
|
0761ccacbb | ||
|
|
40b93c9035 | ||
|
|
2e8c31cca0 | ||
|
|
821709d413 | ||
|
|
94bab191f5 | ||
|
|
9b82113310 | ||
|
|
f4d22d6e90 | ||
|
|
69b210256a | ||
|
|
e827e5b97c | ||
|
|
69506a747d | ||
|
|
509fbdf438 | ||
|
|
0aa6b408c4 | ||
|
|
678dced9e1 | ||
|
|
b28ac34ed5 | ||
|
|
658ead5cdf | ||
|
|
481ed4fdf8 | ||
|
|
cc26b9ad6d | ||
|
|
5f372b462a | ||
|
|
67af54b46e | ||
|
|
5a699de1ca | ||
|
|
1b473a7873 | ||
|
|
9223f8da7c | ||
|
|
8c9b645196 | ||
|
|
2aa4bce089 | ||
|
|
46c62ebefa | ||
|
|
152e6d4328 | ||
|
|
33fff5acba | ||
|
|
2ae1c867b5 | ||
|
|
c990110646 | ||
|
|
5872583fbb | ||
|
|
c8db3915ea | ||
|
|
547e7d66a9 | ||
|
|
bfeca0ac32 | ||
|
|
40d563801a | ||
|
|
e271908109 | ||
|
|
72f75fe754 | ||
|
|
6cb352629a | ||
|
|
d53bb73055 | ||
|
|
ff51035494 | ||
|
|
0ed4f88da2 | ||
|
|
caeba27846 | ||
|
|
a2e254b934 | ||
|
|
8b14466da2 | ||
|
|
5a039ae369 | ||
|
|
aab6b9275b | ||
|
|
26a1086623 | ||
|
|
c00831a72a | ||
|
|
3a120dd400 | ||
|
|
4dc0a7cca5 | ||
|
|
4930a89970 | ||
|
|
72f0f182a6 | ||
|
|
5173554281 | ||
|
|
c2b693c97e | ||
|
|
051094813e | ||
|
|
edf3f95854 | ||
|
|
80887d6098 | ||
|
|
5d5964a327 | ||
|
|
80f80fb707 | ||
|
|
bfc138251a | ||
|
|
7dab5fb9c6 | ||
|
|
8d4c85cc52 | ||
|
|
fc17754996 | ||
|
|
0371624afb | ||
|
|
eed1c4619d | ||
|
|
170398ab6f | ||
|
|
d4e95dcd47 | ||
|
|
e1fedf7231 | ||
|
|
9a2975b154 | ||
|
|
271a995455 | ||
|
|
056178b433 | ||
|
|
2285c9def1 | ||
|
|
6afc9e3c0d | ||
|
|
b06d28e7f6 | ||
|
|
7b90f210b9 | ||
|
|
c75d2bde5a | ||
|
|
9e6b88f60e | ||
|
|
dc6afdd021 | ||
|
|
978cd5953e | ||
|
|
b869c31ba3 | ||
|
|
67fc22237b | ||
|
|
d9f2983ea7 | ||
|
|
3120612e35 | ||
|
|
2a93ece4ba | ||
|
|
b26fa13044 | ||
|
|
7ff46af192 | ||
|
|
6d4b6284ad | ||
|
|
d8456fb9a3 | ||
|
|
b41d9629e1 | ||
|
|
10477a7c8f | ||
|
|
8f6302b446 | ||
|
|
87e924d1d8 | ||
|
|
7fab01e5cb | ||
|
|
4911088dc1 | ||
|
|
086ff512b6 | ||
|
|
96e33834bd | ||
|
|
765b095035 | ||
|
|
358b90516b | ||
|
|
dd0dc26232 | ||
|
|
1dea752a29 | ||
|
|
9f3edd60ae | ||
|
|
0b92294586 | ||
|
|
a52ef29a84 | ||
|
|
97deb93ee7 | ||
|
|
b67186a25b | ||
|
|
258782e3c3 | ||
|
|
acc95d8ee0 | ||
|
|
e9b82fbe9d | ||
|
|
c3bcb4b99d | ||
|
|
cfaf4657ce | ||
|
|
7966f8d505 | ||
|
|
839a7f0abc | ||
|
|
0f751d82cc | ||
|
|
aa8161f764 | ||
|
|
31740b3949 | ||
|
|
e99cf20887 | ||
|
|
cc5542833f | ||
|
|
0568d8ae87 | ||
|
|
c2180d3691 | ||
|
|
42036c23ab | ||
|
|
7bcbcb4008 | ||
|
|
0047f49d2c | ||
|
|
5cc1117f75 | ||
|
|
de3ec7d97a | ||
|
|
89a937fb32 | ||
|
|
830b29ce49 | ||
|
|
269f9984ef | ||
|
|
9e5c23f37d | ||
|
|
36cd4341a7 | ||
|
|
c34dfce6c3 | ||
|
|
84ed406c8e | ||
|
|
f4e1f9d218 | ||
|
|
8eb2c2de95 | ||
|
|
bdf676e05a | ||
|
|
6c7e11db4d | ||
|
|
a53b03265d | ||
|
|
560ffa2cdf | ||
|
|
d89546bec7 | ||
|
|
818dfa3882 | ||
|
|
772107d25b | ||
|
|
c61371005a | ||
|
|
7a0bd67fc0 | ||
|
|
efc420b4ce | ||
|
|
fd2b2908f3 | ||
|
|
eb1fd50add | ||
|
|
a60506a645 | ||
|
|
8b9b4d60ad | ||
|
|
a90eace4d0 | ||
|
|
7c2ae84e32 | ||
|
|
63d692b322 | ||
|
|
1a3ca8704e | ||
|
|
d6ebcb6233 | ||
|
|
48805b5988 | ||
|
|
005daade55 | ||
|
|
27e12dd544 | ||
|
|
5f03080374 | ||
|
|
efaf16dffb | ||
|
|
e4000374ca | ||
|
|
fee4219703 | ||
|
|
6ca9a58a8c | ||
|
|
d86c120969 | ||
|
|
85609f99cd | ||
|
|
29821bd541 | ||
|
|
1fdafd34d1 | ||
|
|
9584953467 | ||
|
|
52097ca59b | ||
|
|
1d6184dd2f | ||
|
|
88a473e7eb | ||
|
|
08ababc2c7 | ||
|
|
59ad77839a | ||
|
|
a594431eb6 | ||
|
|
58d02598da | ||
|
|
395bd4949e | ||
|
|
a6546ac858 | ||
|
|
233e5e6e72 | ||
|
|
b06a5b2d12 | ||
|
|
3ef67c6beb | ||
|
|
4a304e02f3 | ||
|
|
0d08d2d135 | ||
|
|
f9cb1b11ce | ||
|
|
1122f84007 | ||
|
|
2cdb2e3d0b | ||
|
|
f00dda2abd | ||
|
|
3b7b2477cf | ||
|
|
e762ee4b68 |
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
|
||||
28
CLAUDE.md
28
CLAUDE.md
@@ -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
|
||||
|
||||
2703
docs/superpowers/plans/2026-05-26-fusion-login-audit.md
Normal file
2703
docs/superpowers/plans/2026-05-26-fusion-login-audit.md
Normal file
File diff suppressed because it is too large
Load Diff
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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. ✓
|
||||
444
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md
Normal file
444
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md
Normal 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`.
|
||||
@@ -0,0 +1,336 @@
|
||||
# Fusion Helpdesk — Customer Follow-up & Embedded Ticket Inbox
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Approved design (ready for implementation plan)
|
||||
- **Branch:** `feat/helpdesk-customer-followup`
|
||||
- **Modules touched:** `fusion_helpdesk` (client deployments), `fusion_helpdesk_central` (central Odoo)
|
||||
- **Target system:** `odoo-nexa` / `erp.nexasystems.ca`, DB `nexamain`, Odoo 19 Enterprise
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Today, end users at client deployments (ENTECH, MOBILITY, …) file helpdesk tickets through an in-app
|
||||
"Report a Bug / Request a Feature" systray dialog. Those tickets land on the central Odoo Helpdesk but
|
||||
carry **no customer identity**, so:
|
||||
|
||||
- support replies email nobody,
|
||||
- the submitter can't see or follow up on their ticket,
|
||||
- the ticket never appears in any customer portal.
|
||||
|
||||
This design makes ticket follow-up work end to end. It rests on **one keystone fix** (attach the
|
||||
submitter's identity to every ticket) and then exposes **two follow-up surfaces** matched to two
|
||||
audiences:
|
||||
|
||||
1. **In-app embedded inbox** — the systray dialog becomes a small ticket inbox (New + My Tickets). Client
|
||||
staff read replies and follow up **without leaving their own Odoo or logging into the central system**.
|
||||
2. **Native Enterprise portal** — for external web/email customers, the existing Odoo portal + magic-link
|
||||
+ free sign-up does the job; they have no workspace to embed into.
|
||||
|
||||
Scope tier: **Polished** (light branding + acknowledgement email + in-app unread badge). Not a custom
|
||||
portal theme.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem & Diagnosis (grounded in the live system)
|
||||
|
||||
### 2.1 Current architecture
|
||||
|
||||
- **`fusion_helpdesk`** (installed on *client* deployments): OWL systray dialog → `POST
|
||||
/fusion_helpdesk/submit` → forwards to central over **XML-RPC as a shared bot account** (API key issued
|
||||
by `fusion_helpdesk_central`). Ticket payload today is only `{name, description, team_id}`. The
|
||||
reporter's name/login is embedded as **HTML text inside the description's "Diagnostic context" table** —
|
||||
not as structured fields.
|
||||
- **`fusion_helpdesk_central`** (installed on *central* Odoo): manages the per-client API keys on the
|
||||
shared bot user. Does **not** touch tickets, portal, notifications.
|
||||
|
||||
### 2.2 The actual bug (verified on `nexamain`, 2026-05-27)
|
||||
|
||||
All **51/51** tickets have `partner_id`, `partner_email`, `partner_name` = NULL (0 coverage). With no
|
||||
customer attached, Odoo has nobody to email, nobody to add as follower, no `/my/tickets` to populate, and
|
||||
no recipient for a magic link.
|
||||
|
||||
### 2.3 The platform already does the hard part
|
||||
|
||||
Installed & enabled on `odoo-nexa`:
|
||||
|
||||
- Modules: `helpdesk` 19.0.1.6, `website_helpdesk`, `website_helpdesk_knowledge`, `helpdesk_account`,
|
||||
`helpdesk_sale`, `portal`, `website`, `auth_signup`.
|
||||
- `auth_signup.invitation_scope = b2c` (free customer sign-up ON), `auth_signup.reset_password = True`.
|
||||
- `web.base.url = https://erp.nexasystems.ca`, `mail.catchall.domain = nexasystems.ca`, 4 working SMTP
|
||||
servers → outbound email works.
|
||||
- Team 1 **"Customer Care"** is already portal-ready: `privacy_visibility = portal`,
|
||||
`use_website_helpdesk_form = true`, `allow_portal_ticket_closing = true`, `use_alias = true`, alias
|
||||
`support` (→ `support@nexasystems.ca`).
|
||||
|
||||
`helpdesk.ticket` model (Enterprise source, verified):
|
||||
|
||||
- `_inherit = ['portal.mixin', 'mail.thread.cc', 'rating.mixin']`; `_mail_thread_customer = True`;
|
||||
`_primary_email = 'partner_email'`; `access_url = '/my/ticket/<id>'` (← that is the magic link).
|
||||
- **`create()` auto-resolves the partner**: when `partner_email` is given and `partner_id` is not, it calls
|
||||
`mail.thread._partner_find_from_emails_single([partner_email], {name, company_id})` to find-or-create the
|
||||
partner and set `partner_id` (`helpdesk_ticket.py` ≈ L564–572).
|
||||
- **`create()` subscribes the customer as a follower** (the "make customer follower" loop, ≈ L600–620),
|
||||
so they receive reply notifications by email.
|
||||
- Portal routes: `/my/tickets` (auth=`user`); `/my/ticket/<int:ticket_id>/<access_token>` (auth=`public`)
|
||||
→ validates token via `_document_check_access` → renders `helpdesk.tickets_followup` (reply composer
|
||||
included); `/my/ticket/close/<id>/<token>` posts a message with `author_id = partner_id`; public web
|
||||
form at `/helpdesk/<team>`.
|
||||
|
||||
**Consequence:** the keystone fix is small — pass `partner_email` + `partner_name` in the create payload and
|
||||
native helpdesk creates the partner, links it, and subscribes it. Replies then email the customer with a
|
||||
magic-link "View Ticket" button automatically.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
- Every new ticket carries the submitter's real identity (`partner_email`, `partner_name`,
|
||||
`x_fc_client_label`).
|
||||
- Agent replies reach the customer **by email** with a working **magic link**.
|
||||
- **In-app staff** can list, read, and reply to their tickets **inside their own Odoo** — no login, no
|
||||
context switch.
|
||||
- **External web/email customers** get the native portal + magic link + free sign-up.
|
||||
- Light branding (logo/colours) + an acknowledgement email on ticket creation.
|
||||
- Hybrid in-app visibility: regular users see their own tickets; a designated admin sees all of their
|
||||
deployment's tickets.
|
||||
|
||||
### Non-Goals
|
||||
- No custom portal theme, custom website submission form, KB-deflection, or SLA timeline UI (that was
|
||||
Tier C — deliberately out of scope).
|
||||
- No replication of tickets into the client database — the in-app inbox is a **live RPC view**.
|
||||
- No backfill of the 51 existing identity-less tickets (low value; their only identity is free text).
|
||||
- No changes to the billing module (`fusion_centralize_billing`) — separate work.
|
||||
|
||||
---
|
||||
|
||||
## 4. Audiences & channels (locked decisions)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Channels | **Both** — in-app reporter *and* external web/email |
|
||||
| In-app visibility | **Hybrid** — own by default; designated admin sees all of their deployment's tickets |
|
||||
| Scope tier | **Polished** — light branding + ack email + in-app unread badge |
|
||||
| Acknowledgement email on create | **Yes** (immediate magic link) |
|
||||
| Reporter email at submit | **Confirmed / editable** in the New form |
|
||||
| "See all" gating | **New group** on the client deployment |
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
### 5.1 Keystone — identity layer
|
||||
|
||||
- **Client side (`fusion_helpdesk`)**: in `submit()`, add to the create payload:
|
||||
- `partner_name` = `request.env.user.name`
|
||||
- `partner_email` = confirmed value from the form (default `request.env.user.email or .login`, editable)
|
||||
- `x_fc_client_label` = `cfg['client_label']`
|
||||
- **Central side (`fusion_helpdesk_central`)**: add `x_fc_client_label` (Char, indexed) to `helpdesk.ticket`
|
||||
and surface it in the agent backend (list column + search filter) so support can filter by client. Native
|
||||
helpdesk does the partner resolution + follower subscription.
|
||||
|
||||
`x_fc_client_label` is the structured tag that makes deployment-scoped queries (and the admin "see all"
|
||||
view) reliable — far better than parsing the `[ENTECH]` subject prefix.
|
||||
|
||||
### 5.2 Two surfaces
|
||||
|
||||
- **Surface A — in-app embedded inbox** (`fusion_helpdesk`, client deployments). New work.
|
||||
- **Surface B — native Enterprise portal** (`fusion_helpdesk_central` config + light branding). Mostly
|
||||
configuration; near-zero new code.
|
||||
|
||||
### 5.3 Module responsibilities
|
||||
|
||||
**`fusion_helpdesk` (client) — majority of new work**
|
||||
- Controller (`controllers/main.py`): keystone payload change + new endpoints (§6.1).
|
||||
- OWL dialog (`static/src/js/…`, `static/src/xml/…`): New + My Tickets tabs; thread view; reply box.
|
||||
- Systray (`fusion_helpdesk_systray.js`): unread badge.
|
||||
- `res.groups`: `group_reporter_admin` ("Helpdesk Reporter Admin").
|
||||
- Model `fusion.helpdesk.ticket.seen`: per-user read tracking for the badge.
|
||||
- `res.config.settings`: (existing) — no new config required beyond what exists.
|
||||
|
||||
**`fusion_helpdesk_central` (central) — small additions**
|
||||
- `helpdesk.ticket` inherit: `x_fc_client_label` field + backend list/search exposure.
|
||||
- `mail.template`: branded acknowledgement on ticket create (with the magic-link CTA).
|
||||
- Data/doc: confirm the "Customer Care" team portal config (already correct on live — assert via comment or
|
||||
light data, don't fight existing config).
|
||||
|
||||
---
|
||||
|
||||
## 6. Surface A — In-app embedded inbox (detail)
|
||||
|
||||
### 6.1 Controller endpoints
|
||||
|
||||
All `type='jsonrpc'`, `auth='user'`. **Identity is always derived server-side from `request.env.user`** —
|
||||
never from request parameters. All remote calls go through the existing bot XML-RPC layer.
|
||||
|
||||
| Route | Returns | Notes |
|
||||
|---|---|---|
|
||||
| `POST /fusion_helpdesk/submit` *(modified)* | `{ok, ticket_id, ticket_url}` | Adds `x_fc_client_label` + `partner_name`; the confirmed form email is sent as `partner_email` (param may be named `reply_email`, but it maps straight to `partner_email`). |
|
||||
| `/fusion_helpdesk/my_tickets` | `[{id, ref, subject, stage, last_update, has_unread}]` | Scoped (§8). Reuses one remote `search_read`. |
|
||||
| `/fusion_helpdesk/ticket/<int:ticket_id>` | `{id, subject, stage, messages:[…], can_reply}` | **Public comments only** — internal notes excluded (§8). Re-checks scope. |
|
||||
| `/fusion_helpdesk/ticket/<int:ticket_id>/reply` | `{ok}` | Re-checks scope; posts `message_post` with `author_id` = replier's partner. |
|
||||
| `/fusion_helpdesk/unread_count` | `{count}` | For the systray badge (§7). |
|
||||
|
||||
### 6.2 Dialog UX
|
||||
|
||||
- The existing dialog gains two tabs:
|
||||
- **New** — today's form, plus a confirmed/editable **"Your email"** field (prefilled from the logged-in
|
||||
user; used as `reply_email`).
|
||||
- **My Tickets** — list of the user's tickets (ref, subject, stage chip, last-update, unread dot). Admins
|
||||
(in `group_reporter_admin`) see a **"Mine / All [LABEL]"** toggle.
|
||||
- Clicking a ticket opens a **thread view**: customer-visible messages (author, timestamp, body,
|
||||
attachments) + a **reply box** (text + attach) + a "Done"/back control. Opening a ticket marks it seen.
|
||||
|
||||
### 6.3 Reply attribution
|
||||
|
||||
- Replies post to central as `message_type='comment'`, `subtype_xmlid='mail.mt_comment'`, with `author_id`
|
||||
= the **replying user's** partner on central (resolved find-or-create by their email). For a user replying
|
||||
to their own ticket that's the ticket's customer; for an admin replying to a colleague's ticket it's the
|
||||
admin's own identity (correct attribution).
|
||||
- A customer reply notifies the assigned agent + followers (native), closing the two-way loop.
|
||||
|
||||
### 6.4 Read tracking & admin group
|
||||
|
||||
- Model `fusion.helpdesk.ticket.seen` (client DB): `user_id` (m2o `res.users`), `central_ticket_id`
|
||||
(Integer), `last_seen_message_id` (Integer) — unique `(user_id, central_ticket_id)`. This is
|
||||
read-tracking **metadata only** (no ticket content is stored) — it preserves the live-RPC-view principle
|
||||
while letting the badge work without re-fetching on every page load.
|
||||
- `group_reporter_admin` — an Odoo group on the client deployment. Membership unlocks the "All [LABEL]"
|
||||
query path **server-side** (the controller checks `has_group` before broadening scope).
|
||||
|
||||
---
|
||||
|
||||
## 7. Notifications & emails
|
||||
|
||||
- **Agent → customer:** customer is a follower → **native email** with a "View Ticket" magic link
|
||||
(portal.mixin `access_url` + token). Satisfies "they get replies in their email." In-app users also see
|
||||
the reply in My Tickets and the badge increments.
|
||||
- **Acknowledgement on create:** branded `mail.template` sent to the customer with the magic-link CTA so they
|
||||
can track immediately. Fires for any ticket on the portal-enabled team that has a `partner_email`,
|
||||
regardless of channel (in-app, web, email). Per Odoo 19, the template renders the link from the record
|
||||
(`object.access_url` / portal URL); no need to pass it via `ctx` (CLAUDE rule 12). **Implementation note:**
|
||||
verify `website_helpdesk` does not already send its own "ticket received" confirmation for web-form
|
||||
submissions — if it does, gate ours so external customers don't get two acknowledgements.
|
||||
- **Unread badge:** `unread_count` = number of the user's in-scope tickets whose latest customer-visible
|
||||
**support** message id is greater than the local `last_seen_message_id`. Cleared per-ticket on open.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security & scoping (the sharp edge)
|
||||
|
||||
The shared bot can read **every** client's tickets on central, so the client-side controller is the
|
||||
security boundary.
|
||||
|
||||
- Endpoints are `auth='user'`; identity is taken from `request.env.user`, never from the browser.
|
||||
- Scoped domain, built server-side:
|
||||
- regular user → `[('x_fc_client_label','=',label), ('partner_email','=ilike', me.email or me.login)]`
|
||||
- admin (`group_reporter_admin`) → `[('x_fc_client_label','=',label)]`
|
||||
- **`x_fc_client_label = <my deployment>` is ALWAYS ANDed in** (defense in depth) so no user — regular or
|
||||
admin — can ever read another deployment's tickets, even if two deployments share a reporter email.
|
||||
- `ticket/<id>` and `…/reply` **re-resolve the ticket through the same scoped domain** before reading or
|
||||
posting; a ticket outside scope returns not-found.
|
||||
- Thread fetch returns **only customer-visible messages** (exclude internal notes — `subtype_id.internal =
|
||||
True`), mirroring what the portal shows. Internal agent discussion never reaches a client.
|
||||
- Reuse the module's existing granular remote-error handling for auth/network failures.
|
||||
|
||||
---
|
||||
|
||||
## 9. Data flow
|
||||
|
||||
```
|
||||
SUBMIT (in-app)
|
||||
staff clicks icon → New tab → confirm email → submit
|
||||
client controller adds partner_email + partner_name + x_fc_client_label
|
||||
→ XML-RPC create on central (as bot)
|
||||
→ helpdesk find-or-creates partner_id + subscribes follower
|
||||
→ branded acknowledgement email w/ magic link
|
||||
|
||||
AGENT REPLY (Nexa support)
|
||||
reply as a comment in the ticket chatter on central
|
||||
→ native email to customer w/ "View Ticket" magic link
|
||||
→ in-app users also see it in My Tickets; badge increments
|
||||
|
||||
CUSTOMER FOLLOW-UP (any of three, same thread)
|
||||
in-app dialog reply → RPC message_post (author = replier's partner)
|
||||
portal magic link → native reply on /my/ticket/<id>/<token>
|
||||
email reply → native email-in via support@nexasystems.ca
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Edge cases
|
||||
|
||||
- **Missing/invalid reporter email** — New form prefills + lets the user confirm/edit. If still empty, the
|
||||
ticket is created without a customer (degrades to today's behaviour) and the dialog flags "no follow-up
|
||||
email captured."
|
||||
- **Same email across deployments** — partner is shared (their portal shows all their tickets), but the
|
||||
in-app inbox still scopes by `x_fc_client_label`, so each deployment shows only its own.
|
||||
- **Admin replies to a colleague's ticket** — author = the admin's own partner, not the ticket customer.
|
||||
- **Existing 51 orphan tickets** — left as-is (no reliable identity to backfill).
|
||||
- **Bot key revoked/rotated** (managed by `fusion_helpdesk_central`) — endpoints fail gracefully via the
|
||||
existing typed remote-error responses.
|
||||
- **Internal notes** — never returned to the client (subtype filter).
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing strategy
|
||||
|
||||
- **`fusion_helpdesk_central`** (Enterprise; runs on an Enterprise env such as odoo-trial, like the billing
|
||||
module — local dev is Community and can't install `helpdesk`):
|
||||
- `x_fc_client_label` field exists + is searchable.
|
||||
- Integration: `helpdesk.ticket.create({partner_email, partner_name, x_fc_client_label})` resolves
|
||||
`partner_id` and adds the partner as a follower.
|
||||
- Acknowledgement template renders the magic link from the record.
|
||||
- **`fusion_helpdesk`** (client; XML-RPC layer **mocked** — no live central in unit tests):
|
||||
- Scoping: regular vs admin domain construction; `x_fc_client_label` always ANDed.
|
||||
- `…/reply` rejects a ticket outside the caller's scope.
|
||||
- Thread fetch excludes internal notes.
|
||||
- `unread_count` math against `fusion.helpdesk.ticket.seen`.
|
||||
- Refactor the remote proxy so it is injectable/mockable.
|
||||
- **Manual QA on `odoo-nexa`**: full round-trip — submit → agent reply → email + badge → in-app reply →
|
||||
portal magic link → external sign-up shows `/my/tickets`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Out of scope / future
|
||||
|
||||
- Custom portal theme, branded custom web form, KB deflection, SLA/status timeline (Tier C).
|
||||
- Backfilling identity on historical tickets.
|
||||
- Push/websocket live updates in the dialog (polling/refresh is sufficient for v1).
|
||||
|
||||
---
|
||||
|
||||
## 13. References
|
||||
|
||||
**Current code (this repo)**
|
||||
- `fusion_helpdesk/controllers/main.py` — `submit()`, `_read_config()`, `_authenticate()`,
|
||||
`_build_diag_block()` (XML-RPC forwarder; today sends only `{name, description, team_id}`).
|
||||
- `fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js` — OWL submission dialog.
|
||||
- `fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js` — systray entry (badge target).
|
||||
- `fusion_helpdesk/models/res_config_settings.py` — remote endpoint config params.
|
||||
- `fusion_helpdesk_central/models/fusion_helpdesk_client_key.py` — bot user + API-key management.
|
||||
|
||||
**Live system facts (verified 2026-05-27 on `nexamain`)**
|
||||
- Modules installed: `helpdesk` 19.0.1.6, `website_helpdesk`, `website_helpdesk_knowledge`,
|
||||
`helpdesk_account`, `helpdesk_sale`, `portal`, `website`, `auth_signup`.
|
||||
- `auth_signup.invitation_scope=b2c`; `web.base.url=https://erp.nexasystems.ca`;
|
||||
`mail.catchall.domain=nexasystems.ca`; 4 SMTP servers.
|
||||
- Team 1 "Customer Care": `privacy_visibility=portal`, `use_website_helpdesk_form=t`,
|
||||
`allow_portal_ticket_closing=t`, `use_alias=t`, alias `support`.
|
||||
- 51/51 tickets have NULL `partner_id`/`partner_email`/`partner_name`.
|
||||
|
||||
**Enterprise source (read-only, on container)**
|
||||
- `helpdesk/models/helpdesk_ticket.py` — `_inherit` (portal.mixin, mail.thread.cc, rating.mixin);
|
||||
`access_url='/my/ticket/<id>'`; `create()` partner find-or-create (≈L564–572) + follower subscription
|
||||
(≈L600–620).
|
||||
- `helpdesk/controllers/portal.py` — `/my/tickets`, `/my/ticket/<id>/<access_token>`,
|
||||
`/my/ticket/close/<id>/<token>`.
|
||||
- `website_helpdesk/controllers/main.py` — `/helpdesk/<team>` public web form.
|
||||
|
||||
**Odoo 19 gotchas to respect (from repo CLAUDE.md)**
|
||||
- `res.users` group field is `group_ids` (not `groups_id`).
|
||||
- `message_post(body=…)` HTML must be wrapped in `Markup()`.
|
||||
- `mail.template` `ctx` is `env.context`; pass dynamic data via `with_context(**data)`.
|
||||
- `res.config.settings` Boolean via `config_parameter` doesn't persist `False`.
|
||||
- SQL constraints/indexes use declarative `models.Constraint` / `models.Index`.
|
||||
@@ -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`).
|
||||
7
fusion-plating/.claude/settings.local.json
Normal file
7
fusion-plating/.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
70
fusion_centralize_billing/README.md
Normal file
70
fusion_centralize_billing/README.md
Normal 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.
|
||||
2
fusion_centralize_billing/__init__.py
Normal file
2
fusion_centralize_billing/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
55
fusion_centralize_billing/__manifest__.py
Normal file
55
fusion_centralize_billing/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
fusion_centralize_billing/controllers/__init__.py
Normal file
1
fusion_centralize_billing/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import api
|
||||
95
fusion_centralize_billing/controllers/api.py
Normal file
95
fusion_centralize_billing/controllers/api.py
Normal 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)
|
||||
22
fusion_centralize_billing/data/ir_cron.xml
Normal file
22
fusion_centralize_billing/data/ir_cron.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="cron_fc_rate_usage" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Rate usage before invoicing</field>
|
||||
<field name="model_id" ref="model_fusion_billing_usage"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_rate_open_periods()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
|
||||
<field name="model_id" ref="model_fusion_billing_webhook"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_dispatch()</field>
|
||||
<field name="interval_number">2</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
8
fusion_centralize_billing/models/__init__.py
Normal file
8
fusion_centralize_billing/models/__init__.py
Normal 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
|
||||
57
fusion_centralize_billing/models/account_link.py
Normal file
57
fusion_centralize_billing/models/account_link.py
Normal 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,
|
||||
})
|
||||
80
fusion_centralize_billing/models/charge.py
Normal file
80
fusion_centralize_billing/models/charge.py
Normal 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)
|
||||
32
fusion_centralize_billing/models/metric.py
Normal file
32
fusion_centralize_billing/models/metric.py
Normal 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.")
|
||||
39
fusion_centralize_billing/models/reconciliation.py
Normal file
39
fusion_centralize_billing/models/reconciliation.py
Normal 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()
|
||||
32
fusion_centralize_billing/models/sale_order.py
Normal file
32
fusion_centralize_billing/models/sale_order.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = "sale.order"
|
||||
|
||||
def _fc_rate_usage(self, charge, period_start, period_end):
|
||||
"""Aggregate this subscription's usage for `charge`'s metric in the period,
|
||||
compute the overage amount, and upsert a matching overage order line.
|
||||
Returns the amount.
|
||||
|
||||
A zero amount never *creates* a new line (no $0.00 overage clutter); if a
|
||||
line already exists it is still updated so a dropped-to-zero overage clears.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Usage = self.env['fusion.billing.usage']
|
||||
total = Usage._aggregate(self, charge.metric_id, period_start, period_end)
|
||||
_overage, amount = charge._compute_billable(total)
|
||||
if charge.product_id:
|
||||
line = self.order_line.filtered(lambda l: l.product_id == charge.product_id)
|
||||
if not line and amount == 0:
|
||||
return amount
|
||||
vals = {'product_uom_qty': 1, 'price_unit': amount}
|
||||
if line:
|
||||
line.write(vals)
|
||||
else:
|
||||
self.env['sale.order.line'].create(
|
||||
{'order_id': self.id, 'product_id': charge.product_id.id, **vals})
|
||||
return amount
|
||||
225
fusion_centralize_billing/models/service.py
Normal file
225
fusion_centralize_billing/models/service.py
Normal 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}
|
||||
120
fusion_centralize_billing/models/usage.py
Normal file
120
fusion_centralize_billing/models/usage.py
Normal 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)
|
||||
113
fusion_centralize_billing/models/webhook.py
Normal file
113
fusion_centralize_billing/models/webhook.py
Normal 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))
|
||||
11
fusion_centralize_billing/security/ir.model.access.csv
Normal file
11
fusion_centralize_billing/security/ir.model.access.csv
Normal 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
|
||||
|
5
fusion_centralize_billing/tests/__init__.py
Normal file
5
fusion_centralize_billing/tests/__init__.py
Normal 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
|
||||
139
fusion_centralize_billing/tests/test_api.py
Normal file
139
fusion_centralize_billing/tests/test_api.py
Normal 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)]))
|
||||
74
fusion_centralize_billing/tests/test_charge.py
Normal file
74
fusion_centralize_billing/tests/test_charge.py
Normal 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)
|
||||
55
fusion_centralize_billing/tests/test_identity.py
Normal file
55
fusion_centralize_billing/tests/test_identity.py
Normal 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
|
||||
171
fusion_centralize_billing/tests/test_usage.py
Normal file
171
fusion_centralize_billing/tests/test_usage.py
Normal 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)
|
||||
99
fusion_centralize_billing/tests/test_webhook.py
Normal file
99
fusion_centralize_billing/tests/test_webhook.py
Normal 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)
|
||||
358
fusion_clock/CLAUDE.md
Normal file
358
fusion_clock/CLAUDE.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# Fusion Clock - Claude Code Instructions
|
||||
|
||||
> Read together with the repo-root `../CLAUDE.md` for global Odoo 19 rules, asset-cache handling, Supabase KB notes, and shared Fusion conventions. This file is only for the `fusion_clock` module.
|
||||
|
||||
## 1. What This Module Is
|
||||
|
||||
- **Name**: Fusion Clock.
|
||||
- **Version**: `19.0.3.3.0`.
|
||||
- **Category**: Human Resources/Attendances.
|
||||
- **License**: OPL-1, Nexa Systems Inc.
|
||||
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
|
||||
- **Top-level menu**: `Fusion Clock`.
|
||||
- **Main surfaces**:
|
||||
- Portal clock page at `/my/clock`.
|
||||
- Portal timesheets at `/my/clock/timesheets`.
|
||||
- Portal reports at `/my/clock/reports`.
|
||||
- Shared PIN kiosk at `/fusion_clock/kiosk`.
|
||||
- NFC tap kiosk at `/fusion_clock/kiosk/nfc`.
|
||||
- Backend systray clock widget.
|
||||
- Backend manager/team-lead dashboard client action.
|
||||
|
||||
Core behaviours: geofenced clock-in/out, IP whitelist fallback, shift scheduling, break deduction, penalties, overtime, auto clock-out, absence detection, leave requests, correction workflow, payroll CSV export, PDF reports, weekly summaries, shared kiosk, NFC kiosk with photo capture, and activity audit logs.
|
||||
|
||||
## 2. Dependencies
|
||||
|
||||
Declared in `__manifest__.py`:
|
||||
|
||||
```
|
||||
hr_attendance, hr, portal, mail, resource
|
||||
```
|
||||
|
||||
External Python used directly:
|
||||
|
||||
- `pytz` for timezone-safe local day boundaries.
|
||||
- `requests` for Google Geocoding, OpenStreetMap/Nominatim fallback, and IP metadata.
|
||||
- `dateutil.relativedelta` inside pay-period calculations.
|
||||
|
||||
External browser APIs:
|
||||
|
||||
- Browser geolocation.
|
||||
- `ipapi.co` fallback geolocation in frontend/backend clock widgets.
|
||||
- Google Maps/Places when `fusion_clock.google_maps_api_key` is configured.
|
||||
- Web NFC and camera APIs for the NFC kiosk.
|
||||
|
||||
## 3. Naming And Field Prefixes
|
||||
|
||||
This module uses the module-specific prefix **`x_fclk_*`** on inherited Odoo models, not `x_fc_*`.
|
||||
|
||||
Examples:
|
||||
|
||||
- `hr.employee.x_fclk_enable_clock`
|
||||
- `hr.employee.x_fclk_nfc_card_uid`
|
||||
- `hr.attendance.x_fclk_clock_source`
|
||||
- `res.company.x_fclk_nfc_kiosk_location_id`
|
||||
|
||||
New inherited fields in this module should keep the `x_fclk_*` prefix unless there is a strong migration reason not to.
|
||||
|
||||
## 4. Model Map
|
||||
|
||||
Custom models:
|
||||
|
||||
| Model | File | Purpose |
|
||||
|---|---|---|
|
||||
| `fusion.clock.location` | `models/clock_location.py` | Geofenced/IP-whitelisted clock locations. |
|
||||
| `fusion.clock.shift` | `models/clock_shift.py` | Shift start/end/break schedule assigned to employees. |
|
||||
| `fusion.clock.penalty` | `models/clock_penalty.py` | Late clock-in / early clock-out penalty records. |
|
||||
| `fusion.clock.activity.log` | `models/clock_activity_log.py` | Append-style audit log for clock activity, geofence misses, absences, NFC enrolment, corrections. |
|
||||
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
|
||||
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
|
||||
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
|
||||
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
|
||||
|
||||
Inherited models:
|
||||
|
||||
- `hr.employee`: enable clock, default location, shift, kiosk PIN, NFC UID, pending reason flag, streaks, absence/overtime counters, and One2many links.
|
||||
- `hr.attendance`: clock source, location, distances, photos, break minutes, net hours, penalties, auto clock-out flag, overtime fields.
|
||||
- `res.config.settings`: all `fusion_clock.*` settings.
|
||||
- `res.company`: NFC kiosk location binding.
|
||||
|
||||
Timezone helpers live in `models/tz_utils.py`. Use `get_local_today()` and `get_local_day_boundaries()` for attendance domains instead of comparing UTC dates directly.
|
||||
|
||||
## 5. Clocking Flow
|
||||
|
||||
Primary API endpoint: `/fusion_clock/clock_action` in `controllers/clock_api.py`.
|
||||
|
||||
Clock-in flow:
|
||||
|
||||
1. Resolve current user to `hr.employee`.
|
||||
2. Block if `x_fclk_enable_clock` is false.
|
||||
3. If `x_fclk_pending_reason` is true, return `requires_reason`.
|
||||
4. Verify location against allowed active `fusion.clock.location` records.
|
||||
5. Call Odoo's `_attendance_action_change()`.
|
||||
6. Write location, distance, source, and optional photo to `hr.attendance`.
|
||||
7. Log `clock_in`.
|
||||
8. Create `late_in` penalty when outside grace.
|
||||
9. Increment/reset on-time streak; log milestone at 5, 10, 20, 50, 100.
|
||||
10. Notify office user for very-late clock-ins.
|
||||
|
||||
Clock-out flow:
|
||||
|
||||
1. Verify location again.
|
||||
2. Call `_attendance_action_change()`.
|
||||
3. Write out-distance.
|
||||
4. Apply break deduction when configured.
|
||||
5. Create `early_out` penalty when outside grace.
|
||||
6. Log `clock_out`.
|
||||
7. Log overtime if computed overtime is positive.
|
||||
|
||||
Location verification uses GPS when coordinates are available and geocoded locations exist. IP whitelist matching is attempted when a client IP is available. Error types include `no_locations`, `gps_unavailable`, `no_geocoded`, and `outside`.
|
||||
|
||||
## 6. Kiosk And NFC
|
||||
|
||||
Classic kiosk:
|
||||
|
||||
- Page: `/fusion_clock/kiosk`
|
||||
- JSON routes:
|
||||
- `/fusion_clock/kiosk/search`
|
||||
- `/fusion_clock/kiosk/verify_pin`
|
||||
- `/fusion_clock/kiosk/clock`
|
||||
- Requires `fusion_clock.group_fusion_clock_manager`.
|
||||
- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`.
|
||||
- Uses `hr.employee.x_fclk_kiosk_pin`.
|
||||
|
||||
NFC kiosk:
|
||||
|
||||
- Page: `/fusion_clock/kiosk/nfc`
|
||||
- JSON routes:
|
||||
- `/fusion_clock/kiosk/nfc/enroll`
|
||||
- `/fusion_clock/kiosk/nfc/tap`
|
||||
- `/fusion_clock/kiosk/nfc/employee_search`
|
||||
- Requires `fusion_clock.group_fusion_clock_manager`.
|
||||
- Controlled by:
|
||||
- `fusion_clock.enable_nfc_kiosk`
|
||||
- `fusion_clock.nfc_photo_required`
|
||||
- `fusion_clock.nfc_enroll_password`
|
||||
- `fusion_clock.nfc_kiosk_debug`
|
||||
- `res.company.x_fclk_nfc_kiosk_location_id`
|
||||
- Card UID canonical format is uppercase colon-separated hex, e.g. `04:A2:B5:62:C1:80`.
|
||||
- Normalization lives in `FusionClockNfcKiosk._normalize_uid()` and is reused by the backend wizard.
|
||||
- Tap debounce is module-level memory in `controllers/clock_nfc_kiosk.py`: same UID within 5 seconds returns `debounce`.
|
||||
- Photo data URLs are stripped before writing binary fields.
|
||||
- NFC clock-ins write `x_fclk_check_in_photo`; NFC clock-outs write `x_fclk_check_out_photo`.
|
||||
|
||||
Important: unknown-card taps currently return `card_unknown`; the `unknown_card_tap` log type exists but is not written by the endpoint.
|
||||
|
||||
## 7. Reports And Payroll Export
|
||||
|
||||
`fusion.clock.report` supports:
|
||||
|
||||
- Employee reports when `employee_id` is set.
|
||||
- Batch reports when `employee_id` is empty.
|
||||
- PDF generation through QWeb reports:
|
||||
- `fusion_clock.action_report_clock_employee`
|
||||
- `fusion_clock.action_report_clock_batch`
|
||||
- CSV export via `action_export_csv()`.
|
||||
- Custom CSV headings via JSON in `fusion_clock.csv_column_mapping`.
|
||||
- Email send with generated PDF attached.
|
||||
|
||||
Pay period types:
|
||||
|
||||
```
|
||||
weekly, biweekly, semi_monthly, monthly
|
||||
```
|
||||
|
||||
The anchor date setting is `fusion_clock.pay_period_start` as a string in `YYYY-MM-DD` format.
|
||||
|
||||
Historical report generation is exposed through the `Generate Historical Reports` menu action and creates draft reports for completed attendance periods. The scheduled report cron only generates when yesterday is the period end.
|
||||
|
||||
## 8. Scheduled Automation
|
||||
|
||||
Configured in `data/ir_cron_data.xml`:
|
||||
|
||||
| Cron | Model method | Frequency |
|
||||
|---|---|---|
|
||||
| Fusion Clock: Auto Clock-Out | `hr.attendance._cron_fusion_auto_clock_out()` | Every 15 minutes |
|
||||
| Fusion Clock: Generate Period Reports | `fusion.clock.report._cron_generate_period_reports()` | Daily |
|
||||
| Fusion Clock: Daily Absence Check | `hr.attendance._cron_fusion_check_absences()` | Daily |
|
||||
| Fusion Clock: Employee Reminders | `hr.attendance._cron_fusion_employee_reminders()` | Every 15 minutes |
|
||||
| Fusion Clock: Weekly Summary | `hr.attendance._cron_fusion_weekly_summary()` | Daily, internally sends Mondays |
|
||||
|
||||
Auto clock-out closes open attendances after scheduled end plus grace, capped by max shift hours. It sets `x_fclk_pending_reason` so the employee must explain before clocking in again.
|
||||
|
||||
Absence detection checks enabled employees, skips weekends and global resource calendar leaves, and logs `absent` when no attendance or leave request exists.
|
||||
|
||||
## 9. Security
|
||||
|
||||
Groups:
|
||||
|
||||
- `group_fusion_clock_user`
|
||||
- `group_fusion_clock_team_lead`
|
||||
- `group_fusion_clock_manager`
|
||||
|
||||
Admin is auto-assigned to manager in `security/security.xml`.
|
||||
|
||||
Access pattern:
|
||||
|
||||
- Users and portal users can read their own clock data.
|
||||
- Team leads can read direct reports for penalties, activity logs, corrections, and dashboard data.
|
||||
- Managers have full model access and all configuration/kiosk/report menus.
|
||||
- Portal rules are defined for `hr.attendance`, `fusion.clock.location`, `fusion.clock.report`, `fusion.clock.penalty`, `fusion.clock.activity.log`, `fusion.clock.leave.request`, `fusion.clock.correction`, and `fusion.clock.shift`.
|
||||
|
||||
Backend dashboard access is checked in `/fusion_clock/dashboard_data`: manager sees all enabled employees; team lead sees employees where `parent_id` is the current user's employee.
|
||||
|
||||
## 10. Frontend Assets
|
||||
|
||||
Frontend bundle:
|
||||
|
||||
- `static/src/css/portal_clock.css`
|
||||
- `static/src/scss/nfc_kiosk.scss`
|
||||
- `static/src/js/fusion_clock_portal.js`
|
||||
- `static/src/js/fusion_clock_kiosk.js`
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js`
|
||||
|
||||
Backend bundle:
|
||||
|
||||
- `static/src/scss/fusion_clock.scss`
|
||||
- `static/src/js/fusion_clock_systray.js`
|
||||
- `static/src/xml/systray_clock.xml`
|
||||
- `static/src/js/fusion_clock_dashboard.js`
|
||||
- `static/src/xml/fusion_clock_dashboard.xml`
|
||||
- `static/src/js/fusion_clock_location_map.js`
|
||||
- `static/src/js/fusion_clock_location_places.js`
|
||||
- `static/src/xml/fusion_clock_location.xml`
|
||||
|
||||
Patterns:
|
||||
|
||||
- Public portal/kiosk JS should use `Interaction` from `@web/public/interaction` and register in `registry.category("public.interactions")`.
|
||||
- Backend OWL client actions and field widgets use standalone `rpc()` from `@web/core/network/rpc`.
|
||||
- `fusion_clock_systray.js` is a systray OWL component registered as `fusion_clock.ClockSystray`.
|
||||
- `fusion_clock_dashboard.js` is a client action registered as `fusion_clock.Dashboard`.
|
||||
- Location widgets are registered field widgets: `fclk_location_map` and `fclk_places_autocomplete`.
|
||||
|
||||
Known technical debt:
|
||||
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` is currently an isolated IIFE. If touching it, prefer migrating to an Odoo 19 `Interaction` instead of expanding the IIFE pattern.
|
||||
- `static/src/css/portal_clock.css` and `static/src/scss/fusion_clock.scss` contain runtime dark-mode selectors/media rules. For backend SCSS changes, follow the repo-root Odoo 19 compile-time dark bundle guidance.
|
||||
- `fusion_clock.scss` uses some Bootstrap CSS vars for status accents. Avoid relying on Bootstrap vars for card/background/border surfaces in new dashboard work.
|
||||
|
||||
## 11. Settings Keys
|
||||
|
||||
Important `ir.config_parameter` keys:
|
||||
|
||||
```
|
||||
fusion_clock.default_clock_in_time
|
||||
fusion_clock.default_clock_out_time
|
||||
fusion_clock.default_break_minutes
|
||||
fusion_clock.auto_deduct_break
|
||||
fusion_clock.break_threshold_hours
|
||||
fusion_clock.enable_auto_clockout
|
||||
fusion_clock.grace_period_minutes
|
||||
fusion_clock.max_shift_hours
|
||||
fusion_clock.enable_penalties
|
||||
fusion_clock.penalty_grace_minutes
|
||||
fusion_clock.penalty_deduction_minutes
|
||||
fusion_clock.enable_overtime
|
||||
fusion_clock.daily_overtime_threshold
|
||||
fusion_clock.weekly_overtime_threshold
|
||||
fusion_clock.office_user_id
|
||||
fusion_clock.very_late_threshold_minutes
|
||||
fusion_clock.max_monthly_absences
|
||||
fusion_clock.enable_employee_notifications
|
||||
fusion_clock.reminder_before_shift_minutes
|
||||
fusion_clock.reminder_before_end_minutes
|
||||
fusion_clock.send_weekly_summary
|
||||
fusion_clock.enable_ip_fallback
|
||||
fusion_clock.enable_photo_verification
|
||||
fusion_clock.google_maps_api_key
|
||||
fusion_clock.enable_kiosk
|
||||
fusion_clock.kiosk_pin_required
|
||||
fusion_clock.enable_correction_requests
|
||||
fusion_clock.enable_sounds
|
||||
fusion_clock.pay_period_type
|
||||
fusion_clock.pay_period_start
|
||||
fusion_clock.auto_generate_reports
|
||||
fusion_clock.send_employee_reports
|
||||
fusion_clock.report_recipient_user_ids
|
||||
fusion_clock.report_recipient_emails
|
||||
fusion_clock.csv_column_mapping
|
||||
fusion_clock.enable_nfc_kiosk
|
||||
fusion_clock.nfc_photo_required
|
||||
fusion_clock.nfc_enroll_password
|
||||
fusion_clock.nfc_kiosk_debug
|
||||
```
|
||||
|
||||
`fclk_report_recipient_user_ids` is a Many2many on settings but is persisted manually as comma-separated user IDs in `fusion_clock.report_recipient_user_ids`.
|
||||
|
||||
## 12. Routes
|
||||
|
||||
HTTP pages:
|
||||
|
||||
```
|
||||
/my/clock
|
||||
/my/clock/timesheets
|
||||
/my/clock/reports
|
||||
/my/clock/reports/<report_id>/download
|
||||
/fusion_clock/kiosk
|
||||
/fusion_clock/kiosk/nfc
|
||||
```
|
||||
|
||||
JSON-RPC endpoints:
|
||||
|
||||
```
|
||||
/fusion_clock/verify_location
|
||||
/fusion_clock/clock_action
|
||||
/fusion_clock/submit_reason
|
||||
/fusion_clock/request_leave
|
||||
/fusion_clock/request_correction
|
||||
/fusion_clock/get_status
|
||||
/fusion_clock/get_locations
|
||||
/fusion_clock/get_settings
|
||||
/fusion_clock/dashboard_data
|
||||
/fusion_clock/kiosk/search
|
||||
/fusion_clock/kiosk/verify_pin
|
||||
/fusion_clock/kiosk/clock
|
||||
/fusion_clock/kiosk/nfc/enroll
|
||||
/fusion_clock/kiosk/nfc/tap
|
||||
/fusion_clock/kiosk/nfc/employee_search
|
||||
```
|
||||
|
||||
All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
|
||||
|
||||
## 13. Gotchas
|
||||
|
||||
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
|
||||
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
|
||||
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
|
||||
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
|
||||
- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`.
|
||||
- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present.
|
||||
- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`.
|
||||
- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user.
|
||||
- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`.
|
||||
- If CSS/assets change, bump `__manifest__.py` version so Odoo rebuilds bundles.
|
||||
|
||||
## 14. Tests
|
||||
|
||||
Tests are post-install tagged:
|
||||
|
||||
```
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
```
|
||||
|
||||
Coverage currently focuses on NFC:
|
||||
|
||||
- `tests/test_nfc_models.py`: employee UID uniqueness, attendance NFC source/photo fields, company kiosk location field.
|
||||
- `tests/test_clock_nfc_kiosk.py`: kiosk page gating, UID normalization, enroll endpoint, tap happy path, tap errors, photo-required handling, employee search.
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --test-tags fusion_clock --stop-after-init
|
||||
```
|
||||
|
||||
For a normal module upgrade:
|
||||
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init
|
||||
```
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.3.3.0',
|
||||
'version': '19.0.3.5.6',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/clock_correction_views.xml',
|
||||
'views/clock_dashboard_views.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/clock_schedule_views.xml',
|
||||
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||
'wizard/clock_nfc_enrollment_views.xml',
|
||||
'views/clock_menus.xml',
|
||||
@@ -89,15 +90,22 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss',
|
||||
'fusion_clock/static/src/scss/fusion_clock_shift_planner.scss',
|
||||
'fusion_clock/static/src/scss/fusion_clock.scss',
|
||||
'fusion_clock/static/src/js/fusion_clock_systray.js',
|
||||
'fusion_clock/static/src/xml/systray_clock.xml',
|
||||
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
|
||||
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
|
||||
'fusion_clock/static/src/js/fusion_clock_shift_planner.js',
|
||||
'fusion_clock/static/src/xml/fusion_clock_shift_planner.xml',
|
||||
'fusion_clock/static/src/js/fusion_clock_location_map.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_location_places.js',
|
||||
'fusion_clock/static/src/xml/fusion_clock_location.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
|
||||
BIN
fusion_clock/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
@@ -4,3 +4,4 @@ from . import portal_clock
|
||||
from . import clock_api
|
||||
from . import clock_kiosk
|
||||
from . import clock_nfc_kiosk
|
||||
from . import shift_planner
|
||||
|
||||
BIN
fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@
|
||||
import base64
|
||||
import math
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||
return
|
||||
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
return
|
||||
|
||||
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
|
||||
@@ -161,7 +165,16 @@ class FusionClockAPI(http.Controller):
|
||||
worked = attendance.worked_hours or 0.0
|
||||
|
||||
if worked >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes()
|
||||
local_date = get_local_today(request.env, employee)
|
||||
if attendance.check_in:
|
||||
tz_name = (
|
||||
employee.resource_id.tz
|
||||
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
||||
or employee.company_id.partner_id.tz
|
||||
or 'UTC'
|
||||
)
|
||||
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
|
||||
break_min = employee._get_fclk_break_minutes(local_date)
|
||||
current = attendance.x_fclk_break_minutes or 0.0
|
||||
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
||||
new_val = max(break_min, current)
|
||||
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
|
||||
source=source,
|
||||
)
|
||||
|
||||
if is_scheduled_off:
|
||||
self._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"Clocked in on a scheduled OFF day at {location.name}.",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source=source,
|
||||
)
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if office_user_id:
|
||||
request.env['hr.attendance'].sudo()._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Unscheduled Shift: {employee.name}",
|
||||
f"{employee.name} clocked in on a scheduled OFF day.",
|
||||
'hr.attendance',
|
||||
attendance.id,
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'attendance_id': attendance.id,
|
||||
'check_in': fields.Datetime.to_string(attendance.check_in),
|
||||
'location_name': location.name,
|
||||
'location_address': location.address or '',
|
||||
'message': f'Clocked in at {location.name} (unscheduled shift)',
|
||||
'streak': employee.x_fclk_ontime_streak,
|
||||
}
|
||||
|
||||
# Check for late clock-in penalty
|
||||
scheduled_in, _ = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
@@ -359,8 +402,9 @@ class FusionClockAPI(http.Controller):
|
||||
self._apply_break_deduction(attendance, employee)
|
||||
|
||||
# Check for early clock-out penalty
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
# Log clock-out
|
||||
self._log_activity(
|
||||
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
|
||||
'pending_reason': employee.x_fclk_pending_reason,
|
||||
'ontime_streak': employee.x_fclk_ontime_streak,
|
||||
}
|
||||
local_today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(local_today)
|
||||
result.update({
|
||||
'scheduled_shift': day_plan.get('label') or '',
|
||||
'scheduled_hours': round(day_plan.get('hours') or 0.0, 2),
|
||||
'scheduled_off': bool(day_plan.get('is_off')),
|
||||
})
|
||||
|
||||
if is_checked_in:
|
||||
att = request.env['hr.attendance'].sudo().search([
|
||||
@@ -533,7 +584,6 @@ class FusionClockAPI(http.Controller):
|
||||
'location_id': att.x_fclk_location_id.id or False,
|
||||
})
|
||||
|
||||
local_today = get_local_today(request.env, employee)
|
||||
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import logging
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
@@ -120,8 +123,17 @@ class FusionClockKiosk(http.Controller):
|
||||
source='kiosk',
|
||||
)
|
||||
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
if is_scheduled_off:
|
||||
api._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source='kiosk',
|
||||
)
|
||||
else:
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -135,8 +147,9 @@ class FusionClockKiosk(http.Controller):
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
|
||||
@@ -8,6 +8,7 @@ import time
|
||||
import threading
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
|
||||
@@ -183,7 +184,9 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
|
||||
geo_info = {
|
||||
'latitude': 0,
|
||||
@@ -208,8 +211,17 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
)
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
if is_scheduled_off:
|
||||
api._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
)
|
||||
else:
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
@@ -224,8 +236,9 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
|
||||
@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
|
||||
], limit=1)
|
||||
|
||||
# Today stats
|
||||
today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
|
||||
today = get_local_today(request.env, employee)
|
||||
today_schedule = employee._get_fclk_day_plan(today)
|
||||
today_start, _ = get_local_day_boundaries(request.env, today, employee)
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', today_start),
|
||||
@@ -109,7 +111,6 @@ class FusionClockPortal(CustomerPortal):
|
||||
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
|
||||
|
||||
# Week stats
|
||||
today = get_local_today(request.env, employee)
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
|
||||
week_atts = request.env['hr.attendance'].sudo().search([
|
||||
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
'current_attendance': current_attendance,
|
||||
'today_hours': round(today_hours, 1),
|
||||
'week_hours': round(week_hours, 1),
|
||||
'today_schedule': today_schedule,
|
||||
'recent_attendances': recent,
|
||||
'google_maps_key': google_maps_key,
|
||||
'enable_sounds': enable_sounds,
|
||||
|
||||
269
fusion_clock/controllers/shift_planner.py
Normal file
269
fusion_clock/controllers/shift_planner.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import io
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import fields, http, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FusionClockShiftPlanner(http.Controller):
|
||||
"""Backend JSON-RPC API for the Excel-style weekly shift planner."""
|
||||
|
||||
def _check_manager(self):
|
||||
return request.env.user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
|
||||
def _week_start(self, week_start=None):
|
||||
date_obj = fields.Date.to_date(week_start) if week_start else fields.Date.today()
|
||||
return date_obj - timedelta(days=date_obj.weekday())
|
||||
|
||||
def _manager_employees(self):
|
||||
return request.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
('company_id', 'in', request.env.user.company_ids.ids),
|
||||
], order='department_id, name')
|
||||
|
||||
def _load_week_data(self, week_start=None):
|
||||
start = self._week_start(week_start)
|
||||
days = [start + timedelta(days=i) for i in range(7)]
|
||||
employees = self._manager_employees()
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
|
||||
schedules = Schedule.search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('schedule_date', '>=', start),
|
||||
('schedule_date', '<=', days[-1]),
|
||||
])
|
||||
schedule_map = {
|
||||
(schedule.employee_id.id, schedule.schedule_date): schedule
|
||||
for schedule in schedules
|
||||
}
|
||||
|
||||
grouped = defaultdict(list)
|
||||
for employee in employees:
|
||||
grouped[employee.department_id.id or 0].append(employee)
|
||||
|
||||
departments = []
|
||||
employee_rows = []
|
||||
for department_id, department_employees in grouped.items():
|
||||
department = department_employees[0].department_id
|
||||
departments.append({
|
||||
'id': department_id,
|
||||
'name': department.name if department else _('No Department'),
|
||||
'employee_ids': [emp.id for emp in department_employees],
|
||||
})
|
||||
for employee in department_employees:
|
||||
cells = {}
|
||||
for day in days:
|
||||
cells[str(day)] = Schedule.fclk_cell_payload(
|
||||
employee,
|
||||
day,
|
||||
schedule_map.get((employee.id, day)),
|
||||
)
|
||||
employee_rows.append({
|
||||
'id': employee.id,
|
||||
'name': employee.name,
|
||||
'department_id': department_id,
|
||||
'department_name': department.name if department else _('No Department'),
|
||||
'job_title': employee.job_title or '',
|
||||
'cells': cells,
|
||||
})
|
||||
|
||||
shifts = request.env['fusion.clock.shift'].sudo().search([
|
||||
('active', '=', True),
|
||||
('company_id', 'in', request.env.user.company_ids.ids),
|
||||
], order='sequence, name')
|
||||
|
||||
return {
|
||||
'week_start': str(start),
|
||||
'week_end': str(days[-1]),
|
||||
'days': [{
|
||||
'date': str(day),
|
||||
'weekday': day.strftime('%a').upper(),
|
||||
'label': day.strftime('%d-%b'),
|
||||
} for day in days],
|
||||
'departments': departments,
|
||||
'employees': employee_rows,
|
||||
'shifts': [{
|
||||
'id': shift.id,
|
||||
'name': shift.name,
|
||||
'start_time': shift.start_time,
|
||||
'end_time': shift.end_time,
|
||||
'break_minutes': shift.break_minutes,
|
||||
'hours': shift.scheduled_hours,
|
||||
'hours_display': Schedule.fclk_hours_display(shift.scheduled_hours),
|
||||
'label': '%s - %s' % (
|
||||
Schedule.fclk_float_to_display(shift.start_time),
|
||||
Schedule.fclk_float_to_display(shift.end_time),
|
||||
),
|
||||
'option_label': '%s (%s - %s)' % (
|
||||
shift.name,
|
||||
Schedule.fclk_float_to_display(shift.start_time),
|
||||
Schedule.fclk_float_to_display(shift.end_time),
|
||||
),
|
||||
} for shift in shifts],
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/load', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def load(self, week_start=None, **kw):
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
return self._load_week_data(week_start)
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/save', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def save(self, week_start=None, changes=None, **kw):
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employees = self._manager_employees()
|
||||
employee_map = {employee.id: employee for employee in employees}
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
errors = []
|
||||
saved = 0
|
||||
|
||||
for change in changes or []:
|
||||
employee_id = int(change.get('employee_id') or 0)
|
||||
employee = employee_map.get(employee_id)
|
||||
date_str = change.get('date')
|
||||
if not employee:
|
||||
errors.append({
|
||||
'employee_id': employee_id,
|
||||
'date': date_str,
|
||||
'message': 'Employee not found or not allowed.',
|
||||
})
|
||||
continue
|
||||
try:
|
||||
Schedule.fclk_apply_planner_cell(employee, date_str, change, request.env.user)
|
||||
saved += 1
|
||||
except ValidationError as exc:
|
||||
errors.append({
|
||||
'employee_id': employee_id,
|
||||
'date': date_str,
|
||||
'message': str(exc.args[0] if exc.args else exc),
|
||||
})
|
||||
|
||||
if errors:
|
||||
return {'success': False, 'saved': saved, 'errors': errors}
|
||||
return {
|
||||
'success': True,
|
||||
'saved': saved,
|
||||
'data': self._load_week_data(week_start),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def copy_previous_week(self, week_start=None, **kw):
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
start = self._week_start(week_start)
|
||||
prev_start = start - timedelta(days=7)
|
||||
employees = self._manager_employees()
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
prev_schedules = Schedule.search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('schedule_date', '>=', prev_start),
|
||||
('schedule_date', '<=', prev_start + timedelta(days=6)),
|
||||
])
|
||||
prev_map = {
|
||||
(schedule.employee_id.id, schedule.schedule_date): schedule
|
||||
for schedule in prev_schedules
|
||||
}
|
||||
|
||||
before_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
|
||||
for employee in employees:
|
||||
for offset in range(7):
|
||||
source_date = prev_start + timedelta(days=offset)
|
||||
target_date = start + timedelta(days=offset)
|
||||
source = prev_map.get((employee.id, source_date))
|
||||
if not source:
|
||||
payload = {'input': ''}
|
||||
elif source.is_off:
|
||||
payload = {'input': 'OFF'}
|
||||
elif source.shift_id:
|
||||
payload = {'shift_id': source.shift_id.id, 'input': source.fclk_display_value()}
|
||||
else:
|
||||
payload = {
|
||||
'input': source.fclk_display_value(),
|
||||
'start_time': source.start_time,
|
||||
'end_time': source.end_time,
|
||||
'break_minutes': source.break_minutes,
|
||||
}
|
||||
Schedule.fclk_apply_planner_cell(employee, target_date, payload, request.env.user)
|
||||
|
||||
after_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
|
||||
return {
|
||||
'success': True,
|
||||
'changed': after_count - before_count,
|
||||
'data': self._load_week_data(start),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def export_xlsx(self, week_start=None, **kw):
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
data = self._load_week_data(week_start)
|
||||
output = io.BytesIO()
|
||||
import xlsxwriter
|
||||
|
||||
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
|
||||
sheet = workbook.add_worksheet('Shift Planner')
|
||||
|
||||
fmt_day = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#b7dff5', 'border': 1})
|
||||
fmt_sub = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#d8e9bd', 'border': 1})
|
||||
fmt_employee = workbook.add_format({'bold': True, 'border': 1})
|
||||
fmt_shift = workbook.add_format({'border': 1})
|
||||
fmt_hours = workbook.add_format({'border': 1, 'align': 'center', 'bg_color': '#f5d39b'})
|
||||
fmt_department = workbook.add_format({'bold': True, 'bg_color': '#eeeeee', 'border': 1})
|
||||
|
||||
sheet.set_column(0, 0, 22)
|
||||
for col in range(1, 15, 2):
|
||||
sheet.set_column(col, col, 24)
|
||||
sheet.set_column(col + 1, col + 1, 9)
|
||||
|
||||
sheet.write(0, 0, 'EMPLOYEE', fmt_day)
|
||||
col = 1
|
||||
for day in data['days']:
|
||||
sheet.merge_range(0, col, 0, col + 1, day['weekday'], fmt_day)
|
||||
sheet.merge_range(1, col, 1, col + 1, day['label'], fmt_day)
|
||||
sheet.write(2, col, 'Shift', fmt_sub)
|
||||
sheet.write(2, col + 1, 'Hours', fmt_sub)
|
||||
col += 2
|
||||
sheet.write(2, 0, 'EMPLOYEE', fmt_sub)
|
||||
|
||||
row = 3
|
||||
employee_by_id = {emp['id']: emp for emp in data['employees']}
|
||||
for department in data['departments']:
|
||||
sheet.merge_range(row, 0, row, 14, department['name'], fmt_department)
|
||||
row += 1
|
||||
for employee_id in department['employee_ids']:
|
||||
employee = employee_by_id[employee_id]
|
||||
sheet.write(row, 0, employee['name'], fmt_employee)
|
||||
col = 1
|
||||
for day in data['days']:
|
||||
cell = employee['cells'][day['date']]
|
||||
sheet.write(row, col, cell.get('label') or '', fmt_shift)
|
||||
sheet.write(row, col + 1, cell.get('hours_display') or '0:00', fmt_hours)
|
||||
col += 2
|
||||
row += 1
|
||||
|
||||
workbook.close()
|
||||
output.seek(0)
|
||||
filename = 'shift_planner_%s.xlsx' % data['week_start']
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(output.read()),
|
||||
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
})
|
||||
return {
|
||||
'success': True,
|
||||
'attachment_id': attachment.id,
|
||||
'filename': filename,
|
||||
'url': '/web/content/%s?download=true' % attachment.id,
|
||||
}
|
||||
@@ -9,5 +9,6 @@ from . import res_config_settings
|
||||
from . import clock_activity_log
|
||||
from . import clock_leave_request
|
||||
from . import clock_shift
|
||||
from . import clock_schedule
|
||||
from . import clock_correction
|
||||
from . import res_company
|
||||
|
||||
BIN
fusion_clock/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_location.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_location.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/res_company.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/res_company.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model):
|
||||
('correction_request', 'Correction Request'),
|
||||
('ip_fallback', 'IP Fallback Used'),
|
||||
('streak_milestone', 'Streak Milestone'),
|
||||
('unscheduled_shift', 'Unscheduled Shift'),
|
||||
('card_enrollment', 'Card Enrollment'),
|
||||
('unknown_card_tap', 'Unknown Card Tap'),
|
||||
],
|
||||
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
|
||||
'correction_request': 'Correction Request',
|
||||
'ip_fallback': 'IP Fallback Used',
|
||||
'streak_milestone': 'Streak Milestone',
|
||||
'unscheduled_shift': 'Unscheduled Shift',
|
||||
}
|
||||
|
||||
@api.depends('latitude', 'longitude')
|
||||
|
||||
414
fusion_clock/models/clock_schedule.py
Normal file
414
fusion_clock/models/clock_schedule.py
Normal file
@@ -0,0 +1,414 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FusionClockSchedule(models.Model):
|
||||
_name = 'fusion.clock.schedule'
|
||||
_description = 'Clock Shift Schedule Entry'
|
||||
_order = 'schedule_date, employee_id'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
schedule_date = fields.Date(
|
||||
string='Date',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
shift_id = fields.Many2one(
|
||||
'fusion.clock.shift',
|
||||
string='Shift Template',
|
||||
ondelete='set null',
|
||||
)
|
||||
is_off = fields.Boolean(
|
||||
string='Off',
|
||||
default=False,
|
||||
index=True,
|
||||
)
|
||||
start_time = fields.Float(
|
||||
string='Start Time',
|
||||
default=9.0,
|
||||
)
|
||||
end_time = fields.Float(
|
||||
string='End Time',
|
||||
default=17.0,
|
||||
)
|
||||
break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=30.0,
|
||||
)
|
||||
planned_hours = fields.Float(
|
||||
string='Hours',
|
||||
compute='_compute_planned_hours',
|
||||
store=True,
|
||||
)
|
||||
note = fields.Char(string='Note')
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
department_id = fields.Many2one(
|
||||
'hr.department',
|
||||
string='Department',
|
||||
related='employee_id.department_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
_employee_date_unique = models.Constraint(
|
||||
'UNIQUE(employee_id, schedule_date)',
|
||||
'Only one shift schedule is allowed per employee per day.',
|
||||
)
|
||||
|
||||
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
|
||||
def _compute_planned_hours(self):
|
||||
for rec in self:
|
||||
if rec.is_off:
|
||||
rec.planned_hours = 0.0
|
||||
continue
|
||||
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
|
||||
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
|
||||
|
||||
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
date_str = str(rec.schedule_date) if rec.schedule_date else ''
|
||||
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
|
||||
|
||||
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
|
||||
def _check_schedule_times(self):
|
||||
for rec in self:
|
||||
if rec.break_minutes < 0:
|
||||
raise ValidationError(_("Break minutes cannot be negative."))
|
||||
if rec.is_off:
|
||||
continue
|
||||
if rec.start_time < 0 or rec.start_time >= 24:
|
||||
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
|
||||
if rec.end_time <= 0 or rec.end_time > 24:
|
||||
raise ValidationError(_("End time must be between 00:01 and 24:00."))
|
||||
if rec.end_time <= rec.start_time:
|
||||
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||||
shift_minutes = (rec.end_time - rec.start_time) * 60.0
|
||||
if rec.break_minutes >= shift_minutes:
|
||||
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
|
||||
|
||||
@api.onchange('shift_id')
|
||||
def _onchange_shift_id(self):
|
||||
for rec in self:
|
||||
if rec.shift_id:
|
||||
rec.is_off = False
|
||||
rec.start_time = rec.shift_id.start_time
|
||||
rec.end_time = rec.shift_id.end_time
|
||||
rec.break_minutes = rec.shift_id.break_minutes
|
||||
|
||||
@api.model
|
||||
def fclk_float_to_display(self, value):
|
||||
value = float(value or 0.0)
|
||||
hour = int(value)
|
||||
minute = int(round((value - hour) * 60))
|
||||
if minute == 60:
|
||||
hour += 1
|
||||
minute = 0
|
||||
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
|
||||
display_hour = hour % 12
|
||||
if display_hour == 0:
|
||||
display_hour = 12
|
||||
return f"{display_hour}:{minute:02d} {suffix}"
|
||||
|
||||
def fclk_display_value(self):
|
||||
self.ensure_one()
|
||||
if self.is_off:
|
||||
return 'OFF'
|
||||
return (
|
||||
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
|
||||
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
|
||||
)
|
||||
|
||||
@api.model
|
||||
def fclk_hours_display(self, hours):
|
||||
hours = float(hours or 0.0)
|
||||
whole = int(hours)
|
||||
minutes = int(round((hours - whole) * 60))
|
||||
if minutes == 60:
|
||||
whole += 1
|
||||
minutes = 0
|
||||
return f"{whole}:{minutes:02d}"
|
||||
|
||||
@api.model
|
||||
def _fclk_parse_time_part(self, raw):
|
||||
text = (raw or '').strip().lower().replace('.', '')
|
||||
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
|
||||
if not match:
|
||||
raise ValidationError(_("Could not understand time '%s'.") % raw)
|
||||
hour = int(match.group(1))
|
||||
minute = int(match.group(2) or 0)
|
||||
meridiem = match.group(3)
|
||||
if minute < 0 or minute > 59:
|
||||
raise ValidationError(_("Minutes must be between 00 and 59."))
|
||||
if meridiem:
|
||||
if hour < 1 or hour > 12:
|
||||
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
|
||||
if meridiem == 'am':
|
||||
hour = 0 if hour == 12 else hour
|
||||
else:
|
||||
hour = 12 if hour == 12 else hour + 12
|
||||
elif hour > 24:
|
||||
raise ValidationError(_("Hours must be between 0 and 24."))
|
||||
return hour + (minute / 60.0)
|
||||
|
||||
@api.model
|
||||
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
|
||||
text = (input_value or '').strip()
|
||||
if not text:
|
||||
return {'clear': True}
|
||||
if text.upper() == 'OFF':
|
||||
return {
|
||||
'clear': False,
|
||||
'is_off': True,
|
||||
'shift_id': False,
|
||||
'start_time': 0.0,
|
||||
'end_time': 0.0,
|
||||
'break_minutes': 0.0,
|
||||
}
|
||||
|
||||
normalized = (
|
||||
text.replace('–', '-')
|
||||
.replace('—', '-')
|
||||
.replace(' to ', '-')
|
||||
.replace(' TO ', '-')
|
||||
)
|
||||
parts = [p.strip() for p in normalized.split('-', 1)]
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
|
||||
start = self._fclk_parse_time_part(parts[0])
|
||||
end = self._fclk_parse_time_part(parts[1])
|
||||
if end <= start and end + 12 <= 24:
|
||||
end += 12
|
||||
if end <= start:
|
||||
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||||
return {
|
||||
'clear': False,
|
||||
'is_off': False,
|
||||
'shift_id': False,
|
||||
'start_time': start,
|
||||
'end_time': end,
|
||||
'break_minutes': float(default_break_minutes or 0.0),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def fclk_values_from_planner_payload(self, payload, employee):
|
||||
payload = payload or {}
|
||||
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
|
||||
if payload.get('is_off'):
|
||||
return {
|
||||
'clear': False,
|
||||
'is_off': True,
|
||||
'shift_id': False,
|
||||
'start_time': 0.0,
|
||||
'end_time': 0.0,
|
||||
'break_minutes': 0.0,
|
||||
}
|
||||
return {
|
||||
'clear': False,
|
||||
'is_off': False,
|
||||
'shift_id': False,
|
||||
'start_time': float(payload.get('start_time') or 0.0),
|
||||
'end_time': float(payload.get('end_time') or 0.0),
|
||||
'break_minutes': float(payload.get('break_minutes') or 0.0),
|
||||
}
|
||||
shift_id = int(payload.get('shift_id') or 0)
|
||||
if shift_id:
|
||||
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
|
||||
if not shift.exists():
|
||||
raise ValidationError(_("Selected shift template no longer exists."))
|
||||
return {
|
||||
'clear': False,
|
||||
'shift_id': shift.id,
|
||||
'is_off': False,
|
||||
'start_time': shift.start_time,
|
||||
'end_time': shift.end_time,
|
||||
'break_minutes': shift.break_minutes,
|
||||
}
|
||||
|
||||
default_break = employee._get_fclk_break_minutes() if employee else 30.0
|
||||
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
|
||||
|
||||
@api.model
|
||||
def fclk_snapshot(self, schedule):
|
||||
if not schedule:
|
||||
return ''
|
||||
return schedule.fclk_display_value()
|
||||
|
||||
@api.model
|
||||
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
|
||||
self = self.sudo()
|
||||
employee = employee.sudo()
|
||||
date_obj = fields.Date.to_date(schedule_date)
|
||||
if not employee.exists() or not date_obj:
|
||||
raise ValidationError(_("Invalid employee or schedule date."))
|
||||
|
||||
existing = self.search([
|
||||
('employee_id', '=', employee.id),
|
||||
('schedule_date', '=', date_obj),
|
||||
], limit=1)
|
||||
old_value = self.fclk_snapshot(existing)
|
||||
parsed = self.fclk_values_from_planner_payload(payload, employee)
|
||||
|
||||
if parsed.get('clear'):
|
||||
if existing:
|
||||
existing.unlink()
|
||||
new_schedule = self.browse()
|
||||
new_value = ''
|
||||
else:
|
||||
vals = {
|
||||
'employee_id': employee.id,
|
||||
'schedule_date': date_obj,
|
||||
'shift_id': parsed.get('shift_id') or False,
|
||||
'is_off': bool(parsed.get('is_off')),
|
||||
'start_time': parsed.get('start_time') or 0.0,
|
||||
'end_time': parsed.get('end_time') or 0.0,
|
||||
'break_minutes': parsed.get('break_minutes') or 0.0,
|
||||
'note': payload.get('note') or False,
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
new_schedule = existing
|
||||
else:
|
||||
new_schedule = self.create(vals)
|
||||
new_value = new_schedule.fclk_display_value()
|
||||
|
||||
if old_value != new_value:
|
||||
self.env['fusion.clock.schedule.audit'].sudo().create({
|
||||
'schedule_id': new_schedule.id if new_schedule else False,
|
||||
'employee_id': employee.id,
|
||||
'schedule_date': date_obj,
|
||||
'old_value': old_value,
|
||||
'new_value': new_value,
|
||||
'changed_by_id': (user or self.env.user).id,
|
||||
'changed_at': fields.Datetime.now(),
|
||||
'company_id': employee.company_id.id,
|
||||
'department_id': employee.department_id.id,
|
||||
})
|
||||
return new_schedule
|
||||
|
||||
@api.model
|
||||
def fclk_cell_payload(self, employee, date_obj, schedule=None):
|
||||
schedule = schedule or self.search([
|
||||
('employee_id', '=', employee.id),
|
||||
('schedule_date', '=', date_obj),
|
||||
], limit=1)
|
||||
Schedule = self.env['fusion.clock.schedule']
|
||||
if schedule:
|
||||
return {
|
||||
'schedule_id': schedule.id,
|
||||
'source': 'schedule',
|
||||
'input': schedule.fclk_display_value(),
|
||||
'label': schedule.fclk_display_value(),
|
||||
'is_off': schedule.is_off,
|
||||
'shift_id': schedule.shift_id.id or False,
|
||||
'start_time': schedule.start_time,
|
||||
'end_time': schedule.end_time,
|
||||
'break_minutes': schedule.break_minutes,
|
||||
'hours': schedule.planned_hours,
|
||||
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
|
||||
'note': schedule.note or '',
|
||||
}
|
||||
|
||||
plan = employee._get_fclk_day_plan(date_obj)
|
||||
return {
|
||||
'schedule_id': False,
|
||||
'source': plan.get('source') or 'fallback',
|
||||
'input': plan.get('label') or '',
|
||||
'label': plan.get('label') or '',
|
||||
'is_off': plan.get('is_off', False),
|
||||
'shift_id': False,
|
||||
'start_time': plan.get('start_time') or 0.0,
|
||||
'end_time': plan.get('end_time') or 0.0,
|
||||
'break_minutes': plan.get('break_minutes') or 0.0,
|
||||
'hours': plan.get('hours') or 0.0,
|
||||
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
|
||||
'note': '',
|
||||
}
|
||||
|
||||
|
||||
class FusionClockScheduleAudit(models.Model):
|
||||
_name = 'fusion.clock.schedule.audit'
|
||||
_description = 'Clock Schedule Change Audit'
|
||||
_order = 'changed_at desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
schedule_id = fields.Many2one(
|
||||
'fusion.clock.schedule',
|
||||
string='Schedule',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
schedule_date = fields.Date(
|
||||
string='Schedule Date',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
old_value = fields.Char(string='Old Value')
|
||||
new_value = fields.Char(string='New Value')
|
||||
changed_by_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Changed By',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
changed_at = fields.Datetime(
|
||||
string='Changed At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
index=True,
|
||||
)
|
||||
department_id = fields.Many2one(
|
||||
'hr.department',
|
||||
string='Department',
|
||||
index=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
rec.display_name = "%s - %s: %s -> %s" % (
|
||||
rec.employee_id.name or '',
|
||||
rec.schedule_date or '',
|
||||
rec.old_value or 'blank',
|
||||
rec.new_value or 'blank',
|
||||
)
|
||||
@@ -227,7 +227,18 @@ class HrAttendance(models.Model):
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold
|
||||
scheduled_hours = daily_threshold
|
||||
if employee:
|
||||
local_date = get_local_today(self.env, employee)
|
||||
if att.check_in:
|
||||
tz_name = (
|
||||
employee.resource_id.tz
|
||||
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
||||
or employee.company_id.partner_id.tz
|
||||
or 'UTC'
|
||||
)
|
||||
local_date = pytz.UTC.localize(att.check_in).astimezone(pytz.timezone(tz_name)).date()
|
||||
scheduled_hours = employee._get_fclk_scheduled_hours(local_date)
|
||||
net = att.x_fclk_net_hours or 0.0
|
||||
|
||||
if net > scheduled_hours:
|
||||
@@ -264,11 +275,14 @@ class HrAttendance(models.Model):
|
||||
employee = att.employee_id
|
||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
||||
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
||||
|
||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||
max_deadline = check_in + timedelta(hours=max_shift)
|
||||
effective_deadline = min(deadline, max_deadline)
|
||||
day_plan = employee._get_fclk_day_plan(check_in_date)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
effective_deadline = max_deadline
|
||||
else:
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||
effective_deadline = min(deadline, max_deadline)
|
||||
|
||||
if now > effective_deadline:
|
||||
clock_out_time = min(effective_deadline, now)
|
||||
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
|
||||
# Apply break deduction
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes()
|
||||
break_min = employee._get_fclk_break_minutes(check_in_date)
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
att.sudo().message_post(
|
||||
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
|
||||
|
||||
if yesterday.weekday() >= 5:
|
||||
continue
|
||||
day_plan = emp._get_fclk_day_plan(yesterday)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
continue
|
||||
|
||||
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||
|
||||
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
|
||||
|
||||
if today.weekday() >= 5:
|
||||
continue
|
||||
day_plan = emp._get_fclk_day_plan(today)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
continue
|
||||
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
|
||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
||||
)
|
||||
|
||||
def _get_fclk_break_minutes(self):
|
||||
"""Return effective break minutes for this employee.
|
||||
Priority: employee override > shift > global setting.
|
||||
def _get_fclk_schedule_for_date(self, date):
|
||||
"""Return this employee's dated Fusion Clock schedule for a local date."""
|
||||
self.ensure_one()
|
||||
date_obj = fields.Date.to_date(date)
|
||||
if not date_obj:
|
||||
return self.env['fusion.clock.schedule']
|
||||
return self.env['fusion.clock.schedule'].sudo().search([
|
||||
('employee_id', '=', self.id),
|
||||
('schedule_date', '=', date_obj),
|
||||
], limit=1)
|
||||
|
||||
def _get_fclk_day_plan(self, date):
|
||||
"""Return the effective plan for a local date.
|
||||
|
||||
Dated schedules are the source of truth. If none exists, the legacy
|
||||
employee shift/global settings remain the fallback.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Schedule = self.env['fusion.clock.schedule'].sudo()
|
||||
schedule = self._get_fclk_schedule_for_date(date)
|
||||
if schedule:
|
||||
return {
|
||||
'source': 'schedule',
|
||||
'schedule_id': schedule.id,
|
||||
'is_off': schedule.is_off,
|
||||
'start_time': schedule.start_time,
|
||||
'end_time': schedule.end_time,
|
||||
'break_minutes': schedule.break_minutes,
|
||||
'hours': schedule.planned_hours,
|
||||
'label': schedule.fclk_display_value(),
|
||||
}
|
||||
if self.x_fclk_shift_id:
|
||||
shift = self.x_fclk_shift_id
|
||||
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
|
||||
return {
|
||||
'source': 'fallback',
|
||||
'schedule_id': False,
|
||||
'is_off': False,
|
||||
'start_time': shift.start_time,
|
||||
'end_time': shift.end_time,
|
||||
'break_minutes': shift.break_minutes,
|
||||
'hours': hours,
|
||||
'label': '%s - %s' % (
|
||||
Schedule.fclk_float_to_display(shift.start_time),
|
||||
Schedule.fclk_float_to_display(shift.end_time),
|
||||
),
|
||||
}
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
|
||||
hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0)
|
||||
return {
|
||||
'source': 'fallback',
|
||||
'schedule_id': False,
|
||||
'is_off': False,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'break_minutes': break_minutes,
|
||||
'hours': hours,
|
||||
'label': '%s - %s' % (
|
||||
Schedule.fclk_float_to_display(start_time),
|
||||
Schedule.fclk_float_to_display(end_time),
|
||||
),
|
||||
}
|
||||
|
||||
def _get_fclk_break_minutes(self, date=None):
|
||||
"""Return effective break minutes for this employee.
|
||||
Priority: dated schedule > employee override > shift > global setting.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if date:
|
||||
plan = self._get_fclk_day_plan(date)
|
||||
if plan.get('source') == 'schedule' and not plan.get('is_off'):
|
||||
return plan.get('break_minutes') or 0.0
|
||||
if self.x_fclk_break_minutes > 0:
|
||||
return self.x_fclk_break_minutes
|
||||
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
|
||||
@@ -138,7 +209,7 @@ class HrEmployee(models.Model):
|
||||
def _get_fclk_scheduled_times(self, date):
|
||||
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
||||
|
||||
Uses employee shift if assigned, otherwise global settings.
|
||||
Uses dated schedule first, employee shift second, then global settings.
|
||||
The configured hours are interpreted in the employee's local
|
||||
timezone and converted to naive-UTC datetimes so they can be
|
||||
compared with Odoo's UTC-based ``fields.Datetime.now()``.
|
||||
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
|
||||
import pytz
|
||||
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
in_hour = self.x_fclk_shift_id.start_time
|
||||
out_hour = self.x_fclk_shift_id.end_time
|
||||
else:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
plan = self._get_fclk_day_plan(date)
|
||||
in_hour = plan.get('start_time') or 0.0
|
||||
out_hour = plan.get('end_time') or 0.0
|
||||
|
||||
in_h = int(in_hour)
|
||||
in_m = int((in_hour - in_h) * 60)
|
||||
@@ -179,16 +246,13 @@ class HrEmployee(models.Model):
|
||||
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
|
||||
return scheduled_in, scheduled_out
|
||||
|
||||
def _get_fclk_scheduled_hours(self):
|
||||
def _get_fclk_scheduled_hours(self, date=None):
|
||||
"""Return the expected work hours for this employee's shift."""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
return self.x_fclk_shift_id.scheduled_hours
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
break_hrs = self._get_fclk_break_minutes() / 60.0
|
||||
return max((out_hour - in_hour) - break_hrs, 0.0)
|
||||
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
|
||||
if plan.get('is_off'):
|
||||
return 0.0
|
||||
return plan.get('hours') or 0.0
|
||||
|
||||
def _compute_absence_counts(self):
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
|
||||
@@ -11,6 +11,9 @@ access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fus
|
||||
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_schedule_user,fusion.clock.schedule.user,model_fusion_clock_schedule,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_schedule_manager,fusion.clock.schedule.manager,model_fusion_clock_schedule,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_schedule_audit_manager,fusion.clock.schedule.audit.manager,model_fusion_clock_schedule_audit,group_fusion_clock_manager,1,0,0,0
|
||||
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
|
||||
@@ -22,4 +25,5 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio
|
||||
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
|
||||
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
|
||||
|
||||
|
@@ -174,6 +174,49 @@
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Record Rules - Dated Schedules
|
||||
================================================================ -->
|
||||
<record id="rule_schedule_user" model="ir.rule">
|
||||
<field name="name">Schedule: User sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<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>
|
||||
|
||||
<record id="rule_schedule_team_lead" model="ir.rule">
|
||||
<field name="name">Schedule: Team Lead sees direct reports</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||
<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>
|
||||
|
||||
<record id="rule_schedule_manager" model="ir.rule">
|
||||
<field name="name">Schedule: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_schedule_audit_manager" model="ir.rule">
|
||||
<field name="name">Schedule Audit: Manager reads all</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule_audit"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
<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>
|
||||
|
||||
<!-- ================================================================
|
||||
Record Rules - Correction Request
|
||||
================================================================ -->
|
||||
@@ -286,4 +329,15 @@
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_schedule_portal" model="ir.rule">
|
||||
<field name="name">Schedule: Portal user sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<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>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ---- Scheduled Shift Card ---- */
|
||||
.fclk-schedule-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--fclk-card);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin: -14px 0 28px;
|
||||
box-shadow: var(--fclk-shadow);
|
||||
}
|
||||
|
||||
.fclk-schedule-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: var(--fclk-blue);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.fclk-schedule-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fclk-schedule-label {
|
||||
color: var(--fclk-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.fclk-schedule-value {
|
||||
color: var(--fclk-text);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fclk-schedule-hours {
|
||||
color: var(--fclk-text);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Timer Section ---- */
|
||||
.fclk-timer-section {
|
||||
text-align: center;
|
||||
|
||||
741
fusion_clock/static/src/js/fusion_clock_shift_planner.js
Normal file
741
fusion_clock/static/src/js/fusion_clock_shift_planner.js
Normal file
@@ -0,0 +1,741 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onPatched, onWillStart, useExternalListener, useRef, useState } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class FusionClockShiftPlanner extends Component {
|
||||
static template = "fusion_clock.ShiftPlanner";
|
||||
static props = [];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.dirtyCells = {};
|
||||
this.root = useRef("root");
|
||||
this.editorRef = useRef("shiftEditor");
|
||||
this.activeCellAnchor = null;
|
||||
this.activeEditorEmployee = null;
|
||||
this.activeEditorDay = null;
|
||||
this.timeOptions = this._buildTimeOptions();
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
saving: false,
|
||||
weekStart: "",
|
||||
weekEnd: "",
|
||||
days: [],
|
||||
departments: [],
|
||||
employees: [],
|
||||
shifts: [],
|
||||
error: "",
|
||||
dirtyCount: 0,
|
||||
invalidCount: 0,
|
||||
collapsed: {},
|
||||
editor: {
|
||||
open: false,
|
||||
employeeId: false,
|
||||
employeeName: "",
|
||||
date: "",
|
||||
dayLabel: "",
|
||||
startValue: "9.00",
|
||||
endValue: "17.00",
|
||||
breakMinutes: 30,
|
||||
hoursDisplay: "7:30",
|
||||
error: "",
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.loadWeek();
|
||||
});
|
||||
useExternalListener(
|
||||
window,
|
||||
"click",
|
||||
(ev) => this.onGlobalClick(ev),
|
||||
{ capture: true }
|
||||
);
|
||||
useExternalListener(window, "resize", () => this._positionActiveEditor());
|
||||
useExternalListener(window, "scroll", () => this._positionActiveEditor(), true);
|
||||
onPatched(() => {
|
||||
this._positionActiveEditor();
|
||||
});
|
||||
}
|
||||
|
||||
async loadWeek(weekStart = null) {
|
||||
this.state.loading = true;
|
||||
this.state.error = "";
|
||||
try {
|
||||
const data = await rpc("/fusion_clock/shift_planner/load", { week_start: weekStart });
|
||||
if (data.error) {
|
||||
this.state.error = data.error;
|
||||
} else {
|
||||
this._applyData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
this.state.error = error.message || "Failed to load shift planner.";
|
||||
}
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
_applyData(data) {
|
||||
this.dirtyCells = {};
|
||||
this.state.weekStart = data.week_start;
|
||||
this.state.weekEnd = data.week_end;
|
||||
this.state.days = data.days || [];
|
||||
this.state.departments = data.departments || [];
|
||||
this.state.employees = data.employees || [];
|
||||
this.state.shifts = data.shifts || [];
|
||||
this.state.dirtyCount = 0;
|
||||
this.state.invalidCount = 0;
|
||||
this.state.error = "";
|
||||
this.closeCellEditor();
|
||||
}
|
||||
|
||||
get weekTitle() {
|
||||
if (!this.state.weekStart || !this.state.weekEnd) {
|
||||
return "";
|
||||
}
|
||||
return `${this.state.weekStart} to ${this.state.weekEnd}`;
|
||||
}
|
||||
|
||||
getDepartmentEmployees(department) {
|
||||
const ids = new Set(department.employee_ids || []);
|
||||
return this.state.employees.filter((employee) => ids.has(employee.id));
|
||||
}
|
||||
|
||||
isCollapsed(department) {
|
||||
return !!this.state.collapsed[department.id];
|
||||
}
|
||||
|
||||
toggleDepartment(department) {
|
||||
this.state.collapsed[department.id] = !this.state.collapsed[department.id];
|
||||
this.closeCellEditor();
|
||||
}
|
||||
|
||||
async previousWeek() {
|
||||
await this.loadWeek(this._dateAdd(this.state.weekStart, -7));
|
||||
}
|
||||
|
||||
async nextWeek() {
|
||||
await this.loadWeek(this._dateAdd(this.state.weekStart, 7));
|
||||
}
|
||||
|
||||
async currentWeek() {
|
||||
await this.loadWeek();
|
||||
}
|
||||
|
||||
async copyPreviousWeek() {
|
||||
if (!window.confirm("Copy the previous week into this week? Current saved cells for the week may be replaced.")) {
|
||||
return;
|
||||
}
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/shift_planner/copy_previous_week", {
|
||||
week_start: this.state.weekStart,
|
||||
});
|
||||
if (result.error) {
|
||||
this.notification.add(result.error, { type: "danger" });
|
||||
} else {
|
||||
this._applyData(result.data);
|
||||
this.notification.add(`Copied previous week (${result.changed || 0} changes).`, { type: "success" });
|
||||
}
|
||||
} catch (error) {
|
||||
this.notification.add(error.message || "Could not copy previous week.", { type: "danger" });
|
||||
}
|
||||
this.state.saving = false;
|
||||
}
|
||||
|
||||
async save() {
|
||||
this._recountInvalid();
|
||||
if (this.state.invalidCount) {
|
||||
this.notification.add("Fix invalid shift cells before saving.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
const changes = Object.values(this.dirtyCells);
|
||||
if (!changes.length) {
|
||||
this.notification.add("No shift changes to save.", { type: "info" });
|
||||
return;
|
||||
}
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/shift_planner/save", {
|
||||
week_start: this.state.weekStart,
|
||||
changes,
|
||||
});
|
||||
if (result.error) {
|
||||
this.notification.add(result.error, { type: "danger" });
|
||||
} else if (!result.success) {
|
||||
this._markServerErrors(result.errors || []);
|
||||
this.notification.add("Some shift cells could not be saved.", { type: "danger" });
|
||||
} else {
|
||||
this._applyData(result.data);
|
||||
this.notification.add(`Saved ${result.saved || 0} shift changes.`, { type: "success" });
|
||||
}
|
||||
} catch (error) {
|
||||
this.notification.add(error.message || "Could not save shift planner.", { type: "danger" });
|
||||
}
|
||||
this.state.saving = false;
|
||||
}
|
||||
|
||||
async exportXlsx() {
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/shift_planner/export_xlsx", {
|
||||
week_start: this.state.weekStart,
|
||||
});
|
||||
if (result.error) {
|
||||
this.notification.add(result.error, { type: "danger" });
|
||||
return;
|
||||
}
|
||||
window.location = result.url;
|
||||
} catch (error) {
|
||||
this.notification.add(error.message || "Could not export shift planner.", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
openCellEditor(employee, day, ev) {
|
||||
if (this.state.loading || this.state.saving) {
|
||||
return;
|
||||
}
|
||||
const anchor = ev.currentTarget.closest(".fclk-planner__shift-cell") || ev.currentTarget;
|
||||
this.activeCellAnchor = anchor;
|
||||
this.activeEditorEmployee = employee;
|
||||
this.activeEditorDay = day;
|
||||
|
||||
const cell = employee.cells[day.date] || {};
|
||||
const fallback = this._defaultTimes(employee, day);
|
||||
const start = cell.is_off ? fallback.start : (cell.start_time || fallback.start);
|
||||
const end = cell.is_off ? fallback.end : (cell.end_time || fallback.end);
|
||||
const breakMinutes = cell.is_off ? 0 : (cell.break_minutes || fallback.breakMinutes || 30);
|
||||
const hours = cell.is_off ? 0 : Math.max(end - start - breakMinutes / 60, 0);
|
||||
|
||||
this.state.editor.open = true;
|
||||
this.state.editor.employeeId = employee.id;
|
||||
this.state.editor.employeeName = employee.name;
|
||||
this.state.editor.date = day.date;
|
||||
this.state.editor.dayLabel = `${day.weekday} ${day.label}`;
|
||||
this.state.editor.startValue = this._timeValue(start);
|
||||
this.state.editor.endValue = this._timeValue(end);
|
||||
this.state.editor.breakMinutes = breakMinutes;
|
||||
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
|
||||
this.state.editor.error = cell.error || "";
|
||||
this._positionActiveEditor(anchor);
|
||||
}
|
||||
|
||||
closeCellEditor() {
|
||||
this.state.editor.open = false;
|
||||
this.activeCellAnchor = null;
|
||||
this.activeEditorEmployee = null;
|
||||
this.activeEditorDay = null;
|
||||
}
|
||||
|
||||
onGlobalClick(ev) {
|
||||
if (!this.state.editor.open) {
|
||||
return;
|
||||
}
|
||||
const target = ev.target;
|
||||
const clickedEditor = this.editorRef.el && this.editorRef.el.contains(target);
|
||||
const clickedCell = this.activeCellAnchor && this.activeCellAnchor.contains(target);
|
||||
if (!clickedEditor && !clickedCell) {
|
||||
this.closeCellEditor();
|
||||
}
|
||||
}
|
||||
|
||||
isActiveCell(employee, day) {
|
||||
return this.state.editor.open
|
||||
&& this.state.editor.employeeId === employee.id
|
||||
&& this.state.editor.date === day.date;
|
||||
}
|
||||
|
||||
onCellInput(employee, day, ev) {
|
||||
this._setCellFromInput(employee, day, ev.target.value, ev.target);
|
||||
}
|
||||
|
||||
onCellKeydown(employee, day, ev) {
|
||||
if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
this.closeCellEditor();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Tab") {
|
||||
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
|
||||
this.closeCellEditor();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
|
||||
if (!employee.cells[day.date]?.error) {
|
||||
this.closeCellEditor();
|
||||
this._focusRelativeCell(ev.currentTarget, ev.shiftKey ? -this.state.days.length : this.state.days.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectQuickShift(option) {
|
||||
const context = this._activeEditorContext();
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
let parsed;
|
||||
if (option.type === "template") {
|
||||
parsed = {
|
||||
is_off: false,
|
||||
shift_id: option.shiftId,
|
||||
start_time: option.start,
|
||||
end_time: option.end,
|
||||
break_minutes: option.breakMinutes,
|
||||
hours: option.hours,
|
||||
hours_display: option.hoursDisplay,
|
||||
label: option.input,
|
||||
normalized_input: option.input,
|
||||
};
|
||||
} else {
|
||||
parsed = this._parseInput(option.input, context.cell);
|
||||
}
|
||||
this._applyParsedToCell(context.employee, context.day, parsed, option.input);
|
||||
this._syncEditorFromCell(context.employee, context.day);
|
||||
this.closeCellEditor();
|
||||
}
|
||||
|
||||
clearActiveCell() {
|
||||
const context = this._activeEditorContext();
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
this._setCellFromInput(context.employee, context.day, "");
|
||||
this.closeCellEditor();
|
||||
}
|
||||
|
||||
onEditorStartChange(ev) {
|
||||
this.state.editor.startValue = ev.target.value;
|
||||
this.applyEditorRange(false);
|
||||
}
|
||||
|
||||
onEditorEndChange(ev) {
|
||||
this.state.editor.endValue = ev.target.value;
|
||||
this.applyEditorRange(false);
|
||||
}
|
||||
|
||||
applyEditorRange(close = true) {
|
||||
const context = this._activeEditorContext();
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const start = Number(this.state.editor.startValue);
|
||||
let end = Number(this.state.editor.endValue);
|
||||
if (end <= start) {
|
||||
end = Math.min(start + 0.5, 24);
|
||||
this.state.editor.endValue = this._timeValue(end);
|
||||
}
|
||||
const parsed = this._rangeToParsed(start, end, this.state.editor.breakMinutes || 0);
|
||||
if (parsed.error) {
|
||||
context.cell.error = parsed.error;
|
||||
this.state.editor.error = parsed.error;
|
||||
} else {
|
||||
this._applyParsedToCell(context.employee, context.day, parsed, parsed.label);
|
||||
this._syncEditorFromCell(context.employee, context.day);
|
||||
}
|
||||
this._recountInvalid();
|
||||
if (close && !parsed.error) {
|
||||
this.closeCellEditor();
|
||||
}
|
||||
}
|
||||
|
||||
_setCellFromInput(employee, day, input, target = null) {
|
||||
const cell = employee.cells[day.date];
|
||||
cell.input = input;
|
||||
|
||||
const parsed = this._parseInput(input, cell);
|
||||
this._applyParsedToCell(employee, day, parsed, input);
|
||||
if (!parsed.error && target && parsed.normalized_input !== undefined) {
|
||||
target.value = parsed.normalized_input;
|
||||
}
|
||||
this._syncEditorFromCell(employee, day);
|
||||
}
|
||||
|
||||
_applyParsedToCell(employee, day, parsed, input) {
|
||||
const cell = employee.cells[day.date];
|
||||
cell.error = parsed.error || "";
|
||||
if (parsed.error) {
|
||||
cell.input = input;
|
||||
this.state.editor.error = parsed.error;
|
||||
this._markDirty(employee, day);
|
||||
this._recountInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
cell.is_off = parsed.is_off || false;
|
||||
cell.shift_id = parsed.shift_id || false;
|
||||
cell.start_time = parsed.start_time || 0;
|
||||
cell.end_time = parsed.end_time || 0;
|
||||
cell.break_minutes = parsed.break_minutes || 0;
|
||||
cell.hours = parsed.hours || 0;
|
||||
cell.hours_display = parsed.hours_display || "0:00";
|
||||
cell.label = parsed.label || "";
|
||||
cell.input = parsed.normalized_input !== undefined ? parsed.normalized_input : input;
|
||||
this.state.editor.error = "";
|
||||
this._markDirty(employee, day);
|
||||
this._recountInvalid();
|
||||
}
|
||||
|
||||
_markDirty(employee, day) {
|
||||
const cell = employee.cells[day.date];
|
||||
const key = `${employee.id}:${day.date}`;
|
||||
const payload = {
|
||||
employee_id: employee.id,
|
||||
date: day.date,
|
||||
input: cell.input,
|
||||
shift_id: cell.shift_id || false,
|
||||
note: cell.note || "",
|
||||
};
|
||||
if ((cell.input || "").trim()) {
|
||||
payload.is_off = !!cell.is_off;
|
||||
payload.start_time = cell.start_time || 0;
|
||||
payload.end_time = cell.end_time || 0;
|
||||
payload.break_minutes = cell.break_minutes || 0;
|
||||
}
|
||||
this.dirtyCells[key] = payload;
|
||||
this.state.dirtyCount = Object.keys(this.dirtyCells).length;
|
||||
}
|
||||
|
||||
_markServerErrors(errors) {
|
||||
for (const error of errors) {
|
||||
const employee = this.state.employees.find((emp) => emp.id === error.employee_id);
|
||||
const cell = employee && employee.cells[error.date];
|
||||
if (cell) {
|
||||
cell.error = error.message;
|
||||
}
|
||||
}
|
||||
this._recountInvalid();
|
||||
}
|
||||
|
||||
_recountInvalid() {
|
||||
let invalid = 0;
|
||||
for (const employee of this.state.employees) {
|
||||
for (const day of this.state.days) {
|
||||
if (employee.cells[day.date]?.error) {
|
||||
invalid++;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.invalidCount = invalid;
|
||||
}
|
||||
|
||||
_parseInput(value, currentCell = {}) {
|
||||
const text = (value || "").trim();
|
||||
if (!text) {
|
||||
return {
|
||||
is_off: false,
|
||||
shift_id: false,
|
||||
start_time: 0,
|
||||
end_time: 0,
|
||||
break_minutes: 0,
|
||||
label: "",
|
||||
hours: 0,
|
||||
hours_display: "0:00",
|
||||
normalized_input: "",
|
||||
};
|
||||
}
|
||||
if (text.toUpperCase() === "OFF") {
|
||||
return {
|
||||
is_off: true,
|
||||
shift_id: false,
|
||||
start_time: 0,
|
||||
end_time: 0,
|
||||
break_minutes: 0,
|
||||
hours: 0,
|
||||
hours_display: "0:00",
|
||||
label: "OFF",
|
||||
normalized_input: "OFF",
|
||||
};
|
||||
}
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
const template = this.state.shifts.find((shift) =>
|
||||
[shift.option_label, shift.label, shift.name].some((value) => (value || "").toLowerCase() === lowerText)
|
||||
);
|
||||
if (template) {
|
||||
return {
|
||||
is_off: false,
|
||||
shift_id: template.id,
|
||||
start_time: template.start_time,
|
||||
end_time: template.end_time,
|
||||
break_minutes: template.break_minutes,
|
||||
hours: template.hours,
|
||||
hours_display: template.hours_display,
|
||||
label: template.label,
|
||||
normalized_input: template.label,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = this._parseTypedShift(text, currentCell);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
_parseTypedShift(value, currentCell = {}) {
|
||||
const normalized = value.replaceAll("–", "-").replaceAll("—", "-").replace(/\s+to\s+/i, "-");
|
||||
const parts = normalized.split("-");
|
||||
if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) {
|
||||
throw new Error("Use 9-5, 9:00-5:30, 9:00 am - 5:30 pm, or OFF.");
|
||||
}
|
||||
const start = this._parseTimePart(parts[0]);
|
||||
let end = this._parseTimePart(parts[1]);
|
||||
if (end <= start && end + 12 <= 24) {
|
||||
end += 12;
|
||||
}
|
||||
if (end <= start) {
|
||||
throw new Error("End must be after start.");
|
||||
}
|
||||
const breakMinutes = currentCell.break_minutes || 30;
|
||||
const hours = Math.max(end - start - breakMinutes / 60, 0);
|
||||
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
|
||||
return {
|
||||
is_off: false,
|
||||
shift_id: false,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
break_minutes: breakMinutes,
|
||||
hours,
|
||||
hours_display: this._formatHours(hours),
|
||||
label,
|
||||
normalized_input: label,
|
||||
};
|
||||
}
|
||||
|
||||
_rangeToParsed(start, end, breakMinutes) {
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) {
|
||||
return { error: "Choose a start and end time." };
|
||||
}
|
||||
if (end <= start) {
|
||||
return { error: "End must be after start." };
|
||||
}
|
||||
const hours = Math.max(end - start - breakMinutes / 60, 0);
|
||||
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
|
||||
return {
|
||||
is_off: false,
|
||||
shift_id: false,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
break_minutes: breakMinutes,
|
||||
hours,
|
||||
hours_display: this._formatHours(hours),
|
||||
label,
|
||||
normalized_input: label,
|
||||
};
|
||||
}
|
||||
|
||||
_parseTimePart(raw) {
|
||||
const text = raw.trim().toLowerCase().replaceAll(".", "");
|
||||
const match = text.match(/^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not read "${raw.trim()}".`);
|
||||
}
|
||||
let hour = Number(match[1]);
|
||||
const minute = Number(match[2] || 0);
|
||||
const meridiem = match[3];
|
||||
if (minute < 0 || minute > 59) {
|
||||
throw new Error("Minutes must be 00-59.");
|
||||
}
|
||||
if (meridiem) {
|
||||
if (hour < 1 || hour > 12) {
|
||||
throw new Error("Use 1-12 with am/pm.");
|
||||
}
|
||||
if (meridiem === "am") {
|
||||
hour = hour === 12 ? 0 : hour;
|
||||
} else {
|
||||
hour = hour === 12 ? 12 : hour + 12;
|
||||
}
|
||||
}
|
||||
if (hour < 0 || hour > 24) {
|
||||
throw new Error("Hours must be 0-24.");
|
||||
}
|
||||
return hour + minute / 60;
|
||||
}
|
||||
|
||||
_formatFloatTime(value) {
|
||||
let hour = Math.floor(value);
|
||||
let minute = Math.round((value - hour) * 60);
|
||||
if (minute === 60) {
|
||||
hour += 1;
|
||||
minute = 0;
|
||||
}
|
||||
const suffix = hour < 12 || hour === 24 ? "am" : "pm";
|
||||
let displayHour = hour % 12;
|
||||
if (displayHour === 0) {
|
||||
displayHour = 12;
|
||||
}
|
||||
return `${displayHour}:${String(minute).padStart(2, "0")} ${suffix}`;
|
||||
}
|
||||
|
||||
_formatHours(value) {
|
||||
let hour = Math.floor(value);
|
||||
let minute = Math.round((value - hour) * 60);
|
||||
if (minute === 60) {
|
||||
hour += 1;
|
||||
minute = 0;
|
||||
}
|
||||
return `${hour}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
_timeValue(value) {
|
||||
const rounded = Math.round(Number(value || 0) * 4) / 4;
|
||||
return rounded.toFixed(2);
|
||||
}
|
||||
|
||||
_buildTimeOptions() {
|
||||
const options = [];
|
||||
for (let minutes = 0; minutes <= 24 * 60; minutes += 15) {
|
||||
const value = minutes / 60;
|
||||
options.push({
|
||||
value: this._timeValue(value),
|
||||
label: this._formatFloatTime(value),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
_defaultTimes(employee, day) {
|
||||
const dayIndex = this.state.days.findIndex((item) => item.date === day.date);
|
||||
if (dayIndex > 0) {
|
||||
const previousDay = this.state.days[dayIndex - 1];
|
||||
const previousCell = employee.cells[previousDay.date];
|
||||
if (previousCell && !previousCell.is_off && previousCell.start_time && previousCell.end_time) {
|
||||
return {
|
||||
start: previousCell.start_time,
|
||||
end: previousCell.end_time,
|
||||
breakMinutes: previousCell.break_minutes || 30,
|
||||
};
|
||||
}
|
||||
}
|
||||
const firstShift = this.state.shifts[0];
|
||||
if (firstShift) {
|
||||
return {
|
||||
start: firstShift.start_time,
|
||||
end: firstShift.end_time,
|
||||
breakMinutes: firstShift.break_minutes || 30,
|
||||
};
|
||||
}
|
||||
return { start: 9, end: 17, breakMinutes: 30 };
|
||||
}
|
||||
|
||||
get quickShiftOptions() {
|
||||
const options = [{
|
||||
key: "off",
|
||||
type: "input",
|
||||
input: "OFF",
|
||||
label: "OFF",
|
||||
detail: "0:00",
|
||||
}];
|
||||
const seen = new Set(["OFF"]);
|
||||
for (const shift of this.state.shifts) {
|
||||
if (seen.has(shift.label)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(shift.label);
|
||||
options.push({
|
||||
key: `shift-${shift.id}`,
|
||||
type: "template",
|
||||
shiftId: shift.id,
|
||||
input: shift.label,
|
||||
label: shift.name || shift.label,
|
||||
detail: `${shift.label} - ${shift.hours_display}`,
|
||||
start: shift.start_time,
|
||||
end: shift.end_time,
|
||||
breakMinutes: shift.break_minutes,
|
||||
hours: shift.hours,
|
||||
hoursDisplay: shift.hours_display,
|
||||
});
|
||||
}
|
||||
for (const input of ["9:00 am - 5:00 pm", "7:00 am - 3:30 pm", "8:00 am - 4:30 pm", "11:00 am - 7:30 pm", "12:00 pm - 8:30 pm"]) {
|
||||
if (seen.has(input)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = this._parseInput(input, { break_minutes: 30 });
|
||||
seen.add(input);
|
||||
options.push({
|
||||
key: `common-${input}`,
|
||||
type: "input",
|
||||
input,
|
||||
label: input,
|
||||
detail: parsed.hours_display || "0:00",
|
||||
});
|
||||
}
|
||||
return options.slice(0, 10);
|
||||
}
|
||||
|
||||
_activeEditorContext() {
|
||||
if (!this.state.editor.open || !this.activeEditorEmployee || !this.activeEditorDay) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
employee: this.activeEditorEmployee,
|
||||
day: this.activeEditorDay,
|
||||
cell: this.activeEditorEmployee.cells[this.activeEditorDay.date],
|
||||
};
|
||||
}
|
||||
|
||||
_syncEditorFromCell(employee, day) {
|
||||
if (!this.isActiveCell(employee, day)) {
|
||||
return;
|
||||
}
|
||||
const cell = employee.cells[day.date] || {};
|
||||
if (!cell.is_off && cell.start_time && cell.end_time) {
|
||||
this.state.editor.startValue = this._timeValue(cell.start_time);
|
||||
this.state.editor.endValue = this._timeValue(cell.end_time);
|
||||
}
|
||||
this.state.editor.breakMinutes = cell.break_minutes || 0;
|
||||
this.state.editor.hoursDisplay = cell.hours_display || "0:00";
|
||||
this.state.editor.error = cell.error || "";
|
||||
}
|
||||
|
||||
_focusRelativeCell(input, offset) {
|
||||
const inputs = Array.from(document.querySelectorAll(".fclk-planner__shift-input"));
|
||||
const index = inputs.indexOf(input);
|
||||
const next = inputs[index + offset];
|
||||
if (next) {
|
||||
next.focus();
|
||||
next.select();
|
||||
}
|
||||
}
|
||||
|
||||
_positionActiveEditor(anchor = null) {
|
||||
if (!this.state.editor.open) {
|
||||
return;
|
||||
}
|
||||
const target = anchor || this.activeCellAnchor;
|
||||
if (!target || !target.isConnected) {
|
||||
this.closeCellEditor();
|
||||
return;
|
||||
}
|
||||
const rect = target.getBoundingClientRect();
|
||||
const editorWidth = Math.min(380, window.innerWidth - 16);
|
||||
const editorHeight = this.editorRef.el?.offsetHeight || 300;
|
||||
let left = Math.max(8, Math.min(rect.left, window.innerWidth - editorWidth - 8));
|
||||
let top = rect.bottom + 8;
|
||||
if (top + editorHeight > window.innerHeight - 8) {
|
||||
top = Math.max(8, rect.top - editorHeight - 8);
|
||||
}
|
||||
left = Math.round(left);
|
||||
top = Math.round(top);
|
||||
if (this.state.editor.left !== left) {
|
||||
this.state.editor.left = left;
|
||||
}
|
||||
if (this.state.editor.top !== top) {
|
||||
this.state.editor.top = top;
|
||||
}
|
||||
}
|
||||
|
||||
_dateAdd(dateString, days) {
|
||||
const date = new Date(`${dateString}T12:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fusion_clock.ShiftPlanner", FusionClockShiftPlanner);
|
||||
@@ -0,0 +1,77 @@
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_fclk-planner-page: #f3f4f6;
|
||||
$_fclk-planner-panel: #eef1f4;
|
||||
$_fclk-planner-card: #ffffff;
|
||||
$_fclk-planner-text: #1f2937;
|
||||
$_fclk-planner-muted: #6b7280;
|
||||
$_fclk-planner-border: #d8dadd;
|
||||
$_fclk-planner-border-strong: #9ca3af;
|
||||
$_fclk-planner-day: #b7dff5;
|
||||
$_fclk-planner-subhead: #d8e9bd;
|
||||
$_fclk-planner-hours: #f5d39b;
|
||||
$_fclk-planner-fallback: #fff8e5;
|
||||
$_fclk-planner-row-hover: #f9fafb;
|
||||
$_fclk-planner-error: #dc2626;
|
||||
$_fclk-planner-focus: #2563eb;
|
||||
$_fclk-planner-shadow: rgba(15, 23, 42, 0.08);
|
||||
$_fclk-planner-editor: #111827;
|
||||
$_fclk-planner-editor-text: #f9fafb;
|
||||
$_fclk-planner-editor-muted: #cbd5e1;
|
||||
$_fclk-planner-editor-border: #374151;
|
||||
$_fclk-planner-editor-control: #ffffff;
|
||||
$_fclk-planner-editor-control-text: #111827;
|
||||
$_fclk-planner-editor-chip: #1f2937;
|
||||
$_fclk-planner-editor-chip-hover: #334155;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fclk-planner-page: #171a1f !global;
|
||||
$_fclk-planner-panel: #20242b !global;
|
||||
$_fclk-planner-card: #262b33 !global;
|
||||
$_fclk-planner-text: #f3f4f6 !global;
|
||||
$_fclk-planner-muted: #a3aab8 !global;
|
||||
$_fclk-planner-border: #3b424c !global;
|
||||
$_fclk-planner-border-strong: #647082 !global;
|
||||
$_fclk-planner-day: #21465f !global;
|
||||
$_fclk-planner-subhead: #394b2d !global;
|
||||
$_fclk-planner-hours: #6f4f22 !global;
|
||||
$_fclk-planner-fallback: #393326 !global;
|
||||
$_fclk-planner-row-hover: #2b313a !global;
|
||||
$_fclk-planner-error: #f87171 !global;
|
||||
$_fclk-planner-focus: #60a5fa !global;
|
||||
$_fclk-planner-shadow: rgba(0, 0, 0, 0.32) !global;
|
||||
$_fclk-planner-editor: #0f172a !global;
|
||||
$_fclk-planner-editor-text: #f9fafb !global;
|
||||
$_fclk-planner-editor-muted: #cbd5e1 !global;
|
||||
$_fclk-planner-editor-border: #475569 !global;
|
||||
$_fclk-planner-editor-control: #1f2937 !global;
|
||||
$_fclk-planner-editor-control-text: #f9fafb !global;
|
||||
$_fclk-planner-editor-chip: #1e293b !global;
|
||||
$_fclk-planner-editor-chip-hover: #334155 !global;
|
||||
}
|
||||
|
||||
:root {
|
||||
--fclk-planner-page: #{$_fclk-planner-page};
|
||||
--fclk-planner-panel: #{$_fclk-planner-panel};
|
||||
--fclk-planner-card: #{$_fclk-planner-card};
|
||||
--fclk-planner-text: #{$_fclk-planner-text};
|
||||
--fclk-planner-muted: #{$_fclk-planner-muted};
|
||||
--fclk-planner-border: #{$_fclk-planner-border};
|
||||
--fclk-planner-border-strong: #{$_fclk-planner-border-strong};
|
||||
--fclk-planner-day: #{$_fclk-planner-day};
|
||||
--fclk-planner-subhead: #{$_fclk-planner-subhead};
|
||||
--fclk-planner-hours: #{$_fclk-planner-hours};
|
||||
--fclk-planner-fallback: #{$_fclk-planner-fallback};
|
||||
--fclk-planner-row-hover: #{$_fclk-planner-row-hover};
|
||||
--fclk-planner-error: #{$_fclk-planner-error};
|
||||
--fclk-planner-focus: #{$_fclk-planner-focus};
|
||||
--fclk-planner-shadow: #{$_fclk-planner-shadow};
|
||||
--fclk-planner-editor: #{$_fclk-planner-editor};
|
||||
--fclk-planner-editor-text: #{$_fclk-planner-editor-text};
|
||||
--fclk-planner-editor-muted: #{$_fclk-planner-editor-muted};
|
||||
--fclk-planner-editor-border: #{$_fclk-planner-editor-border};
|
||||
--fclk-planner-editor-control: #{$_fclk-planner-editor-control};
|
||||
--fclk-planner-editor-control-text: #{$_fclk-planner-editor-control-text};
|
||||
--fclk-planner-editor-chip: #{$_fclk-planner-editor-chip};
|
||||
--fclk-planner-editor-chip-hover: #{$_fclk-planner-editor-chip-hover};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
:root {
|
||||
--fclk-planner-page: #171a1f;
|
||||
--fclk-planner-panel: #20242b;
|
||||
--fclk-planner-card: #262b33;
|
||||
--fclk-planner-text: #f3f4f6;
|
||||
--fclk-planner-muted: #a3aab8;
|
||||
--fclk-planner-border: #3b424c;
|
||||
--fclk-planner-border-strong: #647082;
|
||||
--fclk-planner-day: #21465f;
|
||||
--fclk-planner-subhead: #394b2d;
|
||||
--fclk-planner-hours: #6f4f22;
|
||||
--fclk-planner-fallback: #393326;
|
||||
--fclk-planner-row-hover: #2b313a;
|
||||
--fclk-planner-error: #f87171;
|
||||
--fclk-planner-focus: #60a5fa;
|
||||
--fclk-planner-shadow: rgba(0, 0, 0, 0.32);
|
||||
--fclk-planner-editor: #0f172a;
|
||||
--fclk-planner-editor-text: #f9fafb;
|
||||
--fclk-planner-editor-muted: #cbd5e1;
|
||||
--fclk-planner-editor-border: #475569;
|
||||
--fclk-planner-editor-control: #1f2937;
|
||||
--fclk-planner-editor-control-text: #f9fafb;
|
||||
--fclk-planner-editor-chip: #1e293b;
|
||||
--fclk-planner-editor-chip-hover: #334155;
|
||||
}
|
||||
447
fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
Normal file
447
fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
Normal file
@@ -0,0 +1,447 @@
|
||||
.fclk-planner {
|
||||
min-height: 100%;
|
||||
background: var(--fclk-planner-page, #f3f4f6);
|
||||
color: var(--fclk-planner-text, #1f2937);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fclk-planner__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
border-bottom: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||
box-shadow: 0 1px 3px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
|
||||
}
|
||||
|
||||
.fclk-planner__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fclk-planner__subtitle {
|
||||
color: var(--fclk-planner-muted, #6b7280);
|
||||
font-size: 13px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.fclk-planner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fclk-planner__warning {
|
||||
margin: 12px 16px 0;
|
||||
padding: 10px 12px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 6px;
|
||||
color: #9a3412;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fclk-planner__loading {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 340px;
|
||||
color: var(--fclk-planner-muted, #6b7280);
|
||||
}
|
||||
|
||||
.fclk-planner__table-wrap {
|
||||
flex: 1;
|
||||
margin: 16px;
|
||||
overflow: auto;
|
||||
background: var(--fclk-planner-panel, #eef1f4);
|
||||
border: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 20px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
|
||||
}
|
||||
|
||||
.fclk-planner__table {
|
||||
--fclk-planner-shift-width: 135px;
|
||||
--fclk-planner-hours-width: 55px;
|
||||
--fclk-planner-days-width: 1330px;
|
||||
width: 100%;
|
||||
min-width: 1600px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fclk-planner__employee-col {
|
||||
width: calc(100% - var(--fclk-planner-days-width));
|
||||
}
|
||||
|
||||
.fclk-planner__shift-col {
|
||||
width: var(--fclk-planner-shift-width);
|
||||
}
|
||||
|
||||
.fclk-planner__hours-col {
|
||||
width: var(--fclk-planner-hours-width);
|
||||
}
|
||||
|
||||
.fclk-planner__table th,
|
||||
.fclk-planner__table td {
|
||||
border-right: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||
border-bottom: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-head,
|
||||
.fclk-planner__day-head,
|
||||
.fclk-planner__sub-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 6;
|
||||
color: var(--fclk-planner-text, #1f2937);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-head {
|
||||
left: 0;
|
||||
z-index: 8;
|
||||
width: calc(100% - var(--fclk-planner-days-width));
|
||||
background: var(--fclk-planner-day, #b7dff5);
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||
}
|
||||
|
||||
.fclk-planner__day-head {
|
||||
background: var(--fclk-planner-day, #b7dff5);
|
||||
text-align: center;
|
||||
padding: 6px 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fclk-planner__sub-head {
|
||||
top: 47px;
|
||||
background: var(--fclk-planner-subhead, #d8e9bd);
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.fclk-planner__hours-head {
|
||||
width: var(--fclk-planner-hours-width);
|
||||
text-align: center;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.fclk-planner__weekday {
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.fclk-planner__date {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.fclk-planner__department-row td {
|
||||
background: var(--fclk-planner-panel, #eef1f4);
|
||||
padding: 0;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.fclk-planner__department-toggle {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fclk-planner-text, #1f2937);
|
||||
font-weight: 650;
|
||||
padding: 7px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fclk-planner__department-count {
|
||||
color: var(--fclk-planner-muted, #6b7280);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fclk-planner__employee-row {
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-row:hover {
|
||||
background: var(--fclk-planner-row-hover, #f9fafb);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-cell {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
width: calc(100% - var(--fclk-planner-days-width));
|
||||
background: inherit;
|
||||
padding: 8px 12px;
|
||||
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||
}
|
||||
|
||||
.fclk-planner__employee-name {
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fclk-planner__employee-role {
|
||||
margin-top: 2px;
|
||||
color: var(--fclk-planner-muted, #6b7280);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell {
|
||||
width: var(--fclk-planner-shift-width);
|
||||
min-height: 42px;
|
||||
padding: 4px;
|
||||
vertical-align: top;
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell--fallback {
|
||||
background: var(--fclk-planner-fallback, #fff8e5);
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell--error {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.fclk-planner__shift-cell--active {
|
||||
box-shadow: inset 0 0 0 2px var(--fclk-planner-focus, #2563eb);
|
||||
}
|
||||
|
||||
.fclk-planner__shift-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--fclk-planner-text, #1f2937);
|
||||
padding: 4px 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fclk-planner__shift-input:focus {
|
||||
background: var(--fclk-planner-card, #ffffff);
|
||||
border-color: var(--fclk-planner-focus, #2563eb);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.fclk-planner__cell-error {
|
||||
color: var(--fclk-planner-error, #dc2626);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
padding: 3px 5px 0;
|
||||
}
|
||||
|
||||
.fclk-planner__hours-cell {
|
||||
width: var(--fclk-planner-hours-width);
|
||||
background: var(--fclk-planner-hours, #f5d39b);
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 650;
|
||||
vertical-align: middle;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.fclk-planner__cell-editor {
|
||||
position: fixed;
|
||||
z-index: 1080;
|
||||
width: calc(100vw - 16px);
|
||||
max-width: 380px;
|
||||
padding: 14px;
|
||||
color: var(--fclk-planner-editor-text, #f9fafb);
|
||||
background: var(--fclk-planner-editor, #111827);
|
||||
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.fclk-planner__cell-editor::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 28px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--fclk-planner-editor, #111827);
|
||||
border-left: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
border-top: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.fclk-planner__editor-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-day {
|
||||
margin-top: 2px;
|
||||
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-hours {
|
||||
min-width: 56px;
|
||||
padding: 5px 8px;
|
||||
text-align: center;
|
||||
color: #111827;
|
||||
background: var(--fclk-planner-hours, #f5d39b);
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-chip {
|
||||
min-height: 46px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 7px 9px;
|
||||
color: var(--fclk-planner-editor-text, #f9fafb);
|
||||
background: var(--fclk-planner-editor-chip, #1f2937);
|
||||
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-chip:hover,
|
||||
.fclk-planner__quick-chip:focus {
|
||||
background: var(--fclk-planner-editor-chip-hover, #334155);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-label {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.fclk-planner__quick-detail {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.fclk-planner__time-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.fclk-planner__time-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.fclk-planner__time-field select {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
color: var(--fclk-planner-editor-control-text, #111827);
|
||||
background: var(--fclk-planner-editor-control, #ffffff);
|
||||
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-error {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 10px;
|
||||
padding: 7px 8px;
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.fclk-planner__editor-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fclk-planner__toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fclk-planner__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fclk-planner__table-wrap {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.fclk-planner__cell-editor {
|
||||
width: calc(100vw - 16px);
|
||||
}
|
||||
}
|
||||
198
fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
Normal file
198
fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
Normal file
@@ -0,0 +1,198 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_clock.ShiftPlanner">
|
||||
<div class="o_action fclk-planner" t-ref="root">
|
||||
<div class="fclk-planner__toolbar">
|
||||
<div>
|
||||
<h2 class="fclk-planner__title">Shift Planner</h2>
|
||||
<div class="fclk-planner__subtitle"><t t-esc="weekTitle"/></div>
|
||||
</div>
|
||||
<div class="fclk-planner__actions">
|
||||
<button class="btn btn-light" t-on-click="() => this.previousWeek()" t-att-disabled="state.loading or state.saving">
|
||||
<i class="fa fa-chevron-left"/>
|
||||
</button>
|
||||
<button class="btn btn-light" t-on-click="() => this.currentWeek()" t-att-disabled="state.loading or state.saving">This Week</button>
|
||||
<button class="btn btn-light" t-on-click="() => this.nextWeek()" t-att-disabled="state.loading or state.saving">
|
||||
<i class="fa fa-chevron-right"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" t-on-click="() => this.copyPreviousWeek()" t-att-disabled="state.loading or state.saving">
|
||||
<i class="fa fa-copy me-1"/> Copy Previous Week
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" t-on-click="() => this.exportXlsx()" t-att-disabled="state.loading or state.saving">
|
||||
<i class="fa fa-file-excel-o me-1"/> Export XLSX
|
||||
</button>
|
||||
<button class="btn btn-primary" t-on-click="() => this.save()" t-att-disabled="state.loading or state.saving or !state.dirtyCount">
|
||||
<t t-if="state.saving"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||
<t t-else=""><i class="fa fa-save me-1"/></t>
|
||||
Save
|
||||
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.error">
|
||||
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
|
||||
</t>
|
||||
|
||||
<t t-if="state.invalidCount">
|
||||
<div class="fclk-planner__warning">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<t t-esc="state.invalidCount"/> invalid cells need attention.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="fclk-planner__loading">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<span>Loading shift planner...</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="!state.loading and !state.error">
|
||||
<div class="fclk-planner__table-wrap">
|
||||
<table class="fclk-planner__table">
|
||||
<colgroup>
|
||||
<col class="fclk-planner__employee-col"/>
|
||||
<t t-foreach="state.days" t-as="day" t-key="'col_' + day.date">
|
||||
<col class="fclk-planner__shift-col"/>
|
||||
<col class="fclk-planner__hours-col"/>
|
||||
</t>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="fclk-planner__employee-head" rowspan="2">Employee</th>
|
||||
<t t-foreach="state.days" t-as="day" t-key="day.date">
|
||||
<th class="fclk-planner__day-head" colspan="2">
|
||||
<div class="fclk-planner__weekday"><t t-esc="day.weekday"/></div>
|
||||
<div class="fclk-planner__date"><t t-esc="day.label"/></div>
|
||||
</th>
|
||||
</t>
|
||||
</tr>
|
||||
<tr>
|
||||
<t t-foreach="state.days" t-as="day" t-key="'sub_' + day.date">
|
||||
<th class="fclk-planner__sub-head">Shift</th>
|
||||
<th class="fclk-planner__sub-head fclk-planner__hours-head">Hours</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.departments" t-as="department" t-key="department.id">
|
||||
<tr class="fclk-planner__department-row">
|
||||
<td t-att-colspan="1 + state.days.length * 2">
|
||||
<button class="fclk-planner__department-toggle" t-on-click="() => this.toggleDepartment(department)">
|
||||
<i t-att-class="isCollapsed(department) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
|
||||
<span><t t-esc="department.name"/></span>
|
||||
<span class="fclk-planner__department-count">
|
||||
<t t-esc="department.employee_ids.length"/> employees
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<t t-if="!isCollapsed(department)">
|
||||
<t t-foreach="getDepartmentEmployees(department)" t-as="employee" t-key="employee.id">
|
||||
<tr class="fclk-planner__employee-row">
|
||||
<td class="fclk-planner__employee-cell">
|
||||
<div class="fclk-planner__employee-name"><t t-esc="employee.name"/></div>
|
||||
<div class="fclk-planner__employee-role" t-if="employee.job_title">
|
||||
<t t-esc="employee.job_title"/>
|
||||
</div>
|
||||
</td>
|
||||
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
|
||||
<t t-set="cell" t-value="employee.cells[day.date]"/>
|
||||
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
|
||||
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
|
||||
<input class="fclk-planner__shift-input"
|
||||
t-att-value="cell.input"
|
||||
t-att-title="cell.error || cell.label"
|
||||
t-on-focus="(ev) => this.openCellEditor(employee, day, ev)"
|
||||
t-on-change="(ev) => this.onCellInput(employee, day, ev)"
|
||||
t-on-keydown="(ev) => this.onCellKeydown(employee, day, ev)"/>
|
||||
<div class="fclk-planner__cell-error" t-if="cell.error">
|
||||
<t t-esc="cell.error"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="fclk-planner__hours-cell">
|
||||
<t t-esc="cell.hours_display || '0:00'"/>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div t-if="state.editor.open"
|
||||
t-ref="shiftEditor"
|
||||
class="fclk-planner__cell-editor"
|
||||
t-att-style="'top: ' + state.editor.top + 'px; left: ' + state.editor.left + 'px;'">
|
||||
<div class="fclk-planner__editor-head">
|
||||
<div class="fclk-planner__editor-person">
|
||||
<div class="fclk-planner__editor-name"><t t-esc="state.editor.employeeName"/></div>
|
||||
<div class="fclk-planner__editor-day"><t t-esc="state.editor.dayLabel"/></div>
|
||||
</div>
|
||||
<div class="fclk-planner__editor-hours">
|
||||
<span><t t-esc="state.editor.hoursDisplay"/></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__quick-grid">
|
||||
<t t-foreach="quickShiftOptions" t-as="option" t-key="option.key">
|
||||
<button type="button"
|
||||
class="fclk-planner__quick-chip"
|
||||
t-on-click="() => this.selectQuickShift(option)">
|
||||
<span class="fclk-planner__quick-label"><t t-esc="option.label"/></span>
|
||||
<span class="fclk-planner__quick-detail"><t t-esc="option.detail"/></span>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__time-row">
|
||||
<label class="fclk-planner__time-field">
|
||||
<span>Start</span>
|
||||
<select t-on-change="(ev) => this.onEditorStartChange(ev)">
|
||||
<t t-foreach="timeOptions" t-as="option" t-key="'start_' + option.value">
|
||||
<option t-att-value="option.value"
|
||||
t-att-selected="option.value === state.editor.startValue">
|
||||
<t t-esc="option.label"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fclk-planner__time-field">
|
||||
<span>End</span>
|
||||
<select t-on-change="(ev) => this.onEditorEndChange(ev)">
|
||||
<t t-foreach="timeOptions" t-as="option" t-key="'end_' + option.value">
|
||||
<option t-att-value="option.value"
|
||||
t-att-selected="option.value === state.editor.endValue">
|
||||
<t t-esc="option.label"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__editor-error" t-if="state.editor.error">
|
||||
<t t-esc="state.editor.error"/>
|
||||
</div>
|
||||
|
||||
<div class="fclk-planner__editor-actions">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-light"
|
||||
t-on-click="() => this.clearActiveCell()">
|
||||
<i class="fa fa-eraser me-1"/> Clear
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
t-on-click="() => this.applyEditorRange(true)">
|
||||
<i class="fa fa-check me-1"/> Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
from . import test_nfc_models
|
||||
from . import test_clock_nfc_kiosk
|
||||
from . import test_shift_planner
|
||||
|
||||
254
fusion_clock/tests/test_shift_planner.py
Normal file
254
fusion_clock/tests/test_shift_planner.py
Normal file
@@ -0,0 +1,254 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
from odoo import fields
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests.common import HttpCase, TransactionCase, tagged
|
||||
from odoo.tools.misc import mute_logger
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestShiftPlannerModels(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Schedule = cls.env['fusion.clock.schedule'].sudo()
|
||||
cls.Shift = cls.env['fusion.clock.shift'].sudo()
|
||||
cls.employee = cls.env['hr.employee'].sudo().create({
|
||||
'name': 'Planner Model Employee',
|
||||
'company_id': cls.env.company.id,
|
||||
'x_fclk_enable_clock': True,
|
||||
})
|
||||
cls.default_shift = cls.Shift.create({
|
||||
'name': 'Default Planner Shift',
|
||||
'start_time': 8.0,
|
||||
'end_time': 16.5,
|
||||
'break_minutes': 30,
|
||||
'company_id': cls.env.company.id,
|
||||
})
|
||||
cls.employee.x_fclk_shift_id = cls.default_shift.id
|
||||
cls.schedule_date = date(2026, 1, 5)
|
||||
|
||||
def test_unique_employee_date_schedule(self):
|
||||
self.Schedule.create({
|
||||
'employee_id': self.employee.id,
|
||||
'schedule_date': self.schedule_date,
|
||||
'is_off': True,
|
||||
})
|
||||
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||
with self.env.cr.savepoint():
|
||||
self.Schedule.create({
|
||||
'employee_id': self.employee.id,
|
||||
'schedule_date': self.schedule_date,
|
||||
'is_off': True,
|
||||
})
|
||||
|
||||
def test_off_schedule_has_zero_hours(self):
|
||||
schedule = self.Schedule.create({
|
||||
'employee_id': self.employee.id,
|
||||
'schedule_date': date(2026, 1, 6),
|
||||
'is_off': True,
|
||||
})
|
||||
self.assertEqual(schedule.planned_hours, 0)
|
||||
self.assertEqual(schedule.fclk_display_value(), 'OFF')
|
||||
|
||||
def test_working_schedule_computes_hours_minus_break(self):
|
||||
schedule = self.Schedule.create({
|
||||
'employee_id': self.employee.id,
|
||||
'schedule_date': date(2026, 1, 7),
|
||||
'start_time': 9.0,
|
||||
'end_time': 17.5,
|
||||
'break_minutes': 30,
|
||||
})
|
||||
self.assertEqual(schedule.planned_hours, 8.0)
|
||||
self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00')
|
||||
|
||||
def test_invalid_same_day_range_is_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Schedule.create({
|
||||
'employee_id': self.employee.id,
|
||||
'schedule_date': date(2026, 1, 8),
|
||||
'start_time': 17.0,
|
||||
'end_time': 9.0,
|
||||
'break_minutes': 30,
|
||||
})
|
||||
|
||||
def test_apply_planner_cell_creates_audit(self):
|
||||
schedule_date = date(2026, 1, 9)
|
||||
self.Schedule.fclk_apply_planner_cell(
|
||||
self.employee,
|
||||
schedule_date,
|
||||
{'input': '9:00 am - 5:30 pm'},
|
||||
self.env.user,
|
||||
)
|
||||
audit = self.env['fusion.clock.schedule.audit'].sudo().search([
|
||||
('employee_id', '=', self.employee.id),
|
||||
('schedule_date', '=', schedule_date),
|
||||
], limit=1)
|
||||
self.assertTrue(audit)
|
||||
self.assertFalse(audit.old_value)
|
||||
self.assertEqual(audit.new_value, '9:00 am - 5:30 pm')
|
||||
|
||||
def test_dated_schedule_overrides_employee_shift_and_fallback_remains(self):
|
||||
planned_date = date(2026, 1, 12)
|
||||
self.Schedule.create({
|
||||
'employee_id': self.employee.id,
|
||||
'schedule_date': planned_date,
|
||||
'start_time': 10.0,
|
||||
'end_time': 18.0,
|
||||
'break_minutes': 60,
|
||||
})
|
||||
|
||||
planned = self.employee._get_fclk_day_plan(planned_date)
|
||||
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
|
||||
|
||||
self.assertEqual(planned['source'], 'schedule')
|
||||
self.assertEqual(planned['start_time'], 10.0)
|
||||
self.assertEqual(planned['hours'], 7.0)
|
||||
self.assertEqual(fallback['source'], 'fallback')
|
||||
self.assertEqual(fallback['start_time'], 8.0)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestShiftPlannerApi(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
manager_group = cls.env.ref('fusion_clock.group_fusion_clock_manager')
|
||||
user_group = cls.env.ref('fusion_clock.group_fusion_clock_user')
|
||||
cls.manager_user = cls.env['res.users'].sudo().create({
|
||||
'name': 'Planner Manager',
|
||||
'login': 'planner-manager',
|
||||
'password': 'plannerpass',
|
||||
'company_id': cls.env.company.id,
|
||||
'company_ids': [(6, 0, [cls.env.company.id])],
|
||||
'group_ids': [(6, 0, [manager_group.id])],
|
||||
})
|
||||
cls.employee_user = cls.env['res.users'].sudo().create({
|
||||
'name': 'Planner Employee User',
|
||||
'login': 'planner-employee-user',
|
||||
'password': 'plannerpass',
|
||||
'company_id': cls.env.company.id,
|
||||
'company_ids': [(6, 0, [cls.env.company.id])],
|
||||
'group_ids': [(6, 0, [user_group.id])],
|
||||
'tz': 'UTC',
|
||||
})
|
||||
cls.employee = cls.env['hr.employee'].sudo().create({
|
||||
'name': 'Planner API Employee',
|
||||
'user_id': cls.employee_user.id,
|
||||
'company_id': cls.env.company.id,
|
||||
'x_fclk_enable_clock': True,
|
||||
})
|
||||
cls.shift = cls.env['fusion.clock.shift'].sudo().create({
|
||||
'name': 'API Morning',
|
||||
'start_time': 7.0,
|
||||
'end_time': 15.5,
|
||||
'break_minutes': 30,
|
||||
'company_id': cls.env.company.id,
|
||||
})
|
||||
cls.week_start = '2026-01-19'
|
||||
|
||||
def _json_call(self, route, payload, login='planner-manager'):
|
||||
self.authenticate(login, 'plannerpass')
|
||||
response = self.url_open(
|
||||
route,
|
||||
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_manager_can_load_save_and_export_planner(self):
|
||||
load_result = self._json_call('/fusion_clock/shift_planner/load', {
|
||||
'week_start': self.week_start,
|
||||
})
|
||||
self.assertIn(self.employee.id, [row['id'] for row in load_result['employees']])
|
||||
|
||||
save_result = self._json_call('/fusion_clock/shift_planner/save', {
|
||||
'week_start': self.week_start,
|
||||
'changes': [{
|
||||
'employee_id': self.employee.id,
|
||||
'date': self.week_start,
|
||||
'input': '9-5',
|
||||
'shift_id': False,
|
||||
}],
|
||||
})
|
||||
self.assertTrue(save_result.get('success'))
|
||||
|
||||
schedule = self.env['fusion.clock.schedule'].sudo().search([
|
||||
('employee_id', '=', self.employee.id),
|
||||
('schedule_date', '=', fields.Date.to_date(self.week_start)),
|
||||
], limit=1)
|
||||
self.assertTrue(schedule)
|
||||
self.assertEqual(schedule.start_time, 9.0)
|
||||
self.assertEqual(schedule.end_time, 17.0)
|
||||
|
||||
export_result = self._json_call('/fusion_clock/shift_planner/export_xlsx', {
|
||||
'week_start': self.week_start,
|
||||
})
|
||||
self.assertTrue(export_result.get('success'))
|
||||
self.assertTrue(export_result.get('url', '').startswith('/web/content/'))
|
||||
self.assertTrue(self.env['ir.attachment'].sudo().browse(export_result['attachment_id']).exists())
|
||||
|
||||
def test_copy_previous_week(self):
|
||||
previous_monday = fields.Date.to_date(self.week_start) - timedelta(days=7)
|
||||
self.env['fusion.clock.schedule'].sudo().create({
|
||||
'employee_id': self.employee.id,
|
||||
'schedule_date': previous_monday,
|
||||
'shift_id': self.shift.id,
|
||||
'start_time': self.shift.start_time,
|
||||
'end_time': self.shift.end_time,
|
||||
'break_minutes': self.shift.break_minutes,
|
||||
})
|
||||
result = self._json_call('/fusion_clock/shift_planner/copy_previous_week', {
|
||||
'week_start': self.week_start,
|
||||
})
|
||||
self.assertTrue(result.get('success'))
|
||||
copied = self.env['fusion.clock.schedule'].sudo().search([
|
||||
('employee_id', '=', self.employee.id),
|
||||
('schedule_date', '=', fields.Date.to_date(self.week_start)),
|
||||
], limit=1)
|
||||
self.assertEqual(copied.shift_id, self.shift)
|
||||
|
||||
def test_non_manager_cannot_mutate_planner(self):
|
||||
result = self._json_call('/fusion_clock/shift_planner/save', {
|
||||
'week_start': self.week_start,
|
||||
'changes': [],
|
||||
}, login='planner-employee-user')
|
||||
self.assertEqual(result.get('error'), 'Access denied.')
|
||||
|
||||
def test_off_day_clock_in_succeeds_and_logs_unscheduled_shift(self):
|
||||
today = fields.Date.today()
|
||||
location = self.env['fusion.clock.location'].sudo().create({
|
||||
'name': 'Planner Test Location',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
'company_id': self.env.company.id,
|
||||
'all_employees': True,
|
||||
})
|
||||
self.env['fusion.clock.schedule'].sudo().create({
|
||||
'employee_id': self.employee.id,
|
||||
'schedule_date': today,
|
||||
'is_off': True,
|
||||
})
|
||||
|
||||
result = self._json_call('/fusion_clock/clock_action', {
|
||||
'latitude': location.latitude,
|
||||
'longitude': location.longitude,
|
||||
'source': 'portal',
|
||||
}, login='planner-employee-user')
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('action'), 'clock_in')
|
||||
self.assertIn('unscheduled', result.get('message', ''))
|
||||
log = self.env['fusion.clock.activity.log'].sudo().search([
|
||||
('employee_id', '=', self.employee.id),
|
||||
('log_type', '=', 'unscheduled_shift'),
|
||||
], limit=1)
|
||||
self.assertTrue(log)
|
||||
@@ -16,6 +16,34 @@
|
||||
sequence="5"
|
||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||
|
||||
<!-- Scheduling -->
|
||||
<menuitem id="menu_fusion_clock_scheduling"
|
||||
name="Scheduling"
|
||||
parent="menu_fusion_clock_root"
|
||||
sequence="8"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_shift_planner"
|
||||
name="Shift Planner"
|
||||
parent="menu_fusion_clock_scheduling"
|
||||
action="action_fusion_clock_shift_planner"
|
||||
sequence="5"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_scheduled_shifts"
|
||||
name="Scheduled Shifts"
|
||||
parent="menu_fusion_clock_scheduling"
|
||||
action="action_fusion_clock_schedule"
|
||||
sequence="10"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_schedule_audit"
|
||||
name="Schedule Audit"
|
||||
parent="menu_fusion_clock_scheduling"
|
||||
action="action_fusion_clock_schedule_audit"
|
||||
sequence="20"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Attendance Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_attendance"
|
||||
name="Attendance"
|
||||
|
||||
128
fusion_clock/views/clock_schedule_views.xml
Normal file
128
fusion_clock/views/clock_schedule_views.xml
Normal file
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_fusion_clock_shift_planner" model="ir.actions.client">
|
||||
<field name="name">Shift Planner</field>
|
||||
<field name="tag">fusion_clock.ShiftPlanner</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_schedule_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.schedule.list</field>
|
||||
<field name="model">fusion.clock.schedule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="schedule_date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id"/>
|
||||
<field name="is_off"/>
|
||||
<field name="shift_id"/>
|
||||
<field name="start_time" widget="float_time"/>
|
||||
<field name="end_time" widget="float_time"/>
|
||||
<field name="break_minutes"/>
|
||||
<field name="planned_hours"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_schedule_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.schedule.form</field>
|
||||
<field name="model">fusion.clock.schedule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id"/>
|
||||
<field name="schedule_date"/>
|
||||
<field name="is_off"/>
|
||||
<field name="shift_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="start_time" widget="float_time"/>
|
||||
<field name="end_time" widget="float_time"/>
|
||||
<field name="break_minutes"/>
|
||||
<field name="planned_hours" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="note"/>
|
||||
<field name="department_id" readonly="1"/>
|
||||
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_schedule_search" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.schedule.search</field>
|
||||
<field name="model">fusion.clock.schedule</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id"/>
|
||||
<field name="schedule_date"/>
|
||||
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
|
||||
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
|
||||
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
|
||||
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_schedule" model="ir.actions.act_window">
|
||||
<field name="name">Scheduled Shifts</field>
|
||||
<field name="res_model">fusion.clock.schedule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_schedule_audit_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.schedule.audit.list</field>
|
||||
<field name="model">fusion.clock.schedule.audit</field>
|
||||
<field name="arch" type="xml">
|
||||
<list create="0" edit="0" delete="0">
|
||||
<field name="changed_at"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="schedule_date"/>
|
||||
<field name="old_value"/>
|
||||
<field name="new_value"/>
|
||||
<field name="changed_by_id"/>
|
||||
<field name="department_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_schedule_audit_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.schedule.audit.form</field>
|
||||
<field name="model">fusion.clock.schedule.audit</field>
|
||||
<field name="arch" type="xml">
|
||||
<form create="0" edit="0" delete="0">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="changed_at"/>
|
||||
<field name="changed_by_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="schedule_date"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="old_value"/>
|
||||
<field name="new_value"/>
|
||||
<field name="department_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_schedule_audit" model="ir.actions.act_window">
|
||||
<field name="name">Schedule Audit</field>
|
||||
<field name="res_model">fusion.clock.schedule.audit</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -142,6 +142,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Shift -->
|
||||
<div class="fclk-schedule-card">
|
||||
<div class="fclk-schedule-icon">
|
||||
<i class="fa fa-calendar-check-o"/>
|
||||
</div>
|
||||
<div class="fclk-schedule-info">
|
||||
<div class="fclk-schedule-label">Today's Shift</div>
|
||||
<div class="fclk-schedule-value">
|
||||
<t t-if="today_schedule.get('is_off')">OFF</t>
|
||||
<t t-else="">
|
||||
<t t-esc="today_schedule.get('label') or 'Not scheduled'"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-schedule-hours">
|
||||
<t t-if="today_schedule.get('is_off')">0:00</t>
|
||||
<t t-else="">
|
||||
<t t-esc="'%.1f' % (today_schedule.get('hours') or 0.0)"/>h
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer Section -->
|
||||
<div class="fclk-timer-section">
|
||||
<div class="fclk-timer-label" id="fclk-timer-label">
|
||||
|
||||
BIN
fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Helpdesk Reporter',
|
||||
'version': '19.0.1.3.0',
|
||||
'version': '19.0.1.4.0',
|
||||
'category': 'Productivity',
|
||||
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
||||
@@ -27,6 +27,7 @@ module bundle. No dependencies on the rest of Fusion Plating.
|
||||
'license': 'OPL-1',
|
||||
'depends': ['base', 'web', 'mail'],
|
||||
'data': [
|
||||
'security/fusion_helpdesk_groups.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
|
||||
@@ -22,6 +22,15 @@ from urllib.parse import urljoin, urlparse
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
from odoo.tools import email_normalize
|
||||
|
||||
from odoo.addons.fusion_helpdesk.utils import (
|
||||
build_ticket_vals,
|
||||
build_scope_domain,
|
||||
is_public_message,
|
||||
compute_unread_count,
|
||||
escape_like,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import res_config_settings
|
||||
from . import fusion_helpdesk_ticket_seen
|
||||
|
||||
66
fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py
Normal file
66
fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Per-user read-tracking for the embedded ticket inbox.
|
||||
|
||||
Stores ONLY metadata — which central ticket a user has seen and up to
|
||||
which message id. No ticket content is replicated locally; this exists
|
||||
purely so the systray unread badge can work without re-fetching the
|
||||
whole inbox on every page load. Tickets themselves remain a live RPC
|
||||
view of the central Odoo.
|
||||
"""
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionHelpdeskTicketSeen(models.Model):
|
||||
_name = 'fusion.helpdesk.ticket.seen'
|
||||
_description = 'Fusion Helpdesk — per-user read tracking (metadata only)'
|
||||
|
||||
user_id = fields.Many2one(
|
||||
'res.users', required=True, index=True, ondelete='cascade',
|
||||
default=lambda self: self.env.uid,
|
||||
)
|
||||
central_ticket_id = fields.Integer(
|
||||
string='Central Ticket ID', required=True, index=True,
|
||||
help='helpdesk.ticket id on the central Odoo.',
|
||||
)
|
||||
last_seen_message_id = fields.Integer(
|
||||
string='Last Seen Message ID', default=0,
|
||||
help='Highest central mail.message id this user has viewed for '
|
||||
'the ticket. Drives the unread badge.',
|
||||
)
|
||||
|
||||
_user_ticket_uniq = models.Constraint(
|
||||
'UNIQUE(user_id, central_ticket_id)',
|
||||
'One seen-row per user per ticket.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _mark_seen(self, central_ticket_id, last_message_id):
|
||||
"""Upsert the current user's last-seen marker for a ticket.
|
||||
|
||||
Monotonic — never moves the marker backwards (a stale client
|
||||
reporting an older id can't resurrect an unread badge)."""
|
||||
rec = self.search([
|
||||
('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', '=', central_ticket_id),
|
||||
], limit=1)
|
||||
if rec:
|
||||
if (last_message_id or 0) > rec.last_seen_message_id:
|
||||
rec.last_seen_message_id = last_message_id
|
||||
else:
|
||||
self.create({
|
||||
'central_ticket_id': central_ticket_id,
|
||||
'last_seen_message_id': last_message_id or 0,
|
||||
})
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _seen_map(self, central_ticket_ids):
|
||||
"""Return {central_ticket_id: last_seen_message_id} for the
|
||||
current user across the given ticket ids."""
|
||||
rows = self.search([
|
||||
('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', 'in', list(central_ticket_ids)),
|
||||
])
|
||||
return {r.central_ticket_id: r.last_seen_message_id for r in rows}
|
||||
18
fusion_helpdesk/security/fusion_helpdesk_groups.xml
Normal file
18
fusion_helpdesk/security/fusion_helpdesk_groups.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1
|
||||
-->
|
||||
<odoo>
|
||||
<!--
|
||||
Deployment-level admin for the embedded ticket inbox. Members see
|
||||
ALL tickets filed from this deployment (scoped by x_fc_client_label)
|
||||
in the "My Tickets" tab; non-members see only their own. The gate is
|
||||
enforced server-side in the controller via has_group().
|
||||
Odoo 19: res.groups has NO `users`/`category_id` fields — keep minimal.
|
||||
-->
|
||||
<record id="group_reporter_admin" model="res.groups">
|
||||
<field name="name">Helpdesk Reporter Admin</field>
|
||||
<field name="comment">Can view all tickets filed from this deployment in the in-app helpdesk inbox.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -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;
|
||||
|
||||
@@ -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() }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
3
fusion_helpdesk/tests/__init__.py
Normal file
3
fusion_helpdesk/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_utils
|
||||
from . import test_seen
|
||||
27
fusion_helpdesk/tests/test_seen.py
Normal file
27
fusion_helpdesk/tests/test_seen.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Tests for fusion.helpdesk.ticket.seen read-tracking."""
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestSeen(TransactionCase):
|
||||
|
||||
def test_mark_seen_upserts_and_is_monotonic(self):
|
||||
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||
Seen._mark_seen(central_ticket_id=42, last_message_id=100)
|
||||
Seen._mark_seen(central_ticket_id=42, last_message_id=120)
|
||||
Seen._mark_seen(central_ticket_id=42, last_message_id=90) # stale, ignored
|
||||
rec = Seen.search([
|
||||
('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', '=', 42),
|
||||
])
|
||||
self.assertEqual(len(rec), 1, "should upsert, not duplicate")
|
||||
self.assertEqual(rec.last_seen_message_id, 120, "monotonic — never moves back")
|
||||
|
||||
def test_seen_map(self):
|
||||
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||
Seen._mark_seen(1, 10)
|
||||
Seen._mark_seen(2, 20)
|
||||
self.assertEqual(Seen._seen_map([1, 2, 3]), {1: 10, 2: 20})
|
||||
105
fusion_helpdesk/tests/test_utils.py
Normal file
105
fusion_helpdesk/tests/test_utils.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Unit tests for the pure helpers in fusion_helpdesk.utils.
|
||||
|
||||
These need no live central Odoo — they pin the identity keystone, the
|
||||
scoping security boundary, the public-message filter and the unread
|
||||
maths as plain data transformations.
|
||||
"""
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
from odoo.addons.fusion_helpdesk.utils import (
|
||||
build_ticket_vals,
|
||||
build_scope_domain,
|
||||
is_public_message,
|
||||
compute_unread_count,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestBuildTicketVals(TransactionCase):
|
||||
|
||||
def test_identity_fields_present(self):
|
||||
vals = build_ticket_vals(
|
||||
kind='bug', subject='X', body_html='<p>b</p>',
|
||||
team_id=1, client_label='ENTECH',
|
||||
reporter_name='John Doe', reporter_email='john@entech.com',
|
||||
company_name='ENTECH Inc',
|
||||
)
|
||||
self.assertEqual(vals['partner_email'], 'john@entech.com')
|
||||
self.assertEqual(vals['partner_name'], 'John Doe')
|
||||
self.assertEqual(vals['x_fc_client_label'], 'ENTECH')
|
||||
self.assertEqual(vals['partner_company_name'], 'ENTECH Inc')
|
||||
self.assertEqual(vals['team_id'], 1)
|
||||
self.assertIn('X', vals['name'])
|
||||
self.assertIn('[ENTECH]', vals['name'])
|
||||
|
||||
def test_no_email_omits_partner_email(self):
|
||||
vals = build_ticket_vals(
|
||||
kind='feature', subject='Y', body_html='<p>b</p>',
|
||||
team_id=False, client_label='', reporter_name='Jane',
|
||||
reporter_email='', company_name='',
|
||||
)
|
||||
self.assertNotIn('partner_email', vals) # never send an empty email
|
||||
self.assertNotIn('team_id', vals) # omit falsy team
|
||||
self.assertNotIn('x_fc_client_label', vals) # omit empty label
|
||||
self.assertEqual(vals['partner_name'], 'Jane')
|
||||
self.assertIn('Feature Request', vals['name'])
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestScopeDomain(TransactionCase):
|
||||
|
||||
def test_regular_scope_binds_email_and_label(self):
|
||||
dom = build_scope_domain(label='ENTECH', email='john@entech.com', is_admin=False)
|
||||
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||
self.assertIn(('partner_email', '=ilike', 'john@entech.com'), dom)
|
||||
|
||||
def test_admin_scope_binds_label_only(self):
|
||||
dom = build_scope_domain(label='ENTECH', email='a@entech.com', is_admin=True)
|
||||
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||
self.assertFalse(any(t[0] == 'partner_email' for t in dom))
|
||||
|
||||
def test_empty_label_never_matches_everything(self):
|
||||
dom = build_scope_domain(label='', email='', is_admin=True)
|
||||
# label term must be present and must NOT be an empty string
|
||||
label_terms = [t for t in dom if t[0] == 'x_fc_client_label']
|
||||
self.assertEqual(len(label_terms), 1)
|
||||
self.assertNotEqual(label_terms[0][2], '')
|
||||
|
||||
def test_wildcard_email_cannot_widen_scope(self):
|
||||
# IDOR guard: a self-set email of '%' must NOT become a match-all
|
||||
# =ilike term — the wildcard has to be escaped to a literal.
|
||||
dom = build_scope_domain(label='ENTECH', email='%', is_admin=False)
|
||||
email_terms = [t for t in dom if t[0] == 'partner_email']
|
||||
self.assertEqual(len(email_terms), 1)
|
||||
self.assertEqual(email_terms[0][2], '\\%',
|
||||
"'%' must be escaped so ILIKE matches it literally")
|
||||
|
||||
def test_underscore_in_real_email_is_escaped_but_preserved(self):
|
||||
dom = build_scope_domain(label='ENTECH', email='john_doe@x.com', is_admin=False)
|
||||
email_terms = [t for t in dom if t[0] == 'partner_email']
|
||||
self.assertEqual(email_terms[0][2], 'john\\_doe@x.com')
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestMessageFilterAndUnread(TransactionCase):
|
||||
|
||||
def test_internal_note_is_not_public(self):
|
||||
self.assertFalse(is_public_message({'subtype_is_internal': True}))
|
||||
self.assertTrue(is_public_message({'subtype_is_internal': False}))
|
||||
self.assertTrue(is_public_message({})) # default visible
|
||||
|
||||
def test_unread_count(self):
|
||||
tickets = [
|
||||
{'id': 1, 'last_support_msg_id': 10}, # seen 10 -> read
|
||||
{'id': 2, 'last_support_msg_id': 5}, # seen 3 -> unread
|
||||
{'id': 3, 'last_support_msg_id': 0}, # no support msg
|
||||
]
|
||||
seen = {1: 10, 2: 3}
|
||||
self.assertEqual(compute_unread_count(tickets, seen), 1)
|
||||
|
||||
def test_unread_count_unseen_ticket_counts(self):
|
||||
tickets = [{'id': 9, 'last_support_msg_id': 4}]
|
||||
self.assertEqual(compute_unread_count(tickets, {}), 1)
|
||||
98
fusion_helpdesk/utils.py
Normal file
98
fusion_helpdesk/utils.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Pure helpers for fusion_helpdesk.
|
||||
|
||||
No Odoo environment, no `request` — just data in, data out. Everything
|
||||
here is unit-testable in isolation, which is what lets us validate the
|
||||
identity keystone, the server-side scoping boundary, the public-message
|
||||
filter and the unread maths without a live central Odoo to talk to.
|
||||
"""
|
||||
|
||||
# Sentinel used so a missing label/email can never widen a domain to
|
||||
# "match everything". An empty string in `=`/`=ilike` would match rows
|
||||
# whose field is also empty; '__none__' will simply match nothing.
|
||||
_NO_MATCH = '__none__'
|
||||
|
||||
|
||||
def escape_like(value):
|
||||
"""Escape SQL LIKE/ILIKE wildcards so a user-supplied value can never
|
||||
widen an `=ilike` match to other rows.
|
||||
|
||||
`res.users.email` is self-writeable and unvalidated, so without this a
|
||||
user could set their email to ``%`` and have ``partner_email =ilike '%'``
|
||||
match EVERY ticket in their deployment (a cross-user IDOR). Escaping the
|
||||
backslash first, then ``%`` and ``_``, makes those characters match
|
||||
literally. Real emails containing ``_`` (e.g. ``john_doe@x.com``) keep
|
||||
working — the underscore is matched as a literal, which is what we want.
|
||||
"""
|
||||
return (value or '').replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||
|
||||
|
||||
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
||||
reporter_name, reporter_email, company_name):
|
||||
"""Construct the `helpdesk.ticket` create vals for a forwarded report.
|
||||
|
||||
The identity fields (`partner_email`, `partner_name`,
|
||||
`partner_company_name`) drive native helpdesk find-or-create of the
|
||||
customer partner + follower subscription on the central Odoo, and
|
||||
`x_fc_client_label` tags the deployment for the scoped inbox.
|
||||
"""
|
||||
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
||||
prefix = ('[%s] ' % client_label) if client_label else ''
|
||||
vals = {
|
||||
'name': '%s%s: %s' % (prefix, kind_label, subject or '(untitled)'),
|
||||
'description': body_html,
|
||||
'partner_name': reporter_name or '',
|
||||
}
|
||||
if team_id:
|
||||
vals['team_id'] = team_id
|
||||
if reporter_email:
|
||||
vals['partner_email'] = reporter_email
|
||||
if company_name:
|
||||
vals['partner_company_name'] = company_name
|
||||
if client_label:
|
||||
vals['x_fc_client_label'] = client_label
|
||||
return vals
|
||||
|
||||
|
||||
def build_scope_domain(label, email, is_admin):
|
||||
"""Server-side ticket scope for the embedded inbox.
|
||||
|
||||
`x_fc_client_label` is ALWAYS bound (defense in depth) so neither a
|
||||
regular user nor a deployment admin can ever read another
|
||||
deployment's tickets — even though the shared bot can technically see
|
||||
every ticket on the central Odoo. Regular users are additionally
|
||||
bound to their own `partner_email`.
|
||||
"""
|
||||
domain = [('x_fc_client_label', '=', label or _NO_MATCH)]
|
||||
if not is_admin:
|
||||
safe_email = escape_like(email)
|
||||
domain.append(('partner_email', '=ilike', safe_email or _NO_MATCH))
|
||||
return domain
|
||||
|
||||
|
||||
def is_public_message(msg):
|
||||
"""True when a message is customer-visible (not an internal note).
|
||||
|
||||
`msg` is a plain dict carrying a `subtype_is_internal` flag resolved
|
||||
from the central `mail.message.subtype`. Internal notes must never be
|
||||
shown to a client in the embedded inbox.
|
||||
"""
|
||||
return not msg.get('subtype_is_internal', False)
|
||||
|
||||
|
||||
def compute_unread_count(tickets, seen_by_id):
|
||||
"""Number of tickets with a support reply the user hasn't seen.
|
||||
|
||||
`tickets` is a list of dicts each carrying `id` and
|
||||
`last_support_msg_id` (id of the latest customer-visible support
|
||||
message, 0 if none). `seen_by_id` maps central ticket id -> last
|
||||
message id the user has seen (absent => 0 baseline).
|
||||
"""
|
||||
count = 0
|
||||
for ticket in tickets:
|
||||
last = ticket.get('last_support_msg_id') or 0
|
||||
if last and last > (seen_by_id.get(ticket['id']) or 0):
|
||||
count += 1
|
||||
return count
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1
|
||||
{
|
||||
'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,
|
||||
|
||||
55
fusion_helpdesk_central/data/mail_template_ack.xml
Normal file
55
fusion_helpdesk_central/data/mail_template_ack.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1
|
||||
|
||||
Branded acknowledgement sent to the customer when an in-app-channel
|
||||
ticket is created. Carries the portal magic link (object.get_portal_url()
|
||||
embeds the access token) so the customer can track + reply without an
|
||||
account. Button colours follow the company email branding, like Odoo's
|
||||
own helpdesk templates.
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mail_template_ticket_ack" model="mail.template">
|
||||
<field name="name">Helpdesk: Ticket Acknowledgement (Fusion)</field>
|
||||
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
|
||||
<field name="subject">We received your request [{{ object.ticket_ref or object.id }}]</field>
|
||||
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin:0; padding:0; font-family:Arial, Helvetica, sans-serif; color:#21252b; font-size:14px;">
|
||||
<p>Hello <t t-out="object.partner_name or 'there'"/>,</p>
|
||||
<p>
|
||||
Thanks for reaching out — we've received your request and our
|
||||
support team will be in touch. Here are the details:
|
||||
</p>
|
||||
<table style="margin:12px 0; font-size:14px;">
|
||||
<tr>
|
||||
<td style="padding:2px 12px 2px 0; color:#6c757d;">Reference</td>
|
||||
<td style="padding:2px 0;"><strong t-out="object.ticket_ref or object.id"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:2px 12px 2px 0; color:#6c757d;">Subject</td>
|
||||
<td style="padding:2px 0;"><t t-out="object.name or ''"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:18px 0;">
|
||||
<a t-att-href="object.get_base_url() + object.get_portal_url()"
|
||||
target="_blank"
|
||||
t-attf-style="background-color: {{ object.company_id.email_secondary_color or '#2c89e9' }}; padding:10px 18px; text-decoration:none; color: {{ object.company_id.email_primary_color or '#ffffff' }}; border-radius:5px; font-size:14px; display:inline-block;">
|
||||
View & track your ticket
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#6c757d; font-size:13px;">
|
||||
You can reply directly to this email to add information, follow up
|
||||
from the link above, or sign up for an account from that page to
|
||||
manage all of your requests in one place.
|
||||
</p>
|
||||
<p style="color:#6c757d; font-size:13px;">— <t t-out="object.company_id.name or 'Support'"/></p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fusion_helpdesk_client_key
|
||||
from . import helpdesk_ticket
|
||||
|
||||
57
fusion_helpdesk_central/models/helpdesk_ticket.py
Normal file
57
fusion_helpdesk_central/models/helpdesk_ticket.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Central-side helpdesk.ticket extensions for the customer follow-up flow.
|
||||
|
||||
Adds the `x_fc_client_label` deployment tag (set by the in-app reporter so
|
||||
the embedded inbox can scope per client) and sends a branded acknowledgement
|
||||
email — carrying the portal magic link — when an in-app ticket is created.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HelpdeskTicket(models.Model):
|
||||
_inherit = 'helpdesk.ticket'
|
||||
|
||||
x_fc_client_label = fields.Char(
|
||||
string='Client Deployment', index=True, copy=False,
|
||||
help='Deployment tag (e.g. ENTECH) set by the fusion_helpdesk in-app '
|
||||
'reporter. Scopes the embedded "My Tickets" inbox per client and '
|
||||
'lets support filter tickets by originating deployment.',
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
tickets = super().create(vals_list)
|
||||
tickets._fc_send_ack_email()
|
||||
return tickets
|
||||
|
||||
def _fc_send_ack_email(self):
|
||||
"""Send the branded acknowledgement (with magic link) to the customer.
|
||||
|
||||
Only fires for in-app-channel tickets (those tagged with a client
|
||||
label) that have a customer email — external web-form submissions
|
||||
rely on the native website confirmation, so this won't double-send.
|
||||
The whole thing is best-effort: a template/mail failure must never
|
||||
block ticket creation, so we log and move on.
|
||||
"""
|
||||
template = self.env.ref(
|
||||
'fusion_helpdesk_central.mail_template_ticket_ack',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not template:
|
||||
return
|
||||
for ticket in self:
|
||||
if not (ticket.x_fc_client_label and ticket.partner_email):
|
||||
continue
|
||||
try:
|
||||
template.send_mail(ticket.id, force_send=False)
|
||||
except Exception: # noqa: BLE001 — ack must never block create
|
||||
_logger.exception(
|
||||
'fusion_helpdesk_central: acknowledgement email failed '
|
||||
'for ticket %s (%s)', ticket.id, ticket.x_fc_client_label,
|
||||
)
|
||||
2
fusion_helpdesk_central/tests/__init__.py
Normal file
2
fusion_helpdesk_central/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_identity
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user