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>
12 KiB
Odoo Modules — Claude Code Instructions
Project
27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems).
Critical Rules — Odoo 19
-
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> -
Frontend JS: Use
Interactionclass from@web/public/interaction, registered viaregistry.category("public.interactions"). NOT IIFE/DOMContentLoaded. -
Backend OWL: Use standalone
rpc()from@web/core/network/rpc. NOTuseService("rpc").static props = []not{}. -
HTTP routes:
type="jsonrpc"— NOTtype="json"(deprecated). -
res.config.settings: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
config_parameter=Boolean fields don't round-tripFalseas a string. Odoo'sset_values()callsIrConfigParameter.set_param(key, value), andset_paramdeletes the row whenvalueis falsy (False / None / empty). So writingFalseto a Boolean config field means the param no longer exists inir_config_parameter; a subsequentget_param(key)returns the default (PythonFalse), not'False'. Test likeself.assertFalse(ICP.get_param('...'))— neverassertEqual(..., 'False'). (Integer/Float/Char go throughrepr(value)/ strip, so they DO persist as strings —'90','0', etc.) Source:odoo/addons/base/models/res_config.py::set_valuesandir_config_parameter.py::set_param. -
res.groups: NO
usersfield, NOcategory_idfield. res.users: field was renamedgroups_id→group_ids(alsoall_group_idsfor implied). The plural form is gone; usinggroups_idraisesValueError: Invalid field 'groups_id' in 'res.users'.ir.ui.view: same rename — view-level visibility gating usesgroup_ids, notgroups_id. A record like<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>on anir.ui.viewraisesValueError: Invalid field 'groups_id' in 'ir.ui.view'at module install. (The XML attributegroups="base.group_system"on form elements like<page>,<button>,<field>is unrelated and still works.)ir.rulegroupsfield is additive, not restrictive. A rule withgroups=[some_group]applies ONLY to users in that group — it does NOT restrict non-members. Sodomain_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. -
Search views: NO
group expand="0"syntax. -
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.scsstokens) as a separate entry inweb.assets_backend. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file. -
SQL constraints & indexes: Odoo 19 dropped
_sql_constraints = [(name, def, msg), ...]and theinit()/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.IndexacceptsDESC,WHEREpredicates, andUSING btree (...). Sources:odoo/orm/model_classes.py(warns at registry build),odoo/orm/table_objects.py(Constraint + Index classes). -
res.users._loginis an instance method in Odoo 19, not a classmethod as in earlier versions. Signature isdef _login(self, credential, user_agent_env)— there is nodbparameter. Override it like any normal instance method (super()._login(credential, user_agent_env)). When called viaauthenticate()on an empty recordset,selfcarries the right env. Older recipes that build a separateapi.Environmentfromodoo.modules.registry.Registry(db)no longer apply. Source:odoo/addons/base/models/res_users.py:760. -
Inherited
ir.ui.viewrecords cannot havegroups/group_idson the record itself. Odoo 19 raisesParseError: Inherited view cannot have 'groups' defined on the record. Use 'groups' attributes inside the view definitionat install time. Move the gate to the inner XML nodes — every<button>,<page>,<field>,<xpath>,<group>etc. supports agroups="base.group_system"attribute. For an inherited form with a smart button + admin tab, putgroups=on the button and the page individually; leave the<record model="ir.ui.view">clean. -
There is NO
sale.subscriptionmodel in Odoo 19 (Enterprisesale_subscription). A subscription is asale.orderwithis_subscription=True,plan_id→sale.subscription.plan(the recurrence), plussubscription_state/next_invoice_date/recurring_monthly. Any Many2one or relation that targets "a subscription" must point atsale.order(filterdomain=[('is_subscription','=',True)]) — notsale.subscription, which does not exist and fails at install. The survivingsale.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 livenexamain(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.scsspartial 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: brightweb.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:
- Bump the module
versionin__manifest__.py DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';then restart odoo- Call
env['ir.qweb']._get_asset_bundle('web.assets_backend').css()in odoo-shell to force regeneration - 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-enablewill die withAddress already in useunless you also pass--http-port=0 --gevent-port=0. This is because Odoo 19 forceshttp_spawn()when--test-enableis set, even when--no-httpis 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_billingtests run on odoo-trial (VM 316). Local dev is Community and cannot install this module. Usebash scripts/fcb_test_on_trial.shfrom the repo root. The script uses--http-port 8070to 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:19image:user_agents(used byfusion_login_audit), and likely others. Install ephemerally withdocker 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 -dafter a compose edit). When this happens, the symptom isModuleNotFoundErrordeep 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 decisionsfusionapps.issues— known issues and fixesfusionapps.code_snippets— reference codefusionapps.quick_commands— deployment and admin commands