From f48778f32262b98ccbdfeaa1d653c89f57a8589c Mon Sep 17 00:00:00 2001 From: PoMa Date: Fri, 19 Jun 2026 14:04:39 +0200 Subject: [PATCH] [DOC] Add usage guide and migration example Add a comprehensive usage guide to README.md covering all public helpers with examples, a pre/post/end script overview, a method placement summary table, and a full script skeleton. Add an example/ folder with a realistic Studio cleanup scenario: data model description, migration challenges table, and working pre- and post-migrate scripts. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 917 +++++++++++++++++++++++++++++++++++++++- example/README.md | 179 ++++++++ example/post-migrate.py | 62 +++ example/pre-migrate.py | 38 ++ 4 files changed, 1195 insertions(+), 1 deletion(-) create mode 100644 example/README.md create mode 100644 example/post-migrate.py create mode 100644 example/pre-migrate.py diff --git a/README.md b/README.md index f16f96e..27fddc4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -> [!WARNING] +> [!WARNING] > This repository is for internal usage only. It will be eventually merged into [odoo/upgrade-util](https://github.com/odoo/upgrade-util). No support will be provided for this code and external contributions will not be accepted. @@ -7,6 +7,8 @@ This repository contains helper functions to facilitate the writing of upgrade scripts, specifically tailored towards custom Odoo modules. +> If you are converting studio or saas modules, you are probably in the right place here. + ## Installation ### Through odoo-bin @@ -32,3 +34,916 @@ from odoo.upgrade import custom_util def migrate(cr, version): custom_util.edit_views(...) # etc. ``` + +## Usage guide + +### Overview + +Odoo upgrade scripts come in three flavours, each running at a different point in the upgrade +lifecycle. Knowing which to use for each task is the most important decision when writing a +migration script. + +| Script type | When it runs | ORM available? | Typical use | +|---|---|---|---| +| `pre-migrate.py` | Before the module is upgraded | No — raw SQL only | Rename models/fields, rename xmlids, SQL-level data fixes | +| `post-migrate.py` | After the module is upgraded | Yes | Fix indirect references, patch views, ORM-level data fixes | +| `end-migrate.py` | After **all** modules are upgraded | Yes (full registry) | Studio view creation/update, anything requiring `web_studio` | + +#### Typical workflow for a field rename + +The most common pattern across migration scripts is: rename in `pre-`, then fix up all indirect +references in `post-`: + +``` +pre-migrate.py post-migrate.py +────────────────────────────── ────────────────────────────────────── +custom_rename_field(...) do_pending_refactors(cr) +custom_rename_field(...) edit_views(cr, {...}) +rename_xmlids(...) add_view_modifications_to_migration_reports(...) +transfer_custom_fields(...) +``` + +`custom_rename_field` queues every rename into `FIELD_RENAMES_PENDING`. When +`do_pending_refactors` is called in `post-` or at the end of 'pre-', it flushes that queue and updates all indirect +references in server actions, mail templates, and other records that embed field names as text. + +#### Migration reports + +`add_view_modifications_to_migration_reports` appends an entry to the upgrade migration report — +a document visible to the PS consultant after the upgrade. Use it whenever you patch a Studio or +website view so the consultant knows what changed and can verify it manually. Call it in +`post-migrate.py` after the `edit_views` calls. + +#### Method placement summary + +| Method | `pre-` | `post-` | `end-` | Notes | +|---|:---:|:---:|:---:|---| +| **Renaming** | | | | | +| `custom_rename_model` | ✓ | | | Must run before field renames on that model | +| `custom_rename_field` | ✓ | | | Queues rename for `do_pending_refactors` | +| `custom_rename_module` | ✓ | | | | +| `transfer_custom_fields` | ✓ | | | | +| `rename_xmlids` | ✓ | | | | +| `update_related_field` | ✓ | | | | +| `update_custom_views` | ✓ | | | Simple text-replace fallback; prefer `edit_views` in post- | +| **Post-rename fixups** | | | | | +| `do_pending_refactors` | | ✓ | | Must run after all `custom_rename_field` calls | +| `fix_renames_in_fields` | | ✓ | | Lower-level alternative to `do_pending_refactors` | +| `fix_renames_in_records` | | ✓ | | Target a specific model | +| `rename_in_translation` | | ✓ | | | +| **View editing** | | | | | +| `edit_views` | | ✓ | | Patch Studio / custom views after the upgrade | +| `edit_website_views` | | ✓ | | Requires website ORM | +| `activate_views` | ✓ | ✓ | | Raw SQL, works in either | +| `deactivate_views` | ✓ | ✓ | | Raw SQL, works in either | +| `set_studio_view` | | | ✓ | Requires `web_studio` in registry | +| `reset_studio_view_priority` | | | ✓ | Requires ORM + `web_studio` | +| **View utilities** | | | | | +| `get_views_ids` | ✓ | ✓ | | Raw SQL | +| `get_website_views_ids` | | ✓ | | Requires ORM | +| `get_arch` | ✓ | ✓ | | Raw SQL | +| `extract_elements` | ✓ | ✓ | | Pure Python | +| `extract_elements_from_view` | ✓ | ✓ | | Raw SQL | +| `create_cow_views` | | ✓ | | Requires website ORM | +| `create_cow_view` | | ✓ | | Requires website ORM | +| **Data migration** | | | | | +| `merge_model_and_data` | ✓ | | | SQL-level, run before ORM loads the new model | +| `merge_groups` | | ✓ | | Requires ORM | +| **Dashboard** | | | | | +| `remove_broken_dashboard_actions` | | ✓ | | Requires ORM | +| `cleanup_old_dashboards` | | ✓ | | Raw SQL, but logically a post- task | +| **Module / record utilities** | | | | | +| `modules_already_installed` | ✓ | ✓ | | Raw SQL, safe anywhere | +| `set_not_imported_modules` | ✓ | ✓ | | Raw SQL, safe anywhere | +| `toggle_active` | ✓ | ✓ | | Raw SQL, safe anywhere | +| `get_ids` | ✓ | ✓ | | Raw SQL, safe anywhere | +| `get_migscript_module` | ✓ | ✓ | | Pure Python, safe anywhere | +| `expand_studio_xmlids` | ✓ | ✓ | | Pure Python, safe anywhere | +| `get_existing_models_fields` | ✓ | ✓ | | Raw SQL, safe anywhere | +| `get_model_xmlid_basename` | ✓ | ✓ | | Pure Python, safe anywhere | +| `build_chained_replace` | ✓ | ✓ | | Pure Python, safe anywhere | +| `indent_tree` | ✓ | ✓ | | Pure Python, safe anywhere | +| **Reporting** | | | | | +| `add_view_modifications_to_migration_reports` | | ✓ | | Call after `edit_views` | + +#### Script skeleton + +Below is a commented skeleton showing the typical structure of a complete migration. Not every +section will be needed for every module — omit what does not apply. + +**`pre-migrate.py`** +```py +from odoo.upgrade import custom_util + + +def migrate(cr, version): + # 1. Rename custom models (before fields, so the table exists under the new name) + custom_util.custom_rename_model(cr, "x_old_model", "new.model") + + # 2. Rename fields on standard and custom models + custom_util.custom_rename_field(cr, "sale.order", "x_studio_note", "custom_note") + custom_util.custom_rename_field(cr, "res.partner", "x_vip_client", "is_vip") + + # 3. Rename external identifiers + custom_util.rename_xmlids(cr, [ + ("old_xmlid", "new_xmlid"), + ("old_module.record", "new_module.record"), + ]) + + # 4. Move/rename Studio fields into the proper technical module + custom_util.transfer_custom_fields(cr, "studio_customization", "my_module", [ + ("sale.order", "x_studio_note"), # becomes "note" (prefix stripped) + ("res.partner", "x_vip_client", "is_vip"), + ]) + + # 5. Rename a custom module if needed + custom_util.custom_rename_module(cr, "old_module_name", "new_module_name") +``` + +**`post-migrate.py`** +```py +import os.path as osp + +from odoo.upgrade import custom_util +from custom_util import ( + do_pending_refactors, + RemoveFields, RenameElements, UpdateAttributes, AddElements, + add_view_modifications_to_migration_reports, +) + + +def migrate(cr, version): + # 1. Fix all indirect references for the field renames done in pre- + # (server actions, mail templates, related fields, etc.) + do_pending_refactors(cr) + + # 2. Patch views that reference renamed / removed fields + view_ops = { + "my_module.view_order_form_custom": [ + RenameElements("x_studio_note", "custom_note"), + RemoveFields("x_obsolete_field"), + ], + "odoo_studio_sale_ord_a1b2c3d4": [ + UpdateAttributes('//field[@name="date_order"]', invisible="1"), + AddElements('//field[@name="partner_id"]', '', position="after"), + ], + } + custom_util.edit_views(cr, view_ops) + + # 3. Log the view patches to the migration report for consultant review + add_view_modifications_to_migration_reports(cr, view_ops) + + # 4. Other data fixes + custom_util.merge_groups(cr, "my_module.group_old", "my_module.group_new") + custom_util.set_not_imported_modules(cr, ["my_module"]) +``` + +**`end-migrate.py`** *(only needed when Studio views must be created/updated)* +```py +import os.path as osp +from odoo.upgrade import custom_util + + +def migrate(cr, version): + custom_util.set_studio_view( + cr, + path=osp.join(osp.dirname(__file__), "studio_customization.xml"), + inherit_xml_id="sale.view_order_form", + ) +``` + +--- + +### Model renaming + +`custom_rename_model(cr, old, new)` — rename a custom model. Sets the model state to `base` +before delegating to `util.rename_model`, which is required for custom models: + +```py +custom_util.custom_rename_model(cr, "x_project_task", "custom.project.task") +``` + +--- + +### Field renaming + +`custom_rename_field(cr, model, old, new)` — rename a field on a model. Sets the field state to +`base` and queues the rename for the post-rename refactor pass (see `do_pending_refactors`): + +```py +custom_util.custom_rename_field(cr, "sale.order", "x_studio_delivery_note", "delivery_note") +custom_util.custom_rename_field(cr, "res.partner", "x_vip_client", "is_vip") +``` + +--- + +### Module renaming + +`custom_rename_module(cr, old, new)` — rename a custom module. Unlike `util.rename_module`, this +handles the case where the new module name was already registered as `uninstalled` by +`update_list()` before the migration ran: + +```py +custom_util.custom_rename_module(cr, "my_project_ext", "my_project") +``` + +--- + +### Studio / custom field transfer + +`transfer_custom_fields(cr, src_module, dest_module, fields_to_transfer)` — move Studio or +custom fields from one module to another, optionally renaming them. The `x_studio_` / `x_` +prefix is stripped automatically when no explicit new name is given: + +```py +# 2-tuple (model, field): just move; prefix stripped automatically +# 3-tuple (model, old_field, new_field): move and rename explicitly +custom_util.transfer_custom_fields(cr, "studio_customization", "my_module", [ + ("res.partner", "x_studio_vip"), # → "vip" + ("sale.order", "x_studio_delivery_note"), # → "delivery_note" + ("sale.order", "x_custom_ref", "reference_code"), # explicit rename +]) +``` + +--- + +### XML ID renaming + +`rename_xmlids(cr, pairs, detect_module=True, noupdate=None)` — rename a batch of external +identifiers. When `detect_module=True` (default), short names without a module prefix are +automatically resolved to the calling migration script's module: + +```py +custom_util.rename_xmlids(cr, [ + # fully qualified: move across modules + ("old_module.old_record_id", "new_module.new_record_id"), + # short name: current module is detected from call stack + ("old_local_xmlid", "new_local_xmlid"), +]) +``` + +--- + +### Updating related fields + +`update_related_field(cr, list_fields)` — update the `related` attribute on `ir.model.fields` +records when a field has been renamed. Useful after renaming fields that are referenced via +`related=` in other field definitions: + +```py +# list of (model, old_field_name, new_field_name) triples +custom_util.update_related_field(cr, [ + ("sale.order.line", "x_mo_id", "manufacturing_order_id"), + ("sale.order", "x_delivery_note", "delivery_note"), +]) +``` + +--- + +### Updating views with renamed fields + +`update_custom_views(cr, list_fields)` — do a text search-and-replace across all `ir.ui.view` +arch XML for the specified old field names. Use this as a quick sweep when `edit_views` would be +overkill: + +```py +custom_util.update_custom_views(cr, [ + ("sale.order.line", "x_mo_id", "manufacturing_order_id"), + ("sale.order", "x_delivery_note", "delivery_note"), +]) +``` + +--- + +### Post-rename refactors + +After renaming fields with `custom_rename_field`, call `do_pending_refactors` to update all +indirect references in server actions, mail templates, and similar records: + +```py +# In a pre- script: rename the fields +custom_util.custom_rename_field(cr, "sale.order", "x_old_field", "new_field") +custom_util.custom_rename_field(cr, "res.partner", "x_vip", "is_vip") + +# At the end of the script: flush all queued rename fixes +do_pending_refactors(cr) +``` + +`fix_renames_in_fields(cr, names_map)` — lower-level version that applies a rename map across +all default models (server actions, mail templates, etc.) without using the pending queue: + +```py +fix_renames_in_fields(cr, {"x_old_field": "new_field", "x_vip": "is_vip"}) +``` + +`fix_renames_in_records(cr, names_map, model, ids_or_xmlids=None, fields=None)` — apply a rename +map to a specific model, optionally restricted to certain records or fields: + +```py +# Fix only in mail.template, all records +fix_renames_in_records(cr, {"x_old": "new_field"}, "mail.template") + +# Fix only specific server actions +fix_renames_in_records( + cr, + {"x_amount": "amount_custom"}, + "ir.actions.server", + ids_or_xmlids=["my_module.action_compute", "my_module.action_confirm"], +) +``` + +--- + +### Updating translations + +`rename_in_translation(cr, name, values_mapping, res_ids, whole_words=True)` — apply renames +inside translated fields (eg. HTML/Jinja templates stored in `ir.translation`). Useful +when field renames must also be reflected in translated content: + +```py +# name is "," — same format as ir.translation records +rename_in_translation( + cr, + name="mail.template,body_html", + values_mapping={"x_old_field": "new_field"}, + res_ids=[42, 57], # restrict to specific template ids; pass [] for all +) +``` + +`build_chained_replace(field_name, values_mapping, whole_words=True)` — generate a chained +PostgreSQL `regexp_replace(...)` expression for bulk SQL updates. Returns a `(sql_expr, params)` +tuple ready to use in a `cr.execute` call: + +```py +sub_expr, kwargs = build_chained_replace( + "body_html", + {"x_old_amount": "amount", "x_ref": "reference"}, +) +cr.execute( + f"UPDATE mail_template SET body_html = {sub_expr} WHERE id IN %(ids)s", + {**kwargs, "ids": (1, 2, 3)}, +) +``` + +--- + +### View editing + +`edit_views(cr, view_operations, verbose=True, update_arch=True, create_missing_cows=False, website_id=…)` +is the main entry point for patching views. It accepts a mapping of view identifiers to sequences +of `ViewOperation` instances: + +```py +custom_util.edit_views(cr, { + # by xmlid + "my_module.view_order_form_custom": [ + RenameElements("x_old_field", "new_field"), + RemoveFields("x_obsolete_field"), + AddInvisibleSiblingFields("product_uom_id", "product_uom_category_id"), + ], + # by Studio xmlid shorthand (module prefix added automatically) + "odoo_studio_sale_order_a1b2c3d4": [ + UpdateAttributes('//field[@name="date_order"]', invisible="1"), + AddElements('//field[@name="partner_id"]', '', position="after"), + ], + # by integer id + 1234: [ + RemoveElements('//group[@name="deprecated_group"]'), + ], + # by ViewKey (for website views) + ViewKey("website_sale.product_item", website_id=1): [ + ReplaceValue("old_class", "new_class"), + ], +}) +``` + +#### Website views + +`edit_website_views(cr, view_operations, website_id=WebsiteId.NOTNULL, create_missing=False)` +is a wrapper around `edit_views` that interprets plain strings as view **keys** rather than +xmlids. Use it for COW-ed website views: + +```py +custom_util.edit_website_views(cr, { + "website_sale.product_item": [ + RemoveElements("//div[@class='ribbon ribbon-top-right']"), + ], +}, website_id=1) +``` + +Set `create_missing=True` to COW-create the view if it does not yet exist for the website: + +```py +custom_util.edit_website_views(cr, { + "website.footer_custom": [ + AddElementsFromFile( + "//xpath[contains(@expr, \"@id='footer'\")]", + osp.join(osp.dirname(__file__), "footer.xml"), + position="replace", + ), + ], +}, website_id=1, create_missing=True) +``` + +#### Activate / deactivate views + +```py +custom_util.activate_views(cr, "my_module.view_to_enable") +custom_util.deactivate_views(cr, ["my_module.view1", "my_module.view2"]) +# also accepts integer ids and ViewKey objects +custom_util.activate_views(cr, xmlids=["my_module.view_a", "my_module.view_b"]) +``` + +#### Studio views + +`set_studio_view(cr, path, inherit_xml_id)` — create or update a Studio view from an XML file, +or delete it if the file is empty. Must be called from an `end-` script because `web_studio` +must be loaded in the registry: + +```py +import os.path as osp + +custom_util.set_studio_view( + cr, + path=osp.join(osp.dirname(__file__), "studio_customization.xml"), + inherit_xml_id="sale.view_order_form", +) +``` + +`reset_studio_view_priority(cr, studio_view_xml_id)` — recalculate the priority of a Studio view +so it remains higher than all other views in the inherited hierarchy. Useful after a new standard +view with a high priority is added: + +```py +custom_util.reset_studio_view_priority(cr, "studio_customization.odoo_studio_sale_order_abc123") +``` + +`create_studio_view(cr, path, model="", inherit_xml_id="", type="form")` — **deprecated** alias +for `set_studio_view`. Prefer `set_studio_view` in new scripts. + +--- + +### View operations + +All operation classes are importable from `custom_util` and can be passed to `edit_views`. +Operations are callable and can also be applied directly: `op(arch, cr)` or `op.on(arch, cr)`. + +#### `AddElements(xpaths, elements_xml, position=AddElementPosition.INSIDE)` + +Insert xml fragments at the elements matched by `xpaths`. `position` can be an +`AddElementPosition` enum value or its name string: `"inside"`, `"after"`, `"before"`, +`"replace"`: + +```py +# add a field after an existing one +AddElements('//field[@name="partner_id"]', '', position="after") + +# wrap matched elements by replacing them +AddElements( + '//group[@name="main"]', + '', + position="replace", +) +``` + +#### `AddElementsFromFile(xpaths, filename, source_xpaths="/*", **kwargs)` + +Same as `AddElements` but loads the xml to insert from a file on disk. `source_xpaths` selects +which elements to extract from the file (defaults to all root children): + +```py +import os.path as osp + +AddElementsFromFile( + "//xpath[contains(@expr, \"@id='footer'\")]", + osp.join(osp.dirname(__file__), "footer.xml"), + position="replace", +) +``` + +#### `CopyElements(source_xpaths, dest_xpaths, from_view=None, **kwargs)` + +Copy elements from another part of the same view, or from a completely different view identified +by xmlid or integer id: + +```py +# copy within the same view +CopyElements("//*[@id='source_block']", "//div[@id='target_block']", position="after") + +# copy from another view +CopyElements( + "//div[@id='footer']", + "//div[@id='footer']", + from_view="website.default_footer", + position="replace", +) +``` + +#### `RemoveElements(xpaths)` + +Remove all elements matching the given xpath(s). Note that removal changes the document +structure, so place it after other operations: + +```py +RemoveElements('//group[@name="deprecated_section"]') +RemoveElements([f"//xpath[{i}]" for i in (3, 5, 7)]) +``` + +#### `RemoveFields(names)` + +Shorthand for removing `` elements: + +```py +RemoveFields("x_studio_old_field") +RemoveFields(["x_obsolete_1", "x_obsolete_2", "x_obsolete_3"]) +``` + +#### `AddInvisibleSiblingFields(name, sibling_name, position="after")` + +Add an invisible `` next to an existing field. A common pattern when a field's domain or +widget depends on a related field that is not in the view: + +```py +# adds after every product_uom_id +AddInvisibleSiblingFields("product_uom_id", "product_uom_category_id") +``` + +#### `RenameElements(name, new_name, xpath="//*")` + +Update the `name` attribute on all matching elements. Also updates `