Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt

Use this file to discover all available pages before exploring further.

The JSON config loader keeps backend POS configuration in a single declarative document. Apply the JSON and the database converges on the described state: records are created, updated, or deleted to match, and the most recently applied document is replayed automatically on module install and upgrade so configuration survives migrations. Implemented in nu_restaurant_pos/lib/config_loader.py and nu_restaurant_pos/models/nu_pos_config_sync.py.

What it manages

SectionOdoo modelMatch key
printersnu.pos.printerkey (loader-owned xmlid)
print_routesnu.pos.print.routekey (loader-owned xmlid)
discount_presetsnu.pos.discount.presetkey (loader-owned xmlid)
void_reasonsnu.pos.void.reasonkey (loader-owned xmlid)
special_request_tagsnu.pos.special.request.tagkey (loader-owned xmlid)
side_dish_groupsnu.pos.side.dish.groupkey (loader-owned xmlid)
picking_sale_reasonsnu.pos.picking.sale.reasonkey (loader-owned xmlid)
shared_configsnu.pos.configname + company
pos_configspos.config (nu_* fields only)name + optional company
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

{
  "version": "1.0",
  "printers": [
    {
      "key": "kitchen",
      "name": "Kitchen Printer",
      "company": "base.main_company",
      "connection_type": "network",
      "ip_address": "192.168.1.50",
      "port": 9100,
      "active": true
    }
  ],
  "print_routes": [
    {
      "key": "kitchen_food",
      "name": "Kitchen — Food",
      "route_type": "kitchen_station",
      "printer": "kitchen",
      "pos_categories": ["point_of_sale.pos_category_misc"]
    }
  ],
  "discount_presets": [
    {
      "key": "ten_off",
      "name": "10% Off",
      "discount_type": "fixed_percent",
      "value": 10.0,
      "applies_to": "both"
    }
  ],
  "void_reasons": [
    { "key": "wrong_item", "name": "Wrong item", "applies_to": "all" }
  ],
  "special_request_tags": [
    { "key": "no_onion", "name": "No onion" },
    { "key": "no_onion_extra", "name": "Extra no onion", "parent": "no_onion" }
  ],
  "side_dish_groups": [
    {
      "key": "fries_or_salad",
      "name": "Fries or salad",
      "lines": [
        { "product": "product.product_product_4", "use_product_price": true,
          "surcharge": 0, "sequence": 10 }
      ]
    }
  ],
  "picking_sale_reasons": [
    { "key": "staff_meal", "name": "Staff meal",
      "expense_account": "account.a_expense" }
  ],
  "shared_configs": [
    {
      "name": "Main floor",
      "company": "base.main_company",
      "printing_enabled": true,
      "send_changes_to_kitchen": true,
      "printers": ["kitchen"],
      "print_routes": ["kitchen_food"]
    }
  ],
  "pos_configs": [
    {
      "name": "Terminal 1",
      "shared_config": "Main floor",
      "nu_lock_mode": "session",
      "nu_login_method": "pin"
    }
  ]
}

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.printer references a printers key).
  • External xmlid — full module.name reference to a record outside the JSON (companies, accounts, journals, POS categories, products, users, floors, tags, fiscal positions).
Every external xmlid is resolved during pre-flight validation; an unknown xmlid fails the whole document before any write.

Apply, dry-run, export

Three entry points, all gated by base.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

env['nu.pos.config.sync'].apply_from_path('/abs/path/to/config.json')
env['nu.pos.config.sync'].apply_from_data(data_dict, dry_run=True)
env['nu.pos.config.sync'].export_to_json()
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.
Only system administrators can apply, dry-run, or export. The check is enforced in the model — UI access alone is not sufficient.

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:
  1. Loads the active attachment (no-op if none exists).
  2. Runs the loader against the live database.
  3. Logs the resulting plan.
This is how configuration survives module upgrades: the last applied JSON is the source of truth, and every upgrade reconverges the database to it. Errors in the replay are logged but do not abort the upgrade.

Execution order

The loader runs everything inside a single savepoint:
  1. Validate — shape, required keys, selection values, all external xmlids resolve.
  2. Plan — diff current xmlid-owned records against the JSON to count create/update/delete per section.
  3. Catalog upserts — printers, routes, presets, void reasons, tags, side-dish groups, picking-sale reasons.
  4. Shared configs (pass 1) — everything except terminal-dependent fields (cashier_pos_configs, shared_floors).
  5. pos.config updates — link terminals to their nu_shared_config_id and apply nu_* fields.
  6. Shared configs (pass 2) — terminal-dependent fields, now that the terminal linkage exists.
  7. Catalog orphan deletes — remove loader-owned records not present in the JSON.
  8. Shared-config deletes — remove loader-owned nu.pos.config records not present in the JSON, only if no pos.config references them.
The two-pass shared-config write avoids constraint failures: fields like 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

Both action_dry_run and action_apply return a plan dict and render it in the Last result panel. Example:
printers: created=0, deleted=0, updated=2
print_routes: created=1, deleted=0, updated=1
discount_presets: created=0, deleted=0, updated=0
void_reasons: created=0, deleted=1, updated=0
shared_configs: created=0, deleted=0, updated=1
pos_configs: created=0, skipped=0, updated=2

Warnings:
  - Skipping delete of shared_config 'Main floor': linked to 2 pos.config terminal(s).

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:
  1. Add the field to the model in nu_restaurant_pos/models/.
  2. Map it in _vals_from_entry() (write) and _export_catalog_entry() (read) in config_loader.py, or in the corresponding SHARED_* tuple for nu.pos.config and POS_CONFIG_FIELDS for pos.config.
  3. If the field references another record by xmlid, add it to _validate_references() so pre-flight catches bad references.
  4. If the field is a Selection, add it to _validate_selections().
  5. 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:
  1. Add the section name to ALLOWED_TOP_LEVEL and CATALOG_SECTIONS.
  2. Map it to its model in CATALOG_MODELS.
  3. Implement _vals_from_entry and _export_catalog_entry cases.
  4. Add reference and selection validators if the section has them.
  5. 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