[FIX] migration: tell modules with no code from those to uninstall

Uninstalling before the 13->14 bump asked for 15 modules; 12 of them no longer
exist in the 13.0 addons path. check_addons_exist.py rejects the command on
the first missing name, so the uninstall aborted whole — and the 3 modules
that WERE there stayed installed, blocking the bump for a reason that had
nothing to do with them.

The list is now split before the script is called, using check_addons_exist
which was already there and simply never consulted at this point. « Missing »
means the addons path has no code for it, not that the database lacks it —
the distinction is the whole point, since Odoo cannot uninstall a module whose
code is gone.

When some are missing, a choice is offered rather than a failure:

  [1] uninstall the present ones, skip the missing   (default)
  [2] try the whole list anyway (it will fail)
  [3] uninstall nothing, continue

Only what is really uninstalled is subtracted from the per-version module
list, so a module left in place stays counted as installed — which it is. The
missing names are written to the progression comments, so what was skipped is
still on record afterwards.

Verified on the exact list from the failure: 3 present and 12 missing,
option 1 issuing a command with only the 3 and leaving the 12 counted as
installed, option 2 sending all 15, option 3 running nothing.

Also swapped entries 4 and 5 of the Deploy menu — QEMU/KVM now sits at [4],
NTFY at [5].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-01 07:16:26 -04:00
parent d40e6aa011
commit 7d96b56fb4
3 changed files with 110 additions and 5 deletions

View file

@ -879,12 +879,12 @@ class TODO:
{"prompt_description": t("SSH (remote host)...")},
{
"prompt_description": t(
"Deploy - Install NTFY notification server"
"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"
)
},
{
"prompt_description": t(
"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"
"Deploy - Install NTFY notification server"
)
},
]
@ -902,9 +902,9 @@ class TODO:
elif status == "3":
self.prompt_execute_deploy_ssh()
elif status == "4":
self._deploy_ntfy_server()
elif status == "5":
self.prompt_execute_qemu()
elif status == "5":
self._deploy_ntfy_server()
else:
print(t("Command not found !"))

View file

@ -1403,6 +1403,43 @@ TRANSLATIONS = {
"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.",
},
"No migration in progress to resume.": {
"fr": "Aucune migration en cours à reprendre.",
"en": "No migration in progress to resume.",

View file

@ -2264,18 +2264,86 @@ class TodoUpgrade:
lst_detail.append((module_name, reason, file_path))
return lst_module, lst_detail
def split_present_missing(self, lst_module):
"""Split a module list into (present, missing) against the ACTIVE code.
« Missing » means the addons path no longer holds the module not
that it is absent from the database. The distinction matters because
check_addons_exist.py refuses the WHOLE uninstall for a single missing
name, so the modules that ARE there never get uninstalled either.
"""
lst_missing, _lst_duplicate = self.check_addons_exist(lst_module)
set_missing = set(lst_missing or [])
return (
[name for name in lst_module if name not in set_missing],
[name for name in lst_module if name in set_missing],
)
def prompt_uninstall_missing(self, lst_present, lst_missing):
"""Ask what to do when part of the list has no code left.
Returns the list to actually uninstall (possibly empty)."""
print()
print(
f"⚠️ {len(lst_missing)} "
f"{t('modules of the list have no code in the active Odoo:')}"
)
for name in lst_missing:
print(f" {name}")
print(f" {t('Odoo cannot uninstall a module whose code is gone;')}")
print(f" {t('one of them fails the whole uninstall command.')}")
print()
if lst_present:
print(
f" {len(lst_present)} "
f"{t('are present and can be uninstalled:')}"
)
for name in lst_present:
print(f" {name}")
else:
print(f" {t('No module of the list is present.')}")
print()
if lst_present:
print(
f" [1] {t('Uninstall the present ones, skip the missing')}"
" *"
)
print(f" [2] {t('Try the whole list anyway (it will fail)')}")
print(f" [3] {t('Uninstall nothing, continue')}")
answer = input(f"💬 {t('Your choice')} : ").strip()
if answer == "2":
return lst_present + lst_missing
if answer == "3" or not lst_present:
return []
return lst_present
def uninstall_from_database(
self, lst_module_to_uninstall, database_name, actual_version
):
if not lst_module_to_uninstall:
return
# Sort out what the active code still holds BEFORE calling the script:
# it aborts on the first missing name and takes the rest down with it.
lst_present, lst_missing = self.split_present_missing(
lst_module_to_uninstall
)
if lst_missing:
self.add_comment_progression(
"uninstall - no code for: " + ", ".join(lst_missing)
)
lst_module_to_uninstall = self.prompt_uninstall_missing(
lst_present, lst_missing
)
if not lst_module_to_uninstall:
print(f"{t('Nothing uninstalled.')}")
return
uninstall_module = ",".join(lst_module_to_uninstall)
self.todo_upgrade_execute(
f"./script/addons/uninstall_addons.sh {database_name} {uninstall_module}",
single_source_odoo=True,
)
# Update list installed module
# Update list installed module — only what was REALLY uninstalled, so
# a module left in place stays counted as installed.
self.dct_module_per_version[actual_version] = sorted(
list(
set(self.dct_module_per_version[actual_version])