<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Upgrade on Lessons learned</title><link>https://eghuro.cz/tags/upgrade/</link><description>Recent content in Upgrade on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Sat, 24 Oct 2026 14:58:00 +0200</lastBuildDate><atom:link href="https://eghuro.cz/tags/upgrade/index.xml" rel="self" type="application/rss+xml"/><item><title>Moodle 5.3 Classic theme serves an empty stylesheet: 'Undefined variable $mds-spacing-md'</title><link>https://eghuro.cz/posts/moodle-53-classic-theme-empty-stylesheet/</link><pubDate>Sat, 24 Oct 2026 14:58:00 +0200</pubDate><guid>https://eghuro.cz/posts/moodle-53-classic-theme-empty-stylesheet/</guid><description>&lt;p&gt;After an upgrade to Moodle 5.3, every page on the Classic theme lost its layout. The theme&amp;rsquo;s stylesheet came back like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ curl -s -o /dev/null -w &amp;#39;%{http_code} %{size_download}\n&amp;#39; \&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#39;https://moodle.example.com/theme/styles_debug.php?theme=classic&amp;amp;type=scss&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;200 0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s a 200 with an empty body, and nothing in the logs. With debugging forced to developer level, the SCSS compiler said:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Error while compiling SCSS: Undefined variable $mds-spacing-md: /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle/course.scss on line 132, at column 17&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Classic imports Boost&amp;rsquo;s SCSS, and in 5.3 that SCSS uses the new design system&amp;rsquo;s tokens. Classic never imports them. One &lt;code&gt;@import&lt;/code&gt; fixes it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Moodle 5.3.0, PHP 8.4.27, in a Docker image of our own, upgraded from 5.0&lt;/li&gt;&#10;&lt;li&gt;The site&amp;rsquo;s theme is Classic. It left core in 5.3, so the image installs it from &lt;a&#10; href="https://github.com/moodlehq/moodle-theme_classic"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;moodlehq/moodle-theme_classic&lt;/a&gt;&#10; (at commit &lt;code&gt;363a179&lt;/code&gt;, the only one when I checked)&lt;/li&gt;&#10;&lt;li&gt;Theme designer mode was on, so pages loaded the theme through &lt;code&gt;styles_debug.php&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I rehearsed the upgrade on a copy of the production database. Every URL I tried returned 200, and &lt;code&gt;admin/cli/checks.php&lt;/code&gt; reported all checks OK. On production, the pages had no layout.&lt;/p&gt;&#10;&#10;&lt;h2 id="two-things-that-looked-like-the-cause"&gt;Two things that looked like the cause &lt;/h2&gt;&#10;&lt;p&gt;The browser console had two errors:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[Error] Failed to load resource: the server responded with a status of 404 (Not Found) (client.bundle.js.map, line 0)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[Error] Failed to load resource: the server responded with a status of 404 (Not Found) (react.bundle.mjs.map, line 0)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Those are source maps. The browser asks for them only for its developer tools, and Moodle doesn&amp;rsquo;t ship them. They don&amp;rsquo;t affect the layout.&lt;/p&gt;&#10;&lt;p&gt;The import map had a few 404s as well, for entries like &lt;code&gt;/core/esm/&amp;lt;rev&amp;gt;/bootstrap/&lt;/code&gt;. Those entries end in a slash because they map a whole prefix, so the bare URL has nothing to serve. The modules under them loaded.&lt;/p&gt;&#10;&lt;p&gt;The problem showed up when I fetched every stylesheet the front page links to and printed its size:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s https://moodle.example.com/ -o index.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -oE &lt;span class="s1"&gt;&amp;#39;&amp;lt;link[^&amp;gt;]+stylesheet[^&amp;gt;]*&amp;gt;&amp;#39;&lt;/span&gt; index.html &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; grep -oE &lt;span class="s1"&gt;&amp;#39;href=&amp;#34;[^&amp;#34;]+&amp;#34;&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sed &lt;span class="s1"&gt;&amp;#39;s/href=&amp;#34;//; s/&amp;#34;$//; s/&amp;amp;amp;/\&amp;amp;/g&amp;#39;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; -r u&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;%s %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl -s &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; wc -c&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The plugin stylesheets had a few kilobytes each. The theme&amp;rsquo;s own, &lt;code&gt;type=scss&lt;/code&gt;, had zero bytes.&lt;/p&gt;&#10;&#10;&lt;h2 id="getting-the-real-error"&gt;Getting the real error &lt;/h2&gt;&#10;&lt;p&gt;Moodle catches the compiler&amp;rsquo;s exception and passes the message to &lt;code&gt;debugging()&lt;/code&gt; at developer level, so at the usual debug setting you get nothing at all. You don&amp;rsquo;t have to change the site&amp;rsquo;s settings to see it. Compile the theme from the CLI with debugging switched on for that one process (run it as the web server&amp;rsquo;s user and adjust the path to &lt;code&gt;config.php&lt;/code&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo -u www-data php -r &lt;span class="s1"&gt;&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;define(&amp;#34;CLI_SCRIPT&amp;#34;, 1);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;require &amp;#34;/opt/moodle/config.php&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$CFG-&amp;gt;debug = E_ALL;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$CFG-&amp;gt;debugdisplay = 1;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$t = \core\output\theme_config::load(&amp;#34;classic&amp;#34;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$m = new ReflectionMethod($t, &amp;#34;get_css_content_from_scss&amp;#34;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;$c = $m-&amp;gt;invoke($t, false);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;var_dump(is_string($c) ? strlen($c) : $c);&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;get_css_content_from_scss&lt;/code&gt; isn&amp;rsquo;t public, hence the reflection. It returns &lt;code&gt;false&lt;/code&gt; when the compile fails, and the error comes with the import chain:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;++ Error while compiling SCSS: Undefined variable $mds-spacing-md: /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle/course.scss on line 132, at column 17&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Call Stack:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;#0 import /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle/course.scss /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle.scss on line 13&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;#1 import /opt/moodle/public/theme/classic/scss/../../boost/scss/moodle.scss /opt/moodle/public/theme/classic/scss/moodle.scss on line 2&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;#2 import /opt/moodle/public/theme/classic/scss/moodle.scss (unknown file) on line 127 ++&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="why"&gt;Why &lt;/h2&gt;&#10;&lt;p&gt;Boost&amp;rsquo;s default preset, &lt;code&gt;theme/boost/scss/preset/default.scss&lt;/code&gt;, starts with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-scss" data-lang="scss"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Import Moodle Design System tokens.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Must be imported before Bootstrap so that the design system tokens can be used to override Bootstrap variables.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;design-system&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;design-system.scss&lt;/code&gt; pulls in the &lt;code&gt;$mds-*&lt;/code&gt; tokens. Classic&amp;rsquo;s presets import &lt;code&gt;fontawesome&lt;/code&gt;, &lt;code&gt;bootstrap&lt;/code&gt; and &lt;code&gt;moodle&lt;/code&gt;, but not &lt;code&gt;design-system&lt;/code&gt;, and Classic&amp;rsquo;s &lt;code&gt;moodle.scss&lt;/code&gt; imports Boost&amp;rsquo;s. So Boost&amp;rsquo;s &lt;code&gt;course.scss&lt;/code&gt; uses &lt;code&gt;$mds-spacing-md&lt;/code&gt;, and nothing has defined it.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Add the import to the top of &lt;code&gt;theme/classic/scss/classic/pre.scss&lt;/code&gt;. Classic&amp;rsquo;s &lt;code&gt;lib.php&lt;/code&gt; puts that file in front of the preset, so this covers both presets:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;pre&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/moodle/public/theme/classic/scss/classic/pre.scss&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;@import &amp;#34;../../boost/scss/design-system&amp;#34;;\n\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; cat &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$pre&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &amp;gt; /tmp/pre.scss&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mv /tmp/pre.scss &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$pre&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo -u www-data php /opt/moodle/admin/cli/purge_caches.php --theme&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The path resolves from &lt;code&gt;theme/classic/scss/&lt;/code&gt;, as the call stack above shows. After the purge, the same CLI compile returned 1,218,070 characters instead of &lt;code&gt;false&lt;/code&gt;, and &lt;code&gt;styles_debug.php&lt;/code&gt; sent 1,557,703 bytes.&lt;/p&gt;&#10;&lt;p&gt;If Classic is part of your build, as here, apply the patch in the build. A fix made inside a running container is gone the next time the container is recreated.&lt;/p&gt;&#10;&lt;p&gt;Once the layout was back, I turned theme designer mode off (&lt;code&gt;admin/cli/cfg.php --name=themedesignermode --set=0&lt;/code&gt;). It recompiles the theme on every page view: here, about 0.8 s and 112 stylesheet requests per page.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A Moodle theme that fails to compile still answers 200, just with no CSS, so upgrade checks that look only at status codes pass right over it.&lt;/p&gt;&#10;&lt;p&gt;Before your next Moodle upgrade, add the theme&amp;rsquo;s compiled CSS size to your checklist: anything near zero is a failed compile.&lt;/p&gt;&#10;</description></item><item><title>Uninstalling one Odoo module can remove its dependents and their data</title><link>https://eghuro.cz/posts/uninstalling-one-module-can-cascade/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/uninstalling-one-module-can-cascade/</guid><description>&lt;p&gt;An Odoo module replacement passed a rehearsal&amp;rsquo;s record-count and accounting-total checks, yet the uninstall also removed a dependent addon that owned important business data. The application still loaded. The acceptance checks had missed the field that mattered.&lt;/p&gt;&#10;&lt;p&gt;Before replacing a module, inspect its reverse dependencies and data ownership. Rehearse the corrected transition on a restored copy, then compare the affected values by record identity before approving the live change.&lt;/p&gt;&#10;&#10;&lt;h2 id="uninstall-computes-a-reverse-dependency-set"&gt;Uninstall computes a reverse-dependency set &lt;/h2&gt;&#10;&lt;p&gt;In &lt;a&#10; href="https://github.com/odoo/odoo/blob/18.0/odoo/addons/base/models/ir_module.py"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Odoo 18&amp;rsquo;s module implementation&lt;/a&gt;&#10;, &lt;code&gt;downstream_dependencies()&lt;/code&gt; recursively finds modules that depend on the selected modules. &lt;code&gt;button_uninstall()&lt;/code&gt; marks both the selection and those dependents as &lt;code&gt;to remove&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The same source describes &lt;code&gt;module_uninstall()&lt;/code&gt; as removing database structures created by the module, including tables and columns. Actual deletion depends on the affected records and metadata; do not assume every business record disappears, or that surviving rows retain every field.&lt;/p&gt;&#10;&lt;p&gt;Generalize the replacement to a full addon, a smaller replacement, and an extension that depends on the full addon. Installing the smaller addon does not automatically redirect the extension&amp;rsquo;s dependency. Uninstalling the full addon still includes that extension in the removal set.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-first-rehearsal-had-the-wrong-acceptance-criteria"&gt;The first rehearsal had the wrong acceptance criteria &lt;/h2&gt;&#10;&lt;p&gt;The historical transition checked surviving records, aggregate totals, and registry health. Those checks passed while the dependent addon was also uninstalled. The user caught the missing preservation requirement and required a restore.&lt;/p&gt;&#10;&lt;p&gt;The subsequent investigation found a second problem: changing the extension&amp;rsquo;s manifest alone would be insufficient. The smaller replacement did not yet implement a field the extension required. That field&amp;rsquo;s behavior needed to be ported before changing the dependency.&lt;/p&gt;&#10;&lt;p&gt;The retained record includes the corrective restore and a later clone&amp;rsquo;s uninstall/registry-reload output. It ends with the corrected rehearsal and final verification still open. It does not prove a successful final transition or a completed before/after digest comparison.&lt;/p&gt;&#10;&#10;&lt;h2 id="rehearse-the-field-handoff-explicitly"&gt;Rehearse the field handoff explicitly &lt;/h2&gt;&#10;&lt;p&gt;A safer transition procedure is:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Quiesce writes and capture a consistent database and filestore backup. Verify checksums and prove that the pair restores.&lt;/li&gt;&#10;&lt;li&gt;Record installed module states, reverse dependencies, and the fields and records that must survive. Keep the addon source revisions with the backup.&lt;/li&gt;&#10;&lt;li&gt;Implement the required field behavior in the replacement, including compute and reversal behavior where applicable. Version-control the dependent addon too.&lt;/li&gt;&#10;&lt;li&gt;On an isolated restore, install the replacement and upgrade the dependent addon so the database&amp;rsquo;s module metadata reflects the new dependency. Inspect the removal set again before uninstalling the old addon.&lt;/li&gt;&#10;&lt;li&gt;Complete the uninstall, reload the registry, and compare the required values and behavior. Investigate field metadata and ownership during the handoff; identical field names alone do not establish safe transfer.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Disable scheduled jobs and outbound integrations on the clone. A rehearsal should not send mail, contact payment services, or write to production storage.&lt;/p&gt;&#10;&#10;&lt;h2 id="compare-identity-and-value-not-only-counts"&gt;Compare identity and value, not only counts &lt;/h2&gt;&#10;&lt;p&gt;For a small affected dataset, compare canonical exports directly. For larger exports, a digest is convenient if the query scope, ordering, and serialization remain identical.&lt;/p&gt;&#10;&lt;p&gt;This synthetic Python check demonstrates why counts and non-null counts are insufficient. It models two records whose field values were exchanged; it is not an Odoo migration test:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;hashlib&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;json&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;,&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;:&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;utf-8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-10&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-20&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-20&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-10&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;counts match; identity/value digest detects the change&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For a real comparison, export the stable record ID alongside each affected field, preserve nulls, and normalize dates and other types explicitly. A missing column must fail the check. Also test compute behavior and important workflows: a value digest cannot prove those work.&lt;/p&gt;&#10;&lt;p&gt;Before your next uninstall, list which dependent addons and field values must remain, and make their survival a rehearsal acceptance criterion.&lt;/p&gt;&#10;</description></item></channel></rss>