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 `