1 Commits

Author SHA1 Message Date
Stéphan Sainléger
ddb1fcd0ad [IMP] add bdd manipulation to migrate credit.request
due to account.invoice transformation in account.move
2025-03-27 09:14:17 +01:00
54 changed files with 385 additions and 4642 deletions

4
.gitignore vendored
View File

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

0
13.0/post_upgrade.sh Executable file
View File

View File

@@ -1,5 +1,4 @@
#!/bin/bash
set -euo pipefail
echo "Prepare migration to 13.0..."
@@ -18,6 +17,20 @@ EOF
)
query_postgres_container "$PRE_MIGRATE_SQL" ou13 || exit 1
# Digital currency specific - to comment if not needed
INVOICE_NAME_SQL=$(cat <<'EOF'
ALTER TABLE credit_request ADD invoice_name VARCHAR;
UPDATE credit_request
SET invoice_name = (
SELECT move_name
FROM account_invoice
WHERE account_invoice.id = credit_request.invoice_id
);
UPDATE credit_request SET invoice_id = NULL;
EOF
)
query_postgres_container "$INVOICE_NAME_SQL" ou13 || exit 1
# Copy filestores
copy_filestore ou12 ou12 ou13 ou13 || exit 1

3
13.0/upgrade.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/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

0
14.0/post_upgrade.sh Executable file
View File

View File

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

3
14.0/upgrade.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/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

0
15.0/post_upgrade.sh Executable file
View File

View File

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

3
15.0/upgrade.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/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

17
16.0/post_upgrade.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
echo "POST migration to 16.0..."
# Digital currency specific - to comment if not needed
INVOICE_NAME_SQL=$(cat <<'EOF'
UPDATE credit_request
SET invoice_id = (
SELECT id
FROM account_move
WHERE account_move.name = credit_request.invoice_name
);
EOF
)
query_postgres_container "$INVOICE_NAME_SQL" ou16 || exit 1
echo "END POST migration to 16.0."

View File

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

3
16.0/upgrade.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/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,492 +1,64 @@
# 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
```bash
git clone <repository-url>
cd 0k-odoo-upgrade
```
- Clone the current repo
## Project Structure
## Configuration
```
.
├── 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) │
└─────────────────┘
```
- Requires to have the 0k scripts installed on the computer: https://git.myceliandre.fr/Lokavaluto/dev-pack
## Usage
### Before migration
### 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
1. **Import the source database** to your local machine
### Local Migration process
2. **Clean up the source database** (recommended)
- Uninstall unnecessary modules
- Do NOT uninstall modules handled by OpenUpgrade
- [ ] launch the origin database `ORIGIN_DATABASE_NAME` with original version of Odoo, with odoo service `ORIGIN_SERVICE`
- [ ] launch the following command:
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>]
``` bash
./upgrade.sh {ORIGIN_VERSION} {DESTINATION_VERSION} {ORIGIN_DATABASE_NAME} {ORIGIN_SERVICE}
```
ex: ./upgrade.sh 14 16 elabore_20241208 odoo14
**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` |
- [ ] 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)!
**Options:**
| Option | Description |
|--------|-------------|
| `--resume-from <version>`, `-r <version>` | Resume from an intermediate checkpoint (see [Resuming a Failed Migration](#resuming-a-failed-migration)) |
The migration process should run all the middle-migrations until the last one without action needed from you.
**Example:**
```bash
./upgrade.sh 14 17 elabore_20241208 odoo14
```
### Deploy migrated base
### During Migration
- [ ] Retrieve the migrated database (vps odoo dump)
- [ ] Copy the database on the concerned VPS
- [ ] vps odoo restore
The script will prompt for confirmation at two points:
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
## Manage the add-ons to uninstall
2. **View state**: verification of potentially problematic views
- `Y`: continue
- `N`: abort to manually fix issues
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
### After Migration
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.
1. **Review logs** to detect any non-blocking errors
The whole list of add-ons to uninstall is displayed, and needs a confirmation before starting the migration.
2. **Validate the migration** (see [Post-Migration Validation](#post-migration-validation))
## Customize the migration scripts
3. **Test the migrated database** locally
FEATURE COMING SOON...
4. **Deploy to production**
```bash
# Export the migrated database
vps odoo dump db_migrated.zip
# On the production server
vps odoo restore db_migrated.zip
```
## Manage migration issues
## Post-Migration Validation
As the migration process is performed on a copy of the orginal database, the process can be restarted without limits.
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.
Some Odoo migration errors won't stop the migration process, then be attentive to the errors in the logs.

View File

@@ -52,7 +52,7 @@ ou15:
ou16:
charm: odoo-tecnativa
docker-compose:
image: docker.0k.io/mirror/odoo:rc_16.0-ELABORE-LIGHT
image: docker.0k.io/mirror/odoo:rc_16.0-MYC-INIT
## Important to keep as a list: otherwise it'll overwrite charm's arguments.
command:
- "--log-level=debug"
@@ -73,21 +73,6 @@ 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:17.2.0-myc
image: docker.0k.io/postgres:12.15.0-myc

20
finalize_db.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/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

24
force_uninstall_addons Normal file
View File

@@ -0,0 +1,24 @@
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

View File

@@ -1,245 +0,0 @@
#!/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

View File

@@ -1,126 +0,0 @@
#!/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

@@ -1,128 +0,0 @@
#!/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

@@ -1,304 +0,0 @@
#!/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)

View File

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

View File

@@ -1,192 +0,0 @@
#!/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

@@ -1,141 +0,0 @@
#!/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()

View File

@@ -1,521 +0,0 @@
#!/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()

89
prepare_db.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/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!"

View File

@@ -1,215 +0,0 @@
#!/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

View File

@@ -1,120 +0,0 @@
#!/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!"

View File

@@ -1,138 +0,0 @@
#!/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,251 +1,207 @@
#!/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
####################
# USAGE & ARGUMENTS
# GLOBAL VARIABLES #
####################
usage() {
cat <<EOF >&2
Usage: $0 <origin_version> <final_version> <db_name> <service_name> [--resume-from|-r <version>]
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"
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}"
# Get origin database name
COPY_DB_NAME="ou${ORIGIN_VERSION}"
# Define finale database name
export FINALE_DB_NAME="ou${FINAL_VERSION}"
readonly FINALE_DB_NAME
readonly FINALE_SERVICE_NAME="${FINALE_DB_NAME}"
# Define finale odoo service name
FINALE_SERVICE_NAME="${FINALE_DB_NAME}"
# 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."
# 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
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
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."
# 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."
exit 1
fi
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})"
# 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."
else
log_info "Resume from version ..... none (full migration)"
echo "ERROR: '$REPERTOIRE' filestore not found, please add it and restart the upgrade process."
exit 1
fi
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"
#######################################
# LAUNCH VIRGIN ODOO IN FINAL VERSION #
#######################################
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..."
# 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
docker exec -u 70 "$POSTGRES_SERVICE_NAME" pgm rm -f "$FINALE_SERVICE_NAME"
sudo rm -rf "${DATASTORE_PATH}/${FINALE_SERVICE_NAME}/${FILESTORE_SUBPATH}/${FINALE_SERVICE_NAME}"
rm -rf /srv/datastore/data/"$FINALE_SERVICE_NAME"/var/lib/odoo/filestore/"$FINALE_SERVICE_NAME"
fi
run_compose --debug run "$FINALE_SERVICE_NAME" -i base --stop-after-init --no-http
compose --debug run "$FINALE_SERVICE_NAME" -i base --stop-after-init --no-http
log_info "Model database in final Odoo version created."
echo "Model database in final Odoo version created."
if [[ -z "$RESUME_FROM_VERSION" ]]; then
log_step "COPY ORIGINAL COMPONENTS"
############################
# COPY ORIGINAL COMPONENTS #
############################
copy_database "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME"
log_info "Original database copied to ${COPY_DB_NAME}@${COPY_DB_NAME}."
echo "
==== COPY ORIGINAL COMPONENTS ===="
echo "UPGRADE: Start copy"
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
# 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."
log_step "PATH OF MIGRATION"
#####################
# PATH OF MIGRATION #
####################
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
echo "
==== PATH OF MIGRATION ===="
# List all the versions to migrate through
declare -a versions
nb_migrations=$(($FINAL_VERSION - $ORIGIN_VERSION))
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"
# Build the migration path
for ((i = 0; i<$nb_migrations; i++))
do
versions[$i]=$(($ORIGIN_VERSION + 1 + i))
done
echo "UPGRADE: Migration path is ${versions[@]}"
log_step "POST-UPGRADE PROCESSES"
"${SCRIPT_DIR}/scripts/finalize_db.sh" "$FINALE_DB_NAME" "$FINALE_SERVICE_NAME"
########################
# DATABASE PREPARATION #
########################
log_step "UPGRADE PROCESS ENDED WITH SUCCESS"
echo "
==== DATABASE PREPARATION ===="
# 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
./prepare_db.sh "$COPY_DB_NAME" "$COPY_DB_NAME" "$FINALE_DB_MODEL_NAME" "$FINALE_SERVICE_NAME" || exit 1
log_info "Full logs available at: ${LOG_FILE}"
###################
# 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"

View File

@@ -1,10 +0,0 @@
# 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

View File

@@ -1,6 +0,0 @@
#!/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 +0,0 @@
#!/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

@@ -1,10 +0,0 @@
# 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

View File

@@ -1,6 +0,0 @@
#!/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 +0,0 @@
#!/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

@@ -1,16 +0,0 @@
# 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

View File

@@ -1,6 +0,0 @@
#!/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 +0,0 @@
#!/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

@@ -1,32 +0,0 @@
# 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]

View File

@@ -1,6 +0,0 @@
#!/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 +0,0 @@
#!/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

@@ -1,13 +0,0 @@
# 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

View File

@@ -1,32 +0,0 @@
#!/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

View File

@@ -1,217 +0,0 @@
#!/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!"

View File

@@ -1,4 +0,0 @@
#!/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

@@ -1,36 +0,0 @@
# 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

View File

@@ -1,408 +0,0 @@
#!/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!"

View File

@@ -1,270 +0,0 @@
#!/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

@@ -1,33 +0,0 @@
<?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

@@ -1,195 +0,0 @@
# 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

@@ -1,20 +0,0 @@
# 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

@@ -1,460 +0,0 @@
# 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)"
)

View File

@@ -1,11 +0,0 @@
#!/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