[ADD] migration: see and repair the website COW views
A copy-on-write view freezes the module view it came from; the module moves on and the upgrade dies hours later on a missing anchor. These tools predict, snapshot, diff, neutralize and reset them. The migration screen gains the real state of each step, replay from any of them, and statistics. --- FR --- Une vue copy-on-write fige la vue de module dont elle vient ; le module évolue et la mise à niveau meurt des heures plus tard sur un point d'ancrage absent. Ces outils les prévoient, photographient, comparent, neutralisent et réinitialisent. L'écran de migration gagne l'état réel de chaque étape, la reprise depuis n'importe laquelle, et des statistiques. Assisted-by: Claude Opus 5
This commit is contained in:
parent
b1c1095977
commit
b28b734338
14 changed files with 2864 additions and 133 deletions
5
private/.gitignore
vendored
5
private/.gitignore
vendored
|
|
@ -1 +1,6 @@
|
||||||
*.kdbx
|
*.kdbx
|
||||||
|
|
||||||
|
# Per-database migration lists (modules to uninstall/install before a version
|
||||||
|
# bump). They describe one specific database, never a shared default, so they
|
||||||
|
# must not be versioned. Shared defaults belong to script/odoo/migration/.
|
||||||
|
odoo/
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,16 @@ class Execute:
|
||||||
"You cannot execute Odoo command if no version is"
|
"You cannot execute Odoo command if no version is"
|
||||||
f" installed. Command : {redact_secrets(command)}"
|
f" installed. Command : {redact_secrets(command)}"
|
||||||
)
|
)
|
||||||
return -1
|
# Return the SAME shape the caller asked for. A bare int here
|
||||||
|
# made callers doing « status, cmd = exec_command_live(...) »
|
||||||
|
# crash with ValueError instead of seeing the failure.
|
||||||
|
if return_status_and_output_and_command:
|
||||||
|
return 1, command, []
|
||||||
|
if return_status_and_command:
|
||||||
|
return 1, command
|
||||||
|
if return_status_and_output:
|
||||||
|
return 1, []
|
||||||
|
return 1
|
||||||
command = f"source ./.venv.{source_odoo}/bin/activate && {command}"
|
command = f"source ./.venv.{source_odoo}/bin/activate && {command}"
|
||||||
if new_window and self.cmd_source_default:
|
if new_window and self.cmd_source_default:
|
||||||
command = self.cmd_source_default % command
|
command = self.cmd_source_default % command
|
||||||
|
|
@ -191,10 +200,16 @@ class Execute:
|
||||||
if process.returncode != 0 and not quiet:
|
if process.returncode != 0 and not quiet:
|
||||||
print("Command returned error code:" f" {process.returncode}")
|
print("Command returned error code:" f" {process.returncode}")
|
||||||
|
|
||||||
|
# An exception MUST report a failure. exit_code stays None otherwise,
|
||||||
|
# and None is falsy: callers testing « if not status: » would mark the
|
||||||
|
# step as done, and « if status and wait_at_error » would skip the error
|
||||||
|
# prompt. A crashed command was therefore recorded as a success.
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
exit_code = 1
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"Error: Command '{redact_secrets(command)}' not found.")
|
print(f"Error: Command '{redact_secrets(command)}' not found.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
exit_code = 1
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"An error occurred: {redact_secrets(str(e))}")
|
print(f"An error occurred: {redact_secrets(str(e))}")
|
||||||
process_end_time = time.time()
|
process_end_time = time.time()
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,46 @@ source ./.venv.odoo15.0_python3.8.20/bin/activate && cat ./script/odoo/migration
|
||||||
<!-- [en] -->
|
<!-- [en] -->
|
||||||
Check [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
|
Check [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
|
||||||
|
|
||||||
|
## Module lists to uninstall
|
||||||
|
|
||||||
|
Before a version bump, the migration uninstalls the modules listed in
|
||||||
|
`uninstall_module_list_odoo<from>_to_odoo<to>.txt`. Two locations are read, the
|
||||||
|
private one first, and the results are merged (duplicates dropped):
|
||||||
|
|
||||||
|
1. `private/odoo/migration/<database>/uninstall_module_list_odooXX0_to_odooYY0.txt`
|
||||||
|
— specific to ONE database, not versioned. Which modules must be dropped
|
||||||
|
depends on the data, so this is where nearly every entry belongs.
|
||||||
|
2. `script/odoo/migration/uninstall_module_list_odooXX0_to_odooYY0.txt`
|
||||||
|
— shared defaults, versioned, valid for every database.
|
||||||
|
|
||||||
|
Syntax: one module per line, with a justification after `#`. Commas and several
|
||||||
|
names per line are accepted; blank lines and full-line comments are ignored.
|
||||||
|
A module without a stated reason is flagged at runtime: removing a module is a
|
||||||
|
decision someone must be able to review later.
|
||||||
|
|
||||||
<!-- [fr] -->
|
<!-- [fr] -->
|
||||||
Consultez [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
|
Consultez [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
|
||||||
|
|
||||||
|
## Listes de modules à désinstaller
|
||||||
|
|
||||||
|
Avant une montée de version, la migration désinstalle les modules listés dans
|
||||||
|
`uninstall_module_list_odoo<depuis>_to_odoo<vers>.txt`. Deux emplacements sont
|
||||||
|
lus, le privé d'abord, puis fusionnés (doublons éliminés) :
|
||||||
|
|
||||||
|
1. `private/odoo/migration/<base>/uninstall_module_list_odooXX0_to_odooYY0.txt`
|
||||||
|
— propre à UNE base de données, non versionné. Les modules à supprimer
|
||||||
|
dépendent des données : c'est ici que va la quasi-totalité des entrées.
|
||||||
|
2. `script/odoo/migration/uninstall_module_list_odooXX0_to_odooYY0.txt`
|
||||||
|
— valeurs par défaut partagées, versionnées, valables pour toute base.
|
||||||
|
|
||||||
|
Syntaxe : un module par ligne, avec une justification après `#`. Les virgules et
|
||||||
|
plusieurs noms par ligne sont acceptés ; lignes vides et commentaires pleine
|
||||||
|
ligne sont ignorés. Un module sans raison est signalé à l'exécution : supprimer
|
||||||
|
un module est une décision qui doit pouvoir être relue plus tard.
|
||||||
|
|
||||||
|
<!-- [common] -->
|
||||||
|
```
|
||||||
|
queue_job # blocks 12->13, trigger queue_job_notify
|
||||||
|
mgmtsystem_hazard # not ported to 13.0
|
||||||
|
web_syncer # dropped upstream
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,26 @@ source ./.venv.odoo15.0_python3.8.20/bin/activate && cat ./script/odoo/migration
|
||||||
```
|
```
|
||||||
|
|
||||||
Consultez [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
|
Consultez [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
|
||||||
|
|
||||||
|
## Listes de modules à désinstaller
|
||||||
|
|
||||||
|
Avant une montée de version, la migration désinstalle les modules listés dans
|
||||||
|
`uninstall_module_list_odoo<depuis>_to_odoo<vers>.txt`. Deux emplacements sont
|
||||||
|
lus, le privé d'abord, puis fusionnés (doublons éliminés) :
|
||||||
|
|
||||||
|
1. `private/odoo/migration/<base>/uninstall_module_list_odooXX0_to_odooYY0.txt`
|
||||||
|
— propre à UNE base de données, non versionné. Les modules à supprimer
|
||||||
|
dépendent des données : c'est ici que va la quasi-totalité des entrées.
|
||||||
|
2. `script/odoo/migration/uninstall_module_list_odooXX0_to_odooYY0.txt`
|
||||||
|
— valeurs par défaut partagées, versionnées, valables pour toute base.
|
||||||
|
|
||||||
|
Syntaxe : un module par ligne, avec une justification après `#`. Les virgules et
|
||||||
|
plusieurs noms par ligne sont acceptés ; lignes vides et commentaires pleine
|
||||||
|
ligne sont ignorés. Un module sans raison est signalé à l'exécution : supprimer
|
||||||
|
un module est une décision qui doit pouvoir être relue plus tard.
|
||||||
|
|
||||||
|
```
|
||||||
|
queue_job # blocks 12->13, trigger queue_job_notify
|
||||||
|
mgmtsystem_hazard # not ported to 13.0
|
||||||
|
web_syncer # dropped upstream
|
||||||
|
```
|
||||||
|
|
@ -8,3 +8,26 @@ source ./.venv.odoo15.0_python3.8.20/bin/activate && cat ./script/odoo/migration
|
||||||
```
|
```
|
||||||
|
|
||||||
Check [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
|
Check [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
|
||||||
|
|
||||||
|
## Module lists to uninstall
|
||||||
|
|
||||||
|
Before a version bump, the migration uninstalls the modules listed in
|
||||||
|
`uninstall_module_list_odoo<from>_to_odoo<to>.txt`. Two locations are read, the
|
||||||
|
private one first, and the results are merged (duplicates dropped):
|
||||||
|
|
||||||
|
1. `private/odoo/migration/<database>/uninstall_module_list_odooXX0_to_odooYY0.txt`
|
||||||
|
— specific to ONE database, not versioned. Which modules must be dropped
|
||||||
|
depends on the data, so this is where nearly every entry belongs.
|
||||||
|
2. `script/odoo/migration/uninstall_module_list_odooXX0_to_odooYY0.txt`
|
||||||
|
— shared defaults, versioned, valid for every database.
|
||||||
|
|
||||||
|
Syntax: one module per line, with a justification after `#`. Commas and several
|
||||||
|
names per line are accepted; blank lines and full-line comments are ignored.
|
||||||
|
A module without a stated reason is flagged at runtime: removing a module is a
|
||||||
|
decision someone must be able to review later.
|
||||||
|
|
||||||
|
```
|
||||||
|
queue_job # blocks 12->13, trigger queue_job_notify
|
||||||
|
mgmtsystem_hazard # not ported to 13.0
|
||||||
|
web_syncer # dropped upstream
|
||||||
|
```
|
||||||
347
script/odoo/migration/check_cow_views.py
Executable file
347
script/odoo/migration/check_cow_views.py
Executable file
|
|
@ -0,0 +1,347 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Predict which website COW views will break on the next version bump.
|
||||||
|
|
||||||
|
Background
|
||||||
|
----------
|
||||||
|
When a website view is customized, Odoo makes a copy-on-write (COW) copy tied
|
||||||
|
to a website_id. That copy freezes the arch AND the structure of the module
|
||||||
|
view it was copied from.
|
||||||
|
|
||||||
|
A module view can change structure between two Odoo versions. Example measured
|
||||||
|
on a real 12.0 database: ``portal.frontend_layout`` is declared ``primary`` in
|
||||||
|
12.0 (a full QWeb template) and becomes an ``extension`` in 13.0
|
||||||
|
(``inherit_id="web.frontend_layout"`` + xpath). During the upgrade the COW copy
|
||||||
|
follows the module and becomes an extension, but keeps its 12.0 full-template
|
||||||
|
arch. Odoo then applies the ``<t t-name=...>`` root as an inheritance spec,
|
||||||
|
cannot find it in the parent, and the whole upgrade stops on::
|
||||||
|
|
||||||
|
ValueError: Element '<t ... t-name="portal.frontend_layout">'
|
||||||
|
cannot be located in parent view
|
||||||
|
|
||||||
|
So the rule is:
|
||||||
|
|
||||||
|
a COW view breaks when the shape its arch must have changes between
|
||||||
|
version N and version N+1.
|
||||||
|
|
||||||
|
The discriminant is NOT ``mode``. What decides the required shape is whether
|
||||||
|
the target declares an ``inherit_id``: if it does, the arch must be inheritance
|
||||||
|
specs (``<data>``, ``<xpath>``, ``position=``); if it does not, the arch must be
|
||||||
|
a standalone template. Comparing ``mode`` alone misses a real case: a view
|
||||||
|
moving from a root template to ``inherit_id`` + ``primary="True"`` keeps
|
||||||
|
``mode='primary'`` on both sides yet still has to change shape, and the copy
|
||||||
|
still breaks.
|
||||||
|
|
||||||
|
That is predictable *before* starting a multi-hour migration: the stored arch is
|
||||||
|
in the database, and the required shape is declared in the target version
|
||||||
|
sources. This script compares the two and reports the views at risk.
|
||||||
|
|
||||||
|
It only reads: no database write, no source modification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
# A view whose module counterpart cannot be found at all.
|
||||||
|
MODE_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
# arch_db is text up to 15.0 and jsonb from 16.0 ({"en_US": "<data>..."}).
|
||||||
|
RE_XML_DECLARATION = re.compile(r"<\?xml.*?\?>", re.DOTALL)
|
||||||
|
RE_FIRST_TAG = re.compile(r"<\s*([A-Za-z_][\w.:-]*)")
|
||||||
|
# Tags that carry inheritance specs rather than a standalone template.
|
||||||
|
SPEC_ROOT_TAG = ("data", "xpath")
|
||||||
|
|
||||||
|
|
||||||
|
def query_cow_views(database):
|
||||||
|
"""Return [(id, key, mode, website_id, arch)] for every website COW view."""
|
||||||
|
sql = (
|
||||||
|
"SELECT id, COALESCE(key, ''), mode, website_id,"
|
||||||
|
" replace(left(COALESCE(arch_db::text, ''), 400), chr(10), ' ')"
|
||||||
|
" FROM ir_ui_view WHERE website_id IS NOT NULL ORDER BY id;"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["psql", "-d", database, "-tAF", "|", "-c", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot read views from '{database}': {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
lst_view = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
view_id, key, mode, website_id, arch = line.split("|", 4)
|
||||||
|
lst_view.append((int(view_id), key, mode, website_id, arch))
|
||||||
|
return lst_view
|
||||||
|
|
||||||
|
|
||||||
|
def arch_is_inheritance_spec(arch):
|
||||||
|
"""True when the arch holds inheritance specs, not a standalone template.
|
||||||
|
|
||||||
|
This is the real discriminant, not ``mode``. A view declared with an
|
||||||
|
``inherit_id`` must hold specs (``<data>``, ``<xpath>``, or an element with
|
||||||
|
a ``position``); a root view holds a full template (``<t t-name=...>``,
|
||||||
|
``<form>``, ...). A copy that keeps the wrong form for what the target
|
||||||
|
version expects is exactly what raises « cannot be located in parent view ».
|
||||||
|
"""
|
||||||
|
if not arch:
|
||||||
|
return None
|
||||||
|
# From 16.0 arch_db is jsonb: take any translation, the structure is shared.
|
||||||
|
if arch.lstrip().startswith("{"):
|
||||||
|
try:
|
||||||
|
translations = json.loads(arch)
|
||||||
|
arch = next(iter(translations.values()), "")
|
||||||
|
except (ValueError, StopIteration):
|
||||||
|
# Truncated jsonb: fall through and look at the raw text.
|
||||||
|
pass
|
||||||
|
arch = RE_XML_DECLARATION.sub("", arch or "")
|
||||||
|
match = RE_FIRST_TAG.search(arch)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
if match.group(1).lower() in SPEC_ROOT_TAG:
|
||||||
|
return True
|
||||||
|
# <field name="x" position="after"> style specs.
|
||||||
|
return "position=" in arch[: match.end() + 200]
|
||||||
|
|
||||||
|
|
||||||
|
def load_renamed_modules(odoo_version):
|
||||||
|
"""Return {old_module: new_module} from the target OpenUpgrade apriori.py.
|
||||||
|
|
||||||
|
Without this a module renamed upstream looks absent, and every view of that
|
||||||
|
module is misreported as « module gone » instead of being checked.
|
||||||
|
"""
|
||||||
|
pattern = os.path.join(odoo_version, "**", "apriori.py")
|
||||||
|
for file_path in sorted(glob.glob(pattern, recursive=True)):
|
||||||
|
data_vars = {}
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
exec(f.read(), data_vars) # noqa: S102 - upstream data file
|
||||||
|
except Exception:
|
||||||
|
# A broken or exotic apriori.py must not stop the whole report.
|
||||||
|
continue
|
||||||
|
renamed = data_vars.get("renamed_modules")
|
||||||
|
if isinstance(renamed, dict) and renamed:
|
||||||
|
return renamed
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def find_module_dir(odoo_version, module_name):
|
||||||
|
"""Locate a module directory inside an odoo<version> tree."""
|
||||||
|
lst_pattern = [
|
||||||
|
os.path.join(odoo_version, "odoo", "addons", module_name),
|
||||||
|
os.path.join(odoo_version, "odoo", "odoo", "addons", module_name),
|
||||||
|
os.path.join(odoo_version, "addons", "*", module_name),
|
||||||
|
]
|
||||||
|
for pattern in lst_pattern:
|
||||||
|
for path in sorted(glob.glob(pattern)):
|
||||||
|
if os.path.isdir(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def declared_view_shape(module_dir, template_id):
|
||||||
|
"""Return (mode, inherits) for a view declared in the sources, else None.
|
||||||
|
|
||||||
|
``inherits`` is what really matters: a declared inherit_id means the arch
|
||||||
|
must be inheritance specs. ``mode`` is kept because it is still worth
|
||||||
|
reporting, but it is NOT a reliable discriminant: a view moving from a root
|
||||||
|
template to « inherit_id + primary="True" » keeps mode='primary' on both
|
||||||
|
sides while its arch shape has to change.
|
||||||
|
"""
|
||||||
|
pattern = os.path.join(module_dir, "**", "*.xml")
|
||||||
|
for file_path in sorted(glob.glob(pattern, recursive=True)):
|
||||||
|
try:
|
||||||
|
root = ET.parse(file_path).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
continue
|
||||||
|
for element in root.iter():
|
||||||
|
if element.get("id") != template_id:
|
||||||
|
continue
|
||||||
|
if element.tag == "template":
|
||||||
|
inherits = bool(element.get("inherit_id"))
|
||||||
|
if str(element.get("primary", "")).lower() in ("true", "1"):
|
||||||
|
return "primary", inherits
|
||||||
|
return ("extension" if inherits else "primary"), inherits
|
||||||
|
if (
|
||||||
|
element.tag == "record"
|
||||||
|
and element.get("model") == "ir.ui.view"
|
||||||
|
):
|
||||||
|
mode = None
|
||||||
|
inherits = False
|
||||||
|
for field in element.findall("field"):
|
||||||
|
if field.get("name") == "mode":
|
||||||
|
mode = (field.text or "").strip()
|
||||||
|
elif field.get("name") == "inherit_id":
|
||||||
|
inherits = True
|
||||||
|
if mode:
|
||||||
|
return mode, inherits
|
||||||
|
return ("extension" if inherits else "primary"), inherits
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def analyse(database, target_version):
|
||||||
|
"""Sort COW views into three buckets by comparing with the target sources.
|
||||||
|
|
||||||
|
- at_risk : the module view changes mode -> the copy will break
|
||||||
|
- module_absent : the module itself is gone in the target version
|
||||||
|
- no_counterpart : no module view with that id, so it is a page or a record
|
||||||
|
created from the editor. Normal, and not at risk.
|
||||||
|
"""
|
||||||
|
lst_at_risk = []
|
||||||
|
lst_module_absent = []
|
||||||
|
lst_no_counterpart = []
|
||||||
|
cache_shape = {}
|
||||||
|
renamed_modules = load_renamed_modules(target_version)
|
||||||
|
|
||||||
|
for view_id, key, mode, website_id, arch in query_cow_views(database):
|
||||||
|
if not key or "." not in key:
|
||||||
|
continue
|
||||||
|
if key not in cache_shape:
|
||||||
|
module_name, _, template_id = key.partition(".")
|
||||||
|
module_dir = find_module_dir(target_version, module_name)
|
||||||
|
if module_dir is None and module_name in renamed_modules:
|
||||||
|
module_dir = find_module_dir(
|
||||||
|
target_version, renamed_modules[module_name]
|
||||||
|
)
|
||||||
|
if module_dir is None:
|
||||||
|
cache_shape[key] = MODE_UNKNOWN
|
||||||
|
else:
|
||||||
|
cache_shape[key] = declared_view_shape(module_dir, template_id)
|
||||||
|
shape = cache_shape[key]
|
||||||
|
|
||||||
|
if shape == MODE_UNKNOWN:
|
||||||
|
lst_module_absent.append((view_id, key, mode, website_id))
|
||||||
|
continue
|
||||||
|
if shape is None:
|
||||||
|
lst_no_counterpart.append((view_id, key, mode, website_id))
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_mode, target_inherits = shape
|
||||||
|
is_spec = arch_is_inheritance_spec(arch)
|
||||||
|
|
||||||
|
# The decisive test: the target expects inheritance specs but the copy
|
||||||
|
# holds a standalone template, or the reverse.
|
||||||
|
if is_spec is not None and target_inherits != is_spec:
|
||||||
|
reason = (
|
||||||
|
"target inherits, copy holds a standalone template"
|
||||||
|
if target_inherits
|
||||||
|
else "target is a root view, copy holds inheritance specs"
|
||||||
|
)
|
||||||
|
lst_at_risk.append(
|
||||||
|
(view_id, key, mode, target_mode, website_id, reason)
|
||||||
|
)
|
||||||
|
elif target_mode != mode:
|
||||||
|
# Shape is fine but the mode moves: worth reporting, less severe.
|
||||||
|
lst_at_risk.append(
|
||||||
|
(
|
||||||
|
view_id,
|
||||||
|
key,
|
||||||
|
mode,
|
||||||
|
target_mode,
|
||||||
|
website_id,
|
||||||
|
"mode changes, arch shape unchanged",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return lst_at_risk, lst_module_absent, lst_no_counterpart
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Report website COW views that will break on the next Odoo"
|
||||||
|
" version bump (read-only)."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d", "--database", required=True, help="database to inspect"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--target_version",
|
||||||
|
required=True,
|
||||||
|
help="target Odoo source directory, e.g. odoo13.0",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="also list the editor-made pages, which are not at risk",
|
||||||
|
)
|
||||||
|
config = parser.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isdir(config.target_version):
|
||||||
|
print(
|
||||||
|
f"❌ Target version directory '{config.target_version}' not found."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
|
||||||
|
config.database, config.target_version
|
||||||
|
)
|
||||||
|
|
||||||
|
if not lst_at_risk:
|
||||||
|
print(
|
||||||
|
"✅ -> No website COW view changes mode in"
|
||||||
|
f" {config.target_version}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"⚠️ {len(lst_at_risk)} website COW view(s) will break when moving"
|
||||||
|
f" to {config.target_version}: the copy keeps an arch whose shape"
|
||||||
|
" no longer matches what the target module view expects."
|
||||||
|
)
|
||||||
|
for (
|
||||||
|
view_id,
|
||||||
|
key,
|
||||||
|
mode,
|
||||||
|
target_mode,
|
||||||
|
website_id,
|
||||||
|
reason,
|
||||||
|
) in lst_at_risk:
|
||||||
|
print(
|
||||||
|
f" - id={view_id} website={website_id} {key}"
|
||||||
|
f" : {mode} -> {target_mode} ({reason})"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Arbitrate BEFORE launching the migration. To neutralize a copy,"
|
||||||
|
" rename its key (UPDATE ir_ui_view SET key='zz_cow_archive.'||key,"
|
||||||
|
" active=false): an unmatched key is never paired with the module"
|
||||||
|
" view, so the copy never receives the new inherit_id. Setting"
|
||||||
|
" active=false alone is NOT enough -- an inactive copy that keeps"
|
||||||
|
" the same key still shadows the module view."
|
||||||
|
)
|
||||||
|
|
||||||
|
if lst_module_absent:
|
||||||
|
print(
|
||||||
|
f"ℹ {len(lst_module_absent)} COW view(s) belong to a module absent"
|
||||||
|
f" from {config.target_version}:"
|
||||||
|
)
|
||||||
|
for view_id, key, mode, website_id in lst_module_absent:
|
||||||
|
print(f" - id={view_id} website={website_id} {key} ({mode})")
|
||||||
|
|
||||||
|
if lst_no_counterpart:
|
||||||
|
print(
|
||||||
|
f"ℹ {len(lst_no_counterpart)} COW view(s) are pages or records made"
|
||||||
|
" from the website editor (no module view of that name): not at"
|
||||||
|
" risk." + ("" if config.verbose else " Use -v to list them.")
|
||||||
|
)
|
||||||
|
if config.verbose:
|
||||||
|
for view_id, key, mode, website_id in lst_no_counterpart:
|
||||||
|
print(f" - id={view_id} website={website_id} {key} ({mode})")
|
||||||
|
|
||||||
|
# Informative only: never fail the migration on a warning.
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
39
script/odoo/migration/fix_migration_odoo130_to_odoo140.sql
Normal file
39
script/odoo/migration/fix_migration_odoo130_to_odoo140.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
-- © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
--
|
||||||
|
-- Odoo 13.0 -> 14.0 : hand the « group_fiscal_year » security group over to
|
||||||
|
-- the module that owns it in 14.0.
|
||||||
|
--
|
||||||
|
-- In 13.0 the group « Allow to define fiscal years of more or less than a
|
||||||
|
-- year » is declared by the core « account » module, so the database holds it
|
||||||
|
-- as account.group_fiscal_year. In 14.0 core account no longer declares it and
|
||||||
|
-- om_account_accountant (odoomates) does. During the upgrade that module finds
|
||||||
|
-- no XML id of its own, tries to CREATE the group, and hits:
|
||||||
|
--
|
||||||
|
-- duplicate key value violates unique constraint "res_groups_name_uniq"
|
||||||
|
-- Key (category_id, name)=(9, Allow to define fiscal years ...) already exists
|
||||||
|
--
|
||||||
|
-- Renaming the XML id makes Odoo UPDATE the existing row instead of creating a
|
||||||
|
-- duplicate. The record id is untouched, so any user assignment, access right
|
||||||
|
-- or record rule pointing at the group survives.
|
||||||
|
--
|
||||||
|
-- Runs through psql, not the Odoo shell: at this point the database is still
|
||||||
|
-- 13.0 and loading it with the 14.0 registry is exactly what fails.
|
||||||
|
|
||||||
|
UPDATE ir_model_data
|
||||||
|
SET module = 'om_account_accountant'
|
||||||
|
WHERE model = 'res.groups'
|
||||||
|
AND module = 'account'
|
||||||
|
AND name = 'group_fiscal_year'
|
||||||
|
-- Only when that module is actually part of this database.
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM ir_module_module
|
||||||
|
WHERE name = 'om_account_accountant'
|
||||||
|
AND state IN ('installed', 'to upgrade', 'to install')
|
||||||
|
)
|
||||||
|
-- Idempotent: do nothing if the target XML id already exists.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ir_model_data
|
||||||
|
WHERE module = 'om_account_accountant'
|
||||||
|
AND name = 'group_fiscal_year'
|
||||||
|
);
|
||||||
158
script/odoo/migration/neutralize_cow_views.py
Executable file
158
script/odoo/migration/neutralize_cow_views.py
Executable file
|
|
@ -0,0 +1,158 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Neutralize the website COW views that would break a version bump.
|
||||||
|
|
||||||
|
How it works
|
||||||
|
------------
|
||||||
|
``key`` is the only thing that pairs a website copy with the module view it
|
||||||
|
came from. A copy whose key matches nothing is never paired, so it never
|
||||||
|
receives the new ``inherit_id``, never changes shape, and never takes part in
|
||||||
|
any view combination. Renaming the key is therefore enough to take a copy out
|
||||||
|
of the way::
|
||||||
|
|
||||||
|
UPDATE ir_ui_view SET key = '<prefix>.' || key, active = false WHERE id = ?
|
||||||
|
|
||||||
|
Setting ``active = false`` alone would NOT work: an inactive copy that keeps
|
||||||
|
the same key still shadows the module view.
|
||||||
|
|
||||||
|
Nothing is deleted, so ``inherit_id ondelete='restrict'`` and the
|
||||||
|
``website_page`` foreign keys are never touched, and the 12.0 arch stays in
|
||||||
|
database as a readable archive. ``--restore`` puts everything back.
|
||||||
|
|
||||||
|
Plain psql on purpose: this must run on a database that has not been migrated
|
||||||
|
yet, where starting an Odoo shell of the target version is not guaranteed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from check_cow_views import analyse # noqa: E402
|
||||||
|
|
||||||
|
DEFAULT_PREFIX = "zz_cow_archive"
|
||||||
|
|
||||||
|
|
||||||
|
def run_psql(database, sql):
|
||||||
|
"""Run a statement and return stdout, raising on failure."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["psql", "-d", database, "-tAc", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Query failed on '{database}': {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def neutralize(database, lst_view_id, prefix):
|
||||||
|
"""Rename the key of the given views and deactivate them."""
|
||||||
|
if not lst_view_id:
|
||||||
|
return 0
|
||||||
|
ids = ",".join(str(view_id) for view_id in lst_view_id)
|
||||||
|
output = run_psql(
|
||||||
|
database,
|
||||||
|
"WITH updated AS ("
|
||||||
|
f" UPDATE ir_ui_view SET key = '{prefix}.' || key, active = false"
|
||||||
|
f" WHERE id IN ({ids}) AND key NOT LIKE '{prefix}.%'"
|
||||||
|
" RETURNING 1) SELECT count(*) FROM updated;",
|
||||||
|
)
|
||||||
|
return int(output or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def restore(database, prefix):
|
||||||
|
"""Undo a neutralization: strip the prefix and reactivate."""
|
||||||
|
output = run_psql(
|
||||||
|
database,
|
||||||
|
"WITH updated AS ("
|
||||||
|
f" UPDATE ir_ui_view SET key = substring(key from {len(prefix) + 2}),"
|
||||||
|
" active = true"
|
||||||
|
f" WHERE key LIKE '{prefix}.%'"
|
||||||
|
" RETURNING 1) SELECT count(*) FROM updated;",
|
||||||
|
)
|
||||||
|
return int(output or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Neutralize the website COW views that would break a version"
|
||||||
|
" bump, by renaming their key. Dry-run unless --apply."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("-d", "--database", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--target_version",
|
||||||
|
help="target Odoo source directory, e.g. odoo13.0",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prefix",
|
||||||
|
default=DEFAULT_PREFIX,
|
||||||
|
help=f"archive prefix for the key (default: {DEFAULT_PREFIX})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply", action="store_true", help="actually write to the database"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--restore",
|
||||||
|
action="store_true",
|
||||||
|
help="undo a previous neutralization and reactivate the copies",
|
||||||
|
)
|
||||||
|
config = parser.parse_args()
|
||||||
|
|
||||||
|
if config.restore:
|
||||||
|
count = restore(config.database, config.prefix)
|
||||||
|
print(f"✅ -> {count} COW view(s) restored on '{config.database}'.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not config.target_version:
|
||||||
|
parser.error("--target_version is required unless --restore is used")
|
||||||
|
if not os.path.isdir(config.target_version):
|
||||||
|
print(
|
||||||
|
f"❌ Target version directory '{config.target_version}' not found."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
lst_at_risk, _, _ = analyse(config.database, config.target_version)
|
||||||
|
if not lst_at_risk:
|
||||||
|
print("✅ -> No website COW view to neutralize.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"⚠️ {len(lst_at_risk)} website COW view(s) would break the bump to"
|
||||||
|
f" {config.target_version}:"
|
||||||
|
)
|
||||||
|
lst_view_id = []
|
||||||
|
for view_id, key, mode, target_mode, website_id, reason in lst_at_risk:
|
||||||
|
lst_view_id.append(view_id)
|
||||||
|
print(
|
||||||
|
f" - id={view_id} website={website_id} {key}"
|
||||||
|
f" : {mode} -> {target_mode} ({reason})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not config.apply:
|
||||||
|
print(
|
||||||
|
"ℹ Dry-run. Add --apply to rename their key to"
|
||||||
|
f" '{config.prefix}.<key>' and deactivate them. Reversible with"
|
||||||
|
" --restore; the arch stays in database."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = neutralize(config.database, lst_view_id, config.prefix)
|
||||||
|
print(
|
||||||
|
f"✅ -> {count} COW view(s) neutralized (key prefixed with"
|
||||||
|
f" '{config.prefix}.', deactivated). The arch is kept as an archive;"
|
||||||
|
" use --restore to undo."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
365
script/odoo/migration/reset_stale_cow_views.py
Executable file
365
script/odoo/migration/reset_stale_cow_views.py
Executable file
|
|
@ -0,0 +1,365 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Find the website COW copies that have gone stale, and reset them.
|
||||||
|
|
||||||
|
The problem
|
||||||
|
-----------
|
||||||
|
A COW copy freezes the module view it was copied from. Version after version,
|
||||||
|
the MODULE view is modernised while the copy is not — it is user data, nothing
|
||||||
|
rewrites it. The copy keeps working right up to the moment a CHILD view does an
|
||||||
|
``xpath`` onto something the module added, renamed or re-anchored::
|
||||||
|
|
||||||
|
Element '<xpath expr="//head/script[@id='web.layout.odooscript']">'
|
||||||
|
cannot be located in parent view
|
||||||
|
|
||||||
|
Observed on ``web.layout`` during 14.0 -> 15.0: the copy dated from 12.0, where
|
||||||
|
the script tag had no ``id`` and QWeb still said ``t-raw``. It had survived
|
||||||
|
until then only because the 14.0 module xpath carried a fallback
|
||||||
|
(``//head/script[@id='…'] | //head/script[last()]``) that 15.0 removed.
|
||||||
|
|
||||||
|
What this checks
|
||||||
|
----------------
|
||||||
|
Not a heuristic on deprecated syntax, and not an absolute one either: the
|
||||||
|
DRIFT between a copy and its module twin. For every active child of a COW
|
||||||
|
copy, each ``xpath`` expression is resolved twice — against the module twin
|
||||||
|
and against the copy. An expression the twin can satisfy and the copy cannot
|
||||||
|
is an anchor the copy has lost.
|
||||||
|
|
||||||
|
The comparison has to be differential. Odoo resolves an xpath against the
|
||||||
|
COMBINED arch of the whole inheritance chain, so « does not resolve in this
|
||||||
|
parent » proves nothing on its own — a child of ``website.layout`` targets
|
||||||
|
``//header``, which comes from an ancestor.
|
||||||
|
|
||||||
|
When it bites
|
||||||
|
-------------
|
||||||
|
A reported copy is not necessarily failing right now. Odoo re-validates a COW
|
||||||
|
copy when its module twin is REWRITTEN — which is what a version bump does —
|
||||||
|
or when the page is rendered for that website. So a finding is a breakage
|
||||||
|
already present in the data, waiting for the next bump to surface it. That is
|
||||||
|
precisely when it is cheap to fix.
|
||||||
|
|
||||||
|
Resetting
|
||||||
|
---------
|
||||||
|
``--reset`` copies the MODULE arch over the stale copy. The previous arch is
|
||||||
|
always written to a backup file first, and the diff is printed, because a copy
|
||||||
|
can hold a genuine customisation — one line among fifty of drift. Read the
|
||||||
|
diff, reset, then re-apply what mattered as an INHERITING view rather than a
|
||||||
|
full copy, so the next version bump cannot make it stale again.
|
||||||
|
|
||||||
|
Plain psql on purpose: this runs on databases whose Odoo registry does not
|
||||||
|
load, which is precisely when it is needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def run_psql(database, sql):
|
||||||
|
"""Run a statement and return stdout, raising on failure."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["psql", "-d", database, "-tAc", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Query failed on '{database}': {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_arch(value):
|
||||||
|
"""The arch as a string, whatever the column type.
|
||||||
|
|
||||||
|
Odoo stores ``arch_db`` as text up to 15.0 and as jsonb (one entry per
|
||||||
|
language) from 16.0. Casting to text gives ``{"en_US": "<t …"}`` in the
|
||||||
|
second case, so unwrap it — the structure is the same in every language,
|
||||||
|
and the structure is all this tool looks at.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
text = value.strip()
|
||||||
|
if text.startswith("{") and '"' in text:
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
if isinstance(data, dict) and data:
|
||||||
|
for lang in ("en_US", *sorted(data)):
|
||||||
|
if lang in data and isinstance(data[lang], str):
|
||||||
|
return data[lang]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_views(database):
|
||||||
|
"""Every view that matters: COW copies, their module twin, their children.
|
||||||
|
|
||||||
|
One query rather than one per view — these databases hold thousands of
|
||||||
|
views. The result comes back as JSON: an arch is multi-line XML, so any
|
||||||
|
line-based format would need a record separator the data could forge.
|
||||||
|
"""
|
||||||
|
raw = run_psql(
|
||||||
|
database,
|
||||||
|
"SELECT COALESCE(json_agg(json_build_object("
|
||||||
|
"'id', id, 'key', COALESCE(key, ''), 'inherit_id', inherit_id,"
|
||||||
|
"'website_id', website_id, 'active', active,"
|
||||||
|
"'arch', arch_db::text))::text, '[]')"
|
||||||
|
" FROM ir_ui_view WHERE arch_db IS NOT NULL",
|
||||||
|
)
|
||||||
|
rows = {}
|
||||||
|
for item in json.loads(raw.strip() or "[]"):
|
||||||
|
item["arch"] = normalise_arch(item.get("arch"))
|
||||||
|
rows[item["id"]] = item
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
XPATH_RE = re.compile(r"<xpath\b[^>]*\bexpr=([\"'])(.*?)\1", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def child_xpaths(arch):
|
||||||
|
"""The xpath expressions a view applies to its parent."""
|
||||||
|
return [match.group(2) for match in XPATH_RE.finditer(arch)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_lxml():
|
||||||
|
"""The XPath engine, or a loud failure.
|
||||||
|
|
||||||
|
Never degrade to « everything resolves » when lxml is missing: a checker
|
||||||
|
that cannot check must not answer « all clean ». Odoo's own venvs all ship
|
||||||
|
lxml; the bare system python3 usually does not.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
return etree
|
||||||
|
except ImportError:
|
||||||
|
sys.exit(
|
||||||
|
"❌ lxml is required to resolve the xpath expressions.\n"
|
||||||
|
" Run this with an interpreter that has it, e.g.\n"
|
||||||
|
" ./.venv.erplibre/bin/python3 " + os.path.relpath(__file__)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(etree, arch, expr):
|
||||||
|
"""True if `expr` matches something in `arch`.
|
||||||
|
|
||||||
|
An arch that does not parse, or an expression lxml cannot evaluate, counts
|
||||||
|
as resolvable: this tool reports views that will CERTAINLY break, and must
|
||||||
|
never invent a failure it cannot substantiate.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = etree.fromstring(arch.encode("utf-8"))
|
||||||
|
except etree.XMLSyntaxError:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return bool(tree.xpath(expr))
|
||||||
|
except etree.XPathEvalError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def analyse(database):
|
||||||
|
"""[(copy, module_twin, [(child_id, failing_expr), ...])] — the COW copies
|
||||||
|
that have DRIFTED away from their module twin.
|
||||||
|
|
||||||
|
The test is differential, and it has to be. Odoo resolves an xpath against
|
||||||
|
the COMBINED arch of the whole inheritance chain, not against the parent's
|
||||||
|
own arch, so « does not resolve here » proves nothing on its own: a child
|
||||||
|
of ``website.layout`` legitimately targets ``//header``, which comes from
|
||||||
|
an ancestor. Comparing the copy with its module twin removes that whole
|
||||||
|
class of noise:
|
||||||
|
|
||||||
|
resolves in the module twin, not in the copy -> the copy lost an
|
||||||
|
anchor: real drift
|
||||||
|
resolves in neither -> the anchor lives
|
||||||
|
further up the chain
|
||||||
|
resolves in both -> nothing to see
|
||||||
|
|
||||||
|
Inactive children are skipped: Odoo never applies them, so they cannot
|
||||||
|
break a load.
|
||||||
|
"""
|
||||||
|
etree = require_lxml()
|
||||||
|
views = fetch_views(database)
|
||||||
|
module_by_key = {
|
||||||
|
v["key"]: v
|
||||||
|
for v in views.values()
|
||||||
|
if v["website_id"] is None and v["key"]
|
||||||
|
}
|
||||||
|
children = {}
|
||||||
|
for view in views.values():
|
||||||
|
if view["inherit_id"]:
|
||||||
|
children.setdefault(view["inherit_id"], []).append(view)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
for view in sorted(views.values(), key=lambda v: v["id"]):
|
||||||
|
twin = module_by_key.get(view["key"])
|
||||||
|
if view["website_id"] is None or twin is None:
|
||||||
|
continue
|
||||||
|
broken = []
|
||||||
|
for child in children.get(view["id"], []):
|
||||||
|
if not child.get("active", True):
|
||||||
|
continue
|
||||||
|
for expr in child_xpaths(child["arch"]):
|
||||||
|
if resolve(etree, twin["arch"], expr) and not resolve(
|
||||||
|
etree, view["arch"], expr
|
||||||
|
):
|
||||||
|
broken.append((child["id"], expr))
|
||||||
|
if broken:
|
||||||
|
findings.append((view, twin, broken))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def show_diff(module_view, cow_view):
|
||||||
|
"""Module arch vs copy: what the copy would gain and lose on a reset."""
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
module_view["arch"].splitlines(),
|
||||||
|
cow_view["arch"].splitlines(),
|
||||||
|
fromfile=f"module id={module_view['id']}",
|
||||||
|
tofile=f"cow id={cow_view['id']}",
|
||||||
|
lineterm="",
|
||||||
|
)
|
||||||
|
for line in diff:
|
||||||
|
print(f" {line}")
|
||||||
|
|
||||||
|
|
||||||
|
def backup(database, cow_view, directory):
|
||||||
|
"""Store the arch about to be replaced, and return the file path."""
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
path = os.path.join(
|
||||||
|
directory, f"{cow_view['key']}_{cow_view['id']}_{stamp}.json"
|
||||||
|
)
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"database": database,
|
||||||
|
"id": cow_view["id"],
|
||||||
|
"key": cow_view["key"],
|
||||||
|
"website_id": cow_view["website_id"],
|
||||||
|
"saved_at": stamp,
|
||||||
|
"arch_db": cow_view["arch"],
|
||||||
|
},
|
||||||
|
fh,
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def reset(database, cow_view, module_view):
|
||||||
|
"""Copy the module arch over the stale copy. Returns rows updated."""
|
||||||
|
sql = (
|
||||||
|
"UPDATE ir_ui_view c SET arch_db = m.arch_db "
|
||||||
|
"FROM ir_ui_view m "
|
||||||
|
f"WHERE c.id = {cow_view['id']} AND m.id = {module_view['id']}"
|
||||||
|
)
|
||||||
|
run_psql(database, sql)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Report the website COW copies whose children can no longer be"
|
||||||
|
" applied, and optionally reset them onto the module view."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("-d", "--database", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset",
|
||||||
|
metavar="KEY",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="reset this key onto its module view ('all' for every finding)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="really write; without it --reset only shows what it would do",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backup-dir",
|
||||||
|
default=None,
|
||||||
|
help="where the replaced arch is saved"
|
||||||
|
" (default private/odoo/migration/<db>/cow_reset)",
|
||||||
|
)
|
||||||
|
config = parser.parse_args()
|
||||||
|
|
||||||
|
findings = analyse(config.database)
|
||||||
|
if not findings:
|
||||||
|
print("✅ No COW copy has drifted from its module view.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"⚠️ {len(findings)} COW copy(ies) drifted from their module view"
|
||||||
|
f" in {config.database}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Odoo surfaces this when the module view is rewritten (a version"
|
||||||
|
" bump) or when the page is rendered.\n"
|
||||||
|
)
|
||||||
|
for cow_view, module_view, broken in findings:
|
||||||
|
twin = (
|
||||||
|
f"module id={module_view['id']}"
|
||||||
|
if module_view
|
||||||
|
else "NO module view with this key"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" id={cow_view['id']} key={cow_view['key']}"
|
||||||
|
f" website_id={cow_view['website_id']} [{twin}]"
|
||||||
|
)
|
||||||
|
for child_id, expr in broken:
|
||||||
|
print(f" child {child_id} cannot apply: {expr}")
|
||||||
|
if module_view:
|
||||||
|
show_diff(module_view, cow_view)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not config.reset:
|
||||||
|
print(
|
||||||
|
"Nothing changed. Re-run with --reset <key> --apply to reset a"
|
||||||
|
" copy onto its module view."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
wanted = set(config.reset)
|
||||||
|
directory = config.backup_dir or os.path.join(
|
||||||
|
"private", "odoo", "migration", config.database, "cow_reset"
|
||||||
|
)
|
||||||
|
done = 0
|
||||||
|
for cow_view, module_view, _broken in findings:
|
||||||
|
if "all" not in wanted and cow_view["key"] not in wanted:
|
||||||
|
continue
|
||||||
|
if not module_view:
|
||||||
|
print(
|
||||||
|
f"⏭ {cow_view['key']}: no module view to reset onto,"
|
||||||
|
" skipped."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not config.apply:
|
||||||
|
print(
|
||||||
|
f"[dry-run] would reset id={cow_view['id']}"
|
||||||
|
f" ({cow_view['key']}) onto id={module_view['id']}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
path = backup(config.database, cow_view, directory)
|
||||||
|
reset(config.database, cow_view, module_view)
|
||||||
|
done += 1
|
||||||
|
print(f"✅ reset id={cow_view['id']} ({cow_view['key']})")
|
||||||
|
print(f" previous arch saved to {path}")
|
||||||
|
if config.apply and done:
|
||||||
|
print(
|
||||||
|
f"\n{done} copy(ies) reset. Re-apply any real customisation as an"
|
||||||
|
" INHERITING view, not a copy, so it cannot go stale again."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
213
script/odoo/migration/snapshot_cow_views.py
Executable file
213
script/odoo/migration/snapshot_cow_views.py
Executable file
|
|
@ -0,0 +1,213 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Snapshot the website COW views, and diff two snapshots.
|
||||||
|
|
||||||
|
Why
|
||||||
|
---
|
||||||
|
A version bump rewrites website views in ways nobody announces: OpenUpgrade
|
||||||
|
converts Bootstrap markup on every ``website_id IS NOT NULL`` view, modules
|
||||||
|
rewrite the copies they own, and some copies simply disappear. Without a
|
||||||
|
before/after record, "the site looks wrong" is unanswerable.
|
||||||
|
|
||||||
|
Taking a snapshot before and after each jump turns that into a diff: which copy
|
||||||
|
lost its arch, which changed mode, which was renamed, which vanished.
|
||||||
|
|
||||||
|
Snapshots hold customer template content, so they belong under ``private/``
|
||||||
|
and are never versioned.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
snapshot_cow_views.py -d <database> --label before_13
|
||||||
|
snapshot_cow_views.py -d <database> --label after_13
|
||||||
|
snapshot_cow_views.py --diff <before.json> <after.json>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Columns worth recording. ir_ui_view does not expose the same set across 12.0
|
||||||
|
# to 18.0, so the query keeps only those that actually exist.
|
||||||
|
WANTED_COLUMN = [
|
||||||
|
"id",
|
||||||
|
"key",
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"mode",
|
||||||
|
"active",
|
||||||
|
"priority",
|
||||||
|
"website_id",
|
||||||
|
"inherit_id",
|
||||||
|
"arch_fs",
|
||||||
|
"arch_updated",
|
||||||
|
]
|
||||||
|
DEFAULT_DIR = os.path.join("private", "odoo", "migration")
|
||||||
|
|
||||||
|
|
||||||
|
def run_psql(database, sql):
|
||||||
|
"""Run a read-only query and return stdout."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["psql", "-d", database, "-tAc", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Query failed on '{database}': {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def existing_columns(database):
|
||||||
|
"""Column names of ir_ui_view present in this database."""
|
||||||
|
output = run_psql(
|
||||||
|
database,
|
||||||
|
"SELECT column_name FROM information_schema.columns"
|
||||||
|
" WHERE table_name = 'ir_ui_view';",
|
||||||
|
)
|
||||||
|
return {line.strip() for line in output.splitlines() if line.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def collect(database):
|
||||||
|
"""Return the list of COW views as plain dicts, arch included.
|
||||||
|
|
||||||
|
The rows come back as JSON straight from Postgres: an arch holds newlines
|
||||||
|
and pipes, so no hand-made separator survives it.
|
||||||
|
"""
|
||||||
|
available = existing_columns(database)
|
||||||
|
lst_column = [name for name in WANTED_COLUMN if name in available]
|
||||||
|
select = ", ".join(lst_column) + ", arch_db::text AS arch_db"
|
||||||
|
output = run_psql(
|
||||||
|
database,
|
||||||
|
"SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM ("
|
||||||
|
f" SELECT {select} FROM ir_ui_view"
|
||||||
|
" WHERE website_id IS NOT NULL ORDER BY id) t;",
|
||||||
|
)
|
||||||
|
lst_view = json.loads(output or "[]")
|
||||||
|
for view in lst_view:
|
||||||
|
arch = view.pop("arch_db", None) or ""
|
||||||
|
view["arch_md5"] = hashlib.md5(arch.encode("utf-8")).hexdigest()
|
||||||
|
view["arch_len"] = len(arch)
|
||||||
|
view["arch_db"] = arch
|
||||||
|
return lst_view
|
||||||
|
|
||||||
|
|
||||||
|
def save(database, label, output_dir):
|
||||||
|
"""Write a snapshot and return its path."""
|
||||||
|
directory = output_dir or os.path.join(
|
||||||
|
DEFAULT_DIR, database, "cow_snapshots"
|
||||||
|
)
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
lst_view = collect(database)
|
||||||
|
payload = {
|
||||||
|
"database": database,
|
||||||
|
"label": label,
|
||||||
|
"taken_at": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"count": len(lst_view),
|
||||||
|
"views": lst_view,
|
||||||
|
}
|
||||||
|
file_path = os.path.join(directory, f"{label}.json")
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"✅ -> {len(lst_view)} COW view(s) recorded in {file_path}")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
def load(file_path):
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def diff(path_before, path_after):
|
||||||
|
"""Print what changed between two snapshots."""
|
||||||
|
before = load(path_before)
|
||||||
|
after = load(path_after)
|
||||||
|
map_before = {view["id"]: view for view in before["views"]}
|
||||||
|
map_after = {view["id"]: view for view in after["views"]}
|
||||||
|
|
||||||
|
removed = sorted(set(map_before) - set(map_after))
|
||||||
|
added = sorted(set(map_after) - set(map_before))
|
||||||
|
common = sorted(set(map_before) & set(map_after))
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"📊 {before.get('label')} ({before.get('count')} views)"
|
||||||
|
f" -> {after.get('label')} ({after.get('count')} views)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
print(f"❌ {len(removed)} COW view(s) disappeared:")
|
||||||
|
for view_id in removed:
|
||||||
|
view = map_before[view_id]
|
||||||
|
print(f" - id={view_id} {view.get('key')}")
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f"➕ {len(added)} COW view(s) appeared:")
|
||||||
|
for view_id in added:
|
||||||
|
view = map_after[view_id]
|
||||||
|
print(f" - id={view_id} {view.get('key')} ({view.get('mode')})")
|
||||||
|
|
||||||
|
lst_changed = []
|
||||||
|
for view_id in common:
|
||||||
|
old, new = map_before[view_id], map_after[view_id]
|
||||||
|
lst_field = []
|
||||||
|
for field in ("key", "mode", "inherit_id", "active", "arch_md5"):
|
||||||
|
if old.get(field) != new.get(field):
|
||||||
|
if field == "arch_md5":
|
||||||
|
lst_field.append(
|
||||||
|
f"arch rewritten ({old.get('arch_len')} ->"
|
||||||
|
f" {new.get('arch_len')} chars)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lst_field.append(
|
||||||
|
f"{field}: {old.get(field)} -> {new.get(field)}"
|
||||||
|
)
|
||||||
|
if lst_field:
|
||||||
|
lst_changed.append((view_id, new.get("key"), lst_field))
|
||||||
|
|
||||||
|
if lst_changed:
|
||||||
|
print(f"✏️ {len(lst_changed)} COW view(s) changed:")
|
||||||
|
for view_id, key, lst_field in lst_changed:
|
||||||
|
print(f" - id={view_id} {key}")
|
||||||
|
for change in lst_field:
|
||||||
|
print(f" {change}")
|
||||||
|
|
||||||
|
if not (removed or added or lst_changed):
|
||||||
|
print("✅ -> No change on the website COW views.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Snapshot website COW views, or diff two snapshots."
|
||||||
|
)
|
||||||
|
parser.add_argument("-d", "--database", help="database to snapshot")
|
||||||
|
parser.add_argument(
|
||||||
|
"-l", "--label", help="snapshot name, e.g. before_13 or after_13"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output_dir", help="where to write (default: private/...)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--diff",
|
||||||
|
nargs=2,
|
||||||
|
metavar=("BEFORE", "AFTER"),
|
||||||
|
help="compare two snapshot files instead of taking one",
|
||||||
|
)
|
||||||
|
config = parser.parse_args()
|
||||||
|
|
||||||
|
if config.diff:
|
||||||
|
return diff(*config.diff)
|
||||||
|
if not config.database or not config.label:
|
||||||
|
parser.error("--database and --label are required to take a snapshot")
|
||||||
|
save(config.database, config.label, config.output_dir)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
186
script/todo/migration_form.py
Normal file
186
script/todo/migration_form.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
"""Écran de reprise de migration Odoo, en TUI.
|
||||||
|
|
||||||
|
Pendant de `qemu_deploy_form` pour l'outil de migration. Deux interfaces
|
||||||
|
posent la MÊME première question — « où en est-on, et par où reprend-on ? » —
|
||||||
|
et renvoient les MÊMES chaînes de réponse (« c », « n », « r », « q »,
|
||||||
|
« 0 »..« 4 », « 4.<version> ») que `TodoUpgrade.apply_resume_answer` traduit
|
||||||
|
en progression. La décision est donc écrite une seule fois.
|
||||||
|
|
||||||
|
- run_resume_tui(ctx, run_app=True) : renvoie la réponse, ou None pour
|
||||||
|
retomber sur les invites en ligne.
|
||||||
|
|
||||||
|
`ctx` vient de `TodoUpgrade.resume_context()` : pure donnée, aucun accès à la
|
||||||
|
base ni au disque depuis l'affichage.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
try:
|
||||||
|
from script.todo.todo_i18n import t
|
||||||
|
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||||
|
|
||||||
|
def t(key: str) -> str:
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def version_line(versions):
|
||||||
|
"""« 13✓ 14✓ 15 16 17 18 » — l'avancement des montées de version."""
|
||||||
|
return " ".join(
|
||||||
|
f"{v['version']}{'✓' if v['done'] else ''}" for v in versions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def next_version(versions):
|
||||||
|
"""Première version pas encore migrée : celle que « continuer » reprend."""
|
||||||
|
for item in versions:
|
||||||
|
if not item["done"]:
|
||||||
|
return item["version"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_resume_tui(ctx, run_app: bool = True):
|
||||||
|
"""Écran de reprise. Renvoie la réponse choisie, ou None si annulé.
|
||||||
|
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
OptionList,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
from textual.widgets.option_list import Option
|
||||||
|
|
||||||
|
result = {"answer": None}
|
||||||
|
|
||||||
|
class Resume(App):
|
||||||
|
CSS = """
|
||||||
|
#head { height: auto; padding: 0 1; color: $text-muted; }
|
||||||
|
#steps { height: auto; max-height: 12; border: solid $accent; }
|
||||||
|
#bumps { height: auto; max-height: 10; border: solid $panel; }
|
||||||
|
.grouptitle { color: $accent; text-style: bold; padding: 1 1 0 1; }
|
||||||
|
#actions { height: auto; padding: 1 1 0 1; }
|
||||||
|
#hint { height: auto; color: $text-muted; padding: 0 1; }
|
||||||
|
"""
|
||||||
|
BINDINGS = [
|
||||||
|
("c", "cont", t("Continue where it stopped")),
|
||||||
|
("n", "new", t("New migration, erase everything")),
|
||||||
|
("r", "keep_zip", t("Keep the zip only")),
|
||||||
|
("q", "quit_nothing", t("Quit without doing anything")),
|
||||||
|
("escape", "quit_nothing", t("Quit without doing anything")),
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Static(
|
||||||
|
f" {t('File'):<9}: {ctx['file']}\n"
|
||||||
|
f" {t('Database'):<9}: {ctx['database']}"
|
||||||
|
f" · {t('Target')} : {ctx['target']}\n"
|
||||||
|
f" {t('Started'):<9}: {ctx['started']}",
|
||||||
|
id="head",
|
||||||
|
)
|
||||||
|
yield Static(f"{t('Steps')}", classes="grouptitle")
|
||||||
|
yield DataTable(id="steps")
|
||||||
|
if ctx["versions"]:
|
||||||
|
yield Static(
|
||||||
|
f"{t('Version bumps')} ({version_line(ctx['versions'])})",
|
||||||
|
classes="grouptitle",
|
||||||
|
)
|
||||||
|
yield OptionList(id="bumps")
|
||||||
|
with Vertical():
|
||||||
|
with Horizontal(id="actions"):
|
||||||
|
yield Button(
|
||||||
|
t("Continue where it stopped"),
|
||||||
|
variant="primary",
|
||||||
|
id="a_cont",
|
||||||
|
)
|
||||||
|
yield Button(t("New migration"), id="a_new")
|
||||||
|
yield Button(t("Keep the zip only"), id="a_keep")
|
||||||
|
yield Button(t("Quit"), id="a_quit")
|
||||||
|
yield Static(
|
||||||
|
f" {t('Enter on a step or a version = replay from there')}",
|
||||||
|
id="hint",
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.title = t("Migration in progress")
|
||||||
|
table = self.query_one("#steps", DataTable)
|
||||||
|
table.cursor_type = "row"
|
||||||
|
table.add_columns("", "", t("Step"), t("Detail"))
|
||||||
|
for item in ctx["steps"]:
|
||||||
|
table.add_row(
|
||||||
|
f"[{item['step']}]",
|
||||||
|
item["icon"],
|
||||||
|
item["label"],
|
||||||
|
item["detail"],
|
||||||
|
)
|
||||||
|
# Curseur sur la première étape inachevée : c'est là que ça a
|
||||||
|
# calé, donc là qu'on veut probablement rejouer.
|
||||||
|
for index, item in enumerate(ctx["steps"]):
|
||||||
|
if item["icon"] != "✅":
|
||||||
|
table.move_cursor(row=index)
|
||||||
|
break
|
||||||
|
if ctx["versions"]:
|
||||||
|
bumps = self.query_one("#bumps", OptionList)
|
||||||
|
for item in ctx["versions"]:
|
||||||
|
mark = "✓" if item["done"] else " "
|
||||||
|
bumps.add_option(
|
||||||
|
Option(
|
||||||
|
f" {mark} Odoo {item['version']}.0 — "
|
||||||
|
f"{t('rebuilds the intermediate database')}",
|
||||||
|
id=str(item["version"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upcoming = next_version(ctx["versions"])
|
||||||
|
if upcoming is not None:
|
||||||
|
bumps.highlighted = [
|
||||||
|
v["version"] for v in ctx["versions"]
|
||||||
|
].index(upcoming)
|
||||||
|
|
||||||
|
# -- choix ------------------------------------------------------ #
|
||||||
|
def _answer(self, value):
|
||||||
|
result["answer"] = value
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
def on_data_table_row_selected(self, event) -> None:
|
||||||
|
index = event.cursor_row
|
||||||
|
if 0 <= index < len(ctx["steps"]):
|
||||||
|
self._answer(str(ctx["steps"][index]["step"]))
|
||||||
|
|
||||||
|
def on_option_list_option_selected(self, event) -> None:
|
||||||
|
self._answer(f"4.{event.option.id}")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event) -> None:
|
||||||
|
mapping = {
|
||||||
|
"a_cont": "c",
|
||||||
|
"a_new": "n",
|
||||||
|
"a_keep": "r",
|
||||||
|
"a_quit": "q",
|
||||||
|
}
|
||||||
|
value = mapping.get(event.button.id)
|
||||||
|
if value:
|
||||||
|
self._answer(value)
|
||||||
|
|
||||||
|
def action_cont(self) -> None:
|
||||||
|
self._answer("c")
|
||||||
|
|
||||||
|
def action_new(self) -> None:
|
||||||
|
self._answer("n")
|
||||||
|
|
||||||
|
def action_keep_zip(self) -> None:
|
||||||
|
self._answer("r")
|
||||||
|
|
||||||
|
def action_quit_nothing(self) -> None:
|
||||||
|
self._answer("q")
|
||||||
|
|
||||||
|
app = Resume()
|
||||||
|
app._result = result # lecture par les tests headless
|
||||||
|
if not run_app:
|
||||||
|
return app
|
||||||
|
app.run()
|
||||||
|
return result["answer"]
|
||||||
201
script/todo/migration_stats.py
Normal file
201
script/todo/migration_stats.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
"""Statistiques d'une migration Odoo, en lecture seule.
|
||||||
|
|
||||||
|
Ne touche NI la base NI le fichier de progression : tout se déduit du journal
|
||||||
|
de migration et des traces laissées sous `private/odoo/migration/<base>/`.
|
||||||
|
C'est ce qui permet de consulter l'état d'une migration en cours depuis une
|
||||||
|
autre session sans risquer de la perturber.
|
||||||
|
|
||||||
|
`compute()` reçoit le contexte déjà construit par
|
||||||
|
`TodoUpgrade.resume_context()` — étapes et montées de version — pour ne pas
|
||||||
|
réimplémenter une seconde fois la lecture des clés « state_* », qui
|
||||||
|
divergerait de l'écran de reprise.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
from script.todo.todo_i18n import t
|
||||||
|
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||||
|
|
||||||
|
def t(key: str) -> str:
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value):
|
||||||
|
"""Clé de version en entier. JSON transforme les clés numériques en
|
||||||
|
chaînes : « 13 » et 13 désignent la même version."""
|
||||||
|
try:
|
||||||
|
return int(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_delay(start, end):
|
||||||
|
"""Écart lisible entre deux horodatages « str(datetime) »."""
|
||||||
|
try:
|
||||||
|
a = datetime.datetime.fromisoformat(str(start))
|
||||||
|
b = datetime.datetime.fromisoformat(str(end))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "?"
|
||||||
|
secs = int(abs((b - a).total_seconds()))
|
||||||
|
days, rest = divmod(secs, 86400)
|
||||||
|
hours, rest = divmod(rest, 3600)
|
||||||
|
minutes = rest // 60
|
||||||
|
if days:
|
||||||
|
return f"{days} j {hours:02d} h"
|
||||||
|
if hours:
|
||||||
|
return f"{hours} h {minutes:02d} min"
|
||||||
|
return f"{minutes} min"
|
||||||
|
|
||||||
|
|
||||||
|
def module_evolution(dct_progression):
|
||||||
|
"""[(version, nb_modules, delta)] du plus ancien au plus récent.
|
||||||
|
|
||||||
|
Montre où les modules disparaissent : un saut qui en perd 15 d'un coup
|
||||||
|
n'a pas le même sens qu'un saut qui n'en perd aucun.
|
||||||
|
"""
|
||||||
|
raw = dct_progression.get("dct_module_per_version") or {}
|
||||||
|
rows = []
|
||||||
|
for key, value in raw.items():
|
||||||
|
version = _as_int(key)
|
||||||
|
if version is not None and isinstance(value, list):
|
||||||
|
rows.append((version, len(value)))
|
||||||
|
rows.sort()
|
||||||
|
out = []
|
||||||
|
previous = None
|
||||||
|
for version, count in rows:
|
||||||
|
out.append(
|
||||||
|
(version, count, None if previous is None else count - previous)
|
||||||
|
)
|
||||||
|
previous = count
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cow_snapshots(private_dir, database_name):
|
||||||
|
"""Instantanés de vues COW enregistrés, du plus ancien au plus récent."""
|
||||||
|
directory = os.path.join(private_dir, database_name, "cow_snapshots")
|
||||||
|
out = []
|
||||||
|
for path in sorted(glob.glob(os.path.join(directory, "*.json"))):
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"label": data.get("label") or os.path.basename(path)[:-5],
|
||||||
|
"count": data.get("count"),
|
||||||
|
"taken_at": data.get("taken_at") or "?",
|
||||||
|
"path": path,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda item: item["taken_at"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fix_hooks(ctx, global_dir):
|
||||||
|
"""Correctifs de migration disponibles, et lesquels ont tourné."""
|
||||||
|
applied = ctx.get("_fix_applied") or []
|
||||||
|
out = []
|
||||||
|
for index, item in enumerate(ctx.get("versions") or []):
|
||||||
|
target = item["version"]
|
||||||
|
stem = f"fix_migration_odoo{(target - 1) * 10}_to_odoo{target * 10}"
|
||||||
|
found = [
|
||||||
|
os.path.basename(p)
|
||||||
|
for ext in (".sql", ".py")
|
||||||
|
for p in [os.path.join(global_dir, stem + ext)]
|
||||||
|
if os.path.exists(p)
|
||||||
|
]
|
||||||
|
if not found:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"version": target,
|
||||||
|
"file": found[0],
|
||||||
|
"applied": bool(index < len(applied) and applied[index]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def journal(dct_progression):
|
||||||
|
"""Commandes exécutées et décisions annotées (les lignes « # »)."""
|
||||||
|
lst = dct_progression.get("command_executed") or []
|
||||||
|
comments = [
|
||||||
|
c[2:].strip() for c in lst if isinstance(c, str) and c.startswith("# ")
|
||||||
|
]
|
||||||
|
commands = [
|
||||||
|
c for c in lst if isinstance(c, str) and not c.startswith("# ")
|
||||||
|
]
|
||||||
|
return {"commands": commands, "comments": comments}
|
||||||
|
|
||||||
|
|
||||||
|
def compute(
|
||||||
|
dct_progression,
|
||||||
|
ctx,
|
||||||
|
database_name,
|
||||||
|
read_uninstall,
|
||||||
|
private_dir,
|
||||||
|
global_dir,
|
||||||
|
):
|
||||||
|
"""Rassemble toutes les statistiques.
|
||||||
|
|
||||||
|
`read_uninstall(version, base)` est la lecture de liste de TodoUpgrade :
|
||||||
|
elle résout ELLE-MÊME ses chemins privé puis global, ce qui garantit
|
||||||
|
qu'on affiche exactement la liste qui serait appliquée. `private_dir` ne
|
||||||
|
sert donc qu'aux instantanés COW, et `global_dir` qu'aux correctifs."""
|
||||||
|
versions = [item["version"] for item in (ctx.get("versions") or [])]
|
||||||
|
uninstall = {}
|
||||||
|
for target in versions:
|
||||||
|
try:
|
||||||
|
_lst, detail = read_uninstall(target - 1, database_name)
|
||||||
|
except Exception:
|
||||||
|
detail = []
|
||||||
|
if detail:
|
||||||
|
uninstall[target] = detail
|
||||||
|
|
||||||
|
evolution = module_evolution(dct_progression)
|
||||||
|
origin = dct_progression.get("lst_module_per_version_origin") or []
|
||||||
|
return {
|
||||||
|
"delay": fmt_delay(
|
||||||
|
dct_progression.get("date_create"),
|
||||||
|
dct_progression.get("date_update"),
|
||||||
|
),
|
||||||
|
"updated": dct_progression.get("date_update") or "?",
|
||||||
|
"evolution": evolution,
|
||||||
|
"origin_count": len(origin) if isinstance(origin, list) else 0,
|
||||||
|
"missing": dct_progression.get("lst_module_missing") or [],
|
||||||
|
"duplicate": dct_progression.get("lst_module_duplicate") or [],
|
||||||
|
"uninstall": uninstall,
|
||||||
|
"removed_total": sum(len(v) for v in uninstall.values()),
|
||||||
|
"cow": cow_snapshots(private_dir, database_name),
|
||||||
|
"fixes": fix_hooks(
|
||||||
|
dict(
|
||||||
|
ctx,
|
||||||
|
_fix_applied=dct_progression.get(
|
||||||
|
"state_4_fix_migration_odoo_lst"
|
||||||
|
)
|
||||||
|
or [],
|
||||||
|
),
|
||||||
|
global_dir,
|
||||||
|
),
|
||||||
|
"journal": journal(dct_progression),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def flat_module_list(uninstall):
|
||||||
|
"""Tous les modules supprimés, dédupliqués, prêts à copier-coller."""
|
||||||
|
seen = []
|
||||||
|
for detail in uninstall.values():
|
||||||
|
for item in detail:
|
||||||
|
name = item[0] if isinstance(item, (list, tuple)) else item
|
||||||
|
if name not in seen:
|
||||||
|
seen.append(name)
|
||||||
|
return seen
|
||||||
|
|
@ -1687,6 +1687,200 @@ TRANSLATIONS = {
|
||||||
"fr": "via",
|
"fr": "via",
|
||||||
"en": "via",
|
"en": "via",
|
||||||
},
|
},
|
||||||
|
"Odoo migration interface": {
|
||||||
|
"fr": "🚚 Interface de la migration Odoo",
|
||||||
|
"en": "🚚 Odoo migration interface",
|
||||||
|
},
|
||||||
|
"modules of the list have no code in the active Odoo:": {
|
||||||
|
"fr": "modules de la liste n'ont plus de code dans l'Odoo actif :",
|
||||||
|
"en": "modules of the list have no code in the active Odoo:",
|
||||||
|
},
|
||||||
|
"Odoo cannot uninstall a module whose code is gone;": {
|
||||||
|
"fr": "Odoo ne peut pas désinstaller un module dont le code a"
|
||||||
|
" disparu ;",
|
||||||
|
"en": "Odoo cannot uninstall a module whose code is gone;",
|
||||||
|
},
|
||||||
|
"one of them fails the whole uninstall command.": {
|
||||||
|
"fr": "un seul d'entre eux fait échouer toute la désinstallation.",
|
||||||
|
"en": "one of them fails the whole uninstall command.",
|
||||||
|
},
|
||||||
|
"are present and can be uninstalled:": {
|
||||||
|
"fr": "sont présents et peuvent être désinstallés :",
|
||||||
|
"en": "are present and can be uninstalled:",
|
||||||
|
},
|
||||||
|
"No module of the list is present.": {
|
||||||
|
"fr": "Aucun module de la liste n'est présent.",
|
||||||
|
"en": "No module of the list is present.",
|
||||||
|
},
|
||||||
|
"Uninstall the present ones, skip the missing": {
|
||||||
|
"fr": "Désinstaller les présents, ignorer les manquants",
|
||||||
|
"en": "Uninstall the present ones, skip the missing",
|
||||||
|
},
|
||||||
|
"Try the whole list anyway (it will fail)": {
|
||||||
|
"fr": "Tout tenter quand même (ça échouera)",
|
||||||
|
"en": "Try the whole list anyway (it will fail)",
|
||||||
|
},
|
||||||
|
"Uninstall nothing, continue": {
|
||||||
|
"fr": "Ne rien désinstaller, continuer",
|
||||||
|
"en": "Uninstall nothing, continue",
|
||||||
|
},
|
||||||
|
"Nothing uninstalled.": {
|
||||||
|
"fr": "Rien de désinstallé.",
|
||||||
|
"en": "Nothing uninstalled.",
|
||||||
|
},
|
||||||
|
"Check the COW views that drifted": {
|
||||||
|
"fr": "Vérifier les vues COW en retard sur leur vue module",
|
||||||
|
"en": "Check the COW views that drifted",
|
||||||
|
},
|
||||||
|
"Tool not found": {
|
||||||
|
"fr": "Outil introuvable",
|
||||||
|
"en": "Tool not found",
|
||||||
|
},
|
||||||
|
"To reset one of them onto its module view:": {
|
||||||
|
"fr": "Pour réinitialiser l'une d'elles sur sa vue module :",
|
||||||
|
"en": "To reset one of them onto its module view:",
|
||||||
|
},
|
||||||
|
"Read the diff first: a copy can hold a customisation.": {
|
||||||
|
"fr": "Lire le diff d'abord : une copie peut porter une"
|
||||||
|
" personnalisation.",
|
||||||
|
"en": "Read the diff first: a copy can hold a customisation.",
|
||||||
|
},
|
||||||
|
"Migration statistics (read-only)": {
|
||||||
|
"fr": "📊 Statistiques de la migration (lecture seule)",
|
||||||
|
"en": "📊 Migration statistics (read-only)",
|
||||||
|
},
|
||||||
|
"Choice (0-3, default 1): ": {
|
||||||
|
"fr": "Choix (0-3, défaut 1) : ",
|
||||||
|
"en": "Choice (0-3, default 1): ",
|
||||||
|
},
|
||||||
|
"Migration statistics": {
|
||||||
|
"fr": "Statistiques de la migration",
|
||||||
|
"en": "Migration statistics",
|
||||||
|
},
|
||||||
|
"elapsed": {
|
||||||
|
"fr": "écoulé",
|
||||||
|
"en": "elapsed",
|
||||||
|
},
|
||||||
|
"Level reached": {
|
||||||
|
"fr": "Niveau atteint",
|
||||||
|
"en": "Level reached",
|
||||||
|
},
|
||||||
|
"Modules": {
|
||||||
|
"fr": "Modules",
|
||||||
|
"en": "Modules",
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"fr": "modules",
|
||||||
|
"en": "modules",
|
||||||
|
},
|
||||||
|
"At the start": {
|
||||||
|
"fr": "Au départ",
|
||||||
|
"en": "At the start",
|
||||||
|
},
|
||||||
|
"Removed in total": {
|
||||||
|
"fr": "Supprimés au total",
|
||||||
|
"en": "Removed in total",
|
||||||
|
},
|
||||||
|
"Reported missing": {
|
||||||
|
"fr": "Signalés manquants",
|
||||||
|
"en": "Reported missing",
|
||||||
|
},
|
||||||
|
"Duplicated": {
|
||||||
|
"fr": "En double",
|
||||||
|
"en": "Duplicated",
|
||||||
|
},
|
||||||
|
"Migration fixes": {
|
||||||
|
"fr": "Correctifs de migration",
|
||||||
|
"en": "Migration fixes",
|
||||||
|
},
|
||||||
|
"COW views": {
|
||||||
|
"fr": "Vues COW",
|
||||||
|
"en": "COW views",
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"fr": "vues",
|
||||||
|
"en": "views",
|
||||||
|
},
|
||||||
|
"no snapshot": {
|
||||||
|
"fr": "aucun instantané",
|
||||||
|
"en": "no snapshot",
|
||||||
|
},
|
||||||
|
"Journal": {
|
||||||
|
"fr": "Journal",
|
||||||
|
"en": "Journal",
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"fr": "commandes",
|
||||||
|
"en": "commands",
|
||||||
|
},
|
||||||
|
"recorded decisions": {
|
||||||
|
"fr": "décisions annotées",
|
||||||
|
"en": "recorded decisions",
|
||||||
|
},
|
||||||
|
"Removed modules, with their reason": {
|
||||||
|
"fr": "Modules supprimés, avec leur justification",
|
||||||
|
"en": "Removed modules, with their reason",
|
||||||
|
},
|
||||||
|
"Removed modules, comma-separated (copy)": {
|
||||||
|
"fr": "Modules supprimés, séparés par des virgules (à copier)",
|
||||||
|
"en": "Removed modules, comma-separated (copy)",
|
||||||
|
},
|
||||||
|
"COW views: snapshots and differences": {
|
||||||
|
"fr": "Vues COW : instantanés et différences",
|
||||||
|
"en": "COW views: snapshots and differences",
|
||||||
|
},
|
||||||
|
"Recorded decisions (journal)": {
|
||||||
|
"fr": "Décisions annotées (journal)",
|
||||||
|
"en": "Recorded decisions (journal)",
|
||||||
|
},
|
||||||
|
"Executed commands (last 30)": {
|
||||||
|
"fr": "Commandes exécutées (30 dernières)",
|
||||||
|
"en": "Executed commands (last 30)",
|
||||||
|
},
|
||||||
|
"nothing recorded": {
|
||||||
|
"fr": "rien d'annoté",
|
||||||
|
"en": "nothing recorded",
|
||||||
|
},
|
||||||
|
"Need two snapshots to diff.": {
|
||||||
|
"fr": "Il faut deux instantanés pour comparer.",
|
||||||
|
"en": "Need two snapshots to diff.",
|
||||||
|
},
|
||||||
|
"Diff which two? (e.g. 1,2 — blank to skip)": {
|
||||||
|
"fr": "Comparer lesquels ? (ex. 1,2 — vide pour passer)",
|
||||||
|
"en": "Diff which two? (e.g. 1,2 — blank to skip)",
|
||||||
|
},
|
||||||
|
"No migration in progress to resume.": {
|
||||||
|
"fr": "Aucune migration en cours à reprendre.",
|
||||||
|
"en": "No migration in progress to resume.",
|
||||||
|
},
|
||||||
|
"Quit without doing anything": {
|
||||||
|
"fr": "Quitter sans rien faire",
|
||||||
|
"en": "Quit without doing anything",
|
||||||
|
},
|
||||||
|
"Version bumps": {
|
||||||
|
"fr": "Montées de version",
|
||||||
|
"en": "Version bumps",
|
||||||
|
},
|
||||||
|
"Step": {
|
||||||
|
"fr": "Étape",
|
||||||
|
"en": "Step",
|
||||||
|
},
|
||||||
|
"Detail": {
|
||||||
|
"fr": "Détail",
|
||||||
|
"en": "Detail",
|
||||||
|
},
|
||||||
|
"Enter on a step or a version = replay from there": {
|
||||||
|
"fr": "Entrée sur une étape ou une version = rejouer depuis là",
|
||||||
|
"en": "Enter on a step or a version = replay from there",
|
||||||
|
},
|
||||||
|
"New migration": {
|
||||||
|
"fr": "Nouvelle migration",
|
||||||
|
"en": "New migration",
|
||||||
|
},
|
||||||
|
"Keep the zip only": {
|
||||||
|
"fr": "Garder seulement le zip",
|
||||||
|
"en": "Keep the zip only",
|
||||||
|
},
|
||||||
"Reset all preferences": {
|
"Reset all preferences": {
|
||||||
"fr": "🧹 Réinitialiser toutes les préférences",
|
"fr": "🧹 Réinitialiser toutes les préférences",
|
||||||
"en": "🧹 Reset all preferences",
|
"en": "🧹 Reset all preferences",
|
||||||
|
|
@ -1952,6 +2146,26 @@ TRANSLATIONS = {
|
||||||
"fr": "Échec de la commande : ",
|
"fr": "Échec de la commande : ",
|
||||||
"en": "Command failed: ",
|
"en": "Command failed: ",
|
||||||
},
|
},
|
||||||
|
"Textual is required for this screen.": {
|
||||||
|
"fr": "Textual est nécessaire pour cet écran.",
|
||||||
|
"en": "Textual is required for this screen.",
|
||||||
|
},
|
||||||
|
"Install it now? (Y/n): ": {
|
||||||
|
"fr": "L'installer maintenant ? (O/n, défaut : oui) : ",
|
||||||
|
"en": "Install it now? (Y/n, default: yes): ",
|
||||||
|
},
|
||||||
|
"Textual is installed.": {
|
||||||
|
"fr": "Textual est installé.",
|
||||||
|
"en": "Textual is installed.",
|
||||||
|
},
|
||||||
|
"Installation finished but textual is still missing.": {
|
||||||
|
"fr": "Installation terminée mais textual reste introuvable.",
|
||||||
|
"en": "Installation finished but textual is still missing.",
|
||||||
|
},
|
||||||
|
"pip exited with": {
|
||||||
|
"fr": "pip a retourné",
|
||||||
|
"en": "pip exited with",
|
||||||
|
},
|
||||||
"Your distribution may package it as python3-textual.": {
|
"Your distribution may package it as python3-textual.": {
|
||||||
"fr": "Votre distribution le fournit peut-être en python3-textual.",
|
"fr": "Votre distribution le fournit peut-être en python3-textual.",
|
||||||
"en": "Your distribution may package it as python3-textual.",
|
"en": "Your distribution may package it as python3-textual.",
|
||||||
|
|
@ -3125,6 +3339,85 @@ TRANSLATIONS = {
|
||||||
"recorded runs": {"fr": "runs enregistrés", "en": "recorded runs"},
|
"recorded runs": {"fr": "runs enregistrés", "en": "recorded runs"},
|
||||||
"runs erased": {"fr": "runs effacés", "en": "runs erased"},
|
"runs erased": {"fr": "runs effacés", "en": "runs erased"},
|
||||||
"Cancelled.": {"fr": "Annulé.", "en": "Cancelled."},
|
"Cancelled.": {"fr": "Annulé.", "en": "Cancelled."},
|
||||||
|
# Database migration - resume menu
|
||||||
|
"Migration in progress": {
|
||||||
|
"fr": "Migration en cours",
|
||||||
|
"en": "Migration in progress",
|
||||||
|
},
|
||||||
|
"File": {"fr": "Fichier", "en": "File"},
|
||||||
|
"Database": {"fr": "Base", "en": "Database"},
|
||||||
|
"Target": {"fr": "Cible", "en": "Target"},
|
||||||
|
"Started": {"fr": "Démarrée", "en": "Started"},
|
||||||
|
"Steps": {"fr": "Étapes", "en": "Steps"},
|
||||||
|
"Prepare the environment": {
|
||||||
|
"fr": "Préparer l'environnement",
|
||||||
|
"en": "Prepare the environment",
|
||||||
|
},
|
||||||
|
"Restore and neutralize the database": {
|
||||||
|
"fr": "Restaurer et neutraliser la base",
|
||||||
|
"en": "Restore and neutralize the database",
|
||||||
|
},
|
||||||
|
"Update all addons": {
|
||||||
|
"fr": "Mettre à jour tous les modules",
|
||||||
|
"en": "Update all addons",
|
||||||
|
},
|
||||||
|
"Clean up before data migration": {
|
||||||
|
"fr": "Nettoyer avant la migration des données",
|
||||||
|
"en": "Clean up before data migration",
|
||||||
|
},
|
||||||
|
"Upgrade version by version (OpenUpgrade)": {
|
||||||
|
"fr": "Monter de version en version (OpenUpgrade)",
|
||||||
|
"en": "Upgrade version by version (OpenUpgrade)",
|
||||||
|
},
|
||||||
|
"not started": {"fr": "non démarrée", "en": "not started"},
|
||||||
|
"done": {"fr": "terminée", "en": "done"},
|
||||||
|
"partially done": {"fr": "partielle", "en": "partially done"},
|
||||||
|
"version bumps migrated": {
|
||||||
|
"fr": "montées de version faites",
|
||||||
|
"en": "version bumps migrated",
|
||||||
|
},
|
||||||
|
"Continue where it stopped": {
|
||||||
|
"fr": "Continuer là où ça s'est arrêté",
|
||||||
|
"en": "Continue where it stopped",
|
||||||
|
},
|
||||||
|
"Replay from that step": {
|
||||||
|
"fr": "Reprendre à partir de cette étape",
|
||||||
|
"en": "Replay from that step",
|
||||||
|
},
|
||||||
|
"erases the progression of that step and the next ones": {
|
||||||
|
"fr": "efface la progression de cette étape et des suivantes",
|
||||||
|
"en": "erases the progression of that step and the next ones",
|
||||||
|
},
|
||||||
|
"New migration, erase everything": {
|
||||||
|
"fr": "Nouvelle migration, tout effacer",
|
||||||
|
"en": "New migration, erase everything",
|
||||||
|
},
|
||||||
|
"Keep the zip only, ask every question again": {
|
||||||
|
"fr": "Garder seulement le zip, reposer toutes les questions",
|
||||||
|
"en": "Keep the zip only, ask every question again",
|
||||||
|
},
|
||||||
|
"Your choice": {"fr": "Votre choix", "en": "Your choice"},
|
||||||
|
"Unknown choice, continuing where it stopped": {
|
||||||
|
"fr": "Choix inconnu, on continue là où ça s'est arrêté",
|
||||||
|
"en": "Unknown choice, continuing where it stopped",
|
||||||
|
},
|
||||||
|
"Replaying from step": {
|
||||||
|
"fr": "Reprise à partir de l'étape",
|
||||||
|
"en": "Replaying from step",
|
||||||
|
},
|
||||||
|
"Replay the upgrade from version N": {
|
||||||
|
"fr": "Reprendre la montée à partir de la version N",
|
||||||
|
"en": "Replay the upgrade from version N",
|
||||||
|
},
|
||||||
|
"rebuilds the intermediate database": {
|
||||||
|
"fr": "recrée la base intermédiaire",
|
||||||
|
"en": "rebuilds the intermediate database",
|
||||||
|
},
|
||||||
|
"Unknown version": {"fr": "Version inconnue", "en": "Unknown version"},
|
||||||
|
"The progression file is invalid, ignoring it": {
|
||||||
|
"fr": "Le fichier de progression est invalide, on l'ignore",
|
||||||
|
"en": "The progression file is invalid, ignoring it",
|
||||||
|
},
|
||||||
# --- Menu Execute › Analyse ---
|
# --- Menu Execute › Analyse ---
|
||||||
# L'émoji vit dans la valeur, jamais dans la clé. Deux clés distinctes,
|
# L'émoji vit dans la valeur, jamais dans la clé. Deux clés distinctes,
|
||||||
# comme « Data » / « Database » : l'entrée de menu porte l'émoji,
|
# comme « Data » / « Database » : l'entrée de menu porte l'émoji,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue