Repository navigation
Conversation
dnplkndll
force-pushed
the
20.0-campaign
branch
11 times, most recently
from
September 21, 2026 02:51
3feb380 to
904b268
Compare
Generated output of upgrade_analysis for 19.0 -> 20.0, covering 362 core modules: the per-module upgrade_analysis.txt, the general log, and the noupdate_changes.xml where data records moved. Produced the way OCA's bot produces it -- a 19.0 instance and a 20.0 instance with the same module set, upgrade.generate.record.wizard run on each to fill upgrade_record, then upgrade.analysis comparing the two over odoorpc. Both sides built without demo data: the analysis is a registry diff, and a full reinstall with demo crashes on a purchase_order_stock_picking_rel foreign key. Fork-local. Not for upstream. Assisted-by: Claude Opus 5
docsource/modules190-200.rst: the coverage table for 611 modules, 45 of them marked |del| as gone in 20.0. Generated by generate_module_coverage_file, which only runs when module_coverage_file_folder is set. apriori.py rewritten for this cycle. Every merged_modules entry is backed by the analysis reporting stored fields as "previously in module <old>" under exactly one new owner, not by inspection of names: account_add_gln -> account, account_peppol_response -> account_peppol, delivery_stock_picking_batch -> stock_delivery, hr_homeworking/hr_hourly_cost/ hr_org_chart -> hr, l10n_dk_nemhandel(+_response) -> l10n_dk, l10n_pl_bank_verification -> l10n_pl, l10n_ro_cpv_code -> l10n_ro_edi, l10n_ro_edi_stock_batch -> l10n_ro_edi_stock, l10n_tr_nilvera_einvoice_extended -> l10n_tr, pos_restaurant_adyen -> pos_adyen, stock_picking_batch -> stock, website_sale_comparison and website_sale_wishlist -> website_sale. base_vat is mapped to base rather than to account: the analysis only shows res.company.vat_check_vies moving to account, while every check_vat_* method now lives in base/models/res_partner.py. website_sale_autocomplete -> website_address_autocomplete is a rename, not a merge, so it sits in renamed_modules. Attributions that came only through res.partner.invoice_edi_format are left out. That selection is contributed by every l10n EDI module, so it named l10n_dk as the new owner of l10n_tr_nilvera's fields, which is an artefact. renamed_models is still empty: 75 models go obsolete and 105 appear, and pairing them is a judgement call that drives real behaviour, so the candidates are parked in docs/19-to-20-model-delta.md until each one is settled. Assisted-by: Claude Opus 5
Every patch in odoo_patch wraps its target via _original_method and forwards *args/**kwargs, so the 20.0 core rewrites of unlink() and load_module_graph() need no adaptation -- load_module_graph dropping its models_to_check parameter is absorbed by the forwarding. All patch targets still resolve on 20.0: BaseModel.unlink, ModuleGraph._update_from_database, MigrationManager._get_files, IrModel._drop_table, IrModelFields._drop_column, IrModelRelation. _module_data_uninstall, the four IrUiView methods, IrModule.update_list and check_external_dependencies, plus MODULE_UNINSTALL_FLAG, models.MAGIC_COLUMNS and registry._force_upgrade_scripts. Dropped the 18->19 ir_model_fields.translate normalisation from the ModuleGraph patch. It rewrote translate='false' to NULL while the column was being converted from boolean to varchar. On a 19.0 source the column is already varchar holding NULL/standard/html_translate/xml_translate, and the selection is byte-identical in 20.0, so the statement matches nothing and no longer describes a real migration step. Assisted-by: Claude Opus 5
…gistry loads 20.0 refuses _button_immediate_function on an unloaded registry and points at button_install instead. Core still reaches for it from code that runs during an install: hr's res_config_settings.set_values installs hr_attendance, and every installed module's set_values chains into hr's, so any post_init_hook executing a res.config.settings brings the operation down once hr is present. Seen from l10n_mx._enable_group_uom_post_init and stock_fleet._enable_dispatch_management -- excluding one module only moves the crash to the next. Doing what the error message asks keeps the module operation, which is all the caller wanted. Assisted-by: Claude Opus 5
…dels mail.activity._get_phone_numbers_by_activity resolves self.env[res_model] for every distinct model among the activities it is given. That holds at runtime, where every installed module is in the registry, but not during a major upgrade: activities belonging to a module that has not been loaded yet are already in the table, so the lookup raises KeyError and stops the upgrade. A single mail.activity on event.event ends a 19->20 run while mail is being updated and event has not been reached. Filters those activities out before delegating. mail is not a dependency of this module, so the patch is skipped when mail is absent. Assisted-by: Claude Opus 5
Working state of the fork-local campaign: run the upgrade against the 585-module 19.0 seed, fix what stops it, repeat. Eight runs so far, each carrying the load several hundred lines further. base: clean_transient_models, the ir.model.fields.index boolean-to-selection conversion, and update_module_names over apriori. mail: claim the administrator's membership of the admin channel, which 20.0 now ships an xml_id for and 19.0 created through autosubscribe, so the data load updates the row instead of tripping discuss_channel_member_partner_unique. account: re-point stock_account.action_report_stock_valuation at account. The client action keeps its unique path, so account's copy has to land on the existing row. hr: rename hr.contract.type to hr.employee.type, model and table. The existing xml_ids still name the old model, so hr's data raises KeyError on a model the registry no longer has. apriori gains the two model renames behind those last two scripts. Both are evidence-backed: each appears in the analysis as an obsolete model paired with a new one, and the collision that motivated the script confirms the pairing. Assisted-by: Claude Opus 5
mass_mailing: release mailing_list_merge_action. 19.0 shipped it as an ir.actions.act_window, 20.0 ships the same external id as an ir.actions.server, and the loader will not bind an id to a record of another model. Only the ir_model_data row goes; the orphaned window action is left for database_cleanup. hr_work_entry: rename the nine country-neutral work entry types to their generic_ names. Without it the data load inserts a second country-less record per code and _check_code_unicity rejects it. l10n_tr: rename l10n_tr_nilvera_einvoice_extended.tax.office to l10n_tr.tax.office, model and table. res.partner.l10n_tr_tax_office_id comes along and its foreign key is rebuilt against l10n_tr_tax_office, which fails on existing partner rows unless the table moves too. apriori gains that third model rename. Log length per run: 2305, 4671, 5092, 5608. Assisted-by: Claude Opus 5
…pgrade account is inconsistent about which mapping it uses. _load_translations picks the companies to work on with _get_chart_template_mapping(get_all=True), while _parse_csv resolves those same codes against the default visible-only mapping and subscripts the result. A company on a template 20.0 stopped offering passes the filter and then fails the lookup. 19.0's plain "fr" is one of them: 20.0 keeps it as the parent of the visible French variants, so an existing FR company ends the load with "'NoneType' object is not subscriptable" after every module has already migrated -- the last thing standing between a 585-module database and a finished upgrade. A database being upgraded has whatever templates it has, and they all need to resolve. account is not a dependency here, so the patch is skipped when absent. Assisted-by: Claude Opus 5
Runs 12-21. The upgrade now finishes: registry loads in 562s, every installed module reports a 20.0 version, and the resulting database serves. uom field rename, 12 fields across mrp, product, purchase, purchase_requisition, repair and stock. 20.0 drops the product_ prefix. Derived from the analysis by pairing DEL and NEW relations to uom.uom per model rather than by reading names. crm_iap_mine: the industry list is replaced wholesale, 23 entries becoming 83 with renumbered ids. Matching both csv files by name, Real Estate is the only one carried over and the only one that collides. l10n_th: claim the four withholding tax tags. 19.0 created them implicitly from the tax template csv, which names tags without giving them ids, so the rows exist with no ir_model_data entry and 20.0's explicit records collide. sale: product.template.sale_delay becomes company_dependent, which in 20.0 means jsonb on the table rather than an ir.property row. Postgres will not cast integer to jsonb, so the value is parked under a legacy column in pre-migration and written back as one entry per company in post-migration. Also releases action_accrued_revenue_entry_sale_order_line, which changed from act_window to server action -- a sweep of every ir.actions.server id in the 20.0 source found it to be the last one in that state. apriori grows to 45 entries. The 23 added here had no field of their own left to trace, so each is matched to the module carrying its feature in 20.0 and confirmed to exist there. That takes modules stuck in "to upgrade" from 27 to 4: delivery_mondialrelay, website_sale_mondialrelay, transifex and purchase_requisition_sale have no successor at all. purchase_requisition_sale -> purchase_alternative_sale is deliberately absent. It reads like a rename and is not one: applying it leaves the upgrade building a foreign key on purchase_alternative_warning_id, a column nothing creates. Assisted-by: Claude Opus 5
ruff-format on the framework patches and the generated purchase_requisition script, plus trailing whitespace in the analysis output and the coverage table. Assisted-by: Claude Opus 5
Status column filled from what is actually in the tree, not by hand:
Done (57) the 16 modules with a hand-written script, the 41 |del|
rows that are apriori merge sources (base's pre-migration
folds their data into the surviving module, so they have
to stay registered), and website_address_autocomplete,
which needs -u after base renames its ir_module_module row
Partial (345) analysed by upgrade_analysis, no reviewed Python yet
Nothing to do (203) no 20.0 script directory at all -- no DB layout delta
blank (4) delivery_mondialrelay, website_sale_mondialrelay,
transifex and purchase_requisition_sale have no successor
Partial rather than Done for the 345 is deliberate. CI's grep does not
distinguish the three words, so the choice is purely what we claim, and calling
machine-analysed work reviewed would be false.
This takes the job from one module to 580 old / 564 new.
Assisted-by: Claude Opus 5
dnplkndll
force-pushed
the
20.0-campaign
branch
from
September 21, 2026 03:23
904b268 to
0b7789d
Compare
--test-tags openupgrade has matched nothing on this branch since the 19.0 series content was dropped, and Odoo exits 0 when a tag matches no tests -- load_modules guards on "if suite.countTestCases()". Zero tests run and all tests passing are indistinguishable from outside, so the migration job proved only that the upgrade does not raise. Nine assertions over base's largest script. The snippet plants records in 19.0 through the old shell; the tests read them back after the upgrade: - the four perm_ booleans concatenate in c,r,u,d order, with perm_write becoming "u" -- "ru" and "cd" are asserted because non-adjacent letters and the write/update spelling are what a reader would get wrong - external ids survive, which is the whole reason the script renames the table instead of copying rows - a three-group rule fans out to exactly three rows, one per group, each carrying the domain byte for byte, and exactly one of them keeps the external id -- asserted by counting, not by picking a row, because the script maps min(id) and which row that is follows join order - a rule with no groups becomes one row with an empty group_id - an unnamed rule is labelled with its model - an access line granting nothing is dropped along with its external id - an archived line survives archived, searched with active_test=False so the assertion cannot pass by finding nothing - nothing is left pointing at ir.model.access or ir.rule Every method opens by asserting its records exist. The snippet runs in a separate process and would exit 0 having written nothing if its commit were lost, so an assertion whose empty case is a pass would report success for a migration that never happened. Verified against the seed: 9 tests, 0 failed. Then verified they can fail -- flipping the expected operation from "ru" to "ur" gives exactly one failure, the mutated test, with the other eight still passing. There is deliberately no rule granting no permissions: 19.0 carries a CHECK constraint, ir_rule_no_access_rights, forbidding one. ir_model_access has no equivalent, which is why the access line case is real and needs handling. Assisted-by: Claude Opus 5
release_xmlid moves into openupgrade_scripts/helpers.py, importable from any
script the way apriori already is. mass_mailing and sale carried the same
eight-line DELETE for the same reason: 19.0 shipped an action as an
ir.actions.act_window and 20.0 ships the id as an ir.actions.server, so the id
has to be freed before the loader will bind it. openupgradelib has nothing for
this -- delete_records_safely_by_xml_id removes the record itself and needs the
model in the registry, which is wrong for a pre step.
base no longer re-points ir_model_data.model by hand. rename_models already
does it: it walks the many2one references, and on a 19.0 schema
get_many2one_references falls back to a static list whose first entry is
("ir.model.data", "res_id", "model", ""). The hand-written update logged
"0 rows affected" every run, and its comment -- that rename_models leaves the
column alone -- was simply wrong. It is also why hr and l10n_tr rename their
models without such an update and come out clean.
The test fixtures file their external ids under a module that does not exist.
_process_end deletes data rows belonging to modules it has just loaded, so ids
claiming to be base's were removed at the end of every run; the tests still
passed, being at_install, but nothing survived to inspect afterwards. They now
survive, and the fan-out is visible in the finished database.
Verified: full migration, 9 tests, 0 failed, registry loaded in 678s, and
__ou19__.ou19_access_ru and __ou19__.ou19_rule_multigroup both resolve to
ir.access records afterwards.
Assisted-by: Claude Opus 5
The 362 analysis files were generated against an empty apriori. It now carries 40 merged modules and one rename, and upgrade_analysis reads both to attribute records that changed hands. Re-running moves 22 files. Two kinds of change. Records that used to be reported as anomalies are gone, because apriori explains them -- "previously in module account_add_gln", "model account.peppol.response (moved from account_peppol_response)" and so on for every absorbed module. And the surviving module now owns what it absorbed, which is why l10n_ro_edi, l10n_cn and l10n_tr grow: l10n_ro_cpv_code, l10n_cn_city and the l10n_tr_nilvera family fold into them, so their records belong to the receiving module's analysis, which is where a migration script for them would have to live. The one renamed_models entry earns its place too: -new model account.stock.valuation.report [abstract] +new model account.stock.valuation.report (renamed from stock_account.stock.valuation.report in module stock_account) [abstract] docsource/modules190-200.rst is deliberately not part of this. generate_module_coverage_file rewrites the table from scratch with a blank Status column, so running the analysis after marking it silently undoes the marking. The row sets are identical -- 610 either way -- so the marked copy is kept. Worth knowing: with the guard added earlier this now fails the migration job loudly instead of quietly migrating nothing. Assisted-by: Claude Opus 5
The analysis generated 73 noupdate_changes.xml files and nothing loaded any of them, so the upgrade ran green while silently delivering none of the 781 record updates they hold. noupdate records are skipped by a module update by definition, so these only reach an existing database through an explicit openupgrade.load_data call. payment and payment_demo need a transformation file. payment_method_ids was a many2many in 19.0 and is a one2many in 20.0, so the analyser's eval="None" no longer clears a relation table but unlinks the provider's payment methods, which core refuses outright. Assisted-by: Claude Opus 5
Every line in these is either the ir.model.access/ir.rule fold into ir.access, which base does globally, or a record whose noupdate flag switched without its definition changing. The three that also carry a definition change are marked against the load_data added for them. Assisted-by: Claude Opus 5
…od-copy hits The four 20.0 gates against the sanitised prod copy erp_mig20test reported five between renames-20 and defaults-20. All five were triaged against the data and none is data loss. check_rename_pairs: the acknowledgement key was the full label, which embeds the module attribution and the type token. The same field pair arrives under different ones depending on which module's analysis file it was read from -- res.partner.peppol_eas -> routing_scheme comes through as `account_edi_ubl_cii:... (selection)` from one and `account_peppol:... (False)` from another. The maintainer had already adjudicated that pair; the key simply stopped matching, and the gate failed the run on a settled question. Match on the field pair, keep module and type in the label because they read well. Plus one new entry: resource.calendar.tz -> calendar_type are different fields (tz holds timezones, calendar_type holds 'fixed' -- 20.0's new fixed/flexible mode, correct for every calendar predating the concept). check_defaulted_columns: three acknowledgements where one value really is right. res.partner.bank.country_id and clearing_label_id are uniform because all 19 accounts are US, not because they were stamped. hr.employee.first_contract_date equals each employee's create_date and the migration ran months later, so it is carried, not stamped -- the uniform-date heuristic misreads a 2-row table whose employees were created the same day. All four gates now pass against erp_mig20test.
…stamped Suggested by the odoo-19-to-20-migration session after the prod-copy triage, and it generalises the acknowledgement it replaces. RULE A flags a date/datetime holding one value across rows that differ, on the reasoning that a column default is evaluated once when the column is created. The corollary was never encoded: that stamp necessarily lands on the day the upgrade ran. A uniform value months earlier is therefore evidence AGAINST stamping -- it came from somewhere -- and any small table whose rows genuinely share a date reads as a defect without it. The migration's date is the newest ir_module_module.write_date, since every module is written during -u all. Proved on erp_mig20test by removing the hr.employee.first_contract_date acknowledgement: defaults-20 still passes, the rule clearing it on its own (2026-03-05 held vs a 2026-09-28 migration). The acknowledgement is kept because it records the reasoning about hr_contract being absent, which the rule does not.
The two defects only our own data could show -- res.device.log losing the user agent 20.0 requires, and the account/base pair -- lived on 20.0-prodcopy alone. Campaign is the tree the lab bind-mounts, so it has to carry them. # Conflicts: # .github/scripts/check_defaulted_columns.py
19.0's crm_menu_root carried action="crm.action_your_pipeline", an ir.actions.server. 20.0 redeclares the same menuitem with no action and drops action_your_pipeline entirely -- My Pipeline moved down to menu_crm_opportunities on crm.crm_lead_action_pipeline. A menuitem redeclared without an action does not clear one, so the upgrade deletes the action and leaves ir_ui_menu.action naming it. Not cosmetic, and not confined to CRM. Resolving the reference raises "Record does not exist or has been deleted", and website.layout does exactly that, so every frontend page answers 500 for a logged-in session -- /web/login included. Nobody can sign in. An anonymous GET of the same URL returns 200, which is why a health check would have called the database fine. Measured on a 19.0 production copy: exactly one stale row, and the whole site down. Read through SQL rather than menu.action, because the ORM resolves the reference into a recordset and there is nothing left to inspect -- and resolving a dangling one is the raise this prevents. crm was marked "Nothing to do"; it isn't.
20.0 moved _get_visitor_from_request from website.visitor to ir.http. The module update rewrites the views it owns, so website.contactus comes through correct -- but it cannot rewrite a COW copy. When a page is edited Odoo duplicates the view with website_id set and no ir_model_data row, and a record no xmlid points at is invisible to every module update thereafter. So the copy keeps the 19.0 call, and it is the copy that gets served: /contactus answers 500 with "'website.visitor' object has no attribute '_get_visitor_from_request'". The public Contact Us page is down and nothing in the log says why until you request it. Measured on a 19.0 production copy: one COW view of eleven carried the call. The OCA seed has no COW views at all, which is why only real data could show it.
… existence The guard added in aca417b asked "does the action this menu names still exist?" and returned early when it did. That condition is never true at the moment it is tested and never false afterwards, so the fix was inert and the defect it exists to prevent came straight back on the next prod-copy run. Odoo deletes obsolete ir_model_data records in one sweep AFTER every migration stage — post-migration and end-migration alike — so the target is always still present while any script can see it, and always gone by the time anything renders a menu. Measured on the 2026-10-01 prod-copy run: 15:18:13 crm post-migration runs, sees action 501, returns 15:18:58 every end-migration script runs (still too early) 15:19:41 Deleting 501@ir.actions.server (crm.action_your_pipeline) 15:20:03 Modules loaded Result: ir_ui_menu 355 (crm.crm_menu_root) left naming ir.actions.server,501 after the upgrade. website.layout resolves menu actions for a logged-in session, so every frontend page 500s and nobody can sign in, while an anonymous GET still answers 200. So the test is not "is it dangling yet" but "is it the record 20.0 removes". This resolves crm.action_your_pipeline by xmlid — still resolvable here, for the same reason the row is still present — and clears the menu only when that is what it names, leaving an installation that points its CRM root menu somewhere else alone. The old existence check is kept as a second case so a re-run, or a database that lost the action earlier, is still repaired. Verified by re-running the full prod-copy migration: the script now fires ("1 rows affected ... UPDATE ir_ui_menu SET action = NULL") and the database ends with 427 menus whose actions all resolve. Assisted-by: Claude Opus 5
19.0 shipped account.journal_group_comp_rule, a global rule on account.journal.group filtering by company_id. 20.0 removes company_id from that model outright -- it has no company field at all now -- and deletes the rule with it, keeping only three plain group rows in ir.access.csv. The rule survives the upgrade, and then every non-superuser search on account.journal.group dies in domain optimisation with KeyError: 'company_id'. Core searches that model while building an ordinary view: account.move.line._get_view does `env['account.journal.group'].search([])` to add the Ledger filters to the SEARCH view. So loading any account.move.line search view raises for a normal user. On a production copy this presented as mis_builder's report preview 500ing, purely because mis_builder was what opened the view first -- the fault is nowhere near mis_builder. The part worth remembering: NOTHING cleans this up on its own. The ir.model.access + ir.rule -> ir.access conversion writes its ir_model_data rows with noupdate=true, and _process_end's obsolete-record sweep selects `COALESCE(noupdate, false) != true` (odoo/addons/base/models/ir_model.py:2732). It skips noupdate records by design. So an obsolete SECURITY record can never be removed by the normal mechanism and has to be deleted by a script. Keyed on the field, not the id: a database that somehow still has company_id on that model has a meaningful rule, and is left alone. A sweep of every ir.access domain in the migrated production copy, parsing each domain structurally so values are never mistaken for field names, found this as the only stale field reference. Assisted-by: Claude Opus 5
19.0 declared most ir.rule records inside <data noupdate="1">; 20.0 ships the same xml_ids as rows of security/ir.access.csv, which loads updatable. _convert_rules re-points the existing ir_model_data row instead of letting the CSV create one, so the noupdate flag is inherited and the loader then declines to write 20.0's definition over it. Measured on a production copy: 271 ir.access rows flagged noupdate that the installed 20.0 source ships updatable, 22 of them still carrying a 19.0 domain. Most are silent -- hr.hr_employee_comp_rule, calendar_event_rule_private and sale.sale_order_line_rule_portal decide who may read what, and they were deciding it by 19.0's rules. One is not silent at all: project.project_task_rule_portal filtered on project.collaborator.limited_access, which 20.0 replaced with access_mode, so reading project.task as a portal user raised "Invalid field project.collaborator.limited_access in condition" and project sharing was simply broken. Nothing in the upgrade reported any of it. The flag is cleared only where the owning module ships the xml_id in an ir.access.csv, i.e. where 20.0 has itself declared the record updatable; rules 20.0 still declares noupdate, and rules a user created, are left alone. Assisted-by: Claude Opus 5
20.0 stops shipping payment_custom's own redirect template and points the two providers it declares at payment.generic_redirect_form; noupdate_changes.xml carries that over, but only for records that have an xml_id. payment.provider rows are routinely duplicated by hand, and a production copy here had a third `custom` provider with no xml_id still referencing the old view. redirect_form_view_id has a database foreign key, so the obsolete-record sweep cannot drop the view while that row points at it. The upgrade does not fail: openupgrade_framework patches the unlink to log "Could not delete obsolete record ... of model ir.ui.view" and carry on, so the run ends green with a view 20.0 no longer maintains still rendering the provider's payment form. The next ordinary -u payment_custom retries the same delete without that patch loaded and stops dead on ForeignKeyViolation. Assisted-by: Claude Opus 5
…he data exists Burns the 20.0 verdict debt from 202 to 158, and records what was FOUND rather than that somebody looked. Nine of the 44 are SCRIPT NEEDED -- real carries the upgrade currently drops. The ones measured against erp_mig20test (the sanitised prod copy): * hr.version.tz is the sharpest. 19.0 had it related='employee_id.tz', so the value lived in resource_resource.tz; 20.0 inverts the relation, stores it required with a 'UTC' default, and makes hr.employee.tz related to the version. The upgrade took the default: both versions hold 'UTC' while their resources hold 'America/New_York', and _get_tz() reads self.tz first, so the wrong zone reaches every working-hours and attendance calculation. * point_of_sale carries three in one config row. 20.0 cuts POS invoices from journal_id, which still holds the 19.0 closing journal (POSS, 0 moves) while every one of the 88 POS invoices sits in invoice_journal_id's journal (INV). closing_journal_id points at a journal the migration itself created on 2026-10-01. And session_closing_mode defaulted to 'daily', which arms a 20.0 cron to post session accounting at 04:00 on sessions nobody closed. website_sale is NOT re-measured and says so: the module is uninstalled in the copy and the seed the bootstrap measured is gone, so those verdicts rest on 19.0/20.0 source plus the bootstrap's recorded counts. Also records which of the verdict-debt modules the copy can even speak for: 18 of them are uninstalled there, so a verdict for those cannot cite rows. Assisted-by: Claude Opus 5
Debt 158 -> 120. Six more SCRIPT NEEDED, two of them measured on real rows rather than argued from source. * base:res.partner.bank.acc_holder_name is the worst of the set, because it OVERWRITES rather than drops. 20.0's _compute_account_holder_name filled holder_name with partner_id.name for every row during the upgrade, so a holder deliberately different from the partner is replaced: on this copy 4 of the 13 accounts that held a legacy value now read differently -- "Ledo Enterprises LLC" became "Ledo Enterprises", and the joint holder "Don Kendall and Aimee Kendall" became "Kendall Family Budget" on two accounts. * account:account.move.line.deductible_percentage is a scale change nothing converts. 19.0's deductible_amount was 0-100 (its consumer divides by 100); 20.0's deductible_percentage is a 0-1 fraction with a `> 1` constraint. The DEL/NEW pair means the new column took its default. Left alone a half-private expense claims full VAT deductibility. This copy cannot show it -- both columns are uniformly at their defaults, 100 and 1.0, which agree by coincidence and are exactly why it would pass unnoticed. * base also loses the whole bank record: 20.0 deletes res.bank and inlines name/bic/address onto res.partner.bank, and nothing carried them -- every such column is empty on all 19 accounts while the orphaned res_bank table still holds values 17 accounts could use. bank_id survives, which is the only reason it is recoverable. Plus account.report.active (archived reports silently un-archive, because 20.0 reads active_fallback and the upgrade defaulted it True) and the GLN carry into additional_identifiers. Records where the copy cannot speak: the chart-of-accounts and report side is far smaller than the counts this file was bootstrapped against (356 accounts, 4 reports, 0 report lines vs 50192 / 187 / 6225), and several base counts differ too, so some mechanical markers above are stale. Assisted-by: Claude Opus 5
Two of the four defects the verdict pass measured on the sanitised prod copy. Both show the same shape: 20.0 turns a derived value into a stored one, the upgrade fills the new column with a default, and the result is plausible enough that nothing looks wrong. base: acc_holder_name -> holder_name joins the two renames already in _renamed_fields on this model. 20.0's _compute_account_holder_name is `if not account.holder_name: account.holder_name = account.partner_id.name`, so it never overwrites -- it fills an EMPTY column, which is what an unrenamed holder_name is. 13 of the copy's 19 accounts carried a holder and 4 disagreed with what the compute wrote: "Ledo Enterprises LLC" showed as "Ledo Enterprises", and the joint holder "Don Kendall and Aimee Kendall" showed as "Kendall Family Budget" on two accounts. The rename is the whole fix -- the value arrives and the compute's own guard then skips those rows. hr: the tz relation inverted. 19.0 had hr.version.tz related to employee_id.tz, which is resource.mixin's related to resource_id.tz, so the zone lived in resource_resource. 20.0 stores it on the version, required, defaulting to UTC, and makes the employee read FROM the version. The upgrade took the default: both versions came out UTC while their resources held America/New_York. _get_tz() reads self.tz first, so the wrong zone reaches every working-hours and attendance calculation. post-migration now carries it from the resource, touches only rows still on the bare UTC default, and reports versions whose resource names no zone. Both fixtures plant a value that differs from what the default would produce -- a holder unlike its partner, a zone that is not UTC -- so neither assertion can pass on the computed value. Assisted-by: Claude Opus 5
Completes Tier 1 -- the four defects measured on the sanitised prod copy. account: deductible_amount was a 0-100 percentage whose consumer divided by 100 (19.0 account_move.py:1731); 20.0's deductible_percentage is a 0-1 fraction constrained `< 0 or > 1`. The analysis reports a DEL/NEW pair, not a rename, so the new column took its default and the 19.0 figure stayed behind. A line marked 50% deductible reads as fully deductible and 20.0 claims the whole VAT on a mixed-use expense. Only purchase documents are rescaled, since 20.0 forbids deductibility elsewhere, and a stray value on a sales document is reported rather than written into a move that could then not be saved. point_of_sale: the two journals swapped jobs and the precompute invented one. 19.0's journal_id was the session CLOSING journal and invoice_journal_id was where POS invoices were cut; 20.0 reverses both. Nothing moved the values, so after the upgrade invoices come from the old closing journal -- POSS, 0 moves, while all 88 POS invoices sit in INV -- and closing_journal_id points at a POSC journal _ensure_company_closing_journal created during the migration run itself. Written as one statement because each half reads what the other overwrites, and guarded: 20.0 narrowed the closing journal to type 'sale' where 19.0 also allowed 'general', so a config with no legal home is reported instead. Three more from the same module: session_closing_mode back to 'closing' (the 'daily' default arms a cron to post session accounting unattended at 04:00), pos.payment.currency_rate carried from the order (was an unstored related, now a stored column nothing fills), and pos.session.move_id re-pointed into account_move.pos_session_sales_id, which drives _compute_always_tax_exigible. Every fixture plants a value the default would not produce -- 50 against a default of 100/1.0, two journals deliberately different from each other -- so no assertion can pass on the default. Assisted-by: Claude Opus 5
…ptom
Both were found by running the migration test suite against the 585-module
19.0 seed rather than reasoning about it, and both had passed review.
hr: the timezone carry was guarded on `v.tz = 'UTC'`, which is only the value
the sanitised prod copy happened to produce -- 20.0's default is
`context tz or user tz or 'UTC'`, so the column is filled with the timezone of
whoever ran the upgrade. On the seed that is Europe/Brussels on all 31
versions, the guard matched nothing, and the carry silently did nothing on any
database whose admin has a timezone set. There is no 19.0 value to protect
either: hr.version.tz was a non-stored related field with no column at all, so
every timezone now in that table was invented by the default seconds earlier.
The write is therefore unconditional wherever the resource names a zone.
Its companion test was equally vacuous -- it searched for versions left on
'UTC' -- so it went green while nothing was carried. Both tests now assert a
disagreement with the resource, which is the actual defect.
crm: the script claimed a dangling menu action makes "EVERY frontend page 500,
/web/login included". Probing it (planting a nonexistent action id and calling
each candidate) gives a different and narrower answer: reading menu.action
through the ORM raises nothing, because Reference browses the id without
checking it. load_web_menus is what raises MissingError, and its two callers
are the backend home controller -- so /odoo is dead for every internal user --
and website.layout's frontend-to-backend app switcher, which sits inside
`groups="base.group_user"`, so anonymous visitors are unaffected and
/web/login is not website.layout at all.
The symptom test rendered website.layout with an empty values dict, which can
never pass: the template's first statement is `lang.replace('_', '-')` and it
also wants main_object, so it failed for a reason unrelated to the migration.
It now calls load_web_menus, the verified raise.
Assisted-by: Claude Opus 5
… the calendar timezone
Tier 2 -- values the 19->20 upgrade drops in silence. All three verified
against the 585-module seed, each with a fixture that plants a value no
default can produce.
base: 20.0 deletes res.bank and inlines bank_name, bank_bic and the address
onto res.partner.bank. Nothing carried them -- every column is empty on all 19
accounts while the orphaned res_bank table still holds the values and the
bank_id column still joins to them, so this is recoverable only until someone
runs database_cleanup. country_id is the exception that makes it more than a
copy: it is a stored precompute whose body reads `partner_id.country_id or
company_id.country_id or env.company.country_id`, yet it sits in the inlined
bank-address block, so a bank abroad acquires the account holder's country.
The bank's own country wins where it named one. email, phone and active have
no destination at all in 20.0 and are reported rather than forced somewhere.
base: company_registry becomes the additional_identifiers Json. Filing
everything under the generic 'OTHER' key would be lossless and invisible --
_compute_available_additional_identifiers_metadata pops that key for any
country offering another EN-category identifier, and 38 of the 39 are
country-scoped, so the value would appear nowhere on the form. The country's
own key is therefore preferred, but only when the value fits it:
@api.constrains('additional_identifiers') revalidates every key with
validation='error', so free text filed under FR_SIREN would leave the partner
permanently unsavable. Each candidate is probed with the non-raising
_validate_identifier first, and a value fitting nothing falls back to 'OTHER'
and is reported. Hidden beats unsavable, and both beat dropped.
resource: resource.calendar.tz is dropped and the timezone becomes
res.company.tz, one per company, defaulting from the company's country -- so a
database whose calendars disagreed silently changes what a working day means,
with no error, because the column is populated either way. The default
calendar's zone wins; the tally was ordered by count first, which would let
three calendars in one zone outvote the calendar the company's own records are
actually computed against. The test plants exactly that arrangement, so it
fails if the ordering is ever restored.
Assisted-by: Claude Opus 5
…timezone The rule landed as "the default calendar's zone wins", on the reasoning that it is the calendar the company's own records are computed against. Walking resource_booking on a migrated production copy showed the other half of it. That company's default calendar said UTC and the two calendars actually driving bookings said US/Eastern, so the default won and every booking type's hours were read five hours out. UTC there is not a decision: resource.calendar.tz was required with default `self.env.user.tz or 'UTC'`, so a UTC default calendar is one created by a user who had no timezone set. Preferring it propagates the unset value over calendars that do name a zone -- and those two were resource-less, which is the case where the company zone is the only thing that says what the hours mean. The default calendar's own resources are unaffected either way, since they carry resource.resource.tz and core prefers it whenever a resource is in play. So the default still wins, unless it is UTC and another zone is named. The existing test keeps the first half honest (a majority in one zone must not outvote a deliberate default); a second fixture plants the measured arrangement and keeps this half honest. Either rule alone passes one of them and fails the other. Assisted-by: Claude Opus 5
Two more carries, verified against the 585-module seed. hr.job.user_id becomes recruiter_id: the same field retyped, res.users -> hr.employee, with both versions labelling it "Recruiter" and carrying the same help text word for word. The analysis reports a DEL and a NEW, so nothing was carried. The new column is not empty either -- recruiter_id defaults to `self.env.user.employee_id`, so adding it stamped every job with the employee of whoever ran the upgrade, which is the same trap the hr.version.tz guard fell into. The mapping is therefore written wherever it resolves, matched inside the job's own company to satisfy check_company, and a recruiter with no employee record there is reported because 20.0 cannot express it at all. hr.version's departure_reason_id, departure_description and departure_date move onto the new hr.employee.departure, with hr.version reading all three through departure_id. The columns survive but go invisible to the ORM, so a departure recorded in 19.0 reads as blank. The rows are inserted directly rather than through create(), which does two things a migration must not. It rewrites the employee's contract -- `contract_date_end = departure.departure_date` on the matching version (hr_employee_departure.py:124-131) -- overwriting what 19.0 recorded. And it leaves apply_date NULL, which arms the daily cron: _cron_apply_departure selects `apply_date = False` with a past departure_date and calls action_register(), archiving the employee AND their res.users (:134-142). Every historical departure carried would archive a user account within a day of the upgrade. The rows are written as already applied, which is true -- 19.0's own wizard applied them years ago -- and a test asserts no past departure is left queued. Only versions carrying a departure_date are carried: 20.0 requires both dismissal_date and departure_reason_id, and a date cannot be invented for a record whose purpose is to say when somebody left. A version with a reason or a note but no date is reported instead. Assisted-by: Claude Opus 5
account_add_gln is absorbed into account, which redeclares
global_location_number as a NON-STORED compute returning
_get_additional_identifier('EAN_GLN') (partner.py:677-679). The 19.0 column
survives, so nothing fails -- the value simply stops being read. The Json key is
written directly rather than through _set_additional_identifier, which validates
with ean.validate and raises on a bad check digit; one legacy typo would abort
the whole upgrade. Each value is probed with the non-raising
_validate_identifier first, and a failure is reported and left in the legacy
column, because @api.constrains('additional_identifiers') revalidates every key
on write -- a bad GLN filed there would block every later save of the partner
rather than merely being unmigrated.
account.report.active stops being stored: 20.0 computes it from active_fallback
and active_selection, and names the carrier in its own loader error ("'active'
field of account.report shouldn't be directly written to in data files. Use
active_fallback."). active_fallback defaults to True, so a report an
administrator archived comes back into every report menu. Only the False side is
carried; True is what the default produces anyway.
Both fixtures identify their records by external id rather than by name. Every
account.report in the seed is core module data, so account's data load rewrites
`name` during the upgrade and a fixture rename does not survive -- which is how
the first version of the test reported "the fixture's report is gone" against a
migration that had in fact worked. `active` is not rewritten, precisely because
20.0's loader refuses to write it from a data file.
Assisted-by: Claude Opus 5
19.0 baked one table class into each report layout template; 20.0 lifts it out into res.company.report_tables_id, whose 'light' default silently restyles the documents of every company that printed with anything else. The mapping is read off 19.0's own classes (web/views/report_templates.xml) and inverted through 20.0's only consumer of the field, external_layout_body (:447-453): striped/boxed/bold/standard keep their own class, folder and wave both used o_table_striped, and bubble used o_table_boxed-rounded. Every 19.0 layout therefore maps to a 20.0 key. It has to be resolved in PRE-migration, and not because the new column is missing. 20.0 ships neither external_layout_striped, external_layout_boxed nor external_layout_bold, so web's data load deletes those view records and every company that printed with one is left with external_report_layout_id NULL -- by post-migration there is nothing to map. The resolved key is kept in a legacy column rather than the view id, which would no longer resolve to an xmlid either. The fixture plants 'bold' for exactly that reason. Post-migration's guard is a comparison, not a NULL check: report_tables_id declares a default, so adding the column stamped every row -- the same shape as hr.version.tz and hr.job.recruiter_id, where guarding on emptiness means doing nothing at all. Assisted-by: Claude Opus 5
… reference price Three settings 20.0 re-defaults, none of which fails anything. contact_us_button_url becomes contact_us_link_url. Both versions declare default="/contactus", so the new column is created already holding it and a site pointing at /help or an external form reads "/contactus" while its own value sits in a column 20.0 never looks at. A pre-migration rename carries the value and the jsonb translations both versions store. The three category display flags move from product.public.category to website. The ORM wrote show_category_description's default True and left the other two NULL, a falsy default not being written at all, so a shop that unticked "Show Description" gets descriptions back and one that ticked "Show Title" loses titles. A flag is carried onto a website only where every category that website shows agrees on it -- its own plus the global ones, which is 19.0's own _get_available_category_domain -- and the disagreements are REPORTED rather than resolved, because one website-wide flag cannot answer for categories that differ and claiming a value nobody chose is worse than saying so. A NULL legacy column is read as that field's 19.0 default, which is what the field returned for a row nobody ever wrote. show_product_reference_price: 19.0 showed the $/kg price whenever the feature group was enabled, with no per-website dimension at all; 20.0 keeps the group AND requires a per-website Boolean that has no default, so the price disappears from every shop that was showing it. The group is read through core's own _is_feature_enabled rather than by inspecting implied_ids, and every website gets the flag -- that is the per-website spelling of "on everywhere". Verified against the 585-module seed: three websites carried, one disagreement reported, three switched back to showing the reference price. Assisted-by: Claude Opus 5
The UTC-default case created a res.company, and Odoo copies every per-company record when a company is created -- including the payment.provider rows the payment fixture had just renamed. Fixtures are planted in module order, so payment runs first and resource's new company duplicated its two providers, failing payment's own test with "2 != 1: ou19-provider-live" in a module this branch had not touched. A second EXISTING company with a default working schedule exercises the same case: its default calendar is set to UTC and two calendars naming America/New_York are added beside it. Nothing about the rule under test needed a company that did not already exist. Assisted-by: Claude Opus 5
Every remaining "# SCRIPT NEEDED" marker across the 20.0 analysis work files now has a script behind it, so each is rewritten as "# DONE" naming the function and the measured outcome on the 585-module seed. 23 markers over account, base, hr, point_of_sale and website_sale; none is left. Three website_sale annotations were stale rather than missing. website's align_category_content and show_category_title both read "NOTHING TO DO: empty on all 3 rows", which was true of the analysis and is no longer true of the database -- the per-website aggregation writes all three flags. The category-side align_category_content had no annotation at all, its alphabetical neighbour having absorbed one. Assisted-by: Claude Opus 5
… not reproduce A 19->20 run over the sanitised prod copy confirms the substance and refutes the number. POSS does hold 0 moves, and INV -- the journal invoice_journal_id named -- holds 508, so an unfixed upgrade really does cut POS invoices into a journal with no history. But the copy carries no pos.order rows at all, so "all 88 POS invoices to date sit in INV" is not a figure this data can produce, and the argument is restated to rest on which journal is which. The invented closing journal is confirmed; only its id was run-specific, so the docstring no longer names one. Assisted-by: Claude Opus 5
…e date Clears the verdict debt for both modules: 19 undecided lines, 3 scripts, 16 NOTHING TO DO each backed by a count rather than an argument. Verified on the 585-module seed and measured against a real 19.0 production copy and its migrated result. Two settings became company fields and nothing carried them. 19.0 kept the Shipping Policy and the Invoicing Policy as ir.default rows -- for sale.order.picking_policy and product.template.invoice_policy -- and 20.0 adds res.company.picking_policy (required, default 'direct') and res.company.sale_invoice_policy (required, default "order"). So a company that chose "When all products are ready" starts shipping partial orders, and one invoicing on delivered quantities starts defaulting new products to ordered ones. Both carries read the company-specific ir.default before the global one, which is the order 19.0 resolved them in. Neither is visible on the production copy: its ir.defaults are "direct" and "order", the same values 20.0 defaults to. The two agreeing by coincidence is exactly what would let this ship unnoticed, so the fixture plants the opposite pair and the tests assert for them. stock.move.line.date becomes `related="move_id.date", store=True`. The column already exists, so the upgrade recomputes nothing and 65 of the seed's 122 lines keep a date their move does not -- until the next write to the move overwrites them silently. They are aligned here instead, with the count reported, because that date was real information in 19.0 and the report is the only record left of it. The 16 others are closed on measurement: stock.move.reference and sale.order.document_tax_mode fill themselves (the latter from company.account_price_include, with both values present on the seed and 0 of 58 orders disagreeing with their own company); product.template.tracking was already handled by _clear_tracking_none; location_final_id and utm.campaign.company_id have no destination in 20.0 and stay in their orphaned columns; customer_lead's float-to-integer narrowing would round, but no row on either database holds a fractional value. Assisted-by: Claude Opus 5
Clears the verdict debt for website, product and mail: 18 undecided lines, one
script, 17 closed on measurement.
website.menu.url stops being stored and becomes a compute:
menu.url = (menu.page_id.url if menu.page_id else menu.manual_url) or "#"
A page-backed menu is fine -- the compute reads the page. A menu whose address
was typed in has nothing to read, because 20.0 keeps that in the new manual_url
and nothing fills it, so the expression falls through to "#". The link stays in
the navigation bar and goes nowhere. On the 585-module seed that is 48 of 53
menus with no page, manual_url empty on all 53, and the orphaned column still
holding /blog, /event and the rest: every website's navigation turned into dead
links. post-migration carries the value across, 39 menus on the seed.
'#' is deliberately not carried -- the compute returns it anyway for a mega menu
or one with children, so writing it would add rows that change nothing a visitor
sees, and the test asserts that too.
One menu stays out of reach and it is core's doing, not a gap here.
website.menu_home is the template menu, no website_id and no page, and 20.0's
data load puts it through _inverse_url, which writes manual_url = '' when the
url it is handed is falsy; its write_date is the upgrade run itself. The live
per-website Home menus are page-backed and resolve through website.page. The
global assertion therefore scopes itself to menus that are not module data --
46 of the seed's 55 -- which is a limit worth naming rather than hiding.
The 17 others close on counts, not arguments. ir.ui.view.visibility was NULL on
all 3552 views of the prod copy and 20.0's required 'public' default fills only
NULLs, so nothing is widened. website.page.name and .url became translatable and
the ORM widened the columns with the values intact. product.pricelist.item
.applied_on is recomputed from references that survive -- verified against the
matched pair, 1_product on template 31 before and after, rather than argued.
product.product.qty_available is still store=False and never had a column.
website_sale's base_unit_id comodel rename is undeclared in apriori and would
need rename_models on a database that used base units; both tables hold 0 rows
here, which is stated rather than glossed. mail's four new selections fill
themselves; its two dropped res.users.settings fields are interface state with
nowhere to go in 20.0.
Assisted-by: Claude Opus 5
…t prod modules
Takes every module installed in production to zero verdict debt: 22 lines across
ten modules, one script, 21 closed on counts.
project.collaborator.limited_access becomes access_mode -- view / edit /
advanced_edit -- required and defaulting to 'view', so the upgrade demotes every
collaborator to a reader. That is granted rights lost rather than cosmetics: a
collaborator row existed only because a project was shared for EDITING, and a
read-only share created none at all. After the upgrade those portal users can
open the project and change nothing.
The mapping is taken from the same method in both versions rather than from the
labels, which is what makes it defensible:
19.0 def _add_collaborators(self, partners, limited_access=False)
20.0 def _add_collaborators(self, partners, access_mode='advanced_edit')
The default argument of one is the default argument of the other, so False is
advanced_edit; the remaining True is 'edit', which is what 20.0 tests for
editing rights (project_task.py:2281) while reserving advanced_edit for the
capability it gates at :1130. Nothing maps to 'view'.
The test asserts row by row against each collaborator's own legacy boolean
rather than against a count. The seed carries a collaborator besides the
fixture's two -- counting them would have tested the seed, and the first version
of this test did exactly that and failed on the third row.
The 21 others close on measurement. resource.calendar.attendance.day_period was
already handled by _reclassify_break_attendances and .name has no destination at
all (12 distinct labels across the copy's 60 rows, kept in the orphaned column);
resource.resource.calendar_id's new NOT NULL applies over data that already
satisfies it. calendar.calendar and calendar.user are new 20.0 models whose rows
20.0 writes itself. purchase.order.document_tax_mode fills from
company.account_price_include exactly as sale.order's does, with 0 of 18 orders
disagreeing with their own company, and non_deductible_tax is a stored compute
the upgrade evaluated. account.move.checked is the column
_carry_a_deliberate_not_reviewed_into_the_review_queue reads, so the retained
value is the point rather than a loss. crm_iap_mine's reveal_ids held an IAP
vendor's own industry codes that only that service can interpret, 3 of 83 rows,
and 20.0 offers nowhere to put them.
Assisted-by: Claude Opus 5
…column 20.0 renamed the link an invoice uses to find its timesheets: 19.0 account.move.timesheet_ids = One2many(..., "timesheet_invoice_id") 20.0 account.move.timesheet_ids = One2many(..., "reinvoice_move_id") The analysis cannot see that as a rename, because the halves belong to different modules: the DEL is reported under sale_timesheet, the NEW reinvoice_move_id under sale. Nothing correlates them, so the old column was preserved untouched and the new one created empty. Both sides had been annotated NOTHING TO DO, and both annotations said the same thing -- the column is empty across all 531 rows. That was true of the SEED, where no timesheet had ever been invoiced. It is not a property of the field. The first real database carried 33, and every one of them came out of the migration with its invoice link gone. The consequence is worse than a blank column. _is_line_reinvoicable consults this link to decide a timesheet has already been invoiced (sale_timesheet/models/sale_order_line.py:168), so a line that lost it becomes billable a second time -- on a company that bills from timesheets. Verified end to end on a sanitised copy of production: 33 rows carrying the old link before, 33 carrying the new one after, 0 left behind and 0 pointing at a different move. The lesson is recorded in both verdict files rather than just fixed: a verdict of "the column held nothing" is only ever as good as the data it was measured on.
… upgrade 20.0 renames product.template.expense_policy to reinvoice_policy -- the upgrade log reads "table product_template, column expense_policy: renaming to reinvoice_policy" -- so every row keeps the value it had. What changes is the MEANING. On 19.0 the field only governed re-invoicing costs. On 20.0 it additionally gates whether an invoice links its analytic lines at all: _is_line_reinvoicable requires it to be something other than 'no' (sale/models/sale_order_line.py). A delivered-timesheet product sensibly carried 'no' on 19.0 -- it re-invoices no expenses -- and that same 'no' now stops it linking timesheets. The invoice posts claiming none, anything reading account.move.timesheet_ids sees an empty set, and the hours keep counting as still-to-invoice because _timesheet_compute_delivered_quantity_domain treats a NULL reinvoice_move_id as not yet billed. Nothing appears in the log. So products that were ALREADY billing from timesheets get 'sales_price' -- invoice_policy 'delivery' with service_type 'timesheet', both stored on both versions -- and only where the column still holds 'no', so a deliberate choice is never overwritten. That is the value core's own sale_timesheet tests set for exactly this case. It is a real change to a field that also feeds sale_expense, sale_stock and sale_purchase re-invoicing, which is why the predicate is this narrow: on the production copy it touches exactly two templates, Consulting Hours and Service on Timesheets, and leaves the other four service products alone. Verified end to end on a migrated copy of production: Consulting Hours comes out 'sales_price', a new sale order with a logged timesheet produces a draft invoice carrying timesheet_ids=1, and posting it generates the timesheet backup PDF (122 KB) showing the hours, the activity and the time range. Without the carry that invoice links nothing and no document is produced. Both verdicts in sale/20.0.1.2 are corrected rather than just the behaviour: the DEL was explained as "nothing was left behind" when the data had in fact been renamed across, and the NEW as "20.0 filled it itself ... a compute did the work" when it is full because the rename carried it.
20.0 dropped `url` from core's `website.menu_home` record, because the field is computed from `page_id` now. The generator emitted it **empty**, and loading an empty url runs `_inverse_url`, whose first branch is `if not menu.url: menu.page_id = None`. Home then computed to "#": a navigation entry pointing nowhere, on a record no 19.0 database has any problem with. Measured on a production copy -- `page_id` 1 on 19.0, NULL after. Core is not the cause and cannot be: its own `menu_home` sits inside a `<data noupdate="1">` block and is never rewritten on upgrade. The previous docstring here blamed core's data load; it was this script. `test_no_menu_was_left_pointing_nowhere` excluded module-data menus, and that exclusion was covering exactly this row -- with the field restored, both it and the new `test_the_home_menu_keeps_its_page` fail. An exclusion that happens to hide the one broken record is indistinguishable from no assertion, so it is gone. Assisted-by: Claude Opus 5
19.0's template fetcher reads Domain('favorite', '=', True); 20.0's
action_fetch_templates reads Domain('is_template', '=', True). Same method,
same job, renamed field -- but the analysis reports the halves as unrelated
DEL and NEW lines, and this was annotated NOTHING TO DO on a two-row sample
where favorite happened to be false. A production copy with 35 mailings, one
favourited, came out with favorite still true and is_template false.
Signed-off-by: Don Kendall <dkendall@ledoweb.com>
Assisted-by: Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Working branch for the fork-local 19→20 campaign: run
-u allagainst the585-module 19.0 seed, fix whatever stops it, repeat. Not for upstream. Stacks the
framework port, the analysis/apriori and the per-module scripts so the whole
thing can be run as one tree.
The seed migrates end to end: exit 0,
Registry loaded in 918s, 563 moduleson 20.0, 0 migration-script errors, 2171
ir_accessrows, no danglingir.model.accessorir.ruledata rows.Exit 0 was hiding six real defects, and there was a one-line detector for them
When migrated data does not satisfy a constraint the new version declares, Odoo
logs one INFO line and carries on. checklog-odoo only reacts to WARNING, so
nothing read them. The run that first exited 0 left twelve constraints
unapplied:
res_partner_bank.account_numberacc_number; every bank account read as having no numberresource_calendar_attendanceduration> 0hr_leave/hr_leave_allocation.work_entry_type_idhr.leave.typefolded intohr.work.entry.typepos_payment_method.typepayment_method.provider_idAll six are fixed and the gate now passes on the full run: 118/118 bank
accounts have numbers, 1825/1825 attendances are in range, 8/8 POS methods are
typed, 12/18 leaves and allocations have a type, 48439/48439 payment methods have
a provider across all 117 companies.
The gate reads the schema, not the log
The log cannot be the verdict on its own, in both directions. The line is written
while the table is reshaped, before post- and end-migration run, so a script
that repairs the data cannot stop it being emitted. And a module that owns a
stricter rule replaces the one being attempted a moment later:
accountcannotbuild its broad
account_move_unique_namewhile LATAM vendor bills are present,and then
l10n_latam_invoice_documentinstalls the narrower index that is thereal rule. Allowlisting on the log would have recorded that as a defect for ever
and blinded the check to a later regression — so one of the six items I set out
to fix turned out not to be a defect at all.
So the log says which constraints to look at and the schema says how each ended:
nulls are counted, and a named check constraint or unique index is looked up.
All three shapes Odoo reports are answerable, so the allowlist is empty. It
fails closed four ways, each verified: a log with no upgrade in it, an
unreachable database, a column that no longer exists, and an unrecognised
message all report rather than pass.
hr_holidays is the substantial one
hr.leave.typeis gone;hr_holidaysnow_inheritshr.work.entry.type. Bothmodels existed in 19.0, so this is a merge, not a rename — it must not go in
apriori.renamed_models. 19.0 already recorded the pairing inhr_leave_type.work_entry_type_id, honoured where unambiguous (46 of 76 linkuniquely, 12 share, 18 have none); two leave types cannot both keep one record,
so the oldest keeps the link and the rest get their own.
count_as,unit_of_measureandcodehave no counterpart and are mapped or, forcode,given a traceable placeholder that is counted in a message.
Not
merge_models: nothing refers tohr.leave.typeby name by then — Odoo hasalready removed its
ir_modelrow, and all nine dependent tables hold zero suchrows — and calling it fails rather than doing nothing.
Analysis coverage
317 of 362 analysed modules carry an
upgrade_analysis_work.txt; the tablereads 129 Done / 477 Nothing to do / 0 Partial. 87 modules carry a migration
script and 16 carry a migration test.
Four gates over the migrated database, because exit 0 is not a verdict
Every defect found in this campaign exited 0 and looked green. Each gate reads
the analysis for where to look and the database for what happened, and each
was proved red on a real historical defect before being trusted:
check_unapplied_constraints.pycheck_selection_values.pycheck_rename_pairs.pyuser_id→user_ids)check_defaulted_columns.pyThe last one exists because the rename gate needs a DEL/NEW pair, and four of
this campaign's five defects had no DEL sibling at all. A column default is
evaluated once when the ORM creates the column, so one distinct value across a
populated table is the fingerprint of stamping rather than carrying. It is scoped
to dates and many2ones on purpose: 178 new columns came out uniform, 152 of them
non-falsy, and a gate needing 152 reviewed reasons is one whose allowlist becomes
boilerplate. On its first run it found a real defect nobody had looked at
(
loyalty.history.points_changed_date, the FIFO expiry key).test_gates.pycovers the gates' own predicates over constructed facts — nodatabase, so CI runs it before the upgrade. It is the only exercise that logic
ever gets, and a gate whose predicate inverts reports the wrong verdict for every
migration proven afterwards rather than failing loudly.
Defects this branch fixes, and how each was proved
Each was shown to fail against the database migrated before the fix, which is
stronger than mutating the test:
pos_restaurant— six columns became onefloor_plan_layoutJSON; 27tables holding 25 distinct positions all came out NULL.
pos_event—default=lambda: str(uuid4())is evaluated once, so 41 rowsacross two tables shared ONE uuid, which is the POS sync identity.
fleet—date→date_fromcarries 19.0's help text and a todaydefault, so every service log moved to the day of the upgrade.
sale—expense_policy→reinvoice_policy; 197 templates collapsed toone value. Renamed in pre-migration because the new field is a stored compute.
resource—schedule_type→calendar_type, read off each version's own_is_flexiblerather than the labels.maintenance— completion lived onmaintenance.stage.done, and theassignee moved many2one → many2many.
loyalty— every history line dated the moment the upgrade ran; restoredfrom
create_date, 7 distinct timestamps in place of 1.account— 19.0's Reviewed flag; only the deviation is carried, becausemapping the rest would assert a review of 3206 posted invoices.
stock_account— documented, NOT fixed:adjustmentreads 0 for the wholevaluation history and cannot be recovered, since 19.0 recorded only the delta.
~206 "empty is the right value" annotations were audited against the data,
and 23 corrected: the boilerplate claimed the default was falsy where it was
14,'login','9','full',20.0hours andTrue. There is no NULL onthose rows; the ORM wrote the default.
Depends on ledoent/openupgradelib#4, which this branch pins: three separate 20.0
breakages, the last of which stops
merge_modelsrunning at all.AI-assisted (Claude Code); every change reviewed, tested, and owned by the author.