Compare commits
174 Commits
fef99809e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1441773e0 | ||
|
|
47956f3244 | ||
|
|
4cd5bac3a1 | ||
|
|
6a60a55cd8 | ||
|
|
eddec0bb6e | ||
|
|
4830613701 | ||
|
|
14cd6a666b | ||
|
|
a66cdefc01 | ||
|
|
2a7b315e98 | ||
|
|
44bd03a96a | ||
|
|
108c76d347 | ||
|
|
3b33e80ee9 | ||
|
|
04abb1520a | ||
|
|
a6186120b2 | ||
|
|
73a59cad0b | ||
|
|
88e1e5e9bb | ||
|
|
8c76a16366 | ||
|
|
dcaa7dc1fe | ||
|
|
19d484680d | ||
|
|
23da01fcc1 | ||
|
|
53c292083f | ||
|
|
1630a2025f | ||
|
|
498963e83a | ||
|
|
0cb30f256d | ||
|
|
2ad94070c7 | ||
|
|
80d06ff77f | ||
|
|
68aaa132ee | ||
|
|
d35d5f4b34 | ||
|
|
3376a32143 | ||
|
|
734b3b94fd | ||
|
|
b4ca85e291 | ||
|
|
53fe13344d | ||
|
|
c9eb61ee0c | ||
|
|
023fc95acd | ||
|
|
9574fa0ae4 | ||
|
|
423f288507 | ||
|
|
a86f20017d | ||
|
|
7426501555 | ||
|
|
3e787a1b24 | ||
|
|
6f006e24ad | ||
|
|
ba6aeaaca9 | ||
|
|
27577dd51a | ||
|
|
a10b7425f7 | ||
|
|
dcd4955bb7 | ||
|
|
a2277b481c | ||
|
|
197030a188 | ||
|
|
c97a0d985c | ||
|
|
e6bbf566ca | ||
|
|
86e9fdead8 | ||
|
|
c80ffa1b2c | ||
|
|
97880765b5 | ||
|
|
587988bb06 | ||
|
|
a209648ed9 | ||
|
|
ea6b3fe2e9 | ||
|
|
b23eaa5695 | ||
|
|
489312365e | ||
|
|
6728197570 | ||
|
|
eea4dad048 | ||
|
|
63694eccb1 | ||
|
|
252716156c | ||
|
|
dfa266d691 | ||
|
|
7b8364eb58 | ||
|
|
4e5e9f4c91 | ||
|
|
f84c22c743 | ||
|
|
46d19fd581 | ||
|
|
56ca82c611 | ||
|
|
d457b86eaa | ||
|
|
92e8a18fcb | ||
|
|
245e551c68 | ||
|
|
a022eaaabe | ||
|
|
0e6bb7b676 | ||
|
|
d5d410f6d0 | ||
|
|
41141a75e8 | ||
|
|
d512dfccf0 | ||
|
|
5e9576ed8f | ||
|
|
80d9a960e7 | ||
|
|
3fe5d5c17c | ||
|
|
190b394001 | ||
|
|
b5a300f439 | ||
|
|
f0400114f9 | ||
|
|
25ef7832f5 | ||
|
|
600e11fabb | ||
|
|
5e3e6b5319 | ||
|
|
774d21863e | ||
|
|
2f8b6b3ae0 | ||
|
|
837198fc8a | ||
|
|
5a3c660322 | ||
|
|
235c8fba39 | ||
|
|
b52b8758a1 | ||
|
|
910ccd0fc6 | ||
|
|
2b0add3a2e | ||
|
|
f00a039fc2 | ||
|
|
5646c97f67 | ||
|
|
fec72a70c1 | ||
|
|
d531faad12 | ||
|
|
951cad0f81 | ||
|
|
acd1fc9f8f | ||
|
|
5424c785d9 | ||
|
|
ae256b4480 | ||
|
|
696f5da662 | ||
|
|
fc3fd513a9 | ||
|
|
a19a299c7f | ||
|
|
78fa8f07ee | ||
|
|
71f4c41d5c | ||
|
|
2f6a8b33a9 | ||
|
|
4b832e7445 | ||
|
|
f67cefc213 | ||
|
|
658611457e | ||
|
|
4df35448c2 | ||
|
|
1d6797f0d2 | ||
|
|
4622521729 | ||
|
|
40a29081bf | ||
|
|
11ab261ad9 | ||
|
|
2285b7b814 | ||
|
|
00f7e90a3d | ||
|
|
859a327738 | ||
|
|
a52f2bbebd | ||
|
|
9a8e1d7ab5 | ||
|
|
837e7b09b7 | ||
|
|
ed91135a3f | ||
|
|
451fc5eafd | ||
|
|
7fcf38ca82 | ||
|
|
64a202ff6e | ||
|
|
13fabb0e79 | ||
|
|
20de9a6b69 | ||
|
|
21cfd55419 | ||
|
|
89467432a7 | ||
|
|
319de06ca6 | ||
|
|
e0ddd9ef40 | ||
|
|
903ceb10d0 | ||
|
|
0499a1ad2e | ||
|
|
b17bd615bf | ||
|
|
4f48bab6e9 | ||
|
|
e36aaab306 | ||
|
|
b616375679 | ||
|
|
37efc5b858 | ||
|
|
5c4a26b65f | ||
|
|
b59ad6b21e | ||
|
|
8a1a09b150 | ||
|
|
a092c385ea | ||
|
|
ca44461b6f | ||
|
|
249adf8145 | ||
|
|
cc568b0ec8 | ||
|
|
17d21bffb5 | ||
|
|
aafc2db8a8 | ||
|
|
6c3830fd4c | ||
|
|
12d383a8c2 | ||
|
|
139e917e09 | ||
|
|
de3e0df5fc | ||
|
|
747c814249 | ||
|
|
c527c7cade | ||
|
|
f7ec1e28f9 | ||
|
|
96b3f124f8 | ||
|
|
2c32e7bcd0 | ||
|
|
aa9b95bd5d | ||
|
|
493f01827e | ||
|
|
2ab59bccde | ||
|
|
914c96a09a | ||
|
|
b015958edc | ||
|
|
ca94a4c42a | ||
|
|
a5ec79013a | ||
|
|
b61e159e6f | ||
|
|
13a892c7ab | ||
|
|
2ee01fd1f2 | ||
|
|
d6d6bbe161 | ||
|
|
31098c4d14 | ||
|
|
1a1ab2da4f | ||
|
|
3f78f652e7 | ||
|
|
e230e42d81 | ||
|
|
06346cfa6b | ||
|
|
a858693d9c | ||
|
|
68b10e1199 | ||
|
|
e260f030d1 | ||
|
|
8d6fcb75a7 |
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:docs.clover.com)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [],
|
||||
"Stop": [],
|
||||
"Notification": []
|
||||
}
|
||||
}
|
||||
53
.gitignore
vendored
53
.gitignore
vendored
@@ -15,3 +15,56 @@ __pycache__/
|
||||
|
||||
# Local-only diagnostic logs from test runs
|
||||
_test_*.log
|
||||
|
||||
# --- Split-out module repos (now independent git repos; managed separately) ---
|
||||
/disable_iap_calls/
|
||||
/disable_odoo_online/
|
||||
/disable_publisher_warranty/
|
||||
/fusion_accounts/
|
||||
/fusion_api/
|
||||
/fusion_canada_post/
|
||||
/fusion_centralize_billing/
|
||||
/fusion_chatter_enhance/
|
||||
/fusion_claims/
|
||||
/fusion_clock/
|
||||
/fusion_clock_ai/
|
||||
/fusion_clover/
|
||||
/fusion_digitize/
|
||||
/fusion_faxes/
|
||||
/fusion_helpdesk/
|
||||
/fusion_helpdesk_central/
|
||||
/fusion_inventory/
|
||||
/fusion_loaners_management/
|
||||
/fusion_login_audit/
|
||||
/fusion_ltc_management/
|
||||
/fusion_notes/
|
||||
/fusion_odoo_fixes/
|
||||
/fusion_payroll/
|
||||
/fusion_pdf_preview/
|
||||
/fusion_planning/
|
||||
/fusion_portal/
|
||||
/fusion_poynt/
|
||||
/fusion_rental/
|
||||
/fusion_repairs/
|
||||
/fusion_reports_templates/
|
||||
/fusion_ringcentral/
|
||||
/fusion_schedule/
|
||||
/fusion_service_charges/
|
||||
/fusion_shipping/
|
||||
/fusion_so_to_po/
|
||||
/fusion_tasks/
|
||||
/fusion_templates/
|
||||
/fusion_theme_switcher/
|
||||
/fusion_voip_ringcentral/
|
||||
/fusion_whitelabels/
|
||||
/network_logger/
|
||||
/nexa_coa_setup/
|
||||
/fusion_plating/
|
||||
/fusion_accounting/
|
||||
/fusion_iot/
|
||||
/fusion_labels/
|
||||
/fusion_projects/
|
||||
/fusion-statements/
|
||||
/fusion-woo-odoo/
|
||||
/fusion-expenses/
|
||||
/fusion_configurator/
|
||||
|
||||
6
.graphifyignore
Normal file
6
.graphifyignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# graphify: skip vendored / minified third-party assets — not first-party code
|
||||
**/static/lib/
|
||||
**/static/src/lib/
|
||||
**/static/**/*.min.js
|
||||
*.min.js
|
||||
*.min.css
|
||||
64
CLAUDE.md
64
CLAUDE.md
@@ -33,6 +33,10 @@
|
||||
|
||||
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%'`.
|
||||
|
||||
16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u <newname>`; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... import`.
|
||||
|
||||
17. **`url_encode` (and werkzeug url helpers) are NOT available in the Odoo 19 `mail.template` QWeb render context.** Using `url_encode({...})` inside a template `body_html` (e.g. to build a fallback link) makes the template fail Odoo's save-time render validation **at install**, surfacing as the opaque `ParseError: ... Oops! We couldn't save your template due to an issue with this value: <the entire body html>` (the real `NameError` is hidden, and `--log-handler odoo.tools.convert:DEBUG` does NOT reveal it). Build URLs with plain string methods instead: `'https://…?q=' + (value or '').replace(' ', '+')`. Found installing `fusion_repairs` (post-visit NPS template). **That same opaque "issue with this value" error wraps ANY render failure in a mail.template body** — when you see it, suspect an undefined name / bad field reference in the template, not malformed XML.
|
||||
|
||||
## 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:
|
||||
```css
|
||||
@@ -94,7 +98,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
|
||||
## Module-Specific Notes
|
||||
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
|
||||
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 1–11 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_authorizer_portal`.
|
||||
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.3.0`** (Plan-1 maintenance foundation added 2026-06-02). **NOT Community-installable** — it transitively pulls in Enterprise `ai` + `knowledge` (`fusion_repairs → fusion_portal → fusion_claims → ai`; `fusion_portal → knowledge`), so it can NOT be installed or tested on local `odoo-modsdev` (Community) — the old `-d fusion-dev -u fusion_repairs` recipe does NOT work. **Test on Enterprise:** an isolated `westin-fr-test` DB on the `odoo-westin` host (clone of prod `westin-v19`; a fresh-DB clone install also needs a one-time orphaned-FK cleanup because prod has orphaned account/tax m2m rows). First-ever clean install surfaced + fixed 2 bugs (url_encode → rule 17; menu parent defined after its children) in commit `903ceb10`. **Not production-deployed** to Westin yet. **Test-runner gotchas on that prod-config container:** `--test-enable` SILENTLY SKIPS all tests without `--workers 0`; the conf's `log_level=warn` hides test output (add `--log-level=test`); the post_install phase also trips on a pre-existing module, so verify behaviour via `odoo shell` rather than the test runner. `mail_template_data.xml` is `noupdate=1` → template edits load on a FRESH install (the prod deploy) but NOT on `-u` of an already-installed DB. Outstanding: maintenance booking (Plan 2), visit log (Plan 3), backfill wizard (Plan 4), office follow-up crons (Plan 5), RingCentral SMS.
|
||||
- **fusion_portal** (formerly `fusion_authorizer_portal`) — authorizer/sales-rep portal; **ENTERPRISE-only** (depends `knowledge` → cannot run on local Community; verify on a westin clone, see *Westin Prod* below). **Assessment-visit flow LIVE on westin, v19.0.2.10.1.** A `fusion.assessment.visit` bundles the assessments from one home visit and, on completion (`action_complete_visit`), groups them by funding workflow (`x_fc_sale_type`) into ONE draft sale order per workflow (MoD/ADP/ODSP/WSIB/private/hardship/insurance) — never one combined SO, never one-per-item-within-a-funding. ADP devices group into one order (combination guard: ≤1 seated {wheelchair/powerchair/scooter} + ≤1 walker); accessibility items group per funding. Reps enter via the "Start a Visit" dashboard tile → `/my/visit/new`; the express/accessibility forms carry `?visit_id=` and defer SO creation to the visit. Renaming the technical name needs a DB rename — see [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql).
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
@@ -136,6 +141,25 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
|
||||
- `fusionapps.code_snippets` — reference code
|
||||
- `fusionapps.quick_commands` — deployment and admin commands
|
||||
|
||||
## Westin Prod — Deploy & Clone-Verify (fusion_portal et al.)
|
||||
|
||||
Westin prod: host `odoo-westin`, app container `odoo-dev-app`, db container `odoo-dev-db`, DB `westin-v19` (user `odoo`, pw `DevSecure2025!`), addons `/opt/odoo/custom-addons` → `/mnt/extra-addons`, Enterprise `/mnt/enterprise-addons`, conf `/etc/odoo/odoo.conf`. ENTERPRISE env — modules depending on `knowledge` (fusion_portal → fusion_claims) cannot run on local Community, so verify on a clone before prod.
|
||||
|
||||
**Clone-verify a change (prod-safe, isolated — prod files + live DB untouched):**
|
||||
1. Clone online: `docker exec -e PGPASSWORD='DevSecure2025!' odoo-dev-db sh -c 'dropdb -U odoo --if-exists westin-v19-visittest; createdb -U odoo -O odoo westin-v19-visittest && pg_dump -U odoo westin-v19 | psql -U odoo -q -d westin-v19-visittest'` (~2 min, ~152M -Fc).
|
||||
2. Stage the branch module into an isolated dir INSIDE the addons path: `/opt/odoo/custom-addons/_test/<module>`, then `-u <module> --stop-after-init --no-http --db_host db --db_port 5432 --db_user odoo --db_password 'DevSecure2025!' --addons-path=/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,/mnt/extra-addons/_test,/mnt/enterprise-addons,/mnt/extra-addons`. The `/mnt/extra-addons/_test` prefix SHADOWS prod's copy (first matching path wins); deps load from the real `/mnt/extra-addons`.
|
||||
3. Smoke-test via `odoo shell -d westin-v19-visittest` (same addons-path); `env.cr.rollback()` at the end. To exercise email paths WITHOUT sending: `UPDATE ir_mail_server SET active=false;` AND in the shell `env['ir.mail_server'].__class__.send_email = lambda self, message, *a, **k: 'noop'` (`odoo shell` rejects `--smtp-server`).
|
||||
|
||||
**THE ORPHANED-TAX-FK TRAP** (cost real diagnosis time): westin-v19 has ~3300 orphaned rows in `product_taxes_rel` + ~3300 in `product_supplier_taxes_rel` (`tax_id` → deleted `account_tax`), under FKs that are `convalidated=true` (taxes deleted via an FK-bypassing path; PG never re-checks a validated constraint). A plain `pg_dump | psql` clone can't recreate a *validating* FK over orphaned data → the FK is lost on the clone → Odoo `check_foreign_keys` tries to add it → `ForeignKeyViolation: Key (tax_id)=(N) is not present in account_tax` → "Failed to load registry". **Fix ON THE CLONE only — and the trap is NO LONGER tax-only (2026-06-12: 13 FKs across 9 tables — company/journal/tax/fiscal-position/payslip orphans from past force-deletions).** Diff the FKs and clean exactly what's missing:
|
||||
```bash
|
||||
# on each DB: SELECT conrelid::regclass||'|'||conname FROM pg_constraint WHERE contype='f'
|
||||
# sort both lists, comm -23 prod clone -> every FK that failed to restore
|
||||
# per missing FK: DELETE rows whose column is NOT IN the referenced table's ids
|
||||
# (exception: mail_message.record_company_id is SET-NULL semantics -> UPDATE ... SET NULL)
|
||||
``` **Prod `-u` is SAFE without touching the orphans** — prod's FK already exists, so Odoo skips it (it never re-validates a present FK); proven empirically by replicating FK-present+orphan on a clone and running `-u` (exit 0, orphan untouched). Owner is auditing the orphans — do NOT delete them on prod without sign-off.
|
||||
|
||||
**Deploy:** backup (`docker exec ... pg_dump -Fc -U odoo westin-v19 > /opt/odoo/backups/<name>.dump` + `cp -r` the module dir to `/opt/odoo/backups/` — OUTSIDE the addons path, never a `*.bak` dir inside it) → `scp` branch to `/opt/odoo/staging/<module>` → swap into `/opt/odoo/custom-addons/<module>` → `-u <module>` → `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` → `docker restart odoo-dev-app`. **Gate the restart on `-u` exit 0**; on failure restore the dir backup and do NOT restart. When a feature branch predates main's other merges, merge to `main` **surgically** (temp worktree off `origin/main` + `git checkout <branch> -- <module>` → commit → fast-forward push) so you don't revert parallel sessions' work.
|
||||
|
||||
## Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)
|
||||
|
||||
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
|
||||
@@ -230,3 +254,41 @@ catches undefined names instantly.
|
||||
open the systray helpdesk dialog. The Mine/All toggle appears for the owner; "All" shows
|
||||
all 50 ENTECH tickets, "Mine" shows the count matching the owner's profile email.
|
||||
Tracebacks live in `/var/log/odoo/odoo-server.log` on entech (LXC 111 / pve-worker5).
|
||||
|
||||
## Fusion Centralized Billing (`fusion_centralize_billing`) — engine + test harness
|
||||
|
||||
Odoo (`odoo-nexa`, live DB `nexamain`) is being made the single billing brain for every
|
||||
NexaSystems app (NexaCloud, NexaDesk/Fusion-Chat, NexaMaps), **superseding Lago**. The
|
||||
module adds only the metering + integration layer (service registry, identity links,
|
||||
metric/charge catalog, aggregate-push usage engine, inbound Lago-shaped REST API at
|
||||
`/api/billing/v1/*`, outbound HMAC webhooks, dual-run reconciliation); all financial
|
||||
behaviour is native Odoo **Enterprise** (`sale_subscription` + `payment_stripe` +
|
||||
`account_accountant`). Design + rollout live in `docs/superpowers/specs/`
|
||||
(`2026-05-27-nexa-billing-centralized-design.md` = architecture;
|
||||
`2026-06-02-nexacloud-odoo-billing-cutover-design.md` = NexaCloud pilot: build → import →
|
||||
dual-run → gated flip) and `docs/superpowers/plans/`.
|
||||
|
||||
**Testing it — NOT on local `odoo-modsdev` (community) and NEVER `-u` against live `nexamain`.**
|
||||
It needs Enterprise deps, so tests run on `odoo-nexa` in an **isolated throwaway container**
|
||||
against a **fresh** DB with the Canadian localization:
|
||||
```
|
||||
ssh odoo-nexa
|
||||
# fresh DB (inside odoo-nexa-db): dropdb --if-exists fcb_test; createdb fcb_test
|
||||
cp -a /opt/odoo/custom-addons /opt/odoo/custom-addons-staging # edit/sync HERE, never the live module dir
|
||||
docker run --rm --network odoo_odoo-network \
|
||||
-v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \
|
||||
-v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro -v /opt/odoo/staging-data:/var/lib/odoo \
|
||||
odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test --db_host=db --db_user=odoo \
|
||||
--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \
|
||||
--without-demo=all --test-enable --test-tags /fusion_centralize_billing \
|
||||
-i l10n_ca,fusion_centralize_billing --stop-after-init --no-http
|
||||
```
|
||||
Iterate with `-u fusion_centralize_billing` (reuse fcb_test). Gotchas that cost hours:
|
||||
- **`l10n_ca` is required** — the ledger tests need a Canadian CoA + active CAD + 13% HST.
|
||||
- A **prod clone is the wrong base** — its existing rows collide with fixed-code test fixtures
|
||||
(`nexacloud` service / `cpu_seconds` metric) across 5 test files.
|
||||
- odoo.conf sets `log_level=warn`, so **passing tests log nothing** — exit 0 alone does NOT
|
||||
prove tests ran (a tag matching zero tests is also exit 0). Confirm execution with
|
||||
`--log-handler=odoo.addons.fusion_centralize_billing.tests:INFO` (look for `Starting
|
||||
<Class>.<method>`). The **exit code is authoritative** (1 on any failure).
|
||||
- Do **NOT** pass `--workers=0` (blanks captured stdout) or `--logfile=/dev/stdout` (errors out).
|
||||
|
||||
58
SYNC.md
Normal file
58
SYNC.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Syncing Odoo-Modules across machines (Mac + Windows)
|
||||
|
||||
Each module/suite folder here is its **own git repo** (private on GitHub at
|
||||
`gsinghpal/<name>`, mirrored to gitea `admin/<name>`). This parent folder is a
|
||||
separate repo that holds the shared files (CLAUDE.md, docs, scripts, these sync
|
||||
helpers). The cloud (GitHub) is the hub: both machines push to it and pull from it.
|
||||
|
||||
Nothing here ever deletes your work. Pulls are fast-forward only, so local changes
|
||||
are never overwritten; pushes only send commits.
|
||||
|
||||
## First-time setup on a new machine (e.g. the Windows PC)
|
||||
|
||||
1. Install **Git** (Git for Windows includes "Git Bash", which runs these scripts).
|
||||
2. Sign in to GitHub once so git can push/pull:
|
||||
- easiest: `gh auth login` (or let Git Credential Manager prompt on first pull)
|
||||
3. Get everything:
|
||||
```
|
||||
git clone https://github.com/gsinghpal/Odoo-Modules.git
|
||||
cd Odoo-Modules
|
||||
bash sync-clone-all.sh
|
||||
```
|
||||
That clones the parent, then all 49 module repos into place.
|
||||
|
||||
(gitea is an optional second mirror. The first push to it will ask for your
|
||||
`git.nexasystems.ca` login. If you only use GitHub, those gitea lines just fail
|
||||
quietly and GitHub stays the source of truth.)
|
||||
|
||||
## Daily workflow (same on Mac and Windows)
|
||||
|
||||
- **Before you start:** `bash sync-pull-all.sh` - pulls the latest for the parent
|
||||
and every module. Anything with local changes or a diverged history is skipped and
|
||||
listed, so you can handle it yourself.
|
||||
- **Do your work**, then **commit inside the module(s) you changed**:
|
||||
```
|
||||
cd fusion_clock
|
||||
git add -A
|
||||
git commit -m "..."
|
||||
cd ..
|
||||
```
|
||||
- **When done:** `bash sync-push-all.sh` - pushes every committed change to GitHub
|
||||
+ gitea, and flags any repo that still has uncommitted changes (so nothing is
|
||||
silently left behind).
|
||||
|
||||
## Golden rule for two machines
|
||||
|
||||
Push from the machine you worked on **before** you switch to the other one, and run
|
||||
`sync-pull-all.sh` on the other machine **before** you start. That keeps both in sync
|
||||
and avoids diverged histories.
|
||||
|
||||
## Helper scripts
|
||||
|
||||
| Script | What it does |
|
||||
|--------|--------------|
|
||||
| `sync-clone-all.sh` | Clone any module repo listed in `repos.txt` that isn't here yet. |
|
||||
| `sync-pull-all.sh` | Fast-forward pull the parent + all modules (safe, never clobbers). |
|
||||
| `sync-push-all.sh` | Push committed work for the parent + all modules to GitHub + gitea. |
|
||||
| `sync-refresh-list.sh` | Rebuild `repos.txt` from the repos present here (after adding/removing a module). |
|
||||
| `repos.txt` | The list of module repo names the scripts act on. |
|
||||
@@ -28,7 +28,7 @@
|
||||
'website',
|
||||
'mail',
|
||||
'fusion_claims',
|
||||
'fusion_authorizer_portal',
|
||||
'fusion_portal',
|
||||
],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Disable IAP Calls',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Disables all IAP (In-App Purchase) external API calls',
|
||||
'description': """
|
||||
This module completely disables:
|
||||
- IAP service calls to Odoo servers
|
||||
- OCR/Extract API calls
|
||||
- Lead enrichment API calls
|
||||
- Any other external Odoo API communication
|
||||
|
||||
For local development use only.
|
||||
""",
|
||||
'author': 'Development',
|
||||
'depends': ['iap'],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Disable IAP Calls',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Disables all IAP (In-App Purchase) external API calls',
|
||||
'description': """
|
||||
This module completely disables:
|
||||
- IAP service calls to Odoo servers
|
||||
- OCR/Extract API calls
|
||||
- Lead enrichment API calls
|
||||
- Any other external Odoo API communication
|
||||
|
||||
For local development use only.
|
||||
""",
|
||||
'author': 'Development',
|
||||
'depends': ['iap'],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import iap_account
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Disable all IAP external API calls for local development
|
||||
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IapAccountDisabled(models.Model):
|
||||
_inherit = 'iap.account'
|
||||
|
||||
@api.model
|
||||
def get_credits(self, service_name):
|
||||
"""
|
||||
DISABLED: Return fake unlimited credits
|
||||
"""
|
||||
_logger.info("IAP get_credits DISABLED - returning unlimited credits for %s", service_name)
|
||||
return 999999
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/disable_iap_calls (2026-04-22)
|
||||
|
||||
## Corpus Check
|
||||
- 8 files · ~284 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 11 nodes · 8 edges · 8 communities detected
|
||||
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Community 0|Community 0]]
|
||||
- [[_COMMUNITY_Community 1|Community 1]]
|
||||
- [[_COMMUNITY_Community 2|Community 2]]
|
||||
- [[_COMMUNITY_Community 3|Community 3]]
|
||||
- [[_COMMUNITY_Community 4|Community 4]]
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `IapAccountDisabled` - 2 edges
|
||||
2. `get_credits()` - 2 edges
|
||||
3. `DISABLED: Return fake unlimited credits` - 0 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- None detected - all connections are within the same source files.
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): get_credits(), IapAccountDisabled
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Return fake unlimited credits
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
## Knowledge Gaps
|
||||
- **1 isolated node(s):** `DISABLED: Return fake unlimited credits`
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 1`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 2`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 3`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 4`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 5`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 6`** (1 nodes): `DISABLED: Return fake unlimited credits`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 7`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **What connects `DISABLED: Return fake unlimited credits` to the rest of the system?**
|
||||
_1 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/__init__.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_init_py", "target": "users_gurpreet_github_odoo_modules_disable_iap_calls_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/__init__.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/__init__.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_init_py", "target": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/__init__.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py", "label": "iap_account.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L1"}, {"id": "iap_account_iapaccountdisabled", "label": "IapAccountDisabled", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L10"}, {"id": "iap_account_get_credits", "label": "get_credits()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L14"}, {"id": "iap_account_rationale_15", "label": "DISABLED: Return fake unlimited credits", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L15"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py", "target": "iap_account_iapaccountdisabled", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L10", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py", "target": "iap_account_get_credits", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L14", "weight": 1.0}, {"source": "iap_account_rationale_15", "target": "iap_account_iapaccountdisabled_get_credits", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L15", "weight": 1.0}], "raw_calls": [{"caller_nid": "iap_account_get_credits", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py", "source_location": "L18"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py", "label": "iap_account.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L1"}, {"id": "iap_account_iapaccountdisabled", "label": "IapAccountDisabled", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L10"}, {"id": "iap_account_get_credits", "label": "get_credits()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L14"}, {"id": "iap_account_rationale_15", "label": "DISABLED: Return fake unlimited credits", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L15"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py", "target": "iap_account_iapaccountdisabled", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L10", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py", "target": "iap_account_get_credits", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L14", "weight": 1.0}, {"source": "iap_account_rationale_15", "target": "iap_account_iapaccountdisabled_get_credits", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L15", "weight": 1.0}], "raw_calls": [{"caller_nid": "iap_account_get_credits", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py", "source_location": "L18"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,205 +0,0 @@
|
||||
{
|
||||
"directed": false,
|
||||
"multigraph": false,
|
||||
"graph": {},
|
||||
"nodes": [
|
||||
{
|
||||
"label": "__init__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/__init__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_init_py",
|
||||
"community": 1,
|
||||
"norm_label": "__init__.py"
|
||||
},
|
||||
{
|
||||
"label": "__manifest__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/__manifest__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_manifest_py",
|
||||
"community": 5,
|
||||
"norm_label": "__manifest__.py"
|
||||
},
|
||||
{
|
||||
"label": "__init__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/__init__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_init_py",
|
||||
"community": 2,
|
||||
"norm_label": "__init__.py"
|
||||
},
|
||||
{
|
||||
"label": "iap_account.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py",
|
||||
"community": 0,
|
||||
"norm_label": "iap_account.py"
|
||||
},
|
||||
{
|
||||
"label": "IapAccountDisabled",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L10",
|
||||
"id": "iap_account_iapaccountdisabled",
|
||||
"community": 0,
|
||||
"norm_label": "iapaccountdisabled"
|
||||
},
|
||||
{
|
||||
"label": "get_credits()",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L14",
|
||||
"id": "iap_account_get_credits",
|
||||
"community": 0,
|
||||
"norm_label": "get_credits()"
|
||||
},
|
||||
{
|
||||
"label": "DISABLED: Return fake unlimited credits",
|
||||
"file_type": "rationale",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L15",
|
||||
"id": "iap_account_rationale_15",
|
||||
"community": 6,
|
||||
"norm_label": "disabled: return fake unlimited credits"
|
||||
},
|
||||
{
|
||||
"label": "__init__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/__init__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_init_py",
|
||||
"community": 3,
|
||||
"norm_label": "__init__.py"
|
||||
},
|
||||
{
|
||||
"label": "__manifest__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/__manifest__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_manifest_py",
|
||||
"community": 7,
|
||||
"norm_label": "__manifest__.py"
|
||||
},
|
||||
{
|
||||
"label": "__init__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/__init__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_init_py",
|
||||
"community": 4,
|
||||
"norm_label": "__init__.py"
|
||||
},
|
||||
{
|
||||
"label": "iap_account.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py",
|
||||
"community": 0,
|
||||
"norm_label": "iap_account.py"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"relation": "imports_from",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/__init__.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_iap_calls_init_py",
|
||||
"_tgt": "users_gurpreet_github_odoo_modules_disable_iap_calls_init_py",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_init_py",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_iap_calls_init_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "imports_from",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/__init__.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_init_py",
|
||||
"_tgt": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_init_py",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_init_py",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_init_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L10",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py",
|
||||
"_tgt": "iap_account_iapaccountdisabled",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py",
|
||||
"target": "iap_account_iapaccountdisabled",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L14",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py",
|
||||
"_tgt": "iap_account_get_credits",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_models_iap_account_py",
|
||||
"target": "iap_account_get_credits",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L10",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py",
|
||||
"_tgt": "iap_account_iapaccountdisabled",
|
||||
"source": "iap_account_iapaccountdisabled",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/iap_account.py",
|
||||
"source_location": "L14",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py",
|
||||
"_tgt": "iap_account_get_credits",
|
||||
"source": "iap_account_get_credits",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_iap_account_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "imports_from",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/__init__.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_init_py",
|
||||
"_tgt": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_init_py",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_init_py",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_init_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "imports_from",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_iap_calls/disable_iap_calls/models/__init__.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_init_py",
|
||||
"_tgt": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_init_py",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_init_py",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_iap_calls_disable_iap_calls_models_init_py",
|
||||
"confidence_score": 1.0
|
||||
}
|
||||
],
|
||||
"hyperedges": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import iap_account
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Disable all IAP external API calls for local development
|
||||
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IapAccountDisabled(models.Model):
|
||||
_inherit = 'iap.account'
|
||||
|
||||
@api.model
|
||||
def get_credits(self, service_name):
|
||||
"""
|
||||
DISABLED: Return fake unlimited credits
|
||||
"""
|
||||
_logger.info("IAP get_credits DISABLED - returning unlimited credits for %s", service_name)
|
||||
return 999999
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# Disable Odoo Online Services
|
||||
|
||||
**Version:** 18.0.1.0.0
|
||||
**License:** LGPL-3
|
||||
**Odoo Version:** 18.0
|
||||
|
||||
## Overview
|
||||
|
||||
This module comprehensively disables all external communications between your Odoo instance and Odoo's servers. It prevents:
|
||||
|
||||
- License/subscription checks
|
||||
- User count reporting
|
||||
- IAP (In-App Purchase) credit checks
|
||||
- Publisher warranty communications
|
||||
- Partner autocomplete/enrichment
|
||||
- Expiration warnings in the UI
|
||||
|
||||
## Features
|
||||
|
||||
### 1. IAP JSON-RPC Blocking
|
||||
Patches the core `iap_jsonrpc` function to prevent all IAP API calls:
|
||||
- Returns fake successful responses
|
||||
- Logs all blocked calls
|
||||
- Provides unlimited credits for services that check
|
||||
|
||||
### 2. License Parameter Protection
|
||||
Protects critical `ir.config_parameter` values:
|
||||
- `database.expiration_date` → Always returns `2099-12-31 23:59:59`
|
||||
- `database.expiration_reason` → Always returns `renewal`
|
||||
- `database.enterprise_code` → Always returns `PERMANENT_LOCAL`
|
||||
|
||||
### 3. Session Info Patching
|
||||
Modifies `session_info()` to prevent frontend warnings:
|
||||
- Sets expiration date to 2099
|
||||
- Sets `warning` to `False`
|
||||
- Removes "already linked" subscription prompts
|
||||
|
||||
### 4. User Creation Protection
|
||||
Logs user creation without triggering subscription checks:
|
||||
- Blocks any external validation
|
||||
- Logs permission changes
|
||||
|
||||
### 5. Publisher Warranty Block
|
||||
Disables all warranty-related server communication:
|
||||
- `_get_sys_logs()` → Returns empty response
|
||||
- `update_notification()` → Returns success without calling server
|
||||
|
||||
### 6. Cron Job Blocking
|
||||
Blocks scheduled actions that contact Odoo:
|
||||
- Publisher Warranty Check
|
||||
- Database Auto-Expiration Check
|
||||
- Various IAP-related crons
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy the module to your Odoo addons directory
|
||||
2. Restart Odoo
|
||||
3. Go to Apps → Update Apps List
|
||||
4. Search for "Disable Odoo Online Services"
|
||||
5. Click Install
|
||||
|
||||
## Verification
|
||||
|
||||
Check that blocking is active:
|
||||
|
||||
```bash
|
||||
docker logs odoo-container 2>&1 | grep -i "BLOCKED\|DISABLED"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
IAP JSON-RPC calls have been DISABLED globally
|
||||
Module update_list: Scanning local addons only (Odoo Apps store disabled)
|
||||
Publisher warranty update_notification BLOCKED
|
||||
Creating 1 user(s) - subscription check DISABLED
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
No configuration required. The module automatically:
|
||||
- Sets permanent expiration values on install (via `_post_init_hook`)
|
||||
- Patches all necessary functions when loaded
|
||||
- Protects values from being changed
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `models/disable_iap_tools.py` | Patches `iap_jsonrpc` globally |
|
||||
| `models/disable_online_services.py` | Blocks publisher warranty, cron jobs |
|
||||
| `models/disable_database_expiration.py` | Protects `ir.config_parameter` |
|
||||
| `models/disable_session_leaks.py` | Patches session info, user creation |
|
||||
| `models/disable_partner_autocomplete.py` | Blocks partner enrichment |
|
||||
| `models/disable_all_external.py` | Additional external call blocks |
|
||||
|
||||
### Blocked Endpoints
|
||||
|
||||
All redirected to `http://localhost:65535`:
|
||||
|
||||
- `iap.endpoint`
|
||||
- `publisher_warranty_url`
|
||||
- `partner_autocomplete.endpoint`
|
||||
- `iap_extract_endpoint`
|
||||
- `olg.endpoint`
|
||||
- `mail.media_library_endpoint`
|
||||
- `sms.endpoint`
|
||||
- `crm.iap_lead_mining.endpoint`
|
||||
- And many more...
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `base`
|
||||
- `web`
|
||||
- `iap`
|
||||
- `mail`
|
||||
- `base_setup`
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Odoo 18.0 Community Edition
|
||||
- Odoo 18.0 Enterprise Edition
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This module is intended for legitimate use cases such as:
|
||||
- Air-gapped environments
|
||||
- Development/testing instances
|
||||
- Self-hosted deployments with proper licensing
|
||||
|
||||
Ensure you comply with Odoo's licensing terms for your use case.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.0.0 (2025-12-29)
|
||||
- Initial release
|
||||
- IAP blocking
|
||||
- Publisher warranty blocking
|
||||
- Session info patching
|
||||
- User creation protection
|
||||
- Config parameter protection
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
|
||||
|
||||
def _post_init_hook(env):
|
||||
"""
|
||||
Set all configuration parameters to disable external Odoo services.
|
||||
This runs after module installation.
|
||||
"""
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
set_param = env['ir.config_parameter'].sudo().set_param
|
||||
|
||||
# Set permanent database expiration
|
||||
params_to_set = {
|
||||
# Database license parameters
|
||||
'database.expiration_date': '2099-12-31 23:59:59',
|
||||
'database.expiration_reason': 'renewal',
|
||||
'database.enterprise_code': 'PERMANENT_LOCAL',
|
||||
|
||||
# Clear "already linked" parameters
|
||||
'database.already_linked_subscription_url': '',
|
||||
'database.already_linked_email': '',
|
||||
'database.already_linked_send_mail_url': '',
|
||||
|
||||
# Redirect all IAP endpoints to localhost
|
||||
'iap.endpoint': 'http://localhost:65535',
|
||||
'partner_autocomplete.endpoint': 'http://localhost:65535',
|
||||
'iap_extract_endpoint': 'http://localhost:65535',
|
||||
'olg.endpoint': 'http://localhost:65535',
|
||||
'mail.media_library_endpoint': 'http://localhost:65535',
|
||||
'website.api_endpoint': 'http://localhost:65535',
|
||||
'sms.endpoint': 'http://localhost:65535',
|
||||
'crm.iap_lead_mining.endpoint': 'http://localhost:65535',
|
||||
'reveal.endpoint': 'http://localhost:65535',
|
||||
'publisher_warranty_url': 'http://localhost:65535',
|
||||
|
||||
# OCN (Odoo Cloud Notification) - blocks push notifications to Odoo
|
||||
'odoo_ocn.endpoint': 'http://localhost:65535', # Main OCN endpoint
|
||||
'mail_mobile.enable_ocn': 'False', # Disable OCN push notifications
|
||||
'odoo_ocn.project_id': '', # Clear any registered project
|
||||
'ocn.uuid': '', # Clear OCN UUID to prevent registration
|
||||
|
||||
# Snailmail (physical mail service)
|
||||
'snailmail.endpoint': 'http://localhost:65535',
|
||||
|
||||
# Social media IAP
|
||||
'social.facebook_endpoint': 'http://localhost:65535',
|
||||
'social.twitter_endpoint': 'http://localhost:65535',
|
||||
'social.linkedin_endpoint': 'http://localhost:65535',
|
||||
}
|
||||
|
||||
_logger.info("=" * 60)
|
||||
_logger.info("DISABLE ODOO ONLINE: Setting configuration parameters")
|
||||
_logger.info("=" * 60)
|
||||
|
||||
for key, value in params_to_set.items():
|
||||
try:
|
||||
set_param(key, value)
|
||||
_logger.info("Set %s = %s", key, value if len(str(value)) < 30 else value[:30] + "...")
|
||||
except Exception as e:
|
||||
_logger.warning("Could not set %s: %s", key, e)
|
||||
|
||||
_logger.info("=" * 60)
|
||||
_logger.info("DISABLE ODOO ONLINE: Configuration complete")
|
||||
_logger.info("=" * 60)
|
||||
@@ -1,56 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Disable Odoo Online Services',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Blocks ALL external Odoo server communications',
|
||||
'description': """
|
||||
Comprehensive Module to Disable ALL Odoo Online Services
|
||||
=========================================================
|
||||
|
||||
This module completely blocks all external communications from Odoo to Odoo's servers.
|
||||
|
||||
**Blocked Services:**
|
||||
- Publisher Warranty checks (license validation)
|
||||
- IAP (In-App Purchase) - All services
|
||||
- Partner Autocomplete API
|
||||
- Company Enrichment API
|
||||
- VAT Lookup API
|
||||
- SMS API
|
||||
- Invoice/Expense OCR Extract
|
||||
- Media Library (Stock Images)
|
||||
- Currency Rate Live Updates
|
||||
- CRM Lead Mining
|
||||
- CRM Reveal (Website visitor identification)
|
||||
- Google Calendar Sync
|
||||
- AI/OLG Content Generation
|
||||
- Database Registration
|
||||
- Module Update checks from Odoo Store
|
||||
- Session-based license detection
|
||||
- Frontend expiration panel warnings
|
||||
|
||||
**Use Cases:**
|
||||
- Air-gapped installations
|
||||
- Local development without internet
|
||||
- Enterprise deployments that don't want telemetry
|
||||
- Testing environments
|
||||
|
||||
**WARNING:** This module disables legitimate Odoo services.
|
||||
Only use if you understand the implications.
|
||||
""",
|
||||
'author': 'Fusion Development',
|
||||
'website': 'https://fusiondevelopment.com',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['base', 'mail', 'web'],
|
||||
'data': [
|
||||
'data/disable_external_services.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'disable_odoo_online/static/src/js/disable_external_links.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': False,
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- All config parameters are set via post_init_hook in __init__.py -->
|
||||
<!-- This file is kept for future data records if needed -->
|
||||
</odoo>
|
||||
@@ -1,143 +0,0 @@
|
||||
# Disable Odoo Online Services
|
||||
|
||||
**Version:** 18.0.1.0.0
|
||||
**License:** LGPL-3
|
||||
**Odoo Version:** 18.0
|
||||
|
||||
## Overview
|
||||
|
||||
This module comprehensively disables all external communications between your Odoo instance and Odoo's servers. It prevents:
|
||||
|
||||
- License/subscription checks
|
||||
- User count reporting
|
||||
- IAP (In-App Purchase) credit checks
|
||||
- Publisher warranty communications
|
||||
- Partner autocomplete/enrichment
|
||||
- Expiration warnings in the UI
|
||||
|
||||
## Features
|
||||
|
||||
### 1. IAP JSON-RPC Blocking
|
||||
Patches the core `iap_jsonrpc` function to prevent all IAP API calls:
|
||||
- Returns fake successful responses
|
||||
- Logs all blocked calls
|
||||
- Provides unlimited credits for services that check
|
||||
|
||||
### 2. License Parameter Protection
|
||||
Protects critical `ir.config_parameter` values:
|
||||
- `database.expiration_date` → Always returns `2099-12-31 23:59:59`
|
||||
- `database.expiration_reason` → Always returns `renewal`
|
||||
- `database.enterprise_code` → Always returns `PERMANENT_LOCAL`
|
||||
|
||||
### 3. Session Info Patching
|
||||
Modifies `session_info()` to prevent frontend warnings:
|
||||
- Sets expiration date to 2099
|
||||
- Sets `warning` to `False`
|
||||
- Removes "already linked" subscription prompts
|
||||
|
||||
### 4. User Creation Protection
|
||||
Logs user creation without triggering subscription checks:
|
||||
- Blocks any external validation
|
||||
- Logs permission changes
|
||||
|
||||
### 5. Publisher Warranty Block
|
||||
Disables all warranty-related server communication:
|
||||
- `_get_sys_logs()` → Returns empty response
|
||||
- `update_notification()` → Returns success without calling server
|
||||
|
||||
### 6. Cron Job Blocking
|
||||
Blocks scheduled actions that contact Odoo:
|
||||
- Publisher Warranty Check
|
||||
- Database Auto-Expiration Check
|
||||
- Various IAP-related crons
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy the module to your Odoo addons directory
|
||||
2. Restart Odoo
|
||||
3. Go to Apps → Update Apps List
|
||||
4. Search for "Disable Odoo Online Services"
|
||||
5. Click Install
|
||||
|
||||
## Verification
|
||||
|
||||
Check that blocking is active:
|
||||
|
||||
```bash
|
||||
docker logs odoo-container 2>&1 | grep -i "BLOCKED\|DISABLED"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
IAP JSON-RPC calls have been DISABLED globally
|
||||
Module update_list: Scanning local addons only (Odoo Apps store disabled)
|
||||
Publisher warranty update_notification BLOCKED
|
||||
Creating 1 user(s) - subscription check DISABLED
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
No configuration required. The module automatically:
|
||||
- Sets permanent expiration values on install (via `_post_init_hook`)
|
||||
- Patches all necessary functions when loaded
|
||||
- Protects values from being changed
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `models/disable_iap_tools.py` | Patches `iap_jsonrpc` globally |
|
||||
| `models/disable_online_services.py` | Blocks publisher warranty, cron jobs |
|
||||
| `models/disable_database_expiration.py` | Protects `ir.config_parameter` |
|
||||
| `models/disable_session_leaks.py` | Patches session info, user creation |
|
||||
| `models/disable_partner_autocomplete.py` | Blocks partner enrichment |
|
||||
| `models/disable_all_external.py` | Additional external call blocks |
|
||||
|
||||
### Blocked Endpoints
|
||||
|
||||
All redirected to `http://localhost:65535`:
|
||||
|
||||
- `iap.endpoint`
|
||||
- `publisher_warranty_url`
|
||||
- `partner_autocomplete.endpoint`
|
||||
- `iap_extract_endpoint`
|
||||
- `olg.endpoint`
|
||||
- `mail.media_library_endpoint`
|
||||
- `sms.endpoint`
|
||||
- `crm.iap_lead_mining.endpoint`
|
||||
- And many more...
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `base`
|
||||
- `web`
|
||||
- `iap`
|
||||
- `mail`
|
||||
- `base_setup`
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Odoo 18.0 Community Edition
|
||||
- Odoo 18.0 Enterprise Edition
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This module is intended for legitimate use cases such as:
|
||||
- Air-gapped environments
|
||||
- Development/testing instances
|
||||
- Self-hosted deployments with proper licensing
|
||||
|
||||
Ensure you comply with Odoo's licensing terms for your use case.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.0.0 (2025-12-29)
|
||||
- Initial release
|
||||
- IAP blocking
|
||||
- Publisher warranty blocking
|
||||
- Session info patching
|
||||
- User creation protection
|
||||
- Config parameter protection
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
|
||||
|
||||
def _post_init_hook(env):
|
||||
"""
|
||||
Set all configuration parameters to disable external Odoo services.
|
||||
This runs after module installation.
|
||||
"""
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
set_param = env['ir.config_parameter'].sudo().set_param
|
||||
|
||||
# Set permanent database expiration
|
||||
params_to_set = {
|
||||
# Database license parameters
|
||||
'database.expiration_date': '2099-12-31 23:59:59',
|
||||
'database.expiration_reason': 'renewal',
|
||||
'database.enterprise_code': 'PERMANENT_LOCAL',
|
||||
|
||||
# Clear "already linked" parameters
|
||||
'database.already_linked_subscription_url': '',
|
||||
'database.already_linked_email': '',
|
||||
'database.already_linked_send_mail_url': '',
|
||||
|
||||
# Redirect all IAP endpoints to localhost
|
||||
'iap.endpoint': 'http://localhost:65535',
|
||||
'partner_autocomplete.endpoint': 'http://localhost:65535',
|
||||
'iap_extract_endpoint': 'http://localhost:65535',
|
||||
'olg.endpoint': 'http://localhost:65535',
|
||||
'mail.media_library_endpoint': 'http://localhost:65535',
|
||||
'website.api_endpoint': 'http://localhost:65535',
|
||||
'sms.endpoint': 'http://localhost:65535',
|
||||
'crm.iap_lead_mining.endpoint': 'http://localhost:65535',
|
||||
'reveal.endpoint': 'http://localhost:65535',
|
||||
'publisher_warranty_url': 'http://localhost:65535',
|
||||
|
||||
# OCN (Odoo Cloud Notification) - blocks push notifications to Odoo
|
||||
'odoo_ocn.endpoint': 'http://localhost:65535', # Main OCN endpoint
|
||||
'mail_mobile.enable_ocn': 'False', # Disable OCN push notifications
|
||||
'odoo_ocn.project_id': '', # Clear any registered project
|
||||
'ocn.uuid': '', # Clear OCN UUID to prevent registration
|
||||
|
||||
# Snailmail (physical mail service)
|
||||
'snailmail.endpoint': 'http://localhost:65535',
|
||||
|
||||
# Social media IAP
|
||||
'social.facebook_endpoint': 'http://localhost:65535',
|
||||
'social.twitter_endpoint': 'http://localhost:65535',
|
||||
'social.linkedin_endpoint': 'http://localhost:65535',
|
||||
}
|
||||
|
||||
_logger.info("=" * 60)
|
||||
_logger.info("DISABLE ODOO ONLINE: Setting configuration parameters")
|
||||
_logger.info("=" * 60)
|
||||
|
||||
for key, value in params_to_set.items():
|
||||
try:
|
||||
set_param(key, value)
|
||||
_logger.info("Set %s = %s", key, value if len(str(value)) < 30 else value[:30] + "...")
|
||||
except Exception as e:
|
||||
_logger.warning("Could not set %s: %s", key, e)
|
||||
|
||||
_logger.info("=" * 60)
|
||||
_logger.info("DISABLE ODOO ONLINE: Configuration complete")
|
||||
_logger.info("=" * 60)
|
||||
@@ -1,56 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Disable Odoo Online Services',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Blocks ALL external Odoo server communications',
|
||||
'description': """
|
||||
Comprehensive Module to Disable ALL Odoo Online Services
|
||||
=========================================================
|
||||
|
||||
This module completely blocks all external communications from Odoo to Odoo's servers.
|
||||
|
||||
**Blocked Services:**
|
||||
- Publisher Warranty checks (license validation)
|
||||
- IAP (In-App Purchase) - All services
|
||||
- Partner Autocomplete API
|
||||
- Company Enrichment API
|
||||
- VAT Lookup API
|
||||
- SMS API
|
||||
- Invoice/Expense OCR Extract
|
||||
- Media Library (Stock Images)
|
||||
- Currency Rate Live Updates
|
||||
- CRM Lead Mining
|
||||
- CRM Reveal (Website visitor identification)
|
||||
- Google Calendar Sync
|
||||
- AI/OLG Content Generation
|
||||
- Database Registration
|
||||
- Module Update checks from Odoo Store
|
||||
- Session-based license detection
|
||||
- Frontend expiration panel warnings
|
||||
|
||||
**Use Cases:**
|
||||
- Air-gapped installations
|
||||
- Local development without internet
|
||||
- Enterprise deployments that don't want telemetry
|
||||
- Testing environments
|
||||
|
||||
**WARNING:** This module disables legitimate Odoo services.
|
||||
Only use if you understand the implications.
|
||||
""",
|
||||
'author': 'Fusion Development',
|
||||
'website': 'https://fusiondevelopment.com',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['base', 'mail', 'web'],
|
||||
'data': [
|
||||
'data/disable_external_services.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'disable_odoo_online/static/src/js/disable_external_links.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': False,
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- All config parameters are set via post_init_hook in __init__.py -->
|
||||
<!-- This file is kept for future data records if needed -->
|
||||
</odoo>
|
||||
@@ -1,8 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import disable_iap_tools # Patches iap_jsonrpc globally - MUST be first
|
||||
from . import disable_http_requests # Patches requests library to block Odoo domains
|
||||
from . import disable_online_services
|
||||
from . import disable_partner_autocomplete
|
||||
from . import disable_database_expiration
|
||||
from . import disable_all_external
|
||||
from . import disable_session_leaks
|
||||
@@ -1,38 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Comprehensive blocking of ALL external Odoo service calls.
|
||||
Only inherits from models that are guaranteed to exist in base Odoo.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Block Currency Rate Live Updates - Uses res.currency which always exists
|
||||
# ============================================================
|
||||
|
||||
class ResCurrencyDisabled(models.Model):
|
||||
_inherit = 'res.currency'
|
||||
|
||||
@api.model
|
||||
def _get_rates_from_provider(self, provider, date):
|
||||
"""DISABLED: Return empty rates."""
|
||||
_logger.debug("Currency rate provider BLOCKED: provider=%s", provider)
|
||||
return {}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Block Gravatar - Uses res.partner which always exists
|
||||
# ============================================================
|
||||
|
||||
class ResPartnerDisabled(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
@api.model
|
||||
def _get_gravatar_image(self, email):
|
||||
"""DISABLED: Return False to skip gravatar lookup."""
|
||||
_logger.debug("Gravatar lookup BLOCKED for email=%s", email)
|
||||
return False
|
||||
@@ -1,106 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Disable database expiration checks and registration.
|
||||
Consolidates all ir.config_parameter overrides.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from odoo import api, models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrConfigParameter(models.Model):
|
||||
"""Override config parameters to prevent expiration and protect license values."""
|
||||
_inherit = 'ir.config_parameter'
|
||||
|
||||
PROTECTED_PARAMS = {
|
||||
'database.expiration_date': '2099-12-31 23:59:59',
|
||||
'database.expiration_reason': 'renewal',
|
||||
'database.enterprise_code': 'PERMANENT_LOCAL',
|
||||
}
|
||||
|
||||
CLEAR_PARAMS = [
|
||||
'database.already_linked_subscription_url',
|
||||
'database.already_linked_email',
|
||||
'database.already_linked_send_mail_url',
|
||||
]
|
||||
|
||||
def init(self, force=False):
|
||||
"""Set permanent valid subscription on module init."""
|
||||
super().init(force=force)
|
||||
self._set_permanent_subscription()
|
||||
|
||||
@api.model
|
||||
def _set_permanent_subscription(self):
|
||||
"""Set database to never expire."""
|
||||
_logger.info("Setting permanent subscription values...")
|
||||
|
||||
for key, value in self.PROTECTED_PARAMS.items():
|
||||
try:
|
||||
self.env.cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
|
||||
VALUES (%s, %s, %s, NOW() AT TIME ZONE 'UTC', %s, NOW() AT TIME ZONE 'UTC')
|
||||
ON CONFLICT (key) DO UPDATE SET value = %s, write_date = NOW() AT TIME ZONE 'UTC'
|
||||
""", (key, value, self.env.uid, self.env.uid, value))
|
||||
except Exception as e:
|
||||
_logger.debug("Could not set param %s: %s", key, e)
|
||||
|
||||
for key in self.CLEAR_PARAMS:
|
||||
try:
|
||||
self.env.cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
|
||||
VALUES (%s, '', %s, NOW() AT TIME ZONE 'UTC', %s, NOW() AT TIME ZONE 'UTC')
|
||||
ON CONFLICT (key) DO UPDATE SET value = '', write_date = NOW() AT TIME ZONE 'UTC'
|
||||
""", (key, self.env.uid, self.env.uid))
|
||||
except Exception as e:
|
||||
_logger.debug("Could not clear param %s: %s", key, e)
|
||||
|
||||
@api.model
|
||||
def get_param(self, key, default=False):
|
||||
"""Override get_param to return permanent values for protected params."""
|
||||
if key in self.PROTECTED_PARAMS:
|
||||
return self.PROTECTED_PARAMS[key]
|
||||
|
||||
if key in self.CLEAR_PARAMS:
|
||||
return ''
|
||||
|
||||
return super().get_param(key, default)
|
||||
|
||||
def set_param(self, key, value):
|
||||
"""Override set_param to prevent external processes from changing protected values."""
|
||||
if key in self.PROTECTED_PARAMS:
|
||||
if value != self.PROTECTED_PARAMS[key]:
|
||||
_logger.warning("Blocked attempt to change protected param %s to %s", key, value)
|
||||
return True
|
||||
|
||||
if key in self.CLEAR_PARAMS:
|
||||
value = ''
|
||||
|
||||
return super().set_param(key, value)
|
||||
|
||||
|
||||
class DatabaseExpirationCheck(models.AbstractModel):
|
||||
_name = 'disable.odoo.online.expiration'
|
||||
_description = 'Database Expiration Blocker'
|
||||
|
||||
@api.model
|
||||
def check_database_expiration(self):
|
||||
return {
|
||||
'valid': True,
|
||||
'expiration_date': '2099-12-31 23:59:59',
|
||||
'expiration_reason': 'renewal',
|
||||
}
|
||||
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
|
||||
@api.model
|
||||
def _get_database_expiration_date(self):
|
||||
return datetime(2099, 12, 31, 23, 59, 59)
|
||||
|
||||
@api.model
|
||||
def _check_database_enterprise_expiration(self):
|
||||
return True
|
||||
@@ -1,129 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Block ALL outgoing HTTP requests to Odoo-related domains.
|
||||
This patches the requests library to intercept and block external calls.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from functools import wraps
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Domains to block - all Odoo external services
|
||||
BLOCKED_DOMAINS = [
|
||||
'odoo.com',
|
||||
'odoofin.com',
|
||||
'odoo.sh',
|
||||
'iap.odoo.com',
|
||||
'iap-services.odoo.com',
|
||||
'partner-autocomplete.odoo.com',
|
||||
'iap-extract.odoo.com',
|
||||
'iap-sms.odoo.com',
|
||||
'upgrade.odoo.com',
|
||||
'apps.odoo.com',
|
||||
'production.odoofin.com',
|
||||
'plaid.com',
|
||||
'yodlee.com',
|
||||
'gravatar.com',
|
||||
'www.gravatar.com',
|
||||
'secure.gravatar.com',
|
||||
]
|
||||
|
||||
# Store original functions
|
||||
_original_request = None
|
||||
_original_get = None
|
||||
_original_post = None
|
||||
|
||||
|
||||
def _is_blocked_url(url):
|
||||
"""Check if the URL should be blocked."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc.lower()
|
||||
for blocked in BLOCKED_DOMAINS:
|
||||
if blocked in domain:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _blocked_request(method, url, **kwargs):
|
||||
"""Intercept and block requests to Odoo domains."""
|
||||
if _is_blocked_url(url):
|
||||
_logger.warning("HTTP REQUEST BLOCKED: %s %s", method.upper(), url)
|
||||
# Return a mock response
|
||||
response = requests.models.Response()
|
||||
response.status_code = 200
|
||||
response._content = b'{}'
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
||||
return _original_request(method, url, **kwargs)
|
||||
|
||||
|
||||
def _blocked_get(url, **kwargs):
|
||||
"""Intercept and block GET requests."""
|
||||
if _is_blocked_url(url):
|
||||
_logger.warning("HTTP GET BLOCKED: %s", url)
|
||||
response = requests.models.Response()
|
||||
response.status_code = 200
|
||||
response._content = b'{}'
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
||||
return _original_get(url, **kwargs)
|
||||
|
||||
|
||||
def _blocked_post(url, **kwargs):
|
||||
"""Intercept and block POST requests."""
|
||||
if _is_blocked_url(url):
|
||||
_logger.warning("HTTP POST BLOCKED: %s", url)
|
||||
response = requests.models.Response()
|
||||
response.status_code = 200
|
||||
response._content = b'{}'
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
||||
return _original_post(url, **kwargs)
|
||||
|
||||
|
||||
def patch_requests():
|
||||
"""Monkey-patch requests library to block Odoo domains."""
|
||||
global _original_request, _original_get, _original_post
|
||||
|
||||
try:
|
||||
if _original_request is None:
|
||||
_original_request = requests.Session.request
|
||||
_original_get = requests.get
|
||||
_original_post = requests.post
|
||||
|
||||
# Patch Session.request (catches most calls)
|
||||
def patched_session_request(self, method, url, **kwargs):
|
||||
if _is_blocked_url(url):
|
||||
_logger.warning("HTTP SESSION REQUEST BLOCKED: %s %s", method.upper(), url)
|
||||
response = requests.models.Response()
|
||||
response.status_code = 200
|
||||
response._content = b'{}'
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
response.request = requests.models.PreparedRequest()
|
||||
response.request.url = url
|
||||
response.request.method = method
|
||||
return response
|
||||
return _original_request(self, method, url, **kwargs)
|
||||
|
||||
requests.Session.request = patched_session_request
|
||||
requests.get = _blocked_get
|
||||
requests.post = _blocked_post
|
||||
|
||||
_logger.info("HTTP requests to Odoo domains have been BLOCKED")
|
||||
_logger.info("Blocked domains: %s", ', '.join(BLOCKED_DOMAINS))
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning("Could not patch requests library: %s", e)
|
||||
|
||||
|
||||
# Apply patch when module is imported
|
||||
patch_requests()
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Override the core IAP tools to block ALL external API calls.
|
||||
This is the master switch that blocks ALL Odoo external communications.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import exceptions, _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Store original function reference
|
||||
_original_iap_jsonrpc = None
|
||||
|
||||
|
||||
def _disabled_iap_jsonrpc(url, method='call', params=None, timeout=15):
|
||||
"""
|
||||
DISABLED: Block all IAP JSON-RPC calls.
|
||||
Returns empty/success response instead of making external calls.
|
||||
"""
|
||||
_logger.info("IAP JSONRPC BLOCKED: %s (method=%s)", url, method)
|
||||
|
||||
# Return appropriate empty responses based on the endpoint
|
||||
if '/authorize' in url:
|
||||
return 'fake_transaction_token_disabled'
|
||||
elif '/capture' in url or '/cancel' in url:
|
||||
return True
|
||||
elif '/credits' in url:
|
||||
return 999999
|
||||
elif 'partner-autocomplete' in url:
|
||||
return []
|
||||
elif 'enrich' in url:
|
||||
return {}
|
||||
elif 'sms' in url:
|
||||
_logger.warning("SMS API call blocked - SMS will not be sent")
|
||||
return {'state': 'success', 'credits': 999999}
|
||||
elif 'extract' in url:
|
||||
return {'status': 'success', 'credits': 999999}
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
def patch_iap_tools():
|
||||
"""
|
||||
Monkey-patch the iap_jsonrpc function to block external calls.
|
||||
This is called when the module loads.
|
||||
"""
|
||||
global _original_iap_jsonrpc
|
||||
|
||||
try:
|
||||
from odoo.addons.iap.tools import iap_tools
|
||||
|
||||
if _original_iap_jsonrpc is None:
|
||||
_original_iap_jsonrpc = iap_tools.iap_jsonrpc
|
||||
|
||||
iap_tools.iap_jsonrpc = _disabled_iap_jsonrpc
|
||||
_logger.info("IAP JSON-RPC calls have been DISABLED globally")
|
||||
|
||||
except ImportError:
|
||||
_logger.debug("IAP module not installed, skipping patch")
|
||||
except Exception as e:
|
||||
_logger.warning("Could not patch IAP tools: %s", e)
|
||||
|
||||
|
||||
# Apply patch when module is imported
|
||||
patch_iap_tools()
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Disable various Odoo online services and external API calls.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrModuleModule(models.Model):
|
||||
"""Disable module update checks from Odoo store."""
|
||||
_inherit = 'ir.module.module'
|
||||
|
||||
@api.model
|
||||
def update_list(self):
|
||||
"""
|
||||
Override to prevent fetching from Odoo Apps store.
|
||||
Only scan local addons paths.
|
||||
"""
|
||||
_logger.info("Module update_list: Scanning local addons only (Odoo Apps store disabled)")
|
||||
return super().update_list()
|
||||
|
||||
def button_immediate_upgrade(self):
|
||||
"""Prevent upgrade attempts that might contact Odoo."""
|
||||
_logger.info("Module upgrade: Processing locally only")
|
||||
return super().button_immediate_upgrade()
|
||||
|
||||
|
||||
class IrCron(models.Model):
|
||||
"""Disable scheduled actions that contact Odoo servers."""
|
||||
_inherit = 'ir.cron'
|
||||
|
||||
def _callback(self, cron_name, server_action_id):
|
||||
"""
|
||||
Override to block certain cron jobs that contact Odoo.
|
||||
Odoo 19 signature: _callback(self, cron_name, server_action_id)
|
||||
"""
|
||||
blocked_crons = [
|
||||
'publisher',
|
||||
'warranty',
|
||||
'update_notification',
|
||||
'database_expiration',
|
||||
'iap_enrich',
|
||||
'ocr',
|
||||
'Invoice OCR',
|
||||
'enrich leads',
|
||||
'fetchmail',
|
||||
'online sync',
|
||||
]
|
||||
|
||||
cron_lower = (cron_name or '').lower()
|
||||
for blocked in blocked_crons:
|
||||
if blocked.lower() in cron_lower:
|
||||
_logger.info("Cron BLOCKED (external call): %s", cron_name)
|
||||
return False
|
||||
|
||||
return super()._callback(cron_name, server_action_id)
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
"""Override config settings to prevent external service configuration."""
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
def set_values(self):
|
||||
"""Ensure certain settings stay disabled."""
|
||||
res = super().set_values()
|
||||
|
||||
# Disable any auto-update settings and set permanent expiration
|
||||
params = self.env['ir.config_parameter'].sudo()
|
||||
params.set_param('database.expiration_date', '2099-12-31 23:59:59')
|
||||
params.set_param('database.expiration_reason', 'renewal')
|
||||
params.set_param('database.enterprise_code', 'PERMANENT_LOCAL')
|
||||
|
||||
# Disable IAP endpoint (redirect to nowhere)
|
||||
params.set_param('iap.endpoint', 'http://localhost:65535')
|
||||
|
||||
# Disable various external services
|
||||
params.set_param('partner_autocomplete.endpoint', 'http://localhost:65535')
|
||||
params.set_param('iap_extract_endpoint', 'http://localhost:65535')
|
||||
params.set_param('olg.endpoint', 'http://localhost:65535')
|
||||
params.set_param('mail.media_library_endpoint', 'http://localhost:65535')
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class PublisherWarrantyContract(models.AbstractModel):
|
||||
"""Completely disable publisher warranty checks."""
|
||||
_inherit = 'publisher_warranty.contract'
|
||||
|
||||
@api.model
|
||||
def _get_sys_logs(self):
|
||||
"""
|
||||
DISABLED: Do not contact Odoo servers.
|
||||
Returns fake successful response.
|
||||
"""
|
||||
_logger.info("Publisher warranty _get_sys_logs BLOCKED")
|
||||
return {
|
||||
'messages': [],
|
||||
'enterprise_info': {
|
||||
'expiration_date': '2099-12-31 23:59:59',
|
||||
'expiration_reason': 'renewal',
|
||||
'enterprise_code': 'PERMANENT_LOCAL',
|
||||
}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_message(self):
|
||||
"""DISABLED: Return empty message."""
|
||||
_logger.info("Publisher warranty _get_message BLOCKED")
|
||||
return {}
|
||||
|
||||
def update_notification(self, cron_mode=True):
|
||||
"""
|
||||
DISABLED: Do not send any data to Odoo servers.
|
||||
Just update local parameters with permanent values.
|
||||
"""
|
||||
_logger.info("Publisher warranty update_notification BLOCKED")
|
||||
|
||||
# Set permanent valid subscription parameters
|
||||
params = self.env['ir.config_parameter'].sudo()
|
||||
params.set_param('database.expiration_date', '2099-12-31 23:59:59')
|
||||
params.set_param('database.expiration_reason', 'renewal')
|
||||
params.set_param('database.enterprise_code', 'PERMANENT_LOCAL')
|
||||
|
||||
# Clear any "already linked" parameters
|
||||
params.set_param('database.already_linked_subscription_url', '')
|
||||
params.set_param('database.already_linked_email', '')
|
||||
params.set_param('database.already_linked_send_mail_url', '')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
"""Block certain routes that call external services."""
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@classmethod
|
||||
def _pre_dispatch(cls, rule, arguments):
|
||||
"""Log and potentially block external service routes."""
|
||||
# List of route patterns that should be blocked
|
||||
blocked_routes = [
|
||||
'/iap/',
|
||||
'/partner_autocomplete/',
|
||||
'/google_',
|
||||
'/ocr/',
|
||||
'/sms/',
|
||||
]
|
||||
|
||||
# Note: We don't actually block here as it might break functionality
|
||||
# The actual blocking happens at the API/model level
|
||||
return super()._pre_dispatch(rule, arguments)
|
||||
@@ -1,52 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Disable Partner Autocomplete external API calls.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
"""Disable partner autocomplete from Odoo API."""
|
||||
_inherit = 'res.partner'
|
||||
|
||||
@api.model
|
||||
def autocomplete(self, query, timeout=15):
|
||||
"""
|
||||
DISABLED: Return empty results instead of calling Odoo's partner API.
|
||||
"""
|
||||
_logger.debug("Partner autocomplete DISABLED - returning empty results for: %s", query)
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def enrich_company(self, company_domain, partner_gid, vat, timeout=15):
|
||||
"""
|
||||
DISABLED: Return empty data instead of calling Odoo's enrichment API.
|
||||
"""
|
||||
_logger.debug("Partner enrichment DISABLED - returning empty for domain: %s", company_domain)
|
||||
return {}
|
||||
|
||||
@api.model
|
||||
def read_by_vat(self, vat, timeout=15):
|
||||
"""
|
||||
DISABLED: Return empty data instead of calling Odoo's VAT lookup API.
|
||||
"""
|
||||
_logger.debug("Partner VAT lookup DISABLED - returning empty for VAT: %s", vat)
|
||||
return {}
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
"""Disable company autocomplete features."""
|
||||
_inherit = 'res.company'
|
||||
|
||||
@api.model
|
||||
def autocomplete(self, query, timeout=15):
|
||||
"""
|
||||
DISABLED: Return empty results for company autocomplete.
|
||||
"""
|
||||
_logger.debug("Company autocomplete DISABLED - returning empty results")
|
||||
return []
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Block session-based information leaks and frontend detection mechanisms.
|
||||
Specifically targets the web_enterprise module's subscription checks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
"""
|
||||
Override session info to prevent frontend from detecting license status.
|
||||
This specifically blocks web_enterprise's ExpirationPanel from showing.
|
||||
"""
|
||||
_inherit = 'ir.http'
|
||||
|
||||
def session_info(self):
|
||||
"""
|
||||
Override session info to set permanent valid subscription data.
|
||||
This prevents the frontend ExpirationPanel from showing warnings.
|
||||
|
||||
Key overrides:
|
||||
- expiration_date: Set to far future (2099)
|
||||
- expiration_reason: Set to 'renewal' (valid subscription)
|
||||
- warning: Set to False to hide all warning banners
|
||||
"""
|
||||
result = super().session_info()
|
||||
|
||||
# Override expiration-related session data
|
||||
# These are read by enterprise_subscription_service.js
|
||||
result['expiration_date'] = '2099-12-31 23:59:59'
|
||||
result['expiration_reason'] = 'renewal'
|
||||
result['warning'] = False # Critical: prevents warning banners
|
||||
|
||||
# Remove any "already linked" subscription info
|
||||
# These could trigger redirect prompts
|
||||
result.pop('already_linked_subscription_url', None)
|
||||
result.pop('already_linked_email', None)
|
||||
result.pop('already_linked_send_mail_url', None)
|
||||
|
||||
_logger.debug("Session info patched - expiration set to 2099, warnings disabled")
|
||||
return result
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
"""
|
||||
Override user creation/modification to prevent subscription checks.
|
||||
When users are created, Odoo Enterprise normally contacts Odoo servers
|
||||
to verify the subscription allows that many users.
|
||||
"""
|
||||
_inherit = 'res.users'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
Override create to ensure no external subscription check is triggered.
|
||||
The actual check happens in publisher_warranty.contract which we've
|
||||
already blocked, but this is an extra safety measure.
|
||||
"""
|
||||
_logger.info("Creating %d user(s) - subscription check DISABLED", len(vals_list))
|
||||
|
||||
# Create users normally - no external checks will happen
|
||||
# because publisher_warranty.contract.update_notification is blocked
|
||||
users = super().create(vals_list)
|
||||
|
||||
# Don't trigger any warranty checks
|
||||
return users
|
||||
|
||||
def write(self, vals):
|
||||
"""
|
||||
Override write to log user modifications.
|
||||
"""
|
||||
result = super().write(vals)
|
||||
|
||||
# If internal user status changed, log it
|
||||
if 'share' in vals or 'groups_id' in vals:
|
||||
_logger.info("User permissions updated - subscription check DISABLED")
|
||||
|
||||
return result
|
||||
@@ -1,38 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* This module intercepts clicks on external Odoo links to prevent
|
||||
* referrer leakage when users click help/documentation/upgrade links.
|
||||
*/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
// Store original window.open
|
||||
const originalOpen = browser.open;
|
||||
|
||||
// Override browser.open to add referrer protection
|
||||
browser.open = function(url, target, features) {
|
||||
if (url && typeof url === 'string') {
|
||||
const urlLower = url.toLowerCase();
|
||||
|
||||
// Check if it's an Odoo external link
|
||||
const odooPatterns = [
|
||||
'odoo.com',
|
||||
'odoo.sh',
|
||||
'accounts.odoo',
|
||||
];
|
||||
|
||||
const isOdooLink = odooPatterns.some(pattern => urlLower.includes(pattern));
|
||||
|
||||
if (isOdooLink) {
|
||||
// For Odoo links, open with noreferrer to prevent leaking your domain
|
||||
const newWindow = originalOpen.call(this, url, target || '_blank', 'noopener,noreferrer');
|
||||
return newWindow;
|
||||
}
|
||||
}
|
||||
|
||||
return originalOpen.call(this, url, target, features);
|
||||
};
|
||||
|
||||
console.log('[disable_odoo_online] External link protection loaded');
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/disable_odoo_online (2026-04-22)
|
||||
|
||||
## Corpus Check
|
||||
- 22 files · ~5,870 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 106 nodes · 119 edges · 27 communities detected
|
||||
- Extraction: 97% EXTRACTED · 3% INFERRED · 0% AMBIGUOUS · INFERRED: 3 edges (avg confidence: 0.8)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Community 0|Community 0]]
|
||||
- [[_COMMUNITY_Community 1|Community 1]]
|
||||
- [[_COMMUNITY_Community 2|Community 2]]
|
||||
- [[_COMMUNITY_Community 3|Community 3]]
|
||||
- [[_COMMUNITY_Community 4|Community 4]]
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
- [[_COMMUNITY_Community 8|Community 8]]
|
||||
- [[_COMMUNITY_Community 9|Community 9]]
|
||||
- [[_COMMUNITY_Community 10|Community 10]]
|
||||
- [[_COMMUNITY_Community 11|Community 11]]
|
||||
- [[_COMMUNITY_Community 12|Community 12]]
|
||||
- [[_COMMUNITY_Community 13|Community 13]]
|
||||
- [[_COMMUNITY_Community 14|Community 14]]
|
||||
- [[_COMMUNITY_Community 15|Community 15]]
|
||||
- [[_COMMUNITY_Community 16|Community 16]]
|
||||
- [[_COMMUNITY_Community 17|Community 17]]
|
||||
- [[_COMMUNITY_Community 18|Community 18]]
|
||||
- [[_COMMUNITY_Community 19|Community 19]]
|
||||
- [[_COMMUNITY_Community 20|Community 20]]
|
||||
- [[_COMMUNITY_Community 21|Community 21]]
|
||||
- [[_COMMUNITY_Community 22|Community 22]]
|
||||
- [[_COMMUNITY_Community 23|Community 23]]
|
||||
- [[_COMMUNITY_Community 24|Community 24]]
|
||||
- [[_COMMUNITY_Community 25|Community 25]]
|
||||
- [[_COMMUNITY_Community 26|Community 26]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `_is_blocked_url()` - 6 edges
|
||||
2. `IrConfigParameter` - 5 edges
|
||||
3. `_post_init_hook()` - 4 edges
|
||||
4. `IrModuleModule` - 4 edges
|
||||
5. `IrCron` - 4 edges
|
||||
6. `ResConfigSettings` - 4 edges
|
||||
7. `PublisherWarrantyContract` - 4 edges
|
||||
8. `_blocked_request()` - 4 edges
|
||||
9. `_blocked_get()` - 4 edges
|
||||
10. `_blocked_post()` - 4 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- None detected - all connections are within the same source files.
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): _get_message(), _get_sys_logs(), IrCron, IrHttp, IrModuleModule, _pre_dispatch(), PublisherWarrantyContract, Disable module update checks from Odoo store. (+8 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.26
|
||||
Nodes (10): Base, _check_database_enterprise_expiration(), check_database_expiration(), DatabaseExpirationCheck, _get_database_expiration_date(), get_param(), IrConfigParameter, Override config parameters to prevent expiration and protect license values. (+2 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.27
|
||||
Nodes (10): _blocked_get(), _blocked_post(), _blocked_request(), _is_blocked_url(), patch_requests(), Check if the URL should be blocked., Intercept and block requests to Odoo domains., Intercept and block GET requests. (+2 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): create(), IrHttp, Override session info to prevent frontend from detecting license status. Thi, Override session info to set permanent valid subscription data. This pre, Override user creation/modification to prevent subscription checks. When use, Override write to log user modifications., ResUsers
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.24
|
||||
Nodes (5): Override set_param to prevent external processes from changing protected values., DISABLED: Do not send any data to Odoo servers. Just update local parame, Ensure certain settings stay disabled., _post_init_hook(), Set all configuration parameters to disable external Odoo services. This run
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.33
|
||||
Nodes (7): autocomplete(), enrich_company(), Disable partner autocomplete from Odoo API., Disable company autocomplete features., read_by_vat(), ResCompany, ResPartner
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.4
|
||||
Nodes (4): _disabled_iap_jsonrpc(), patch_iap_tools(), DISABLED: Block all IAP JSON-RPC calls. Returns empty/success response inste, Monkey-patch the iap_jsonrpc function to block external calls. This is calle
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.53
|
||||
Nodes (4): _get_gravatar_image(), _get_rates_from_provider(), ResCurrencyDisabled, ResPartnerDisabled
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Set database to never expire.
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Override get_param to return permanent values for protected params.
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Override to prevent fetching from Odoo Apps store. Only scan local addon
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Do not contact Odoo servers. Returns fake successful response.
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Return empty message.
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Log and potentially block external service routes.
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Return empty rates.
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Return False to skip gravatar lookup.
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Return empty results instead of calling Odoo's partner API.
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Return empty data instead of calling Odoo's enrichment API.
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Return empty data instead of calling Odoo's VAT lookup API.
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Return empty results for company autocomplete.
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Override create to ensure no external subscription check is triggered. T
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
## Knowledge Gaps
|
||||
- **39 isolated node(s):** `Set all configuration parameters to disable external Odoo services. This run`, `Override config parameters to prevent expiration and protect license values.`, `Set permanent valid subscription on module init.`, `Set database to never expire.`, `Override get_param to return permanent values for protected params.` (+34 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 8`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 9`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 10`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 11`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 12`** (1 nodes): `Set database to never expire.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 13`** (1 nodes): `Override get_param to return permanent values for protected params.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 14`** (1 nodes): `Override to prevent fetching from Odoo Apps store. Only scan local addon`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 15`** (1 nodes): `DISABLED: Do not contact Odoo servers. Returns fake successful response.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 16`** (1 nodes): `DISABLED: Return empty message.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 17`** (1 nodes): `Log and potentially block external service routes.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 18`** (1 nodes): `DISABLED: Return empty rates.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 19`** (1 nodes): `DISABLED: Return False to skip gravatar lookup.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 20`** (1 nodes): `DISABLED: Return empty results instead of calling Odoo's partner API.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 21`** (1 nodes): `DISABLED: Return empty data instead of calling Odoo's enrichment API.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 22`** (1 nodes): `DISABLED: Return empty data instead of calling Odoo's VAT lookup API.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 23`** (1 nodes): `DISABLED: Return empty results for company autocomplete.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 24`** (1 nodes): `Override create to ensure no external subscription check is triggered. T`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 25`** (1 nodes): `disable_external_links.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 26`** (1 nodes): `disable_external_links.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `IrConfigParameter` connect `Community 1` to `Community 4`?**
|
||||
_High betweenness centrality (0.069) - this node is a cross-community bridge._
|
||||
- **Why does `ResConfigSettings` connect `Community 0` to `Community 4`?**
|
||||
_High betweenness centrality (0.042) - this node is a cross-community bridge._
|
||||
- **Why does `PublisherWarrantyContract` connect `Community 0` to `Community 4`?**
|
||||
_High betweenness centrality (0.042) - this node is a cross-community bridge._
|
||||
- **What connects `Set all configuration parameters to disable external Odoo services. This run`, `Override config parameters to prevent expiration and protect license values.`, `Set permanent valid subscription on module init.` to the rest of the system?**
|
||||
_39 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `Community 0` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.14 - nodes in this community are weakly interconnected._
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_all_external_py", "label": "disable_all_external.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L1"}, {"id": "disable_all_external_rescurrencydisabled", "label": "ResCurrencyDisabled", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L17"}, {"id": "disable_all_external_get_rates_from_provider", "label": "_get_rates_from_provider()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L21"}, {"id": "disable_all_external_respartnerdisabled", "label": "ResPartnerDisabled", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L31"}, {"id": "disable_all_external_get_gravatar_image", "label": "_get_gravatar_image()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L35"}, {"id": "disable_all_external_rationale_22", "label": "DISABLED: Return empty rates.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L22"}, {"id": "disable_all_external_rationale_36", "label": "DISABLED: Return False to skip gravatar lookup.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L36"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_all_external_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_all_external_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_all_external_py", "target": "disable_all_external_rescurrencydisabled", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L17", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_all_external_py", "target": "disable_all_external_get_rates_from_provider", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L21", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_all_external_py", "target": "disable_all_external_respartnerdisabled", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L31", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_all_external_py", "target": "disable_all_external_get_gravatar_image", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L35", "weight": 1.0}, {"source": "disable_all_external_rationale_22", "target": "disable_all_external_rescurrencydisabled_get_rates_from_provider", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L22", "weight": 1.0}, {"source": "disable_all_external_rationale_36", "target": "disable_all_external_respartnerdisabled_get_gravatar_image", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L36", "weight": 1.0}], "raw_calls": [{"caller_nid": "disable_all_external_get_rates_from_provider", "callee": "debug", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L23"}, {"caller_nid": "disable_all_external_get_gravatar_image", "callee": "debug", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_all_external.py", "source_location": "L37"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_static_src_js_disable_external_links_js", "label": "disable_external_links.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/static/src/js/disable_external_links.js", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_static_src_js_disable_external_links_js", "target": "browser", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/static/src/js/disable_external_links.js", "source_location": "L8", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/__init__.py", "source_location": "L8", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_static_src_js_disable_external_links_js", "label": "disable_external_links.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/static/src/js/disable_external_links.js", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_static_src_js_disable_external_links_js", "target": "browser", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/static/src/js/disable_external_links.js", "source_location": "L8", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/__init__.py", "source_location": "L8", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_disable_iap_tools_py", "label": "disable_iap_tools.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L1"}, {"id": "disable_iap_tools_disabled_iap_jsonrpc", "label": "_disabled_iap_jsonrpc()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L16"}, {"id": "disable_iap_tools_patch_iap_tools", "label": "patch_iap_tools()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L43"}, {"id": "disable_iap_tools_rationale_17", "label": "DISABLED: Block all IAP JSON-RPC calls. Returns empty/success response inste", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L17"}, {"id": "disable_iap_tools_rationale_44", "label": "Monkey-patch the iap_jsonrpc function to block external calls. This is calle", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L44"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_disable_iap_tools_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_disable_iap_tools_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_disable_iap_tools_py", "target": "disable_iap_tools_disabled_iap_jsonrpc", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L16", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_models_disable_iap_tools_py", "target": "disable_iap_tools_patch_iap_tools", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L43", "weight": 1.0}, {"source": "disable_iap_tools_rationale_17", "target": "disable_iap_tools_disabled_iap_jsonrpc", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L17", "weight": 1.0}, {"source": "disable_iap_tools_rationale_44", "target": "disable_iap_tools_patch_iap_tools", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L44", "weight": 1.0}], "raw_calls": [{"caller_nid": "disable_iap_tools_disabled_iap_jsonrpc", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L21"}, {"caller_nid": "disable_iap_tools_disabled_iap_jsonrpc", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L35"}, {"caller_nid": "disable_iap_tools_patch_iap_tools", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L57"}, {"caller_nid": "disable_iap_tools_patch_iap_tools", "callee": "debug", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L60"}, {"caller_nid": "disable_iap_tools_patch_iap_tools", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L62"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L1"}, {"id": "init_post_init_hook", "label": "_post_init_hook()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L5"}, {"id": "init_rationale_6", "label": "Set all configuration parameters to disable external Odoo services. This run", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L6"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_init_py", "target": "init_post_init_hook", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "init_rationale_6", "target": "init_post_init_hook", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L6", "weight": 1.0}], "raw_calls": [{"caller_nid": "init_post_init_hook", "callee": "getLogger", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L11"}, {"caller_nid": "init_post_init_hook", "callee": "sudo", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L13"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L54"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L55"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L56"}, {"caller_nid": "init_post_init_hook", "callee": "items", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L58"}, {"caller_nid": "init_post_init_hook", "callee": "set_param", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L60"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L61"}, {"caller_nid": "init_post_init_hook", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L61"}, {"caller_nid": "init_post_init_hook", "callee": "str", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L61"}, {"caller_nid": "init_post_init_hook", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L63"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L65"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L66"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__init__.py", "source_location": "L67"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L1"}, {"id": "init_post_init_hook", "label": "_post_init_hook()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L5"}, {"id": "init_rationale_6", "label": "Set all configuration parameters to disable external Odoo services. This run", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L6"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_init_py", "target": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_disable_odoo_online_init_py", "target": "init_post_init_hook", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "init_rationale_6", "target": "init_post_init_hook", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L6", "weight": 1.0}], "raw_calls": [{"caller_nid": "init_post_init_hook", "callee": "getLogger", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L11"}, {"caller_nid": "init_post_init_hook", "callee": "sudo", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L13"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L54"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L55"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L56"}, {"caller_nid": "init_post_init_hook", "callee": "items", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L58"}, {"caller_nid": "init_post_init_hook", "callee": "set_param", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L60"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L61"}, {"caller_nid": "init_post_init_hook", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L61"}, {"caller_nid": "init_post_init_hook", "callee": "str", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L61"}, {"caller_nid": "init_post_init_hook", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L63"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L65"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L66"}, {"caller_nid": "init_post_init_hook", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/disable_odoo_online/__init__.py", "source_location": "L67"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_iap_tools_py", "label": "disable_iap_tools.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L1"}, {"id": "disable_iap_tools_disabled_iap_jsonrpc", "label": "_disabled_iap_jsonrpc()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L16"}, {"id": "disable_iap_tools_patch_iap_tools", "label": "patch_iap_tools()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L43"}, {"id": "disable_iap_tools_rationale_17", "label": "DISABLED: Block all IAP JSON-RPC calls. Returns empty/success response inste", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L17"}, {"id": "disable_iap_tools_rationale_44", "label": "Monkey-patch the iap_jsonrpc function to block external calls. This is calle", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L44"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_iap_tools_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_iap_tools_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_iap_tools_py", "target": "disable_iap_tools_disabled_iap_jsonrpc", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L16", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_disable_odoo_online_models_disable_iap_tools_py", "target": "disable_iap_tools_patch_iap_tools", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L43", "weight": 1.0}, {"source": "disable_iap_tools_rationale_17", "target": "disable_iap_tools_disabled_iap_jsonrpc", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L17", "weight": 1.0}, {"source": "disable_iap_tools_rationale_44", "target": "disable_iap_tools_patch_iap_tools", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L44", "weight": 1.0}], "raw_calls": [{"caller_nid": "disable_iap_tools_disabled_iap_jsonrpc", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L21"}, {"caller_nid": "disable_iap_tools_disabled_iap_jsonrpc", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L35"}, {"caller_nid": "disable_iap_tools_patch_iap_tools", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L57"}, {"caller_nid": "disable_iap_tools_patch_iap_tools", "callee": "debug", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L60"}, {"caller_nid": "disable_iap_tools_patch_iap_tools", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_odoo_online/models/disable_iap_tools.py", "source_location": "L62"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import disable_iap_tools # Patches iap_jsonrpc globally - MUST be first
|
||||
from . import disable_http_requests # Patches requests library to block Odoo domains
|
||||
from . import disable_online_services
|
||||
from . import disable_partner_autocomplete
|
||||
from . import disable_database_expiration
|
||||
from . import disable_all_external
|
||||
from . import disable_session_leaks
|
||||
@@ -1,38 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Comprehensive blocking of ALL external Odoo service calls.
|
||||
Only inherits from models that are guaranteed to exist in base Odoo.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Block Currency Rate Live Updates - Uses res.currency which always exists
|
||||
# ============================================================
|
||||
|
||||
class ResCurrencyDisabled(models.Model):
|
||||
_inherit = 'res.currency'
|
||||
|
||||
@api.model
|
||||
def _get_rates_from_provider(self, provider, date):
|
||||
"""DISABLED: Return empty rates."""
|
||||
_logger.debug("Currency rate provider BLOCKED: provider=%s", provider)
|
||||
return {}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Block Gravatar - Uses res.partner which always exists
|
||||
# ============================================================
|
||||
|
||||
class ResPartnerDisabled(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
@api.model
|
||||
def _get_gravatar_image(self, email):
|
||||
"""DISABLED: Return False to skip gravatar lookup."""
|
||||
_logger.debug("Gravatar lookup BLOCKED for email=%s", email)
|
||||
return False
|
||||
@@ -1,106 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Disable database expiration checks and registration.
|
||||
Consolidates all ir.config_parameter overrides.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from odoo import api, models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrConfigParameter(models.Model):
|
||||
"""Override config parameters to prevent expiration and protect license values."""
|
||||
_inherit = 'ir.config_parameter'
|
||||
|
||||
PROTECTED_PARAMS = {
|
||||
'database.expiration_date': '2099-12-31 23:59:59',
|
||||
'database.expiration_reason': 'renewal',
|
||||
'database.enterprise_code': 'PERMANENT_LOCAL',
|
||||
}
|
||||
|
||||
CLEAR_PARAMS = [
|
||||
'database.already_linked_subscription_url',
|
||||
'database.already_linked_email',
|
||||
'database.already_linked_send_mail_url',
|
||||
]
|
||||
|
||||
def init(self, force=False):
|
||||
"""Set permanent valid subscription on module init."""
|
||||
super().init(force=force)
|
||||
self._set_permanent_subscription()
|
||||
|
||||
@api.model
|
||||
def _set_permanent_subscription(self):
|
||||
"""Set database to never expire."""
|
||||
_logger.info("Setting permanent subscription values...")
|
||||
|
||||
for key, value in self.PROTECTED_PARAMS.items():
|
||||
try:
|
||||
self.env.cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
|
||||
VALUES (%s, %s, %s, NOW() AT TIME ZONE 'UTC', %s, NOW() AT TIME ZONE 'UTC')
|
||||
ON CONFLICT (key) DO UPDATE SET value = %s, write_date = NOW() AT TIME ZONE 'UTC'
|
||||
""", (key, value, self.env.uid, self.env.uid, value))
|
||||
except Exception as e:
|
||||
_logger.debug("Could not set param %s: %s", key, e)
|
||||
|
||||
for key in self.CLEAR_PARAMS:
|
||||
try:
|
||||
self.env.cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
|
||||
VALUES (%s, '', %s, NOW() AT TIME ZONE 'UTC', %s, NOW() AT TIME ZONE 'UTC')
|
||||
ON CONFLICT (key) DO UPDATE SET value = '', write_date = NOW() AT TIME ZONE 'UTC'
|
||||
""", (key, self.env.uid, self.env.uid))
|
||||
except Exception as e:
|
||||
_logger.debug("Could not clear param %s: %s", key, e)
|
||||
|
||||
@api.model
|
||||
def get_param(self, key, default=False):
|
||||
"""Override get_param to return permanent values for protected params."""
|
||||
if key in self.PROTECTED_PARAMS:
|
||||
return self.PROTECTED_PARAMS[key]
|
||||
|
||||
if key in self.CLEAR_PARAMS:
|
||||
return ''
|
||||
|
||||
return super().get_param(key, default)
|
||||
|
||||
def set_param(self, key, value):
|
||||
"""Override set_param to prevent external processes from changing protected values."""
|
||||
if key in self.PROTECTED_PARAMS:
|
||||
if value != self.PROTECTED_PARAMS[key]:
|
||||
_logger.warning("Blocked attempt to change protected param %s to %s", key, value)
|
||||
return True
|
||||
|
||||
if key in self.CLEAR_PARAMS:
|
||||
value = ''
|
||||
|
||||
return super().set_param(key, value)
|
||||
|
||||
|
||||
class DatabaseExpirationCheck(models.AbstractModel):
|
||||
_name = 'disable.odoo.online.expiration'
|
||||
_description = 'Database Expiration Blocker'
|
||||
|
||||
@api.model
|
||||
def check_database_expiration(self):
|
||||
return {
|
||||
'valid': True,
|
||||
'expiration_date': '2099-12-31 23:59:59',
|
||||
'expiration_reason': 'renewal',
|
||||
}
|
||||
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
|
||||
@api.model
|
||||
def _get_database_expiration_date(self):
|
||||
return datetime(2099, 12, 31, 23, 59, 59)
|
||||
|
||||
@api.model
|
||||
def _check_database_enterprise_expiration(self):
|
||||
return True
|
||||
@@ -1,129 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Block ALL outgoing HTTP requests to Odoo-related domains.
|
||||
This patches the requests library to intercept and block external calls.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from functools import wraps
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Domains to block - all Odoo external services
|
||||
BLOCKED_DOMAINS = [
|
||||
'odoo.com',
|
||||
'odoofin.com',
|
||||
'odoo.sh',
|
||||
'iap.odoo.com',
|
||||
'iap-services.odoo.com',
|
||||
'partner-autocomplete.odoo.com',
|
||||
'iap-extract.odoo.com',
|
||||
'iap-sms.odoo.com',
|
||||
'upgrade.odoo.com',
|
||||
'apps.odoo.com',
|
||||
'production.odoofin.com',
|
||||
'plaid.com',
|
||||
'yodlee.com',
|
||||
'gravatar.com',
|
||||
'www.gravatar.com',
|
||||
'secure.gravatar.com',
|
||||
]
|
||||
|
||||
# Store original functions
|
||||
_original_request = None
|
||||
_original_get = None
|
||||
_original_post = None
|
||||
|
||||
|
||||
def _is_blocked_url(url):
|
||||
"""Check if the URL should be blocked."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc.lower()
|
||||
for blocked in BLOCKED_DOMAINS:
|
||||
if blocked in domain:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _blocked_request(method, url, **kwargs):
|
||||
"""Intercept and block requests to Odoo domains."""
|
||||
if _is_blocked_url(url):
|
||||
_logger.warning("HTTP REQUEST BLOCKED: %s %s", method.upper(), url)
|
||||
# Return a mock response
|
||||
response = requests.models.Response()
|
||||
response.status_code = 200
|
||||
response._content = b'{}'
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
||||
return _original_request(method, url, **kwargs)
|
||||
|
||||
|
||||
def _blocked_get(url, **kwargs):
|
||||
"""Intercept and block GET requests."""
|
||||
if _is_blocked_url(url):
|
||||
_logger.warning("HTTP GET BLOCKED: %s", url)
|
||||
response = requests.models.Response()
|
||||
response.status_code = 200
|
||||
response._content = b'{}'
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
||||
return _original_get(url, **kwargs)
|
||||
|
||||
|
||||
def _blocked_post(url, **kwargs):
|
||||
"""Intercept and block POST requests."""
|
||||
if _is_blocked_url(url):
|
||||
_logger.warning("HTTP POST BLOCKED: %s", url)
|
||||
response = requests.models.Response()
|
||||
response.status_code = 200
|
||||
response._content = b'{}'
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
||||
return _original_post(url, **kwargs)
|
||||
|
||||
|
||||
def patch_requests():
|
||||
"""Monkey-patch requests library to block Odoo domains."""
|
||||
global _original_request, _original_get, _original_post
|
||||
|
||||
try:
|
||||
if _original_request is None:
|
||||
_original_request = requests.Session.request
|
||||
_original_get = requests.get
|
||||
_original_post = requests.post
|
||||
|
||||
# Patch Session.request (catches most calls)
|
||||
def patched_session_request(self, method, url, **kwargs):
|
||||
if _is_blocked_url(url):
|
||||
_logger.warning("HTTP SESSION REQUEST BLOCKED: %s %s", method.upper(), url)
|
||||
response = requests.models.Response()
|
||||
response.status_code = 200
|
||||
response._content = b'{}'
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
response.request = requests.models.PreparedRequest()
|
||||
response.request.url = url
|
||||
response.request.method = method
|
||||
return response
|
||||
return _original_request(self, method, url, **kwargs)
|
||||
|
||||
requests.Session.request = patched_session_request
|
||||
requests.get = _blocked_get
|
||||
requests.post = _blocked_post
|
||||
|
||||
_logger.info("HTTP requests to Odoo domains have been BLOCKED")
|
||||
_logger.info("Blocked domains: %s", ', '.join(BLOCKED_DOMAINS))
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning("Could not patch requests library: %s", e)
|
||||
|
||||
|
||||
# Apply patch when module is imported
|
||||
patch_requests()
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Override the core IAP tools to block ALL external API calls.
|
||||
This is the master switch that blocks ALL Odoo external communications.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import exceptions, _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Store original function reference
|
||||
_original_iap_jsonrpc = None
|
||||
|
||||
|
||||
def _disabled_iap_jsonrpc(url, method='call', params=None, timeout=15):
|
||||
"""
|
||||
DISABLED: Block all IAP JSON-RPC calls.
|
||||
Returns empty/success response instead of making external calls.
|
||||
"""
|
||||
_logger.info("IAP JSONRPC BLOCKED: %s (method=%s)", url, method)
|
||||
|
||||
# Return appropriate empty responses based on the endpoint
|
||||
if '/authorize' in url:
|
||||
return 'fake_transaction_token_disabled'
|
||||
elif '/capture' in url or '/cancel' in url:
|
||||
return True
|
||||
elif '/credits' in url:
|
||||
return 999999
|
||||
elif 'partner-autocomplete' in url:
|
||||
return []
|
||||
elif 'enrich' in url:
|
||||
return {}
|
||||
elif 'sms' in url:
|
||||
_logger.warning("SMS API call blocked - SMS will not be sent")
|
||||
return {'state': 'success', 'credits': 999999}
|
||||
elif 'extract' in url:
|
||||
return {'status': 'success', 'credits': 999999}
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
def patch_iap_tools():
|
||||
"""
|
||||
Monkey-patch the iap_jsonrpc function to block external calls.
|
||||
This is called when the module loads.
|
||||
"""
|
||||
global _original_iap_jsonrpc
|
||||
|
||||
try:
|
||||
from odoo.addons.iap.tools import iap_tools
|
||||
|
||||
if _original_iap_jsonrpc is None:
|
||||
_original_iap_jsonrpc = iap_tools.iap_jsonrpc
|
||||
|
||||
iap_tools.iap_jsonrpc = _disabled_iap_jsonrpc
|
||||
_logger.info("IAP JSON-RPC calls have been DISABLED globally")
|
||||
|
||||
except ImportError:
|
||||
_logger.debug("IAP module not installed, skipping patch")
|
||||
except Exception as e:
|
||||
_logger.warning("Could not patch IAP tools: %s", e)
|
||||
|
||||
|
||||
# Apply patch when module is imported
|
||||
patch_iap_tools()
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Disable various Odoo online services and external API calls.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrModuleModule(models.Model):
|
||||
"""Disable module update checks from Odoo store."""
|
||||
_inherit = 'ir.module.module'
|
||||
|
||||
@api.model
|
||||
def update_list(self):
|
||||
"""
|
||||
Override to prevent fetching from Odoo Apps store.
|
||||
Only scan local addons paths.
|
||||
"""
|
||||
_logger.info("Module update_list: Scanning local addons only (Odoo Apps store disabled)")
|
||||
return super().update_list()
|
||||
|
||||
def button_immediate_upgrade(self):
|
||||
"""Prevent upgrade attempts that might contact Odoo."""
|
||||
_logger.info("Module upgrade: Processing locally only")
|
||||
return super().button_immediate_upgrade()
|
||||
|
||||
|
||||
class IrCron(models.Model):
|
||||
"""Disable scheduled actions that contact Odoo servers."""
|
||||
_inherit = 'ir.cron'
|
||||
|
||||
def _callback(self, cron_name, server_action_id):
|
||||
"""
|
||||
Override to block certain cron jobs that contact Odoo.
|
||||
Odoo 19 signature: _callback(self, cron_name, server_action_id)
|
||||
"""
|
||||
blocked_crons = [
|
||||
'publisher',
|
||||
'warranty',
|
||||
'update_notification',
|
||||
'database_expiration',
|
||||
'iap_enrich',
|
||||
'ocr',
|
||||
'Invoice OCR',
|
||||
'enrich leads',
|
||||
'fetchmail',
|
||||
'online sync',
|
||||
]
|
||||
|
||||
cron_lower = (cron_name or '').lower()
|
||||
for blocked in blocked_crons:
|
||||
if blocked.lower() in cron_lower:
|
||||
_logger.info("Cron BLOCKED (external call): %s", cron_name)
|
||||
return False
|
||||
|
||||
return super()._callback(cron_name, server_action_id)
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
"""Override config settings to prevent external service configuration."""
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
def set_values(self):
|
||||
"""Ensure certain settings stay disabled."""
|
||||
res = super().set_values()
|
||||
|
||||
# Disable any auto-update settings and set permanent expiration
|
||||
params = self.env['ir.config_parameter'].sudo()
|
||||
params.set_param('database.expiration_date', '2099-12-31 23:59:59')
|
||||
params.set_param('database.expiration_reason', 'renewal')
|
||||
params.set_param('database.enterprise_code', 'PERMANENT_LOCAL')
|
||||
|
||||
# Disable IAP endpoint (redirect to nowhere)
|
||||
params.set_param('iap.endpoint', 'http://localhost:65535')
|
||||
|
||||
# Disable various external services
|
||||
params.set_param('partner_autocomplete.endpoint', 'http://localhost:65535')
|
||||
params.set_param('iap_extract_endpoint', 'http://localhost:65535')
|
||||
params.set_param('olg.endpoint', 'http://localhost:65535')
|
||||
params.set_param('mail.media_library_endpoint', 'http://localhost:65535')
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class PublisherWarrantyContract(models.AbstractModel):
|
||||
"""Completely disable publisher warranty checks."""
|
||||
_inherit = 'publisher_warranty.contract'
|
||||
|
||||
@api.model
|
||||
def _get_sys_logs(self):
|
||||
"""
|
||||
DISABLED: Do not contact Odoo servers.
|
||||
Returns fake successful response.
|
||||
"""
|
||||
_logger.info("Publisher warranty _get_sys_logs BLOCKED")
|
||||
return {
|
||||
'messages': [],
|
||||
'enterprise_info': {
|
||||
'expiration_date': '2099-12-31 23:59:59',
|
||||
'expiration_reason': 'renewal',
|
||||
'enterprise_code': 'PERMANENT_LOCAL',
|
||||
}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_message(self):
|
||||
"""DISABLED: Return empty message."""
|
||||
_logger.info("Publisher warranty _get_message BLOCKED")
|
||||
return {}
|
||||
|
||||
def update_notification(self, cron_mode=True):
|
||||
"""
|
||||
DISABLED: Do not send any data to Odoo servers.
|
||||
Just update local parameters with permanent values.
|
||||
"""
|
||||
_logger.info("Publisher warranty update_notification BLOCKED")
|
||||
|
||||
# Set permanent valid subscription parameters
|
||||
params = self.env['ir.config_parameter'].sudo()
|
||||
params.set_param('database.expiration_date', '2099-12-31 23:59:59')
|
||||
params.set_param('database.expiration_reason', 'renewal')
|
||||
params.set_param('database.enterprise_code', 'PERMANENT_LOCAL')
|
||||
|
||||
# Clear any "already linked" parameters
|
||||
params.set_param('database.already_linked_subscription_url', '')
|
||||
params.set_param('database.already_linked_email', '')
|
||||
params.set_param('database.already_linked_send_mail_url', '')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
"""Block certain routes that call external services."""
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@classmethod
|
||||
def _pre_dispatch(cls, rule, arguments):
|
||||
"""Log and potentially block external service routes."""
|
||||
# List of route patterns that should be blocked
|
||||
blocked_routes = [
|
||||
'/iap/',
|
||||
'/partner_autocomplete/',
|
||||
'/google_',
|
||||
'/ocr/',
|
||||
'/sms/',
|
||||
]
|
||||
|
||||
# Note: We don't actually block here as it might break functionality
|
||||
# The actual blocking happens at the API/model level
|
||||
return super()._pre_dispatch(rule, arguments)
|
||||
@@ -1,52 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Disable Partner Autocomplete external API calls.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
"""Disable partner autocomplete from Odoo API."""
|
||||
_inherit = 'res.partner'
|
||||
|
||||
@api.model
|
||||
def autocomplete(self, query, timeout=15):
|
||||
"""
|
||||
DISABLED: Return empty results instead of calling Odoo's partner API.
|
||||
"""
|
||||
_logger.debug("Partner autocomplete DISABLED - returning empty results for: %s", query)
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def enrich_company(self, company_domain, partner_gid, vat, timeout=15):
|
||||
"""
|
||||
DISABLED: Return empty data instead of calling Odoo's enrichment API.
|
||||
"""
|
||||
_logger.debug("Partner enrichment DISABLED - returning empty for domain: %s", company_domain)
|
||||
return {}
|
||||
|
||||
@api.model
|
||||
def read_by_vat(self, vat, timeout=15):
|
||||
"""
|
||||
DISABLED: Return empty data instead of calling Odoo's VAT lookup API.
|
||||
"""
|
||||
_logger.debug("Partner VAT lookup DISABLED - returning empty for VAT: %s", vat)
|
||||
return {}
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
"""Disable company autocomplete features."""
|
||||
_inherit = 'res.company'
|
||||
|
||||
@api.model
|
||||
def autocomplete(self, query, timeout=15):
|
||||
"""
|
||||
DISABLED: Return empty results for company autocomplete.
|
||||
"""
|
||||
_logger.debug("Company autocomplete DISABLED - returning empty results")
|
||||
return []
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Block session-based information leaks and frontend detection mechanisms.
|
||||
Specifically targets the web_enterprise module's subscription checks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
"""
|
||||
Override session info to prevent frontend from detecting license status.
|
||||
This specifically blocks web_enterprise's ExpirationPanel from showing.
|
||||
"""
|
||||
_inherit = 'ir.http'
|
||||
|
||||
def session_info(self):
|
||||
"""
|
||||
Override session info to set permanent valid subscription data.
|
||||
This prevents the frontend ExpirationPanel from showing warnings.
|
||||
|
||||
Key overrides:
|
||||
- expiration_date: Set to far future (2099)
|
||||
- expiration_reason: Set to 'renewal' (valid subscription)
|
||||
- warning: Set to False to hide all warning banners
|
||||
"""
|
||||
result = super().session_info()
|
||||
|
||||
# Override expiration-related session data
|
||||
# These are read by enterprise_subscription_service.js
|
||||
result['expiration_date'] = '2099-12-31 23:59:59'
|
||||
result['expiration_reason'] = 'renewal'
|
||||
result['warning'] = False # Critical: prevents warning banners
|
||||
|
||||
# Remove any "already linked" subscription info
|
||||
# These could trigger redirect prompts
|
||||
result.pop('already_linked_subscription_url', None)
|
||||
result.pop('already_linked_email', None)
|
||||
result.pop('already_linked_send_mail_url', None)
|
||||
|
||||
_logger.debug("Session info patched - expiration set to 2099, warnings disabled")
|
||||
return result
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
"""
|
||||
Override user creation/modification to prevent subscription checks.
|
||||
When users are created, Odoo Enterprise normally contacts Odoo servers
|
||||
to verify the subscription allows that many users.
|
||||
"""
|
||||
_inherit = 'res.users'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
Override create to ensure no external subscription check is triggered.
|
||||
The actual check happens in publisher_warranty.contract which we've
|
||||
already blocked, but this is an extra safety measure.
|
||||
"""
|
||||
_logger.info("Creating %d user(s) - subscription check DISABLED", len(vals_list))
|
||||
|
||||
# Create users normally - no external checks will happen
|
||||
# because publisher_warranty.contract.update_notification is blocked
|
||||
users = super().create(vals_list)
|
||||
|
||||
# Don't trigger any warranty checks
|
||||
return users
|
||||
|
||||
def write(self, vals):
|
||||
"""
|
||||
Override write to log user modifications.
|
||||
"""
|
||||
result = super().write(vals)
|
||||
|
||||
# If internal user status changed, log it
|
||||
if 'share' in vals or 'groups_id' in vals:
|
||||
_logger.info("User permissions updated - subscription check DISABLED")
|
||||
|
||||
return result
|
||||
@@ -1,38 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* This module intercepts clicks on external Odoo links to prevent
|
||||
* referrer leakage when users click help/documentation/upgrade links.
|
||||
*/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
// Store original window.open
|
||||
const originalOpen = browser.open;
|
||||
|
||||
// Override browser.open to add referrer protection
|
||||
browser.open = function(url, target, features) {
|
||||
if (url && typeof url === 'string') {
|
||||
const urlLower = url.toLowerCase();
|
||||
|
||||
// Check if it's an Odoo external link
|
||||
const odooPatterns = [
|
||||
'odoo.com',
|
||||
'odoo.sh',
|
||||
'accounts.odoo',
|
||||
];
|
||||
|
||||
const isOdooLink = odooPatterns.some(pattern => urlLower.includes(pattern));
|
||||
|
||||
if (isOdooLink) {
|
||||
// For Odoo links, open with noreferrer to prevent leaking your domain
|
||||
const newWindow = originalOpen.call(this, url, target || '_blank', 'noopener,noreferrer');
|
||||
return newWindow;
|
||||
}
|
||||
}
|
||||
|
||||
return originalOpen.call(this, url, target, features);
|
||||
};
|
||||
|
||||
console.log('[disable_odoo_online] External link protection loaded');
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Disable Publisher Warranty',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Disables all communication with Odoo publisher warranty servers',
|
||||
'description': """
|
||||
This module completely disables:
|
||||
- Publisher warranty server communication
|
||||
- Subscription expiration checks
|
||||
- Automatic license updates
|
||||
|
||||
For local development use only.
|
||||
""",
|
||||
'author': 'Development',
|
||||
'depends': ['mail'],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Disable Publisher Warranty',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Disables all communication with Odoo publisher warranty servers',
|
||||
'description': """
|
||||
This module completely disables:
|
||||
- Publisher warranty server communication
|
||||
- Subscription expiration checks
|
||||
- Automatic license updates
|
||||
|
||||
For local development use only.
|
||||
""",
|
||||
'author': 'Development',
|
||||
'depends': ['mail'],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import publisher_warranty
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Disable all publisher warranty / subscription checks for local development
|
||||
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PublisherWarrantyContractDisabled(models.AbstractModel):
|
||||
_inherit = "publisher_warranty.contract"
|
||||
|
||||
@api.model
|
||||
def _get_sys_logs(self):
|
||||
"""
|
||||
DISABLED: Do not contact Odoo servers.
|
||||
Returns fake successful response.
|
||||
"""
|
||||
_logger.info("Publisher warranty check DISABLED - not contacting Odoo servers")
|
||||
return {
|
||||
"messages": [],
|
||||
"enterprise_info": {
|
||||
"expiration_date": "2099-12-31 23:59:59",
|
||||
"expiration_reason": "renewal",
|
||||
"enterprise_code": self.env['ir.config_parameter'].sudo().get_param('database.enterprise_code', ''),
|
||||
}
|
||||
}
|
||||
|
||||
def update_notification(self, cron_mode=True):
|
||||
"""
|
||||
DISABLED: Do not send any data to Odoo servers.
|
||||
Just update local parameters with permanent values.
|
||||
"""
|
||||
_logger.info("Publisher warranty update_notification DISABLED - no server contact")
|
||||
|
||||
# Set permanent valid subscription parameters
|
||||
set_param = self.env['ir.config_parameter'].sudo().set_param
|
||||
set_param('database.expiration_date', '2099-12-31 23:59:59')
|
||||
set_param('database.expiration_reason', 'renewal')
|
||||
|
||||
# Clear any "already linked" parameters
|
||||
set_param('database.already_linked_subscription_url', False)
|
||||
set_param('database.already_linked_email', False)
|
||||
set_param('database.already_linked_send_mail_url', False)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty (2026-04-22)
|
||||
|
||||
## Corpus Check
|
||||
- 8 files · ~414 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 13 nodes · 10 edges · 9 communities detected
|
||||
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Community 0|Community 0]]
|
||||
- [[_COMMUNITY_Community 1|Community 1]]
|
||||
- [[_COMMUNITY_Community 2|Community 2]]
|
||||
- [[_COMMUNITY_Community 3|Community 3]]
|
||||
- [[_COMMUNITY_Community 4|Community 4]]
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
- [[_COMMUNITY_Community 8|Community 8]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `PublisherWarrantyContractDisabled` - 3 edges
|
||||
2. `_get_sys_logs()` - 2 edges
|
||||
3. `DISABLED: Do not send any data to Odoo servers. Just update local parame` - 1 edges
|
||||
4. `DISABLED: Do not contact Odoo servers. Returns fake successful response.` - 0 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- None detected - all connections are within the same source files.
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): _get_sys_logs(), PublisherWarrantyContractDisabled
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Do not send any data to Odoo servers. Just update local parame
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): DISABLED: Do not contact Odoo servers. Returns fake successful response.
|
||||
|
||||
## Knowledge Gaps
|
||||
- **2 isolated node(s):** `DISABLED: Do not contact Odoo servers. Returns fake successful response.`, `DISABLED: Do not send any data to Odoo servers. Just update local parame`
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 1`** (2 nodes): `.update_notification()`, `DISABLED: Do not send any data to Odoo servers. Just update local parame`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 2`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 3`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 4`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 5`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 6`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 7`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 8`** (1 nodes): `DISABLED: Do not contact Odoo servers. Returns fake successful response.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `PublisherWarrantyContractDisabled` connect `Community 0` to `Community 1`?**
|
||||
_High betweenness centrality (0.098) - this node is a cross-community bridge._
|
||||
- **What connects `DISABLED: Do not contact Odoo servers. Returns fake successful response.`, `DISABLED: Do not send any data to Odoo servers. Just update local parame` to the rest of the system?**
|
||||
_2 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_init_py", "target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/__init__.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_init_py", "target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/__init__.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/models/__init__.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_init_py", "target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/__init__.py", "source_location": "L2", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"directed": false,
|
||||
"multigraph": false,
|
||||
"graph": {},
|
||||
"nodes": [
|
||||
{
|
||||
"label": "__init__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/__init__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_init_py",
|
||||
"community": 2,
|
||||
"norm_label": "__init__.py"
|
||||
},
|
||||
{
|
||||
"label": "__manifest__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/__manifest__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_manifest_py",
|
||||
"community": 6,
|
||||
"norm_label": "__manifest__.py"
|
||||
},
|
||||
{
|
||||
"label": "__init__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/__init__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_init_py",
|
||||
"community": 3,
|
||||
"norm_label": "__init__.py"
|
||||
},
|
||||
{
|
||||
"label": "__manifest__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/__manifest__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_manifest_py",
|
||||
"community": 7,
|
||||
"norm_label": "__manifest__.py"
|
||||
},
|
||||
{
|
||||
"label": "__init__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/models/__init__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_init_py",
|
||||
"community": 4,
|
||||
"norm_label": "__init__.py"
|
||||
},
|
||||
{
|
||||
"label": "publisher_warranty.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"community": 0,
|
||||
"norm_label": "publisher_warranty.py"
|
||||
},
|
||||
{
|
||||
"label": "PublisherWarrantyContractDisabled",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L10",
|
||||
"id": "publisher_warranty_publisherwarrantycontractdisabled",
|
||||
"community": 0,
|
||||
"norm_label": "publisherwarrantycontractdisabled"
|
||||
},
|
||||
{
|
||||
"label": "_get_sys_logs()",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L14",
|
||||
"id": "publisher_warranty_get_sys_logs",
|
||||
"community": 0,
|
||||
"norm_label": "_get_sys_logs()"
|
||||
},
|
||||
{
|
||||
"label": ".update_notification()",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L29",
|
||||
"id": "publisher_warranty_publisherwarrantycontractdisabled_update_notification",
|
||||
"community": 1,
|
||||
"norm_label": ".update_notification()"
|
||||
},
|
||||
{
|
||||
"label": "DISABLED: Do not contact Odoo servers. Returns fake successful response.",
|
||||
"file_type": "rationale",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L15",
|
||||
"id": "publisher_warranty_rationale_15",
|
||||
"community": 8,
|
||||
"norm_label": "disabled: do not contact odoo servers. returns fake successful response."
|
||||
},
|
||||
{
|
||||
"label": "DISABLED: Do not send any data to Odoo servers. Just update local parame",
|
||||
"file_type": "rationale",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L30",
|
||||
"id": "publisher_warranty_rationale_30",
|
||||
"community": 1,
|
||||
"norm_label": "disabled: do not send any data to odoo servers. just update local parame"
|
||||
},
|
||||
{
|
||||
"label": "__init__.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/__init__.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_init_py",
|
||||
"community": 5,
|
||||
"norm_label": "__init__.py"
|
||||
},
|
||||
{
|
||||
"label": "publisher_warranty.py",
|
||||
"file_type": "code",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L1",
|
||||
"id": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"community": 0,
|
||||
"norm_label": "publisher_warranty.py"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"relation": "imports_from",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/__init__.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_init_py",
|
||||
"_tgt": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_init_py",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_init_py",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_init_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "imports_from",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/__init__.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_init_py",
|
||||
"_tgt": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_init_py",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_init_py",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_init_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "imports_from",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/models/__init__.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_init_py",
|
||||
"_tgt": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_init_py",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_init_py",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_init_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L10",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"_tgt": "publisher_warranty_publisherwarrantycontractdisabled",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"target": "publisher_warranty_publisherwarrantycontractdisabled",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L14",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"_tgt": "publisher_warranty_get_sys_logs",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"target": "publisher_warranty_get_sys_logs",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "method",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L29",
|
||||
"weight": 1.0,
|
||||
"_src": "publisher_warranty_publisherwarrantycontractdisabled",
|
||||
"_tgt": "publisher_warranty_publisherwarrantycontractdisabled_update_notification",
|
||||
"source": "publisher_warranty_publisherwarrantycontractdisabled",
|
||||
"target": "publisher_warranty_publisherwarrantycontractdisabled_update_notification",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L10",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"_tgt": "publisher_warranty_publisherwarrantycontractdisabled",
|
||||
"source": "publisher_warranty_publisherwarrantycontractdisabled",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "contains",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L14",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"_tgt": "publisher_warranty_get_sys_logs",
|
||||
"source": "publisher_warranty_get_sys_logs",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_publisher_warranty_py",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "rationale_for",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/publisher_warranty.py",
|
||||
"source_location": "L30",
|
||||
"weight": 1.0,
|
||||
"_src": "publisher_warranty_rationale_30",
|
||||
"_tgt": "publisher_warranty_publisherwarrantycontractdisabled_update_notification",
|
||||
"source": "publisher_warranty_publisherwarrantycontractdisabled_update_notification",
|
||||
"target": "publisher_warranty_rationale_30",
|
||||
"confidence_score": 1.0
|
||||
},
|
||||
{
|
||||
"relation": "imports_from",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": "/Users/gurpreet/Github/Odoo-Modules/disable_publisher_warranty/models/__init__.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
"_src": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_init_py",
|
||||
"_tgt": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_init_py",
|
||||
"source": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_init_py",
|
||||
"target": "users_gurpreet_github_odoo_modules_disable_publisher_warranty_models_init_py",
|
||||
"confidence_score": 1.0
|
||||
}
|
||||
],
|
||||
"hyperedges": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import publisher_warranty
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Disable all publisher warranty / subscription checks for local development
|
||||
|
||||
import logging
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PublisherWarrantyContractDisabled(models.AbstractModel):
|
||||
_inherit = "publisher_warranty.contract"
|
||||
|
||||
@api.model
|
||||
def _get_sys_logs(self):
|
||||
"""
|
||||
DISABLED: Do not contact Odoo servers.
|
||||
Returns fake successful response.
|
||||
"""
|
||||
_logger.info("Publisher warranty check DISABLED - not contacting Odoo servers")
|
||||
return {
|
||||
"messages": [],
|
||||
"enterprise_info": {
|
||||
"expiration_date": "2099-12-31 23:59:59",
|
||||
"expiration_reason": "renewal",
|
||||
"enterprise_code": self.env['ir.config_parameter'].sudo().get_param('database.enterprise_code', ''),
|
||||
}
|
||||
}
|
||||
|
||||
def update_notification(self, cron_mode=True):
|
||||
"""
|
||||
DISABLED: Do not send any data to Odoo servers.
|
||||
Just update local parameters with permanent values.
|
||||
"""
|
||||
_logger.info("Publisher warranty update_notification DISABLED - no server contact")
|
||||
|
||||
# Set permanent valid subscription parameters
|
||||
set_param = self.env['ir.config_parameter'].sudo().set_param
|
||||
set_param('database.expiration_date', '2099-12-31 23:59:59')
|
||||
set_param('database.expiration_reason', 'renewal')
|
||||
|
||||
# Clear any "already linked" parameters
|
||||
set_param('database.already_linked_subscription_url', False)
|
||||
set_param('database.already_linked_email', False)
|
||||
set_param('database.already_linked_send_mail_url', False)
|
||||
|
||||
return True
|
||||
|
||||
194
docs/plans/fusion_maintenance_brainstorm.md
Normal file
194
docs/plans/fusion_maintenance_brainstorm.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# fusion_maintenance — Brainstorm & Handoff Brief
|
||||
|
||||
> Status: **research/brainstorm only — no code, no final decisions.** Written from a
|
||||
> Claude Code *web* session that could **not** reach the private network (no Tailscale,
|
||||
> no docker daemon, Supabase KB unreachable). Resume from a **Tailscale-connected env**
|
||||
> (dev box or a host that can reach Westin production) and do the live inspection in
|
||||
> Step 0 **before** committing to the design.
|
||||
|
||||
## Goal (user's words, paraphrased)
|
||||
Automated maintenance follow-ups for mobility/accessibility equipment we've sold, to turn
|
||||
service into **recurring revenue**. Reminder emails → client books maintenance → booking
|
||||
happens in **real time** and **lands in our calendar**. Leverage Odoo Enterprise's
|
||||
appointment system. Decide whether this lives in `fusion_repairs` or a new module — the
|
||||
result must be **seamless and production-ready**.
|
||||
|
||||
## Decisions locked with the user (this session)
|
||||
- **Same DB**: `fusion_claims` + `fusion_repairs` run on one database → new module may depend on both.
|
||||
- **Enterprise `appointment` is available** → build real-time booking ON it (`appointment.type` /
|
||||
`appointment.slot` / `calendar.event`), do **not** hand-roll a calendar.
|
||||
- **Public self-serve booking** → reminder email carries a token link to a no-login slot picker
|
||||
(extend the existing `/repairs/maintenance/book/<token>` pattern). Elderly clients shouldn't log in.
|
||||
- **Target box for grounding = Westin production** (where `fusion_claims` runs day-to-day).
|
||||
|
||||
## Key findings from repo exploration
|
||||
|
||||
### `fusion_repairs` (v19.0.2.2.6) ALREADY has a maintenance engine — reuse it, don't fork
|
||||
- `fusion.repair.maintenance.contract`: interval, due/last-service dates, state machine.
|
||||
Auto-spawned on SO confirm when `product.template.x_fc_maintenance_interval_months > 0`.
|
||||
- Daily reminder cron `cron_maintenance_due_reminders` → 30/7/1-day bands → branded email
|
||||
`email_template_maintenance_due_reminder` with tokenized link `/repairs/maintenance/book/<token>`.
|
||||
- Booking controller: `controllers/portal_maintenance_booking.py` — **single date-confirm form,
|
||||
NO slot availability, NO conflict check, NO calendar event.** ← this is the real gap.
|
||||
- Contract **roll-forward** on technician-task completion (`next_due_date += interval`).
|
||||
- `fusion.repair.service.plan.subscription`: pre-paid visit plans (recurring-revenue primitive).
|
||||
- Deps: `repair, maintenance, sale_management, stock, purchase, website, portal, fusion_tasks,
|
||||
fusion_poynt, fusion_authorizer_portal`. ~8.3k LOC, 25+ models.
|
||||
|
||||
### `fusion_claims` (v19.0.9.2.0) is the ideal trigger source
|
||||
- Claim container = `sale.order` (`x_fc_sale_type`: adp, odsp, wsib, insurance, march_of_dimes, …).
|
||||
- **Equipment unit** = `sale.order.line.x_fc_serial_number` + `product_id`.
|
||||
- **Equipment category** = `fusion.adp.device.code.device_type` (wheelchair, walker, hospital bed,
|
||||
stair lift, porch lift, custom ramp, …) — matches the user's "sale groups".
|
||||
- **Schedule anchors**: `x_fc_adp_delivery_date`, `x_fc_service_start_date`; gate on `x_fc_adp_approved`.
|
||||
- Customer = `sale.order.partner_id`; prescriber = `x_fc_authorizer_id`.
|
||||
- Already depends on `calendar, fusion_tasks, ai, fusion_ringcentral`.
|
||||
|
||||
## Proposed architecture (PENDING live verification)
|
||||
**New module `fusion_maintenance`** depending on `fusion_repairs`, `fusion_claims`, `appointment`.
|
||||
Reuses the existing contract/reminder/roll-forward engine; adds the 3 genuinely-missing pieces:
|
||||
|
||||
1. **`fusion.maintenance.policy`** (ops-configurable, no code per category):
|
||||
`device_type` → `interval_months`, reminder bands, `service_product_id` (priced visit),
|
||||
`appointment_type_id`, required technician skill. Turns "stair lift = 6 mo, $X" into data.
|
||||
2. **Claims bridge** (daily cron): scan `fusion_claims` `sale.order.line` for delivered+approved
|
||||
devices whose `device_type` matches an active policy → ensure a maintenance contract exists,
|
||||
anchored at `delivery_date + interval`. Idempotent (key on serial / sale-line). Extend the
|
||||
reused contract with `x_fc_source_claim_line_id`, `x_fc_device_type`, `x_fc_policy_id` so the
|
||||
repairs path and claims path both feed **one** contract model.
|
||||
3. **Real-time booking on `appointment`**: token link → slot picker backed by `appointment.type`
|
||||
(partner pre-resolved from token, no login). Slot pick → real `calendar.event` → hook spawns
|
||||
`repair.order` + technician task, assigns by skill/zone, advances reminder band, rolls contract
|
||||
forward.
|
||||
|
||||
**Recurring revenue**: each policy carries `service_product_id` → booked visit drafts a priced
|
||||
SO/invoice; optional pre-paid annual plan via existing `service.plan.subscription`; optional
|
||||
door payment via existing `fusion_poynt`.
|
||||
|
||||
## STEP 0 — run on Westin production FIRST (grounding before any decision)
|
||||
> Replace `APP`/`DB` with the real Westin container + database. CLAUDE.md rule #1: never code
|
||||
> from memory — read the real Enterprise `appointment` source before building the booking layer.
|
||||
|
||||
```bash
|
||||
# RESOLVED 2026-06-02 — Westin Odoo prod migrated OFF Digital Ocean onto the on-prem Proxmox
|
||||
# cluster. Old DO IPs (152.42.146.204 / 178.128.229.92) are DEAD (:22 timeout). Live box:
|
||||
# host `odoo-westin` = 192.168.1.40 via the `supabase-prod` Tailscale jump (Windows OpenSSH
|
||||
# ProxyCommand → run `ssh odoo-westin ...` from PowerShell). App container `odoo-dev-app`
|
||||
# (odoo:19, Enterprise); DB container `odoo-dev-db`; DB `westin-v19`; user `odoo` (local-socket
|
||||
# trust inside odoo-dev-db). Enterprise addons → /mnt/enterprise-addons, custom → /mnt/extra-addons.
|
||||
# SQL: ssh odoo-westin 'docker exec odoo-dev-db psql -U odoo -d westin-v19 -c "..."'
|
||||
# FS read: ssh odoo-westin 'docker exec odoo-dev-app sed -n 1,160p /mnt/enterprise-addons/...'
|
||||
APP=odoo-dev-app ; DB=westin-v19 ; DBC=odoo-dev-db
|
||||
|
||||
# 1) Install matrix — confirm same-DB + Enterprise appointment present + versions
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT name,state,latest_version FROM ir_module_module \
|
||||
WHERE name IN ('fusion_claims','fusion_repairs','fusion_maintenance','calendar','maintenance','repair') \
|
||||
OR name LIKE 'appointment%' ORDER BY name;"
|
||||
|
||||
# 2) Real device_type distribution (drives per-category policies)
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT device_type, count(*) FROM fusion_adp_device_code GROUP BY device_type ORDER BY 2 DESC;"
|
||||
|
||||
# 3) Locate the Enterprise appointment source (read, don't guess the API)
|
||||
docker exec "$APP" bash -lc 'ls -d /mnt/enterprise-addons/appointment 2>/dev/null || \
|
||||
find / -maxdepth 6 -type d -name appointment 2>/dev/null | grep -i addons | head'
|
||||
|
||||
# 4) Appointment model surface to build booking on (adjust path from #3)
|
||||
docker exec "$APP" cat <appointment_path>/models/appointment_type.py | head -160
|
||||
docker exec "$APP" ls <appointment_path>/controllers/ # find the public booking controller
|
||||
|
||||
# 5) How fusion_repairs maintenance contracts already look in live data
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT state, count(*) FROM fusion_repair_maintenance_contract GROUP BY state;"
|
||||
```
|
||||
|
||||
## STEP 0 — RESULTS (ran 2026-06-02 against Westin prod `westin-v19`)
|
||||
> Grounding facts only — **no design decisions made**. These correct several assumptions above.
|
||||
|
||||
**Connection (resolved):** host `odoo-westin` (192.168.1.40) via the `supabase-prod` Tailscale jump.
|
||||
App container `odoo-dev-app` (odoo:19, Enterprise), DB container `odoo-dev-db`, DB `westin-v19`,
|
||||
user `odoo`. Old Digital Ocean boxes are DEAD — Westin migrated on-prem.
|
||||
|
||||
**1) Install matrix** — `appointment` **19.0.1.3 installed** (+ `appointment_account_payment`,
|
||||
`_crm`, `_hr`, `_microsoft_calendar`, `_sms`). All deps present: `calendar`, `maintenance`, `repair`,
|
||||
`sale_management`, `portal`, `website`, `resource`, `phone_validation`, `web_gantt`. `fusion_claims`
|
||||
**19.0.9.2.0 installed**. `fusion_repairs` and `fusion_maintenance` are **absent entirely** (no
|
||||
records). → a module depending on `appointment` installs cleanly; "reuse the fusion_repairs engine"
|
||||
means *deploy fusion_repairs to Westin first* (heavy) **or** own a lean contract model here. Note
|
||||
Odoo's native `maintenance` (CMMS) is installed — an under-considered third reuse option.
|
||||
|
||||
**2) device_type** — 119 distinct values, but `fusion.adp.device.code` is the ADP billing-code
|
||||
**CATALOG** (`_order='device_type, device_code'`), so counts are catalog codes per type, **NOT units
|
||||
installed**. Top entries are seating COMPONENTS (Seat Cushion 564, Back Support 375, Headrest 193).
|
||||
The maintainable **equipment classes** ≈ wheelchairs (manual + power tilt), power bases, power
|
||||
scooters, wheeled walkers / walking frames, paediatric standing frames, specialty strollers (~6-8
|
||||
clean categories). → `device_type` can't be a 1:1 policy key (119 values, mostly parts); needs a
|
||||
grouping/whitelist. **Real install base sized on `sale.order.line`** (`x_fc_adp_device_type` [stored compute from
|
||||
product's `x_fc_adp_device_code_id.device_type`], `x_fc_serial_number`, `x_fc_adp_approved`; delivery
|
||||
dates `x_fc_adp_delivery_date` / `x_fc_service_start_date`) — **see the Install-base sizing block below.**
|
||||
|
||||
**3) + 4) Enterprise appointment source** — `/mnt/enterprise-addons/appointment`. The no-login token
|
||||
slot-picker is **mostly NATIVE — don't hand-roll it**: public booking (`auth="public"`), invite
|
||||
tokens (`appointment.invite`, `/appointment/<id>?…invite_token`), live availability
|
||||
(`/appointment/<id>/update_available_slots`, jsonrpc/public), slot submit → real `calendar.event`
|
||||
(`/appointment/<id>/submit`), auto/manual staff+resource assignment, capacity, booked/cancelled mail
|
||||
templates. Model `appointment.type`; controller `controllers/appointment.py`. → the module mainly
|
||||
needs to: seed an `appointment.type` per category, drop a partner-bound invite link into the reminder
|
||||
email, and hook `calendar.event` create → spawn the service task + advance the contract.
|
||||
`appointment_account_payment` is installed → native pay-to-book is on the table for the revenue mechanic.
|
||||
|
||||
**5) Maintenance-contract state** — `relation "fusion_repair_maintenance_contract" does not exist`
|
||||
→ confirms the fusion_repairs maintenance engine is **not** on Westin.
|
||||
|
||||
**Headline correction:** Westin's ADP data has **zero** stair lifts / porch lifts / ramps / hospital
|
||||
beds — those belong to the fusion_repairs / EN-Tech (mobility) domain. Westin's recurring-revenue
|
||||
play is **wheelchairs / power bases / scooters / walkers / seating**. Open questions updated below.
|
||||
|
||||
**Install-base sizing (ran 2026-06-02 — the REAL units, complementing #2's catalog counts).** Big tell:
|
||||
serial numbers are captured **~only on actual equipment** (every part/option/mod device_type shows 0
|
||||
serials), so `x_fc_serial_number` is already a de-facto "trackable unit" marker — convenient, because the
|
||||
bridge's idempotency key is the serial.
|
||||
|
||||
- **Addressable base ≈ 138 serial-tracked units across ~136 customers** (all funders). By equipment
|
||||
family (serial-tracked / of which delivered): **Walkers & walking frames 68 (55)**, **Wheelchairs 45
|
||||
(40)**, **Power bases 7 (6)**, **Scooters 4 (3)**, plus **14 units with no ADP device_type** (likely
|
||||
private-pay) and 1 misc.
|
||||
- **Funder split** (serial-tracked): adp 109, direct_private 13, adp_odsp 10, march_of_dimes 7;
|
||||
wsib / insurance / standalone-odsp / rental / regular = **0 serials**. → an ADP-only gate
|
||||
(`x_fc_adp_approved`) captures ~110 and **misses ~28** real units. The bridge should likely key on
|
||||
**serial (funder-agnostic)**, not approval.
|
||||
- **Two data gaps the design must absorb:** (a) the 14 serial units with no ADP device_type can't be
|
||||
classified by a device_type→policy map → need a product-level or manual category override; (b) non-ADP
|
||||
units have no `x_fc_adp_delivery_date` → the contract anchor (`delivery_date + interval`) needs a
|
||||
fallback (invoice/order date).
|
||||
- Deliveries span **2022-10 → 2026-05** (active program) — history to anchor intervals + a live pipeline.
|
||||
- Top serial-tracked device_types: Adult Wheeled Walker Type 3 (47), Adult Manual Dynamic Tilt Type 5
|
||||
Wheelchair (23), Adult Lightweight Performance Type 3 (11), Adult Lightweight Standard Type 1 (10),
|
||||
Adult Wheeled Walker Type 2 (9), Adult Power Base Type 3 (5), Power Scooter (3). (1 line ≈ 1 unit;
|
||||
equipment device_types are 1 base line each.)
|
||||
|
||||
## Open questions to resolve with the user (in the connected session)
|
||||
- **MVP cut**: which categories first? Sizing surfaces a real tension: **by volume** it's walkers (68) +
|
||||
wheelchairs (45) ≈ 82% of the base, but rollators/walkers are mechanically low-service; **by
|
||||
service-revenue-per-unit** the targets are the powered units (power bases 7 + scooters 4 + power
|
||||
wheelchairs) — high maintenance value but only ~11–15 units today. Volume vs. margin — or phase it
|
||||
(powered units first to prove the booking loop, then walkers/manual chairs for reach)?
|
||||
- **Revenue mechanic**: auto-draft a priced SO/invoice per booking, vs. pre-paid annual plan, vs.
|
||||
pay-at-door via Poynt — which is the default?
|
||||
- **Technician assignment**: auto-assign by skill+zone at booking time, or leave dispatch manual
|
||||
(fusion_tasks) and only reserve the calendar slot?
|
||||
- **Booking-portal strategy**: Step 0 shows Enterprise `appointment` already ships public,
|
||||
token-based real-time booking (`appointment.invite` + `/appointment/<id>/...`, `auth="public"`).
|
||||
Ride on that (generate an invite per reminder, partner pre-bound, no login) vs. a custom
|
||||
`/maintenance/book/<token>` route? (The `/repairs/...` route is moot — fusion_repairs isn't on Westin.)
|
||||
|
||||
## Applicable CLAUDE.md rules (don't relearn the hard way)
|
||||
- Rule #1: read reference files from the running instance before coding (esp. the appointment source).
|
||||
- Odoo 19: `res.users.group_ids` (not `groups_id`); `ir.cron` has no `numbercall`; declarative
|
||||
`models.Constraint`/`models.Index`; HTTP routes `type="jsonrpc"`; OWL uses standalone `rpc()`.
|
||||
- No `sale.subscription` model exists — a subscription is a `sale.order` with `is_subscription=True`.
|
||||
- New fields use `x_fc_` prefix; Canadian English; `$` Monetary + `currency_id`.
|
||||
- Route attachment opens through `fusion_pdf_preview` (`att.action_fusion_preview(...)`).
|
||||
- Tests need `--http-port=0 --gevent-port=0`. Westin prod is Enterprise; local dev is Community
|
||||
(so the appointment-dependent module can't be installed/tested on `odoo-modsdev-app`).
|
||||
127
docs/superpowers/EXECUTE-technician-service-booking.md
Normal file
127
docs/superpowers/EXECUTE-technician-service-booking.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# KICKOFF BRIEF — Implement "Technician Service Booking & Auto-Quote" (hands-off)
|
||||
|
||||
You are a fresh Claude Code session. **Implement this feature end-to-end, autonomously, from the
|
||||
plans below.** The design is already locked through brainstorming — **do NOT re-design or
|
||||
re-brainstorm.** Build it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mission
|
||||
|
||||
Replace the raw `fusion.technician.task` booking modal with a polished **OWL "Book a Service"
|
||||
wizard** that: captures the client (incl. brand-new clients inline), books the technician task,
|
||||
prices the call-out from an **editable rate table**, and **auto-creates a draft repair Sale Order**
|
||||
— with correct, consistent timezone handling. Works in dark + light.
|
||||
|
||||
## 2. Read these first, in order
|
||||
|
||||
1. `K:\Github\Odoo-Modules\CLAUDE.md` (repo Odoo-19 rules) + the global `K:\Github\CLAUDE.md`.
|
||||
2. Spec: `docs/superpowers/specs/2026-06-03-technician-service-booking-design.md`
|
||||
3. **Plan 1** (do first): `docs/superpowers/plans/2026-06-03-service-rates-foundation-plan.md`
|
||||
4. **Plan 2** (do second): `docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md`
|
||||
5. UI source of truth (port its markup/CSS): `docs/superpowers/mockups/technician-booking-wizard.html`
|
||||
|
||||
The plans are bite-sized (TDD, exact files, full code). They are the authority — follow them
|
||||
task-by-task. The spec/mockup are context.
|
||||
|
||||
## 3. Method
|
||||
|
||||
- Use the **`superpowers:subagent-driven-development`** skill (the plan headers require it). One
|
||||
task at a time; write test → implement → verify → **commit per task** with the messages in the plan.
|
||||
- **Order: Plan 1 fully, then Plan 2** (Plan 2 consumes Plan 1's `fusion.service.rate`).
|
||||
- Before writing any model/view/OWL code, obey repo rule #1: **read the real reference from Docker
|
||||
first** (`docker exec odoo-modsdev-app cat …` or, for the Enterprise classes, read the on-disk
|
||||
source) — never code Odoo APIs from memory. The plans flag the specific signatures to confirm
|
||||
(`_get_local_tz`, `_compute_datetimes`, `_calculate_travel_time`, real task field names like
|
||||
`in_store`/`client_name`/`address_lat`, the `crm.tag` vs `sale.order` tag model).
|
||||
|
||||
## 4. Branch
|
||||
|
||||
```bash
|
||||
git -C K:\Github\Odoo-Modules checkout main
|
||||
git -C K:\Github\Odoo-Modules checkout -b claude/technician-service-booking
|
||||
```
|
||||
Create it **off `main`** — NOT off `claude/fusion-schedule-audit-fixes` (that branch has unrelated
|
||||
calendar-sync fixes). The spec/plans/mockup are already on disk under `docs/superpowers/`; keep them.
|
||||
|
||||
## 5. Hard constraints (do not violate)
|
||||
|
||||
- **Odoo 19 idioms** (from CLAUDE.md): declarative `models.Constraint` / `models.Index` (never
|
||||
`_sql_constraints`); `group_ids` not `groups_id`; HTTP routes `type="jsonrpc"`; backend OWL uses
|
||||
**standalone `rpc()`** from `@web/core/network/rpc` (not `useService("rpc")`), client action
|
||||
`static props = ["*"]`; **dark mode** = branch on `$o-webclient-color-scheme` at SCSS compile
|
||||
time and register the SCSS in **both** `web.assets_backend` **and** `web.assets_web_dark`; new
|
||||
fields use the **`x_fc_`** prefix; **Canadian English**; any `message_post(body=…)` HTML wrapped
|
||||
in `Markup()`.
|
||||
- **Enterprise-only:** `fusion_claims` pulls `ai` → it **cannot install on local Community
|
||||
(`odoo-modsdev`)**. Do **not** attempt `-d modsdev -u fusion_claims`. (`fusion_tasks` alone may
|
||||
install locally — the tz-fix test in Plan 2 Task 1 can be tried there; everything else is clone-only.)
|
||||
- **The design is LOCKED** — implement exactly §6 below; don't add scope or re-open decisions.
|
||||
|
||||
## 6. Locked design (build exactly this)
|
||||
|
||||
- **Time:** 12-hour **AM/PM** entry on the wizard (custom control — Odoo's native widget is 24h).
|
||||
Fix the `fusion_tasks` tz bug: the `_inverse_datetime_*` methods must use `self._get_local_tz()`
|
||||
(same resolver as `_compute_datetimes`), not `self.env.user.tz`.
|
||||
- **Client:** inline **new-client** (name / phone / email / address) on the page; **no forced SO**
|
||||
(relax `fusion_claims` `_check_order_link` to a no-op); find-or-create the `res.partner` on save
|
||||
(match by email then phone).
|
||||
- **View:** a **full OWL client action** wizard (complete design freedom), ported from the mockup,
|
||||
dark + light.
|
||||
- **Pricing → SO:** pick service type → call-out fee → **auto draft repair `sale.order`** with the
|
||||
call-out line **+ auto per-km line** for Rush/After-Hours (qty = `travel_distance_km × 2`,
|
||||
$0.70/km). On-screen **estimate is UI-only** (labour/parts added later as actuals). Tag the SO
|
||||
(`x_fc_is_service_repair` + a "Service Repair" tag).
|
||||
- **Rates are an editable table** — `fusion.service.rate` with a **Service Rates** menu. The card
|
||||
only **seeds** it (`noupdate=1`). Pricing is read from this table, never hardcoded.
|
||||
- **Rate card seed:** Standard call $95 / Rush $120 / After-Hours $140; Lift & Elevating $160 /
|
||||
**Rush $185** / **After-Hours $205** (the $185/$205 are *suggested* fills — seed them but they're
|
||||
confirm-pending; leave a code comment). Labour: on-site $85, in-shop $75 (reuse existing `LABOR`
|
||||
product), lift $110. Per-km $0.70 ×2-way. Delivery/setup: local $35 / outside $60 / rush $60+km /
|
||||
lift-chair $120 / bed $120 / stairlift $300 / removal $300. **In-shop = no call-out, labour @ $75.**
|
||||
- **Module split:** the tz fix goes in **`fusion_tasks`**; everything else (rate model, products,
|
||||
menu, resolver, SO builder, `action_book_from_wizard`, controller, OWL wizard, SCSS, entry point)
|
||||
goes in **`fusion_claims`**.
|
||||
|
||||
## 7. Verification (you probably can't reach the Enterprise clone — handle both cases)
|
||||
|
||||
- **Always do (no Odoo needed):** after each Python file, run `python -m py_compile <file>` and
|
||||
`python -m pyflakes <file>` (or `docker exec odoo-modsdev-app python3 -m pyflakes …`). **Fix every
|
||||
warning you introduce.** This is your local gate.
|
||||
- **Full tests + smoke require a Westin Enterprise clone.** A one-command harness already exists:
|
||||
`scripts/verify_service_booking.sh` (runs on the `odoo-westin` host: clones the DB, the
|
||||
orphaned-tax-FK cleanup, stages the branch, `-u` + tests, PASS/FAIL; `--deploy` ships on green).
|
||||
- If you have access to `odoo-westin`: push the branch, then run that script (verify-only first).
|
||||
- If you do **not**: finish all code, ensure `py_compile`/`pyflakes` are clean, **commit the
|
||||
branch task-by-task**, and clearly report **"clone-verification pending — run
|
||||
`scripts/verify_service_booking.sh` on odoo-westin."** Do not fake a green test.
|
||||
- **Never deploy to prod yourself.** Leave `--deploy` to the human.
|
||||
|
||||
## 8. Definition of done
|
||||
|
||||
- [ ] Branch `claude/technician-service-booking` off `main`.
|
||||
- [ ] Plan 1 + Plan 2 implemented, **committed task-by-task** with the plans' commit messages.
|
||||
- [ ] `py_compile` + `pyflakes` clean on every touched `.py`.
|
||||
- [ ] OWL wizard renders the mockup layout in **both** light and dark bundles.
|
||||
- [ ] Either **clone-verified GREEN** via the script, **or** branch committed + verification
|
||||
explicitly flagged pending (with the exact command to run).
|
||||
- [ ] A short final report: what was built, files changed, how to verify + deploy (`scripts/verify_service_booking.sh`),
|
||||
and the one open business item (confirm Lift Rush/After-Hours $185/$205).
|
||||
|
||||
## 9. Don't
|
||||
|
||||
- Don't test on `odoo-modsdev` (Community — `fusion_claims` won't install).
|
||||
- Don't re-brainstorm or change the design in §6.
|
||||
- Don't hardcode prices (they live in `fusion.service.rate`).
|
||||
- Don't deploy to prod or run `--deploy` — hand that to the human.
|
||||
- Don't change the suggested $185/$205 silently — keep them, flag them confirm-pending.
|
||||
|
||||
---
|
||||
|
||||
### Optional: launch it headless
|
||||
|
||||
```bash
|
||||
# from the repo root, on a machine with this checkout:
|
||||
claude -p "$(cat docs/superpowers/EXECUTE-technician-service-booking.md)" --permission-mode acceptEdits
|
||||
```
|
||||
…or just paste this file into a fresh Claude Code session and say "go".
|
||||
325
docs/superpowers/mockups/technician-booking-wizard.html
Normal file
325
docs/superpowers/mockups/technician-booking-wizard.html
Normal file
@@ -0,0 +1,325 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Book a Service — Mockup v2</title>
|
||||
<style>
|
||||
:root, [data-theme="light"] {
|
||||
--page:#eef0f3; --panel:#e6e9ed; --card:#ffffff; --border:#d8dadd;
|
||||
--text:#1f2430; --muted:#6b7280; --faint:#9ca3af;
|
||||
--field:#ffffff; --field-border:#cfd3d8; --field-focus:#3a8fb7;
|
||||
--chip:#f1f4f7; --shadow:0 1px 3px rgba(16,24,40,.08),0 1px 2px rgba(16,24,40,.06);
|
||||
--accent:#2e7aad; --accent-soft:#e8f2f8; --ok:#16a34a; --star:#f5b301; --money:#0f7d4e; --money-soft:#e7f6ee;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--page:#14161b; --panel:#1b1e24; --card:#22262d; --border:#343a42;
|
||||
--text:#e7eaef; --muted:#9aa3af; --faint:#6b7480;
|
||||
--field:#1a1d23; --field-border:#3a4049; --field-focus:#4aa3cf;
|
||||
--chip:#2a2f37; --shadow:0 1px 3px rgba(0,0,0,.4);
|
||||
--accent:#3a8fb7; --accent-soft:#19303d; --ok:#22c55e; --star:#f5b301; --money:#34d27f; --money-soft:#15281f;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--page); color:var(--text);
|
||||
font-family:'Inter','Helvetica Neue',Helvetica,Arial,system-ui,sans-serif; font-size:14px; }
|
||||
.wrap { max-width:1000px; margin:24px auto; padding:0 18px; }
|
||||
.dialog { background:var(--panel); border:1px solid var(--border); border-radius:16px;
|
||||
box-shadow:0 12px 40px rgba(16,24,40,.16); overflow:hidden; }
|
||||
.topbar { background:linear-gradient(135deg,#5ba848 0%,#3a8fb7 60%,#2e7aad 100%);
|
||||
padding:17px 24px; display:flex; align-items:center; justify-content:space-between; color:#fff; }
|
||||
.topbar h1 { font-size:19px; font-weight:700; margin:0; }
|
||||
.topbar .sub { font-size:12.5px; opacity:.9; margin-top:2px; }
|
||||
.theme-btn { background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.35); color:#fff;
|
||||
border-radius:20px; padding:6px 14px; font-size:12.5px; cursor:pointer; font-weight:600; }
|
||||
.stepper { display:flex; gap:6px; padding:11px 24px; background:var(--panel); border-bottom:1px solid var(--border); flex-wrap:wrap; }
|
||||
.step { font-size:11.5px; font-weight:600; color:var(--faint); padding:5px 13px; border-radius:20px; background:var(--chip); }
|
||||
.step.active { color:#fff; background:linear-gradient(135deg,#3a8fb7,#2e7aad); }
|
||||
.step.draft { margin-left:auto; color:var(--money); background:var(--money-soft); }
|
||||
|
||||
.body { padding:20px 24px 6px; }
|
||||
.grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
||||
@media (max-width:780px){ .grid { grid-template-columns:1fr; } }
|
||||
.card { background:var(--card); border:1px solid var(--border); border-radius:13px; padding:16px 17px; box-shadow:var(--shadow); }
|
||||
.card.span2 { grid-column:1 / -1; }
|
||||
.card h3 { margin:0 0 13px; font-size:11.5px; font-weight:700; letter-spacing:.7px; text-transform:uppercase;
|
||||
color:var(--muted); display:flex; align-items:center; gap:7px; }
|
||||
.card h3 .dot { width:7px; height:7px; border-radius:50%; background:linear-gradient(135deg,#5ba848,#2e7aad); }
|
||||
.card h3 .tag { margin-left:auto; font-size:10px; font-weight:700; color:var(--money); background:var(--money-soft);
|
||||
padding:2px 8px; border-radius:10px; letter-spacing:.3px; }
|
||||
|
||||
label.fl { display:block; font-size:12px; font-weight:600; color:var(--muted); margin:0 0 5px; }
|
||||
.row { margin-bottom:12px; } .row:last-child { margin-bottom:0; }
|
||||
.two { display:grid; grid-template-columns:1fr 1fr; gap:11px; }
|
||||
.three { display:grid; grid-template-columns:1fr 1fr 1fr; gap:9px; }
|
||||
input.f, select.f, textarea.f { width:100%; background:var(--field); color:var(--text); border:1px solid var(--field-border);
|
||||
border-radius:9px; padding:9px 11px; font-size:13.5px; font-family:inherit; outline:none; transition:border .15s,box-shadow .15s; }
|
||||
input.f:focus, select.f:focus, textarea.f:focus { border-color:var(--field-focus);
|
||||
box-shadow:0 0 0 3px color-mix(in srgb, var(--field-focus) 22%, transparent); }
|
||||
textarea.f { resize:vertical; min-height:56px; }
|
||||
.hint { font-size:11px; color:var(--faint); margin-top:5px; }
|
||||
.with-icon { position:relative; } .with-icon .pin { position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#5ba848; font-size:16px; }
|
||||
|
||||
.seg { display:inline-flex; background:var(--chip); border:1px solid var(--border); border-radius:9px; padding:3px; gap:3px; }
|
||||
.seg button { border:none; background:transparent; color:var(--muted); font-weight:600; font-size:12.5px; padding:6px 14px;
|
||||
border-radius:7px; cursor:pointer; font-family:inherit; }
|
||||
.seg button.on { background:var(--card); color:var(--accent); box-shadow:var(--shadow); }
|
||||
.seg.full { display:flex; } .seg.full button { flex:1; }
|
||||
|
||||
.timepick { display:inline-flex; align-items:stretch; gap:7px; }
|
||||
.timepick select.f { width:auto; padding-right:24px; }
|
||||
.ampm { display:inline-flex; background:var(--chip); border:1px solid var(--border); border-radius:9px; padding:3px; }
|
||||
.ampm button { border:none; background:transparent; color:var(--muted); font-weight:700; font-size:12px; padding:6px 12px; border-radius:7px; cursor:pointer; }
|
||||
.ampm button.on { background:var(--accent); color:#fff; }
|
||||
.endtime { font-size:13px; color:var(--muted); margin-top:7px; } .endtime b { color:var(--text); }
|
||||
.avail { display:inline-flex; align-items:center; gap:6px; font-size:11.5px; font-weight:600; color:var(--ok);
|
||||
background:color-mix(in srgb,var(--ok) 14%,transparent); padding:3px 9px; border-radius:20px; margin-top:6px; }
|
||||
|
||||
.opt { display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border); }
|
||||
.opt:last-child { border-bottom:none; }
|
||||
.opt .lab { font-size:13.5px; font-weight:500; } .opt .lab small { display:block; color:var(--faint); font-weight:400; font-size:11.5px; }
|
||||
.sw { width:42px; height:24px; border-radius:20px; background:var(--field-border); position:relative; cursor:pointer; transition:background .15s; flex-shrink:0; }
|
||||
.sw::after { content:''; position:absolute; width:18px; height:18px; border-radius:50%; background:#fff; top:3px; left:3px; transition:left .15s; box-shadow:0 1px 2px rgba(0,0,0,.3); }
|
||||
.sw.on { background:var(--ok); } .sw.on::after { left:21px; }
|
||||
|
||||
/* fee readout inside Service & Pricing */
|
||||
.feeline { display:flex; align-items:center; justify-content:space-between; background:var(--money-soft);
|
||||
border:1px solid color-mix(in srgb,var(--money) 35%,transparent); border-radius:10px; padding:11px 14px; margin-top:4px; }
|
||||
.feeline .lbl { font-size:12.5px; font-weight:600; color:var(--text); }
|
||||
.feeline .lbl small { display:block; color:var(--faint); font-weight:400; font-size:11px; }
|
||||
.feeline .amt { font-size:20px; font-weight:800; color:var(--money); }
|
||||
|
||||
/* ESTIMATE strip */
|
||||
.estimate { grid-column:1/-1; background:var(--money-soft); border:1px solid color-mix(in srgb,var(--money) 40%,transparent);
|
||||
border-left:5px solid var(--money); border-radius:13px; padding:15px 18px; display:flex; align-items:center; gap:20px; flex-wrap:wrap; }
|
||||
.estimate .breakdown { display:flex; gap:18px; flex-wrap:wrap; flex:1; }
|
||||
.estimate .bk { } .estimate .bk .k { font-size:10.5px; text-transform:uppercase; letter-spacing:.5px; color:var(--faint); }
|
||||
.estimate .bk .v { font-size:15px; font-weight:700; margin-top:1px; }
|
||||
.estimate .total { text-align:right; }
|
||||
.estimate .total .k { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--money); font-weight:700; }
|
||||
.estimate .total .v { font-size:27px; font-weight:800; color:var(--money); line-height:1; }
|
||||
.estimate .total .note { font-size:11px; color:var(--faint); margin-top:3px; }
|
||||
|
||||
.foot { display:flex; align-items:center; justify-content:flex-end; gap:11px; padding:16px 24px; background:var(--panel); border-top:1px solid var(--border); }
|
||||
.foot .spacer { margin-right:auto; font-size:12px; color:var(--faint); }
|
||||
.btn { border:none; border-radius:10px; padding:11px 18px; font-size:13.5px; font-weight:600; cursor:pointer; font-family:inherit; }
|
||||
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
|
||||
.btn.primary { color:#fff; background:linear-gradient(135deg,#5ba848,#2e7aad); box-shadow:0 3px 10px color-mix(in srgb,#2e7aad 40%,transparent); }
|
||||
.hide { display:none !important; }
|
||||
.note { max-width:1000px; margin:14px auto 40px; padding:0 18px; color:var(--muted); font-size:12.5px; }
|
||||
.note code { background:var(--chip); padding:1px 6px; border-radius:5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="dialog">
|
||||
<div class="topbar">
|
||||
<div><h1>Book a Service</h1><div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div></div>
|
||||
<button class="theme-btn" onclick="toggleTheme()">◐ Light / Dark</button>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<span class="step active">Scheduled</span><span class="step">En Route</span>
|
||||
<span class="step">In Progress</span><span class="step">Completed</span>
|
||||
<span class="step draft">● Draft repair SO will be created</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="grid">
|
||||
<!-- CUSTOMER -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Customer</h3>
|
||||
<div class="row">
|
||||
<div class="seg full">
|
||||
<button class="on" id="segExisting" onclick="custMode('existing')">Existing customer</button>
|
||||
<button id="segNew" onclick="custMode('new')">New client</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="custExisting">
|
||||
<div class="row">
|
||||
<label class="fl">Search by phone, name or SO</label>
|
||||
<input class="f" placeholder="e.g. (416) 555-0142 …" value="(416) 555-0142 — Margaret Chen">
|
||||
<div class="hint">Inbound call? Type the phone number — we match the contact & their history.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="custNew" class="hide">
|
||||
<div class="row two">
|
||||
<div><label class="fl">Client name *</label><input class="f" placeholder="Full name"></div>
|
||||
<div><label class="fl">Phone *</label><input class="f" placeholder="(416) 555-…"></div>
|
||||
</div>
|
||||
<div class="row"><label class="fl">Email</label><input class="f" type="email" placeholder="client@email.com"></div>
|
||||
<div class="row"><label class="fl">Address</label>
|
||||
<div class="with-icon"><input class="f" placeholder="Start typing an address…"><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row three">
|
||||
<div><label class="fl">Unit</label><input class="f" placeholder="#"></div>
|
||||
<div><label class="fl">Buzz</label><input class="f" placeholder="—"></div>
|
||||
<div><label class="fl">City</label><input class="f" placeholder="City"></div>
|
||||
</div>
|
||||
<div class="hint">Contact is created & linked on save — all from this page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE & PRICING -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
||||
<div class="row two">
|
||||
<div>
|
||||
<label class="fl">Device being serviced</label>
|
||||
<select class="f" id="device" onchange="onDevice()">
|
||||
<option value="standard">Mobility Scooter</option>
|
||||
<option value="standard">Powerchair</option>
|
||||
<option value="standard">Wheelchair</option>
|
||||
<option value="lift">Stairlift</option>
|
||||
<option value="lift">Patient / Ceiling Lift</option>
|
||||
<option value="standard">Lift Chair</option>
|
||||
<option value="standard">Hospital Bed</option>
|
||||
<option value="standard">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Issue / symptom</label>
|
||||
<input class="f" placeholder="e.g. won't power on">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="callTypeRow">
|
||||
<label class="fl">Service call type</label>
|
||||
<select class="f" id="callType" onchange="recalc()">
|
||||
<option data-fee="95" data-km="0">Standard Service Call — $95 (incl. 30 min labour)</option>
|
||||
<option data-fee="160" data-km="0">Lift & Elevating Service Call — $160 (incl. 30 min)</option>
|
||||
<option data-fee="120" data-km="1">Rush Service Call — $120 + $0.70/km ×2-way</option>
|
||||
<option data-fee="140" data-km="1">After-Hours Service Call — $140 + $0.70/km ×2-way</option>
|
||||
</select>
|
||||
<div class="hint">Auto-suggested from the device — change if needed.</div>
|
||||
</div>
|
||||
<div class="feeline" id="feeBox">
|
||||
<div class="lbl">Call-out fee<small id="feeSub">Standard · includes 30 min labour</small></div>
|
||||
<div class="amt" id="feeAmt">$95</div>
|
||||
</div>
|
||||
<div class="hint" id="inshopNote" style="display:none;">In-shop job — no call-out fee; labour billed at $75/hr.</div>
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULE -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Schedule</h3>
|
||||
<div class="row two">
|
||||
<div><label class="fl">Date</label><input class="f" type="date" value="2026-06-03"></div>
|
||||
<div><label class="fl">Duration</label>
|
||||
<select class="f" id="dur" onchange="recalc();endTime()">
|
||||
<option value="0.5">30 min</option><option value="1" selected>1 hour</option>
|
||||
<option value="1.5">1.5 hours</option><option value="2">2 hours</option><option value="3">3 hours</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="fl">Start time</label>
|
||||
<div class="timepick">
|
||||
<select class="f" id="hh" onchange="endTime()"><option>9</option><option>10</option><option>11</option><option>12</option><option>1</option><option>2</option><option>3</option><option>4</option></select>
|
||||
<select class="f" id="mm" onchange="endTime()"><option>:00</option><option>:15</option><option>:30</option><option>:45</option></select>
|
||||
<div class="ampm"><button class="on" onclick="ampm(this)">AM</button><button onclick="ampm(this)">PM</button></div>
|
||||
</div>
|
||||
<div class="endtime">Ends at <b id="endlbl">10:00 AM</b> · your local time</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="fl">Technician</label>
|
||||
<select class="f"><option>— Choose —</option><option selected>Dave Wilson</option><option>Priya Anand</option></select>
|
||||
<span class="avail">● 3 open slots before 5:00 PM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOCATION -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Location</h3>
|
||||
<div class="opt" style="border:none; padding-top:0;">
|
||||
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $75/hr</small></div>
|
||||
<div class="sw" id="inshopSw" onclick="toggleShop(this)"></div>
|
||||
</div>
|
||||
<div id="addrBlock">
|
||||
<div class="row"><label class="fl">Job address</label>
|
||||
<div class="with-icon"><input class="f" placeholder="Auto-fills from customer…" value="88 Bloor St E, Toronto"><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row two">
|
||||
<div><label class="fl">Unit / Suite</label><input class="f" placeholder="#"></div>
|
||||
<div><label class="fl">Buzz code</label><input class="f" placeholder="—"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JOB DETAILS -->
|
||||
<div class="card span2">
|
||||
<h3><span class="dot"></span>Job details</h3>
|
||||
<div class="two">
|
||||
<div class="row"><label class="fl">Work description</label><textarea class="f" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||
</div>
|
||||
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" onclick="sw(this)"></div></div>
|
||||
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" onclick="sw(this)"></div></div>
|
||||
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw on" onclick="sw(this)"></div></div>
|
||||
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw on" onclick="sw(this)"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ESTIMATE -->
|
||||
<div class="estimate">
|
||||
<div class="breakdown">
|
||||
<div class="bk"><div class="k">Call-out</div><div class="v" id="eCall">$95</div></div>
|
||||
<div class="bk"><div class="k">Est. labour</div><div class="v" id="eLab">$85 · 1h</div></div>
|
||||
<div class="bk" id="eKmBox" style="display:none;"><div class="k">Travel ($0.70/km ×2)</div><div class="v" id="eKm">$18</div></div>
|
||||
</div>
|
||||
<div class="total"><div class="k">Estimated total</div><div class="v" id="eTotal">$180</div>
|
||||
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="spacer">Local time · America/Toronto · 13 km away</span>
|
||||
<button class="btn ghost">Cancel</button>
|
||||
<button class="btn primary">Book & Create SO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
Mockup v2 — demo-wired (theme, customer mode, device→call-type, in-shop, AM/PM, switches, live estimate).
|
||||
Real build = an OWL client action; <b>Book & Create SO</b> calls one server method that find-or-creates the
|
||||
contact, creates the <code>fusion.technician.task</code> + a draft <code>sale.order</code> with the call-out line
|
||||
(+ auto per-km for rush/after-hours, from the computed distance). Rate-card items are seeded as service products.
|
||||
Toggle <b>◐</b> top-right for dark/light.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DIST_2WAY = 26, KM_RATE = 0.70; // demo: 13km away, 2-way
|
||||
let inshop=false, ap='AM';
|
||||
function toggleTheme(){ const h=document.documentElement; h.dataset.theme=h.dataset.theme==='dark'?'light':'dark'; }
|
||||
function custMode(m){ const ex=m==='existing';
|
||||
segExisting.classList.toggle('on',ex); segNew.classList.toggle('on',!ex);
|
||||
custExisting.classList.toggle('hide',!ex); custNew.classList.toggle('hide',ex); }
|
||||
function onDevice(){ const cat=device.value; callType.selectedIndex = cat==='lift'?1:0; recalc(); }
|
||||
function ampm(el){ [...el.parentNode.children].forEach(b=>b.classList.remove('on')); el.classList.add('on'); ap=el.textContent; endTime(); }
|
||||
function sw(el){ el.classList.toggle('on'); }
|
||||
function toggleShop(el){ el.classList.toggle('on'); inshop=el.classList.contains('on');
|
||||
addrBlock.classList.toggle('hide',inshop); callTypeRow.classList.toggle('hide',inshop);
|
||||
feeBox.classList.toggle('hide',inshop); inshopNote.style.display=inshop?'block':'none'; recalc(); }
|
||||
function endTime(){ const h=+hh.value, m=+mm.value.replace(':',''), dur=+document.getElementById('dur').value;
|
||||
let mins=((h%12)+(ap==='PM'?12:0))*60+m+dur*60;
|
||||
let eh=Math.floor(mins/60)%24, em=mins%60; endlbl.textContent=(eh%12||12)+':'+String(em).padStart(2,'0')+' '+(eh>=12?'PM':'AM'); }
|
||||
function money(n){ return '$'+n.toFixed(n%1?2:0); }
|
||||
function recalc(){
|
||||
const dur=+document.getElementById('dur').value;
|
||||
const labRate = inshop?75:85;
|
||||
let callout=0, km=0, sub='', kmFlag=false;
|
||||
if(!inshop){ const o=callType.options[callType.selectedIndex];
|
||||
callout=+o.dataset.fee; kmFlag=o.dataset.km==='1';
|
||||
feeAmt.textContent=money(callout); feeSub.textContent=o.text.split('—')[0].trim()+(kmFlag?' · + travel':' · incl. 30 min labour');
|
||||
if(kmFlag) km=DIST_2WAY*KM_RATE;
|
||||
}
|
||||
// labour: first 30 min included on standard/lift call (not rush/afterhours which are time-based but keep simple)
|
||||
const incl = (!inshop && !kmFlag) ? 0.5 : 0;
|
||||
const billLabHrs = Math.max(0, dur - incl);
|
||||
const lab = billLabHrs*labRate;
|
||||
eCall.textContent = inshop?'—':money(callout);
|
||||
eLab.textContent = money(lab)+' · '+billLabHrs+'h @ $'+labRate;
|
||||
eKmBox.style.display = kmFlag?'block':'none'; eKm.textContent=money(km);
|
||||
eTotal.textContent = money(callout+lab+km);
|
||||
}
|
||||
endTime(); recalc();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,864 @@
|
||||
# Fusion Clock — Province-Aware Automatic Unpaid Break 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:** Make the unpaid meal break deduct automatically from worked hours on every path (portal, kiosk, NFC, cron, **and manual backend entry**), using a 2-tier per-province rule table (Ontario: 5h→30min, 10h→+30min), with no duplicated logic.
|
||||
|
||||
**Architecture:** A new `fusion.clock.break.rule` table holds the per-province thresholds. `hr.employee._get_fclk_break_rule()` resolves an employee's rule from its company's province (global default fallback). `hr.attendance.x_fclk_break_minutes` becomes a single stored **computed** field — `statutory_break(worked_hours) + Σ penalty_minutes` — that recomputes on every save and replaces the four scattered write sites (controller `_apply_break_deduction` ×3 call sites, the auto-clock-out cron, and the penalty code's manual write).
|
||||
|
||||
**Tech Stack:** Odoo 19, Python, QWeb/XML views, Odoo test framework (`TransactionCase`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Dev environment & sync (READ FIRST — applies to every task)
|
||||
|
||||
**Two working copies (per project memory `feedback_dual_path_fusion_clock`):**
|
||||
- **Git/source tree (edit + commit here):** `K:\Github\Odoo-Modules\fusion_clock`
|
||||
- **Docker/active tree (what the container loads):** `K:\Github\odoo-modsdev\addons\fusion_clock`
|
||||
|
||||
Edit in the **git tree**, then **mirror to the Docker tree before every test run**:
|
||||
|
||||
```powershell
|
||||
robocopy "K:\Github\Odoo-Modules\fusion_clock" "K:\Github\odoo-modsdev\addons\fusion_clock" /MIR /XD ".git" "__pycache__" /XF "*.pyc" /NFL /NDL /NJH /NJS; if ($LASTEXITCODE -lt 8) { "sync ok" } else { "sync FAILED" }
|
||||
```
|
||||
(robocopy exit codes < 8 = success.) **Preflight:** if `K:\Github\odoo-modsdev\addons\fusion_clock` does not exist, the dual-tree setup changed — STOP and confirm the active copy with the user before continuing.
|
||||
|
||||
**Container/DB:** `odoo-modsdev-app` / db `modsdev` (per memory `reference_docker_env_names`).
|
||||
|
||||
**Canonical commands** (note the ephemeral ports — `--test-enable` forces `http_spawn()` so 8069/8072 collide without them; per repo CLAUDE.md):
|
||||
|
||||
- Run this module's tests:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
|
||||
```
|
||||
- Plain upgrade (no tests):
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -50
|
||||
```
|
||||
- Pyflakes a changed Python file (catches undefined names instantly):
|
||||
```bash
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/<relpath>.py
|
||||
```
|
||||
|
||||
**Commit:** only from the git tree (`git -C "K:/Github/Odoo-Modules" ...`). Per memory `feedback_always_push_to_main`, push after each commit on `main`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Created:**
|
||||
- `fusion_clock/models/clock_break_rule.py` — the `fusion.clock.break.rule` model + tier engine + constraints.
|
||||
- `fusion_clock/data/clock_break_rule_data.xml` — seed Ontario rule (`is_default`).
|
||||
- `fusion_clock/views/clock_break_rule_views.xml` — list/form/action for the rule.
|
||||
- `fusion_clock/migrations/19.0.4.1.0/post-migrate.py` — drop retired param + recompute break.
|
||||
- `fusion_clock/tests/test_break_rules.py` — all new tests.
|
||||
|
||||
**Modified:**
|
||||
- `fusion_clock/models/__init__.py` — import the new model.
|
||||
- `fusion_clock/models/hr_employee.py` — add `_get_fclk_break_rule()`.
|
||||
- `fusion_clock/models/hr_attendance.py` — `x_fclk_break_minutes` → stored compute; drop cron break-write.
|
||||
- `fusion_clock/controllers/clock_api.py` — delete `_apply_break_deduction`, its clock-out call, and the penalty break-write.
|
||||
- `fusion_clock/controllers/clock_kiosk.py` — delete the `_apply_break_deduction` call.
|
||||
- `fusion_clock/controllers/clock_nfc_kiosk.py` — delete the `_apply_break_deduction` call.
|
||||
- `fusion_clock/models/res_config_settings.py` — remove `fclk_break_threshold_hours`.
|
||||
- `fusion_clock/views/res_config_settings_views.xml` — remove threshold row; relabel default-break as scheduling-only; point to Break Rules.
|
||||
- `fusion_clock/data/ir_config_parameter_data.xml` — remove the `break_threshold_hours` seed record.
|
||||
- `fusion_clock/security/ir.model.access.csv` — manager access for the new model.
|
||||
- `fusion_clock/views/clock_menus.xml` — "Break Rules" config menu.
|
||||
- `fusion_clock/__manifest__.py` — version bump + new data/view files.
|
||||
- `fusion_clock/tests/__init__.py` — import the new test module.
|
||||
- `fusion_clock/tests/test_settings.py` — assert the retired field is gone.
|
||||
- `fusion_clock/CLAUDE.md` — model map, settings keys, break gotcha (Task 5).
|
||||
|
||||
**Behaviour-change note (intentional, approved by spec §4.3):** today a *late-in* penalty written at clock-in (e.g. +15) is silently swallowed at clock-out because `_apply_break_deduction` does `max(break, current)`. The new compute makes **all** penalty minutes strictly additive (`statutory + Σ penalties`), so a late-in penalty on a long shift is no longer lost. Net hours for such shifts will be correctly lower than before.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: New model `fusion.clock.break.rule`
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_clock/models/clock_break_rule.py`
|
||||
- Create: `fusion_clock/data/clock_break_rule_data.xml`
|
||||
- Create: `fusion_clock/views/clock_break_rule_views.xml`
|
||||
- Create: `fusion_clock/tests/test_break_rules.py`
|
||||
- Modify: `fusion_clock/models/__init__.py`
|
||||
- Modify: `fusion_clock/tests/__init__.py`
|
||||
- Modify: `fusion_clock/security/ir.model.access.csv`
|
||||
- Modify: `fusion_clock/views/clock_menus.xml`
|
||||
- Modify: `fusion_clock/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — create `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tests import tagged, TransactionCase
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestBreakRules(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
|
||||
cls.Rule = cls.env['fusion.clock.break.rule']
|
||||
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
|
||||
cls.employee = cls.env['hr.employee'].create({'name': 'FCLK Break Test'})
|
||||
|
||||
def _mk_att(self, hours):
|
||||
check_in = datetime(2026, 1, 5, 9, 0, 0)
|
||||
return self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': check_in,
|
||||
'check_out': check_in + timedelta(hours=hours),
|
||||
})
|
||||
|
||||
# ---- Task 1: tier engine + constraints ----
|
||||
def test_break_minutes_for_tiers(self):
|
||||
rule = self.Rule.create({
|
||||
'name': 'Tier Test', 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
|
||||
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
|
||||
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
|
||||
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
|
||||
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
|
||||
|
||||
def test_second_tier_must_exceed_first(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Rule.create({
|
||||
'name': 'Bad', 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
|
||||
def test_single_default_enforced(self):
|
||||
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Rule.create({
|
||||
'name': 'Another Default', 'is_default': True, 'active': True,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
```
|
||||
|
||||
Append the import to `fusion_clock/tests/__init__.py` (add the line if not already present):
|
||||
|
||||
```python
|
||||
from . import test_break_rules
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the model** — `fusion_clock/models/clock_break_rule.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FusionClockBreakRule(models.Model):
|
||||
_name = 'fusion.clock.break.rule'
|
||||
_description = 'Statutory Break Rule'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
country_id = fields.Many2one('res.country', string='Country')
|
||||
state_id = fields.Many2one(
|
||||
'res.country.state',
|
||||
string='Province / State',
|
||||
help="Employees whose company is in this province use this rule.",
|
||||
)
|
||||
is_default = fields.Boolean(
|
||||
string='Default Rule',
|
||||
help="Used when an employee's company province matches no other rule. "
|
||||
"Only one active rule may be the default.",
|
||||
)
|
||||
break1_after_hours = fields.Float(
|
||||
string='First Break After (h)', default=5.0,
|
||||
help="Worked hours at or above this trigger the first unpaid break.",
|
||||
)
|
||||
break1_minutes = fields.Float(
|
||||
string='First Break (min)', default=30.0,
|
||||
help="Length of the first unpaid break. 0 disables it.",
|
||||
)
|
||||
break2_after_hours = fields.Float(
|
||||
string='Second Break After (h)', default=10.0,
|
||||
help="Worked hours at or above this add the second unpaid break.",
|
||||
)
|
||||
break2_minutes = fields.Float(
|
||||
string='Second Break (min)', default=30.0,
|
||||
help="Length of the second unpaid break. 0 disables it.",
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
def break_minutes_for(self, worked_hours):
|
||||
"""Total statutory unpaid break (minutes) for the given worked hours.
|
||||
|
||||
Tiers are inclusive (``>=``): a break applies when worked hours are
|
||||
equal to or greater than the threshold. The second tier adds on top of
|
||||
the first.
|
||||
"""
|
||||
self.ensure_one()
|
||||
worked = worked_hours or 0.0
|
||||
total = 0.0
|
||||
if self.break1_minutes and worked >= self.break1_after_hours:
|
||||
total += self.break1_minutes
|
||||
if self.break2_minutes and worked >= self.break2_after_hours:
|
||||
total += self.break2_minutes
|
||||
return total
|
||||
|
||||
@api.constrains('break1_after_hours', 'break1_minutes',
|
||||
'break2_after_hours', 'break2_minutes')
|
||||
def _check_tiers(self):
|
||||
for rule in self:
|
||||
if min(rule.break1_after_hours, rule.break1_minutes,
|
||||
rule.break2_after_hours, rule.break2_minutes) < 0:
|
||||
raise ValidationError(_("Break hours and minutes cannot be negative."))
|
||||
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
|
||||
raise ValidationError(_(
|
||||
"The second break threshold (%(n2)s h) must be greater than "
|
||||
"the first (%(n1)s h).",
|
||||
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
|
||||
|
||||
@api.constrains('is_default', 'active')
|
||||
def _check_single_default(self):
|
||||
for rule in self:
|
||||
if rule.is_default and rule.active:
|
||||
dupe = self.search([
|
||||
('is_default', '=', True), ('active', '=', True),
|
||||
('id', '!=', rule.id),
|
||||
], limit=1)
|
||||
if dupe:
|
||||
raise ValidationError(_(
|
||||
"Only one active break rule can be the default "
|
||||
"(currently: %s).", dupe.name))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the model** — add to `fusion_clock/models/__init__.py` after the `clock_penalty` import:
|
||||
|
||||
```python
|
||||
from . import clock_break_rule
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Grant access** — append one row to `fusion_clock/security/ir.model.access.csv`:
|
||||
|
||||
```
|
||||
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
|
||||
```
|
||||
|
||||
(No user/portal grant needed — the resolver reads the table via `sudo()`.)
|
||||
|
||||
- [ ] **Step 5: Seed the Ontario rule** — create `fusion_clock/data/clock_break_rule_data.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="break_rule_ontario" model="fusion.clock.break.rule">
|
||||
<field name="name">Ontario</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="state_id" ref="base.state_ca_on"/>
|
||||
<field name="is_default" eval="True"/>
|
||||
<field name="break1_after_hours">5.0</field>
|
||||
<field name="break1_minutes">30.0</field>
|
||||
<field name="break2_after_hours">10.0</field>
|
||||
<field name="break2_minutes">30.0</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Views + action** — create `fusion_clock/views/clock_break_rule_views.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.break.rule.list</field>
|
||||
<field name="model">fusion.clock.break.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="state_id"/>
|
||||
<field name="country_id" optional="hide"/>
|
||||
<field name="break1_after_hours" widget="float_time"/>
|
||||
<field name="break1_minutes"/>
|
||||
<field name="break2_after_hours" widget="float_time"/>
|
||||
<field name="break2_minutes"/>
|
||||
<field name="is_default"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.break.rule.form</field>
|
||||
<field name="model">fusion.clock.break.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||
invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Jurisdiction">
|
||||
<field name="country_id"/>
|
||||
<field name="state_id"
|
||||
domain="[('country_id', '=', country_id)]"/>
|
||||
<field name="is_default"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Unpaid Break Tiers">
|
||||
<label for="break1_after_hours" string="First break after"/>
|
||||
<div class="o_row">
|
||||
<field name="break1_after_hours" widget="float_time"/>
|
||||
<span>h →</span>
|
||||
<field name="break1_minutes"/>
|
||||
<span>min</span>
|
||||
</div>
|
||||
<label for="break2_after_hours" string="Second break after"/>
|
||||
<div class="o_row">
|
||||
<field name="break2_after_hours" widget="float_time"/>
|
||||
<span>h →</span>
|
||||
<field name="break2_minutes"/>
|
||||
<span>min</span>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<p class="text-muted">
|
||||
Breaks are unpaid and deducted from actual worked hours. A tier with
|
||||
0 minutes is disabled. Triggers are inclusive — a break applies when
|
||||
worked hours are equal to or above the threshold.
|
||||
</p>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
|
||||
<field name="name">Break Rules</field>
|
||||
<field name="res_model">fusion.clock.break.rule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'active_test': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
|
||||
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
|
||||
the rule matching their company's province, or the default rule.</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add the menu** — in `fusion_clock/views/clock_menus.xml`, insert after the `menu_fusion_clock_locations_config` menuitem (the Locations config item) and before `menu_fusion_clock_nfc_enrollment`:
|
||||
|
||||
```xml
|
||||
<menuitem id="menu_fusion_clock_break_rules"
|
||||
name="Break Rules"
|
||||
parent="menu_fusion_clock_config"
|
||||
action="action_fusion_clock_break_rule"
|
||||
sequence="25"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Wire the manifest** — in `fusion_clock/__manifest__.py`:
|
||||
|
||||
**Do NOT bump the version yet** — it stays `19.0.4.0.3` until Task 4, so the
|
||||
`19.0.4.1.0` migration actually fires in dev (Odoo only runs a version's migration
|
||||
when the installed version is *lower* than the manifest version).
|
||||
|
||||
Add the seed data file after `'data/ir_config_parameter_data.xml',`:
|
||||
```python
|
||||
'data/clock_break_rule_data.xml',
|
||||
```
|
||||
Add the view file after `'views/clock_schedule_views.xml',`:
|
||||
```python
|
||||
'views/clock_break_rule_views.xml',
|
||||
```
|
||||
(Data and view files reload on every `-u` regardless of the version number, so the
|
||||
new model/menu install without a bump. No assets change in this plan, so the bump's
|
||||
only purpose is the migration trigger — deferred to Task 4.)
|
||||
|
||||
- [ ] **Step 9: Sync, upgrade, run tests**
|
||||
|
||||
Sync (see preamble), then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
|
||||
```
|
||||
Expected: module upgrades cleanly; `test_break_minutes_for_tiers`, `test_second_tier_must_exceed_first`, `test_single_default_enforced` PASS. (Other tests in the class will error until Tasks 2–3 add their dependencies — that's expected if you scoped the run; otherwise the not-yet-added methods simply don't exist yet.)
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/clock_break_rule.py fusion_clock/models/__init__.py fusion_clock/data/clock_break_rule_data.xml fusion_clock/views/clock_break_rule_views.xml fusion_clock/views/clock_menus.xml fusion_clock/security/ir.model.access.csv fusion_clock/__manifest__.py fusion_clock/tests/test_break_rules.py fusion_clock/tests/__init__.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): add fusion.clock.break.rule per-province break table" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Jurisdiction resolver on `hr.employee`
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/hr_employee.py`
|
||||
- Modify: `fusion_clock/tests/test_break_rules.py`
|
||||
|
||||
- [ ] **Step 1: Add the resolver tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# ---- Task 2: jurisdiction resolver ----
|
||||
def test_resolver_matches_company_province(self):
|
||||
bc = self.env.ref('base.state_ca_bc')
|
||||
bc_rule = self.Rule.create({
|
||||
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
self.employee.company_id.state_id = bc.id
|
||||
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
|
||||
|
||||
def test_resolver_falls_back_to_default(self):
|
||||
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
|
||||
self.employee.company_id.state_id = alberta.id
|
||||
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Sync, then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
Expected: FAIL — `AttributeError: 'hr.employee' object has no attribute '_get_fclk_break_rule'`.
|
||||
|
||||
- [ ] **Step 3: Implement the resolver** — in `fusion_clock/models/hr_employee.py`, add this method immediately after the `_get_fclk_break_minutes` method (after its `return float(...)` block, before `_get_fclk_scheduled_times`):
|
||||
|
||||
```python
|
||||
def _get_fclk_break_rule(self):
|
||||
"""Return the statutory break rule for this employee.
|
||||
|
||||
Resolution: company's province → matching rule; else the global default
|
||||
rule; else an empty recordset (caller treats as zero break). Read via
|
||||
sudo so the portal net-hours compute can resolve it without a direct ACL.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Rule = self.env['fusion.clock.break.rule'].sudo()
|
||||
rule = Rule.browse()
|
||||
state = self.company_id.state_id
|
||||
if state:
|
||||
rule = Rule.search([('state_id', '=', state.id)], limit=1)
|
||||
if not rule:
|
||||
rule = Rule.search([('is_default', '=', True)], limit=1)
|
||||
return rule
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Sync, then re-run the Step 2 command. Expected: `test_resolver_matches_company_province` and `test_resolver_falls_back_to_default` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_employee.py fusion_clock/tests/test_break_rules.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): resolve employee break rule from company province" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `x_fclk_break_minutes` → stored compute; remove all manual writes
|
||||
|
||||
This task is atomic: once the field is computed (no inverse), any remaining `write({'x_fclk_break_minutes': ...})` raises at runtime, so the field conversion and the removal of all four write sites must land together.
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/hr_attendance.py`
|
||||
- Modify: `fusion_clock/controllers/clock_api.py`
|
||||
- Modify: `fusion_clock/controllers/clock_kiosk.py`
|
||||
- Modify: `fusion_clock/controllers/clock_nfc_kiosk.py`
|
||||
- Modify: `fusion_clock/tests/test_break_rules.py`
|
||||
|
||||
- [ ] **Step 1: Add the attendance tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# ---- Task 3: automatic deduction on every path ----
|
||||
def test_manual_attendance_applies_statutory_break(self):
|
||||
att = self._mk_att(6) # 6h >= 5 -> first break
|
||||
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
|
||||
|
||||
def test_manual_edit_extends_break(self):
|
||||
att = self._mk_att(6)
|
||||
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
|
||||
self.assertEqual(att.x_fclk_break_minutes, 60.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
|
||||
|
||||
def test_under_first_threshold_no_break(self):
|
||||
att = self._mk_att(4) # 4h < 5 -> nothing
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
|
||||
|
||||
def test_penalty_minutes_are_additive(self):
|
||||
att = self._mk_att(6) # statutory 30
|
||||
self.env['fusion.clock.penalty'].create({
|
||||
'attendance_id': att.id,
|
||||
'employee_id': self.employee.id,
|
||||
'penalty_type': 'early_out',
|
||||
'penalty_minutes': 15.0,
|
||||
'date': att.check_in.date(),
|
||||
})
|
||||
self.assertEqual(att.x_fclk_break_minutes, 45.0)
|
||||
|
||||
def test_master_toggle_off_zero_statutory(self):
|
||||
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
|
||||
att = self._mk_att(6)
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
|
||||
def test_open_attendance_zero_break(self):
|
||||
att = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2026, 1, 5, 9, 0, 0),
|
||||
})
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Sync, then run the module tests. Expected: the new tests FAIL — e.g. `test_manual_attendance_applies_statutory_break` asserts 30 but gets 0 (no write override exists yet).
|
||||
|
||||
- [ ] **Step 3: Convert the field to a stored compute** — in `fusion_clock/models/hr_attendance.py`, replace the field definition:
|
||||
|
||||
OLD:
|
||||
```python
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
tracking=True,
|
||||
help="Break duration in minutes to deduct from worked hours.",
|
||||
)
|
||||
```
|
||||
NEW:
|
||||
```python
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
compute='_compute_fclk_break_minutes',
|
||||
store=True,
|
||||
tracking=True,
|
||||
help="Unpaid break deducted from worked hours: statutory break (per the "
|
||||
"employee's province rule, from actual hours worked) plus any penalty "
|
||||
"minutes. Computed automatically on every save.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the compute method** — in the same file, insert this method immediately before the `_compute_net_hours` method (just above its `@api.depends('worked_hours', 'x_fclk_break_minutes')` decorator):
|
||||
|
||||
```python
|
||||
@api.depends('worked_hours', 'check_out',
|
||||
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
|
||||
def _compute_fclk_break_minutes(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
|
||||
for att in self:
|
||||
statutory = 0.0
|
||||
if auto and att.check_out and att.employee_id:
|
||||
rule = att.employee_id._get_fclk_break_rule()
|
||||
if rule:
|
||||
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
|
||||
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
|
||||
att.x_fclk_break_minutes = statutory + penalties
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove the cron's break write** — in the same file, inside `_cron_fusion_auto_clock_out`:
|
||||
|
||||
Remove the now-unused threshold read (the line near the top of the method):
|
||||
```python
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
```
|
||||
Remove the two now-unused locals in the per-attendance loop:
|
||||
```python
|
||||
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()
|
||||
```
|
||||
Remove the break-write block (the compute now applies the break when `check_out` is set):
|
||||
```python
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
att.sudo().write(
|
||||
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
||||
)
|
||||
```
|
||||
(Leave the surrounding `employee = att.employee_id` and `clock_out_time = effective_deadline` lines intact.)
|
||||
|
||||
- [ ] **Step 6: Delete the controller helper and its call sites** — in `fusion_clock/controllers/clock_api.py`:
|
||||
|
||||
Delete the entire `_apply_break_deduction` method:
|
||||
```python
|
||||
def _apply_break_deduction(self, attendance, employee):
|
||||
"""Apply automatic break deduction if configured."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
|
||||
return
|
||||
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
worked = attendance.worked_hours or 0.0
|
||||
|
||||
if worked >= threshold:
|
||||
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)
|
||||
if new_val != current:
|
||||
attendance.sudo().write({'x_fclk_break_minutes': new_val})
|
||||
|
||||
```
|
||||
Delete its clock-out call (in the CLOCK OUT branch):
|
||||
```python
|
||||
# Apply break deduction
|
||||
self._apply_break_deduction(attendance, employee)
|
||||
|
||||
```
|
||||
Delete the penalty break-write in `_check_and_create_penalty` (keep the penalty-record `create` above it and the activity log below it):
|
||||
```python
|
||||
# Deduct penalty minutes from attendance (adds to break deduction)
|
||||
current_break = attendance.x_fclk_break_minutes or 0.0
|
||||
attendance.sudo().write({
|
||||
'x_fclk_break_minutes': current_break + deduction,
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Delete the kiosk call sites**
|
||||
|
||||
In `fusion_clock/controllers/clock_kiosk.py`, delete the line:
|
||||
```python
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
```
|
||||
In `fusion_clock/controllers/clock_nfc_kiosk.py`, delete the line:
|
||||
```python
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Pyflakes the touched controllers/models** (catches a missed `pytz`/var reference instantly)
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/controllers/clock_api.py /mnt/extra-addons/fusion_clock/controllers/clock_kiosk.py /mnt/extra-addons/fusion_clock/controllers/clock_nfc_kiosk.py /mnt/extra-addons/fusion_clock/models/hr_attendance.py
|
||||
```
|
||||
Expected: no output (clean). If it flags `pytz` as unused in `hr_attendance.py`, that's fine only if no other code uses it — verify before removing the import (the absence/overtime crons still use `pytz`, so leave the import).
|
||||
|
||||
- [ ] **Step 9: Run to verify all Task 3 tests pass**
|
||||
|
||||
Sync, then run the module tests. Expected: all `test_manual_*`, `test_under_first_threshold_no_break`, `test_penalty_minutes_are_additive`, `test_master_toggle_off_zero_statutory`, `test_open_attendance_zero_break` PASS, and the existing NFC/kiosk/dashboard tests still PASS.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_attendance.py fusion_clock/controllers/clock_api.py fusion_clock/controllers/clock_kiosk.py fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_break_rules.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): auto-apply statutory break via one stored compute" -m "x_fclk_break_minutes is now statutory(worked_hours) + penalties, recomputed on every path including manual backend entry. Removes the four duplicated write sites (controller _apply_break_deduction + 3 call sites, auto-clock-out cron, penalty write)." -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Retire `break_threshold_hours`; clean settings & migrate
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/res_config_settings.py`
|
||||
- Modify: `fusion_clock/views/res_config_settings_views.xml`
|
||||
- Modify: `fusion_clock/data/ir_config_parameter_data.xml`
|
||||
- Create: `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`
|
||||
- Modify: `fusion_clock/tests/test_settings.py`
|
||||
|
||||
- [ ] **Step 1: Add the dead-setting assertion** — in `fusion_clock/tests/test_settings.py`, add one line to `test_dead_settings_removed`:
|
||||
|
||||
```python
|
||||
self.assertNotIn('fclk_break_threshold_hours', fields)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the settings field** — in `fusion_clock/models/res_config_settings.py`, delete:
|
||||
|
||||
```python
|
||||
fclk_break_threshold_hours = fields.Float(
|
||||
string='Break Threshold (hours)',
|
||||
config_parameter='fusion_clock.break_threshold_hours',
|
||||
default=4.0,
|
||||
help="Only deduct break if shift is longer than this many hours.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Fix the settings view** — in `fusion_clock/views/res_config_settings_views.xml`, replace the whole `fclk_auto_break` setting block:
|
||||
|
||||
OLD:
|
||||
```xml
|
||||
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||
help="Automatically deduct unpaid break from worked hours on clock-out.">
|
||||
<field name="fclk_auto_deduct_break"/>
|
||||
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_break_minutes"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_break_threshold_hours" widget="float_time"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
```
|
||||
NEW:
|
||||
```xml
|
||||
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
|
||||
<field name="fclk_auto_deduct_break"/>
|
||||
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_break_minutes"/>
|
||||
</div>
|
||||
<div class="text-muted small mt4">
|
||||
Used as the default break when building shifts/schedules
|
||||
(planned hours). Actual deductions follow the province Break Rules.
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove the seed param** — in `fusion_clock/data/ir_config_parameter_data.xml`, delete:
|
||||
|
||||
```xml
|
||||
<record id="config_break_threshold_hours" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.break_threshold_hours</field>
|
||||
<field name="value">4.0</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Bump the version + create the migration**
|
||||
|
||||
First bump the manifest so the migration fires (installed `19.0.4.0.3` < manifest
|
||||
`19.0.4.1.0`). In `fusion_clock/__manifest__.py`:
|
||||
```python
|
||||
'version': '19.0.4.1.0',
|
||||
```
|
||||
Then create `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Retire the single-threshold break param (superseded by per-rule
|
||||
break1_after_hours), and force-recompute the now-computed break field so
|
||||
existing closed attendances reflect the province rule + their penalties."""
|
||||
cr.execute(
|
||||
"DELETE FROM ir_config_parameter WHERE key = %s",
|
||||
('fusion_clock.break_threshold_hours',),
|
||||
)
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
Attendance = env['hr.attendance']
|
||||
field = Attendance._fields['x_fclk_break_minutes']
|
||||
closed = Attendance.search([('check_out', '!=', False)])
|
||||
if closed:
|
||||
env.add_to_compute(field, closed)
|
||||
closed.flush_recordset(['x_fclk_break_minutes'])
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Sync, upgrade, run tests**
|
||||
|
||||
Sync, then run the module tests. Expected: module upgrades cleanly and the `19.0.4.1.0` migration executes (installed `19.0.4.0.3` < manifest `19.0.4.1.0`; modsdev shows the INFO line, nexa/entech run `log_level=warn`), `test_dead_settings_removed` PASS, full `fusion_clock` suite green.
|
||||
|
||||
- [ ] **Step 7: Verify the param is gone and historical rows recomputed** (sanity)
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo shell -d modsdev --no-http 2>/dev/null <<'PY'
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
print('threshold param:', ICP.get_param('fusion_clock.break_threshold_hours', 'ABSENT'))
|
||||
print('default rule:', env['fusion.clock.break.rule'].search([('is_default','=',True)]).mapped('name'))
|
||||
PY
|
||||
```
|
||||
Expected: `threshold param: ABSENT`; `default rule: ['Ontario']`.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/migrations/19.0.4.1.0/post-migrate.py fusion_clock/tests/test_settings.py fusion_clock/__manifest__.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "refactor(fusion_clock): retire break_threshold_hours; breaks now driven by Break Rules" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Full verification, docs, manual smoke
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Full test run (whole module)**
|
||||
|
||||
Sync, then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -120
|
||||
```
|
||||
Expected: all `fusion_clock` tests PASS, zero tracebacks. If anything fails, fix before continuing.
|
||||
|
||||
- [ ] **Step 2: Manual smoke (manager UI)** at http://localhost:8082
|
||||
|
||||
- Configuration → **Break Rules** exists; the **Ontario** row shows 5h→30 / 10h→30, Default ticked.
|
||||
- Attendances → create a manual attendance, check-in 09:00 check-out 15:00 (6h) → **Break = 30**, Net = 5.5h, with no clock action.
|
||||
- Edit that record's check-out to 19:00 (10h) → **Break = 60**, Net = 9.0h.
|
||||
- Create a 4h attendance → **Break = 0**.
|
||||
- Settings → the old "Min. Shift" threshold field is gone; the Auto-Deduct Break help points to Break Rules.
|
||||
|
||||
- [ ] **Step 3: Update the module CLAUDE.md** — in `fusion_clock/CLAUDE.md`:
|
||||
|
||||
- §4 Model Map: add a row — `fusion.clock.break.rule | models/clock_break_rule.py | Per-province statutory unpaid-break thresholds (2-tier).`
|
||||
- §5 Clocking Flow: note that the break deduction is no longer a controller step — `x_fclk_break_minutes` is a stored compute (`statutory(worked_hours) + Σ penalties`) that fires on every path including manual backend entry; resolved rule via `hr.employee._get_fclk_break_rule()` (company province → default).
|
||||
- §11 Settings Keys: remove `fusion_clock.break_threshold_hours`.
|
||||
- §13 Gotchas: add — "Unpaid break is computed, not written: never `write({'x_fclk_break_minutes': ...})`; change the province rule (`fusion.clock.break.rule`) or `auto_deduct_break` instead. Penalty minutes are now strictly additive (the old `max()` that swallowed late-in penalties is gone)."
|
||||
- Bump the version line in §1 to `19.0.4.1.0`.
|
||||
|
||||
- [ ] **Step 4: Commit the docs**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/CLAUDE.md
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "docs(fusion_clock): document province break rules + computed break field" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Report** — summarize what changed, the behaviour-change note (penalties now additive), and that live deployment to entech (`odoo-entech`) is a separate step pending user sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (performed against the spec)
|
||||
|
||||
**1. Spec coverage**
|
||||
- §4.1 model → Task 1. §4.2 resolver → Task 2. §4.3 stored compute → Task 3. §4.4 removals → Task 3 (writes) + Task 4 (setting/param/view). §4.5 UI/security/data → Task 1 (+ settings view in Task 4). §5 edge cases → tests in Tasks 1 & 3. §6 migration → Task 4. §7 tests → all six+ cases present across Tasks 1–3. §8 rollout → preamble + Task 5. ✓ No gaps.
|
||||
|
||||
**2. Placeholder scan** — every step has full code/commands; no TBD/TODO/"similar to". ✓
|
||||
|
||||
**3. Type/name consistency** — `break_minutes_for`, `_get_fclk_break_rule`, `_compute_fclk_break_minutes`, fields `break1_after_hours/break1_minutes/break2_after_hours/break2_minutes/is_default`, model `fusion.clock.break.rule`, access id `model_fusion_clock_break_rule`, action `action_fusion_clock_break_rule`, menu `menu_fusion_clock_break_rules` — all used identically across tasks. The compute folds `Σ penalty_minutes` (field `penalty_minutes` on `fusion.clock.penalty`, confirmed). ✓
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user