Compare commits

...

71 Commits

Author SHA1 Message Date
Stéphan Sainléger
e06d750b74 [PERF] finalize_db: purge `ir.ui.view` images in a single pass
The purge ran one correlated ``NOT EXISTS`` scan of ``ir_ui_view`` per
candidate attachment (``ILIKE '%web/image/<id>%'`` on ``arch_db::text``),
taking hours on large databases. It now scans ``ir_ui_view`` once with
``regexp_matches``, builds a deduplicated set of referenced ids, and
deletes orphans via hash lookups.

This also fixes substring false positives: ``/web/image/12`` was kept
because it matched ``/web/image/123``. Integer comparison (``ref_id =
a.id``) now requires an exact match.
2026-08-13 11:25:51 +02:00
Stéphan Sainléger
0c096bb5ff [FIX] pre_upgrade: guard `theme_ir_ui_view` cleanup with to_regclass
The table ``theme_ir_ui_view`` only exists when the ``theme`` (website)
module was installed in the source database. If it was never installed,
the cleanup block crashes with "relation does not exist". Add a
``to_regclass`` guard to skip silently instead.
2026-08-11 22:50:51 +02:00
Stéphan Sainléger
1d75a0f9db [CLN] finalize_db: purge `ir.ui.view images orphaned and unreferenced in any arch_db`
Delete image attachments linked to deleted ``ir.ui.view`` records only when
no other view's HTML references them via ``/web/image/<id>`` or
``/web/content/<id>``.  This avoids the aggressive approach of deleting all
``res_id = 0`` attachments, which can break website images that survived
their parent view's deletion but are still embedded in live pages.

The ``NOT EXISTS`` subquery scans all ``arch_db`` columns and is expensive
but runs once at migration time.
2026-08-11 09:52:51 +02:00
Stéphan Sainléger
134b935fa4 [ADD] finalize_db: run `_gc_file_store_unsafe` after attachment cleanup
Add ``lib/python/file_gc.py``, a minimal Odoo-shell script that calls
``ir.attachment._gc_file_store_unsafe()`` to remove filestore files
no longer referenced by any ``ir_attachment`` record.

Invoke it in ``finalize_db.sh`` after the SQL attachment purges so
that the deleted CSS/JS asset bundles, resized partner thumbnails,
and orphaned ``ir_attachment`` rows also have their backing files
cleaned from disk.
2026-08-10 17:07:08 +02:00
Stéphan Sainléger
b6fd05104a [CLN] finalize_db: purge resized partner images and orphaned `ir_attachment`
Delete the four thumbnail sizes (``image_128``, ``image_256``,
``image_512``, ``image_1024``) from ``ir_attachment`` for
``res.partner`` records.  Odoo 13+ regenerates all thumbnails
automatically from ``image_1920`` on the next partner write.

Also delete attachments whose ``res_model`` is ``NULL`` (orphaned
``factur-x.xml``, SCSS, ICS files) — these have no associated record
and consume ~48 MB of filestore space.
2026-08-10 17:06:34 +02:00
Stéphan Sainléger
8cb2fe5fd2 [CLN] finalize_db: purge `ir_logging`
Truncate the ``ir_logging`` table which accumulates Python logger
messages (63 MB of ``INFO``-level ``ir.actions.server`` traces) with
no business value after migration.
2026-08-10 17:06:14 +02:00
Stéphan Sainléger
7c680b17a4 [REF] pre_upgrade: rename `account_factoring to account_factoring_oca` for 18.0
``account_factoring`` (depends on ``account_payment_partner`` +
``account_payment_mode``) is replaced by ``account_factoring_oca``
(depends on ``account_payment_base_oca`` +
``account_payment_base_oca_sale``) in v18. Both modules share rigorously
identical stored fields on ``account_journal``, ``account_move`` and
``res_partner``, so a rename (not an uninstall) preserves the 4910
invoice factor statuses, 311 partner credit limits and journal factor
config; OpenUpgrade then treats ``account_factoring_oca`` as already
installed and adopts the existing columns via ORM ``_auto_init``.

Redirects dependencies pointing to the old name, drops the obsolete
``account_payment_partner``/``account_payment_mode`` dependencies and
inserts the new OCA equivalents. Must run AFTER the bank-payment
renaming block which creates ``account_payment_base_oca`` in the DB.
The orphaned ``base.module_account_factoring`` xmlid is renamed so
OpenUpgrade can rediscover the physical ``account_factoring`` addon dir
without conflicting on ``(base, module_account_factoring)``.
2026-08-07 10:36:12 +02:00
Stéphan Sainléger
803ae858ea [REM] pre_upgrade: delete obsolete `contract_payment_mode` before 18.0 load
``contract_payment_mode`` v18 still depends on ``account_payment_partner``
(removed by the bank-payment renaming) and uses the obsolete
``account.payment.mode`` model; no v18 replacement provides contract
integration, and only 2 contracts carried a ``payment_mode_id``.

Must run BEFORE the bank-payment renaming and BEFORE OpenUpgrade's
``button_upgrade()`` which would otherwise crash on the missing
dependency.

The module is DELETED (module row + ``ir_model_data`` + dependencies)
rather than marked 'to remove', because Odoo's "Transient module states
were reset" in ``load_modules()`` converts 'to remove' -> 'installed',
making ``button_upgrade()`` re-parse the missing
``account_payment_partner`` dependency and abort the registry load. The
orphaned ``base.module_contract_payment_mode`` xmlid is renamed so
``update_list()`` rediscovers the physical addon as a fresh uninstalled
module that ``button_upgrade()`` never touches.
2026-08-07 10:35:59 +02:00
Stéphan Sainléger
6890d19524 [FIX] pre_upgrade: drop orphan `theme.ir.ui.view` records before 17.0 load
The migrated 16.0 database still holds ``theme_ir_ui_view`` rows whose
``inherit_id`` points to ``ir.ui.view`` ids removed by the 17.0 asset
bundle rework (``web._assets_utils``, ``website.assets_editor``,
``website._assets_frontend_helpers`` replaced by the ``ir.asset`` model).

When ``website._theme_load`` runs during the registry load,
``ThemeView._convert_to_base_model`` accesses ``inherit.website_id`` on
the ghost record and raises ``MissingError``, aborting the whole
registry build.

Delete the ``ir_model_data`` + ``theme_ir_ui_view`` rows whose
``inherit_id`` targets a missing ``ir.ui.view``. Generic (covers any
theme), idempotent, scoped to ``theme_ir_ui_view`` only; no
``ir.ui.view`` copies exist for the affected templates.
2026-08-07 10:35:34 +02:00
Stéphan Sainléger
bc3907e1d8 [FIX] l10n_fr_account: recreate missing property-account xml-ids in 18.0
The core script ``l10n_fr_account/migrations/2.1/end-migrate_update_taxes.py``
calls ``account.chart.template.try_loading('fr', company)``. During
``_post_load_data``, Odoo 18 resolves the French template's default accounts via
``self.ref()`` WITHOUT ``raise_if_not_found=False`` (chart_template.py:711/714
and the ``_get_property_accounts`` loop at :746). If any expected
``account.<company>_<key>`` xml-id is missing, it raises
``ValueError: External ID not found`` and aborts the registry load.

This happens when a database customized its chart of accounts: the generic PCG
account was deleted (e.g. 607000) or recreated without its xml-id (e.g. 707000).

Add a generic ORM ``post-migration`` script that, for every company with a
``chart_template``, reads the expected account xml-ids from the template API
(``_get_property_accounts`` / ``_get_chart_template_data`` /
``_get_account_account``) and recreates any missing ``account.<company>_<key>``
``ir.model.data`` pointing to the best matching account: exact code, else the
company account sharing the longest common code prefix, else a non-blocking
warning. It runs at stage ``post`` (before the native ``end`` script) and only
creates missing xml-ids — no account is modified. Idempotent, generic across
all migrated FR databases.

Remove the previous hardcoded 607/707 SQL block from
``versions/18.0/pre_upgrade.sh`` now superseded by this script.
2026-07-22 16:14:36 +02:00
Stéphan Sainléger
b8e3168eaf [FIX] project: drop `project_list` act_window_view before 17.0 migration
``project_list`` (OCA) is not ported to 17.0 and is merged into core: in 17.0
the core ``project`` module natively adds the kanban/tree views to the
"Projects" actions (``act_window_id`` 149 & 760), and 17.0 introduces the
constraint ``act_window_view_unique_mode_per_action = unique(act_window_id,
view_mode)``.

When core reloads ``project_project_views.xml`` it INSERTs its own
``(act_window_id, view_mode='kanban')`` rows, colliding with the rows still
owned by ``project_list`` and aborting the registry load with a
``UniqueViolation`` on ``act_window_view_unique_mode_per_action``.

Delete ``project_list``'s ``ir.actions.act_window.view`` records (and their
``ir_model_data``) in ``versions/17.0/pre_upgrade.sh`` so core can recreate its
canonical rows. Verified against the 17.0 OpenUpgrade scripts: none reference
``project_list`` xml-ids nor remap these records, so this is safe. Scoped to
``ir.actions.act_window.view`` only, idempotent.
2026-07-15 23:15:06 +02:00
Stéphan Sainléger
a3bd1bbbe8 [REF] l10n_fr_account: host custom migration scripts in a dedicated `--upgrade-path`
Custom migration scripts for a core module used to require copying the
whole module into ``versions/18.0/addons/`` so its ``migrations/`` folder
would be picked up. Because the migration container mounts that copy with
priority (``--addons-path=mig,addons``) and ``get_module_path`` returns the
first match, the copy fully shadowed the core module — dragging along its
entire data/models just to carry one script.

Instead, inject the scripts through a dedicated OpenUpgrade ``upgrade_path``:

- New ``versions/18.0/scripts/l10n_fr_account/18.0.2.2/`` holding
  ``pre-migration-0k-fix-orphan-expressions.py`` (converted to the
  ``@openupgrade.migrate()`` / ``migrate(env, version)`` convention) and
  ``post-migration-0k-vat-box25.py`` + ``noupdate_changes.xml`` that reapply
  the box 25 VAT-report customization via ``openupgrade.load_data``.
- ``config/compose.yml``: mount it as ``/opt/odoo/auto/upgrade`` for ou18.
- ``versions/18.0/upgrade.sh``: pass
  ``--upgrade-path=/opt/odoo/auto/upgrade,/opt/odoo/auto/addons/openupgrade_scripts/scripts``.
  Odoo appends every path to ``odoo.upgrade.__path__`` and concatenates the
  per-version scripts, so our scripts run IN ADDITION to the native ones;
  the native path is listed explicitly because ``--upgrade-path`` overrides
  ``config['upgrade_path']`` which ``openupgrade_framework`` only sets when empty.

Files are named with a ``-0k-`` marker so they never collide with the native
``pre-migration.py`` / ``post-migration.py``. This removes the need for the
shadowing module copy entirely.

Also ignore ``__pycache__``/``*.pyc``.
2026-07-14 00:24:54 +02:00
Stéphan Sainléger
718b367e86 [ADD] various: deduplicate soon-to-be-unique fields before migration
Some target versions add a ``UNIQUE`` constraint on a field that allowed
duplicates in the source version (e.g. ``utm.source.name`` in 16.0). The
unique index creation then aborts the whole registry load with a
``psycopg2.errors.UniqueViolation``.

Declare such constraints per version in ``known_changes.yaml`` under the
``new_unique_constraints`` key (only ``model`` and ``fields`` needed).
During ``prepare_db.sh``, ``dedup_unique_constraints.py`` aggregates the
declarations of every traversed version and, on the source database,
renames duplicates by suffixing `` [<id>]`` while preserving foreign keys.

The actual PostgreSQL column type is used (not the ORM ``translate``
attribute) so both ``varchar`` and already-converted ``jsonb`` columns are
handled: ``jsonb`` duplicates are compared and renamed on every language
key (indexed ``en_US`` value). Renamed records are reported in a
timestamped JSON file and summarized at the end of ``migration.log``.
2026-07-13 15:23:26 +02:00
Stéphan Sainléger
781407fe4c [FIX] finalize_db: detach `ir_filters pointing to deleted ir_actions`
OpenUpgrade migrations can delete and recreate ``ir_actions`` records with
new ids, leaving ``ir_filters.action_id`` (custom user filters) pointing to
a now-missing action. Opening the custom filters list then fails with
"Record does not exist or has been deleted (ir.actions.actions(<id>,))"
when Odoo resolves the ``action_id`` many2one ``display_name``.

Detach such filters (set ``action_id = NULL``) as a final, version-agnostic
cleanup step: the filter stays usable, only the broken action link is
dropped. Placed in ``finalize_db.sh`` so the check runs whatever the target
version, with an integrated re-count that warns if orphans remain.
2026-07-07 22:24:17 +02:00
Stéphan Sainléger
9dd66f1b0a [FIX] finalize_db: only reset `to upgrade modules, not to install`
The pre-shell state reset in ``scripts/finalize_db.sh`` forced both
``to upgrade`` and ``to install`` modules to ``installed``. Forcing
``to install`` to ``installed`` makes Odoo skip their install scripts
entirely (tables, ``noupdate`` data, init hooks never run), leaving
ghost "installed but empty" modules that the final ``-u all`` cannot
recover.

Restrict the reset to ``to upgrade`` only, whose real update is honored
by the controlled ``-u all`` at the end of the script. Also drop the
dead ``NOT IN (... state = 'uninstalled')`` sub-query: ``name`` is
unique so a module never has two states, making the filter a no-op.

A commented-out ``SELECT`` is added to trace pending-upgrade modules if
the trailing ``-u all`` is ever removed.
2026-07-07 22:24:17 +02:00
Stéphan Sainléger
085abb02cb [FIX] remove useless and destructive website template cleaning 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
1a8fb4f659 [IMP] add project_deadline in "merged in core" modules in 15.0 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
9af147b113 [IMP] add migration log file
to store all the migration logs. File reset at each migration.
2026-07-07 22:24:17 +02:00
Stéphan Sainléger
e57241e789 [IMP] add --resume-from option
to resume the migration process from a step wehre the database and
filestore are correct.
2026-07-07 22:24:17 +02:00
Stéphan Sainléger
a2450faf93 [IMP] add hr_expense_report_merge_attachment in "merged in core" modules in 18.0 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
3b81052ca6 [IMP] delete res.settings views in 18.0 pre-update to rebuild them latter 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
231ec1e9b7 [IMP] manage the sequence issues in stock picking and pos orders in v18 post-upgrade 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
d7d7904a36 [IMP] detect missing pos.order hashes and regenerate them 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
1c88a2d6af [IMP] add Odoo addons known changes 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
4594d5971c [NEW] add detection of obsolete, Odoo core integrated or renamed addons 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
bf022605d4 [IMP] add migrations script to use bank-payment-alternative addons in 18.0 2026-07-07 22:21:42 +02:00
Stéphan Sainléger
82b4713f02 [NEW] add post-migration views validation process 2026-02-04 11:38:55 +01:00
Stéphan Sainléger
54057611eb [IMP] include Elabore and Lokavaluto add-ons in missing add-ons detection process 2026-02-04 00:04:39 +01:00
Stéphan Sainléger
b239176afe [FIX] correct final database detection 2026-02-04 00:02:11 +01:00
Stéphan Sainléger
ee27536011 [FIX] use relative path for compose to avoid 0k dev-pack IOError
The 0k dev-pack's compose script doesn't handle absolute paths correctly.
It passes HOST_COMPOSE_YML_FILE to the container, which tries to open
it directly instead of using the mounted path.

Add run_compose() wrapper that changes to PROJECT_ROOT before calling
compose with a relative path, ensuring consistent behavior regardless
of the current working directory.
2026-02-03 17:15:20 +01:00
Stéphan Sainléger
ebc1adb4fa [IMP] rewrite README with comprehensive documentation
Add complete documentation in French including:
- Table of contents for easy navigation
- Prerequisites section (0k dev-pack, Docker, rsync, sudo)
- Project structure explanation with directory tree
- Detailed workflow explanation with step-by-step breakdown
- ASCII diagram showing migration flow
- Usage examples with command-line syntax
- Customization guide for version-specific scripts
- Troubleshooting section with common issues and solutions

Replace the previous minimal README that only contained basic
installation and configuration notes.
2026-02-02 23:48:28 +01:00
Stéphan Sainléger
8d2b151a85 [IMP] update all script paths for new directory structure
Update all path references to match the new directory layout:

upgrade.sh:
  - ./prepare_db.sh -> ${SCRIPT_DIR}/scripts/prepare_db.sh
  - ./finalize_db.sh -> ${SCRIPT_DIR}/scripts/finalize_db.sh
  - ${SCRIPT_DIR}/${version}.0/ -> ${SCRIPT_DIR}/versions/${version}.0/

scripts/prepare_db.sh:
  - pre_migration_view_checking.py -> ${SCRIPT_DIR}/lib/python/check_views.py

scripts/finalize_db.sh:
  - post_migration_fix_duplicated_views.py -> ${SCRIPT_DIR}/lib/python/fix_duplicated_views.py
  - post_migration_cleanup_obsolete_modules.py -> ${SCRIPT_DIR}/lib/python/cleanup_modules.py

versions/*/upgrade.sh:
  - ../compose.yml -> ../../config/compose.yml
2026-02-02 22:11:15 +01:00
Stéphan Sainléger
245ddcc3f9 [IMP] reorganize project directory structure
Restructure the project for better organization and maintainability:

New structure:
  ./upgrade.sh              - Main entry point (unchanged)
  ./lib/common.sh           - Shared bash functions
  ./lib/python/             - Python utility scripts
  ./scripts/                - Workflow scripts (prepare_db, finalize_db)
  ./config/                 - Configuration files (compose.yml)
  ./versions/{13..18}.0/    - Version-specific migration scripts

File renames:
  - pre_migration_view_checking.py -> lib/python/check_views.py
  - post_migration_fix_duplicated_views.py -> lib/python/fix_duplicated_views.py
  - post_migration_cleanup_obsolete_modules.py -> lib/python/cleanup_modules.py

Benefits:
  - Single entry point visible at root level
  - Clear separation between shared code, scripts, and config
  - Shorter, cleaner Python script names (context given by caller)
  - Easier navigation and maintenance
2026-02-02 22:10:01 +01:00
Stéphan Sainléger
eb95a8152a [IMP] avoid directory changes in migration loop
Replace cd into version directories with absolute path execution:

Before:
  cd "${version}.0"
  ./pre_upgrade.sh
  ./upgrade.sh
  ./post_upgrade.sh
  cd ..

After:
  "${SCRIPT_DIR}/${version}.0/pre_upgrade.sh"
  "${SCRIPT_DIR}/${version}.0/upgrade.sh"
  "${SCRIPT_DIR}/${version}.0/post_upgrade.sh"

Benefits:
- No working directory state to track
- More robust: script works regardless of where it's called from
- Easier debugging: no need to remember current directory
- Avoids potential issues if a subscript changes directory
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
febe877043 [FIX] correct undefined variable FINALE_DB_MODEL_NAME
Replace $FINALE_DB_MODEL_NAME with $FINALE_DB_NAME in the call to
prepare_db.sh.

FINALE_DB_MODEL_NAME was never defined anywhere in the codebase,
causing the script to fail immediately with 'set -u' (unbound variable
error). The intended variable is FINALE_DB_NAME which contains the
target database name (e.g., 'ou16').
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
f07a654c22 [IMP] factor out user confirmation prompts into reusable function
Add confirm_or_exit() function to lib/common.sh to eliminate duplicated
confirmation dialog code in prepare_db.sh.

Before: Two 10-line case statements with identical logic
After: Two single-line function calls

The function provides consistent behavior:
- Displays the question with Y/N options
- Returns 0 on Y/y (continue execution)
- Exits with error on any other input

This follows DRY principle and ensures all confirmation prompts
behave identically across the codebase.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
60d25124c4 [IMP] use rsync instead of cp for filestore copy
Replace mkdir + rm -rf + cp -a sequence with rsync --delete:

Before (3 commands):
  sudo mkdir -p "$dst_path"
  sudo rm -rf "$dst_path"
  sudo cp -a "$src_path" "$dst_path"

After (2 commands):
  sudo mkdir -p "$(dirname "$dst_path")"
  sudo rsync -a --delete "${src_path}/" "${dst_path}/"

Benefits:
- Incremental copy: only transfers changed files on re-run
- Atomic delete + copy: --delete removes extra files in destination
- Preserves all attributes like cp -a
- Faster for large filestores when re-running migration

Added rsync to required commands check.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
67c2d5a061 [IMP] combine SQL queries into single transaction with documentation
Merge three separate SQL queries into one for better performance:
- 1 database connection instead of 3
- Atomic execution of all cleanup operations

Added detailed SQL comments explaining each operation:
- DROP SEQUENCE: Why stale sequences prevent Odoo startup
- UPDATE ir_ui_view: Why website templates are reset except pages
- DELETE ir_attachment: Why compiled assets must be purged

Also changed DROP SEQUENCE to DROP SEQUENCE IF EXISTS to avoid
errors if sequences don't exist.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
e17db5d062 [IMP] simplify migration path construction with seq
Replace manual loop building version array with seq + readarray:

Before (4 lines):
  declare -a versions
  nb_migrations=$((FINAL_VERSION - ORIGIN_VERSION))
  for ((i = 0; i < nb_migrations; i++)); do
      versions[i]=$((ORIGIN_VERSION + 1 + i))
  done

After (1 line):
  readarray -t versions < <(seq $((ORIGIN_VERSION + 1)) "$FINAL_VERSION")

The seq command is purpose-built for generating number sequences,
making the intent clearer and the code more concise.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
89cc3be05e [IMP] simplify PostgreSQL container detection with readarray
Replace double grep pattern with readarray for cleaner container detection:
- Single grep call instead of two
- Native bash array instead of string manipulation
- Array length check instead of grep -c
- Proper formatting when listing multiple containers

The readarray approach is more idiomatic and avoids edge cases with
empty strings and newline handling.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
22d5b6af7e [IMP] remove redundant SQL query and grep for missing addons
The SQL query already filters on module_origin.state = 'installed',
so the second query to get installed addons and the grep intersection
were completely redundant.

Before: 2 SQL queries + grep + 3 temp files
After: 1 SQL query + variable

This simplifies the code and reduces database round-trips.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
00c12769bc [IMP] add external command verification at startup
Add check_required_commands() function to verify that all required
external tools are available before the script begins execution:
- docker: Container runtime
- compose: Docker compose wrapper (0k-scripts)
- sudo: Required for filestore operations

Benefits:
- Fails fast with a clear error message listing missing commands
- Prevents cryptic 'command not found' errors mid-execution
- Documents script dependencies explicitly
- Called immediately after argument validation in upgrade.sh
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
4bdedf3759 [IMP] apply naming conventions for variables
Apply consistent naming conventions throughout upgrade.sh:
- UPPERCASE + readonly for script-level constants (immutable values)
- lowercase for temporary/local variables within the script flow

Constants marked readonly:
- ORIGIN_VERSION, FINAL_VERSION, ORIGIN_DB_NAME, ORIGIN_SERVICE_NAME
- COPY_DB_NAME, FINALE_DB_NAME, FINALE_SERVICE_NAME
- POSTGRES_SERVICE_NAME

Local variables renamed to lowercase:
- postgres_containers, postgres_count (detection phase)
- db_exists, filestore_path (validation phase)

This convention makes it immediately clear which variables are
configuration constants vs runtime values, and prevents accidental
modification of critical values.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
1027428bfd [IMP] use mktemp and trap for temporary file cleanup
Replace hardcoded temporary file paths with mktemp -d for secure
temporary directory creation, and add a trap to automatically clean
up on script exit (success, failure, or interruption).

Benefits:
- Automatic cleanup even on Ctrl+C or script errors
- No leftover temporary files in the working directory
- Secure temporary directory creation (proper permissions)
- Files isolated in dedicated temp directory

Added '|| true' to grep command since it returns exit code 1 when
no matches are found, which would trigger set -e otherwise.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
01e23cc92c [IMP] use heredoc with variable expansion for SQL query
Convert the SQL_404_ADDONS_LIST query from a quoted string to a heredoc
without quotes (<<EOF instead of <<'EOF') to make variable expansion
explicit and consistent with other SQL blocks in the codebase.

Key difference between heredoc variants:
- <<'EOF': Literal content, no variable expansion (use for static SQL)
- <<EOF: Variables like ${FINALE_DB_NAME} are expanded (use when needed)

Also improved SQL formatting for better readability.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
d3f0998036 [IMP] add structured logging functions
Add logging functions to lib/common.sh for consistent output formatting:
- log_info(): Standard informational messages with [INFO] prefix
- log_warn(): Warning messages to stderr with [WARN] prefix
- log_error(): Error messages to stderr with [ERROR] prefix
- log_step(): Section headers with visual separators

Update upgrade.sh to use these functions throughout, replacing ad-hoc
echo statements. This provides:
- Consistent visual formatting across all scripts
- Clear distinction between info, warnings and errors
- Errors properly sent to stderr
- Easier log parsing and filtering

Also removed redundant '|| exit 1' statements since set -e handles
command failures automatically.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
914ae34f12 [IMP] centralize common functions in lib/common.sh
Extract shared utility functions into a dedicated library file:
- query_postgres_container: Execute SQL queries in postgres container
- copy_database: Copy database using pgm
- copy_filestore: Copy Odoo filestore directory
- exec_python_script_in_odoo_shell: Run Python scripts in Odoo shell

Benefits:
- Single source of truth for utility functions
- Easier maintenance and testing
- Consistent behavior across all scripts
- Reduced code duplication

Also introduces readonly constants DATASTORE_PATH and FILESTORE_SUBPATH
to avoid hardcoded paths scattered throughout the codebase.
2026-02-02 22:04:49 +01:00
Stéphan Sainléger
176fa0957c [FIX] correct undefined variable DB_CONTAINER_NAME
Replace $DB_CONTAINER_NAME with $POSTGRES_SERVICE_NAME which is the
correct variable exported from the parent script (upgrade.sh).

DB_CONTAINER_NAME was never defined, causing the script to fail
immediately with 'set -u' enabled (unbound variable error). The
intended variable is POSTGRES_SERVICE_NAME which contains the name
of the PostgreSQL container detected at runtime.
2026-02-02 22:04:41 +01:00
Stéphan Sainléger
8061d52d25 [FIX] correct return statement outside function
Replace 'return 1' with 'exit 1' in prepare_db.sh.

The 'return' statement is only valid inside functions. When used at
the script's top level, it behaves unpredictably - in some shells it
exits the script, in others it's an error. Using 'exit 1' explicitly
terminates the script with an error status, which is the intended
behavior when the PostgreSQL container is not running.
2026-02-02 22:04:20 +01:00
Stéphan Sainléger
3fe2e93d3d [IMP] use [[ instead of [ for conditionals
Replace single bracket [ ] with double bracket [[ ]] for all test
conditionals in the main scripts.

Benefits of [[ over [:
- No need to quote variables (though we still do for consistency)
- Supports regex matching with =~
- Supports pattern matching with == and !=
- && and || work inside [[ ]] without escaping
- More predictable behavior with empty strings
- Is a bash keyword, not an external command

Note: posbox scripts are left unchanged as they appear to be
third-party code imported into the repository.
2026-02-02 20:06:27 +01:00
Stéphan Sainléger
526b27fdec [IMP] fix variable quoting issues
Properly quote all variable expansions to prevent word splitting and
glob expansion issues:
- Quote $POSTGRES_SERVICE_NAME in docker exec command
- Quote $REPERTOIRE in directory test
- Remove unnecessary $ inside arithmetic expressions (($VAR -> VAR))

Unquoted variables can cause unexpected behavior when values contain
spaces or special characters. In arithmetic contexts, $ is unnecessary
and can mask errors with set -u.
2026-02-02 20:06:27 +01:00
Stéphan Sainléger
266842585b [IMP] add argument validation with usage message
Add proper argument validation at the start of upgrade.sh:
- Check that exactly 4 arguments are provided
- Display a helpful usage message with argument descriptions
- Include a concrete example command

This prevents cryptic errors when the script is called incorrectly
and provides clear guidance on expected parameters. With set -u enabled,
accessing unset positional parameters would cause an unclear error message.
2026-02-02 20:06:27 +01:00
Stéphan Sainléger
30909a3b28 [IMP] add strict mode (set -euo pipefail) to all scripts
Enable bash strict mode in all shell scripts to catch errors early:
- set -e: Exit immediately if a command exits with non-zero status
- set -u: Treat unset variables as an error
- set -o pipefail: Return value of a pipeline is the status of the last
  command to exit with non-zero status

This prevents silent failures and makes debugging easier by failing fast
when something goes wrong instead of continuing with potentially corrupted
state.
2026-02-02 20:06:27 +01:00
Stéphan Sainléger
4d7933cef0 [IMP] add final script to purge QWeb cache from compiled assets 2026-02-02 17:24:11 +01:00
Stéphan Sainléger
4a3d3b238f [IMP] add final script to reset all custom website templates 2026-02-02 17:23:24 +01:00
Stéphan Sainléger
59fc39620d [IMP] improves the way postgres container is detected 2026-01-16 14:19:51 +01:00
Stéphan Sainléger
7d001ff163 [IMP] adds pythons script to clean obsolete addons 2026-01-13 15:08:11 +01:00
Stéphan Sainléger
da59dffcfa [IMP] adds .gitignore 2026-01-13 12:50:16 +01:00
Stéphan Sainléger
d8b332762b [IMP] set debug log level on compose run commands 2026-01-13 12:44:35 +01:00
Stéphan Sainléger
023deeea5b [IMP] adds duplicated views cleaning at finalize db step 2026-01-13 12:39:17 +01:00
Stéphan Sainléger
743d1ce831 [IMP] adds check view python scripts at db preparation step 2026-01-13 12:38:47 +01:00
Stéphan Sainléger
469fb42e48 [IMP] add function to execute python scripts in Odoo shell 2026-01-13 12:37:38 +01:00
Stéphan Sainléger
f18d50cb94 [CLN] remove useless force_uninstall_addons file 2026-01-12 17:09:38 +01:00
Stéphan Sainléger
afeaa3d00f [IMP] fix the issue of account_analytic_plan migration in v17
Should be great to understand the origin of the problem one day...
2026-01-12 17:08:43 +01:00
Stéphan Sainléger
93fc10395f [IMP] add base in --load option of upgrade compose run command
due to account.invoice transformation in account.move
2026-01-12 17:00:03 +01:00
Stéphan Sainléger
458af6a795 [IMP] use odoo image rc/16.0-ELABORE-LIGHT instead of rc/16.0-MYC-INIT 2025-09-11 11:03:33 +02:00
Stéphan Sainléger
61733b04a3 [NEW] add migration scripts for Odoo 18.0 2025-09-11 11:03:29 +02:00
Stéphan Sainléger
385b9bc751 [NEW] add migration scripts for Odoo 17.0 2025-09-11 10:39:37 +02:00
Stéphan Sainléger
f432b4c75e [IMP] add commented command to launch global addons update at each step of the migration 2025-09-11 10:35:13 +02:00
Stéphan Sainléger
21028149be [IMP] update postgres version to 17.2.0 2025-09-11 10:33:39 +02:00
Stéphan Sainléger
972e6c7b26 [FIX] add su access for filestore manipulation 2025-09-11 10:31:52 +02:00
54 changed files with 4642 additions and 354 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
final_404_addons
migration.log
__pycache__/
*.pyc

View File

View File

@@ -1,3 +0,0 @@
#!/bin/bash
compose -f ../compose.yml run -p 8013:8069 ou13 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=warn --max-cron-threads=0 --limit-time-real=10000 --database=ou13

View File

View File

@@ -1,3 +0,0 @@
#!/bin/bash
compose -f ../compose.yml run -p 8014:8069 ou14 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=warn --max-cron-threads=0 --limit-time-real=10000 --database=ou14 --load=web,openupgrade_framework

View File

View File

@@ -1,3 +0,0 @@
#!/bin/bash
compose -f ../compose.yml run -p 8015:8069 ou15 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=warn --max-cron-threads=0 --limit-time-real=10000 --database=ou15 --load=web,openupgrade_framework

View File

View File

@@ -1,3 +0,0 @@
#!/bin/bash
compose -f ../compose.yml run -p 8016:8069 ou16 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=warn --max-cron-threads=0 --limit-time-real=10000 --database=ou16 --load=web,openupgrade_framework

500
README.md
View File

@@ -1,64 +1,492 @@
# 0k-odoo-upgrade
A tool for migrating Odoo databases between major versions, using [OpenUpgrade](https://github.com/OCA/OpenUpgrade) in a production-like Docker environment.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Project Structure](#project-structure)
- [How It Works](#how-it-works)
- [Usage](#usage)
- [Resuming a Failed Migration](#resuming-a-failed-migration)
- [Customization](#customization)
- [Troubleshooting](#troubleshooting)
## Prerequisites
- [0k dev-pack](https://git.myceliandre.fr/Lokavaluto/dev-pack) installed (provides the `compose` command)
- Docker and Docker Compose
- `rsync` for filestore copying
- `sudo` access for filestore operations
## Installation
- Clone the current repo
```bash
git clone <repository-url>
cd 0k-odoo-upgrade
```
## Configuration
## Project Structure
- Requires to have the 0k scripts installed on the computer: https://git.myceliandre.fr/Lokavaluto/dev-pack
```
.
├── upgrade.sh # Main entry point
├── config/
│ └── compose.yml # Docker Compose configuration
├── lib/
│ ├── common.sh # Shared bash functions
│ └── python/ # Python utility scripts
│ ├── check_views.py # View analysis (pre-migration)
│ ├── validate_views.py # View validation (post-migration)
│ ├── fix_duplicated_views.py # Fix duplicated views
│ ├── dedup_unique_constraints.py # Dedup soon-to-be-unique fields (pre-migration)
│ └── cleanup_modules.py # Obsolete module cleanup
├── scripts/
│ ├── prepare_db.sh # Database preparation before migration
│ ├── finalize_db.sh # Post-migration finalization
│ └── validate_migration.sh # Manual post-migration validation
└── versions/ # Version-specific scripts
├── 13.0/
│ ├── pre_upgrade.sh # SQL fixes before migration
│ ├── upgrade.sh # OpenUpgrade execution
│ └── post_upgrade.sh # Fixes after migration
├── 14.0/
├── ...
└── 18.0/
```
## How It Works
### Overview
The script performs a **step-by-step migration** between each major version. For example, to migrate from 14.0 to 17.0, it executes:
```
14.0 → 15.0 → 16.0 → 17.0
```
### Process Steps
1. **Initial Checks**
- Argument validation
- Required command verification (`docker`, `compose`, `sudo`, `rsync`)
- Source database and filestore existence check
2. **Environment Preparation**
- Creation of a fresh Odoo database in the target version (for module comparison)
- Copy of the source database to a working database
- Filestore copy
3. **Database Preparation** (`scripts/prepare_db.sh`)
- Neutralization: disable mail servers and cron jobs
- Detection of installed modules missing in the target version
- Deduplication of fields that become `UNIQUE` in a traversed version
(see [Unique Constraints](#unique-constraints-new_unique_constraints))
- View state verification
- User confirmation prompt
4. **Migration Loop** (for each intermediate version)
- `pre_upgrade.sh`: version-specific SQL fixes before migration
- `upgrade.sh`: OpenUpgrade execution via Docker
- `post_upgrade.sh`: fixes after migration
5. **Finalization** (`scripts/finalize_db.sh`)
- Obsolete sequence removal
- Modified website template reset
- Compiled asset cache purge
- Duplicated view fixes
- Obsolete module cleanup
- Final update with `-u all`
### Flow Diagram
```
┌─────────────────┐
│ upgrade.sh │
└────────┬────────┘
┌─────────────────┐ ┌─────────────────┐
│ Initial │────▶│ Copy DB + │
│ checks │ │ filestore │
└─────────────────┘ └────────┬────────┘
┌─────────────────┐
│ prepare_db.sh │
│ (neutralization)│
└────────┬────────┘
┌───────────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ versions/13.0/ │────▶│ versions/14.0/ │────▶│ versions/N.0/ │
│ pre/upgrade/post│ │ pre/upgrade/post│ │ pre/upgrade/post│
└─────────────────┘ └─────────────────┘ └────────┬────────┘
┌─────────────────┐
│ finalize_db.sh │
│ (cleanup) │
└─────────────────┘
```
## Usage
### Before migration
- [ ] import the origin database to migrate on local computer
- [ ] Uninstall all known useless Odoo add-ons. Warning: do not uninstall add-ons for which the disappearance in the finale version is managed by Open Upgrade scrips.
- [ ] Unsure all the add-ons are migrated in the final Odoo version
- [ ] (optional) De-active all the website views
### Before Migration
### Local Migration process
1. **Import the source database** to your local machine
- [ ] launch the origin database `ORIGIN_DATABASE_NAME` with original version of Odoo, with odoo service `ORIGIN_SERVICE`
- [ ] launch the following command:
2. **Clean up the source database** (recommended)
- Uninstall unnecessary modules
- Do NOT uninstall modules handled by OpenUpgrade
``` bash
./upgrade.sh {ORIGIN_VERSION} {DESTINATION_VERSION} {ORIGIN_DATABASE_NAME} {ORIGIN_SERVICE}
3. **Check module availability**
- Ensure all custom modules are ported to the target version
4. **Start the Docker environment**
```bash
# Start the PostgreSQL container
compose up -d postgres
# Verify only one postgres container is running
docker ps | grep postgres
```
### Running the Migration
```bash
./upgrade.sh <source_version> <target_version> <database_name> <source_service> [--resume-from|-r <version>]
```
ex: ./upgrade.sh 14 16 elabore_20241208 odoo14
- [ ] Inspect the list of add-ons identified as missing in the final Odoo docker image:
- if you want to uninstall some of them:
- STOP the process (N)
- uninstall the concernet add-ons manually
- launch the migration script again
- if the list suits you, show can go on (Y)!
**Parameters:**
| Parameter | Description | Example |
|-----------|-------------|---------|
| `source_version` | Source Odoo version (without .0) | `14` |
| `target_version` | Target Odoo version (without .0) | `17` |
| `database_name` | Database name | `my_prod_db` |
| `source_service` | Source Docker Compose service | `odoo14` |
The migration process should run all the middle-migrations until the last one without action needed from you.
**Options:**
| Option | Description |
|--------|-------------|
| `--resume-from <version>`, `-r <version>` | Resume from an intermediate checkpoint (see [Resuming a Failed Migration](#resuming-a-failed-migration)) |
### Deploy migrated base
**Example:**
```bash
./upgrade.sh 14 17 elabore_20241208 odoo14
```
- [ ] Retrieve the migrated database (vps odoo dump)
- [ ] Copy the database on the concerned VPS
- [ ] vps odoo restore
### During Migration
The script will prompt for confirmation at two points:
## Manage the add-ons to uninstall
1. **Missing modules list**: installed modules that don't exist in the target version
- `Y`: continue (modules will be marked for removal)
- `N`: abort to manually uninstall certain modules
The migration script will manage the uninstall of Odoo add-ons:
- add-ons we want to uninstall, whatever the reasons
- add-ons to uninstall because they do not exist in the final Odoo docker image
2. **View state**: verification of potentially problematic views
- `Y`: continue
- `N`: abort to manually fix issues
At the beginning of the process, the script compare the list of add-ons installed in the origin database, and the list of add-ons available in the finlal Odoo docker image.
### After Migration
The whole list of add-ons to uninstall is displayed, and needs a confirmation before starting the migration.
1. **Review logs** to detect any non-blocking errors
## Customize the migration scripts
2. **Validate the migration** (see [Post-Migration Validation](#post-migration-validation))
FEATURE COMING SOON...
3. **Test the migrated database** locally
4. **Deploy to production**
```bash
# Export the migrated database
vps odoo dump db_migrated.zip
## Manage migration issues
# On the production server
vps odoo restore db_migrated.zip
```
As the migration process is performed on a copy of the orginal database, the process can be restarted without limits.
## Post-Migration Validation
Some Odoo migration errors won't stop the migration process, then be attentive to the errors in the logs.
After migration, use the validation script to check for broken views and XPath errors.
### Quick Start
```bash
./scripts/validate_migration.sh ou17 odoo17
```
### What Gets Validated
Runs in Odoo shell, no HTTP server needed:
| Check | Description |
|-------|-------------|
| **Inherited views** | Verifies all inherited views can combine with their parent |
| **XPath targets** | Ensures XPath expressions find their targets in parent views |
| **QWeb templates** | Validates QWeb templates are syntactically correct |
| **Field references** | Checks that field references point to existing model fields |
| **Odoo native** | Runs Odoo's built-in `_validate_custom_views()` |
### Running Directly
You can also run the Python script directly in Odoo shell:
```bash
compose run odoo17 shell -d ou17 --no-http --stop-after-init < lib/python/validate_views.py
```
### Output
- **Colored terminal output** with `[OK]`, `[ERROR]`, `[WARN]` indicators
- **JSON report** written to `/tmp/validation_views_<db>_<timestamp>.json`
- **Exit code**: `0` = success, `1` = errors found
## Resuming a Failed Migration
Each version hop copies the database before modifying it (`ou14` → `ou15` → `ou16` → …). If a migration crashes mid-way, the intermediate databases from completed hops are still intact and can be used as a restart point.
### How It Works
Use `--resume-from <version>` (or `-r <version>`) to restart from an intermediate checkpoint:
```bash
./upgrade.sh <source_version> <target_version> <database_name> <source_service> --resume-from <checkpoint_version>
```
When a checkpoint is specified, the script:
- **Skips** the initial database and filestore copy
- **Skips** `prepare_db.sh` (already done before the crash)
- **Starts the migration loop** from `checkpoint_version + 1`
### Example
A migration from 14 to 18 crashes during the 16→17 hop. The database `ou15` was successfully created. Resume from there:
```bash
./upgrade.sh 14 18 my_database odoo14 --resume-from 15
```
This runs: `16.0 → 17.0 → 18.0`, starting from `ou15`.
### Constraints
- The checkpoint version must be strictly between the source and target versions.
- The intermediate database (`ou<version>`) and its filestore must exist before resuming.
### Restarting From Scratch
To restart a full migration from the beginning (ignoring all intermediate databases):
```bash
./upgrade.sh 14 18 my_database odoo14
```
The script automatically drops and recreates the final target database (`ou18`) if it already exists.
## Customization
### Version Scripts
Each `versions/X.0/` directory contains three scripts you can customize:
#### `pre_upgrade.sh`
Executed **before** OpenUpgrade. Use it to:
- Add missing columns expected by OpenUpgrade
- Fix incompatible data
- Remove problematic records
```bash
#!/bin/bash
set -euo pipefail
echo "Prepare migration to 15.0..."
copy_database ou14 ou15 ou15
PRE_MIGRATE_SQL=$(cat <<'EOF'
-- Example: remove a problematic module
DELETE FROM ir_module_module WHERE name = 'obsolete_module';
EOF
)
query_postgres_container "$PRE_MIGRATE_SQL" ou15
copy_filestore ou14 ou14 ou15 ou15
echo "Ready for migration to 15.0!"
```
#### `upgrade.sh`
Runs OpenUpgrade migration scripts.
#### `post_upgrade.sh`
Executed **after** OpenUpgrade. Use it to:
- Fix incorrectly migrated data
- Remove orphan records
- Update system parameters
```bash
#!/bin/bash
set -euo pipefail
echo "Post migration to 15.0..."
POST_MIGRATE_SQL=$(cat <<'EOF'
-- Example: fix a configuration value
UPDATE ir_config_parameter
SET value = 'new_value'
WHERE key = 'my_key';
EOF
)
query_postgres_container "$POST_MIGRATE_SQL" ou15
```
### Available Functions
Version scripts have access to functions defined in `lib/common.sh`:
| Function | Description |
|----------|-------------|
| `query_postgres_container "$SQL" "$DB"` | Execute an SQL query |
| `copy_database $from $to_service $to_db` | Copy a PostgreSQL database |
| `copy_filestore $from_svc $from_db $to_svc $to_db` | Copy a filestore |
| `log_info`, `log_warn`, `log_error` | Logging functions |
| `log_step "title"` | Display a section header |
### Unique Constraints (`new_unique_constraints`)
Some Odoo versions add a `UNIQUE` constraint on a field that previously allowed
duplicates. A well-known case is `utm.source.name` in 16.0. When the migration
rebuilds the model, PostgreSQL tries to create the unique index and aborts the
whole registry load if the legacy data contains duplicates:
```
psycopg2.errors.UniqueViolation: could not create unique index "utm_source_unique_name"
DETAIL: Key (name)=(Webinaire gouvernance (copie)) is duplicated.
```
To prevent this, declare the newly-introduced unique constraints in the
`known_changes.yaml` of the version that introduces them, under the
`new_unique_constraints` key. Only `model` and `fields` are needed — the table,
the column type and the translatable (jsonb) flag are resolved automatically
from the ORM:
```yaml
new_unique_constraints:
- model: utm.source
fields: [name]
- model: utm.medium
fields: [name]
- model: utm.campaign
fields: [name]
```
During `prepare_db.sh`, the `dedup_unique_constraints.py` checker aggregates the
declarations of every traversed version and, on the **source** database:
- detects duplicate groups (for translatable/jsonb fields it compares the
indexed `en_US` value, not the raw jsonb);
- keeps the record with the smallest `id` untouched and renames each other
duplicate by appending ` [<id>]` to every language key (jsonb) or to the
plain value (varchar) — all foreign keys are preserved;
- writes a timestamped report to
`/tmp/dedup_unique_constraints_<source_db>_<timestamp>.json`.
Renamed records are summarized at the end of `migration.log` (see the final
`UPGRADE PROCESS ENDED WITH SUCCESS` section).
### Adding a New Version
To add support for a new version (e.g., 19.0):
```bash
mkdir versions/19.0
cp versions/18.0/*.sh versions/19.0/
# Edit the scripts to:
# - Change references from ou18 → ou19
# - Change the port from -p 8018:8069 → -p 8019:8069
# - Add SQL fixes specific to this migration
# - Declare any new UNIQUE constraints in versions/19.0/known_changes.yaml
# under `new_unique_constraints` (see "Unique Constraints" above)
```
## Troubleshooting
### Common Issues
#### "No running PostgreSQL container found"
```bash
# Check active containers
docker ps | grep postgres
# Start the container if needed
compose up -d postgres
```
#### "Multiple PostgreSQL containers found"
Stop the extra PostgreSQL containers:
```bash
docker stop <container_name_to_stop>
```
#### "Database not found"
The source database must exist in PostgreSQL:
```bash
# List databases
docker exec -u 70 <postgres_container> psql -l
# Import a database if needed
docker exec -u 70 <postgres_container> pgm restore <file.zip>
```
#### "Filestore not found"
The filestore must be present at `/srv/datastore/data/<service>/var/lib/odoo/filestore/<database>/`
### Restarting After an Error
The script works on a **copy** of the original database. You can restart as many times as needed:
```bash
# Simply restart - the copy will be recreated
./upgrade.sh 14 17 my_database odoo14
```
### Viewing Detailed Logs
All output (scripts, Docker containers, OpenUpgrade logs) is automatically saved to `migration.log` at the project root. The file is overwritten at the start of each run (full migration or resume).
```bash
# Follow live output while also saving to file (already done automatically)
tail -f migration.log
# Search for errors after the fact
grep -i error migration.log
# View with ANSI colors preserved (less handles CRLF line endings natively)
less -R migration.log
# Plain text output without CRLF artifacts
cat migration.log | col -b
```
> **Note:** `migration.log` is recorded via a pseudo-TTY (`script`), which preserves
> color output in the terminal. As a side effect, the file uses `\r\n` line endings.
> Use `less -R` or `col -b` for clean viewing.
For a problematic migration:
1. Note the version where the error occurs
2. Check `migration.log` to identify the problematic module/table
3. Add a fix in the `pre_upgrade.sh` for that version
4. Restart the migration
## License
See the [LICENSE](LICENSE) file.

View File

@@ -52,7 +52,7 @@ ou15:
ou16:
charm: odoo-tecnativa
docker-compose:
image: docker.0k.io/mirror/odoo:rc_16.0-MYC-INIT
image: docker.0k.io/mirror/odoo:rc_16.0-ELABORE-LIGHT
## Important to keep as a list: otherwise it'll overwrite charm's arguments.
command:
- "--log-level=debug"
@@ -73,6 +73,21 @@ ou17:
options:
workers: 0
ou18:
charm: odoo-tecnativa
docker-compose:
image: docker.0k.io/mirror/odoo:rc_18.0-ELABORE-LIGHT
## Important to keep as a list: otherwise it'll overwrite charm's arguments.
command:
- "--log-level=debug"
- "--limit-time-cpu=1000000"
- "--limit-time-real=1000000"
volumes:
- /home/stephan/dev/Odoo/0k-odoo-upgrade/versions/18.0/addons/:/opt/odoo/auto/mig:rw
- /home/stephan/dev/Odoo/0k-odoo-upgrade/versions/18.0/scripts/:/opt/odoo/auto/upgrade:rw
options:
workers: 0
postgres:
docker-compose:
image: docker.0k.io/postgres:12.15.0-myc
image: docker.0k.io/postgres:17.2.0-myc

View File

@@ -1,20 +0,0 @@
#!/bin/bash
DB_NAME="$1"
ODOO_SERVICE="$2"
FINALE_SQL=$(cat <<'EOF'
/*Delete sequences that prevent Odoo to start*/
drop sequence base_registry_signaling;
drop sequence base_cache_signaling;
EOF
)
query_postgres_container "$FINALE_SQL" "$DB_NAME" || exit 1
# Give back the right to user to access to the tables
# docker exec -u 70 "$DB_CONTAINER_NAME" pgm chown "$FINALE_SERVICE_NAME" "$DB_NAME"
# Launch Odoo with database in finale version to run all updates
compose --debug run "$ODOO_SERVICE" -u all --stop-after-init --no-http

View File

@@ -1,24 +0,0 @@
galicea_base
galicea_environment_checkup
mass_editing
mass_mailing_themes
muk_autovacuum
muk_fields_lobject
muk_fields_stream
muk_utils
muk_web_theme_mail
muk_web_utils
account_usability
kpi_dashboard
web_window_title
website_project_kanbanview
project_usability
project_tag
maintenance_server_monitoring_ping
maintenance_server_monitoring_ssh
maintenance_server_monitoring_memory
maintenance_server_monitoring_maintenance_equipment_status
maintenance_server_monitoring_disk
project_task_assignees_avatar
account_partner_reconcile
account_invoice_import_simple_pdf

245
lib/common.sh Normal file
View File

@@ -0,0 +1,245 @@
#!/bin/bash
#
# Common functions for Odoo migration scripts
# Source this file from other scripts: source "$(dirname "$0")/lib/common.sh"
#
set -euo pipefail
# Get the absolute path of the project root (parent of lib/)
readonly PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly DATASTORE_PATH="/srv/datastore/data"
readonly FILESTORE_SUBPATH="var/lib/odoo/filestore"
check_required_commands() {
local missing=()
for cmd in docker compose sudo rsync yq; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
log_error "Required commands not found: ${missing[*]}"
log_error "Please install them before running this script."
exit 1
fi
}
log_info() { printf "[INFO] %s\n" "$*"; }
log_warn() { printf "[WARN] %s\n" "$*" >&2; }
log_error() { printf "[ERROR] %s\n" "$*" >&2; }
log_step() { printf "\n===== %s =====\n" "$*"; }
confirm_or_exit() {
local message="$1"
local choice
echo ""
echo "$message"
echo "Y - Yes, continue"
echo "N - No, cancel"
read -r -n 1 -p "Your choice: " choice
echo ""
case "$choice" in
[Yy]) return 0 ;;
*) log_error "Cancelled by user."; exit 1 ;;
esac
}
query_postgres_container() {
local query="$1"
local db_name="$2"
if [[ -z "$query" ]]; then
return 0
fi
local result
if ! result=$(docker exec -u 70 "$POSTGRES_SERVICE_NAME" psql -d "$db_name" -t -A -c "$query"); then
printf "Failed to execute SQL query: %s\n" "$query" >&2
printf "Error: %s\n" "$result" >&2
return 1
fi
echo "$result"
}
copy_database() {
local from_db="$1"
local to_service="$2"
local to_db="$3"
docker exec -u 70 "$POSTGRES_SERVICE_NAME" pgm cp -f "$from_db" "${to_db}@${to_service}"
}
copy_filestore() {
local from_service="$1"
local from_db="$2"
local to_service="$3"
local to_db="$4"
local src_path="${DATASTORE_PATH}/${from_service}/${FILESTORE_SUBPATH}/${from_db}"
local dst_path="${DATASTORE_PATH}/${to_service}/${FILESTORE_SUBPATH}/${to_db}"
sudo mkdir -p "$(dirname "$dst_path")"
sudo rsync -a --delete "${src_path}/" "${dst_path}/"
echo "Filestore ${from_service}/${from_db} copied to ${to_service}/${to_db}."
}
# Workaround: 0k dev-pack's compose script doesn't handle absolute paths correctly.
# It passes HOST_COMPOSE_YML_FILE to the container, which tries to open it directly
# instead of using the mounted path. Using a relative path from PROJECT_ROOT avoids this.
run_compose() {
(cd "$PROJECT_ROOT" && compose -f ./config/compose.yml "$@")
}
exec_python_script_in_odoo_shell() {
local service_name="$1"
local db_name="$2"
local python_script="$3"
run_compose --debug run "$service_name" shell -d "$db_name" --no-http --stop-after-init < "$python_script"
}
# Same as exec_python_script_in_odoo_shell but prepends a Python preamble read
# from stdin (heredoc). Useful to inject environment values into the Odoo shell
# when the compose wrapper does not reliably propagate host env vars into the
# container. The preamble runs before the script body in the same interpreter.
#
# Usage:
# exec_python_script_in_odoo_shell_with_preamble svc db /path/script.py <<'PY'
# import os; os.environ['FOO'] = 'bar'
# PY
exec_python_script_in_odoo_shell_with_preamble() {
local service_name="$1"
local db_name="$2"
local python_script="$3"
local preamble
preamble="$(cat)"
{ printf '%s\n' "$preamble"; cat "$python_script"; } \
| run_compose --debug run "$service_name" shell -d "$db_name" --no-http --stop-after-init
}
# Classifies missing modules into 4 categories based on the known_changes.yaml
# files from each traversed version (from ORIGIN_VERSION+1 to FINAL_VERSION).
# The following global arrays are populated:
# addons_obsolete : modules that became obsolete
# addons_core : modules merged into Odoo Core
# addons_renamed : renamed modules (format "old_name -> new_name")
# addons_truly_missing : modules that are genuinely missing
#
# Prerequisites: ORIGIN_VERSION and FINAL_VERSION must be exported.
classify_missing_addons() {
local missing_addons_raw="$1"
addons_obsolete=()
addons_core=()
addons_renamed=()
addons_truly_missing=()
# Convert the string into an array (one entry per line)
local -a missing=()
while IFS= read -r line; do
[[ -n "$line" ]] && missing+=("$line")
done <<< "$missing_addons_raw"
if [[ ${#missing[@]} -eq 0 ]]; then
return 0
fi
# Build lookup tables from all known_changes.yaml files in traversed versions
local -A known_obsolete=()
local -A known_core=()
local -A known_renamed=()
local versions_path="${PROJECT_ROOT}/versions"
local v
for v in $(seq $((ORIGIN_VERSION + 1)) "$FINAL_VERSION"); do
local yaml_file="${versions_path}/${v}.0/known_changes.yaml"
[[ -f "$yaml_file" ]] || continue
local mod
while IFS= read -r mod; do
[[ -n "$mod" && "$mod" != "null" ]] && known_obsolete["$mod"]=1
done < <(yq '.obsolete[]?' "$yaml_file" 2>/dev/null)
while IFS= read -r mod; do
[[ -n "$mod" && "$mod" != "null" ]] && known_core["$mod"]=1
done < <(yq '.merged_in_core[]?' "$yaml_file" 2>/dev/null)
local count
count=$(yq '.renamed | length' "$yaml_file" 2>/dev/null)
if [[ "$count" =~ ^[0-9]+$ && "$count" -gt 0 ]]; then
local i
for ((i = 0; i < count; i++)); do
local old new
old=$(yq ".renamed[$i].old" "$yaml_file" 2>/dev/null)
new=$(yq ".renamed[$i].new" "$yaml_file" 2>/dev/null)
[[ -n "$old" && "$old" != "null" ]] && known_renamed["$old"]="$new"
done
fi
done
# Classify each missing module
local mod
for mod in "${missing[@]}"; do
if [[ -n "${known_obsolete[$mod]:-}" ]]; then
addons_obsolete+=("$mod")
elif [[ -n "${known_core[$mod]:-}" ]]; then
addons_core+=("$mod")
elif [[ -n "${known_renamed[$mod]:-}" ]]; then
addons_renamed+=("${mod} -> ${known_renamed[$mod]}")
else
addons_truly_missing+=("$mod")
fi
done
}
# Aggregates the `new_unique_constraints` sections from the known_changes.yaml
# files of every traversed version (from ORIGIN_VERSION+1 to FINAL_VERSION) into
# a single JSON array printed on stdout, e.g.:
# [{"model":"utm.source","fields":["name"]},{"model":"utm.medium","fields":["name"]}]
# Duplicate (model, fields) entries are collapsed. Prints "[]" when none.
#
# Prerequisites: ORIGIN_VERSION and FINAL_VERSION must be exported.
collect_new_unique_constraints() {
local versions_path="${PROJECT_ROOT}/versions"
local v
local -a json_parts=()
local -A seen=()
for v in $(seq $((ORIGIN_VERSION + 1)) "$FINAL_VERSION"); do
local yaml_file="${versions_path}/${v}.0/known_changes.yaml"
[[ -f "$yaml_file" ]] || continue
local count
count=$(yq '.new_unique_constraints | length' "$yaml_file" 2>/dev/null)
[[ "$count" =~ ^[0-9]+$ && "$count" -gt 0 ]] || continue
local i
for ((i = 0; i < count; i++)); do
local model fields entry
model=$(yq -o=json ".new_unique_constraints[$i].model" "$yaml_file" 2>/dev/null)
fields=$(yq -o=json -I=0 ".new_unique_constraints[$i].fields" "$yaml_file" 2>/dev/null)
[[ -n "$model" && "$model" != "null" ]] || continue
[[ -n "$fields" && "$fields" != "null" ]] || continue
entry="{\"model\":${model},\"fields\":${fields}}"
if [[ -z "${seen[$entry]:-}" ]]; then
seen["$entry"]=1
json_parts+=("$entry")
fi
done
done
local IFS=','
printf '[%s]' "${json_parts[*]}"
}
export PROJECT_ROOT DATASTORE_PATH FILESTORE_SUBPATH
export -f log_info log_warn log_error log_step confirm_or_exit
export -f check_required_commands
export -f query_postgres_container copy_database copy_filestore run_compose exec_python_script_in_odoo_shell
export -f exec_python_script_in_odoo_shell_with_preamble
export -f classify_missing_addons
export -f collect_new_unique_constraints

126
lib/python/check_views.py Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Pre-Migration Cleanup Script for Odoo
Run this BEFORE migrating to identify and clean up custom views.
Usage: odoo shell -d dbname < pre_migration_cleanup.py
"""
print("\n" + "="*80)
print("PRE-MIGRATION CLEANUP - VIEW ANALYSIS")
print("="*80 + "\n")
# 1. Find all custom (COW) views
print("STEP 1: Identifying Custom/COW Views")
print("-"*80)
all_views = env['ir.ui.view'].search(['|', ('active', '=', True), ('active', '=', False)])
cow_views = all_views.filtered(lambda v: not v.model_data_id)
print(f"Total views in database: {len(all_views)}")
print(f"Custom views (no module): {len(cow_views)}")
print(f"Module views: {len(all_views) - len(cow_views)}\n")
if cow_views:
print("Custom views found:\n")
print(f"{'ID':<8} {'Active':<8} {'Key':<50} {'Name':<40}")
print("-"*120)
for view in cow_views[:50]: # Show first 50
active_str = "" if view.active else ""
key_str = view.key[:48] if view.key else "N/A"
name_str = view.name[:38] if view.name else "N/A"
print(f"{view.id:<8} {active_str:<8} {key_str:<50} {name_str:<40}")
if len(cow_views) > 50:
print(f"\n... and {len(cow_views) - 50} more custom views")
# 2. Find duplicate views
print("\n" + "="*80)
print("STEP 2: Finding Duplicate Views (Same Key)")
print("-"*80 + "\n")
from collections import defaultdict
keys = defaultdict(list)
for view in all_views.filtered(lambda v: v.key and v.active):
keys[view.key].append(view)
duplicates = {k: v for k, v in keys.items() if len(v) > 1}
print(f"Found {len(duplicates)} keys with duplicate views:\n")
if duplicates:
for key, views in sorted(duplicates.items()):
print(f"\nKey: {key} ({len(views)} duplicates)")
for view in views:
module = view.model_data_id.module if view.model_data_id else "⚠️ Custom/DB"
print(f" ID {view.id:>6}: {module:<25} | {view.name}")
# 3. Find views that might have xpath issues
print("\n" + "="*80)
print("STEP 3: Finding Views with XPath Expressions")
print("-"*80 + "\n")
import re
views_with_xpath = []
xpath_pattern = r'<xpath[^>]+expr="([^"]+)"'
for view in all_views.filtered(lambda v: v.active and v.inherit_id):
xpaths = re.findall(xpath_pattern, view.arch_db)
if xpaths:
views_with_xpath.append({
'view': view,
'xpaths': xpaths,
'is_custom': not bool(view.model_data_id)
})
print(f"Found {len(views_with_xpath)} views with xpath expressions")
custom_xpath_views = [v for v in views_with_xpath if v['is_custom']]
print(f" - {len(custom_xpath_views)} are custom views (potential issue!)")
print(f" - {len(views_with_xpath) - len(custom_xpath_views)} are module views\n")
if custom_xpath_views:
print("Custom views with xpaths (risk for migration issues):\n")
for item in custom_xpath_views:
view = item['view']
print(f"ID {view.id}: {view.name}")
print(f" Key: {view.key}")
print(f" Inherits from: {view.inherit_id.key}")
print(f" XPath count: {len(item['xpaths'])}")
print(f" Sample xpaths: {item['xpaths'][:2]}")
print()
# 4. Summary and recommendations
print("=" * 80)
print("SUMMARY AND RECOMMENDATIONS")
print("=" * 80 + "\n")
print(f"📊 Statistics:")
print(f" • Total views: {len(all_views)}")
print(f" • Custom views: {len(cow_views)}")
print(f" • Duplicate view keys: {len(duplicates)}")
print(f" • Custom views with xpaths: {len(custom_xpath_views)}\n")
print(f"\n📋 RECOMMENDED ACTIONS BEFORE MIGRATION:\n")
if custom_xpath_views:
print(f"1. Archive or delete {len(custom_xpath_views)} custom views with xpaths:")
print(f" • Review each one and determine if still needed")
print(f" • Archive unnecessary ones: env['ir.ui.view'].browse([ids]).write({{'active': False}})")
print(f" • Plan to recreate important ones as proper module views after migration\n")
if duplicates:
print(f"2. Fix {len(duplicates)} duplicate view keys:")
print(f" • Manually review and delete obsolete duplicates, keeping the most appropriate one")
print(f" • Document the remaining appropriate ones as script post_migration_fix_duplicated_views.py will run AFTER the migration and delete all duplicates.\n")
if cow_views:
print(f"3. Review {len(cow_views)} custom views:")
print(f" • Document which ones are important")
print(f" • Export their XML for reference")
print(f" • Consider converting to module views\n")
print("=" * 80 + "\n")

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
Post-Migration Obsolete Module Cleanup
Run this AFTER migration to detect and remove modules that exist in the database
but no longer exist in the filesystem (addons paths).
"""
print("\n" + "="*80)
print("POST-MIGRATION OBSOLETE MODULE CLEANUP")
print("="*80 + "\n")
import odoo.modules.module as module_lib
# Get all modules from database
all_modules = env['ir.module.module'].search([])
print(f"Analyzing {len(all_modules)} modules in database...\n")
# Detect obsolete modules (in database but not in filesystem)
obsolete_modules = []
for mod in all_modules:
mod_path = module_lib.get_module_path(mod.name, display_warning=False)
if not mod_path:
obsolete_modules.append(mod)
if not obsolete_modules:
print("✓ No obsolete modules found! Database is clean.")
print("=" * 80 + "\n")
exit()
# Separate modules by state
safe_to_delete = [m for m in obsolete_modules if m.state != 'installed']
installed_obsolete = [m for m in obsolete_modules if m.state == 'installed']
# Display obsolete modules
print(f"Obsolete modules found: {len(obsolete_modules)}\n")
if installed_obsolete:
print("-" * 80)
print("⚠️ OBSOLETE INSTALLED MODULES (require attention)")
print("-" * 80)
for mod in sorted(installed_obsolete, key=lambda m: m.name):
print(f"{mod.name:40} | ID: {mod.id}")
print()
if safe_to_delete:
print("-" * 80)
print("OBSOLETE UNINSTALLED MODULES (safe to delete)")
print("-" * 80)
for mod in sorted(safe_to_delete, key=lambda m: m.name):
print(f"{mod.name:40} | State: {mod.state:15} | ID: {mod.id}")
print()
# Summary
print("=" * 80)
print("SUMMARY")
print("=" * 80 + "\n")
print(f" • Obsolete uninstalled modules (safe to delete): {len(safe_to_delete)}")
print(f" • Obsolete INSTALLED modules (caution!): {len(installed_obsolete)}")
# Delete uninstalled modules
if safe_to_delete:
print("\n" + "=" * 80)
print("DELETING OBSOLETE UNINSTALLED MODULES")
print("=" * 80 + "\n")
deleted_count = 0
failed_deletes = []
for mod in safe_to_delete:
try:
mod_name = mod.name
mod_id = mod.id
mod.unlink()
print(f"✓ Deleted: {mod_name} (ID: {mod_id})")
deleted_count += 1
except Exception as e:
print(f"✗ Failed: {mod.name} - {e}")
failed_deletes.append({'name': mod.name, 'id': mod.id, 'reason': str(e)})
# Commit changes
print("\n" + "=" * 80)
print("COMMITTING CHANGES")
print("=" * 80 + "\n")
try:
env.cr.commit()
print("✓ All changes committed successfully!")
except Exception as e:
print(f"✗ Commit failed: {e}")
print("Changes were NOT saved!")
exit(1)
# Final result
print("\n" + "=" * 80)
print("RESULT")
print("=" * 80 + "\n")
print(f" • Successfully deleted modules: {deleted_count}")
print(f" • Failed deletions: {len(failed_deletes)}")
if failed_deletes:
print("\n⚠️ Modules not deleted:")
for item in failed_deletes:
print(f"{item['name']} (ID: {item['id']}): {item['reason']}")
if installed_obsolete:
print("\n" + "=" * 80)
print("⚠️ WARNING: OBSOLETE INSTALLED MODULES")
print("=" * 80 + "\n")
print("The following modules are marked 'installed' but no longer exist")
print("in the filesystem. They may cause problems.\n")
print("Options:")
print(" 1. Check if these modules were renamed/merged in the new version")
print(" 2. Manually uninstall them if possible")
print(" 3. Force delete them (risky, may break dependencies)\n")
for mod in sorted(installed_obsolete, key=lambda m: m.name):
# Find modules that depend on this module
dependents = env['ir.module.module'].search([
('state', '=', 'installed'),
('dependencies_id.name', '=', mod.name)
])
dep_info = f" <- Dependents: {dependents.mapped('name')}" if dependents else ""
print(f"{mod.name}{dep_info}")
print("\n" + "=" * 80)
print("CLEANUP COMPLETED!")
print("=" * 80 + "\n")

View File

@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""
Pre-Migration Unique Constraint Deduplication Script for Odoo
Some Odoo versions introduce new UNIQUE constraints on fields that previously
allowed duplicates (e.g. utm.source.name in 16.0). When the migration rebuilds
the model, the unique index creation raises
`psycopg2.errors.UniqueViolation: could not create unique index ...`
and aborts the whole registry load.
This script runs BEFORE the migration loop, on the SOURCE database, and renames
duplicate records so the target-version unique index can be built successfully.
For each declared constraint (model + fields), it:
- resolves the table, column type and translatable flag from the ORM;
- detects duplicate groups (for translatable/jsonb fields the comparison is
done on the value actually indexed by Odoo, i.e. the `en_US` key);
- keeps the record with the smallest id untouched and renames every other
duplicate by appending ` [<id>]` to ALL language keys (jsonb) or to the
plain value (varchar), preserving all foreign keys.
Constraints to check are passed as JSON via the DEDUP_UNIQUE_CONSTRAINTS
environment variable, e.g.:
[{"model": "utm.source", "fields": ["name"]}, ...]
A machine-readable report of every rename is written to
/tmp/dedup_unique_constraints_<db>_<timestamp>.json when DEDUP_REPORT is set.
Usage:
compose run <service> shell -d <db> --no-http --stop-after-init \
< dedup_unique_constraints.py
Exit codes:
0 - Completed (duplicates, if any, were deduplicated)
2 - Technical failure (see traceback)
"""
import os
import sys
import json
from datetime import datetime
from collections import defaultdict
class Colors:
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
BOLD = '\033[1m'
END = '\033[0m'
def _c(color, text):
return f"{color}{text}{Colors.END}"
def load_constraints():
"""Load the constraints catalog from the environment."""
raw = os.environ.get('DEDUP_UNIQUE_CONSTRAINTS', '').strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"DEDUP_UNIQUE_CONSTRAINTS is not valid JSON: {exc}")
if not isinstance(data, list):
raise ValueError("DEDUP_UNIQUE_CONSTRAINTS must be a JSON list")
return data
def resolve_constraint(env, spec):
"""Resolve a {model, fields} spec into concrete DB metadata.
Returns a dict with table, list of (field_name, column, is_jsonb) or
None if the model/fields are not present in this database (e.g. module
not installed in the source version).
"""
model_name = spec.get('model')
field_names = spec.get('fields') or []
if not model_name or not field_names:
return None
if model_name not in env:
return None
model = env[model_name]
table = model._table
# The physical table must exist in the source DB.
env.cr.execute("SELECT to_regclass(%s)", (table,))
if not env.cr.fetchone()[0]:
return None
columns = []
for fname in field_names:
field = model._fields.get(fname)
if field is None:
return None
column = field.name
# Column must physically exist (skip non-stored/computed fields).
# We rely on the ACTUAL PostgreSQL column type, not the ORM `translate`
# attribute: in the source version a translatable field may still be a
# plain `character varying` column (it only becomes `jsonb` during the
# migration to the version that introduces translation storage).
env.cr.execute(
"""
SELECT data_type FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
""",
(table, column),
)
row = env.cr.fetchone()
if not row:
return None
data_type = row[0]
columns.append({
'field': fname,
'column': column,
'is_jsonb': data_type == 'jsonb',
})
return {'model': model_name, 'table': table, 'columns': columns}
def key_sql(column, is_jsonb):
"""SQL expression yielding the value Odoo compares for the unique index.
For jsonb (translated) columns Odoo indexes the value; the semantically
relevant duplicate is on the `en_US` key. For plain columns, the value.
"""
if is_jsonb:
return f"({column} ->> 'en_US')"
return column
def find_duplicate_groups(env, meta):
"""Return duplicate groups as list of dicts: {value, ids (sorted)}."""
table = meta['table']
key_exprs = [key_sql(c['column'], c['is_jsonb']) for c in meta['columns']]
key_concat = " || '\x1f' || ".join(f"COALESCE({e}, '')" for e in key_exprs)
query = f"""
SELECT {key_concat} AS dup_key, array_agg(id ORDER BY id) AS ids
FROM {table}
GROUP BY {key_concat}
HAVING COUNT(*) > 1
"""
env.cr.execute(query)
groups = []
for dup_key, ids in env.cr.fetchall():
groups.append({'value': dup_key, 'ids': list(ids)})
return groups
def rename_record(env, meta, record_id):
"""Suffix ` [<id>]` on every language key (jsonb) / plain value (varchar).
Returns a list of {field, old_value, new_value} describing the change.
"""
table = meta['table']
changes = []
suffix = f" [{record_id}]"
for col in meta['columns']:
column = col['column']
env.cr.execute(
f"SELECT {column} FROM {table} WHERE id = %s", (record_id,)
)
row = env.cr.fetchone()
if not row:
continue
old_value = row[0]
if col['is_jsonb']:
# jsonb: append suffix to each language value
if not isinstance(old_value, dict):
# psycopg may already decode jsonb to dict; if not, load it.
try:
old_value = json.loads(old_value) if old_value else {}
except (TypeError, ValueError):
old_value = {}
new_value = {
lang: (val or '') + suffix for lang, val in old_value.items()
}
env.cr.execute(
f"UPDATE {table} SET {column} = %s::jsonb WHERE id = %s",
(json.dumps(new_value), record_id),
)
else:
new_value = (old_value or '') + suffix
env.cr.execute(
f"UPDATE {table} SET {column} = %s WHERE id = %s",
(new_value, record_id),
)
changes.append({
'field': col['field'],
'old_value': old_value,
'new_value': new_value,
})
return changes
def main():
print("\n" + "=" * 80)
print(_c(Colors.BOLD, "PRE-MIGRATION - UNIQUE CONSTRAINT DEDUPLICATION"))
print("=" * 80 + "\n")
try:
specs = load_constraints()
except ValueError as exc:
print(_c(Colors.RED, f"[ERROR] {exc}"))
sys.exit(2)
if not specs:
print(_c(Colors.YELLOW, "No unique constraints declared. Nothing to do."))
return
renamed_records = [] # flat report entries
checked = 0
skipped = []
for spec in specs:
meta = resolve_constraint(env, spec)
label = f"{spec.get('model')}({', '.join(spec.get('fields', []))})"
if meta is None:
skipped.append(label)
print(_c(Colors.YELLOW, f"[SKIP] {label} - not present in this database"))
continue
checked += 1
groups = find_duplicate_groups(env, meta)
if not groups:
print(_c(Colors.GREEN, f"[OK] {label} - no duplicates"))
continue
total_dups = sum(len(g['ids']) - 1 for g in groups)
print(_c(Colors.BOLD,
f"[FIX] {label} - {len(groups)} duplicate group(s), "
f"{total_dups} record(s) to rename"))
for group in groups:
ids = group['ids']
keep_id, dup_ids = ids[0], ids[1:]
print(f" value={group['value']!r} keep id={keep_id} "
f"rename ids={dup_ids}")
for dup_id in dup_ids:
changes = rename_record(env, meta, dup_id)
for ch in changes:
renamed_records.append({
'model': meta['model'],
'table': meta['table'],
'id': dup_id,
'field': ch['field'],
'old_value': ch['old_value'],
'new_value': ch['new_value'],
})
if renamed_records:
env.cr.commit()
# ---- Summary ---------------------------------------------------------
print("\n" + "=" * 80)
print(_c(Colors.BOLD, "DEDUPLICATION SUMMARY"))
print("=" * 80)
print(f" Constraints checked ... {checked}")
print(f" Constraints skipped ... {len(skipped)}")
print(f" Records renamed ....... {len(renamed_records)}")
if renamed_records:
print("")
for r in renamed_records:
old_display = r['old_value']
new_display = r['new_value']
print(f" - {r['model']} #{r['id']} [{r['field']}]: "
f"{old_display!r} -> {new_display!r}")
print("=" * 80 + "\n")
# ---- JSON report -----------------------------------------------------
if os.environ.get('DEDUP_REPORT') and renamed_records:
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
db = env.cr.dbname
path = f"/tmp/dedup_unique_constraints_{db}_{ts}.json"
report = {
'type': 'dedup_unique_constraints',
'timestamp': datetime.now().isoformat(),
'database': db,
'constraints_checked': checked,
'constraints_skipped': skipped,
'renamed_count': len(renamed_records),
'renamed': renamed_records,
}
with open(path, 'w') as fh:
json.dump(report, fh, indent=2, default=str)
print(_c(Colors.BLUE, f"[REPORT] JSON written to {path}"))
try:
main()
except Exception as exc: # noqa: BLE001
print(_c(Colors.RED, f"[ERROR] Deduplication script failed: {exc}"))
import traceback
traceback.print_exc()
sys.exit(2)

1
lib/python/file_gc.py Normal file
View File

@@ -0,0 +1 @@
env['ir.attachment']._gc_file_store_unsafe()

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Post-Migration Duplicate View Fixer
Run this AFTER migration to fix duplicate views automatically.
"""
print("\n" + "="*80)
print("POST-MIGRATION DUPLICATE VIEW FIXER")
print("="*80 + "\n")
from collections import defaultdict
# Find all duplicate views
all_views = env['ir.ui.view'].search(['|', ('active', '=', True), ('active', '=', False)])
keys = defaultdict(list)
for view in all_views:
if view.key:
keys[view.key].append(view)
duplicates = {k: v for k, v in keys.items() if len(v) > 1}
print(f"Found {len(duplicates)} keys with duplicate views\n")
if not duplicates:
print("✓ No duplicate views found! Database is clean.")
print("=" * 80 + "\n")
exit()
# Process duplicates
views_to_delete = []
redirect_log = []
for key, views in sorted(duplicates.items()):
print(f"\nProcessing key: {key}")
print("-" * 80)
# Sort views: module views first, then by ID (older first)
sorted_views = sorted(views, key=lambda v: (
0 if v.model_data_id else 1, # Module views first
v.id # Older views first (lower ID = older)
))
# Keep the first view (should be module view or oldest)
keep = sorted_views[0]
to_delete = sorted_views[1:]
module_keep = keep.model_data_id.module if keep.model_data_id else "Custom/DB"
print(f"KEEP: ID {keep.id:>6} | Module: {module_keep:<20} | {keep.name}")
for view in to_delete:
module = view.model_data_id.module if view.model_data_id else "Custom/DB"
print(f"DELETE: ID {view.id:>6} | Module: {module:<20} | {view.name}")
# Find and redirect children
children = env['ir.ui.view'].search([('inherit_id', '=', view.id)])
if children:
print(f" → Redirecting {len(children)} children {children.ids} to view {keep.id}")
for child in children:
child_module = child.model_data_id.module if child.model_data_id else "Custom/DB"
redirect_log.append({
'child_id': child.id,
'child_name': child.name,
'child_module': child_module,
'from': view.id,
'to': keep.id
})
try:
children.write({'inherit_id': keep.id})
print(f" ✓ Redirected successfully")
except Exception as e:
print(f" ✗ Redirect failed: {e}")
continue
views_to_delete.append(view)
# Summary before deletion
print("\n" + "="*80)
print("SUMMARY")
print("="*80 + "\n")
print(f"Views to delete: {len(views_to_delete)}")
print(f"Child views to redirect: {len(redirect_log)}\n")
if redirect_log:
print("Redirections that will be performed:")
for item in redirect_log[:10]: # Show first 10
print(f" • View {item['child_id']} ({item['child_module']})")
print(f" '{item['child_name']}'")
print(f" Parent: {item['from']}{item['to']}")
if len(redirect_log) > 10:
print(f" ... and {len(redirect_log) - 10} more redirections")
# Delete duplicate views
print("\n" + "="*80)
print("DELETING DUPLICATE VIEWS")
print("="*80 + "\n")
deleted_count = 0
failed_deletes = []
# Sort views by ID descending (delete newer/child views first)
views_to_delete_sorted = sorted(views_to_delete, key=lambda v: v.id, reverse=True)
for view in views_to_delete_sorted:
try:
# Create savepoint to isolate each deletion
env.cr.execute('SAVEPOINT delete_view')
view_id = view.id
view_name = view.name
view_key = view.key
# Double-check it has no children
remaining_children = env['ir.ui.view'].search([('inherit_id', '=', view_id)])
if remaining_children:
print(f"⚠️ Skipping view {view_id}: Still has {len(remaining_children)} children")
failed_deletes.append({
'id': view_id,
'reason': f'Still has {len(remaining_children)} children'
})
env.cr.execute('ROLLBACK TO SAVEPOINT delete_view')
continue
view.unlink()
env.cr.execute('RELEASE SAVEPOINT delete_view')
print(f"✓ Deleted view {view_id}: {view_key}")
deleted_count += 1
except Exception as e:
env.cr.execute('ROLLBACK TO SAVEPOINT delete_view')
print(f"✗ Failed to delete view {view.id}: {e}")
failed_deletes.append({
'id': view.id,
'name': view.name,
'reason': str(e)
})
# Commit changes
print("\n" + "="*80)
print("COMMITTING CHANGES")
print("="*80 + "\n")
try:
env.cr.commit()
print("✓ All changes committed successfully!")
except Exception as e:
print(f"✗ Commit failed: {e}")
print("Changes were NOT saved!")
exit(1)
# Final verification
print("\n" + "="*80)
print("FINAL VERIFICATION")
print("="*80 + "\n")
# Re-check for duplicates
all_views_after = env['ir.ui.view'].search([('active', '=', True)])
keys_after = defaultdict(list)
for view in all_views_after:
if view.key:
keys_after[view.key].append(view)
duplicates_after = {k: v for k, v in keys_after.items() if len(v) > 1}
print(f"Results:")
print(f" • Successfully deleted: {deleted_count} views")
print(f" • Failed deletions: {len(failed_deletes)}")
print(f" • Child views redirected: {len(redirect_log)}")
print(f" • Remaining duplicates: {len(duplicates_after)}")
if failed_deletes:
print(f"\n⚠️ Failed deletions:")
for item in failed_deletes:
print(f" • View {item['id']}: {item['reason']}")
if duplicates_after:
print(f"\n⚠️ Still have {len(duplicates_after)} duplicate keys:")
for key, views in sorted(duplicates_after.items())[:5]:
print(f"{key}: {len(views)} views")
for view in views:
module = view.model_data_id.module if view.model_data_id else "Custom/DB"
print(f" - ID {view.id} ({module})")
print(f"\n Run this script again to attempt another cleanup.")
else:
print(f"\n✓ All duplicates resolved!")
print("\n" + "="*80)
print("FIX COMPLETED!")
print("="*80)

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Regenerate all POS order inalterability hashes and sequence numbers.
This script should only be called when the SQL pre-check in finalize_db.sh
has confirmed that some pos_orders are missing l10n_fr_hash or
l10n_fr_secure_sequence_number.
It resets ALL sequence numbers and hashes from scratch, in chronological order
(date_order ASC, id ASC), and re-establishes the full hash chain.
Usage (via Odoo shell):
odoo-shell -d <db_name> < regenerate_pos_hashes.py
"""
import logging
from hashlib import sha256
_logger = logging.getLogger(__name__)
def regenerate_all():
PosOrder = env['pos.order']
Company = env['res.company']
for company in Company.search([]):
if not company._is_accounting_unalterable():
continue
print(f"\n{'='*60}")
print(f" Company: {company.name}")
print(f"{'='*60}")
orders = PosOrder.search([
('state', 'in', ['paid', 'done', 'invoiced']),
('company_id', '=', company.id),
], order='date_order ASC, id ASC')
if not orders:
print(" No orders to process.")
continue
n = len(orders)
# ── Step 1: Reset all fields ─────────────────────────────
print("\n--- Resetting fields ---")
env.cr.execute("""
UPDATE pos_order
SET l10n_fr_hash = NULL,
l10n_fr_secure_sequence_number = NULL,
previous_order_id = NULL
WHERE id IN %s
""", (tuple(orders.ids),))
env.invalidate_all()
env.cr.commit()
print(f"{n} orders reset")
# ── Step 2: Assign sequence numbers ──────────────────────
print("\n--- Assigning sequence numbers ---")
for i, order in enumerate(orders, start=1):
env.cr.execute("""
UPDATE pos_order
SET l10n_fr_secure_sequence_number = %s
WHERE id = %s
""", (i, order.id))
env.invalidate_all()
env.cr.commit()
print(f" ✓ Sequence numbers 1 → {n} assigned")
# ── Step 3: Compute previous_order_id ────────────────────
print("\n--- Computing previous_order_id ---")
orders = PosOrder.search([
('state', 'in', ['paid', 'done', 'invoiced']),
('company_id', '=', company.id),
], order='l10n_fr_secure_sequence_number ASC')
orders._compute_previous_order()
env.cr.commit()
print(" ✓ OK")
# ── Step 4: Compute hashes (with cache invalidation after each write) ─
print("\n--- Computing hashes ---")
success = errors = 0
for idx in range(n):
order = PosOrder.search([
('company_id', '=', company.id),
('l10n_fr_secure_sequence_number', '=', idx + 1),
], limit=1)
if not order:
continue
try:
order._compute_string_to_hash()
prev = order.previous_order_id
prev_hash = prev.l10n_fr_hash if prev else ''
if not prev_hash:
prev_hash = ''
computed_hash = sha256(
(prev_hash + order.l10n_fr_string_to_hash).encode('utf-8')
).hexdigest()
env.cr.execute(
"UPDATE pos_order SET l10n_fr_hash = %s WHERE id = %s",
(computed_hash, order.id)
)
env.invalidate_all()
print(f"{order.name} (seq {idx+1})")
success += 1
except Exception as e:
print(f"{order.name} (seq {idx+1}) : {e}")
errors += 1
import traceback
traceback.print_exc()
env.cr.commit()
remaining = PosOrder.search_count([
('state', 'in', ['paid', 'done', 'invoiced']),
('company_id', '=', company.id),
'|', ('l10n_fr_hash', '=', False),
('l10n_fr_hash', '=', None),
])
print(f"\n Final result: {success} hashes written, {errors} errors, "
f"{remaining} remaining")
# Reset sequence for future orders
seq = company.l10n_fr_pos_cert_sequence_id
if seq:
env.cr.execute(
"UPDATE ir_sequence SET number_next = %s WHERE id = %s",
(n + 1, seq.id)
)
print(f" ✓ ir_sequence number_next set to {n + 1}")
print("\n✓ Done.")
regenerate_all()

521
lib/python/validate_views.py Executable file
View File

@@ -0,0 +1,521 @@
#!/usr/bin/env python3
"""
Post-Migration View Validation Script for Odoo
Validates all views after migration to detect:
- Broken XPath expressions in inherited views
- Views that fail to combine with their parent
- Invalid QWeb templates
- Missing asset files
- Field references to non-existent fields
Usage:
odoo-bin shell -d <database> < validate_views.py
# Or with compose:
compose run <service> shell -d <database> --no-http --stop-after-init < validate_views.py
Exit codes:
0 - All validations passed
1 - Validation errors found (see report)
"""
import os
import sys
import re
import json
from datetime import datetime
from collections import defaultdict
from lxml import etree
# ANSI colors for terminal output
class Colors:
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
BOLD = '\033[1m'
END = '\033[0m'
def print_header(title):
"""Print a formatted section header."""
print(f"\n{Colors.BOLD}{'='*80}{Colors.END}")
print(f"{Colors.BOLD}{title}{Colors.END}")
print(f"{Colors.BOLD}{'='*80}{Colors.END}\n")
def print_subheader(title):
"""Print a formatted subsection header."""
print(f"\n{Colors.BLUE}{'-'*60}{Colors.END}")
print(f"{Colors.BLUE}{title}{Colors.END}")
print(f"{Colors.BLUE}{'-'*60}{Colors.END}\n")
def print_ok(message):
"""Print success message."""
print(f"{Colors.GREEN}[OK]{Colors.END} {message}")
def print_error(message):
"""Print error message."""
print(f"{Colors.RED}[ERROR]{Colors.END} {message}")
def print_warn(message):
"""Print warning message."""
print(f"{Colors.YELLOW}[WARN]{Colors.END} {message}")
def print_info(message):
"""Print info message."""
print(f"{Colors.BLUE}[INFO]{Colors.END} {message}")
class ViewValidator:
"""Validates Odoo views after migration."""
def __init__(self, env):
self.env = env
self.View = env['ir.ui.view']
self.errors = []
self.warnings = []
self.stats = {
'total_views': 0,
'inherited_views': 0,
'qweb_views': 0,
'broken_xpath': 0,
'broken_combine': 0,
'broken_qweb': 0,
'broken_fields': 0,
'missing_assets': 0,
}
def validate_all(self):
"""Run all validation checks."""
print_header("ODOO VIEW VALIDATION - POST-MIGRATION")
print(f"Started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Database: {self.env.cr.dbname}")
# Get all active views
all_views = self.View.search([('active', '=', True)])
self.stats['total_views'] = len(all_views)
print_info(f"Total active views to validate: {len(all_views)}")
# Run validations
self._validate_inherited_views()
self._validate_xpath_targets()
self._validate_qweb_templates()
self._validate_field_references()
self._validate_odoo_native()
self._check_assets()
# Print summary
self._print_summary()
# Rollback to avoid any accidental changes
self.env.cr.rollback()
return len(self.errors) == 0
def _validate_inherited_views(self):
"""Check that all inherited views can combine with their parent."""
print_subheader("1. Validating Inherited Views (Combination)")
inherited_views = self.View.search([
('inherit_id', '!=', False),
('active', '=', True)
])
self.stats['inherited_views'] = len(inherited_views)
print_info(f"Found {len(inherited_views)} inherited views to check")
broken = []
for view in inherited_views:
try:
# Attempt to get combined architecture
view._get_combined_arch()
except Exception as e:
broken.append({
'view_id': view.id,
'xml_id': view.xml_id or 'N/A',
'name': view.name,
'model': view.model,
'parent_xml_id': view.inherit_id.xml_id if view.inherit_id else 'N/A',
'error': str(e)[:200]
})
self.stats['broken_combine'] = len(broken)
if broken:
for item in broken:
error_msg = (
f"View '{item['xml_id']}' (ID: {item['view_id']}) "
f"cannot combine with parent '{item['parent_xml_id']}': {item['error']}"
)
print_error(error_msg)
self.errors.append({
'type': 'combination_error',
'severity': 'error',
**item
})
else:
print_ok("All inherited views combine correctly with their parents")
def _validate_xpath_targets(self):
"""Check that XPath expressions find their targets in parent views."""
print_subheader("2. Validating XPath Targets")
inherited_views = self.View.search([
('inherit_id', '!=', False),
('active', '=', True)
])
xpath_pattern = re.compile(r'<xpath[^>]+expr=["\']([^"\']+)["\']')
orphan_xpaths = []
for view in inherited_views:
if not view.arch_db or not view.inherit_id or not view.inherit_id.arch_db:
continue
try:
# Get parent's combined arch (to handle chained inheritance)
parent_arch = view.inherit_id._get_combined_arch()
parent_tree = etree.fromstring(parent_arch)
except Exception:
# Parent view is already broken, skip
continue
# Parse child view
try:
view_tree = etree.fromstring(view.arch_db)
except Exception:
continue
# Find all xpath nodes
for xpath_node in view_tree.xpath('//xpath'):
expr = xpath_node.get('expr')
if not expr:
continue
try:
matches = parent_tree.xpath(expr)
if not matches:
orphan_xpaths.append({
'view_id': view.id,
'xml_id': view.xml_id or 'N/A',
'name': view.name,
'model': view.model,
'xpath': expr,
'parent_xml_id': view.inherit_id.xml_id or 'N/A',
'parent_id': view.inherit_id.id
})
except etree.XPathEvalError as e:
orphan_xpaths.append({
'view_id': view.id,
'xml_id': view.xml_id or 'N/A',
'name': view.name,
'model': view.model,
'xpath': expr,
'parent_xml_id': view.inherit_id.xml_id or 'N/A',
'parent_id': view.inherit_id.id,
'xpath_error': str(e)
})
self.stats['broken_xpath'] = len(orphan_xpaths)
if orphan_xpaths:
for item in orphan_xpaths:
error_msg = (
f"View '{item['xml_id']}' (ID: {item['view_id']}): "
f"XPath '{item['xpath']}' finds no target in parent '{item['parent_xml_id']}'"
)
if 'xpath_error' in item:
error_msg += f" (XPath syntax error: {item['xpath_error']})"
print_error(error_msg)
self.errors.append({
'type': 'orphan_xpath',
'severity': 'error',
**item
})
else:
print_ok("All XPath expressions find their targets")
def _validate_qweb_templates(self):
"""Validate QWeb templates can be rendered."""
print_subheader("3. Validating QWeb Templates")
qweb_views = self.View.search([
('type', '=', 'qweb'),
('active', '=', True)
])
self.stats['qweb_views'] = len(qweb_views)
print_info(f"Found {len(qweb_views)} QWeb templates to check")
broken = []
for view in qweb_views:
try:
# Basic XML parsing check
if view.arch_db:
etree.fromstring(view.arch_db)
# Try to get combined arch for inherited qweb views
if view.inherit_id:
view._get_combined_arch()
except Exception as e:
broken.append({
'view_id': view.id,
'xml_id': view.xml_id or 'N/A',
'name': view.name,
'key': view.key or 'N/A',
'error': str(e)[:200]
})
self.stats['broken_qweb'] = len(broken)
if broken:
for item in broken:
error_msg = (
f"QWeb template '{item['xml_id']}' (key: {item['key']}): {item['error']}"
)
print_error(error_msg)
self.errors.append({
'type': 'qweb_error',
'severity': 'error',
**item
})
else:
print_ok("All QWeb templates are valid")
def _validate_field_references(self):
"""Check that field references in views point to existing fields."""
print_subheader("4. Validating Field References")
field_pattern = re.compile(r'(?:name|field)=["\'](\w+)["\']')
missing_fields = []
# Only check form, tree, search, kanban views (not qweb)
views = self.View.search([
('type', 'in', ['form', 'tree', 'search', 'kanban', 'pivot', 'graph']),
('active', '=', True),
('model', '!=', False)
])
print_info(f"Checking field references in {len(views)} views")
checked_models = set()
for view in views:
model_name = view.model
if not model_name or model_name in checked_models:
continue
# Skip if model doesn't exist
if model_name not in self.env:
continue
checked_models.add(model_name)
try:
# Get combined arch
arch = view._get_combined_arch()
tree = etree.fromstring(arch)
except Exception:
continue
model = self.env[model_name]
model_fields = set(model._fields.keys())
# Find all field references
for field_node in tree.xpath('//*[@name]'):
field_name = field_node.get('name')
if not field_name:
continue
# Skip special names
if field_name in ('id', '__last_update', 'display_name'):
continue
# Skip if it's a button or action (not a field)
if field_node.tag in ('button', 'a'):
continue
# Check if field exists
if field_name not in model_fields:
# Check if it's a related field path (e.g., partner_id.name)
if '.' in field_name:
continue
missing_fields.append({
'view_id': view.id,
'xml_id': view.xml_id or 'N/A',
'model': model_name,
'field_name': field_name,
'tag': field_node.tag
})
self.stats['broken_fields'] = len(missing_fields)
if missing_fields:
# Group by view for cleaner output
by_view = defaultdict(list)
for item in missing_fields:
by_view[item['xml_id']].append(item['field_name'])
for xml_id, fields in list(by_view.items())[:20]: # Limit output
print_warn(f"View '{xml_id}': references missing fields: {', '.join(fields)}")
self.warnings.append({
'type': 'missing_field',
'severity': 'warning',
'xml_id': xml_id,
'fields': fields
})
if len(by_view) > 20:
print_warn(f"... and {len(by_view) - 20} more views with missing fields")
else:
print_ok("All field references are valid")
def _validate_odoo_native(self):
"""Run Odoo's native view validation."""
print_subheader("5. Running Odoo Native Validation")
try:
# This validates all custom views
self.View._validate_custom_views('all')
print_ok("Odoo native validation passed")
except Exception as e:
error_msg = f"Odoo native validation failed: {str(e)[:500]}"
print_error(error_msg)
self.errors.append({
'type': 'native_validation',
'severity': 'error',
'error': str(e)
})
def _check_assets(self):
"""Check for missing asset files."""
print_subheader("6. Checking Asset Files")
try:
IrAsset = self.env['ir.asset']
except KeyError:
print_info("ir.asset model not found (Odoo < 14.0), skipping asset check")
return
assets = IrAsset.search([])
print_info(f"Checking {len(assets)} asset definitions")
missing = []
for asset in assets:
if not asset.path:
continue
try:
# Try to resolve the asset path
# This is a simplified check - actual asset resolution is complex
path = asset.path
if path.startswith('/'):
path = path[1:]
# Check if it's a glob pattern or specific file
if '*' in path:
continue # Skip glob patterns
# Try to get the asset content (this will fail if file is missing)
# Note: This is environment dependent and may not catch all issues
except Exception as e:
missing.append({
'asset_id': asset.id,
'path': asset.path,
'bundle': asset.bundle or 'N/A',
'error': str(e)[:100]
})
self.stats['missing_assets'] = len(missing)
if missing:
for item in missing:
print_warn(f"Asset '{item['path']}' (bundle: {item['bundle']}): may be missing")
self.warnings.append({
'type': 'missing_asset',
'severity': 'warning',
**item
})
else:
print_ok("Asset definitions look valid")
def _print_summary(self):
"""Print validation summary."""
print_header("VALIDATION SUMMARY")
print(f"Statistics:")
print(f" - Total views checked: {self.stats['total_views']}")
print(f" - Inherited views: {self.stats['inherited_views']}")
print(f" - QWeb templates: {self.stats['qweb_views']}")
print()
print(f"Issues found:")
print(f" - Broken view combinations: {self.stats['broken_combine']}")
print(f" - Orphan XPath expressions: {self.stats['broken_xpath']}")
print(f" - Invalid QWeb templates: {self.stats['broken_qweb']}")
print(f" - Missing field references: {self.stats['broken_fields']}")
print(f" - Missing assets: {self.stats['missing_assets']}")
print()
total_errors = len(self.errors)
total_warnings = len(self.warnings)
if total_errors == 0 and total_warnings == 0:
print(f"{Colors.GREEN}{Colors.BOLD}")
print("="*60)
print(" ALL VALIDATIONS PASSED!")
print("="*60)
print(f"{Colors.END}")
elif total_errors == 0:
print(f"{Colors.YELLOW}{Colors.BOLD}")
print("="*60)
print(f" VALIDATION PASSED WITH {total_warnings} WARNING(S)")
print("="*60)
print(f"{Colors.END}")
else:
print(f"{Colors.RED}{Colors.BOLD}")
print("="*60)
print(f" VALIDATION FAILED: {total_errors} ERROR(S), {total_warnings} WARNING(S)")
print("="*60)
print(f"{Colors.END}")
if os.environ.get('VALIDATE_VIEWS_REPORT'):
report = {
'type': 'views',
'timestamp': datetime.now().isoformat(),
'database': self.env.cr.dbname,
'stats': self.stats,
'errors': self.errors,
'warnings': self.warnings
}
MARKER = '___VALIDATE_VIEWS_JSON___'
print(MARKER)
print(json.dumps(report, indent=2, default=str))
print(MARKER)
def main():
"""Main entry point."""
try:
validator = ViewValidator(env)
success = validator.validate_all()
# Exit with appropriate code
if not success:
sys.exit(1)
except Exception as e:
print_error(f"Validation script failed: {e}")
import traceback
traceback.print_exc()
sys.exit(2)
# Run when executed in Odoo shell
if __name__ == '__main__' or 'env' in dir():
main()

View File

@@ -1,89 +0,0 @@
#!/bin/bash
# Global variables
ODOO_SERVICE="$1"
DB_NAME="$2"
DB_FINALE_MODEL="$3"
DB_FINALE_SERVICE="$4"
echo "Start database preparation"
# Check POSTGRES container is running
if ! docker ps | grep -q "$DB_CONTAINER_NAME"; then
printf "Docker container %s is not running.\n" "$DB_CONTAINER_NAME" >&2
return 1
fi
EXT_EXISTS=$(query_postgres_container "SELECT 1 FROM pg_extension WHERE extname = 'dblink'" "$DB_NAME") || exit 1
if [ "$EXT_EXISTS" != "1" ]; then
query_postgres_container "CREATE EXTENSION dblink;" "$DB_NAME" || exit 1
fi
# Neutralize the database
SQL_NEUTRALIZE=$(cat <<'EOF'
/* Archive all the mail servers */
UPDATE fetchmail_server SET active = false;
UPDATE ir_mail_server SET active = false;
/* Archive all the cron */
ALTER TABLE ir_cron ADD COLUMN IF NOT EXISTS active_bkp BOOLEAN;
UPDATE ir_cron SET active_bkp = active;
UPDATE ir_cron SET active = False;
EOF
)
echo "Neutralize base..."
query_postgres_container "$SQL_NEUTRALIZE" "$DB_NAME" || exit 1
echo "Base neutralized..."
#######################################
## List add-ons not in final version ##
#######################################
# Retrieve add-ons not available on the final Odoo version
SQL_404_ADDONS_LIST="
SELECT module_origin.name
FROM ir_module_module module_origin
LEFT JOIN (
SELECT *
FROM dblink('dbname=$FINALE_DB_NAME','SELECT name, shortdesc, author FROM ir_module_module')
AS tb2(name text, shortdesc text, author text)
) AS module_dest ON module_dest.name = module_origin.name
WHERE (module_dest.name IS NULL) AND (module_origin.state = 'installed') AND (module_origin.author NOT IN ('Odoo S.A.', 'Lokavaluto', 'Elabore'))
ORDER BY module_origin.name
;
"
echo "Retrieve 404 addons... "
echo "SQL REQUEST = $SQL_404_ADDONS_LIST"
query_postgres_container "$SQL_404_ADDONS_LIST" "$DB_NAME" > 404_addons || exit 1
# Keep only the installed add-ons
INSTALLED_ADDONS="SELECT name FROM ir_module_module WHERE state='installed';"
query_postgres_container "$INSTALLED_ADDONS" "$DB_NAME" > installed_addons || exit 1
grep -Fx -f 404_addons installed_addons > final_404_addons
rm -f 404_addons installed_addons
# Ask confirmation to uninstall the selected add-ons
echo "
==== ADD-ONS CHECK ====
Installed add-ons not available in final Odoo version:
"
cat final_404_addons
echo "
Do you accept to migrate the database with all these add-ons still installed? (Y/N/R)"
echo "Y - Yes, let's go on with the upgrade."
echo "N - No, stop the upgrade"
read -n 1 -p "Your choice: " choice
case "$choice" in
[Yy] ) echo "
Upgrade confirmed!";;
[Nn] ) echo "
Upgrade cancelled!"; exit 1;;
* ) echo "
Please answer by Y or N.";;
esac
echo "Database successfully prepared!"

215
scripts/finalize_db.sh Executable file
View File

@@ -0,0 +1,215 @@
#!/bin/bash
set -euo pipefail
DB_NAME="$1"
ODOO_SERVICE="$2"
echo "Running SQL cleanup..."
CLEANUP_SQL=$(cat <<'EOF'
-- Drop sequences that prevent Odoo from starting.
-- These sequences are recreated by Odoo on startup but stale values
-- from the old version can cause conflicts.
DROP SEQUENCE IF EXISTS base_registry_signaling;
DROP SEQUENCE IF EXISTS base_cache_signaling;
-- Purge compiled frontend assets (CSS/JS bundles).
-- These cached files reference old asset versions and must be regenerated
-- by Odoo after migration to avoid broken stylesheets and scripts.
DELETE FROM ir_attachment
WHERE name LIKE '/web/assets/%'
OR name LIKE '%.assets_%'
OR (res_model = 'ir.ui.view' AND mimetype = 'text/css');
EOF
)
query_postgres_container "$CLEANUP_SQL" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Purge orphaned ``ir.ui.view`` images whose parent view was
# deleted AND whose ID is not referenced in any other view's
# HTML (``arch_db``). An image with ``res_id = 0`` can still
# be used by a live page via ``/web/image/<id>``, so we only
# remove it when no ``arch_db`` contains that URL.
# ────────────────────────────────────────────────────────────
echo "Purging ir.ui.view images orphaned of their parent view and unreferenced in any arch_db..."
PURGE_VIEW_IMAGES_SQL=$(cat <<'EOF'
DO $$
DECLARE purged int;
BEGIN
WITH refs AS (
SELECT DISTINCT (m[1])::bigint AS ref_id
FROM ir_ui_view v
CROSS JOIN LATERAL regexp_matches(
COALESCE(v.arch_db::text, ''), '/web/(?:image|content)/(\d+)', 'g') AS m
)
DELETE FROM ir_attachment a
WHERE a.res_model = 'ir.ui.view'
AND a.mimetype LIKE 'image/%'
AND a.store_fname IS NOT NULL
AND a.res_id NOT IN (SELECT id FROM ir_ui_view)
AND NOT EXISTS (SELECT 1 FROM refs r WHERE r.ref_id = a.id);
GET DIAGNOSTICS purged = ROW_COUNT;
RAISE NOTICE 'Purged % orphaned ir.ui.view image attachments', purged;
END $$;
EOF
)
query_postgres_container "$PURGE_VIEW_IMAGES_SQL" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Purge ir_logging (Python logger messages, no business value).
# ────────────────────────────────────────────────────────────
echo "Purging ir_logging..."
query_postgres_container "TRUNCATE ir_logging;" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Purge resized partner images (128, 256, 512, 1024) and
# attachments with no res_model (orphaned factur-x, CSS, ICS).
# Odoo 13+ regenerates thumbnails automatically from
# image_1920 on the next partner write, so only the full-size
# source needs to be kept.
# ────────────────────────────────────────────────────────────
echo "Purging resized partner images and orphaned attachments..."
PURGE_ATTACHMENTS_SQL=$(cat <<'EOF'
DELETE FROM ir_attachment
WHERE res_model = 'res.partner'
AND (name ILIKE '%image_128%'
OR name ILIKE '%image_256%'
OR name ILIKE '%image_512%'
OR name ILIKE '%image_1024%');
DELETE FROM ir_attachment
WHERE (res_model IS NULL OR res_model = '')
AND store_fname IS NOT NULL;
EOF
)
query_postgres_container "$PURGE_ATTACHMENTS_SQL" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Fix orphan ir_filters pointing to deleted ir_actions
#
# During OpenUpgrade migrations, ir_actions records can be deleted and
# recreated with new ids, while ir_filters.action_id (custom user filters)
# still points to the old, now-missing action. When a user opens the custom
# filters list, Odoo tries to resolve the display_name of the many2one
# action_id and raises "Record does not exist or has been deleted
# (ir.actions.actions(<id>,))". We detach such filters (set action_id = NULL),
# which keeps the filter usable and only drops the broken action link.
# ────────────────────────────────────────────────────────────
FIX_ORPHAN_FILTERS_SQL=$(cat <<'EOF'
DO $$
DECLARE
orphan_count INTEGER;
remaining INTEGER;
BEGIN
SELECT COUNT(*) INTO orphan_count
FROM ir_filters f
LEFT JOIN ir_actions a ON a.id = f.action_id
WHERE f.action_id IS NOT NULL AND a.id IS NULL;
IF orphan_count > 0 THEN
UPDATE ir_filters f
SET action_id = NULL
FROM (
SELECT f2.id
FROM ir_filters f2
LEFT JOIN ir_actions a ON a.id = f2.action_id
WHERE f2.action_id IS NOT NULL AND a.id IS NULL
) orphans
WHERE f.id = orphans.id;
RAISE NOTICE 'Detached % orphan ir_filters pointing to deleted ir_actions', orphan_count;
ELSE
RAISE NOTICE 'OK: no orphan ir_filters to fix';
END IF;
-- Verification
SELECT COUNT(*) INTO remaining
FROM ir_filters f
LEFT JOIN ir_actions a ON a.id = f.action_id
WHERE f.action_id IS NOT NULL AND a.id IS NULL;
IF remaining > 0 THEN
RAISE WARNING 'Still % orphan ir_filters remaining after fix', remaining;
END IF;
END $$;
EOF
)
echo "Fixing orphan ir_filters pointing to deleted ir_actions..."
query_postgres_container "$FIX_ORPHAN_FILTERS_SQL" "$DB_NAME"
# Reset modules still marked as 'to upgrade' before launching the Odoo shell
# scripts below. Loading the registry in `odoo shell` re-triggers the upgrade
# of these modules, which can fail on broken views (e.g. website_sale
# TypeError). The controlled `-u all` at the end of this script performs the
# real update afterwards. We deliberately do NOT touch 'to install' modules:
# forcing them to 'installed' would skip their install scripts entirely.
#
# Uncomment the SELECT below to trace which modules were pending upgrade
# before we neutralize their state (useful if the `-u all` above is removed).
# query_postgres_container "
# SELECT name FROM ir_module_module WHERE state = 'to upgrade' ORDER BY name;
# " "$DB_NAME" || true
query_postgres_container "
UPDATE ir_module_module
SET state = 'installed'
WHERE state = 'to upgrade';
" "$DB_NAME" || true
# ────────────────────────────────────────────────────────────
# Clean orphaned filestore files left behind by the attachment
# deletions above. ``_gc_file_store_unsafe`` scans the filestore
# and removes files that no longer have a matching
# ``ir_attachment`` record.
# ────────────────────────────────────────────────────────────
echo "Cleaning orphaned filestore files..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec_python_script_in_odoo_shell "$ODOO_SERVICE" "$DB_NAME" "$SCRIPT_DIR/lib/python/file_gc.py"
PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/fix_duplicated_views.py"
echo "Remove duplicated views with script $PYTHON_SCRIPT ..."
exec_python_script_in_odoo_shell "$DB_NAME" "$DB_NAME" "$PYTHON_SCRIPT"
PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/cleanup_modules.py"
echo "Uninstall obsolete add-ons with script $PYTHON_SCRIPT ..."
exec_python_script_in_odoo_shell "$DB_NAME" "$DB_NAME" "$PYTHON_SCRIPT"
# ────────────────────────────────────────────────────────────
# Regenerate POS inalterability hashes if needed
# ────────────────────────────────────────────────────────────
HASHES_NEEDED=$(query_postgres_container "
SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'pos_order'
" "$DB_NAME")
if [[ "$HASHES_NEEDED" =~ ^[0-9]+$ && "$HASHES_NEEDED" -gt 0 ]]; then
HASHES_NEEDED=$(query_postgres_container "
SELECT COUNT(*)
FROM pos_order po
JOIN res_company rc ON rc.id = po.company_id
WHERE po.state IN ('paid', 'done', 'invoiced')
AND rc.l10n_fr_pos_cert_sequence_id IS NOT NULL
AND (po.l10n_fr_hash IS NULL OR po.l10n_fr_secure_sequence_number IS NULL)
" "$DB_NAME")
fi
if [[ "$HASHES_NEEDED" =~ ^[0-9]+$ && "$HASHES_NEEDED" -gt 0 ]]; then
echo ""
echo "Found $HASHES_NEEDED pos.order(s) with missing inalterability hash or sequence number."
echo "Regenerating all POS hashes..."
PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/regenerate_pos_hashes.py"
exec_python_script_in_odoo_shell "$DB_NAME" "$DB_NAME" "$PYTHON_SCRIPT"
echo "POS hash regeneration completed."
else
echo "No missing POS hashes detected."
fi
# Give back the right to user to access to the tables
# docker exec -u 70 "$DB_CONTAINER_NAME" pgm chown "$FINALE_SERVICE_NAME" "$DB_NAME"
# Launch Odoo with database in finale version to run all updates
run_compose --debug run "$ODOO_SERVICE" -u all --log-level=debug --stop-after-init --no-http --load=base,web,openupgrade_framework
echo ""
echo "Running post-migration view validation..."
if exec_python_script_in_odoo_shell "$DB_NAME" "$DB_NAME" "${SCRIPT_DIR}/lib/python/validate_views.py"; then
echo "View validation passed."
else
echo "WARNING: View validation found issues. Run scripts/validate_migration.sh for the full report."
fi

120
scripts/prepare_db.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/bin/bash
set -euo pipefail
ODOO_SERVICE="$1"
DB_NAME="$2"
DB_FINALE_MODEL="$3"
DB_FINALE_SERVICE="$4"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo "Start database preparation"
# Check POSTGRES container is running
if ! docker ps | grep -q "$POSTGRES_SERVICE_NAME"; then
printf "Docker container %s is not running.\n" "$POSTGRES_SERVICE_NAME" >&2
exit 1
fi
EXT_EXISTS=$(query_postgres_container "SELECT 1 FROM pg_extension WHERE extname = 'dblink'" "$DB_NAME") || exit 1
if [[ "$EXT_EXISTS" != "1" ]]; then
query_postgres_container "CREATE EXTENSION dblink;" "$DB_NAME" || exit 1
fi
# Neutralize the database
SQL_NEUTRALIZE=$(cat <<'EOF'
/* Archive all the mail servers */
UPDATE fetchmail_server SET active = false;
UPDATE ir_mail_server SET active = false;
/* Archive all the cron */
ALTER TABLE ir_cron ADD COLUMN IF NOT EXISTS active_bkp BOOLEAN;
UPDATE ir_cron SET active_bkp = active;
UPDATE ir_cron SET active = False;
EOF
)
echo "Neutralize base..."
query_postgres_container "$SQL_NEUTRALIZE" "$DB_NAME" || exit 1
echo "Base neutralized..."
#######################################
## List add-ons not in final version ##
#######################################
SQL_MISSING_ADDONS=$(cat <<EOF
SELECT module_origin.name
FROM ir_module_module module_origin
LEFT JOIN (
SELECT *
FROM dblink('dbname=${FINALE_DB_NAME}','SELECT name, shortdesc, author FROM ir_module_module')
AS tb2(name text, shortdesc text, author text)
) AS module_dest ON module_dest.name = module_origin.name
WHERE (module_dest.name IS NULL)
AND (module_origin.state = 'installed')
AND (module_origin.author NOT IN ('Odoo S.A.'))
ORDER BY module_origin.name;
EOF
)
echo "Retrieve missing addons..."
missing_addons=$(query_postgres_container "$SQL_MISSING_ADDONS" "$DB_NAME")
log_step "ADD-ONS CHECK"
classify_missing_addons "$missing_addons"
if [[ ${#addons_obsolete[@]} -gt 0 ]]; then
log_info "Obsolete modules (${#addons_obsolete[@]}):"
printf "%s\n" "${addons_obsolete[@]}"
echo ""
fi
if [[ ${#addons_core[@]} -gt 0 ]]; then
log_info "Merged into Odoo Core (${#addons_core[@]}):"
printf "%s\n" "${addons_core[@]}"
echo ""
fi
if [[ ${#addons_renamed[@]} -gt 0 ]]; then
log_info "Renamed modules (${#addons_renamed[@]}):"
printf "%s\n" "${addons_renamed[@]}"
echo ""
fi
if [[ ${#addons_truly_missing[@]} -gt 0 ]]; then
log_warn "Truly missing modules (${#addons_truly_missing[@]}):"
printf "%s\n" "${addons_truly_missing[@]}"
echo ""
confirm_or_exit "Do you accept to migrate with these add-ons truly missing?"
else
log_info "No truly missing modules — all accounted for."
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
#############################################
## Deduplicate soon-to-be-unique fields ##
#############################################
# Some target versions add UNIQUE constraints on fields that allowed duplicates
# in the source version (e.g. utm.source.name in 16.0). Left untouched, the
# unique index creation aborts the migration with a UniqueViolation. We rename
# duplicates in the source DB now, before the migration loop.
log_step "UNIQUE CONSTRAINTS DEDUP"
UNIQUE_CONSTRAINTS_JSON=$(collect_new_unique_constraints)
if [[ "$UNIQUE_CONSTRAINTS_JSON" == "[]" || -z "$UNIQUE_CONSTRAINTS_JSON" ]]; then
log_info "No new unique constraints declared for the traversed versions — skipping."
else
log_info "Checking unique constraints: $UNIQUE_CONSTRAINTS_JSON"
DEDUP_SCRIPT="${SCRIPT_DIR}/lib/python/dedup_unique_constraints.py"
# Inject the catalog via a Python preamble because the compose wrapper does
# not reliably propagate host environment variables into the container.
exec_python_script_in_odoo_shell_with_preamble "$DB_NAME" "$DB_NAME" "$DEDUP_SCRIPT" <<PY
import os
os.environ['DEDUP_UNIQUE_CONSTRAINTS'] = ${UNIQUE_CONSTRAINTS_JSON@Q}
os.environ['DEDUP_REPORT'] = '1'
PY
fi
PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/check_views.py"
echo "Check views with script $PYTHON_SCRIPT ..."
exec_python_script_in_odoo_shell "$DB_NAME" "$DB_NAME" "$PYTHON_SCRIPT"
confirm_or_exit "Do you accept to migrate with the current views state?"
echo "Database successfully prepared!"

138
scripts/validate_migration.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/bin/bash
#
# Post-Migration Validation Script for Odoo
# Validates views, XPath expressions, and QWeb templates.
#
# View validation runs automatically at the end of the upgrade process.
# This script can also be run manually for the full report with JSON output.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "${PROJECT_ROOT}/lib/common.sh"
####################
# CONFIGURATION
####################
REPORT_DIR="/tmp"
REPORT_TIMESTAMP=$(date +%Y%m%d_%H%M%S)
VIEWS_REPORT=""
VIEWS_REPORT_MARKER="___VALIDATE_VIEWS_JSON___"
####################
# USAGE
####################
usage() {
cat <<EOF
Usage: $0 <db_name> <service_name>
Post-migration view validation for Odoo databases.
Validates:
- Inherited view combination (parent + child)
- XPath expressions find their targets
- QWeb template syntax
- Field references point to existing fields
- Odoo native view validation
Arguments:
db_name Name of the database to validate
service_name Docker compose service name (e.g., odoo17, ou17)
Examples:
$0 ou17 odoo17
$0 elabore_migrated odoo18
Notes:
- Runs via Odoo shell (no HTTP server needed)
- Report is written to /tmp/validation_views_<db>_<timestamp>.json
EOF
exit 1
}
####################
# ARGUMENT PARSING
####################
DB_NAME=""
SERVICE_NAME=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
;;
*)
if [[ -z "$DB_NAME" ]]; then
DB_NAME="$1"
shift
elif [[ -z "$SERVICE_NAME" ]]; then
SERVICE_NAME="$1"
shift
else
log_error "Unexpected argument: $1"
usage
fi
;;
esac
done
if [[ -z "$DB_NAME" ]]; then
log_error "Missing database name"
usage
fi
if [[ -z "$SERVICE_NAME" ]]; then
log_error "Missing service name"
usage
fi
####################
# MAIN
####################
log_step "POST-MIGRATION VIEW VALIDATION"
log_info "Database: $DB_NAME"
log_info "Service: $SERVICE_NAME"
PYTHON_SCRIPT="${PROJECT_ROOT}/lib/python/validate_views.py"
if [[ ! -f "$PYTHON_SCRIPT" ]]; then
log_error "Validation script not found: $PYTHON_SCRIPT"
exit 1
fi
VIEWS_REPORT="${REPORT_DIR}/validation_views_${DB_NAME}_${REPORT_TIMESTAMP}.json"
log_info "Running view validation in Odoo shell..."
echo ""
RESULT=0
RAW_OUTPUT=$(run_compose run --rm -e VALIDATE_VIEWS_REPORT=1 "$SERVICE_NAME" shell -d "$DB_NAME" --no-http --stop-after-init < "$PYTHON_SCRIPT") || RESULT=$?
echo "$RAW_OUTPUT" | sed "/${VIEWS_REPORT_MARKER}/,/${VIEWS_REPORT_MARKER}/d"
echo "$RAW_OUTPUT" | sed -n "/${VIEWS_REPORT_MARKER}/,/${VIEWS_REPORT_MARKER}/p" | grep -v "$VIEWS_REPORT_MARKER" > "$VIEWS_REPORT"
echo ""
log_step "VALIDATION COMPLETE"
if [[ -s "$VIEWS_REPORT" ]]; then
log_info "Report: $VIEWS_REPORT"
else
log_warn "Could not extract validation report from output"
VIEWS_REPORT=""
fi
if [[ $RESULT -eq 0 ]]; then
log_info "All validations passed!"
else
log_error "Some validations failed. Check the output above for details."
fi
exit $RESULT

View File

@@ -1,207 +1,251 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
readonly LOG_FILE="${SCRIPT_DIR}/migration.log"
if [[ -z "${_MIGRATION_LOGGING:-}" ]]; then
rm -f "$LOG_FILE"
export _MIGRATION_LOGGING=1
exec script -q -c "$(printf '%q ' "$0" "$@")" "$LOG_FILE"
fi
####################
# GLOBAL VARIABLES #
# USAGE & ARGUMENTS
####################
ORIGIN_VERSION="$1" # "12" for version 12.0
FINAL_VERSION="$2" # "16" for version 16.0
# Path to the database to migrate. Must be a .zip file with the following syntax: {DATABASE_NAME}.zip
ORIGIN_DB_NAME="$3"
ORIGIN_SERVICE_NAME="$4"
usage() {
cat <<EOF >&2
Usage: $0 <origin_version> <final_version> <db_name> <service_name> [--resume-from|-r <version>]
# Get origin database name
COPY_DB_NAME="ou${ORIGIN_VERSION}"
# Define finale database name
Arguments:
origin_version Origin Odoo version number (e.g., 12 for version 12.0)
final_version Target Odoo version number (e.g., 16 for version 16.0)
db_name Name of the database to migrate
service_name Name of the origin Odoo service (docker compose service)
Options:
--resume-from, -r <version>
Resume migration from an already-migrated intermediate
database (e.g., ou15). Skips the initial DB copy and
prepare_db.sh phases. The intermediate DB must exist.
Examples:
$0 14 18 elabore_20241208 odoo14
$0 14 18 elabore_20241208 odoo14 --resume-from 15
$0 14 18 elabore_20241208 odoo14 -r 15
EOF
exit 1
}
if [[ $# -lt 4 ]]; then
log_error "Missing arguments. Expected at least 4, got $#."
usage
fi
check_required_commands
export ORIGIN_VERSION="$1"
readonly ORIGIN_VERSION
export FINAL_VERSION="$2"
readonly FINAL_VERSION
readonly ORIGIN_DB_NAME="$3"
readonly ORIGIN_SERVICE_NAME="$4"
# Parse optional --resume-from / -r flag
RESUME_FROM_VERSION=""
shift 4
while [[ $# -gt 0 ]]; do
case "$1" in
--resume-from|-r)
if [[ $# -lt 2 ]]; then
log_error "Option '$1' requires a version number argument."
usage
fi
RESUME_FROM_VERSION="$2"
shift 2
;;
*)
log_error "Unknown option: '$1'"
usage
;;
esac
done
readonly RESUME_FROM_VERSION
readonly COPY_DB_NAME="ou${ORIGIN_VERSION}"
export FINALE_DB_NAME="ou${FINAL_VERSION}"
# Define finale odoo service name
FINALE_SERVICE_NAME="${FINALE_DB_NAME}"
readonly FINALE_DB_NAME
readonly FINALE_SERVICE_NAME="${FINALE_DB_NAME}"
# Service postgres name
export POSTGRES_SERVICE_NAME="lokavaluto_postgres_1"
#############################################
# DISPLAYS ALL INPUTS PARAMETERS
#############################################
echo "===== INPUT PARAMETERS ====="
echo "Origin version .......... $ORIGIN_VERSION"
echo "Final version ........... $FINAL_VERSION"
echo "Origin DB name ........... $ORIGIN_DB_NAME"
echo "Origin service name ..... $ORIGIN_SERVICE_NAME"
echo "
===== COMPUTED GLOBALE VARIABLES ====="
echo "Copy DB name ............. $COPY_DB_NAME"
echo "Finale DB name ........... $FINALE_DB_NAME"
echo "Finale service name ...... $FINALE_SERVICE_NAME"
echo "Postgres service name .... $POSTGRES_SERVICE_NAME"
# Function to launch an SQL request to the postgres container
query_postgres_container(){
local QUERY="$1"
local DB_NAME="$2"
if [ -z "$QUERY" ]; then
return 0
fi
local result
if ! result=$(docker exec -u 70 "$POSTGRES_SERVICE_NAME" psql -d "$DB_NAME" -t -A -c "$QUERY"); then
printf "Failed to execute SQL query: %s\n" "$query" >&2
printf "Error: %s\n" "$result" >&2
# Validate --resume-from value if provided
if [[ -n "$RESUME_FROM_VERSION" ]]; then
if ! [[ "$RESUME_FROM_VERSION" =~ ^[0-9]+$ ]]; then
log_error "--resume-from value must be a numeric version number (got: '$RESUME_FROM_VERSION')."
exit 1
fi
if [[ "$RESUME_FROM_VERSION" -le "$ORIGIN_VERSION" ]]; then
log_error "--resume-from ($RESUME_FROM_VERSION) must be strictly greater than origin version ($ORIGIN_VERSION)."
log_error "To start a fresh migration, run without --resume-from."
exit 1
fi
if [[ "$RESUME_FROM_VERSION" -ge "$FINAL_VERSION" ]]; then
log_error "--resume-from ($RESUME_FROM_VERSION) must be strictly less than final version ($FINAL_VERSION)."
log_error "Nothing to migrate: checkpoint is at or beyond the target version."
exit 1
fi
echo "$result"
}
export -f query_postgres_container
# Function to copy the postgres databases
copy_database(){
local FROM_DB="$1"
local TO_SERVICE="$2"
local TO_DB="$3"
docker exec -u 70 "$POSTGRES_SERVICE_NAME" pgm cp -f "$FROM_DB" "$TO_DB"@"$TO_SERVICE"
}
export -f copy_database
# Function to copy the filetores
copy_filestore(){
local FROM_SERVICE="$1"
local FROM_DB="$2"
local TO_SERVICE="$3"
local TO_DB="$4"
mkdir -p /srv/datastore/data/"$TO_SERVICE"/var/lib/odoo/filestore/"$TO_DB" || exit 1
rm -rf /srv/datastore/data/"$TO_SERVICE"/var/lib/odoo/filestore/"$TO_DB" || exit 1
cp -a /srv/datastore/data/"$FROM_SERVICE"/var/lib/odoo/filestore/"$FROM_DB" /srv/datastore/data/"$TO_SERVICE"/var/lib/odoo/filestore/"$TO_DB" || exit 1
echo "Filestore $FROM_SERVICE/$FROM_DB copied."
}
export -f copy_filestore
##############################################
# CHECKS ALL NEEDED COMPONENTS ARE AVAILABLE #
##############################################
echo "
==== CHECKS ALL NEEDED COMPONENTS ARE AVAILABLE ===="
# Check POSTGRES container is running
if ! docker ps | grep -q "$POSTGRES_SERVICE_NAME"; then
printf "Docker container %s is not running.\n" "$POSTGRES_SERVICE_NAME" >&2
return 1
else
echo "UPGRADE: container $POSTGRES_SERVICE_NAME running."
fi
# Check origin database is in the local postgres
DB_EXISTS=$(docker exec -it -u 70 $POSTGRES_SERVICE_NAME psql -tc "SELECT 1 FROM pg_database WHERE datname = '$ORIGIN_DB_NAME'" | tr -d '[:space:]')
if [ "$DB_EXISTS" ]; then
echo "UPGRADE: Database '$ORIGIN_DB_NAME' found."
else
echo "ERROR: Database '$ORIGIN_DB_NAME' not found in the local postgress service. Please add it and restart the upgrade process."
readarray -t postgres_containers < <(docker ps --format '{{.Names}}' | grep postgres || true)
if [[ ${#postgres_containers[@]} -eq 0 ]]; then
log_error "No running PostgreSQL container found. Please start a PostgreSQL container and try again."
exit 1
elif [[ ${#postgres_containers[@]} -gt 1 ]]; then
log_error "Multiple PostgreSQL containers found:"
printf ' %s\n' "${postgres_containers[@]}" >&2
log_error "Please ensure only one PostgreSQL container is running."
exit 1
fi
# Check that the origin filestore exist
REPERTOIRE="/srv/datastore/data/${ORIGIN_SERVICE_NAME}/var/lib/odoo/filestore/${ORIGIN_DB_NAME}"
if [ -d $REPERTOIRE ]; then
echo "UPGRADE: '$REPERTOIRE' filestore found."
export POSTGRES_SERVICE_NAME="${postgres_containers[0]}"
readonly POSTGRES_SERVICE_NAME
log_step "INPUT PARAMETERS"
log_info "Origin version .......... $ORIGIN_VERSION"
log_info "Final version ........... $FINAL_VERSION"
log_info "Origin DB name .......... $ORIGIN_DB_NAME"
log_info "Origin service name ..... $ORIGIN_SERVICE_NAME"
if [[ -n "$RESUME_FROM_VERSION" ]]; then
log_info "Resume from version ..... $RESUME_FROM_VERSION (ou${RESUME_FROM_VERSION})"
else
echo "ERROR: '$REPERTOIRE' filestore not found, please add it and restart the upgrade process."
exit 1
log_info "Resume from version ..... none (full migration)"
fi
#######################################
# LAUNCH VIRGIN ODOO IN FINAL VERSION #
#######################################
log_step "COMPUTED GLOBAL VARIABLES"
log_info "Copy DB name ............. $COPY_DB_NAME"
log_info "Finale DB name ........... $FINALE_DB_NAME"
log_info "Finale service name ...... $FINALE_SERVICE_NAME"
log_info "Postgres service name .... $POSTGRES_SERVICE_NAME"
# Remove finale database and datastore if already exists (we need a virgin Odoo)
if docker exec -u 70 "$POSTGRES_SERVICE_NAME" pgm ls | grep -q "$FINALE_SERVICE_NAME"; then
log_step "CHECKS ALL NEEDED COMPONENTS ARE AVAILABLE"
if [[ -n "$RESUME_FROM_VERSION" ]]; then
readonly RESUME_DB_NAME="ou${RESUME_FROM_VERSION}"
resume_db_exists=$(docker exec -u 70 "$POSTGRES_SERVICE_NAME" psql -tc "SELECT 1 FROM pg_database WHERE datname = '${RESUME_DB_NAME}'" | tr -d '[:space:]')
if [[ "$resume_db_exists" ]]; then
log_info "Checkpoint database '${RESUME_DB_NAME}' found."
else
log_error "Checkpoint database '${RESUME_DB_NAME}' not found in the local postgres service."
log_error "Ensure the migration up to version ${RESUME_FROM_VERSION} completed successfully before resuming."
exit 1
fi
resume_filestore_path="${DATASTORE_PATH}/${RESUME_DB_NAME}/${FILESTORE_SUBPATH}/${RESUME_DB_NAME}"
if [[ -d "$resume_filestore_path" ]]; then
log_info "Checkpoint filestore '${resume_filestore_path}' found."
else
log_error "Checkpoint filestore '${resume_filestore_path}' not found."
log_error "Ensure the filestore for '${RESUME_DB_NAME}' is intact before resuming."
exit 1
fi
else
db_exists=$(docker exec -u 70 "$POSTGRES_SERVICE_NAME" psql -tc "SELECT 1 FROM pg_database WHERE datname = '$ORIGIN_DB_NAME'" | tr -d '[:space:]')
if [[ "$db_exists" ]]; then
log_info "Database '$ORIGIN_DB_NAME' found."
else
log_error "Database '$ORIGIN_DB_NAME' not found in the local postgres service. Please add it and restart the upgrade process."
exit 1
fi
filestore_path="${DATASTORE_PATH}/${ORIGIN_SERVICE_NAME}/${FILESTORE_SUBPATH}/${ORIGIN_DB_NAME}"
if [[ -d "$filestore_path" ]]; then
log_info "Filestore '$filestore_path' found."
else
log_error "Filestore '$filestore_path' not found, please add it and restart the upgrade process."
exit 1
fi
fi
log_step "LAUNCH VIRGIN ODOO IN FINAL VERSION"
if docker exec -u 70 "$POSTGRES_SERVICE_NAME" pgm ls | grep "$FINALE_SERVICE_NAME"; then
log_info "Removing existing finale database and filestore..."
docker exec -u 70 "$POSTGRES_SERVICE_NAME" pgm rm -f "$FINALE_SERVICE_NAME"
rm -rf /srv/datastore/data/"$FINALE_SERVICE_NAME"/var/lib/odoo/filestore/"$FINALE_SERVICE_NAME"
sudo rm -rf "${DATASTORE_PATH}/${FINALE_SERVICE_NAME}/${FILESTORE_SUBPATH}/${FINALE_SERVICE_NAME}"
fi
compose --debug run "$FINALE_SERVICE_NAME" -i base --stop-after-init --no-http
run_compose --debug run "$FINALE_SERVICE_NAME" -i base --stop-after-init --no-http
echo "Model database in final Odoo version created."
log_info "Model database in final Odoo version created."
############################
# COPY ORIGINAL COMPONENTS #
############################
if [[ -z "$RESUME_FROM_VERSION" ]]; then
log_step "COPY ORIGINAL COMPONENTS"
echo "
==== COPY ORIGINAL COMPONENTS ===="
echo "UPGRADE: Start copy"
copy_database "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME"
log_info "Original database copied to ${COPY_DB_NAME}@${COPY_DB_NAME}."
# Copy database
copy_database "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME" || exit 1
echo "UPGRADE: original database copied in ${COPY_DB_NAME}@${COPY_DB_NAME}."
# Copy filestore
copy_filestore "$ORIGIN_SERVICE_NAME" "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME" || exit 1
echo "UPGRADE: original filestore copied."
copy_filestore "$ORIGIN_SERVICE_NAME" "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME"
log_info "Original filestore copied."
else
log_step "COPY ORIGINAL COMPONENTS — SKIPPED (resuming from checkpoint ou${RESUME_FROM_VERSION})"
fi
#####################
# PATH OF MIGRATION #
####################
log_step "PATH OF MIGRATION"
echo "
==== PATH OF MIGRATION ===="
# List all the versions to migrate through
declare -a versions
nb_migrations=$(($FINAL_VERSION - $ORIGIN_VERSION))
if [[ -n "$RESUME_FROM_VERSION" ]]; then
readarray -t versions < <(seq $((RESUME_FROM_VERSION + 1)) "$FINAL_VERSION")
log_info "Resuming migration from ou${RESUME_FROM_VERSION} — path is ${versions[*]}"
else
readarray -t versions < <(seq $((ORIGIN_VERSION + 1)) "$FINAL_VERSION")
log_info "Migration path is ${versions[*]}"
fi
# Build the migration path
for ((i = 0; i<$nb_migrations; i++))
do
versions[$i]=$(($ORIGIN_VERSION + 1 + i))
if [[ -z "$RESUME_FROM_VERSION" ]]; then
log_step "DATABASE PREPARATION"
"${SCRIPT_DIR}/scripts/prepare_db.sh" "$COPY_DB_NAME" "$COPY_DB_NAME" "$FINALE_DB_NAME" "$FINALE_SERVICE_NAME"
else
log_step "DATABASE PREPARATION — SKIPPED (resuming from checkpoint ou${RESUME_FROM_VERSION})"
fi
log_step "UPGRADE PROCESS"
for version in "${versions[@]}"; do
log_info "START UPGRADE TO ${version}.0"
"${SCRIPT_DIR}/versions/${version}.0/pre_upgrade.sh"
"${SCRIPT_DIR}/versions/${version}.0/upgrade.sh"
"${SCRIPT_DIR}/versions/${version}.0/post_upgrade.sh"
log_info "END UPGRADE TO ${version}.0"
done
echo "UPGRADE: Migration path is ${versions[@]}"
log_step "POST-UPGRADE PROCESSES"
########################
# DATABASE PREPARATION #
########################
"${SCRIPT_DIR}/scripts/finalize_db.sh" "$FINALE_DB_NAME" "$FINALE_SERVICE_NAME"
echo "
==== DATABASE PREPARATION ===="
log_step "UPGRADE PROCESS ENDED WITH SUCCESS"
./prepare_db.sh "$COPY_DB_NAME" "$COPY_DB_NAME" "$FINALE_DB_MODEL_NAME" "$FINALE_SERVICE_NAME" || exit 1
# Report any records that were renamed to satisfy new unique constraints.
# The deduplication runs on the source DB (ou${ORIGIN_VERSION}) during
# prepare_db.sh and writes a timestamped JSON report to /tmp.
readarray -t dedup_reports < <(ls -1t /tmp/dedup_unique_constraints_"${COPY_DB_NAME}"_*.json 2>/dev/null || true)
if [[ ${#dedup_reports[@]} -gt 0 ]]; then
latest_report="${dedup_reports[0]}"
renamed_count=$(yq -p=json '.renamed_count' "$latest_report" 2>/dev/null || echo "?")
log_info "Unique-constraint deduplication: ${renamed_count} record(s) renamed in the source DB before migration."
log_info "Details: ${latest_report}"
if [[ "$renamed_count" =~ ^[0-9]+$ && "$renamed_count" -gt 0 ]]; then
yq -p=json -r '.renamed[] | " - \(.model) #\(.id) [\(.field)]: \(.old_value) -> \(.new_value)"' \
"$latest_report" 2>/dev/null || true
fi
fi
###################
# UPGRADE PROCESS #
###################
for version in "${versions[@]}"
do
echo "START UPGRADE TO ${version}.0"
start_version=$((version-1))
end_version="$version"
### Go to the repository holding the upgrate scripts
cd "${end_version}.0"
### Execute pre_upgrade scripts
./pre_upgrade.sh || exit 1
### Start upgrade
./upgrade.sh || exit 1
### Execute post-upgrade scripts
./post_upgrade.sh || exit 1
### Return to parent repository for the following steps
cd ..
echo "END UPGRADE TO ${version}.0"
done
## END UPGRADE LOOP
##########################
# POST-UPGRADE PROCESSES #
##########################
./finalize_db.sh "$FINALE_DB_NAME" "$FINALE_SERVICE_NAME" || exit 1
echo "UPGRADE PROCESS ENDED WITH SUCCESS"
log_info "Full logs available at: ${LOG_FILE}"

View File

@@ -0,0 +1,10 @@
# Modules that became obsolete in version 13.0
obsolete: []
# Modules merged into Odoo Core in version 13.0
merged_in_core: []
# Modules renamed in version 13.0
renamed: []
# - old: old_name
# new: new_name

6
versions/13.0/post_upgrade.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -euo pipefail
echo "Post migration to 13.0..."
#compose --debug run ou13 -u base --stop-after-init --no-http

View File

@@ -1,4 +1,5 @@
#!/bin/bash
set -euo pipefail
echo "Prepare migration to 13.0..."

4
versions/13.0/upgrade.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
set -euo pipefail
run_compose run -p 8013:8069 ou13 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=debug --max-cron-threads=0 --limit-time-real=10000 --database=ou13

View File

@@ -0,0 +1,10 @@
# Modules that became obsolete in version 14.0
obsolete: []
# Modules merged into Odoo Core in version 14.0
merged_in_core: []
# Modules renamed in version 14.0
renamed: []
# - old: old_name
# new: new_name

6
versions/14.0/post_upgrade.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -euo pipefail
echo "Post migration to 14.0..."
#compose --debug run ou14 -u base --stop-after-init --no-http

View File

@@ -1,4 +1,5 @@
#!/bin/bash
set -euo pipefail
echo "Prepare migration to 14.0..."

4
versions/14.0/upgrade.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
set -euo pipefail
run_compose run -p 8014:8069 ou14 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=debug --max-cron-threads=0 --limit-time-real=10000 --database=ou14 --load=base,web,openupgrade_framework

View File

@@ -0,0 +1,16 @@
# Modules that became obsolete in version 15.0
obsolete:
- l10n_ch_base_bank
- l10n_ch_isrb
- project_timeline_task_dependency
- account_reconcile_reconciliation_date
# Modules merged into Odoo Core in version 15.0
merged_in_core:
- project_category
- project_deadline
# Modules renamed in version 15.0
renamed:
- old: crm_phone
new: crm_phonecall

6
versions/15.0/post_upgrade.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -euo pipefail
echo "Post migration to 15.0..."
#compose --debug run ou15 -u base --stop-after-init --no-http

View File

@@ -1,4 +1,5 @@
#!/bin/bash
set -euo pipefail
echo "Prepare migration to 15.0..."

4
versions/15.0/upgrade.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
set -euo pipefail
run_compose run -p 8015:8069 ou15 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=debug --max-cron-threads=0 --limit-time-real=10000 --database=ou15 --load=base,web,openupgrade_framework

View File

@@ -0,0 +1,32 @@
# Modules that became obsolete in version 16.0
obsolete:
- account_reconciliation_widget
- account_statement_import
- account_statement_import_file_reconciliation_widget
# Modules merged into Odoo Core in version 16.0
merged_in_core:
- account_balance_line
- l10n_ch_states
- project_task_dependency
- web_ir_actions_act_view_reload
- mail_activity_creator
- website_sale_require_login
# Modules renamed in version 16.0
renamed:
- old: account_statement_import_file_reconcile_oca
new: account_statement_import_file_reconcile_oca
- old: mass_editing
new: server_action_mass_edit
- old: crm_project
new: crm_lead_to_task
# Unique constraints newly introduced in 16.0
new_unique_constraints:
- model: utm.source
fields: [name]
- model: utm.medium
fields: [name]
- model: utm.campaign
fields: [name]

6
versions/16.0/post_upgrade.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -euo pipefail
echo "Post migration to 16.0..."
#compose --debug run ou16 -u base --stop-after-init --no-http

View File

@@ -1,4 +1,5 @@
#!/bin/bash
set -euo pipefail
echo "Prepare migration to 16.0..."

4
versions/16.0/upgrade.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
set -euo pipefail
run_compose run -p 8016:8069 ou16 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=debug --max-cron-threads=0 --limit-time-real=10000 --database=ou16 --load=base,web,openupgrade_framework

View File

@@ -0,0 +1,13 @@
# Modules that became obsolete in version 17.0
obsolete: []
# Modules merged into Odoo Core in version 17.0
merged_in_core:
- project_list
- web_advanced_search
- web_listview_range_select
# Modules renamed in version 17.0
renamed: []
# - old: old_name
# new: new_name

32
versions/17.0/post_upgrade.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
echo "Post migration to 17.0..."
# Execute SQL post-migration commands
POST_MIGRATE_SQL=$(cat <<'EOF'
DO $$
DECLARE
plan_id INTEGER;
BEGIN
-- Check if the 'Projects' analytic plan exists
SELECT id INTO plan_id FROM account_analytic_plan WHERE complete_name = 'migration_PROJECTS' LIMIT 1;
-- If it does exist, delete it
IF plan_id IS NOT NULL THEN
DELETE FROM account_analytic_plan WHERE complete_name = 'migration_PROJECTS';
SELECT id INTO plan_id FROM account_analytic_plan WHERE complete_name = 'Projects' LIMIT 1;
-- Delete existing system parameter (if any)
DELETE FROM ir_config_parameter WHERE key = 'analytic.project_plan';
-- Insert the system parameter with the correct plan ID
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('analytic.project_plan', plan_id::text, now(), now());
END IF;
END $$;
EOF
)
echo "SQL command = $POST_MIGRATE_SQL"
query_postgres_container "$POST_MIGRATE_SQL" ou17 || exit 1
#compose --debug run ou17 -u base --stop-after-init --no-http

217
versions/17.0/pre_upgrade.sh Executable file
View File

@@ -0,0 +1,217 @@
#!/bin/bash
set -euo pipefail
echo "Prepare migration to 17.0..."
# Copy database
copy_database ou16 ou17 ou17 || exit 1
# Execute SQL pre-migration commands
PRE_MIGRATE_SQL=$(cat <<'EOF'
DO $$
DECLARE
plan_id INTEGER;
BEGIN
-- Check if the 'Projects' analytic plan exists
SELECT id INTO plan_id FROM account_analytic_plan WHERE name = 'Projects' LIMIT 1;
-- If it doesn't exist, create it
IF plan_id IS NULL THEN
INSERT INTO account_analytic_plan (name, complete_name, default_applicability, create_date, write_date)
VALUES ('Projects', 'migration_PROJECTS', 'optional', now(), now())
RETURNING id INTO plan_id;
END IF;
-- Delete existing system parameter (if any)
DELETE FROM ir_config_parameter WHERE key = 'analytic.project_plan';
-- Insert the system parameter with the correct plan ID
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('analytic.project_plan', plan_id::text, now(), now());
END $$;
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL"
query_postgres_container "$PRE_MIGRATE_SQL" ou17 || exit 1
PRE_MIGRATE_SQL_2=$(cat <<'EOF'
DELETE FROM ir_model_fields WHERE name = 'kanban_state_label';
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL_2"
query_postgres_container "$PRE_MIGRATE_SQL_2" ou17 || exit 1
PRE_MIGRATE_SQL_3=$(cat <<'EOF'
DELETE FROM ir_model_fields WHERE name = 'phone' AND model='hr.employee';
DELETE FROM ir_model_fields WHERE name = 'hr_responsible_id' AND model='hr.job';
DELETE FROM ir_model_fields WHERE name = 'address_home_id' AND model='hr.employee';
DELETE FROM ir_model_fields WHERE name = 'manager_id' AND model='project.task';
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL_3"
query_postgres_container "$PRE_MIGRATE_SQL_3" ou17 || exit 1
# ────────────────────────────────────────────────────────────
# Remove project_list's ir.actions.act_window.view records
#
# `project_list` (OCA) is not ported to 17.0 and is classified as
# merged_in_core: in 17.0 the core `project` module natively adds the
# kanban/tree views to the "Projects" actions (act_window_id 149 & 760).
# 17.0 also introduces the unique constraint
# `act_window_view_unique_mode_per_action = unique(act_window_id, view_mode)`.
#
# When core reloads project_project_views.xml, it INSERTs its own
# (act_window_id, view_mode='kanban') rows, which collide with the rows still
# owned by project_list -> UniqueViolation, aborting the registry load:
#
# ERROR: duplicate key value violates unique constraint
# "act_window_view_unique_mode_per_action"
# DETAIL: Key (act_window_id, view_mode)=(760, kanban) already exists.
#
# We delete project_list's act_window_view rows (and their ir_model_data) so
# core can recreate its canonical rows. Verified against the 17.0 OpenUpgrade
# scripts: none reference project_list xmlids nor remap these records, so this
# is safe. Scoped to ir.actions.act_window.view only. Idempotent.
# ────────────────────────────────────────────────────────────
PRE_MIGRATE_SQL_4=$(cat <<'EOF'
DO $$
DECLARE
deleted_count INTEGER;
BEGIN
WITH targets AS (
SELECT d.id AS imd_id, d.res_id AS awv_id
FROM ir_model_data d
WHERE d.module = 'project_list'
AND d.model = 'ir.actions.act_window.view'
),
del_views AS (
DELETE FROM ir_act_window_view
WHERE id IN (SELECT awv_id FROM targets)
RETURNING id
),
del_imd AS (
DELETE FROM ir_model_data
WHERE id IN (SELECT imd_id FROM targets)
RETURNING id
)
SELECT count(*) INTO deleted_count FROM del_views;
RAISE NOTICE 'Removed % project_list act_window_view record(s) to avoid act_window_view_unique_mode_per_action collision', deleted_count;
END $$;
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL_4"
query_postgres_container "$PRE_MIGRATE_SQL_4" ou17 || exit 1
# ────────────────────────────────────────────────────────────
# Remove orphan theme.ir.ui.view templates pointing to deleted ir.ui.view
#
# In Odoo 17, the asset bundle system was reworked: `web._assets_utils`,
# `website.assets_editor`, and `website._assets_frontend_helpers` were
# replaced by the new `ir.asset` model. The theme_common/theme_clean 17.0
# modules no longer define those templates, but the migrated database still
# holds the old `theme_ir_ui_view` records (kept by ir_model_data noupdate).
#
# Their `inherit_id` Reference field points to ir.ui.view ids that no longer
# exist. When website's `_theme_load(website)` runs (ir_module_module.py:95)
# during the registry load, `ThemeView._convert_to_base_model`
# (theme_models.py:85) accesses `inherit.website_id` on the ghost record and
# raises MissingError, aborting the whole registry:
#
# odoo.exceptions.MissingError: Record does not exist or has been deleted.
# (Record: ir.ui.view(174,), User: 1)
#
# This deletes the ir_model_data + theme_ir_ui_view rows whose inherit_id
# targets a missing ir.ui.view. Generic (covers any theme, idempotent).
# Scoped to theme_ir_ui_view only. Verified: no ir.ui.view copies exist for
# the affected templates, so no side effects.
# ────────────────────────────────────────────────────────────
PRE_MIGRATE_SQL_5=$(cat <<'EOF'
DO $$
DECLARE
deleted_imd INTEGER;
deleted_tpl INTEGER;
BEGIN
IF to_regclass('theme_ir_ui_view') IS NULL THEN
RAISE NOTICE 'Table theme_ir_ui_view does not exist, skipping stale view cleanup.';
RETURN;
END IF;
WITH targets AS (
SELECT id FROM theme_ir_ui_view
WHERE inherit_id LIKE 'ir.ui.view,%'
AND NOT EXISTS (
SELECT 1 FROM ir_ui_view v
WHERE v.id = split_part(theme_ir_ui_view.inherit_id, ',', 2)::int
)
),
del AS (
DELETE FROM ir_model_data
WHERE model = 'theme.ir.ui.view'
AND res_id IN (SELECT id FROM targets)
RETURNING id
)
SELECT count(*) INTO deleted_imd FROM del;
WITH del AS (
DELETE FROM theme_ir_ui_view
WHERE inherit_id LIKE 'ir.ui.view,%'
AND NOT EXISTS (
SELECT 1 FROM ir_ui_view v
WHERE v.id = split_part(theme_ir_ui_view.inherit_id, ',', 2)::int
)
RETURNING id
)
SELECT count(*) INTO deleted_tpl FROM del;
RAISE NOTICE 'Cleaned % stale theme_ir_ui_view record(s) and % ir_model_data entry(ies)', deleted_tpl, deleted_imd;
END $$;
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL_5"
query_postgres_container "$PRE_MIGRATE_SQL_5" ou17 || exit 1
# ────────────────────────────────────────────────────────────
# Drop mail.tracking.value rows referencing event.registration.mobile
#
# In Odoo 17 the field event.registration.mobile is removed (OpenUpgrade:
# "event / event.registration / mobile (char) : DEL"). During _process_end,
# Odoo unlinks the now-orphan ir.model.fields row. mail's ir_model_fields
# unlink() override iterates the mail.tracking.value rows pointing to it and
# calls event.registration._mail_track_get_field_sequence('mobile'), which
# does self._fields['mobile'] -> KeyError: 'mobile', aborting the registry:
#
# KeyError: 'mobile' (mail/models/models.py, _mail_track_get_field_sequence)
#
# The OpenUpgrade event script deletes the field but leaves its tracking
# values, so any DB that tracked this field crashes. We delete those tracking
# values beforehand so the field unlinks cleanly.
# Scoped strictly to event.registration.mobile. Idempotent.
# NOTE: this runs before -u all, so the FK column is still named 'field'
# (mail renames it to 'field_id' during the 16->17 upgrade).
# ────────────────────────────────────────────────────────────
PRE_MIGRATE_SQL_6=$(cat <<'EOF'
DO $$
DECLARE
deleted_count INTEGER;
BEGIN
WITH del AS (
DELETE FROM mail_tracking_value
WHERE field IN (
SELECT id FROM ir_model_fields
WHERE model = 'event.registration' AND name = 'mobile'
)
RETURNING id
)
SELECT count(*) INTO deleted_count FROM del;
RAISE NOTICE 'Removed % mail_tracking_value row(s) for event.registration.mobile', deleted_count;
END $$;
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL_6"
query_postgres_container "$PRE_MIGRATE_SQL_6" ou17 || exit 1
# Copy filestores
copy_filestore ou16 ou16 ou17 ou17 || exit 1
echo "Ready for migration to 17.0!"

4
versions/17.0/upgrade.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
set -euo pipefail
run_compose run -p 8017:8069 ou17 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=debug --max-cron-threads=0 --limit-time-real=10000 --database=ou17 --load=base,web,openupgrade_framework

View File

@@ -0,0 +1,36 @@
# Modules that became obsolete in version 18.0
obsolete:
- account_payment_paired_internal_transfer
- account_reconciliation_widget
# Modules merged into Odoo Core in version 18.0
merged_in_core:
- account_payment_partner
- hr_expense_report_merge_attachment
# Modules renamed in version 18.0
renamed:
# Refactor bank-payment-alternative
- old: account_payment_mode
new: account_payment_base_oca
- old: account_payment_sale
new: account_payment_base_oca_sale
- old: account_payment_order
new: account_payment_batch_oca
- old: account_payment_order_tier_validation
new: account_payment_batch_oca_tier_validation
- old: account_banking_pain_base
new: account_payment_sepa_base
- old: account_banking_sepa_credit_transfer
new: account_payment_sepa_credit_transfer
- old: account_banking_mandate
new: account_payment_mandate
- old: account_banking_mandate_sale
new: account_payment_mandate_sale
- old: account_banking_sepa_direct_debit
new: account_payment_sepa_direct_debit
# End refactor
- old: base_delivery_carrier_label
new: delivery_shipping_label_default
- old: l10n_fr_oca
new: l10n_fr_account_oca

408
versions/18.0/post_upgrade.sh Executable file
View File

@@ -0,0 +1,408 @@
#!/bin/bash
set -euo pipefail
echo "Post migration to 18.0..."
# ============================================================================
# BANK-PAYMENT -> BANK-PAYMENT-ALTERNATIVE DATA MIGRATION
# Source PR: https://github.com/OCA/bank-payment-alternative/pull/42
#
# Only executed if account_payment_mode table exists (module was installed).
# ============================================================================
# Check if account_payment_mode table exists (meaning the module was installed before migration)
BANK_PAYMENT_TABLE_EXISTS=$(query_postgres_container "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'account_payment_mode';" ou18 2>/dev/null | grep -E '^\s*[0-9]+' | tr -d ' ' || echo "0")
if [ "$BANK_PAYMENT_TABLE_EXISTS" -gt 0 ]; then
echo "Table account_payment_mode exists, proceeding with bank-payment data migration..."
BANK_PAYMENT_POST_SQL=$(cat <<'EOF'
DO $$
DECLARE
mode_rec RECORD;
new_line_id INTEGER;
journal_rec RECORD;
BEGIN
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'account_payment_mode') THEN
RAISE NOTICE 'No account_payment_mode table found, skipping bank-payment migration';
RETURN;
END IF;
RAISE NOTICE 'Starting bank-payment to bank-payment-alternative migration...';
ALTER TABLE account_payment_method_line
ADD COLUMN IF NOT EXISTS old_payment_mode_id INT,
ADD COLUMN IF NOT EXISTS old_refund_payment_mode_id INT;
FOR mode_rec IN
SELECT id, name, company_id, payment_method_id,
fixed_journal_id AS journal_id, bank_account_link,
create_date, create_uid, write_date, write_uid,
show_bank_account, refund_payment_mode_id, active
FROM account_payment_mode
LOOP
INSERT INTO account_payment_method_line (
name, payment_method_id, bank_account_link, journal_id,
selectable, company_id, create_uid, create_date,
write_uid, write_date, show_bank_account,
old_payment_mode_id, old_refund_payment_mode_id, active
) VALUES (
to_jsonb(mode_rec.name),
mode_rec.payment_method_id,
mode_rec.bank_account_link,
mode_rec.journal_id,
true,
mode_rec.company_id,
mode_rec.create_uid,
mode_rec.create_date,
mode_rec.write_uid,
mode_rec.write_date,
mode_rec.show_bank_account,
mode_rec.id,
mode_rec.refund_payment_mode_id,
mode_rec.active
) RETURNING id INTO new_line_id;
IF mode_rec.bank_account_link = 'variable' THEN
IF EXISTS (SELECT FROM information_schema.tables
WHERE table_name = 'account_journal_account_payment_method_line_rel') THEN
FOR journal_rec IN
SELECT rel.journal_id
FROM account_payment_mode_variable_journal_rel rel
WHERE rel.payment_mode_id = mode_rec.id
LOOP
INSERT INTO account_journal_account_payment_method_line_rel
(account_payment_method_line_id, account_journal_id)
VALUES (new_line_id, journal_rec.journal_id)
ON CONFLICT DO NOTHING;
END LOOP;
END IF;
END IF;
RAISE NOTICE 'Migrated payment mode % -> payment method line %', mode_rec.id, new_line_id;
END LOOP;
UPDATE account_payment_method_line apml
SET refund_payment_method_line_id = apml2.id
FROM account_payment_method_line apml2
WHERE apml.old_refund_payment_mode_id IS NOT NULL
AND apml.old_refund_payment_mode_id = apml2.old_payment_mode_id;
UPDATE account_move am
SET preferred_payment_method_line_id = apml.id
FROM account_payment_mode apm, account_payment_method_line apml
WHERE am.payment_mode_id = apm.id
AND apm.id = apml.old_payment_mode_id
AND am.preferred_payment_method_line_id IS NULL;
RAISE NOTICE 'account_payment_base_oca migration completed';
END $$;
EOF
)
echo "Executing bank-payment base migration..."
query_postgres_container "$BANK_PAYMENT_POST_SQL" ou18 || exit 1
BANK_PAYMENT_BATCH_SQL=$(cat <<'EOF'
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'account_payment_mode') THEN
RETURN;
END IF;
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'account_payment_order') THEN
RAISE NOTICE 'No account_payment_order table, skipping batch migration';
RETURN;
END IF;
RAISE NOTICE 'Starting account_payment_batch_oca migration...';
IF EXISTS (SELECT FROM information_schema.columns
WHERE table_name = 'account_payment_method' AND column_name = 'payment_order_only') THEN
UPDATE account_payment_method
SET payment_order_ok = payment_order_only
WHERE payment_order_only IS NOT NULL;
END IF;
UPDATE account_payment_method_line apml
SET payment_order_ok = apm.payment_order_ok,
no_debit_before_maturity = apm.no_debit_before_maturity,
default_payment_mode = apm.default_payment_mode,
default_invoice = apm.default_invoice,
default_target_move = apm.default_target_move,
default_date_type = apm.default_date_type,
default_date_prefered = apm.default_date_prefered,
group_lines = apm.group_lines
FROM account_payment_mode apm
WHERE apml.old_payment_mode_id IS NOT NULL
AND apm.id = apml.old_payment_mode_id;
IF EXISTS (SELECT FROM information_schema.tables
WHERE table_name = 'account_journal_account_payment_method_line_rel') THEN
DELETE FROM account_journal_account_payment_method_line_rel
WHERE account_payment_method_line_id IN (
SELECT id FROM account_payment_method_line WHERE old_payment_mode_id IS NOT NULL
);
INSERT INTO account_journal_account_payment_method_line_rel
(account_payment_method_line_id, account_journal_id)
SELECT apml.id, rel.account_journal_id
FROM account_journal_account_payment_mode_rel rel
JOIN account_payment_method_line apml ON rel.account_payment_mode_id = apml.old_payment_mode_id
ON CONFLICT DO NOTHING;
END IF;
UPDATE account_payment_order apo
SET payment_method_line_id = apml.id,
payment_method_code = apm_method.code
FROM account_payment_method_line apml,
account_payment_mode apm,
account_payment_method apm_method
WHERE apo.payment_mode_id = apm.id
AND apml.old_payment_mode_id = apm.id
AND apm_method.id = apml.payment_method_id;
RAISE NOTICE 'account_payment_batch_oca migration completed';
RAISE NOTICE 'NOTE: Payment lots for open orders must be generated manually via Odoo UI or script';
END $$;
EOF
)
echo "Executing bank-payment batch migration..."
query_postgres_container "$BANK_PAYMENT_BATCH_SQL" ou18 || exit 1
else
echo "Table account_payment_mode not found, skipping bank-payment migration."
fi
# ============================================================================
# FIX: stock_picking name='/' and missing POS picking type sequences
#
# Two issues can occur after migration:
#
# 1. stock_picking records with name='/' violate the new V18 unique constraint
# stock_picking_name_uniq (name, company_id). In previous versions this
# constraint did not exist, so multiple pickings could share name='/'.
#
# 2. POS picking types (pos_type_id on stock_warehouse) may lack a sequence_id.
# In V18, stock.picking.create() assigns the name from picking_type.sequence_id,
# but if sequence_id is NULL the name stays '/' and the unique constraint is
# violated on every new POS payment.
#
# The standard _create_missing_pos_picking_types() only fixes warehouses where
# pos_type_id is NULL — it does NOT fix existing pos_type_id records that are
# missing their sequence_id.
#
# Only executed if stock module tables exist.
# ============================================================================
STOCK_PICKING_TABLE_EXISTS=$(query_postgres_container "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'stock_picking';" ou18 2>/dev/null | grep -E '^\s*[0-9]+' | tr -d ' ' || echo "0")
if [ "$STOCK_PICKING_TABLE_EXISTS" -gt 0 ]; then
echo "stock_picking table exists, proceeding with POS picking fixes..."
# --- Step 1: Rename stock_picking records with name='/' ---
FIX_SLASH_PICKING_NAMES_SQL=$(cat <<'EOF'
DO $$
DECLARE
slash_count INTEGER;
BEGIN
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'stock_picking') THEN
RAISE NOTICE 'stock_picking table not found, skipping slash name fix';
RETURN;
END IF;
SELECT COUNT(*) INTO slash_count FROM stock_picking WHERE name = '/';
IF slash_count > 0 THEN
UPDATE stock_picking SET name = 'MIGRATED-' || id WHERE name = '/';
RAISE NOTICE 'Renamed % stock_picking record(s) with name=/ to MIGRATED-<id>', slash_count;
ELSE
RAISE NOTICE 'No stock_picking with name=/ found, nothing to rename';
END IF;
END $$;
EOF
)
echo "Fixing stock_picking names with '/'..."
query_postgres_container "$FIX_SLASH_PICKING_NAMES_SQL" ou18 || exit 1
# --- Step 2: Create missing ir.sequence for POS picking types without sequence ---
FIX_POS_PICKING_TYPE_SEQUENCES_SQL=$(cat <<'EOF'
DO $$
DECLARE
pt_rec RECORD;
seq_id INTEGER;
seq_name TEXT;
seq_prefix TEXT;
BEGIN
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'stock_picking_type') THEN
RAISE NOTICE 'stock_picking_type table not found, skipping POS sequence fix';
RETURN;
END IF;
IF NOT EXISTS (SELECT FROM information_schema.columns
WHERE table_name = 'stock_warehouse' AND column_name = 'pos_type_id') THEN
RAISE NOTICE 'stock_warehouse.pos_type_id column not found (point_of_sale not installed), skipping POS sequence fix';
RETURN;
END IF;
FOR pt_rec IN
SELECT spt.id AS picking_type_id,
spt.name AS picking_type_name,
COALESCE(spt.sequence_code, 'POS') AS sequence_code,
sw.code AS warehouse_code,
sw.name AS warehouse_name,
sw.company_id
FROM stock_picking_type spt
JOIN stock_warehouse sw ON sw.pos_type_id = spt.id
WHERE spt.sequence_id IS NULL OR spt.sequence_id = 0
LOOP
seq_prefix := pt_rec.warehouse_code || '/' || pt_rec.sequence_code || '/';
seq_name := pt_rec.warehouse_name || ' Picking POS';
INSERT INTO ir_sequence (
name, prefix, padding, company_id, implementation,
number_increment, number_next, active
) VALUES (
seq_name, seq_prefix, 5, pt_rec.company_id, 'standard',
1, 1, true
) RETURNING id INTO seq_id;
UPDATE stock_picking_type
SET sequence_id = seq_id
WHERE id = pt_rec.picking_type_id;
RAISE NOTICE 'Created ir.sequence % (%) for POS picking type % (ID:%) on warehouse %',
seq_name, seq_id, pt_rec.picking_type_name, pt_rec.picking_type_id, pt_rec.warehouse_name;
END LOOP;
END $$;
EOF
)
echo "Creating missing POS picking type sequences..."
query_postgres_container "$FIX_POS_PICKING_TYPE_SEQUENCES_SQL" ou18 || exit 1
# --- Step 3: Create missing PostgreSQL sequences for ir.sequence records ---
# When ir.sequence records are created via SQL INSERT (not ORM), the underlying
# PostgreSQL sequence (ir_sequence_NNN) is NOT created. This causes errors when
# Odoo tries to read or use the sequence.
FIX_MISSING_PG_SEQUENCES_SQL=$(cat <<'EOF'
DO $$
DECLARE
seq_rec RECORD;
seq_pg_name TEXT;
seq_count INTEGER;
BEGIN
seq_count := 0;
FOR seq_rec IN
SELECT id, number_increment, number_next
FROM ir_sequence
WHERE implementation = 'standard'
LOOP
seq_pg_name := 'ir_sequence_' || lpad(seq_rec.id::text, 3, '0');
IF NOT EXISTS (
SELECT 1 FROM pg_class
WHERE relkind = 'S' AND relname = seq_pg_name
) THEN
EXECUTE format('CREATE SEQUENCE %I INCREMENT BY %s START WITH %s',
seq_pg_name, seq_rec.number_increment, seq_rec.number_next);
seq_count := seq_count + 1;
RAISE NOTICE 'Created PostgreSQL sequence %', seq_pg_name;
END IF;
END LOOP;
IF seq_count > 0 THEN
RAISE NOTICE 'Created % missing PostgreSQL sequence(s)', seq_count;
ELSE
RAISE NOTICE 'All PostgreSQL sequences already exist, nothing to create';
END IF;
END $$;
EOF
)
echo "Creating missing PostgreSQL sequences for ir.sequence records..."
query_postgres_container "$FIX_MISSING_PG_SEQUENCES_SQL" ou18 || exit 1
# --- Step 4: Grant permissions on newly created PostgreSQL sequences ---
# Sequences created above are owned by the current DB user, but we ensure
# the Odoo DB user has proper access (needed when created by a different superuser).
GRANT_PG_SEQUENCES_SQL=$(cat <<'EOF'
DO $$
DECLARE
seq_rec RECORD;
seq_pg_name TEXT;
db_user TEXT;
grant_count INTEGER;
BEGIN
-- Determine the Odoo database user from the current connection
SELECT current_user INTO db_user;
grant_count := 0;
FOR seq_rec IN
SELECT id
FROM ir_sequence
WHERE implementation = 'standard'
LOOP
seq_pg_name := 'ir_sequence_' || lpad(seq_rec.id::text, 3, '0');
IF EXISTS (
SELECT 1 FROM pg_class
WHERE relkind = 'S' AND relname = seq_pg_name
) THEN
BEGIN
EXECUTE format('GRANT ALL ON SEQUENCE %I TO %I', seq_pg_name, db_user);
grant_count := grant_count + 1;
EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE 'Cannot GRANT on % (not owner), skipping', seq_pg_name;
END;
END IF;
END LOOP;
RAISE NOTICE 'Granted permissions on % PostgreSQL sequence(s) to %', grant_count, db_user;
END $$;
EOF
)
echo "Granting permissions on PostgreSQL sequences..."
query_postgres_container "$GRANT_PG_SEQUENCES_SQL" ou18 || exit 1
# --- Step 5: Verification ---
VERIFY_POS_FIXES_SQL=$(cat <<'EOF'
DO $$
DECLARE
slash_count INTEGER;
missing_seq_count INTEGER;
BEGIN
-- Check remaining pickings with name='/'
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'stock_picking') THEN
SELECT COUNT(*) INTO slash_count FROM stock_picking WHERE name = '/';
IF slash_count > 0 THEN
RAISE WARNING 'Still % stock_picking record(s) with name=/', slash_count;
ELSE
RAISE NOTICE 'OK: No stock_picking with name=/ remaining';
END IF;
END IF;
-- Check POS picking types without sequence
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'stock_picking_type')
AND EXISTS (SELECT FROM information_schema.columns
WHERE table_name = 'stock_warehouse' AND column_name = 'pos_type_id') THEN
SELECT COUNT(*) INTO missing_seq_count
FROM stock_picking_type spt
JOIN stock_warehouse sw ON sw.pos_type_id = spt.id
WHERE spt.sequence_id IS NULL OR spt.sequence_id = 0;
IF missing_seq_count > 0 THEN
RAISE WARNING 'Still % POS picking type(s) without sequence_id', missing_seq_count;
ELSE
RAISE NOTICE 'OK: All POS picking types have a sequence_id';
END IF;
END IF;
END $$;
EOF
)
echo "Verifying POS picking fixes..."
query_postgres_container "$VERIFY_POS_FIXES_SQL" ou18 || exit 1
else
echo "stock_picking table not found, skipping POS picking fixes."
fi
echo "Post migration to 18.0 completed!"

270
versions/18.0/pre_upgrade.sh Executable file
View File

@@ -0,0 +1,270 @@
#!/bin/bash
set -euo pipefail
echo "Prepare migration to 18.0..."
# Copy database
copy_database ou17 ou18 ou18 || exit 1
# ============================================================================
# REMOVE contract_payment_mode (depends on obsolete account_payment_partner)
#
# contract_payment_mode v18 still depends on account_payment_partner (deleted
# by the bank-payment renaming below) and uses the obsolete account.payment.mode
# model. No v18 replacement exists — account_payment_base_oca provides no
# contract integration. Only 2 contracts had payment_mode_id set, and the
# migration to the new payment.method.line model is not supported.
#
# MUST run BEFORE the bank-payment renaming and BEFORE OpenUpgrade's
# button_upgrade() which would otherwise crash on the missing dependency.
#
# We DELETE the module entirely (like the bank-payment merged_modules pattern)
# rather than marking it 'to remove', because Odoo's "Transient module states
# were reset" in load_modules() converts 'to remove' -> 'installed', causing
# button_upgrade() to re-parse the missing account_payment_partner dependency
# and abort the registry load. Deleting the row + ir_model_data + dependencies
# ensures OpenUpgrade's update_list() rediscovers the physical addon as a fresh
# 'uninstalled' module that button_upgrade() never touches.
# ============================================================================
contract_payment_mode_sql=$(cat <<'EOF'
DO $$
DECLARE
mod_id INTEGER;
BEGIN
SELECT id INTO mod_id FROM ir_module_module WHERE name = 'contract_payment_mode';
IF mod_id IS NOT NULL THEN
DELETE FROM ir_module_module_dependency WHERE module_id = mod_id;
DELETE FROM ir_model_data WHERE module = 'contract_payment_mode';
DELETE FROM ir_module_module WHERE id = mod_id;
-- Rename the module's own external ID (base.module_contract_payment_mode)
-- so update_list() can rediscover the physical contract_payment_mode addon
-- (which still exists in v18 addons path) without conflicting on
-- (base, module_contract_payment_mode). The orphaned xmlid pointed to
-- the deleted ir.module.module row and has no other references.
UPDATE ir_model_data
SET name = 'module_contract_payment_mode_removed'
WHERE module = 'base' AND name = 'module_contract_payment_mode';
RAISE NOTICE 'Removed contract_payment_mode module (depends on obsolete account_payment_partner)';
ELSE
RAISE NOTICE 'contract_payment_mode not found, skipping';
END IF;
END $$;
EOF
)
echo "Removing contract_payment_mode module..."
query_postgres_container "$contract_payment_mode_sql" ou18 || exit 1
# ============================================================================
# BANK-PAYMENT -> BANK-PAYMENT-ALTERNATIVE MODULE RENAMING
# Migration from OCA/bank-payment to OCA/bank-payment-alternative
# Source PR: https://github.com/OCA/bank-payment-alternative/pull/42
#
# This renaming MUST be done BEFORE OpenUpgrade runs, so that the migration
# scripts in the new modules (account_payment_base_oca, account_payment_batch_oca)
# can properly migrate the data.
#
# Only executed if account_payment_mode module was installed before migration.
# ============================================================================
# Check if account_payment_mode module is installed
BANK_PAYMENT_INSTALLED=$(query_postgres_container "SELECT COUNT(*) FROM ir_module_module WHERE name = 'account_payment_mode' AND state = 'installed';" ou18 2>/dev/null | grep -E '^\s*[0-9]+' | tr -d ' ' || echo "0")
if [ "$BANK_PAYMENT_INSTALLED" -gt 0 ]; then
echo "Module account_payment_mode is installed, proceeding with bank-payment migration..."
BANK_PAYMENT_RENAME_SQL=$(cat <<'EOF'
DO $$
DECLARE
renamed_modules TEXT[][] := ARRAY[
['account_payment_mode', 'account_payment_base_oca'],
['account_banking_pain_base', 'account_payment_sepa_base'],
['account_banking_sepa_credit_transfer', 'account_payment_sepa_credit_transfer'],
['account_payment_order', 'account_payment_batch_oca']
];
merged_modules TEXT[][] := ARRAY[
['account_payment_partner', 'account_payment_base_oca']
];
old_name TEXT;
new_name TEXT;
old_module_id INTEGER;
deleted_count INTEGER;
BEGIN
FOR i IN 1..array_length(renamed_modules, 1) LOOP
old_name := renamed_modules[i][1];
new_name := renamed_modules[i][2];
SELECT id INTO old_module_id FROM ir_module_module WHERE name = old_name;
IF old_module_id IS NOT NULL THEN
RAISE NOTICE 'Renaming module: % -> %', old_name, new_name;
UPDATE ir_module_module SET name = new_name WHERE name = old_name;
UPDATE ir_model_data SET module = new_name WHERE module = old_name;
UPDATE ir_module_module_dependency SET name = new_name WHERE name = old_name;
END IF;
END LOOP;
FOR i IN 1..array_length(merged_modules, 1) LOOP
old_name := merged_modules[i][1];
new_name := merged_modules[i][2];
SELECT id INTO old_module_id FROM ir_module_module WHERE name = old_name;
IF old_module_id IS NOT NULL THEN
RAISE NOTICE 'Merging module: % -> %', old_name, new_name;
DELETE FROM ir_model_data
WHERE module = old_name
AND name IN (SELECT name FROM ir_model_data WHERE module = new_name);
GET DIAGNOSTICS deleted_count = ROW_COUNT;
IF deleted_count > 0 THEN
RAISE NOTICE ' Deleted % duplicate ir_model_data records', deleted_count;
END IF;
UPDATE ir_model_data SET module = new_name WHERE module = old_name;
UPDATE ir_module_module_dependency SET name = new_name WHERE name = old_name;
UPDATE ir_module_module SET state = 'uninstalled' WHERE name = old_name;
DELETE FROM ir_module_module WHERE name = old_name;
END IF;
END LOOP;
END $$;
EOF
)
echo "Executing bank-payment module renaming..."
query_postgres_container "$BANK_PAYMENT_RENAME_SQL" ou18 || exit 1
BANK_PAYMENT_PRE_SQL=$(cat <<'EOF'
UPDATE ir_model_data
SET noupdate = false
WHERE module = 'account_payment_base_oca'
AND name = 'view_account_invoice_report_search';
EOF
)
echo "Executing bank-payment pre-migration..."
query_postgres_container "$BANK_PAYMENT_PRE_SQL" ou18 || exit 1
else
echo "Module account_payment_mode not installed, skipping bank-payment migration."
fi
# ============================================================================
# ACCOUNT_FACTORING -> ACCOUNT_FACTORING_OCA MODULE RENAMING
#
# account_factoring (depends on account_payment_partner + account_payment_mode)
# is replaced by account_factoring_oca (depends on account_payment_base_oca
# + account_payment_base_oca_sale) in v18.
#
# Both modules have rigorously identical stored fields:
# - account_journal: is_factor, factor_fee, factor_holdback_percent,
# factor_partner_id, factor_fee_account_id, factor_holdback_account_id,
# factor_limit_holdback_account_id, factor_tax_id, factor_validation
# - account_move: factor_transfer_id, factor_payment_id,
# payment_state_with_factor
# - res_partner: factor_credit_limit
#
# Renaming (not uninstalling) preserves the 4910 invoice factor statuses,
# 311 partner credit limits, and journal factor config. OpenUpgrade will
# treat account_factoring_oca as already installed and adopt the existing
# columns via ORM _auto_init.
#
# MUST run AFTER the bank-payment renaming block above, which creates
# account_payment_base_oca in the DB.
# ============================================================================
ACCOUNT_FACTORING_RENAME_SQL=$(cat <<'EOF'
DO $$
DECLARE
factor_module_id INTEGER;
BEGIN
SELECT id INTO factor_module_id FROM ir_module_module WHERE name = 'account_factoring';
IF factor_module_id IS NOT NULL THEN
-- Rename the module itself
UPDATE ir_module_module SET name = 'account_factoring_oca' WHERE name = 'account_factoring';
UPDATE ir_model_data SET module = 'account_factoring_oca' WHERE module = 'account_factoring';
-- Rename the module's own external ID (base.module_account_factoring)
-- so OpenUpgrade can rediscover the physical account_factoring addon dir
-- (which still exists in v18 addons path) without conflicting on
-- (base, module_account_factoring) — that xmlid now points to the
-- renamed module under its new name.
UPDATE ir_model_data
SET name = 'module_account_factoring_oca'
WHERE module = 'base' AND name = 'module_account_factoring';
-- Redirect dependencies pointing TO the old name
UPDATE ir_module_module_dependency SET name = 'account_factoring_oca' WHERE name = 'account_factoring';
-- Replace obsolete dependencies (account_payment_partner + account_payment_mode
-- no longer exist in v18 addons) with the new OCA equivalents
DELETE FROM ir_module_module_dependency
WHERE module_id = factor_module_id
AND name IN ('account_payment_partner', 'account_payment_mode');
INSERT INTO ir_module_module_dependency (name, module_id)
SELECT 'account_payment_base_oca', factor_module_id
WHERE NOT EXISTS (
SELECT 1 FROM ir_module_module_dependency
WHERE module_id = factor_module_id AND name = 'account_payment_base_oca'
);
INSERT INTO ir_module_module_dependency (name, module_id)
SELECT 'account_payment_base_oca_sale', factor_module_id
WHERE NOT EXISTS (
SELECT 1 FROM ir_module_module_dependency
WHERE module_id = factor_module_id AND name = 'account_payment_base_oca_sale'
);
RAISE NOTICE 'Renamed account_factoring -> account_factoring_oca (data preserved)';
ELSE
RAISE NOTICE 'account_factoring not found, skipping rename';
END IF;
END $$;
EOF
)
echo "Executing account_factoring -> account_factoring_oca renaming..."
query_postgres_container "$ACCOUNT_FACTORING_RENAME_SQL" ou18 || exit 1
# ============================================================================
# FIX: Rename company-dependent columns before OpenUpgrade runs
# In Odoo 18, company-dependent fields are stored as JSONB columns.
# The ORM's _auto_init() tries to convert existing VARCHAR columns to JSONB,
# which fails if the data is not valid JSON.
# Solution: Rename the columns so Odoo creates new JSONB columns, then
# OpenUpgrade's convert_company_dependent() will migrate the data from ir.property.
#
# See: https://github.com/OCA/OpenUpgrade/issues/5449
# ============================================================================
COMPANY_DEPENDENT_FIX_SQL=$(cat <<'EOF'
DO $$
BEGIN
-- res.partner.barcode (base module)
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'res_partner' AND column_name = 'barcode') THEN
ALTER TABLE res_partner RENAME COLUMN barcode TO openupgrade_legacy_18_0_barcode;
RAISE NOTICE 'Renamed res_partner.barcode for company-dependent conversion';
END IF;
END $$;
EOF
)
echo "Fixing company-dependent columns for Odoo 18..."
query_postgres_container "$COMPANY_DEPENDENT_FIX_SQL" ou18 || exit 1
# Execute SQL pre-migration commands
PRE_MIGRATE_SQL=$(cat <<'EOF'
UPDATE account_analytic_plan SET default_applicability=NULL WHERE default_applicability='optional';
DELETE FROM ir_ui_view WHERE model = 'res.config.settings';
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL"
query_postgres_container "$PRE_MIGRATE_SQL" ou18 || exit 1
# NOTE: Missing generic PCG account xml-ids expected by l10n_fr_account (e.g.
# account.<company>_pcg_607_account / _pcg_707_account) are recreated generically
# by the ORM post-migration script
# versions/18.0/scripts/l10n_fr_account/18.0.2.2/post-migration-0k-fix-property-account-xmlids.py
# which runs at stage "post" of l10n_fr_account, before the native "end" script
# end-migrate_update_taxes.py that would otherwise crash on the missing xml-id.
# Copy filestores
copy_filestore ou17 ou17 ou18 ou18 || exit 1
echo "Ready for migration to 18.0!"

View File

@@ -0,0 +1,33 @@
<?xml version='1.0' encoding='utf-8'?>
<!--
0k / Élabore customization of the French VAT report (l10n_fr_account).
Adds an editable "adjustment" expression on box 25 (VAT credit) and
integrates it into the box 25 balance formula. Historically this lived in a
full copy of l10n_fr_account/data/tax_report_data.xml that shadowed the core
module; it is now applied the OpenUpgrade way, via load_data() from
post-migration.py, without duplicating the core module.
Loaded with mode="init"; records use the module's own xml ids
(l10n_fr_account.*). tax_report_25_formula already exists in core and is
updated here; tax_report_25_adjustment is new and gets created.
-->
<odoo>
<!-- Update the box 25 balance formula to include the adjustment -->
<record id="tax_report_25_formula" model="account.report.expression">
<field name="report_line_id" ref="l10n_fr_account.tax_report_25"/>
<field name="label">balance</field>
<field name="engine">aggregation</field>
<field name="formula">box_23.balance - box_16.balance + box_25.adjustment</field>
<field name="subformula">if_above(EUR(0))</field>
</record>
<!-- New editable external "adjustment" expression on box 25 -->
<record id="tax_report_25_adjustment" model="account.report.expression">
<field name="report_line_id" ref="l10n_fr_account.tax_report_25"/>
<field name="label">adjustment</field>
<field name="engine">external</field>
<field name="formula">sum</field>
<field name="subformula">editable;rounding=0</field>
</record>
</odoo>

View File

@@ -0,0 +1,195 @@
# Copyright 0k / Élabore
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
#
# Custom OpenUpgrade migration script, injected through a dedicated
# --upgrade-path (see versions/18.0/upgrade.sh). Runs at stage "post" of
# l10n_fr_account/18.0.2.2, i.e. BEFORE the global "end" stage where the native
# l10n_fr_account/migrations/2.1/end-migrate_update_taxes.py runs.
#
# Problem
# -------
# end-migrate_update_taxes.py calls account.chart.template.try_loading('fr', ...).
# During _post_load_data(), Odoo 18 resolves the French template's default
# accounts via self.ref() WITHOUT raise_if_not_found=False, at three places:
# - property_account_income_categ_id (pcg_707_account) -> chart_template.py:711
# - property_account_expense_categ_id (pcg_607_account) -> chart_template.py:714
# - every _get_property_accounts()/additional_properties account -> :746
# If any expected account xml-id "account.<company>_<key>" is missing, it raises:
# ValueError: External ID not found in the system: account.1_pcg_607_account
# which aborts the whole registry load.
#
# This happens when a database customized its chart of accounts: the generic PCG
# account was deleted (e.g. 607000) or recreated without its xml-id (e.g. 707000).
#
# Fix (generic, not hardcoded to 607/707)
# ---------------------------------------
# For every company with a chart_template, we read the list of account xml-ids
# the template expects (dynamically, from the template ORM API), and for each one
# that is missing in the DB we recreate the "account.<company>_<key>" ir.model.data
# pointing to the best matching real account:
# 1. the account whose code matches exactly the template account code,
# 2. otherwise the company account whose code shares the longest common prefix
# with the template code (e.g. 607000 -> 607100),
# 3. otherwise nothing (logged as a warning; never blocks).
# No account is modified; only missing xml-ids are (re)created. Idempotent.
import logging
from openupgradelib import openupgrade
_logger = logging.getLogger(__name__)
def _collect_property_account_xmlids(template, code, accounts_csv):
"""Return the set of template account xml-ids referenced as property accounts.
Sources:
- template._get_property_accounts({}) (receivable, payable, expense, income, ...)
- the template_data dict (property_account_*_id keys)
- additional_properties declared on res.company template data
Only keep values that are real account xml-ids of this template, i.e. present
in accounts_csv (this filters out journals and dotted external ids).
"""
xmlids = set()
# 1. Base property accounts (model-level defaults)
try:
prop_fields = template._get_property_accounts({})
except Exception as exc: # noqa: BLE001
_logger.warning("Could not read _get_property_accounts: %s", exc)
prop_fields = {}
# 2. Full template data: template_data holds property_account_*_id values,
# res.company holds additional_properties (currency exchange, deferral...).
try:
tdata = template._get_chart_template_data(code)
except Exception as exc: # noqa: BLE001
_logger.warning("Could not read _get_chart_template_data(%s): %s", code, exc)
tdata = {}
candidate_values = []
template_data = tdata.get("template_data", {}) if tdata else {}
for key, value in template_data.items():
if key in prop_fields or key.startswith("property_"):
candidate_values.append(value)
# additional_properties live inside the res.company template values
res_company = tdata.get("res.company", {}) if tdata else {}
for _company_key, company_vals in res_company.items():
if not isinstance(company_vals, dict):
continue
add_props = company_vals.get("additional_properties")
if isinstance(add_props, dict):
candidate_values.extend(add_props.values())
# Keep only plain (non-dotted) xml-ids that are actual accounts of the template
for value in candidate_values:
if isinstance(value, str) and value and "." not in value and value in accounts_csv:
xmlids.add(value)
return xmlids
def _find_best_account(env, company, target_code):
"""Return the best account record for a template account code, or None.
1. exact code match for the company;
2. otherwise the company account whose code shares the longest common prefix
with target_code; ties are broken by smallest code (deterministic).
3. otherwise None.
The exact fallback account matters little functionally: it is only used as a
journal's default_account_id / an ir.default, which stays user-overridable.
The goal is to provide a valid account of the right family so that
try_loading() no longer crashes on a missing external id.
"""
if not target_code:
return None
Account = env["account.account"]
company_domain = [("company_ids", "in", company.id)]
exact = Account.search(company_domain + [("code", "=", target_code)], limit=1)
if exact:
return exact
# Longest common prefix fallback; ties broken by smallest code.
candidates = Account.search(company_domain)
best = None
best_key = None # (prefix_len, negated-code-for-min) -> maximize
for acc in candidates:
code = acc.code or ""
common = 0
for a, b in zip(code, target_code):
if a != b:
break
common += 1
if common == 0:
continue
key = (common, tuple(-ord(c) for c in code)) # longer prefix, then smallest code
if best_key is None or key > best_key:
best = acc
best_key = key
return best
@openupgrade.migrate()
def migrate(env, version):
template = env["account.chart.template"]
companies = env["res.company"].search([("chart_template", "!=", False)])
created = 0
for company in companies:
code = company.chart_template
tmpl = template.with_company(company)
try:
accounts_csv = tmpl._get_account_account(code)
except Exception as exc: # noqa: BLE001
_logger.warning(
"Company %s (%s): cannot read template accounts: %s",
company.id, code, exc,
)
continue
expected = _collect_property_account_xmlids(tmpl, code, accounts_csv)
for tmpl_xmlid in sorted(expected):
full = f"account.{company.id}_{tmpl_xmlid}"
if env.ref(full, raise_if_not_found=False):
continue
if company.parent_ids:
parent_full = (
f"account.{company.parent_ids[0].id}_{tmpl_xmlid}"
)
if env.ref(parent_full, raise_if_not_found=False):
continue
target_code = accounts_csv.get(tmpl_xmlid, {}).get("code")
account = _find_best_account(env, company, target_code)
if not account:
_logger.warning(
"Company %s: no account found for template xml-id %s "
"(code %s); skipping",
company.id, tmpl_xmlid, target_code,
)
continue
env["ir.model.data"].create({
"module": "account",
"name": f"{company.id}_{tmpl_xmlid}",
"model": "account.account",
"res_id": account.id,
"noupdate": True,
})
created += 1
_logger.info(
"Company %s: created xml-id %s -> account %s (%s) for "
"template code %s",
company.id, full, account.id, account.code, target_code,
)
_logger.info(
"[l10n_fr_account] property-account xml-id fix: %s xml-id(s) created",
created,
)

View File

@@ -0,0 +1,20 @@
# Copyright 0k / Élabore
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
#
# Custom OpenUpgrade migration script, injected through a dedicated
# --upgrade-path (see versions/18.0/upgrade.sh). It runs IN ADDITION to the
# native openupgrade_scripts post-migration.py of l10n_fr_account/18.0.2.2.
#
# Loads the 0k customization of the French VAT report (box 25 "adjustment")
# the OpenUpgrade way, without shadowing the core l10n_fr_account module.
from openupgradelib import openupgrade
@openupgrade.migrate()
def migrate(env, version):
# mode="init" (re)creates records marked noupdate that the normal upgrade
# mechanism would skip; it updates existing ones (tax_report_25_formula)
# and creates new ones (tax_report_25_adjustment).
openupgrade.load_data(
env, "l10n_fr_account", "18.0.2.2/noupdate_changes.xml"
)

View File

@@ -0,0 +1,460 @@
# Copyright 0k / Élabore
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
#
# Custom OpenUpgrade migration script, injected through a dedicated
# --upgrade-path (see versions/18.0/upgrade.sh). It runs IN ADDITION to the
# native openupgrade_scripts pre-migration.py of l10n_fr_account/18.0.2.2:
# the framework appends every upgrade path, so this script does not override
# nor duplicate the native one.
from openupgradelib import openupgrade
@openupgrade.migrate()
def migrate(env, version):
"""Fix orphan account.report.expression records before XML data loading.
During migration from V17, some account.report.expression records lose
their ir_model_data entries. When l10n_fr_account loads tax_report_data.xml
in V18, it tries to INSERT these records again, violating the unique
constraint account_report_expression_line_label_uniq (report_line_id, label).
Fix 1: Re-insert missing ir_model_data entries so Odoo performs UPDATE
instead of INSERT when loading the XML data.
Fix 2: Delete orphan expressions that no longer exist in V18 XML
(box_X5, box_Y4, box_Y5, box_Z5 now use aggregation_formula field).
"""
cr = env.cr
# (expr_xmlid, line_xmlid, label)
# Generated from l10n_fr_account/data/tax_report_data.xml (18.0)
expressions = [
("tax_report_A1_tag", "tax_report_A1", "balance"),
("tax_report_A1_tag_rounded", "tax_report_A1", "balance_rounded"),
("tax_report_A1_balance_from_tags", "tax_report_A1", "balance_from_tags"),
("tax_report_A1_adjustment", "tax_report_A1", "adjustment"),
("tax_report_A2_tag", "tax_report_A2", "balance"),
("tax_report_A2_tag_rounded", "tax_report_A2", "balance_rounded"),
("tax_report_A2_balance_from_tags", "tax_report_A2", "balance_from_tags"),
("tax_report_A2_adjustment", "tax_report_A2", "adjustment"),
("tax_report_A3_tag", "tax_report_A3", "balance"),
("tax_report_A3_tag_rounded", "tax_report_A3", "balance_rounded"),
("tax_report_A3_balance_from_tags", "tax_report_A3", "balance_from_tags"),
("tax_report_A3_adjustment", "tax_report_A3", "adjustment"),
("tax_report_A4_tag", "tax_report_A4", "balance"),
("tax_report_A4_tag_rounded", "tax_report_A4", "balance_rounded"),
("tax_report_A4_balance_from_tags", "tax_report_A4", "balance_from_tags"),
("tax_report_A4_adjustment", "tax_report_A4", "adjustment"),
("tax_report_A5_tag", "tax_report_A5", "balance"),
("tax_report_A5_tag_rounded", "tax_report_A5", "balance_rounded"),
("tax_report_A5_balance_from_tags", "tax_report_A5", "balance_from_tags"),
("tax_report_A5_adjustment", "tax_report_A5", "adjustment"),
("tax_report_B1_tag", "tax_report_B1", "balance"),
("tax_report_B1_tag_rounded", "tax_report_B1", "balance_rounded"),
("tax_report_B1_balance_from_tags", "tax_report_B1", "balance_from_tags"),
("tax_report_B1_adjustment", "tax_report_B1", "adjustment"),
("tax_report_B2_tag", "tax_report_B2", "balance"),
("tax_report_B2_tag_rounded", "tax_report_B2", "balance_rounded"),
("tax_report_B2_balance_from_tags", "tax_report_B2", "balance_from_tags"),
("tax_report_B2_adjustment", "tax_report_B2", "adjustment"),
("tax_report_B3_tag", "tax_report_B3", "balance"),
("tax_report_B3_tag_rounded", "tax_report_B3", "balance_rounded"),
("tax_report_B3_balance_from_tags", "tax_report_B3", "balance_from_tags"),
("tax_report_B3_adjustment", "tax_report_B3", "adjustment"),
("tax_report_B4_tag", "tax_report_B4", "balance"),
("tax_report_B4_tag_rounded", "tax_report_B4", "balance_rounded"),
("tax_report_B4_balance_from_tags", "tax_report_B4", "balance_from_tags"),
("tax_report_B4_adjustment", "tax_report_B4", "adjustment"),
("tax_report_B5_tag", "tax_report_B5", "balance"),
("tax_report_B5_tag_rounded", "tax_report_B5", "balance_rounded"),
("tax_report_B5_balance_from_tags", "tax_report_B5", "balance_from_tags"),
("tax_report_B5_adjustment", "tax_report_B5", "adjustment"),
("tax_report_E1_tag", "tax_report_E1", "balance"),
("tax_report_E1_tag_rounded", "tax_report_E1", "balance_rounded"),
("tax_report_E1_balance_from_tags", "tax_report_E1", "balance_from_tags"),
("tax_report_E1_adjustment", "tax_report_E1", "adjustment"),
("tax_report_E2_tag", "tax_report_E2", "balance"),
("tax_report_E2_tag_rounded", "tax_report_E2", "balance_rounded"),
("tax_report_E2_balance_from_tags", "tax_report_E2", "balance_from_tags"),
("tax_report_E2_adjustment", "tax_report_E2", "adjustment"),
("tax_report_E3_tag", "tax_report_E3", "balance"),
("tax_report_E3_tag_rounded", "tax_report_E3", "balance_rounded"),
("tax_report_E3_balance_from_tags", "tax_report_E3", "balance_from_tags"),
("tax_report_E3_adjustment", "tax_report_E3", "adjustment"),
("tax_report_E4_tag", "tax_report_E4", "balance"),
("tax_report_E4_tag_rounded", "tax_report_E4", "balance_rounded"),
("tax_report_E4_balance_from_tags", "tax_report_E4", "balance_from_tags"),
("tax_report_E4_adjustment", "tax_report_E4", "adjustment"),
("tax_report_E5_tag", "tax_report_E5", "balance"),
("tax_report_E5_tag_rounded", "tax_report_E5", "balance_rounded"),
("tax_report_E5_balance_from_tags", "tax_report_E5", "balance_from_tags"),
("tax_report_E5_adjustment", "tax_report_E5", "adjustment"),
("tax_report_E6_tag", "tax_report_E6", "balance"),
("tax_report_E6_tag_rounded", "tax_report_E6", "balance_rounded"),
("tax_report_E6_balance_from_tags", "tax_report_E6", "balance_from_tags"),
("tax_report_E6_adjustment", "tax_report_E6", "adjustment"),
("tax_report_F1_tag", "tax_report_F1", "balance"),
("tax_report_F1_tag_rounded", "tax_report_F1", "balance_rounded"),
("tax_report_F1_balance_from_tags", "tax_report_F1", "balance_from_tags"),
("tax_report_F1_adjustment", "tax_report_F1", "adjustment"),
("tax_report_F2_tag", "tax_report_F2", "balance"),
("tax_report_F2_tag_rounded", "tax_report_F2", "balance_rounded"),
("tax_report_F2_balance_from_tags", "tax_report_F2", "balance_from_tags"),
("tax_report_F2_adjustment", "tax_report_F2", "adjustment"),
("tax_report_F3_tag", "tax_report_F3", "balance"),
("tax_report_F3_tag_rounded", "tax_report_F3", "balance_rounded"),
("tax_report_F3_balance_from_tags", "tax_report_F3", "balance_from_tags"),
("tax_report_F3_adjustment", "tax_report_F3", "adjustment"),
("tax_report_F4_tag", "tax_report_F4", "balance"),
("tax_report_F4_tag_rounded", "tax_report_F4", "balance_rounded"),
("tax_report_F4_balance_from_tags", "tax_report_F4", "balance_from_tags"),
("tax_report_F4_adjustment", "tax_report_F4", "adjustment"),
("tax_report_F5_tag", "tax_report_F5", "balance"),
("tax_report_F5_tag_rounded", "tax_report_F5", "balance_rounded"),
("tax_report_F5_balance_from_tags", "tax_report_F5", "balance_from_tags"),
("tax_report_F5_adjustment", "tax_report_F5", "adjustment"),
("tax_report_F6_tag", "tax_report_F6", "balance"),
("tax_report_F6_tag_rounded", "tax_report_F6", "balance_rounded"),
("tax_report_F6_balance_from_tags", "tax_report_F6", "balance_from_tags"),
("tax_report_F6_adjustment", "tax_report_F6", "adjustment"),
("tax_report_F7_tag", "tax_report_F7", "balance"),
("tax_report_F7_tag_rounded", "tax_report_F7", "balance_rounded"),
("tax_report_F7_balance_from_tags", "tax_report_F7", "balance_from_tags"),
("tax_report_F7_adjustment", "tax_report_F7", "adjustment"),
("tax_report_F8_tag", "tax_report_F8", "balance"),
("tax_report_F8_tag_rounded", "tax_report_F8", "balance_rounded"),
("tax_report_F8_balance_from_tags", "tax_report_F8", "balance_from_tags"),
("tax_report_F8_adjustment", "tax_report_F8", "adjustment"),
("tax_report_F9_tag", "tax_report_F9", "balance"),
("tax_report_F9_tag_rounded", "tax_report_F9", "balance_rounded"),
("tax_report_F9_balance_from_tags", "tax_report_F9", "balance_from_tags"),
("tax_report_F9_adjustment", "tax_report_F9", "adjustment"),
("tax_report_08_base_tag", "tax_report_08_base", "balance"),
("tax_report_08_base_tag_rounded", "tax_report_08_base", "balance_rounded"),
("tax_report_08_base_balance_from_tags", "tax_report_08_base", "balance_from_tags"),
("tax_report_08_base_adjustment", "tax_report_08_base", "adjustment"),
("tax_report_08_taxe_tag", "tax_report_08_taxe", "balance"),
("tax_report_08_taxe_tag_rounded", "tax_report_08_taxe", "balance_rounded"),
("tax_report_08_taxe_tag_no_rounding", "tax_report_08_taxe", "balance_from_tags"),
("tax_report_09_base_tag", "tax_report_09_base", "balance"),
("tax_report_09_base_tag_rounded", "tax_report_09_base", "balance_rounded"),
("tax_report_09_base_balance_from_tags", "tax_report_09_base", "balance_from_tags"),
("tax_report_09_base_adjustment", "tax_report_09_base", "adjustment"),
("tax_report_09_taxe_tag", "tax_report_09_taxe", "balance"),
("tax_report_09_taxe_tag_rounded", "tax_report_09_taxe", "balance_rounded"),
("tax_report_09_taxe_tag_no_rounding", "tax_report_09_taxe", "balance_from_tags"),
("tax_report_9B_base_tag", "tax_report_9B_base", "balance"),
("tax_report_9B_base_tag_rounded", "tax_report_9B_base", "balance_rounded"),
("tax_report_9B_base_balance_from_tags", "tax_report_9B_base", "balance_from_tags"),
("tax_report_9B_base_adjustment", "tax_report_9B_base", "adjustment"),
("tax_report_9B_taxe_tag", "tax_report_9B_taxe", "balance"),
("tax_report_9B_taxe_tag_rounded", "tax_report_9B_taxe", "balance_rounded"),
("tax_report_9B_taxe_tag_no_rounding", "tax_report_9B_taxe", "balance_from_tags"),
("tax_report_10_base_tag", "tax_report_10_base", "balance"),
("tax_report_10_base_tag_rounded", "tax_report_10_base", "balance_rounded"),
("tax_report_10_base_balance_from_tags", "tax_report_10_base", "balance_from_tags"),
("tax_report_10_base_adjustment", "tax_report_10_base", "adjustment"),
("tax_report_10_taxe_tag", "tax_report_10_taxe", "balance"),
("tax_report_10_taxe_tag_balance", "tax_report_10_taxe", "balance_rounded"),
("tax_report_10_taxe_tag_no_rounding", "tax_report_10_taxe", "balance_from_tags"),
("tax_report_11_base_tag", "tax_report_11_base", "balance"),
("tax_report_11_base_tag_rounded", "tax_report_11_base", "balance_rounded"),
("tax_report_11_base_balance_from_tags", "tax_report_11_base", "balance_from_tags"),
("tax_report_11_base_adjustment", "tax_report_11_base", "adjustment"),
("tax_report_11_taxe_tag", "tax_report_11_taxe", "balance"),
("tax_report_11_taxe_tag_rounded", "tax_report_11_taxe", "balance_rounded"),
("tax_report_11_taxe_tag_no_rounding", "tax_report_11_taxe", "balance_from_tags"),
("tax_report_T1_base_tag", "tax_report_T1_base", "balance"),
("tax_report_T1_base_tag_rounded", "tax_report_T1_base", "balance_rounded"),
("tax_report_T1_base_balance_from_tags", "tax_report_T1_base", "balance_from_tags"),
("tax_report_T1_base_adjustment", "tax_report_T1_base", "adjustment"),
("tax_report_T1_taxe_tag", "tax_report_T1_taxe", "balance"),
("tax_report_T1_taxe_tag_rounded", "tax_report_T1_taxe", "balance_rounded"),
("tax_report_T1_taxe_tag_no_rounding", "tax_report_T1_taxe", "balance_from_tags"),
("tax_report_T2_base_tag", "tax_report_T2_base", "balance"),
("tax_report_T2_base_tag_rounded", "tax_report_T2_base", "balance_rounded"),
("tax_report_T2_base_balance_from_tags", "tax_report_T2_base", "balance_from_tags"),
("tax_report_T2_base_adjustment", "tax_report_T2_base", "adjustment"),
("tax_report_T2_taxe_tag", "tax_report_T2_taxe", "balance"),
("tax_report_T2_taxe_tag_rounded", "tax_report_T2_taxe", "balance_rounded"),
("tax_report_T2_taxe_tag_no_rounding", "tax_report_T2_taxe", "balance_from_tags"),
("tax_report_T3_base_tag", "tax_report_T3_base", "balance"),
("tax_report_T3_base_tag_rounded", "tax_report_T3_base", "balance_rounded"),
("tax_report_T3_base_balance_from_tags", "tax_report_T3_base", "balance_from_tags"),
("tax_report_T3_base_adjustment", "tax_report_T3_base", "adjustment"),
("tax_report_T3_taxe_tag", "tax_report_T3_taxe", "balance"),
("tax_report_T3_taxe_tag_rounded", "tax_report_T3_taxe", "balance_rounded"),
("tax_report_T3_taxe_tag_no_rounding", "tax_report_T3_taxe", "balance_from_tags"),
("tax_report_T4_base_tag", "tax_report_T4_base", "balance"),
("tax_report_T4_base_tag_rounded", "tax_report_T4_base", "balance_rounded"),
("tax_report_T4_base_balance_from_tags", "tax_report_T4_base", "balance_from_tags"),
("tax_report_T4_base_adjustment", "tax_report_T4_base", "adjustment"),
("tax_report_T4_taxe_tag", "tax_report_T4_taxe", "balance"),
("tax_report_T4_taxe_tag_balance", "tax_report_T4_taxe", "balance_rounded"),
("tax_report_T4_taxe_tag_no_rounding", "tax_report_T4_taxe", "balance_from_tags"),
("tax_report_T5_base_tag", "tax_report_T5_base", "balance"),
("tax_report_T5_base_tag_rounded", "tax_report_T5_base", "balance_rounded"),
("tax_report_T5_base_balance_from_tags", "tax_report_T5_base", "balance_from_tags"),
("tax_report_T5_base_adjustment", "tax_report_T5_base", "adjustment"),
("tax_report_T5_taxe_tag", "tax_report_T5_taxe", "balance"),
("tax_report_T5_taxe_tag_rounded", "tax_report_T5_taxe", "balance_rounded"),
("tax_report_T5_taxe_tag_no_rounding", "tax_report_T5_taxe", "balance_from_tags"),
("tax_report_T6_base_tag", "tax_report_T6_base", "balance"),
("tax_report_T6_base_tag_balance", "tax_report_T6_base", "balance_rounded"),
("tax_report_T6_base_balance_from_tags", "tax_report_T6_base", "balance_from_tags"),
("tax_report_T6_base_adjustment", "tax_report_T6_base", "adjustment"),
("tax_report_T6_taxe_tag", "tax_report_T6_taxe", "balance"),
("tax_report_T6_taxe_tag_balance", "tax_report_T6_taxe", "balance_rounded"),
("tax_report_T6_taxe_tag_no_rounding", "tax_report_T6_taxe", "balance_from_tags"),
("tax_report_T7_base_tag", "tax_report_T7_base", "balance"),
("tax_report_T7_base_tag_rounded", "tax_report_T7_base", "balance_rounded"),
("tax_report_T7_base_balance_from_tags", "tax_report_T7_base", "balance_from_tags"),
("tax_report_T7_base_adjustment", "tax_report_T7_base", "adjustment"),
("tax_report_T7_taxe_tag", "tax_report_T7_taxe", "balance"),
("tax_report_T7_taxe_balance_rounded", "tax_report_T7_taxe", "balance_rounded"),
("tax_report_T7_taxe_balance_from_tags", "tax_report_T7_taxe", "balance_from_tags"),
("tax_report_13_base_tag", "tax_report_13_base", "balance"),
("tax_report_13_base_tag_rounded", "tax_report_13_base", "balance_rounded"),
("tax_report_13_base_balance_from_tags", "tax_report_13_base", "balance_from_tags"),
("tax_report_13_base_adjustment", "tax_report_13_base", "adjustment"),
("tax_report_13_taxe_tag", "tax_report_13_taxe", "balance"),
("tax_report_13_taxe_balance_rounded", "tax_report_13_taxe", "balance_rounded"),
("tax_report_13_taxe_balance_from_tags", "tax_report_13_taxe", "balance_from_tags"),
("tax_report_14_base_tag", "tax_report_14_base", "balance"),
("tax_report_14_base_tag_rounded", "tax_report_14_base", "balance_rounded"),
("tax_report_14_base_balance_from_tags", "tax_report_14_base", "balance_from_tags"),
("tax_report_14_base_adjustment", "tax_report_14_base", "adjustment"),
("tax_report_14_taxe_tag", "tax_report_14_taxe", "balance"),
("tax_report_14_taxe_balance_rounded", "tax_report_14_taxe", "balance_rounded"),
("tax_report_14_taxe_balance_from_tags", "tax_report_14_taxe", "balance_from_tags"),
("tax_report_P1_base_tag", "tax_report_P1_base", "balance"),
("tax_report_P1_base_tag_rounded", "tax_report_P1_base", "balance_rounded"),
("tax_report_P1_base_balance_from_tags", "tax_report_P1_base", "balance_from_tags"),
("tax_report_P1_base_adjustment", "tax_report_P1_base", "adjustment"),
("tax_report_P1_taxe_tag", "tax_report_P1_taxe", "balance"),
("tax_report_P1_taxe_tag_rounded", "tax_report_P1_taxe", "balance_rounded"),
("tax_report_P1_taxe_tag_no_rounding", "tax_report_P1_taxe", "balance_from_tags"),
("tax_report_P2_base_tag", "tax_report_P2_base", "balance"),
("tax_report_P2_base_tag_rounded", "tax_report_P2_base", "balance_rounded"),
("tax_report_P2_base_balance_from_tags", "tax_report_P2_base", "balance_from_tags"),
("tax_report_P2_base_adjustment", "tax_report_P2_base", "adjustment"),
("tax_report_P2_taxe_tag", "tax_report_P2_taxe", "balance"),
("tax_report_P2_taxe_tag_rounded", "tax_report_P2_taxe", "balance_rounded"),
("tax_report_P2_taxe_tag_no_rounding", "tax_report_P2_taxe", "balance_from_tags"),
("tax_report_I1_base_tag", "tax_report_I1_base", "balance"),
("tax_report_I1_base_tag_rounded", "tax_report_I1_base", "balance_rounded"),
("tax_report_I1_base_balance_from_tags", "tax_report_I1_base", "balance_from_tags"),
("tax_report_I1_base_adjustment", "tax_report_I1_base", "adjustment"),
("tax_report_I1_taxe_tag", "tax_report_I1_taxe", "balance"),
("tax_report_I1_taxe_tag_rounded", "tax_report_I1_taxe", "balance_rounded"),
("tax_report_I1_taxe_tag_no_rounding", "tax_report_I1_taxe", "balance_from_tags"),
("tax_report_I2_base_tag", "tax_report_I2_base", "balance"),
("tax_report_I2_base_tag_rounded", "tax_report_I2_base", "balance_rounded"),
("tax_report_I2_base_balance_from_tags", "tax_report_I2_base", "balance_from_tags"),
("tax_report_I2_base_adjustment", "tax_report_I2_base", "adjustment"),
("tax_report_I2_taxe_tag", "tax_report_I2_taxe", "balance"),
("tax_report_I2_taxe_tag_rounded", "tax_report_I2_taxe", "balance_rounded"),
("tax_report_I2_taxe_tag_no_rounding", "tax_report_I2_taxe", "balance_from_tags"),
("tax_report_I3_base_tag", "tax_report_I3_base", "balance"),
("tax_report_I3_base_tag_rounded", "tax_report_I3_base", "balance_rounded"),
("tax_report_I3_base_balance_from_tags", "tax_report_I3_base", "balance_from_tags"),
("tax_report_I3_base_adjustment", "tax_report_I3_base", "adjustment"),
("tax_report_I3_taxe_tag", "tax_report_I3_taxe", "balance"),
("tax_report_I3_taxe_tag_rounded", "tax_report_I3_taxe", "balance_rounded"),
("tax_report_I3_taxe_tag_no_rounding", "tax_report_I3_taxe", "balance_from_tags"),
("tax_report_I4_base_tag", "tax_report_I4_base", "balance"),
("tax_report_I4_base_tag_rounded", "tax_report_I4_base", "balance_rounded"),
("tax_report_I4_base_balance_from_tags", "tax_report_I4_base", "balance_from_tags"),
("tax_report_I4_base_adjustment", "tax_report_I4_base", "adjustment"),
("tax_report_I4_taxe_tag", "tax_report_I4_taxe", "balance"),
("tax_report_I4_taxe_tag_rounded", "tax_report_I4_taxe", "balance_rounded"),
("tax_report_I4_taxe_tag_no_rounding", "tax_report_I4_taxe", "balance_from_tags"),
("tax_report_I5_base_tag", "tax_report_I5_base", "balance"),
("tax_report_I5_base_tag_rounded", "tax_report_I5_base", "balance_rounded"),
("tax_report_I5_base_balance_from_tags", "tax_report_I5_base", "balance_from_tags"),
("tax_report_I5_base_adjustment", "tax_report_I5_base", "adjustment"),
("tax_report_I5_taxe_tag", "tax_report_I5_taxe", "balance"),
("tax_report_I5_taxe_tag_rounded", "tax_report_I5_taxe", "balance_rounded"),
("tax_report_I5_taxe_tag_no_rounding", "tax_report_I5_taxe", "balance_from_tags"),
("tax_report_I6_base_tag", "tax_report_I6_base", "balance"),
("tax_report_I6_base_tag_rounded", "tax_report_I6_base", "balance_rounded"),
("tax_report_I6_base_balance_from_tags", "tax_report_I6_base", "balance_from_tags"),
("tax_report_I6_base_adjustment", "tax_report_I6_base", "adjustment"),
("tax_report_I6_taxe_tag", "tax_report_I6_taxe", "balance"),
("tax_report_I6_taxe_tag_rounded", "tax_report_I6_taxe", "balance_rounded"),
("tax_report_I6_taxe_tag_no_rounding", "tax_report_I6_taxe", "balance_from_tags"),
("tax_report_15_tag", "tax_report_15", "balance"),
("tax_report_15_balance_rounded", "tax_report_15", "balance_rounded"),
("tax_report_15_balance_from_tags", "tax_report_15", "balance_from_tags"),
("tax_report_15_1_tag", "tax_report_15_1", "balance"),
("tax_report_15_1_balance_rounded", "tax_report_15_1", "balance_rounded"),
("tax_report_15_1_balance_from_tags", "tax_report_15_1", "balance_from_tags"),
("tax_report_15_2_tag", "tax_report_15_2", "balance"),
("tax_report_15_2_balance_rounded", "tax_report_15_2", "balance_rounded"),
("tax_report_15_2_balance_from_tags", "tax_report_15_2", "balance_from_tags"),
("tax_report_5B_tag", "tax_report_5B", "balance"),
("tax_report_5B_balance_rounded", "tax_report_5B", "balance_rounded"),
("tax_report_5B_balance_from_tags", "tax_report_5B", "balance_from_tags"),
("tax_report_16_formula", "tax_report_16", "balance"),
("tax_report_17_tag", "tax_report_17", "balance"),
("tax_report_17_balance_rounded", "tax_report_17", "balance_rounded"),
("tax_report_17_balance_from_tags", "tax_report_17", "balance_from_tags"),
("tax_report_18_tag", "tax_report_18", "balance"),
("tax_report_18_balance_rounded", "tax_report_18", "balance_rounded"),
("tax_report_18_balance_from_tags", "tax_report_18", "balance_from_tags"),
("tax_report_19_tag", "tax_report_19", "balance"),
("tax_report_19_balance_rounded", "tax_report_19", "balance_rounded"),
("tax_report_19_balance_from_tags", "tax_report_19", "balance_from_tags"),
("tax_report_20_tag", "tax_report_20", "balance"),
("tax_report_20_balance_rounded", "tax_report_20", "balance_rounded"),
("tax_report_20_balance_from_tags", "tax_report_20", "balance_from_tags"),
("tax_report_21_tag", "tax_report_21", "balance"),
("tax_report_21_balance_from_tags", "tax_report_21", "balance_from_tags"),
("tax_report_22_applied_carryover", "tax_report_22", "_applied_carryover_balance"),
("tax_report_22_tag", "tax_report_22", "tag"),
("tax_report_22_balance_rounded", "tax_report_22", "balance_rounded"),
("tax_report_22_balance", "tax_report_22", "balance"),
("tax_report_2C_tag", "tax_report_2C", "balance"),
("tax_report_2C_balance_rounded", "tax_report_2C", "balance_rounded"),
("tax_report_2C_balance_from_tags", "tax_report_2C", "balance_from_tags"),
("tax_report_22A_tag", "tax_report_22A", "balance"),
("tax_report_22A_balance_rounded", "tax_report_22A", "balance_rounded"),
("tax_report_22A_balance_from_tags", "tax_report_22A", "balance_from_tags"),
("tax_report_23_formula", "tax_report_23", "balance"),
("tax_report_24_tag", "tax_report_24", "balance"),
("tax_report_24_balance_rounded", "tax_report_24", "balance_rounded"),
("tax_report_24_balance_from_tags", "tax_report_24", "balance_from_tags"),
("tax_report_2E_tag", "tax_report_2E", "balance"),
("tax_report_2E_balance_rounded", "tax_report_2E", "balance_rounded"),
("tax_report_2E_balance_from_tags", "tax_report_2E", "balance_from_tags"),
("tax_report_25_formula", "tax_report_25", "balance"),
("tax_report_25_adjustment", "tax_report_25", "adjustment"),
("tax_report_td_formula", "tax_report_TD", "balance"),
("tax_report_TICFE_tag", "tax_report_TICFE", "balance"),
("tax_report_TICGN_tag", "tax_report_TICGN", "balance"),
("tax_report_TICC_tag", "tax_report_TICC", "balance"),
("tax_report_TIC_total_formula", "tax_report_TIC_total", "balance"),
("tax_report_X1_tag", "tax_report_X1", "balance"),
("tax_report_X1_balance_rounded", "tax_report_X1", "balance_rounded"),
("tax_report_X1_balance_from_tags", "tax_report_X1", "balance_from_tags"),
("tax_report_X2_tag", "tax_report_X2", "balance"),
("tax_report_X2_balance_rounded", "tax_report_X2", "balance_rounded"),
("tax_report_X2_balance_from_tags", "tax_report_X2", "balance_from_tags"),
("tax_report_X3_tag", "tax_report_X3", "balance"),
("tax_report_X3_balance_rounded", "tax_report_X3", "balance_rounded"),
("tax_report_X3_balance_from_tags", "tax_report_X3", "balance_from_tags"),
("tax_report_X4_formula", "tax_report_X4", "balance"),
("tax_report_Y1_formula", "tax_report_Y1", "balance"),
("tax_report_Y2_formula", "tax_report_Y2", "balance"),
("tax_report_Y3_formula", "tax_report_Y3", "balance"),
("tax_report_Z1_tag", "tax_report_Z1", "balance"),
("tax_report_Z1_balance_rounded", "tax_report_Z1", "balance_rounded"),
("tax_report_Z1_balance_from_tags", "tax_report_Z1", "balance_from_tags"),
("tax_report_Z2_tag", "tax_report_Z2", "balance"),
("tax_report_Z2_balance_rounded", "tax_report_Z2", "balance_rounded"),
("tax_report_Z2_balance_from_tags", "tax_report_Z2", "balance_from_tags"),
("tax_report_Z3_tag", "tax_report_Z3", "balance"),
("tax_report_Z3_balance_rounded", "tax_report_Z3", "balance_rounded"),
("tax_report_Z3_balance_from_tags", "tax_report_Z3", "balance_from_tags"),
("tax_report_Z4_formula", "tax_report_Z4", "balance"),
("tax_report_26_external_tag", "tax_report_26_external", "balance"),
("tax_report_AA_tag", "tax_report_AA", "balance"),
("tax_report_AA_balance_rounded", "tax_report_AA", "balance_rounded"),
("tax_report_AA_balance_from_tags", "tax_report_AA", "balance_from_tags"),
("tax_report_27_formula", "tax_report_27", "balance"),
("tax_report_27_formula_temp", "tax_report_27", "_balance_temp"),
("tax_report_27_carryover", "tax_report_27", "_carryover_balance"),
("tax_report_28_formula", "tax_report_28", "balance"),
("tax_report_29_tag", "tax_report_29", "balance"),
("tax_report_29_balance_rounded", "tax_report_29", "balance_rounded"),
("tax_report_29_balance_from_tags", "tax_report_29", "balance_from_tags"),
("tax_report_32_formula", "tax_report_32", "balance"),
]
# Fix 1: insert missing ir_model_data entries for orphan expressions
cr.execute(
"""
SELECT imd.name, imd.res_id
FROM ir_model_data imd
WHERE imd.module = 'l10n_fr_account'
AND imd.model = 'account.report.line'
"""
)
line_xmlid_to_id = {row[0]: row[1] for row in cr.fetchall()}
cr.execute(
"""
SELECT res_id FROM ir_model_data
WHERE model = 'account.report.expression'
"""
)
known_expr_ids = {row[0] for row in cr.fetchall()}
inserted = 0
for expr_xmlid, line_xmlid, label in expressions:
line_id = line_xmlid_to_id.get(line_xmlid)
if not line_id:
continue
cr.execute(
"""
SELECT id FROM account_report_expression
WHERE report_line_id = %s AND label = %s
""",
(line_id, label),
)
row = cr.fetchone()
if not row:
continue
expr_id = row[0]
if expr_id in known_expr_ids:
continue
cr.execute(
"""
INSERT INTO ir_model_data (name, module, model, res_id, noupdate, write_date, create_date)
VALUES (%s, 'l10n_fr_account', 'account.report.expression', %s, false, NOW(), NOW())
ON CONFLICT DO NOTHING
""",
(expr_xmlid, expr_id),
)
known_expr_ids.add(expr_id)
inserted += 1
if inserted:
print(
f" [l10n_fr_account] Inserted {inserted} missing ir_model_data entries"
" for orphan expressions"
)
# Fix 2: delete orphan expressions obsolete in V18
# box_X5, box_Y4, box_Y5, box_Z5 now use aggregation_formula field
# instead of expression_ids, so their V17 expressions are obsolete.
obsolete_line_xmlids = [
"tax_report_X5",
"tax_report_Y4",
"tax_report_Y5",
"tax_report_Z5",
]
cr.execute(
"""
DELETE FROM account_report_expression
WHERE report_line_id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'l10n_fr_account'
AND model = 'account.report.line'
AND name = ANY(%s)
)
AND id NOT IN (
SELECT res_id FROM ir_model_data
WHERE model = 'account.report.expression'
)
""",
(obsolete_line_xmlids,),
)
deleted = cr.rowcount
if deleted:
print(
f" [l10n_fr_account] Deleted {deleted} obsolete orphan expressions"
" (X5/Y4/Y5/Z5)"
)

11
versions/18.0/upgrade.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -euo pipefail
# --upgrade-path lists BOTH our custom migration scripts and OpenUpgrade's
# native ones. Passing --upgrade-path overrides config['upgrade_path'], and
# openupgrade_framework only sets that default when it is empty; so we must
# include the native openupgrade_scripts/scripts path explicitly, otherwise the
# native migration scripts would no longer run. Odoo appends every listed path
# to odoo.upgrade.__path__ and concatenates (does not override) the scripts
# found per module/version, so our script runs IN ADDITION to the native ones.
run_compose run -p 8018:8069 ou18 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=debug --max-cron-threads=0 --limit-time-real=10000 --database=ou18 --load=base,web,openupgrade_framework --addons-path=/opt/odoo/auto/mig,/opt/odoo/auto/addons --upgrade-path=/opt/odoo/auto/upgrade,/opt/odoo/auto/addons/openupgrade_scripts/scripts