Compare commits
142 Commits
feat/asses
...
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 | ||
|
|
319de06ca6 | ||
|
|
903ceb10d0 | ||
|
|
0499a1ad2e | ||
|
|
4f48bab6e9 | ||
|
|
b616375679 | ||
|
|
5c4a26b65f | ||
|
|
b59ad6b21e | ||
|
|
8a1a09b150 | ||
|
|
a092c385ea | ||
|
|
ca44461b6f | ||
|
|
249adf8145 | ||
|
|
cc568b0ec8 | ||
|
|
17d21bffb5 | ||
|
|
6c3830fd4c | ||
|
|
12d383a8c2 | ||
|
|
139e917e09 | ||
|
|
de3e0df5fc |
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": []
|
||||
}
|
||||
}
|
||||
54
.gitignore
vendored
54
.gitignore
vendored
@@ -15,4 +15,56 @@ __pycache__/
|
||||
|
||||
# Local-only diagnostic logs from test runs
|
||||
_test_*.log
|
||||
.superpowers/
|
||||
|
||||
# --- 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
|
||||
62
CLAUDE.md
62
CLAUDE.md
@@ -35,6 +35,8 @@
|
||||
|
||||
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
|
||||
@@ -96,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_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`
|
||||
@@ -138,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)
|
||||
@@ -232,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. |
|
||||
@@ -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,506 @@
|
||||
# fusion_maintenance Foundation — Implementation Plan (Plan 1 of 5)
|
||||
|
||||
> **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:** Confirming a sale of a maintainable product auto-creates a *priced* maintenance contract, and the due-reminder email shows the maintenance cost.
|
||||
|
||||
**Architecture:** Extend `fusion_repairs`. A maintenance **policy** (enabled / interval / flat fee) lives on `fusion.repair.product.category`, with a per-product fee/interval override on `product.template`. We fix the dead `_spawn_maintenance_contracts()` (anchor on delivery date, capture serial + fee + provenance, dedup) and call it from the **existing** `action_confirm()` override. The branded reminder email gains a fee line.
|
||||
|
||||
**Tech Stack:** Odoo 19 **Community**, Python, `TransactionCase`. Local dev: `docker odoo-modsdev-app`, DB `fusion-dev`.
|
||||
|
||||
**Spec:** [`2026-06-02-fusion-maintenance-design.md`](../specs/2026-06-02-fusion-maintenance-design.md). This is **Plan 1 of 5**; see the Roadmap at the bottom for Plans 2–5 (booking, visit log, backfill, office crons) — each is written when reached because it needs its own live-source reads (spec §15).
|
||||
|
||||
**Conventions (from CLAUDE.md):** new fields `x_fc_` prefix; Canadian English; Monetary = `$` + `currency_id`; declarative `models.Constraint` / `models.Index` (no `_sql_constraints`); `message_post` HTML wrapped in `Markup()`; `res.users` group field is `group_ids`.
|
||||
|
||||
**Run tests:**
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs \
|
||||
-u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
**Grounding (verified source, 2026-06-02):**
|
||||
- [`maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py) — contract model (fields end at `company_id`, line 81; `_booking_token_unique` constraint line 83); dead `_spawn_maintenance_contracts()` (line 198, anchors on `today`, dedups by partner/product/SO, no fee/serial/source).
|
||||
- [`repair_product_category.py`](../../../fusion_repairs/models/repair_product_category.py) — category model; `safety_critical`, `equipment_class`; `_code_unique` constraint line 56.
|
||||
- [`product_template.py`](../../../fusion_repairs/models/product_template.py) — `x_fc_repair_category_id` (line 11), `x_fc_maintenance_interval_months` (line 23, default 0).
|
||||
- [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py) — **existing** `action_confirm()` override (line 229) ending `return res` (line 250); wire the maintenance spawn here.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `fusion_repairs/models/repair_product_category.py` — add maintenance-policy fields + `currency_id`.
|
||||
- **Modify** `fusion_repairs/models/product_template.py` — add `x_fc_maintenance_fee` override.
|
||||
- **Modify** `fusion_repairs/models/maintenance_contract.py` — add contract fields + indexes; add `_fc_maintenance_anchor_date`; rewrite `_spawn_maintenance_contracts`.
|
||||
- **Modify** `fusion_repairs/models/repair_service_plan.py` — call `self._spawn_maintenance_contracts()` inside `action_confirm`.
|
||||
- **Modify** `fusion_repairs/data/mail_template_data.xml` — add a fee row to the reminder template.
|
||||
- **Modify** `fusion_repairs/views/repair_product_category_views.xml` — expose the policy fields.
|
||||
- **Create** `fusion_repairs/tests/__init__.py`, `fusion_repairs/tests/test_maintenance_foundation.py`.
|
||||
- **Modify** `fusion_repairs/__manifest__.py` — bump `version` to `19.0.2.3.0`.
|
||||
|
||||
> **Scope note:** the technician-skill field (`x_fc_maintenance_skill_id`) is deferred to **Plan 2 (booking)** because skill matching is a booking concern and the exact skills representation is an open item (spec §15). Plan 1 is enrollment + pricing only.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Maintenance policy fields on the equipment category
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/models/repair_product_category.py` (insert after `intake_template_id`, before `_code_unique` at line 56)
|
||||
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||
|
||||
- [ ] **Step 1: Create the tests package + write the failing test**
|
||||
|
||||
Create `fusion_repairs/tests/__init__.py`:
|
||||
```python
|
||||
from . import test_maintenance_foundation
|
||||
```
|
||||
|
||||
Create `fusion_repairs/tests/test_maintenance_foundation.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMaintenanceFoundation(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'Mrs. Test Client'})
|
||||
cls.category = cls.env['fusion.repair.product.category'].create({
|
||||
'name': 'Stair Lift', 'code': 'stairlift',
|
||||
'equipment_class': 'lift_elevating', 'safety_critical': True,
|
||||
'x_fc_maintenance_enabled': True,
|
||||
'x_fc_maintenance_interval_months': 6,
|
||||
'x_fc_maintenance_fee': 149.0,
|
||||
})
|
||||
|
||||
def test_category_policy_fields_exist(self):
|
||||
self.assertTrue(self.category.x_fc_maintenance_enabled)
|
||||
self.assertEqual(self.category.x_fc_maintenance_interval_months, 6)
|
||||
self.assertEqual(self.category.x_fc_maintenance_fee, 149.0)
|
||||
self.assertTrue(self.category.currency_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
|
||||
```
|
||||
Expected: FAIL — `Invalid field 'x_fc_maintenance_enabled' on model 'fusion.repair.product.category'`.
|
||||
|
||||
- [ ] **Step 3: Add the policy fields**
|
||||
|
||||
In `repair_product_category.py`, insert before the `_code_unique = models.Constraint(...)` line:
|
||||
```python
|
||||
# ── Maintenance policy (per equipment type) ──────────────────────────
|
||||
x_fc_maintenance_enabled = fields.Boolean(
|
||||
string='Offer Maintenance',
|
||||
help='If set, units in this category are enrolled in recurring preventive '
|
||||
'maintenance on sale (and via the backfill wizard).',
|
||||
)
|
||||
x_fc_maintenance_interval_months = fields.Integer(
|
||||
string='Maintenance Interval (Months)', default=6,
|
||||
help='Default months between preventive maintenance visits for this category. '
|
||||
'Overridden by the product field of the same name when that is > 0.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee', currency_field='currency_id',
|
||||
help='Flat fee shown to the client for a maintenance visit of this equipment type.',
|
||||
)
|
||||
x_fc_maintenance_service_product_id = fields.Many2one(
|
||||
'product.product', string='Maintenance Service Product',
|
||||
help='Optional product used when drafting the priced visit line (Plan 2). '
|
||||
'Falls back to a generic visit product.',
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run the same command as Step 2. Expected: `test_category_policy_fields_exist` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/models/repair_product_category.py fusion_repairs/tests/
|
||||
git commit -m "feat(fusion_repairs): maintenance policy fields on equipment category"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Per-product fee override
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/models/product_template.py` (after `x_fc_maintenance_interval_months`, line 28)
|
||||
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to the test class)
|
||||
```python
|
||||
def test_product_fee_override_field_exists(self):
|
||||
tmpl = self.env['product.template'].create({
|
||||
'name': 'Handicare Freecurve Stairlift',
|
||||
'x_fc_repair_category_id': self.category.id,
|
||||
'x_fc_maintenance_fee': 199.0,
|
||||
})
|
||||
self.assertEqual(tmpl.x_fc_maintenance_fee, 199.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run the test command. Expected: FAIL — `Invalid field 'x_fc_maintenance_fee' on model 'product.template'`.
|
||||
|
||||
- [ ] **Step 3: Add the field**
|
||||
|
||||
In `product_template.py`, after the `x_fc_maintenance_interval_months` field (line 28):
|
||||
```python
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee (override)', currency_field='currency_id',
|
||||
help='Per-product override of the category maintenance fee. 0 = use the category fee.',
|
||||
)
|
||||
```
|
||||
(`product.template` already provides `currency_id`.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes** — `test_product_fee_override_field_exists` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/models/product_template.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||
git commit -m "feat(fusion_repairs): per-product maintenance fee override"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Contract model extensions (fee, source, serial, policy)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/models/maintenance_contract.py` (add fields after `company_id`, line 81; add indexes near `_booking_token_unique`, line 83)
|
||||
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
```python
|
||||
def test_contract_extension_fields_exist(self):
|
||||
c = self.env['fusion.repair.maintenance.contract'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.env['product.product'].create({'name': 'Unit'}).id,
|
||||
'next_due_date': '2026-12-01',
|
||||
'x_fc_source': 'sale',
|
||||
'x_fc_device_serial': 'SN-123',
|
||||
'x_fc_maintenance_fee': 149.0,
|
||||
})
|
||||
self.assertEqual(c.x_fc_source, 'sale')
|
||||
self.assertEqual(c.x_fc_device_serial, 'SN-123')
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails** — `Invalid field 'x_fc_source' ...`.
|
||||
|
||||
- [ ] **Step 3: Add the fields + indexes**
|
||||
|
||||
In `maintenance_contract.py`, after the `company_id` field (line 81), before `_booking_token_unique`:
|
||||
```python
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee', currency_field='currency_id',
|
||||
help='Flat fee shown to the client for this maintenance visit.',
|
||||
)
|
||||
x_fc_source = fields.Selection(
|
||||
[('sale', 'New Sale'), ('backfill', 'Backfill'),
|
||||
('claims', 'Claims Bridge'), ('manual', 'Manual')],
|
||||
string='Source', default='manual', index=True,
|
||||
)
|
||||
x_fc_source_sale_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Source Sale Line', index=True, copy=False,
|
||||
)
|
||||
x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False)
|
||||
x_fc_policy_category_id = fields.Many2one(
|
||||
'fusion.repair.product.category', string='Maintenance Policy',
|
||||
)
|
||||
```
|
||||
(Idempotency is enforced in Python — Task 4 — to support the two-regime dedup in spec §6.2; the `index=True` above covers lookups.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes** — `test_contract_extension_fields_exist` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/models/maintenance_contract.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||
git commit -m "feat(fusion_repairs): maintenance contract fee/source/serial/policy fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Spawn priced contracts on sale confirm (fix the dead trigger + wire it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/models/maintenance_contract.py` (rewrite `_spawn_maintenance_contracts`, lines 198-227; add `_fc_maintenance_anchor_date` helper)
|
||||
- Modify: `fusion_repairs/models/repair_service_plan.py` (call it in `action_confirm`, before `return res` at line 250)
|
||||
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
```python
|
||||
def _make_product(self, **kw):
|
||||
vals = {'name': 'Stairlift Unit', 'type': 'consu',
|
||||
'x_fc_repair_category_id': self.category.id}
|
||||
vals.update(kw)
|
||||
return self.env['product.product'].create(vals)
|
||||
|
||||
def _confirm_so(self, product, commitment='2026-01-10'):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'commitment_date': commitment,
|
||||
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
|
||||
})
|
||||
so.action_confirm()
|
||||
return so
|
||||
|
||||
def _contracts_for(self, so):
|
||||
return self.env['fusion.repair.maintenance.contract'].search(
|
||||
[('original_sale_order_id', '=', so.id)])
|
||||
|
||||
def test_no_contract_when_category_not_maintainable(self):
|
||||
cat = self.env['fusion.repair.product.category'].create(
|
||||
{'name': 'Cane', 'code': 'cane', 'x_fc_maintenance_enabled': False})
|
||||
so = self._confirm_so(self._make_product(x_fc_repair_category_id=cat.id))
|
||||
self.assertFalse(self._contracts_for(so))
|
||||
|
||||
def test_contract_created_via_category_policy(self):
|
||||
so = self._confirm_so(self._make_product())
|
||||
contracts = self._contracts_for(so)
|
||||
self.assertEqual(len(contracts), 1)
|
||||
c = contracts
|
||||
self.assertEqual(c.interval_months, 6)
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
|
||||
self.assertEqual(c.x_fc_source, 'sale')
|
||||
self.assertEqual(c.x_fc_policy_category_id, self.category)
|
||||
# anchor = commitment_date + 6 months
|
||||
self.assertEqual(str(c.next_due_date), '2026-07-10')
|
||||
|
||||
def test_product_override_beats_category(self):
|
||||
p = self._make_product()
|
||||
p.product_tmpl_id.x_fc_maintenance_interval_months = 3
|
||||
p.product_tmpl_id.x_fc_maintenance_fee = 199.0
|
||||
so = self._confirm_so(p)
|
||||
c = self._contracts_for(so)
|
||||
self.assertEqual(c.interval_months, 3)
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 199.0)
|
||||
|
||||
def test_idempotent_on_reconfirm(self):
|
||||
p = self._make_product()
|
||||
so = self._confirm_so(p)
|
||||
so._spawn_maintenance_contracts() # call again
|
||||
self.assertEqual(len(self._contracts_for(so)), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail** — contracts not created (trigger not wired) → assertions fail.
|
||||
|
||||
- [ ] **Step 3: Rewrite `_spawn_maintenance_contracts` + add the anchor helper**
|
||||
|
||||
Replace the body of `_spawn_maintenance_contracts` (lines 198-227) and add the helper, in the `SaleOrder` class of `maintenance_contract.py`:
|
||||
```python
|
||||
def _fc_maintenance_anchor_date(self, line):
|
||||
"""Best-available delivery anchor: commitment_date -> date_order -> today.
|
||||
(Non-ADP/lift units lack a delivery date; this fallback chain handles them.)"""
|
||||
so = line.order_id
|
||||
anchor = so.commitment_date or so.date_order
|
||||
return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self)
|
||||
|
||||
def _spawn_maintenance_contracts(self):
|
||||
"""Create a priced maintenance contract per maintainable unit on a confirmed SO.
|
||||
Policy = product interval override, else the product's category policy.
|
||||
Idempotent: by serial when captured, else by source sale line."""
|
||||
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
|
||||
for so in self:
|
||||
if so.state not in ('sale', 'done'):
|
||||
continue
|
||||
for line in so.order_line:
|
||||
product = line.product_id
|
||||
if not product:
|
||||
continue
|
||||
tmpl = product.product_tmpl_id
|
||||
category = tmpl.x_fc_repair_category_id
|
||||
product_interval = tmpl.x_fc_maintenance_interval_months or 0
|
||||
cat_enabled = bool(category) and category.x_fc_maintenance_enabled
|
||||
interval = product_interval or (
|
||||
category.x_fc_maintenance_interval_months if cat_enabled else 0)
|
||||
if interval <= 0 or not (product_interval > 0 or cat_enabled):
|
||||
continue
|
||||
fee = tmpl.x_fc_maintenance_fee or (
|
||||
category.x_fc_maintenance_fee if category else 0.0)
|
||||
# Capture serial only if fusion_claims' line field is present.
|
||||
serial = ''
|
||||
if 'x_fc_serial_number' in line._fields:
|
||||
serial = (line.x_fc_serial_number or '').strip()
|
||||
# Idempotency: serial regime vs source-line regime (spec §6.2).
|
||||
if serial:
|
||||
dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)]
|
||||
else:
|
||||
dedup = [('state', '=', 'active'),
|
||||
('x_fc_source_sale_line_id', '=', line.id)]
|
||||
if Contract.search_count(dedup):
|
||||
continue
|
||||
anchor = so._fc_maintenance_anchor_date(line)
|
||||
# One contract per serialized unit; without a serial, per quantity.
|
||||
count = 1 if serial else max(int(line.product_uom_qty or 1), 1)
|
||||
for _i in range(count):
|
||||
Contract.create({
|
||||
'partner_id': so.partner_id.id,
|
||||
'product_id': product.id,
|
||||
'original_sale_order_id': so.id,
|
||||
'x_fc_source_sale_line_id': line.id,
|
||||
'x_fc_source': 'sale',
|
||||
'x_fc_device_serial': serial,
|
||||
'x_fc_policy_category_id': category.id if category else False,
|
||||
'interval_months': interval,
|
||||
'x_fc_maintenance_fee': fee,
|
||||
'next_due_date': anchor + relativedelta(months=interval),
|
||||
'state': 'active',
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire it into the existing `action_confirm`**
|
||||
|
||||
In `repair_service_plan.py`, in `action_confirm`, change line 249-250 from:
|
||||
```python
|
||||
self._fc_spawn_labor_warranties()
|
||||
return res
|
||||
```
|
||||
to:
|
||||
```python
|
||||
self._fc_spawn_labor_warranties()
|
||||
self._spawn_maintenance_contracts()
|
||||
return res
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify the Task-4 tests pass** — all four PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/models/maintenance_contract.py fusion_repairs/models/repair_service_plan.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||
git commit -m "feat(fusion_repairs): spawn priced maintenance contracts on sale confirm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Show the fee in the reminder email
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/data/mail_template_data.xml` (the `email_template_maintenance_due_reminder` record)
|
||||
|
||||
- [ ] **Step 1: Read the current template**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app sh -c "grep -n 'email_template_maintenance_due_reminder' /mnt/odoo-modules/fusion_repairs/data/mail_template_data.xml"
|
||||
```
|
||||
Then open that record's `<field name="body_html">` and find the equipment-name / due-date details table (the green-accent reminder).
|
||||
|
||||
- [ ] **Step 2: Add a fee row to the details table**
|
||||
|
||||
Inside the details table of the reminder body, after the "Next due" row, add (Canadian English, `$` + currency):
|
||||
```xml
|
||||
<tr t-if="object.x_fc_maintenance_fee">
|
||||
<td style="opacity:0.6;width:35%;">Maintenance fee</td>
|
||||
<td><span t-field="object.x_fc_maintenance_fee"
|
||||
t-options='{"widget": "monetary", "display_currency": object.currency_id}'/>
|
||||
<span style="opacity:0.6;"> + applicable tax</span></td>
|
||||
</tr>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Upgrade + manually verify the rendered email**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init
|
||||
```
|
||||
Then in odoo-shell render the template for a contract with a fee and confirm the fee line appears:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo shell -d fusion-dev --no-http <<'PY'
|
||||
c = env['fusion.repair.maintenance.contract'].search([('x_fc_maintenance_fee','>',0)], limit=1)
|
||||
tpl = env.ref('fusion_repairs.email_template_maintenance_due_reminder')
|
||||
print('FEE' if 'applicable tax' in tpl._render_field('body_html', c.ids)[c.id] else 'MISSING')
|
||||
PY
|
||||
```
|
||||
Expected: `FEE`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/data/mail_template_data.xml
|
||||
git commit -m "feat(fusion_repairs): show maintenance fee in due-reminder email"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Expose policy fields in the category form + bump version
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/views/repair_product_category_views.xml`
|
||||
- Modify: `fusion_repairs/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Read the category form view**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app sh -c "grep -n 'fusion.repair.product.category' /mnt/odoo-modules/fusion_repairs/views/repair_product_category_views.xml | head"
|
||||
```
|
||||
Locate the `<form>` for the category.
|
||||
|
||||
- [ ] **Step 2: Add a Maintenance group to the form**
|
||||
|
||||
Inside the category form sheet, add:
|
||||
```xml
|
||||
<group string="Maintenance Policy">
|
||||
<field name="x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_interval_months"
|
||||
invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_fee"
|
||||
invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_service_product_id"
|
||||
invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Bump the version**
|
||||
|
||||
In `fusion_repairs/__manifest__.py`, change `'version': '19.0.2.2.6',` to `'version': '19.0.2.3.0',`.
|
||||
|
||||
- [ ] **Step 4: Upgrade + run the full test module green**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
|
||||
```
|
||||
Expected: all `TestMaintenanceFoundation` tests PASS, 0 failures, module loads.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/views/repair_product_category_views.xml fusion_repairs/__manifest__.py
|
||||
git commit -m "feat(fusion_repairs): category maintenance-policy UI + version 19.0.2.3.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (against the spec)
|
||||
|
||||
- **Spec §2 D2 (flat fee per type):** Tasks 1-2 (policy on category + product override), Task 4 (fee snapshot on contract), Task 5 (fee in email). ✓
|
||||
- **Spec §3.2 gap #1 (dead trigger):** Task 4 fixes + wires `_spawn_maintenance_contracts`. ✓
|
||||
- **Spec §3.2 gap #3 (no cost shown):** Task 5. ✓
|
||||
- **Spec §5.1 / §5.2 (policy + contract fields):** Tasks 1-3. ✓
|
||||
- **Spec §6.1 (new-sale path, delivery anchor, idempotent, serial when present):** Task 4 (`_fc_maintenance_anchor_date`, two-regime dedup, guarded serial capture). ✓
|
||||
- **Deferred to Plan 2:** `x_fc_maintenance_skill_id` (skills representation is §15 open item) — noted in File Structure.
|
||||
- **No placeholders:** every code step shows complete code; the two "read first" steps (Tasks 5-6) target XML whose exact surrounding markup must be read live before editing, and give the exact snippet to insert.
|
||||
- **Type consistency:** `x_fc_maintenance_fee` Monetary + `currency_id` used identically on category, product, contract; `_spawn_maintenance_contracts` / `_fc_maintenance_anchor_date` names consistent between maintenance_contract.py and the call site in repair_service_plan.py.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap — Plans 2–5 (write each when reached; each needs its own live-source reads per spec §15)
|
||||
|
||||
- **Plan 2 — Technician-aware booking** (the largest build): read `fusion_tasks/models/technician_task.py` `_find_next_available_slot` (line 544) / `_get_available_gaps` (line 664) signatures + working-hours source; add `x_fc_maintenance_skill_id` to the category and confirm the `res.users.x_fc_repair_skills` representation; replace the `<input type="date">` booking page with a real slot-picker controller; on confirm create a `fusion.technician.task` (`task_type='maintenance'`) + the maintenance `repair.order`; double-book guard; office "Book maintenance" action; per-cycle `booking_token` regen in `roll_next_due_date`. Delivers: real self-serve booking.
|
||||
- **Plan 3 — Maintenance visit log + checklist**: read the visit-report wizard + the inspection-certificate (M1) API; add `fusion.repair.maintenance.visit` + `fusion.repair.maintenance.checklist.line`; seed checklists per category; issue an inspection certificate for `safety_critical` categories. Delivers: queryable per-unit history + compliance proof.
|
||||
- **Plan 4 — Backfill wizard** (two-regime, spec §6.2): `fusion.repair.maintenance.backfill.wizard`; serial dedup for ADP wheelchairs (guarded `fusion_claims` read), partner+base-product+sale-line dedup for lifts with accessory-line exclusion; stagger; dry-run report → execute. Delivers: the existing install base enrolled.
|
||||
- **Plan 5 — Office follow-up crons**: `unbooked` + `overdue` crons gated on the existing `ir.config_parameter` toggles; per-row savepoint isolation. Delivers: staff nudges when clients don't self-serve.
|
||||
@@ -0,0 +1,298 @@
|
||||
# NexaCloud→Odoo Cutover — Plan 01: Odoo subscription-cancel endpoint
|
||||
|
||||
> **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:** Add the one inbound endpoint NexaCloud's deprovision path needs — cancel (close) a subscription — to `fusion_centralize_billing`, with the same auth model the other endpoints already use.
|
||||
|
||||
**Architecture:** New `fusion.billing.service._api_cancel_subscription(external_ref)` resolves the subscription via the existing `_fc_resolve_subscription`, enforces the same "partner must be linked to this service" authorization as `_api_record_usage`, and closes it with Odoo 19's native `set_close()` (→ `subscription_state='6_churn'`). A `DELETE /api/billing/v1/subscriptions/<ref>` route wraps it.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise (`sale_subscription`), Python, Odoo `TransactionCase` tests.
|
||||
|
||||
**Spec:** [`2026-06-02-nexacloud-odoo-billing-cutover-design.md`](../specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md) §4.1.3
|
||||
|
||||
---
|
||||
|
||||
## ⚠ Test harness (supersedes any `-d nexamain` command below)
|
||||
|
||||
**NEVER run `-u` / `--test-enable` against the live `nexamain` DB.** Tests run in an **isolated throwaway container** against a dedicated DB, reading a **separate** addons copy so the live module is never touched:
|
||||
|
||||
```
|
||||
# 1) edit files on branch feat/nexacloud-odoo-billing-cutover, then sync the changed
|
||||
# module files to the staging addons copy on odoo-nexa:
|
||||
# /opt/odoo/custom-addons-staging/fusion_centralize_billing/...
|
||||
# 2) run (ssh odoo-nexa):
|
||||
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 \
|
||||
--test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel \
|
||||
-u fusion_centralize_billing --stop-after-init --no-http
|
||||
```
|
||||
- `fcb_test` is a **fresh** install DB (not a prod clone). `nexamain_staging` is a prod clone kept for later integration/importer plans.
|
||||
- **Scope each step's run to the relevant test class** (`:TestSubscriptionCancel`, `:TestSubscriptionCancelHttp`). The wider suite is **not hermetic yet** (see Plan 00) — `test_invoice_ledger` needs a configured Canadian CoA/active CAD/HST; `test_usage`/`test_webhook` collide with cloned prod data. Don't gate this plan on those.
|
||||
- The per-step `Run:` blocks below that mention `-d nexamain` are **illustrative only — use this harness instead.**
|
||||
|
||||
> **Prerequisite — Plan 00 (make the suite hermetic):** before green-baseline TDD, fix fixtures so the whole suite passes on `fcb_test`: `setUp` should get-or-create the `nexacloud`/`cpu_seconds` records (idempotent), and a test-setup helper must ensure an active CAD currency + a Canadian CoA + a 13% HST sale tax. Tracked as its own plan; recommended before Plan 01 execution.
|
||||
|
||||
---
|
||||
|
||||
## Increment plan sequence (this is Plan 01 of 6)
|
||||
|
||||
Each is its own plan doc + its own working, testable deliverable. Order reflects dependencies:
|
||||
|
||||
1. **Odoo: subscription-cancel endpoint** ← *this doc* (unblocked; no external decisions).
|
||||
2. **Odoo: NexaCloud charge catalog** — products + `sale.subscription.plan` (`NC-PLAN-*`) + `fusion.billing.charge` (cpu_seconds quota/overage). **Blocked on confirming real NexaCloud plan pricing/quotas** (open review Q#1) before it can be written placeholder-free.
|
||||
3. **Odoo: importer go-forward subscriptions** — extend `wizards/import_wizard.py` to create one shadow `sale.order` per active deployment with go-forward `next_invoice_date`; the safety test that asserts **no past-period invoice** is the centrepiece (guards against the 2026-05-27 Lago re-bill).
|
||||
4. **NexaCloud: adapter activation** — config (`odoo_billing_base_url`/`api_key`/staged enable), customer + subscription create/cancel calls, reconciliation-amount push.
|
||||
5. **NexaCloud: control-loop receiver** — activate `/billing/webhooks/central` HMAC verify → suspend/restore/deprovision via `network_isolation`/`throttle_checker`/`resource_manager`.
|
||||
6. **Dual-run + gated flip** — operational runbook: shadow ≥1 cycle, reconcile to cent, then the reversible flip flag.
|
||||
|
||||
---
|
||||
|
||||
## File structure (this plan)
|
||||
|
||||
- Modify: `fusion_centralize_billing/models/service.py` — add `_api_cancel_subscription`.
|
||||
- Modify: `fusion_centralize_billing/controllers/api.py` — add `DELETE /subscriptions/<ref>`.
|
||||
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py` — service-method + authorization tests.
|
||||
- Modify: `fusion_centralize_billing/tests/__init__.py` — import the new test module.
|
||||
|
||||
Run tests (from `K:\Github\CLAUDE.md` workflow, adapted to odoo-nexa):
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `_api_cancel_subscription` service method
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/models/service.py` (add method after `_api_create_subscription`, ~line 250)
|
||||
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py`
|
||||
- Modify: `fusion_centralize_billing/tests/__init__.py`
|
||||
|
||||
- [ ] **Step 0: Verify the Odoo 19 close method (do NOT code from memory — per `K:\Github\CLAUDE.md`)**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app grep -nE 'def set_close|def set_open|6_churn' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head"
|
||||
```
|
||||
Expected: a `def set_close(self...)` exists and sets `subscription_state='6_churn'`. If the method name differs in this build, use the actual name in Step 3 and the assertion in Step 1.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `fusion_centralize_billing/tests/test_subscription_cancel.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSubscriptionCancel(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.product = self.env['product.product'].sudo().create(
|
||||
{'name': 'NexaCloud Plan', 'type': 'service',
|
||||
'recurring_invoice': True, 'list_price': 49.0})
|
||||
self.svc_a = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
self.svc_b = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'Other', 'code': 'other'})
|
||||
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
|
||||
res = self.svc_a._api_create_subscription({
|
||||
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
|
||||
self.sub = self.env['sale.order'].browse(res['subscription_id'])
|
||||
|
||||
def test_cancel_closes_subscription(self):
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_is_idempotent(self):
|
||||
self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_unknown_subscription_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('999999999')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
|
||||
def test_cancel_cross_service_rejected(self):
|
||||
# svc_b is not linked to the customer that owns self.sub
|
||||
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
|
||||
def test_cancel_missing_id_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
```
|
||||
|
||||
Append to `fusion_centralize_billing/tests/__init__.py`:
|
||||
```python
|
||||
from . import test_subscription_cancel
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: FAIL — `AttributeError: 'fusion.billing.service' object has no attribute '_api_cancel_subscription'`.
|
||||
|
||||
- [ ] **Step 3: Implement the method**
|
||||
|
||||
In `fusion_centralize_billing/models/service.py`, add immediately after `_api_create_subscription`:
|
||||
```python
|
||||
def _api_cancel_subscription(self, external_ref):
|
||||
"""Cancel (close) the subscription identified by ``external_ref``.
|
||||
|
||||
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
|
||||
exist, be a subscription, and belong to a customer THIS service is linked
|
||||
to. Idempotent — closing an already-churned subscription returns ok.
|
||||
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if external_ref in (None, ''):
|
||||
return {'status': 'error', 'error': 'subscription id required'}
|
||||
sub = self._fc_resolve_subscription(external_ref)
|
||||
linked_partners = self.account_link_ids.mapped('partner_id')
|
||||
if not sub.exists() or not sub.is_subscription \
|
||||
or sub.partner_id not in linked_partners:
|
||||
return {'status': 'error', 'error': 'unknown subscription'}
|
||||
if sub.subscription_state != '6_churn':
|
||||
sub.set_close()
|
||||
return {'status': 'ok', 'subscription_id': sub.id,
|
||||
'subscription_state': sub.subscription_state}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: PASS — 5 tests, 0 failures. (If `set_close()` was a different name in Step 0, use that name here and re-run.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_subscription_cancel.py fusion_centralize_billing/tests/__init__.py
|
||||
git commit -m "feat(billing): add _api_cancel_subscription (close sub, service-scoped authz)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `DELETE /subscriptions/<ref>` route
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/controllers/api.py` (add route after `post_subscription`, ~line 95)
|
||||
- Modify: `fusion_centralize_billing/tests/test_subscription_cancel.py` (add an HTTP-layer test)
|
||||
|
||||
- [ ] **Step 1: Write the failing test (HTTP layer)**
|
||||
|
||||
Append to `tests/test_subscription_cancel.py` a class that exercises the route through Odoo's test client. Add the import at the top of the file:
|
||||
```python
|
||||
from odoo.tests import HttpCase
|
||||
```
|
||||
Then append:
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSubscriptionCancelHttp(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.product = self.env['product.product'].sudo().create(
|
||||
{'name': 'NexaCloud Plan', 'type': 'service',
|
||||
'recurring_invoice': True, 'list_price': 49.0})
|
||||
self.svc = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
self.raw_key = self.svc.action_generate_api_key()
|
||||
self.svc._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
|
||||
res = self.svc._api_create_subscription({
|
||||
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
|
||||
self.sub_id = res['subscription_id']
|
||||
self.env.cr.commit()
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
self.env['sale.order'].browse(self.sub_id).sudo().unlink()
|
||||
|
||||
def test_delete_requires_auth(self):
|
||||
resp = self.url_open(
|
||||
"/api/billing/v1/subscriptions/%s" % self.sub_id,
|
||||
method='DELETE')
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
def test_delete_cancels_with_valid_key(self):
|
||||
resp = self.url_open(
|
||||
"/api/billing/v1/subscriptions/%s" % self.sub_id,
|
||||
method='DELETE',
|
||||
headers={'Authorization': 'Bearer %s' % self.raw_key})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()['subscription_state'], '6_churn')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: FAIL — the DELETE route returns 404 (route not registered) so the assertions fail.
|
||||
|
||||
- [ ] **Step 3: Implement the route**
|
||||
|
||||
In `fusion_centralize_billing/controllers/api.py`, add after `post_subscription`:
|
||||
```python
|
||||
@http.route(f"{API_BASE}/subscriptions/<sub_ref>", type="http", auth="none",
|
||||
methods=["DELETE"], csrf=False)
|
||||
def delete_subscription(self, sub_ref, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
result = service._api_cancel_subscription(sub_ref)
|
||||
if result.get("status") == "error":
|
||||
status = 404 if result.get("error") == "unknown subscription" else 400
|
||||
return self._json(result, status=status)
|
||||
return self._json(result)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: PASS — 2 tests, 0 failures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/controllers/api.py fusion_centralize_billing/tests/test_subscription_cancel.py
|
||||
git commit -m "feat(billing): DELETE /api/billing/v1/subscriptions/<ref> cancel route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** §4.1.3 "add subscription cancel (`DELETE /subscriptions/:id`)" → Tasks 1+2. ✔
|
||||
- **Placeholder scan:** none — all code is concrete; Step 0 verifies the one Odoo-internal name (`set_close`) against the live container instead of assuming.
|
||||
- **Type consistency:** `_api_cancel_subscription` returns the same `{'status','subscription_id','subscription_state'}` shape as `_api_create_subscription`; error shape matches `_api_record_usage` (`{'status':'error','error':...}`); resolver reused (`_fc_resolve_subscription`) so cross-service rejection is identical to `/usage`. ✔
|
||||
- **Authorization parity:** cancel uses the exact `not sub.exists() or not sub.is_subscription or sub.partner_id not in linked_partners` guard as `_api_record_usage`. ✔
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user