# OCA OpenUpgrade — Odoo 17.0 → 18.0 Migration Framework OpenUpgrade is an OCA (Odoo Community Association) project that provides an open-source upgrade path for Odoo databases between consecutive major versions. The 18.0 branch handles migrations from Odoo 17.0 to 18.0 and ships as two Odoo add-on modules: `openupgrade_framework`, which monkey-patches the Odoo server at load time to make it safe for migration, and `openupgrade_scripts`, which contains the concrete per-module migration scripts and the `upgrade_analysis.txt` reports that document every model and field change between the two versions. Together they form a complete, in-place migration pipeline: a single Odoo upgrade run processes pre-migration scripts, loads updated modules, then executes post- and end-migration scripts across every installed module. The framework works by temporarily overriding key Odoo internals—model deletion, column dropping, view validation, module loading, and dependency resolution—so that obsolete tables and columns are preserved instead of destroyed, broken views are silently warned about rather than raising fatal exceptions, and new module dependencies introduced by the upgrade are automatically installed with their own migration scripts. Each concrete migration script in `openupgrade_scripts` uses the `openupgradelib` helper library (functions such as `openupgrade.rename_fields`, `openupgrade.add_columns`, `openupgrade.logged_query`, `openupgrade.m2o_to_x2m`, `openupgrade.load_data`) together with a `migrate(env, version)` function decorated with `@openupgrade.migrate()`, which is the sole entry point Odoo's migration manager calls. --- ## `openupgrade_framework` — Server-Wide Framework Module ### Activating the Framework via `server_wide_modules` The framework must be loaded before any database is opened. Add it to your Odoo configuration so all monkey-patches are applied before the upgrade starts. When `openupgrade_scripts` is also present on the add-on path, the `upgrade_path` setting is automatically pointed at its `scripts/` directory. ```ini # odoo.conf [options] addons_path = /path/to/odoo/addons,/path/to/openupgrade server_wide_modules = web,openupgrade_framework # upgrade_path is set automatically if openupgrade_scripts is on addons_path ``` ```bash # Or pass directly on the command line python odoo-bin \ --load=base,web,openupgrade_framework \ --addons-path=/opt/odoo/addons,/opt/openupgrade \ --upgrade-path=/opt/openupgrade/openupgrade_scripts/scripts \ -d mydb \ -u all ``` --- ### `openupgrade_test` — Test-Class Decorator A decorator exported from `openupgrade_framework.__init__` that marks an Odoo `TransactionCase` subclass so the OpenUpgrade test runner discovers and executes it. It sets the `openupgrade` tag, ensures `at_install` timing, and records the owning module name — making the test run right after the corresponding module is upgraded. ```python # openupgrade_scripts/scripts/account/tests/test_migration.py from odoo.tests import TransactionCase from odoo.addons.openupgrade_framework import openupgrade_test @openupgrade_test class TestAccountMigration(TransactionCase): def test_sending_data(self): """Verify that send_and_print_values was migrated correctly to sending_data.""" moves = self.env["account.move"].search([("sending_data", "!=", False)]) self.assertTrue(moves, "Expected at least one move with sending_data populated") first = moves[0] # Field was renamed and restructured during pre-migration self.assertIn("author_user_id", first.sending_data) self.assertEqual(first.sending_data["author_user_id"], self.env.user.id) self.assertIn("author_partner_id", first.sending_data) self.assertEqual( first.sending_data["author_partner_id"], self.env.user.partner_id.id, ) ``` --- ### `IrModel._drop_table` Monkey-Patch — Preserve Obsolete Tables Overrides the Odoo method that drops database tables when a model is removed. During migration, obsolete tables must be retained so migration scripts can read from them. A warning is logged instead of performing the DROP. ```python # Behaviour injected by openupgrade_framework/odoo_patch/odoo/addons/base/models/ir_model.py # (illustrative — no user code needed; the patch is active when the framework is loaded) # Normally Odoo would run: DROP TABLE IF EXISTS old_model_table # With OpenUpgrade loaded, the runtime instead logs: # "Not dropping the table or view of model old.model" # and the table survives for migration scripts to query. # Example: a pre-migration script can safely read the legacy table from openupgradelib import openupgrade @openupgrade.migrate() def migrate(env, version): # The old table still exists because _drop_table is suppressed if openupgrade.table_exists(env.cr, "old_module_legacy_table"): env.cr.execute("SELECT id, old_column FROM old_module_legacy_table") rows = env.cr.fetchall() # … transform and migrate rows into the new schema ``` --- ### `IrModelFields._drop_column` Monkey-Patch — Preserve Obsolete Columns Overrides the method that drops database columns when fields are removed from a model. Columns are retained throughout the migration so pre-migration scripts can read the old data before the new schema is applied. ```python # openupgrade_framework/odoo_patch/odoo/addons/base/models/ir_model.py (active automatically) # Pre-migration script pattern: read from old column, write to new column from openupgradelib import openupgrade field_renames = [ # (model_name, table_name, old_field, new_field) ("account.move", "account_move", "payment_id", "origin_payment_id"), ("account.move", "account_move", "reversal_move_id", "reversal_move_ids"), ] @openupgrade.migrate() def migrate(env, version): # old columns still present because _drop_column is suppressed openupgrade.rename_fields(env, field_renames) # Now origin_payment_id holds what was in payment_id ``` --- ### `BaseModel.unlink` Monkey-Patch — Safe Deletion with Savepoints Wraps every `unlink()` call issued during module uninstall inside a database savepoint. If the deletion fails due to an unexpected foreign-key dependency the savepoint is rolled back, a warning is logged, and the migration continues instead of aborting. ```python # openupgrade_framework/odoo_patch/odoo/models.py (active automatically) # Demonstration: deleting an obsolete record during migration from openupgradelib import openupgrade @openupgrade.migrate() def migrate(env, version): # Safe deletion — if this record has unforeseen dependents, a warning is # logged and migration proceeds rather than crashing the whole upgrade. openupgrade.delete_records_safely_by_xml_id( env, [ "account.action_account_unreconcile", # removed in 18.0 "account.account_root_comp_rule", # removed in 18.0 ], ) ``` --- ### `View._check_xml` / `View._raise_view_error` Monkey-Patches — Silent View Validation Suppresses fatal `ValidationError` and `ValueError` exceptions from Odoo's view XML validation during migration. Broken views (caused by fields or models removed in the new version) are logged as warnings and archived rather than crashing the upgrade. ```python # openupgrade_framework/odoo_patch/odoo/addons/base/models/ir_ui_view.py (active automatically) # No user action required. The patch produces log output like: # # WARNING odoo.addons.base.models.ir_ui_view: # Can't render custom view my_module.my_broken_view for model account.move. # Assuming you are migrating between major versions of Odoo. # Please review the view contents manually after the migration. # Error: Field account.move.old_field does not exist. # # After migration: search for archived views and fix or delete them. # To re-enable strict validation in a specific context: env["ir.ui.view"].with_context(raise_view_error=True).search([]).read(["arch"]) ``` --- ### `Graph.update_from_db` Monkey-Patch — Suppress Demo Data Reload Prevents Odoo from reloading demo data when the database was created with demo data but is now being upgraded to a production release. Set `OPENUPGRADE_USE_DEMO=yes` in the environment to opt out. ```bash # Default behaviour: demo flag is cleared on major upgrade python odoo-bin --load=base,web,openupgrade_framework -d mydb -u all # The patch executes: UPDATE ir_module_module SET demo = false # and sets package.dbdemo = False for all packages. # To keep demo data during migration: OPENUPGRADE_USE_DEMO=yes python odoo-bin \ --load=base,web,openupgrade_framework -d mydb -u all ``` --- ### `Graph.add_modules` Monkey-Patch — Auto-Install New Dependencies When a module upgrade introduces new mandatory dependencies, this patch resolves the full transitive dependency graph, marks those modules as `to install`, and ensures their own pre-migration scripts are executed before their Python code is loaded. ```python # openupgrade_framework/odoo_patch/odoo/modules/graph.py (active automatically) # Scenario: upgrading 'sale' in 18.0 pulls in the new 'sale_edi_ubl' module. # Without the patch: 'sale_edi_ubl' would install silently with no migration scripts. # With the patch: the full dependency chain is resolved and each new module's # pre-migration script fires before its ORM models are initialised. # The SQL executed internally: # UPDATE ir_module_module SET state = 'to install' # WHERE name IN (...new_deps...) AND name NOT IN (...originally_requested...) # AND state = 'uninstalled' ``` --- ### `MigrationManager.migrate_module` Monkey-Patch — Run Scripts on Fresh Installs Forces migration scripts to execute even when a module is being freshly installed (state `to install`) during an upgrade run, not just when it is being upgraded. This is needed for modules that are new dependencies introduced by the target version. ```python # openupgrade_framework/odoo_patch/odoo/modules/migration.py (active automatically) # Also filters out Odoo SA's own migration scripts that conflict with OpenUpgrade: # to_exclude = [("analytic", "1.2")] # These entries are removed from MigrationManager.migrations before scripts run. ``` --- ## `openupgrade_scripts` — Per-Module Migration Scripts ### `apriori.py` — Module Rename and Merge Registry Defines the authoritative mapping of renamed and merged modules between 17.0 and 18.0. These dictionaries are used by the upgrade analysis tooling to match records between the old and new database schemas. They do not run as part of the migration itself but are consulted by the `upgrade_analysis` wizard. ```python # openupgrade_scripts/apriori.py # renamed_modules: old_name → new_name renamed_modules = { "l10n_es_pos_tbai": "l10n_es_edi_tbai_pos", "mrp_subonctracting_landed_costs": "mrp_subcontracting_landed_costs", "website_sale_picking": "website_sale_collect", "website_form_project": "website_project", # OCA modules: "account_commission": "account_commission_oca", "sale_commission": "sale_commission_oca", "pdf_helper": "pdf_xml_attachment", } # merged_modules: old_name → surviving_module_name merged_modules = { "account_audit_trail": "account", "account_lock": "account", "l10n_fr_fec": "l10n_fr_account", "payment_ogone": "payment_worldline", "payment_sips": "payment_worldline", "sale_product_configurator": "sale", } # renamed_models: old_model → new_model (used only in upgrade_analysis) renamed_models = { "hr.applicant.skill": "hr.candidate.skill", "mail.shortcode": "mail.canned.response", "pos.combo": "product.combo", "pos.combo.line": "product.combo.item", } ``` --- ### `pre-migration.py` — Pre-Migration Script Pattern Each module that requires schema preparation before Odoo loads the new ORM defines a `pre-migration.py` file under `scripts///`. The file must expose a `migrate(env, version)` function decorated with `@openupgrade.migrate()`. Typical pre-migration tasks: rename columns/fields, add new columns with defaults, drop SQL views that would block column renames, and update selection field values. ```python # openupgrade_scripts/scripts/account/18.0.1.3/pre-migration.py from openupgradelib import openupgrade # Declare field renames: (model, table, old_column, new_column) field_renames = [ ("account.move", "account_move", "payment_id", "origin_payment_id"), ("account.move", "account_move", "reversal_move_id", "reversal_move_ids"), ("account.move", "account_move", "send_and_print_values", "sending_data"), ("account.payment", "account_payment", "journal_id", "old_journal_id"), ("account.payment", "account_payment", "destination_journal_id", "journal_id"), ] # Declare new columns to add before ORM initialisation _new_columns = [ ("account.bank.statement.line", "company_id", "many2one"), ("account.journal", "autocheck_on_post", "boolean", True), ("account.move", "checked", "boolean"), ("account.move", "preferred_payment_method_line_id", "many2one"), ("account.tax", "price_include_override", "selection", "tax_excluded"), ("account.payment", "name", "char"), ("account.payment", "state", "selection"), ] def _drop_sql_views(env): # Drop computed SQL views that reference columns being renamed openupgrade.logged_query(env.cr, "DROP VIEW IF EXISTS account_root") def rename_selection_option(env): # Migrate renamed selection key 'off_balance' → 'off' openupgrade.logged_query( env.cr, "UPDATE account_account SET internal_group = 'off' " "WHERE internal_group = 'off_balance'", ) # Migrate price_include boolean → price_include_override selection openupgrade.logged_query( env.cr, "UPDATE account_tax SET price_include_override = 'tax_included' " "WHERE price_include", ) @openupgrade.migrate() def migrate(env, version): openupgrade.rename_fields(env, field_renames) openupgrade.add_columns(env, _new_columns) _drop_sql_views(env) rename_selection_option(env) # Convert a plain-text field to HTML, preserving translations openupgrade.convert_field_to_html( env.cr, "account_tax", "description", "description", verbose=False, translate=True, ) # Safe record deletion — rolls back individual failures openupgrade.delete_records_safely_by_xml_id( env, ["account.action_account_unreconcile"] ) ``` --- ### `post-migration.py` — Post-Migration Script Pattern Runs after Odoo has loaded and initialised all upgraded modules. At this point the new ORM is fully available, new columns exist, and Odoo's own data loading has completed. Typical tasks: back-fill new relational columns, convert `many2one` fields to `many2many`, apply `company_dependent` conversions, load `noupdate_changes.xml`, and delete stale translated strings. ```python # openupgrade_scripts/scripts/account/18.0.1.3/post-migration.py from openupgradelib import openupgrade, openupgrade_180 def handle_lock_dates(env): """Copy period_lock_date to the two new split lock-date fields.""" openupgrade.logged_query( env.cr, """ UPDATE res_company SET sale_lock_date = period_lock_date, purchase_lock_date = period_lock_date WHERE period_lock_date IS NOT NULL """, ) # Check whether account_lock was installed in the source database env.cr.execute( f""" SELECT state FROM {openupgrade.get_legacy_name("ir_module_module")} WHERE name = 'account_lock' """ ) row = env.cr.fetchone() if row and row[0] == "installed": openupgrade.logged_query( env.cr, "UPDATE res_company SET hard_lock_date = fiscalyear_lock_date " "WHERE fiscalyear_lock_date IS NOT NULL", ) def link_payments_to_moves(env): """Populate the new account_move__account_payment relation table.""" openupgrade.logged_query( env.cr, """ INSERT INTO account_move__account_payment (invoice_id, payment_id) SELECT am.id, ap.id FROM account_payment ap JOIN account_move am ON ap.move_id = am.id """, ) def convert_company_dependent(env): """Convert legacy ir.property rows to v18-style company-dependent JSON.""" for model, field in [ ("account.cash.rounding", "loss_account_id"), ("account.cash.rounding", "profit_account_id"), ("product.category", "property_account_expense_categ_id"), ("product.template", "property_account_expense_id"), ("res.partner", "credit_limit"), ("res.partner", "property_account_payable_id"), ("res.partner", "property_account_receivable_id"), ("res.partner", "property_payment_term_id"), ]: openupgrade_180.convert_company_dependent(env, model, field) @openupgrade.migrate() def migrate(env, version): handle_lock_dates(env) link_payments_to_moves(env) # Convert company_id (many2one) → company_ids (many2many) openupgrade.m2o_to_x2m( env.cr, env["account.account"], "account_account", "company_ids", "company_id", ) convert_company_dependent(env) # Load XML records that must not be updated on subsequent upgrades openupgrade.load_data(env, "account", "18.0.1.3/noupdate_changes.xml") # Remove stale translations for renamed/deleted records openupgrade.delete_record_translations( env.cr, "account", ["email_template_edi_invoice"] ) openupgrade.delete_records_safely_by_xml_id( env, [ "account.default_followup_trust", "account.account_move_send_rule_group_invoice", "account.onboarding_onboarding_step_bank_account", ], ) ``` --- ### `end-migration.py` — End-Migration Script Pattern Runs after all post-migration scripts have completed and Odoo's internal caches have been rebuilt. Used for tasks that depend on the fully-initialised new state, such as recomputing stored computed fields or synchronising cross-module data structures. ```python # openupgrade_scripts/scripts/analytic/18.0.1.2/end-migration.py from openupgradelib import openupgrade @openupgrade.migrate() def migrate(env, version): # Recompute stored column data that depends on fully loaded plan structure env["account.analytic.plan"].search([])._sync_all_plan_column() ``` --- ### `upgrade_analysis.txt` — Schema Change Report Machine-generated diff report produced by the `upgrade_analysis` OCA module comparing two live Odoo databases. Each `pre-migration.py` script is guided by the corresponding `upgrade_analysis.txt` to ensure every model, field, and selection change is handled. Below is an excerpt for the `account` module (17.0 → 18.0). ```text # openupgrade_scripts/scripts/account/18.0.1.3/upgrade_analysis.txt (excerpt) ---Models in module 'account'--- obsolete model account.tour.upload.bill [transient] obsolete model account.unreconcile [transient] new model account.autopost.bills.wizard [transient] new model account.lock_exception ---Fields in module 'account'--- account / account.account / code (char) : not stored anymore account / account.account / code_store (char) : NEW account / account.account / company_id (many2one) : DEL relation: res.company, required account / account.account / company_ids (many2many) : NEW relation: res.company, required account / account.account / internal_group (selection) : selection_keys added: [off], removed: [off_balance] account / account.move / payment_id (many2one) : DEL relation: account.payment account / account.move / origin_payment_id (many2one) : NEW relation: account.payment account / account.move / reversal_move_ids (many2many) : NEW relation: account.move account / account.cash.rounding / loss_account_id : needs conversion to v18-style company dependent account / account.cash.rounding / profit_account_id : needs conversion to v18-style company dependent ``` --- ### `openupgrade_180.convert_company_dependent` — Convert `ir.property` to JSON A version-specific helper from `openupgradelib` that migrates fields previously stored as `ir.property` records (the Odoo ≤ 17.0 mechanism for company-dependent values) to the new Odoo 18.0 JSON-based storage directly on the model's table column. Used extensively in post-migration scripts. ```python # Pattern used in multiple post-migration scripts from openupgradelib import openupgrade, openupgrade_180 @openupgrade.migrate() def migrate(env, version): # Each call reads all ir.property rows for (model, field) and writes # a per-company JSON dict back to the model's column. openupgrade_180.convert_company_dependent( env, "account.analytic.plan", "default_applicability" ) # Example with a partner field: openupgrade_180.convert_company_dependent( env, "res.partner", "property_account_payable_id" ) openupgrade_180.convert_company_dependent( env, "res.partner", "property_account_receivable_id" ) openupgrade_180.convert_company_dependent( env, "res.partner", "property_payment_term_id" ) ``` --- ### `openupgrade.logged_query` — Logged Raw SQL Execution Executes a raw PostgreSQL statement and logs its execution (query text plus affected row count) at `DEBUG` level. Preferred over bare `env.cr.execute` inside migration scripts for auditability. ```python from openupgradelib import openupgrade @openupgrade.migrate() def migrate(env, version): # Rename a selection value across all rows in a table openupgrade.logged_query( env.cr, """ UPDATE account_account SET internal_group = 'off' WHERE internal_group = 'off_balance' """, ) # Back-fill a new column from a JOIN openupgrade.logged_query( env.cr, """ UPDATE account_bank_statement_line sl SET company_id = mv.company_id FROM account_move mv WHERE sl.move_id = mv.id """, ) # Migrate a JSONB field's internal structure openupgrade.logged_query( env.cr, """ UPDATE account_move SET sending_data = jsonb_set( sending_data::jsonb - 'sp_partner_id', '{author_partner_id}', sending_data::jsonb->'sp_partner_id' ) WHERE sending_data IS NOT NULL AND sending_data::jsonb ? 'sp_partner_id' """, ) ``` --- ### `openupgrade.column_exists` / `openupgrade.table_exists` — Schema Introspection Guards Utility functions that check whether a column or table exists in the current database before attempting to read from or rename it. Essential when migration scripts must handle databases that were upgraded from different intermediate versions (e.g., 15.0 → 18.0 via multiple hops). ```python from openupgradelib import openupgrade @openupgrade.migrate() def migrate(env, version): # Guard: only rename if the source column is present # (may be absent if upgrading from a version where it didn't exist) if openupgrade.column_exists(env.cr, "account_cash_rounding", "profit_account_id"): openupgrade.rename_columns( env.cr, {"account_cash_rounding": [("profit_account_id", None)]}, ) if openupgrade.column_exists(env.cr, "res_partner", "credit_limit"): openupgrade.rename_columns( env.cr, {"res_partner": [("credit_limit", None)]}, ) # Guard: read from a legacy table only if it survived if openupgrade.table_exists(env.cr, "account_root"): env.cr.execute("SELECT id, name FROM account_root") # … migrate rows ``` --- ### `openupgrade.get_legacy_name` — Access Pre-Upgrade Module State Table Returns the name of the shadow table created by OpenUpgrade before the upgrade run begins. This table is a snapshot of `ir_module_module` at the start of the migration and is used to query what modules were installed in the source version without being affected by the in-progress upgrade. ```python from openupgradelib import openupgrade @openupgrade.migrate() def migrate(env, version): # Was 'account_lock' installed in the 17.0 source database? env.cr.execute( f""" SELECT state FROM {openupgrade.get_legacy_name("ir_module_module")} WHERE name = 'account_lock' """ ) row = env.cr.fetchone() if row and row[0] == "installed": # Apply migration logic that only applies when account_lock was present openupgrade.logged_query( env.cr, "UPDATE res_company SET hard_lock_date = fiscalyear_lock_date " "WHERE fiscalyear_lock_date IS NOT NULL", ) ``` --- ### `openupgrade.m2o_to_x2m` — Convert Many2one to Many2many/One2many Migrates data when a field changes from a `many2one` relation to an `x2many` relation. Reads the existing single-value column and inserts the corresponding rows into the new relational table. ```python from openupgradelib import openupgrade @openupgrade.migrate() def migrate(env, version): # account.account.company_id (many2one) → company_ids (many2many) # Creates rows in the account_account_res_company_rel table openupgrade.m2o_to_x2m( env.cr, env["account.account"], # ORM model (for table name introspection) "account_account", # database table name "company_ids", # new many2many field name "company_id", # old many2one column name ) ``` --- ## Summary OpenUpgrade's primary use case is in-place major-version upgrades of production Odoo databases from 17.0 to 18.0. Operators clone this repository onto the 18.0 branch, place it on the Odoo `addons_path` alongside the target Odoo 18.0 source, configure `server_wide_modules = web,openupgrade_framework`, and launch Odoo with `-u all` (or a targeted module list). The framework patches fire automatically at server startup; the migration manager then discovers and executes the `pre-migration.py`, `post-migration.py`, and `end-migration.py` files for every installed module that has a corresponding script under `openupgrade_scripts/scripts//18.0.x.x/`. The `apriori.py` rename and merge dictionaries ensure the upgrade analysis tooling can reconcile module identities across versions, while the `upgrade_analysis.txt` reports serve as the authoritative specification for what each migration script must handle. Contributors extend the project by adding new migration scripts for uncovered modules: create `openupgrade_scripts/scripts//18.0.x.x/pre-migration.py` and/or `post-migration.py` following the `@openupgrade.migrate()` decorator pattern, guided by the module's `upgrade_analysis.txt` report. Integration with the OCA `upgrade_analysis` addon (a separate project) allows contributors to generate fresh `upgrade_analysis.txt` files by connecting two live Odoo instances — one running 17.0 and one running 18.0 — and running the analysis wizard, which produces the field-level diff that drives script development. The entire pipeline is validated by CI (GitHub Actions) that runs the OpenUpgrade test suite with the `openupgrade` tag against a real upgraded database, with per-module `TransactionCase` classes decorated with `@openupgrade_test` verifying data integrity after each module's migration.