nu_restaurant_pos/lib/config_loader.py and nu_restaurant_pos/models/nu_pos_config_sync.py.
What it manages
Catalog records (the first seven sections) and shared configs are matched by xmlids stamped under the synthetic module
__nu_pos_config_json__. The loader only ever sees records it owns — anything created manually in the Odoo UI is invisible to it and will never be modified or deleted.Document shape
Keys vs xmlids
key— a string you choose. Stable within a section, scoped to the loader. Used to reference sibling records inside the document (e.g.print_routes.printerreferences aprinterskey).- External xmlid — full
module.namereference to a record outside the JSON (companies, accounts, journals, POS categories, products, users, floors, tags, fiscal positions).
Referencing records by ID
Every field that accepts an external xmlid also accepts a positive integer record ID. The loader dispatches by Python type — strings containing a dot are treated as xmlids, integers as record IDs. Lists may mix both freely.
Numeric strings like
"109" are explicitly rejected — the loader does not coerce types.
Trade-off: Record IDs are bound to a specific database. If you ever migrate or restore the database to a new environment, the same numeric ID may point to a different record. xmlids do not have this problem because they resolve through ir.model.data. Prefer IDs when you maintain a single environment; prefer xmlids when the same config travels between environments.
Apply, dry-run, export
Three entry points, all gated bybase.group_system:
1
Admin form
Settings → POS → JSON Config Sync. Upload the file, then click Dry-run preview to see the create/update/delete plan, or Apply for the real run. Export current state downloads the current DB as JSON.
2
Python / CLI
3
Module install / upgrade
The
post_init_hook (install) and migrations/<version>/post-migrate.py (upgrade) call apply_active_attachment(), which replays the most recently uploaded JSON.Active attachment & upgrade replay
action_apply (and the programmatic equivalents) persists the raw JSON as a single ir.attachment named active_config.json attached to the nu.pos.config.sync model with res_id=0. The post-init hook and the version migration script both call apply_active_attachment(), which:
- Loads the active attachment (no-op if none exists).
- Runs the loader against the live database.
- Logs the resulting plan.
Execution order
The loader runs everything inside a single savepoint:- Validate — shape, required keys, selection values, all external xmlids resolve.
- Plan — diff current xmlid-owned records against the JSON to count create/update/delete per section.
- Catalog upserts — printers, routes, presets, void reasons, tags, side-dish groups, picking-sale reasons.
- Shared configs (pass 1) — everything except terminal-dependent fields (
cashier_pos_configs,shared_floors). pos.configupdates — link terminals to theirnu_shared_config_idand applynu_*fields.- Shared configs (pass 2) — terminal-dependent fields, now that the terminal linkage exists.
- Catalog orphan deletes — remove loader-owned records not present in the JSON.
- Shared-config deletes — remove loader-owned
nu.pos.configrecords not present in the JSON, only if nopos.configreferences them.
shared_floors require linked terminals, which don’t exist until step 5.
Safety rules
1
Terminals are never created or deleted
pos_configs is update-only. An entry whose name doesn’t match an existing pos.config is skipped and recorded as a warning. The loader never creates or removes physical terminals.2
Hand-managed records are untouchable
A printer (or any catalog record) created in the Odoo UI has no xmlid under
__nu_pos_config_json__. The loader doesn’t see it, doesn’t update it, doesn’t delete it.3
Shared configs linked to terminals are not deleted
If a JSON-removed shared config still has a
pos_config_ids link, the loader skips the delete and emits a warning instead of breaking running terminals.4
Pre-flight references all resolve
Every xmlid in the document is checked before any write. A single bad reference fails the whole document; nothing partially applies.
5
Whole apply is transactional
apply() opens a savepoint. If any step raises, the database rolls back to the pre-apply state.Plan output
Bothaction_dry_run and action_apply return a plan dict and render it in the Last result panel. Example:
Authoring workflow
A typical lifecycle for managing config in a deployment:1
Bootstrap from the live database
Click Export current state to download a JSON snapshot of the current configuration. Commit it to your config repo.
2
Edit declaratively
Add, remove, or change entries. Keys must be stable — renaming a
key is treated as a delete + create.3
Dry-run
Upload and click Dry-run preview. Review the create/update/delete counts and any warnings before applying.
4
Apply
Click Apply. The plan is rendered and the JSON becomes the new active attachment, ready to replay on the next module upgrade.
Adding a new field
To add a new field to one of the managed models:- Add the field to the model in
nu_restaurant_pos/models/. - Map it in
_vals_from_entry()(write) and_export_catalog_entry()(read) inconfig_loader.py, or in the correspondingSHARED_*tuple fornu.pos.configandPOS_CONFIG_FIELDSforpos.config. - If the field references another record by xmlid, add it to
_validate_references()so pre-flight catches bad references. - If the field is a
Selection, add it to_validate_selections(). - Add a test in
nu_restaurant_pos/tests/test_config_loader.py.
Catalog sections that need cross-record references (like
special_request_tags.parent or side_dish_groups.lines.product) are written in two passes inside the loader. Follow the existing patterns in _upsert_special_request_tags and _upsert_side_dish_groups rather than inlining the logic.Adding a new section
Adding a brand-new catalog section requires more than_vals_from_entry:
- Add the section name to
ALLOWED_TOP_LEVELandCATALOG_SECTIONS. - Map it to its model in
CATALOG_MODELS. - Implement
_vals_from_entryand_export_catalog_entrycases. - Add reference and selection validators if the section has them.
- If the section needs multi-pass writes (e.g. self-references), add a dedicated
_upsert_*function and dispatch to it from_execute().
Reference
- Loader:
nu_restaurant_pos/lib/config_loader.py - UI wizard + replay:
nu_restaurant_pos/models/nu_pos_config_sync.py - Form view & menu:
nu_restaurant_pos/views/nu_pos_config_sync_views.xml - Post-init hook:
nu_restaurant_pos/__init__.py(_post_init_apply_config) - Upgrade replay:
nu_restaurant_pos/migrations/12.0.1.14.0/post-migrate.py - Tests:
nu_restaurant_pos/tests/test_config_loader.py