Compare commits

..

41 Commits

Author SHA1 Message Date
gsinghpal
fb345be375 fix(fusion_helpdesk): close IDOR + reliability gaps from code review
- P0 IDOR: escape LIKE wildcards in build_scope_domain + normalise emails (email_normalize) on both write and scope sides, so a self-set email of '%' can no longer match every ticket in a deployment (+2 regression tests).
- Convert XML-RPC ProtocolError (central 502/503/429) to _RemoteError instead of a raw 500.
- _resolve_author: log + post as service account on failure instead of silently impersonating the ticket customer.
- _mark_seen now best-effort (runs after the remote post) so a bookkeeping failure can't report a successful reply as failed (no duplicate replies).
- Attachment loop catches network errors + reports failed count (no duplicate-ticket trap); dialog surfaces failures.
- console.error in JS catches for diagnosability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:53:37 -04:00
gsinghpal
6ce49573ef fix(fusion_helpdesk): reply body_is_html=True so HTML isn't escaped over XML-RPC
Remote message_post escapes a plain str body (expects Markup, which can't cross XML-RPC). Without body_is_html=True the customer's reply rendered literal <p>/<br> tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:41:04 -04:00
gsinghpal
9dceff5baf feat(fusion_helpdesk_central): client-label field, views, branded ack email
x_fc_client_label on helpdesk.ticket (+ list/search exposure), create() override sends a branded acknowledgement (magic link via get_portal_url) for in-app-channel tickets only. 3 Enterprise tests (run on nexa).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:38:39 -04:00
gsinghpal
b6e7ad3485 feat(fusion_helpdesk): embedded ticket inbox UI + unread badge
Dialog gains New/My Tickets tabs, ticket list (with admin Mine/All toggle + unread dots), thread view with inline reply, confirmed reply-email field, and an Open-full-ticket magic link. Systray shows an unread-reply badge (polled). Controller returns portal_url + per-message attachment counts. Assets compile clean (light+dark).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:32:48 -04:00
gsinghpal
304e19ce45 feat(fusion_helpdesk): identity keystone in submit + scoped inbox endpoints
submit() now sends partner_email/partner_name/x_fc_client_label. New jsonrpc endpoints my_tickets/ticket_detail/ticket_reply/unread_count, all auth=user with server-side scoping (build_scope_domain), internal-note filtering, and graceful remote-failure handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:28:15 -04:00
gsinghpal
96b3441997 fix(billing): resolve code-review findings (authz, cross-billing, validation, webhook integrity)
- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
       controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
       caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints

Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:27:34 -04:00
gsinghpal
33fc046fa2 feat(fusion_helpdesk): identity keystone helpers, seen model, admin group
Phase 1-2: build_ticket_vals/scope/unread helpers (utils.py), fusion.helpdesk.ticket.seen read-tracking model, group_reporter_admin. 10 unit tests pass on local Community.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:23:31 -04:00
gsinghpal
960fe67966 docs(helpdesk): TDD implementation plan (16 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:19:24 -04:00
gsinghpal
2899e43d84 docs(helpdesk): customer follow-up + embedded ticket inbox design
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:11:36 -04:00
gsinghpal
e71cae6dc9 feat(billing): usage-rating + webhook-dispatch crons
- SaleOrder._fc_rate_usage: aggregates usage, computes overage via
  charge._compute_billable, upserts sale.order.line for the overage product
- FusionBillingUsage._cron_rate_open_periods: hourly cron iterates active
  charges × in-progress subscriptions, calls _fc_rate_usage
- data/ir_cron.xml: two crons — rate usage (hourly), dispatch webhooks (2 min)
- __manifest__.py: registers data/ir_cron.xml in data list
- test_usage.py: test_rate_open_period_creates_overage_line (TDD, FCB_EXIT=0)

Reference: _create_recurring_invoice / _get_invoiceable_lines confirmed in
Enterprise sale_subscription/models/sale_order.py — overage line goes onto
sale.order so native invoicing picks it up via _get_invoiceable_lines.
2026-05-27 03:08:45 -04:00
gsinghpal
7cdee2e7ce feat(billing): wire HTTP controllers to API handlers 2026-05-27 03:05:55 -04:00
gsinghpal
b3b3e61f01 feat(billing): outbound webhook engine (HMAC + retry/backoff)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:04:05 -04:00
gsinghpal
b47af5aea4 feat(billing): subscription creation handler (sale.order is_subscription)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:59:17 -04:00
gsinghpal
4e8638e3a5 feat(billing): inbound API handlers (customer/usage/catalog)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:56:04 -04:00
gsinghpal
e7f6e46d09 feat(billing): period usage aggregation by metric function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:53:47 -04:00
gsinghpal
4ecc1a3aaa feat(billing): idempotent usage ingestion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:52:05 -04:00
gsinghpal
b3bc6ce342 feat(billing): metered charge math (quota + overage)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:50:14 -04:00
gsinghpal
e532b30d11 feat(billing): identity resolution external account -> partner 2026-05-27 02:47:27 -04:00
gsinghpal
08e528e5be feat(billing): service API-key generation + matching
Add _match_api_key() class method to fusion.billing.service, with a
TDD test suite (TestServiceApiKey) covering key generation, hash storage,
positive match, and rejection of bad/inactive keys. Also fix
fcb_test_on_trial.sh to use --http-port 8070, as Odoo 19 forces
http_spawn() even under --no-http when --test-enable is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:44:30 -04:00
gsinghpal
096de38315 test(billing): odoo-trial Enterprise test runner + plan test-env fix
Local dev Odoo is Community (can't install the module). Add a guest-exec runner
that syncs the module to the odoo-trial Enterprise sandbox (VM 316, db trial) and
runs --test-enable there; pass = FCB_EXIT=0. Scaffold verified installing on
Odoo 19.0 Enterprise (7 fusion_billing_* tables created).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:37:20 -04:00
gsinghpal
dec557c88d docs(billing): core engine implementation plan (TDD, 11 tasks) 2026-05-27 02:24:19 -04:00
gsinghpal
773608f17b feat(billing): design + scaffold fusion_centralize_billing
Centralize billing for all NexaSystems services (NexaCloud, NexaDesk,
NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance,
replacing Lago. The module adds only the metering + integration layer;
native sale_subscription / account_accountant / payment_stripe do all the
financial work (invoicing, HST, dunning, portal, credit notes, Stripe).

Includes:
- Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md):
  6 locked decisions, architecture, data model, usage engine, Lago-shaped
  API, webhook control loop, NexaCloud pilot, phased dual-run migration.
- Module scaffold: 7 fusion.billing.* models (service, account.link, metric,
  charge, usage, webhook, reconciliation), bearer-auth API controller shell,
  security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs
  pending the implementation plan.
- CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription
  is a sale.order(is_subscription) + sale.subscription.plan (verified live).

Task 0 verified: a single Stripe account is shared across NexaCloud and all
Lago providers, so no Stripe account/card migration is required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:14:19 -04:00
gsinghpal
c07a79c260 fix(fusion_login_audit): avoid duplicate row on bad-password
When the login string resolves to an existing user and the password is
wrong, BOTH overrides used to write a failure row:
  - _check_credentials wrapper: result=failure, reason=bad_password
  - _login wrapper (catching the propagating AccessDenied): result=
    failure, reason=unknown_user

Discovered in production smoke on westin-v19 after the deploy: a
single failed login for info@gsafinancialconsulting.com produced two
audit rows (one bad_password, one unknown_user). The unknown_user
label was wrong — the user IS in the system.

Fix: _login now checks whether the login string resolves to any user
BEFORE writing the unknown_user row. If yes, _check_credentials
already logged the attempt and _login skips. If no, the user lookup
in super() failed and _login is the only chance to log.

Regression test test_login_known_user_bad_password_single_row asserts
exactly one row per attempt and that the row carries bad_password
(not unknown_user) when the user exists.

30 tests green locally; production smoke on westin-v19 confirms:
one row per failed login, bad_password, IP 172.18.0.1 captured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:02:20 -04:00
gsinghpal
73d7052f7b chore(fusion_login_audit): release 19.0.1.0.0
Module is feature-complete per
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md:
- T1  Module skeleton + icon
- T2  fusion.login.audit model (16 fields, declarative Constraint+3 Indexes)
- T3  Security: ACL + admin-only record rule + 5 tests
- T4  _fc_build_event_vals context helper (UA parse, password safety)
- T5  Success hook: _update_last_login -> result=success row
- T6  Bad-password hook: _check_credentials wrapped
- T7  Unknown-user hook: _login override (instance method in 19)
- T8  res.users smart button + Login Activity tab (4 x_fc_* fields)
- T9  Standalone list/form/search/kanban + 2 actions + 3 menus
- T10 res.config.settings + General Settings section (4 knobs)
- T11 Failure-burst alert email + 60-min cooldown
- T12 Daily retention GC cron
- T13 5-min async geo enrichment cron (private/cache/HTTP)
- T14 View-visibility security tests for non-admin
- 29 tests pass; both crons active; 3 menus installed.

Out of scope for v1 (documented in spec): API-key auth, OAuth/SSO,
per-user self-service view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:52:05 -04:00
gsinghpal
0761ccacbb docs(plan): inline corrections from T11/T12/T13 execution
Capture in the plan the Odoo 19 gotchas discovered during execution
that the original plan template missed:
- Test command requires --http-port=0 --gevent-port=0 (running
  container holds 8069).
- Declarative models.Constraint / models.Index (T2).
- res.users.groups_id renamed to group_ids (T3, T6).
- ir.rule groups is additive not restrictive (T3).
- mail.template inline-template ctx IS env.context (T11).
- ir.cron has no numbercall field in 19 (T12).
- registry.cursor() in tests is TestCursor; cr.commit() raises;
  use savepoints (T13).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:52:05 -04:00
gsinghpal
40b93c9035 test(fusion_login_audit): view-visibility checks for admin vs non-admin
Asserts the smart-button and Login Activity tab fields are stripped
from res.users get_view() for non-admin users, and present for
Settings admins. Locks down the contract behind the
groups="base.group_system" XML attributes on the form-inheritance
view (the inherited view record cannot carry groups itself per
CLAUDE.md rule #11; the gate must live on the inner nodes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:51:24 -04:00
gsinghpal
2e8c31cca0 feat(fusion_login_audit): async geo enrichment cron
5-min cron processes up to 100 pending rows per pass: private IPs
short-circuit to state=private_ip; same-IP cache (30 days) avoids
duplicate ip-api.com calls; reverse DNS via socket with 1.5s timeout;
HTTP lookup respects ip-api''s X-Rl rate-limit header. Tests cover
private-IP shortcut, cache hit (no HTTP), and internal-state skip --
no network calls needed.

Per-row isolation uses cr.savepoint() instead of cr.commit() because
Odoo 19 TestCursor raises AssertionError on commit/rollback. Recorded
the gotcha as CLAUDE.md rule #14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:50:24 -04:00
gsinghpal
821709d413 feat(fusion_login_audit): nightly retention GC cron
Adds _fc_retention_gc() that deletes rows older than the configured
horizon (default 365 days; 0 = keep forever). Registered as a daily
ir.cron. Tests verify both the delete path and the "keep forever"
short-circuit.

Also documents the Odoo 19 gotcha that ir.cron dropped the numbercall
field (the legacy "-1 = run forever" pattern now raises ValueError at
install time; just omit the field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:46:23 -04:00
gsinghpal
94bab191f5 feat(fusion_login_audit): failure-burst alert email + cooldown
Mail template + helpers (_fc_alert_*, _fc_recent_failure_count,
_fc_send_failure_alert) wired into _check_credentials so that crossing
the consecutive-failure threshold within the window queues exactly one
mail.mail per attempted login per 60-minute cooldown. Master switch
x_fc_login_audit_alert_enabled honoured. Recipients are members of
base.group_system with a non-empty email and share=False; the
__system__ superuser is excluded by Odoo''s default user filter.

Tests (3 new, 22 total green):
  test_failure_burst_queues_one_email
  test_cooldown_suppresses_second_alert
  test_alert_disabled_master_switch

setUp ensures base.user_admin has an email (fusion-dev''s admin user
ships without one; the only user with an email is __system__, which
is filtered out of standard res.users searches).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:43:18 -04:00
gsinghpal
9b82113310 feat(fusion_login_audit): settings model + page section
Four x_fc_* fields on res.config.settings backed by ir.config_parameter:
retention_days (default 365, 0 = forever), alert_threshold (5),
alert_window_min (15), alert_enabled (True). New "Login Audit" block
on the General Settings page (gated by base.group_system on the block,
NOT on the inherited view record per CLAUDE.md rule #11).

CLAUDE.md gotchas added during this task:
  #5 Boolean config_parameter fields don't round-trip "False" as a
     string — IrConfigParameter.set_param deletes the row on falsy.
     Test with assertFalse, never assertEqual(..., "False").
  #6 ir.ui.view uses group_ids (Odoo 19 rename mirrored from res.users).
     Setting groups_id on an ir.ui.view record raises ValueError at
     install. (The XML attribute groups="..." on inner nodes is
     unrelated and still works.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:27:13 -04:00
gsinghpal
f4d22d6e90 feat(fusion_login_audit): standalone views + menus
List, form, and search views for fusion.login.audit, plus a "Login
Events" full-history action and a "Failed Logins (24h)" pre-filtered
action. Both surface under Settings -> Technical -> Login Audit
(menu items gated by base.group_system). Views are no-create / no-edit
/ no-delete to enforce append-only at the UI layer too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:23:05 -04:00
gsinghpal
69b210256a feat(fusion_login_audit): smart button + Login Activity tab on res.users
Adds four x_fc_* fields on res.users: login_audit_ids (One2many),
login_audit_count (compute), last_successful_login (compute, stored),
last_login_ip (compute, stored). action_fc_view_login_audit returns
a window action scoped to the current user. View inheritance adds a
smart button to the button box and a "Login Activity" page to the
notebook, both gated by base.group_system on the inner XML nodes
(NOT on the view record — Odoo 19 forbids that; see CLAUDE.md rule #11).

Tests (2 new, 18 total green):
  test_computed_last_successful_login — uses registry cursor to commit
    the audit row so the stored compute picks it up across the
    TransactionCase boundary.
  test_action_view_login_audit_returns_window_action — smart-button
    action shape + domain scoping.

CLAUDE.md rule #11 added: inherited ir.ui.view records cannot have
groups/group_ids on the record; the gate must be on the inner XML nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:20:08 -04:00
gsinghpal
e827e5b97c feat(fusion_login_audit): hook unknown-user failures via _login
Overrides res.users._login. When the login string does not resolve to
any user, super() raises AccessDenied; we record a row with user_id=NULL
and failure_reason="unknown_user", then re-raise. Closes the gap where
typo'd or scanned logins would otherwise vanish from the audit trail.

The existing _fc_record_login_event helper writes through an independent
registry.cursor(), so the audit row survives the rollback that follows
the re-raised AccessDenied.

Note: in Odoo 19 _login is a plain instance method (not the classmethod
it was in earlier versions) and takes (credential, user_agent_env). The
original plan was written for the classmethod signature; corrected here
and recorded in CLAUDE.md rule #10 so future-Claude does not waste time
re-discovering it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:11:57 -04:00
gsinghpal
69506a747d feat(fusion_login_audit): hook bad-password failures via _check_credentials
Wraps res.users._check_credentials. On AccessDenied, records a row with
result=failure and failure_reason='bad_password' (or '2fa_failed' when
credential['type'] == 'totp'), then re-raises. Regression test asserts
the attempted password value never lands in any audit field.

The audit row is written through registry.cursor() (independent cursor) so
it survives the rollback that follows AccessDenied — in production
odoo/service/model.py::retrying resets the transaction and http.py closes
the cursor without committing, in tests assertRaises opens its own
savepoint. Either way an inline write would vanish. Tests
enter registry_test_mode and use manual try/except to keep the audit row
visible across the savepoint hierarchy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:03 -04:00
gsinghpal
509fbdf438 feat(fusion_login_audit): hook successful login via _update_last_login
Overrides res.users._update_last_login to create a fusion.login.audit
row with result=success after the parent runs. The write goes through
sudo() + mail_create_nolog=True. Any exception in the audit path is
caught and logged but never propagates — a broken audit table must
never block a real user from logging in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:49:14 -04:00
gsinghpal
0aa6b408c4 feat(fusion_login_audit): add _fc_build_event_vals context helper
Single helper builds vals for fusion.login.audit rows from the live
HTTP request, or falls back to ip=''internal'' + geo_lookup_state=''internal''
when there is no request. Parses UA into browser/os/device_type via the
bundled user_agents library. Never reads credential[''password'']. Tests
cover: no-request fallback, UA parsing on a Chrome/Windows UA, and the
regression that no password value leaks into the vals dict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:47:19 -04:00
gsinghpal
678dced9e1 feat(fusion_login_audit): admin-only record rule + security tests
Record rule grants admins an unrestricted domain on the audit log;
ACL forbids write/create/unlink for every group (audit is append-only;
sudo() inside auth hooks is the only write path). Defence-in-depth
layering: ACL is the actual gate, the rule documents and locks down
admin access path.

Tests (5, all green) cover:
  test_admin_can_read_through_acl_and_rule — positive path through both.
  test_acl_blocks_read_for_regular_user    — base.group_user denied by ACL.
  test_acl_blocks_read_for_portal_user     — base.group_portal share user
                                             denied (sensitive data leakage
                                             surface closed at ACL layer).
  test_acl_blocks_write_for_admin          — append-only at the write boundary.
  test_acl_blocks_unlink_for_admin         — append-only at the unlink boundary.

Drop the redundant `from . import tests` from the root __init__.py —
Odoo's test loader imports `odoo.addons.<mod>.tests` directly; the
extra import was dead weight (and inconsistent with the repo pattern).

CLAUDE.md gotchas added during this task:
  #6 res.users.groups_id -> group_ids rename (test setUp pitfall).
  #6 ir.rule `groups` is additive, not restrictive — group-scoped
     rules only apply to users in that group, they do not restrict
     non-members. Default to letting the ACL gate; use rules for
     row-level filters ACLs cannot express.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:38:43 -04:00
gsinghpal
b28ac34ed5 feat(fusion_login_audit): add fusion.login.audit model
- All 16 columns per spec (user, attempted_login, result, failure_reason,
  event_time, ip/geo fields, user_agent triple, device_type, database).
- Check constraint binds failure_reason presence to result value.
- Three composite indexes (user+time, login+time, geo_state+time) supporting
  the per-user, failure-burst, and geo cron queries.
- Minimal admin-read ACL added so subsequent tests can verify writes.
- 3 TransactionCase tests passing: model create, failure_reason nullable on
  success, geo_lookup_state='internal' accepted.

Odoo 19 deprecation note: this implementation uses the declarative
models.Constraint and models.Index attributes (Odoo 19 silently drops the
legacy `_sql_constraints = [...]` list and `init()`/raw-SQL pattern with
only a warning). Captured in CLAUDE.md rule #9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:20:17 -04:00
gsinghpal
658ead5cdf feat(fusion_login_audit): module skeleton + icon
Empty installable module with manifest, package inits, and icon.
Subsequent tasks add the audit model, hooks, views, and tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:09:14 -04:00
gsinghpal
481ed4fdf8 docs(plan): fusion_login_audit implementation plan
15 TDD tasks targeting ~28 tests:
T1 skeleton+icon, T2 model+indexes, T3 security, T4 capture helper,
T5 success hook, T6 bad-password hook, T7 unknown-user hook, T8 user
form (smart button + tab + computes), T9 standalone views + menus,
T10 settings + page section, T11 failure-burst alert + cooldown,
T12 retention GC cron, T13 async geo enrichment cron, T14 view
visibility security tests, T15 manual smoke + release tag.

Self-reviewed: every spec section maps to a task; no placeholders;
method and field names consistent across tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:02:11 -04:00
gsinghpal
cc26b9ad6d docs(spec): fusion_login_audit design
Durable login audit for Odoo 19 (westin-v19). Captures successful and
failed authentications via _update_last_login / _check_credentials /
_login overrides, surfaces history on res.users as a smart button +
"Login Activity" tab (admins-only), async geo-enriches IPs via ip-api.com
through network_logger, 365-day retention with daily GC cron, and
emails Settings admins on N consecutive failures for the same login
within a configurable window.

Motivation: a spot audit of GSA Accounting (uid 63) showed Odoo's
res_users_log keeps only one row per user (rest is GC'd), /var/log/odoo
is empty (warn-level stdout logging), and the container json log
rotates within days — leaving no durable login trail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:52:44 -04:00
8545 changed files with 1878423 additions and 164402 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"WebFetch(domain:docs.clover.com)"
]
},
"hooks": {
"UserPromptSubmit": [],
"Stop": [],
"Notification": []
}
}

53
.gitignore vendored
View File

@@ -15,56 +15,3 @@ __pycache__/
# Local-only diagnostic logs from test runs
_test_*.log
# --- Split-out module repos (now independent git repos; managed separately) ---
/disable_iap_calls/
/disable_odoo_online/
/disable_publisher_warranty/
/fusion_accounts/
/fusion_api/
/fusion_canada_post/
/fusion_centralize_billing/
/fusion_chatter_enhance/
/fusion_claims/
/fusion_clock/
/fusion_clock_ai/
/fusion_clover/
/fusion_digitize/
/fusion_faxes/
/fusion_helpdesk/
/fusion_helpdesk_central/
/fusion_inventory/
/fusion_loaners_management/
/fusion_login_audit/
/fusion_ltc_management/
/fusion_notes/
/fusion_odoo_fixes/
/fusion_payroll/
/fusion_pdf_preview/
/fusion_planning/
/fusion_portal/
/fusion_poynt/
/fusion_rental/
/fusion_repairs/
/fusion_reports_templates/
/fusion_ringcentral/
/fusion_schedule/
/fusion_service_charges/
/fusion_shipping/
/fusion_so_to_po/
/fusion_tasks/
/fusion_templates/
/fusion_theme_switcher/
/fusion_voip_ringcentral/
/fusion_whitelabels/
/network_logger/
/nexa_coa_setup/
/fusion_plating/
/fusion_accounting/
/fusion_iot/
/fusion_labels/
/fusion_projects/
/fusion-statements/
/fusion-woo-odoo/
/fusion-expenses/
/fusion_configurator/

View File

@@ -1,6 +0,0 @@
# graphify: skip vendored / minified third-party assets — not first-party code
**/static/lib/
**/static/src/lib/
**/static/**/*.min.js
*.min.js
*.min.css

View File

@@ -77,7 +77,6 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
## Cursor-Managed Modules
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
- **fusion_repairs** — status and deferred work: [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) (bundles 111 shipped at `19.0.2.2.4`; not production-deployed)
## Workflow
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`

164
CLAUDE.md
View File

@@ -13,7 +13,7 @@
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
6. **res.groups**: NO `users` field, NO `category_id` field. **The Odoo 19 replacement for `category_id` is `res.groups.privilege`.** To make a module's groups appear as application-access dropdowns on the user form (Settings → Users → *Application Accesses*) instead of only in developer mode: define an `ir.module.category`, a `res.groups.privilege` (with `category_id` → that category), and set each group's `privilege_id` → that privilege. Groups under one privilege that form an `implied_ids` chain render as a single role dropdown; a standalone group in its own privilege renders as a separate row under the same category header. Verified in `fusion_clock/security/security.xml`; mirrors `fusion_plating`/`fusion_tasks`.
6. **res.groups**: NO `users` field, NO `category_id` field.
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
@@ -33,10 +33,6 @@
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u <newname>`; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... import`.
17. **`url_encode` (and werkzeug url helpers) are NOT available in the Odoo 19 `mail.template` QWeb render context.** Using `url_encode({...})` inside a template `body_html` (e.g. to build a fallback link) makes the template fail Odoo's save-time render validation **at install**, surfacing as the opaque `ParseError: ... Oops! We couldn't save your template due to an issue with this value: <the entire body html>` (the real `NameError` is hidden, and `--log-handler odoo.tools.convert:DEBUG` does NOT reveal it). Build URLs with plain string methods instead: `'https://…?q=' + (value or '').replace(' ', '+')`. Found installing `fusion_repairs` (post-visit NPS template). **That same opaque "issue with this value" error wraps ANY render failure in a mail.template body** — when you see it, suspect an undefined name / bad field reference in the template, not malformed XML.
## Card Styling — Copy Odoo's Kanban Pattern
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
```css
@@ -96,10 +92,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
- Canadian English for all user-facing text
- Currency: `$` sign with Monetary fields + currency_id
## 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.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).
## Cursor-Managed Modules
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
## Workflow
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
@@ -140,155 +134,3 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
- `fusionapps.issues` — known issues and fixes
- `fusionapps.code_snippets` — reference code
- `fusionapps.quick_commands` — deployment and admin commands
## 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)
and **`fusion_helpdesk_central`** (runs on the central Odoo = nexa). The client forwards
tickets to central over **XML-RPC**; central find-or-creates the customer partner +
follower; the client shows a server-side-scoped "My Tickets" inbox + systray unread badge.
### Where each runs / how to deploy
- **Central = nexa** (`erp.nexasystems.ca`, VM 315 on pve-worker1, Docker, DB `nexamain`).
Source on host: `/opt/odoo/custom-addons/fusion_helpdesk_central`. Upgrade (brief downtime):
```bash
ssh pve-worker1 "qm guest exec 315 --timeout 590 -- bash -c 'docker stop odoo-nexa-app; docker run --rm --network odoo_odoo-network -v odoo_odoo-data:/var/lib/odoo -v /opt/odoo/custom-addons:/mnt/extra-addons -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons -v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf odoo-nexa:19 odoo -d nexamain -u fusion_helpdesk_central --stop-after-init --http-port=0 --gevent-port=0 > /tmp/up.log 2>&1; docker start odoo-nexa-app'"
```
Use `;` (not `&&`) before `docker start` so the app ALWAYS restarts even if the upgrade
fails. nexa `odoo.conf` has `log_level=warn`, so test/INFO lines are suppressed — verify
the result via DB query, not the upgrade log.
- **Client = entech** (LXC 111 on pve-worker5, **native systemd `odoo.service`**, DB `admin`,
config `/etc/odoo/odoo.conf`, source `/mnt/extra-addons/custom/fusion_helpdesk`). No host
bind mount — get files in with `scp` to pve-worker5 then `pct push 111 <file> <dest>`.
Upgrade as the `odoo` user (NOT root):
```bash
pct exec 111 -- bash -lc "systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo"
```
**Backup dir MUST live OUTSIDE the addons path** (e.g. `/root/`). A dir named `*.bak.*`
*inside* `/mnt/extra-addons/custom` makes Odoo try to load it as a module →
`FileNotFoundError: Invalid module name: fusion_helpdesk.bak.predeploy` → whole registry
load fails. (Learned the hard way; auto-rollback restored it.) Current rollback copy:
`/root/fh_bak_predeploy`.
### REQUIRED prerequisite on the central service account (easy to miss)
The keystone passes `partner_email`, so central find-or-creates the partner. The XML-RPC
service account (**`support@nexasystems.ca`, uid 33** on nexa) MUST have the **Contact
Creation** group (`base.group_partner_manager`). Without it, `helpdesk.ticket.create`
faults with *"not allowed to create 'Contact' (res.partner)"* for any reporter who isn't
already a contact. Granted on nexa 2026-05-27. **Every new client deployment needs this
grant on the central account.**
### Testing lesson
Client logic (scope domain, seen model, vals, `_norm_email`) is unit-tested in
`fusion_helpdesk/tests/` and runs on local Community (`-d modsdev`). **Smoke tests must
call the controller endpoints, not re-implement their logic** — the Phase 6 smoke test
replicated `build_scope_domain` directly and so missed a `NameError` (`_norm_email`
referenced but never imported) that broke every inbox endpoint. Run
`docker exec odoo-modsdev-app python3 -m pyflakes <file>` after editing controllers — it
catches undefined names instantly.
### Two non-obvious gotchas the first ship hit (fixed 2026-05-27 afternoon)
1. **`group_reporter_admin` had zero members on install** — `res.groups` doesn't auto-grant
to the deployment admin, so the "All (deployment)" toggle never appeared and admins were
stuck with the per-user `partner_email` filter. Fix lives in
`fusion_helpdesk/security/fusion_helpdesk_groups.xml`: extend `base.group_system.implied_ids`
with `(4, ref('fusion_helpdesk.group_reporter_admin'))`. The (4, id) tuple is additive — it
never replaces base's existing implied groups. Verified live: all six entech
`base.group_system` members now return True for
`has_group('fusion_helpdesk.group_reporter_admin')` after the upgrade.
2. **Historical tickets had NULL `x_fc_client_label` + NULL `partner_email`** — anything
created before the customer-followup ship was invisible in "My Tickets" because the scope
filter requires both fields. The reporter identity was preserved only in the description
HTML (the diag block's "User" row). Backfill recipe (50 ENTECH + 1 WESTIN, all in one
transaction):
```sql
UPDATE helpdesk_ticket
SET x_fc_client_label = substring(name from '^\[([A-Z]+)\]'),
partner_email = lower(substring(
substring(description from 'User</td><td[^>]*><code>([^<]+)</code>')
from ', ([^)]+)\)')),
partner_name = regexp_replace(
substring(description from 'User</td><td[^>]*><code>([^<]+)</code>'),
' \(#\d+, [^)]+\)$', '')
WHERE name ~ '^\[[A-Z]+\]'
AND description ~ 'User</td>'
AND x_fc_client_label IS NULL;
```
Safe: SQL UPDATE bypasses the central `helpdesk.ticket.create` override, so no duplicate
ack emails. Per-deployment label inferred from the `[XXX]` name prefix the old code was
already adding. Note: users whose `login != email` (e.g. uid=2 on entech has login
`gsinghpal@outlook.com` and email `gs@nexasystems.ca`) get tagged with their *login* in
backfill — they won't see their old tickets in "Mine", only in "All". New tickets are
tagged with the profile email (`user.email` first, `user.login` fallback).
### STATUS (handoff 2026-05-27 afternoon)
- **Merged to `main`** as squash commit `6c15a7b1` (initial ship). Today's followup is the
group/backfill fix described above — committed separately.
- **Deployed live**: nexa `fusion_helpdesk_central` **19.0.1.1.0**; entech `fusion_helpdesk`
**19.0.1.5.0** (bumped from 19.0.1.4.1 for the implied_ids fix). Both services healthy.
- **Historical entech tickets backfilled** on nexa (51 rows: 50 ENTECH + 1 WESTIN).
- **Smoke-tested live end-to-end** (entech→nexa): partner resolved + follower + `ENTECH`
label, branded ack email queued, support reply visible in thread, inbox scope finds own
ticket, no cross-deployment leak. The "Mine" view for non-admins and the "All" view for
the entech owner both populate as expected.
- **Browser confirmation**: hard-refresh entech (DevTools → Empty Cache and Hard Reload),
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
View File

@@ -1,58 +0,0 @@
# 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. |

View File

@@ -28,7 +28,7 @@
'website',
'mail',
'fusion_claims',
'fusion_portal',
'fusion_authorizer_portal',
],
'data': [
'security/security.xml',

View File

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

View File

@@ -0,0 +1,23 @@
# -*- 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',
}

View File

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

View File

@@ -0,0 +1,23 @@
# -*- 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',
}

View File

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

View File

@@ -0,0 +1,20 @@
# -*- 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

View File

@@ -0,0 +1,86 @@
# 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._

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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"}]}

View File

@@ -0,0 +1 @@
{"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"}]}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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

View File

@@ -0,0 +1,205 @@
{
"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": []
}

View File

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

View File

@@ -0,0 +1,20 @@
# -*- 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

View File

@@ -0,0 +1,143 @@
# 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

View File

@@ -0,0 +1,67 @@
# -*- 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)

View File

@@ -0,0 +1,56 @@
# -*- 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,
}

View File

@@ -0,0 +1,5 @@
<?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>

View File

@@ -0,0 +1,143 @@
# 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

View File

@@ -0,0 +1,67 @@
# -*- 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)

View File

@@ -0,0 +1,56 @@
# -*- 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,
}

View File

@@ -0,0 +1,5 @@
<?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>

View File

@@ -0,0 +1,8 @@
# -*- 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

View File

@@ -0,0 +1,38 @@
# -*- 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

View File

@@ -0,0 +1,106 @@
# -*- 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

View File

@@ -0,0 +1,129 @@
# -*- 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()

View File

@@ -0,0 +1,67 @@
# -*- 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()

View File

@@ -0,0 +1,153 @@
# -*- 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)

View File

@@ -0,0 +1,52 @@
# -*- 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 []

View File

@@ -0,0 +1,82 @@
# -*- 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

View File

@@ -0,0 +1,38 @@
/** @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');

View File

@@ -0,0 +1,220 @@
# 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

View File

@@ -0,0 +1 @@
{"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"}]}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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"}]}

View File

@@ -0,0 +1 @@
{"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

View File

@@ -0,0 +1 @@
{"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

View File

@@ -0,0 +1 @@
{"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

View File

@@ -0,0 +1 @@
{"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

View File

@@ -0,0 +1,8 @@
# -*- 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

View File

@@ -0,0 +1,38 @@
# -*- 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

View File

@@ -0,0 +1,106 @@
# -*- 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

View File

@@ -0,0 +1,129 @@
# -*- 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()

View File

@@ -0,0 +1,67 @@
# -*- 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()

View File

@@ -0,0 +1,153 @@
# -*- 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)

View File

@@ -0,0 +1,52 @@
# -*- 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 []

View File

@@ -0,0 +1,82 @@
# -*- 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

View File

@@ -0,0 +1,38 @@
/** @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');

View File

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

View File

@@ -0,0 +1,22 @@
# -*- 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',
}

View File

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

View File

@@ -0,0 +1,22 @@
# -*- 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',
}

View File

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

View File

@@ -0,0 +1,47 @@
# -*- 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

View File

@@ -0,0 +1,96 @@
# 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

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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": []}

View File

@@ -0,0 +1 @@
{"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

View File

@@ -0,0 +1 @@
{"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

View File

@@ -0,0 +1,247 @@
{
"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": []
}

View File

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

View File

@@ -0,0 +1,47 @@
# -*- 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

View File

@@ -1,194 +0,0 @@
# 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 ~1115 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`).

View File

@@ -1,166 +0,0 @@
# fusion_centralize_billing — Session Handoff (2026-05-27)
Resume point for the centralized-billing initiative. Read this first, then continue
from **"Decision pending"** below.
## Where we are
- **Sub-project #1 (core billing engine): DONE and on `main`** (tip `d770c0c3`, pushed to
GitHub + Gitea).
- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
(cross-billing cron → match by `plan_id`; `/usage` authz vs IDOR; input validation →
4xx not 500; correct billing-period window; idempotency scoped to `(sub, metric, key)`;
webhook sign-exact-bytes + event-id + SSRF guard).
- **39 tests green on Odoo 19 Enterprise.**
- Note: the 14 billing commits were rebased off the old login-audit/helpdesk stack and
landed cleanly on `main`.
- **`fusion_login_audit`: also landed on `main`** (2026-05-27). Its 19 commits were rebased
onto `main` and the `feat/fusion-login-audit` branch was deleted. This also restored
Odoo-19 rules #914 in `CLAUDE.md`, which had gone missing on `main` when billing landed
alone (they were authored alongside login_audit and never existed on the old base).
- A concurrent `feat/helpdesk-customer-followup` session still carries pre-landing copies
of the billing + login_audit commits; when it merges, replay its helpdesk-only commits
onto `main`.
- **Reference docs (on `main`):**
- Spec: `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`
- Core plan: `docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md`
## Next: sub-project #2 — NexaCloud adapter + dual-run reconciliation
Per spec §12, each sub-project is its own spec → plan → build cycle. #2 decomposes into
four chunks (dependency order):
| Chunk | What | Risk |
|-------|------|------|
| **2a — Mapping + importer** | Read `nexacloud` DB → create `res.partner` + `account.link`, `product.template` + subscription plans, one subscription `sale.order` per deployment | **Low** — read-only on NexaCloud, writes only into Odoo |
| **2b — Usage metering wiring** | NexaCloud `usage_metering.py` pushes CPU-seconds → Odoo `/usage`; verify aggregation → draft invoice w/ quota + overage + HST | Edits NexaCloud code |
| **2c — Control loop** | NexaCloud consumes Odoo's outbound webhooks (`invoice.payment_failed` → suspend via existing `network_isolation`/`throttle_checker`; `subscription.terminated` → deprovision) | Edits NexaCloud code |
| **2d — Dual-run reconciliation** | `fusion.billing.reconciliation` diffs Odoo-computed vs NexaCloud-actual per customer/period for ≥ 1 cycle before any flip | Safety gate before flipping real billing |
The core engine already built the *receiving* side (`/usage`, webhook engine, charge math).
#2 is about **connecting NexaCloud to it and proving the numbers match before flipping.**
## Decision pending (resume here)
We were in the `superpowers:brainstorming` flow for #2 and stopped at: **which slice to
start with?**
- **(recommended) 2a — Mapping + importer** — lowest risk, foundation for everything else.
- 2d — Reconciliation first (front-load the trust mechanism).
- Full #2 design as one spec, then one plan.
- Just write the #2 plan, no code this session.
## Open questions to resolve before building #2
- **Spec §15 Q2 — NexaCloud billing granularity:** confirm **one subscription per
deployment** (spec leans this way) vs one subscription per customer with deployment line
items.
- **Access / environments needed:**
- Read access to the `nexacloud` DB schema (LXC 102 / its Postgres on LXC 201) to design
the importer mapping.
- A NexaCloud staging or safe path for 2b/2c (they edit live NexaCloud code).
- Test target for the Odoo side stays the odoo-trial Enterprise sandbox.
- **Resolved already:** Stripe is one account (`acct_1ShlA9IkwUB1dVox`) for everything — no
account migration (spec §11 / §15 Q1). Branch strategy — land on `main`, branch new work
off `main`.
## How to run / test
- **Billing tests:** `bash scripts/fcb_test_on_trial.sh` from repo root → pass = `FCB_EXIT=0`
(~12 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db
`trial`) and runs `--test-enable`. Local dev Odoo is Community and **cannot** install this
module.
## Branch hygiene (lesson from this session)
Cut each new feature branch from `main`, and land it before starting the next. For any
cross-branch git surgery, use a **throwaway `git worktree`** — never switch the shared
working dir's branch, because a concurrent session may be working on it.
---
## UPDATE — sub-project #2 complete (2026-05-27, later session)
All four chunks of #2 are now built. The brainstorm "which slice" question resolved to
2a-first; everything else followed.
**Done + on `main` in `Odoo-Modules` (fully tested on odoo-trial, suite `FCB_EXIT=0`):**
- **2a — importer** (`fusion.billing.import.wizard`): read-only `psycopg2` reader split
from pure-Odoo writes; users→partners+links, plans→`cpu_seconds` charge catalog
(`plan_id` NULL), deployments→one **draft shadow** `sale.order` each with the flat price.
Shadow-safe by construction (draft + no token + NULL `plan_id`). Idempotent, dry-run,
Test-Connection guard, README runbook.
- **2d — reconciliation** (`fusion.billing.reconciliation`): `_compute_reconciliation` +
`_reconcile_rows` (Odoo flat+overage vs NexaCloud actual, status match/delta), reader for
NexaCloud usage+invoice actuals, "Run Reconciliation" button. **Upsert key is
`(service, external_subscription_id, period)`** — per subscription, so a customer with
two deployments doesn't collide.
- **/usage enabler**: `_api_record_usage` resolves a subscription by the source app's own
id (`x_fc_nexacloud_subscription_id`) so NexaCloud can push against shadow subs.
- Core-engine bug fixed in passing: `charge.price_per_unit` is now `Float(16,6)` and
`_compute_billable` keeps 6-dp precision (was `Monetary`/cent-rounded → would under-bill
sub-cent rates and drift from NexaCloud's 4-dp amounts).
**Code-complete in `Nexa-Cloud` (feature-flagged, NOT deployed, NOT integration-tested):**
- **2b — usage push**: `services/odoo_billing_client.py` + a hook in `usage_metering.py`
posting cpu-seconds to Odoo `/usage`. **2c — control loop**:
`routers/odoo_billing.py` (`POST /api/v1/billing/webhooks/central`, HMAC-verified) +
`services/odoo_billing_integration.py` (suspend/restore/deprovision). All INERT unless
`ODOO_BILLING_ENABLED`. Implemented as NEW modules + edits to clean files only —
NexaCloud `main` had concurrent **Cursor uncommitted WIP** (`routers/billing.py`,
`scheduler.py`, `stripe_service.py`, `models/billing.py`, …) which was deliberately not
touched. Commits: `94542ec` + `956abb2` (only my files staged).
**Deployment status (2026-05-27):**
- **odoo-nexa (production `nexamain`): DEPLOYED** — `fusion_centralize_billing` (core + 2a
+ 2d) **fresh-installed** (#1 had never actually been deployed here; `DIR_ABSENT` before).
`ir_module_module.state = installed`, `odoo-nexa-app` healthy. **INERT**: no
`nexacloud_dsn`, all charges `plan_id` NULL (rating cron no-op), no webhooks queued
(dispatch cron no-op), inbound API 401s with no key configured. Synced to
`/opt/odoo/custom-addons` + `-i` via the restart-safe recipe.
- **NexaCloud (prod, `vps.nexasystems.ca` / 192.168.1.250): DEPLOYED — INERT.** Did NOT
use `./deploy.sh` (it `rsync --delete`s the working tree → would have shipped the
concurrent **uncommitted Cursor WIP** (7 files) AND wiped the gitignored prod `.env`
files). Instead deployed **surgically**: rsync of ONLY my 6 committed billing files (no
`--delete`; `.env` + Cursor's files untouched), `docker compose build backend`,
**boot-tested in a throwaway container** (`run --rm --no-deps backend python -c "import
app.main"` → BOOT_OK) before swapping, then `up -d backend`. `nexacloud-api` healthy,
`/health` OK. Feature OFF: `ODOO_BILLING_ENABLED` unset → `/billing/webhooks/central`
returns 404 and no usage is pushed. Activate later by setting `ODOO_BILLING_*` in
`/opt/nexacloud/.env` (+ compose env passthrough) once the Odoo side is wired.
**NOTE:** Cursor's 7-file WIP remains uncommitted locally and was never deployed — when
Cursor finishes, a normal `./deploy.sh` will ship it (and re-sync `.env`).
**Dual-run stand-up results (2026-05-27) — STOPPED here for review, NOT flipped:**
- Read-only role `odoo_billing_ro` created on nexacloud Postgres (192.168.1.50); DSN set in
`ir.config_parameter` `fusion_billing.nexacloud_dsn` on nexamain. Test Connection OK
(read 7 users / 232 plans / 87 subscriptions).
- **Shadow import committed on nexamain**: 7 partners, 232 plan catalogs, 87 draft shadow
subscriptions; 0 skipped, 0 failed. (NOTE: importer takes ALL plans/subs regardless of
active status → ~464 NC-* products now in the prod ERP catalog. Consider filtering to
`is_active` plans / active subscriptions, or prune the shadow records — all reversible.)
- **Reconciliation pass**: 9 (sub,period) rows had real billing activity → **2 match, 7
delta**, 0 failed. The 7 deltas, MUST resolve before flipping:
1. **One-off / non-subscription invoices** (3 rows: $877.99, $872.66, $32.20) — nexacloud
invoices with NULL subscription_id (fees/manual/credits); not modeled per-subscription.
2. **List-price ≠ actual-invoiced** (4 rows: Odoo $200/$50 vs actual ~$9.1x) — likely
proration or NexaCloud invoicing ≠ plan list price.
- **2d bug surfaced (analysis-only, not safety):** `_reconcile_rows` with an empty
`subscription_external_id` matches NULL-field orders instead of skipping → spurious
delta rows for the one-off invoices. Add `if not sub_ext: skip`.
**Remaining before go-live (gated on infra / ops you do):**
1. Grant the read-only DSN (`fusion_billing.nexacloud_dsn`) — see the module README — then
Test Connection → dry-run import → review → real import.
2. Run a dual-run cycle (Run Reconciliation), confirm all rows `match`.
3. **2c needs the Odoo side to actually EMIT** `invoice.payment_failed` /
`payment_succeeded` / `subscription.terminated` webhooks with `deployment_id` in the
payload — that emission isn't wired yet (it belongs to the live billing flow). The
NexaCloud receiver is built to that contract; confirm the payload shape when wiring it.
4. Integration-test + deploy the NexaCloud changes (no test harness in that repo).
5. The flip: set `charge.plan_id`, attach Stripe tokens, confirm the shadow subs.
Specs/plans: `specs/2026-05-27-nexacloud-billing-importer-design.md`,
`specs/2026-05-27-nexacloud-reconciliation-design.md`, and the matching plans.

View File

@@ -1,127 +0,0 @@
# 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".

Some files were not shown because too many files have changed in this diff Show More