Files
Odoo-Modules/CLAUDE.md
gsinghpal a78ceaba51 docs(claude): fusion_helpdesk deploy procedures + 2026-05-27 handoff
Durable: nexa/entech upgrade commands, central service-account Contact
Creation prerequisite, backup-outside-addons-path gotcha, smoke-tests-must-
call-the-controller lesson. Plus current deploy status + the one remaining
step (browser confirmation of My Tickets / New on entech).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:26:21 -04:00

19 KiB

Odoo Modules — Claude Code Instructions

Project

27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems).

Critical Rules — Odoo 19

  1. NEVER code from memory — Always read a reference file from Docker first:

    docker exec odoo-dev-app cat /usr/lib/python3/dist-packages/odoo/addons/<module>/static/src/<path>
    
  2. Frontend JS: Use Interaction class from @web/public/interaction, registered via registry.category("public.interactions"). NOT IIFE/DOMContentLoaded.

  3. Backend OWL: Use standalone rpc() from @web/core/network/rpc. NOT useService("rpc"). static props = [] not {}.

  4. HTTP routes: type="jsonrpc" — NOT type="json" (deprecated).

  5. res.config.settings: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields. config_parameter= Boolean fields don't round-trip False as a string. Odoo's set_values() calls IrConfigParameter.set_param(key, value), and set_param deletes the row when value is falsy (False / None / empty). So writing False to a Boolean config field means the param no longer exists in ir_config_parameter; a subsequent get_param(key) returns the default (Python False), not 'False'. Test like self.assertFalse(ICP.get_param('...')) — never assertEqual(..., 'False'). (Integer/Float/Char go through repr(value) / strip, so they DO persist as strings — '90', '0', etc.) Source: odoo/addons/base/models/res_config.py::set_values and ir_config_parameter.py::set_param.

  6. res.groups: NO users field, NO category_id field. res.users: field was renamed groups_idgroup_ids (also all_group_ids for implied). The plural form is gone; using groups_id raises ValueError: Invalid field 'groups_id' in 'res.users'. ir.ui.view: same rename — view-level visibility gating uses group_ids, not groups_id. A record like <field name="groups_id" eval="[(4, ref('base.group_system'))]"/> on an ir.ui.view raises ValueError: Invalid field 'groups_id' in 'ir.ui.view' at module install. (The XML attribute groups="base.group_system" on form elements like <page>, <button>, <field> is unrelated and still works.) ir.rule groups field is additive, not restrictive. A rule with groups=[some_group] applies ONLY to users in that group — it does NOT restrict non-members. So domain_force=[(1,'=',1)] + groups=[base.group_system] does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (ir.model.access.csv), not the rule. To truly restrict by group at the rule layer, pair a global rule (groups=[], domain_force=[(0,'=',1)] = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.

  7. Search views: NO group expand="0" syntax.

  8. SCSS imports: @import "./partial" is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including _partial.scss tokens) as a separate entry in web.assets_backend. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.

  9. SQL constraints & indexes: Odoo 19 dropped _sql_constraints = [(name, def, msg), ...] and the init()/raw-SQL pattern. Both still parse but only emit a warning and are silently ignored. Use declarative class attributes instead:

    _check_qty_positive = models.Constraint('CHECK (qty > 0)', 'Quantity must be positive.')
    _user_time_idx = models.Index('(user_id, event_time DESC)')
    

    The attribute name after the leading underscore becomes the SQL object name suffix ({table}_{suffix}). models.Index accepts DESC, WHERE predicates, and USING btree (...). Sources: odoo/orm/model_classes.py (warns at registry build), odoo/orm/table_objects.py (Constraint + Index classes).

  10. res.users._login is an instance method in Odoo 19, not a classmethod as in earlier versions. Signature is def _login(self, credential, user_agent_env) — there is no db parameter. Override it like any normal instance method (super()._login(credential, user_agent_env)). When called via authenticate() on an empty recordset, self carries the right env. Older recipes that build a separate api.Environment from odoo.modules.registry.Registry(db) no longer apply. Source: odoo/addons/base/models/res_users.py:760.

  11. Inherited ir.ui.view records cannot have groups/group_ids on the record itself. Odoo 19 raises ParseError: Inherited view cannot have 'groups' defined on the record. Use 'groups' attributes inside the view definition at install time. Move the gate to the inner XML nodes — every <button>, <page>, <field>, <xpath>, <group> etc. supports a groups="base.group_system" attribute. For an inherited form with a smart button + admin tab, put groups= on the button and the page individually; leave the <record model="ir.ui.view"> clean.

  12. mail.template QWeb/inline_template ctx IS self.env.context — not a nested dict you can pass. MailRenderMixin._render_eval_context() sets ctx = self.env.context, so ctx.get('foo') in subject/body resolves to env.context.get('foo'). To pass dynamic data to a template, spread keys directly into the context: tmpl.with_context(**my_data).send_mail(res_id, ...). Calling tmpl.with_context(ctx=my_data) puts the dict at env.context['ctx'], and the template's ctx.get('foo') becomes env.context.get('foo')None (looks like a silent rendering bug — subject ends up blank).

  13. ir.cron dropped numbercall in Odoo 19. Old recipes set <field name="numbercall">-1</field> for "run forever"; that now raises ValueError: Invalid field 'numbercall' in 'ir.cron' at install time. Just omit the field — recurring crons keep running as long as active=True. Source: odoo/addons/base/models/ir_cron.py field list.

  14. cr.commit() / cr.rollback() raise AssertionError inside TransactionCase — they are NOT silent no-ops in Odoo 19. The test cursor explicitly refuses both ("Cannot commit or rollback a cursor from inside a test, this will lead to a broken cursor when trying to rollback the test. Please rollback to a specific savepoint instead..."). For cron/worker code that needs per-row isolation so one bad row doesn't roll back the whole batch, use with self.env.cr.savepoint(): ... inside the loop instead of cr.commit(). Savepoints work in both prod (under the outer cron transaction) and tests (under the outer test transaction). The cron transaction commits the whole batch when the method returns; in tests everything rolls back cleanly. Source: odoo/sql_db.py::TestCursor.commit and Cursor.savepoint().

  15. There is NO sale.subscription model in Odoo 19 (Enterprise sale_subscription). A subscription is a sale.order with is_subscription=True, plan_idsale.subscription.plan (the recurrence), plus subscription_state / next_invoice_date / recurring_monthly. Any Many2one or relation that targets "a subscription" must point at sale.order (filter domain=[('is_subscription','=',True)]) — not sale.subscription, which does not exist and fails at install. The surviving sale.subscription.* records are only the plan + wizards/reports (sale.subscription.plan, sale.subscription.report, sale.subscription.change.customer.wizard, sale.subscription.close.reason.wizard). Verified on live nexamain (odoo-nexa, 19.0): SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'.

Card Styling — Copy Odoo's Kanban Pattern

Don't rely on var(--bs-border-color) or var(--bs-body-bg) for card surfaces — they drift between themes/addons and often render invisible. Odoo's own kanban (.o_kanban_record) uses explicit hex values:

background-color: white;
border: 1px solid #d8dadd;

For custom OWL dashboards / client actions use the same approach:

  • Define a _tokens.scss partial with explicit hex values wrapped in a CSS custom property:
    $fp-card:   var(--fp-card-bg, #ffffff);
    $fp-border: var(--fp-border-color, #d8dadd);
    
  • Reference those tokens everywhere (never var(--bs-border-color) directly)
  • Three-layer contrast: page (grayest) → container/column (mid) → card (brightest). That's what makes cards pop.
  • Reference implementation: fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss.

Dark Mode — Branch on $o-webclient-color-scheme at SCSS Compile Time

Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:

  • web.assets_backend — compiled with $o-webclient-color-scheme: bright
  • web.assets_web_dark — compiled with $o-webclient-color-scheme: dark (dark variant primary variables loaded first)

Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, branch at compile time using the SCSS variable Odoo sets:

$o-webclient-color-scheme: bright !default;

$_my-page-hex: #f3f4f6;
$_my-card-hex: #ffffff;

@if $o-webclient-color-scheme == dark {
    $_my-page-hex: #1a1d21 !global;
    $_my-card-hex: #22262d !global;
}

$my-page: var(--my-page-bg, $_my-page-hex);
$my-card: var(--my-card-bg, $_my-card-hex);

Do NOT use .o_dark_mode class selectors, [data-bs-theme="dark"], or @media (prefers-color-scheme: dark) — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a color_scheme cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS @if handles the rest at compile time.

Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:

env['ir.qweb']._get_asset_bundle('web.assets_backend').css()     # light
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css()    # dark

Asset Bundle Cache Busting

Odoo content-hashes the compiled bundle URL (/web/assets/<hash>/...). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:

  1. Bump the module version in __manifest__.py
  2. DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'; then restart odoo
  3. Call env['ir.qweb']._get_asset_bundle('web.assets_backend').css() in odoo-shell to force regeneration
  4. Hard-refresh browser with cache clear (DevTools → right-click refresh → Empty Cache and Hard Reload); on mobile clear website data

Naming

  • New fields: x_fc_* prefix
  • Legacy fields: x_studio_*
  • Canadian English for all user-facing text
  • Currency: $ sign with Monetary fields + currency_id

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
  • Local URL: http://localhost:8082
  • Running module tests requires ephemeral ports. The dev container's main Odoo process holds 8069 and 8072; a docker exec ... odoo --test-enable will die with Address already in use unless you also pass --http-port=0 --gevent-port=0. This is because Odoo 19 forces http_spawn() when --test-enable is set, even when --no-http is passed. Canonical test invocation:
    docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /<module> \
        -u <module> --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
    
  • fusion_centralize_billing tests run on odoo-trial (VM 316). Local dev is Community and cannot install this module. Use bash scripts/fcb_test_on_trial.sh from the repo root. The script uses --http-port 8070 to avoid the port 8069 conflict with the live odoo-trial-app container. Pass = FCB_EXIT=0. Takes ~1-2 min.
  • Python deps not bundled with odoo:19 image: user_agents (used by fusion_login_audit), and likely others. Install ephemerally with docker exec -u 0 odoo-modsdev-app pip install <pkg> --break-system-packages. The install is LOST when the container is recreated (e.g. docker compose up -d after a compose edit). When this happens, the symptom is ModuleNotFoundError deep in the auth or report code. Re-run the pip install. A persistent fix would be a custom Dockerfile or a startup hook on the compose service — not done yet.
  • Test before deploying. Edit existing files — don't create unnecessary new ones.

PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab

When a Python action opens an attachment, route it through fusion_pdf_preview instead of returning ir.actions.act_url with download=true or target=new. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.

The drop-in replacement is the new helper on ir.attachment:

return att.action_fusion_preview(title='My Doc')
# vs. the old pattern:
# return {'type': 'ir.actions.act_url',
#         'url':  '/web/content/%s?download=true' % att.id,
#         'target': 'new'}

The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.

If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is fusion_pdf_preview.open_attachment and the params are {attachment_id, title, model_name, record_ids, report_name}. See fusion_pdf_preview/static/src/js/open_attachment_action.js.

Existing reports (ir.actions.report of type qweb-pdf) are intercepted automatically by fusion_pdf_preview/static/src/js/pdf_preview.js; the helper above is for the other pattern — attachments opened by custom buttons.

Supabase Knowledge Base

Before starting unfamiliar work, check Supabase for context:

PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U postgres -d postgres
  • fusionapps.decisions — past architecture decisions
  • fusionapps.issues — known issues and fixes
  • fusionapps.code_snippets — reference code
  • fusionapps.quick_commands — deployment and admin commands

Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)

Two modules: fusion_helpdesk (client — runs on each client deployment, e.g. entech) and fusion_helpdesk_central (runs on the central Odoo = nexa). The client forwards tickets to central over XML-RPC; central find-or-creates the customer partner + follower; the client shows a server-side-scoped "My Tickets" inbox + systray unread badge.

Where each runs / how to deploy

  • Central = nexa (erp.nexasystems.ca, VM 315 on pve-worker1, Docker, DB nexamain). Source on host: /opt/odoo/custom-addons/fusion_helpdesk_central. Upgrade (brief downtime):
    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):
    pct exec 111 -- bash -lc "systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo"
    
    Backup dir MUST live OUTSIDE the addons path (e.g. /root/). A dir named *.bak.* inside /mnt/extra-addons/custom makes Odoo try to load it as a module → FileNotFoundError: Invalid module name: fusion_helpdesk.bak.predeploy → whole registry load fails. (Learned the hard way; auto-rollback restored it.) Current rollback copy: /root/fh_bak_predeploy.

REQUIRED prerequisite on the central service account (easy to miss)

The keystone passes partner_email, so central find-or-creates the partner. The XML-RPC service account (support@nexasystems.ca, uid 33 on nexa) MUST have the Contact Creation group (base.group_partner_manager). Without it, helpdesk.ticket.create faults with "not allowed to create 'Contact' (res.partner)" for any reporter who isn't already a contact. Granted on nexa 2026-05-27. Every new client deployment needs this grant on the central account.

Testing lesson

Client logic (scope domain, seen model, vals, _norm_email) is unit-tested in fusion_helpdesk/tests/ and runs on local Community (-d modsdev). Smoke tests must call the controller endpoints, not re-implement their logic — the Phase 6 smoke test replicated build_scope_domain directly and so missed a NameError (_norm_email referenced but never imported) that broke every inbox endpoint. Run docker exec odoo-modsdev-app python3 -m pyflakes <file> after editing controllers — it catches undefined names instantly.

STATUS (handoff 2026-05-27 — continuing from office)

  • Merged to main as squash commit 6c15a7b1, pushed to GitHub + Gitea. Feature branch feat/helpdesk-customer-followup deleted (local + remote). Pull main at the office to get the latest.
  • Deployed live: nexa fusion_helpdesk_central 19.0.1.1.0; entech fusion_helpdesk 19.0.1.4.1. Both services healthy.
  • Smoke-tested live end-to-end (entech→nexa): partner resolved + follower + ENTECH label, branded ack email queued, support reply visible in thread, inbox scope finds own ticket, no cross-deployment leak.
  • Bug fixed this session: _norm_email NameError that broke "My Tickets" AND the "New" ticket submit path — _norm_email added to fusion_helpdesk/utils.py, imported in the controller, regression test added, deployed to entech.
  • ONLY thing left = browser confirmation. Hard-refresh entech (DevTools → Empty Cache and Hard Reload), open the systray helpdesk dialog: My Tickets should load, and the New tab should file a ticket. Both share the now-fixed _identity() path, and the systray unread badge calls it too. If anything still errors, the traceback is in /var/log/odoo/odoo-server.log on entech (LXC 111 / pve-worker5).