Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DywdYrFTrWa4h1T3ZykPBX
10 KiB
Odoo 19 / 20 (Enterprise) — field names & footguns
Hard-won notes from a real v19 demo build, re-verified against the Odoo 20.0 source (released 2026-09-17). Everything below holds on v20 unless a v20 note says otherwise. Check these before you waste a build cycle. Always confirm the major version first: version()["version"].
Renamed core fields (these break v17/v18 code)
| Concept | v17/18 | v19 |
|---|---|---|
| User's groups | groups_id |
group_ids |
| Group's app category | category_id on res.groups |
removed — use privilege_id (model res.groups.privilege), or just omit |
| (so in security XML) | <field name="groups_id" ...> |
<field name="group_ids" ...> |
A custom module that sets category_id on res.groups or groups_id on res.users will fail to install on v19 with Invalid field ... / Cannot .... Drop the category or use privilege_id.
v20-only changes (verified on the 20.0 branch)
- Custom-module security:
security/ir.model.access.csv+ir.rulerecords are replaced by ONEsecurity/ir.access.csv(modelir.access, merges ACLs and record rules). Columns:id,name,model_id,group_id/id,operation,domain—operationis a subset ofcrud,domainis optional. A v19 module shippingir.model.access.csvwill not install on v20. - Binary fields over RPC:
writeaccepts{"file_name": ..., "content": ...}on binary fields andweb_readno longer returns base64 blobs;bin_sizecontext is gone. Only matters if you upload logos/images — this skill doesn't. - RPC
dbservice removed (/xmlrpc/db,/jsonrpcservicedb). Use/web/database/*or JSON-2 (res.lang,res.country). The connector never used it. - Field renames from v19 (
group_ids,privilege_id,is_storable, credit-limit fields,stock_fleetdispatch fields) are unchanged in v20.
fields_get returns null attributes over RPC (some v19 builds)
fields_get([], {"attributes": ["string","type"]}) may return each field with string=None. The keys (technical names) are still correct — match on keys, not labels. (fields_named() in odoo_connect.py does this.)
Product model (v19)
- Goods that track stock:
type="consu"plusis_storable=True(the oldtype="product"is gone). - Weight:
product.template.weight(kg by default).
Credit limit (Accounting)
- Native behaviour is WARNING ONLY — it does NOT block SO confirmation. A hard block needs a Studio approval rule or a custom
action_confirmoverride. - Enable:
res.config.settings.account_use_credit_limit = True(+account_default_credit_limit). Apply viacreatethenexecute. - Per-customer:
res.partner.use_partner_credit_limit = True,credit_limit = <amount>. These are non-stored — you cannotsearchonuse_partner_credit_limit(Postgres "not stored" error); read it per-record instead. res.partner.credit= current Total Receivable (computed from posted AR moves). Pre-stage it by posting an unpaid customer invoice.
Fleet load-by-weight / Dispatch (v19) — IS native, but gated
- Native
fleetalone = vehicle cost/maintenance; no capacity fields. - Install
stock_fleet("Stock Transport"). It adds the dispatch fields tostock.picking.batch:vehicle_id,vehicle_category_id,vehicle_weight_capacity,used_weight_percentage,estimated_shipping_weight,has_dispatch_management. - Capacity lives on
fleet.vehicle.model.category→ fieldsweight_capacity,volume_capacity. Create a category with capacity, assign it to the vehicle (fleet.vehicle.category_id), then a batch with that vehicle computesused_weight_percentage. - Demo flow: confirm SOs → deliveries → create
stock.picking.batchwithvehicle_id+picking_ids→ readused_weight_percentage.
Currency
- New databases ship most currencies inactive. Find with
search([["name","=","EGP"]], context={"active_test": False}), setactive=True, thenres.company.currency_id. - Change company currency BEFORE posting any invoices/journal entries — Odoo blocks the switch once entries exist.
Stock seeding (so deliveries go Ready)
- Create
stock.quantwithinventory_quantityandcontext={"inventory_mode": True}, then callaction_apply_inventorywith the same context. The apply may warn harmlessly if already applied; check by reserving (stock.picking.action_assign→ stateassigned).
State fields are read-only in practice
stateonsale.order/account.move/stock.pickingis writable over RPC and doing so is always wrong: it skips the workflow. Confirming an SO withwritecreates no delivery and no invoice basis; posting a move withwritecreates no journal entries and no sequence number.- Use the button method:
action_confirm,action_post/button_draft/button_cancel,action_assign+button_validate,button_confirm(PO). stock.picking.button_validatecan return a wizard dict (stock.backorder.confirmation, immediate transfer) instead ofTrue— create the wizard fromres_model/contextand call itsprocessbutton.
RPC helper footgun
- v19's API is JSON-2 (
POST /json/2/<model>/<method>,Authorization: bearer <api key>)./xmlrpc,/xmlrpc/2and/jsonrpcstill work but are deprecated:dbservice gone in Odoo 20, the rest in Odoo 22. - No positional arguments exist. ids go in the
idsbody key, everything else under the ORM parameter's own name:domain,fields,vals(write),vals_list(create),context,limit. Wrong name =TypeErrorfrom the server, not a silent misread — but guessing still costs a round trip. Every model+method signature for YOUR database is published at<ODOO_URL>/doc. - The old execute_kw footgun (a context dict passed positionally landing in
offset→psycopg2 can't adapt type 'dict') is gone with the endpoint. If you see it, you are on legacy code. - One call, one transaction. Committed on success, discarded on error, and nothing chains across calls. Prefer a single method that does the whole job (
search_read,action_confirm) over a sequence you orchestrate client-side. - API keys expire — three months maximum, one day if you generated it interactively. Check the expiry before demo day.
- Odoo Online exposes the external API on Custom plans only (not One App Free, not Standard).
- Batch by widening calls, not by bundling them. JSON-2 has no multi-call envelope, but every ORM method is set-based:
create(vals_list=[...])takes a list and returns ids in order,write/read/action_posttake an id list,search_readreplaces search+read in one transaction.get_or_create_many()inodoo_connect.pydoes the batched get-or-create. A batchedcreatefails as a unit — keep the loop where you need per-record error isolation.
Module install over RPC
x("ir.module.module","button_immediate_install", ids). Heavy — let it run minutes (x()defaults to a 600s timeout for this reason). On a failed XML-data load it rolls back cleanly (module stays uninstalled); fix and retry.- After deploying a custom module to git (Odoo.sh), wait for the rebuild, then
x("ir.module.module","update_list")and search for it before installing.
Custom module on v19 — five that cost a build cycle each
stock.move.nameis gone._rec_name = 'reference'. Passingnamein move vals raises. Setdescription_pickingif you need text on the line.- Search views:
<group expand="0" string="Group By">no longer validates. RelaxNG rejects the attributes (ERROR:RELAXNGV:RELAXNG_ERR_NOELEM, reported only as "Invalid view definition"). Use a bare<group>wrapping the group-by<filter>s, as core does. - Mixed stored/non-stored fields in one compute →
UserWarning: inconsistent 'store', and the non-stored one silently recomputes the stored ones. Give non-stored fields their own compute method. sale.order.lineusesproduct_uom_idin v19 whilestock.movestill usesproduct_uom._action_launch_stock_ruleis keyword-only:def _action_launch_stock_rule(self, *, previous_product_uom_qty=False).- Suppressing the automatic delivery on confirmation (blanket/call-off orders) is cleanest by overriding
sale.order.line._action_launch_stock_ruleto skip those lines — no picking is created at all, so nothing has to be cancelled afterwards.
Local Odoo 19 Enterprise in Docker (no Odoo.sh, no sudo needed)
postgres:16 + the official odoo:19 image (it carries every Python dep), but run the mounted 19.0 source, not the image's copy:
entrypoint: ["/bin/bash","-lc","rm -rf /usr/lib/python3/dist-packages/odoo && exec python3 /mnt/odoo/odoo-bin \"$$@\"","--"]
command: ["-c","/etc/odoo/odoo.conf"]
Without the rm, odoo.addons is a namespace package spanning both trees: core loads from the mount while half the addons load from dist-packages — a silent version mix. Verify with a traceback path or python3 -c "import odoo" (should fail).
- Config keys:
http_port(notxmlrpc_port),gevent_port.--without-demo=allis now a boolean — pass--without-demo=True. - Driving the build through
odoo-bin shell(script on stdin) beats the external API on a local box only: full ORM, no named-argument footguns, and nothing commits unless you callenv.cr.commit()— which makes a dry-run of the live demo beat trivially safe (env.cr.rollback()). It needs shell access to the container, so it is not an option on Odoo.sh or Odoo Online; there, JSON-2 remains the only route. - After a CLI
-u <module>, restart the server container. The upgrade runs in its own process: the DB gets the new fields and views, the long-running web process keeps its old registry. The browser then loads a view referencing a field that server doesn't know →OwlError: "model"."field" field is undefined, reported as an Owl lifecycle crash.odoo-bin shellscripts never see it (fresh registry per process), so the whole test suite can be green while the UI is broken — verify a renamed/added field withfields_getover JSON-2 against the running server, not only through shell.