From 848335086c5a710442a0f859acbe71bcf95c49e2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 30 Jul 2026 13:08:28 +0530 Subject: [PATCH 1/2] fix: seed standard Item Groups under the existing tree root install_fixtures always inserted "All Item Groups" as a parentless group. On a site where another app had already created the root, ItemGroup.validate re-parented it, leaving a second group-root that held the standard groups while the real root held everything else. This is reproducible with the healthcare app on a non-English site: its after_install seeds the root as _("All Item Groups"), so a pt-BR site gets "Todos os Grupos de Itens" as the root before the setup wizard runs. The split predates #57390 -- the old translated-name lookup resolved to the same root and produced an identical tree. Resolve the root once with get_root_of (falling back to the canonical English name on fresh installs) and use it for the root record's exists-guard and the standard groups' parent, matching Company.create_default_departments. Patch merges an already-seeded "All Item Groups" into the root it sits under, lifting its children and repointing every link. Closes #57581 (cherry picked from commit e7088d89812aaca79cedc66e818206a9fad712c5) # Conflicts: # erpnext/setup/doctype/item_group/test_item_group.py # erpnext/setup/setup_wizard/operations/install_fixtures.py --- erpnext/patches.txt | 1 + .../v16_0/merge_seeded_item_group_root.py | 23 ++++++ .../doctype/item_group/test_item_group.py | 76 +++++++++++++++++++ .../operations/install_fixtures.py | 19 +++-- 4 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 erpnext/patches/v16_0/merge_seeded_item_group_root.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 022b33cac10e..9ca27a734d81 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -446,3 +446,4 @@ erpnext.patches.v16_0.access_control_for_project_users erpnext.patches.v16_0.rename_ar_ap_ageing_filter erpnext.patches.v15_0.fix_titles erpnext.patches.v16_0.backfill_repost_accounting_ledger_status +erpnext.patches.v16_0.merge_seeded_item_group_root diff --git a/erpnext/patches/v16_0/merge_seeded_item_group_root.py b/erpnext/patches/v16_0/merge_seeded_item_group_root.py new file mode 100644 index 000000000000..95683fc96f02 --- /dev/null +++ b/erpnext/patches/v16_0/merge_seeded_item_group_root.py @@ -0,0 +1,23 @@ +import frappe +from frappe.utils.nestedset import get_root_of + +SEEDED_ROOT = "All Item Groups" + + +def execute(): + """Collapse the "All Item Groups" node seeded under a pre-existing root. + + Setup seeding always inserted "All Item Groups" as a parentless group. On a + site where another app had already created the root (under a translated + name), it was re-parented instead, leaving a second group-root holding the + standard Item Groups. + """ + root = get_root_of("Item Group") + if not root or root == SEEDED_ROOT: + return + + seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True) + if not seeded or not seeded.is_group or seeded.parent_item_group != root: + return + + frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False) diff --git a/erpnext/setup/doctype/item_group/test_item_group.py b/erpnext/setup/doctype/item_group/test_item_group.py index a579fb703da1..2f14d1ec925c 100644 --- a/erpnext/setup/doctype/item_group/test_item_group.py +++ b/erpnext/setup/doctype/item_group/test_item_group.py @@ -1,8 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +<<<<<<< HEAD import unittest +======= +from unittest.mock import patch +>>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) import frappe from frappe.utils.nestedset import ( @@ -16,6 +20,8 @@ test_records = frappe.get_test_records("Item Group") +TRANSLATED_ROOT = "Todos os Grupos de Itens" + class TestItem(unittest.TestCase): def test_basic_tree(self, records=None): @@ -234,3 +240,73 @@ def test_merge_group_into_leaf(self): "_Test Item Group B - 3", merge=True, ) +<<<<<<< HEAD +======= + + def test_preset_records_use_existing_root(self): + from erpnext.setup.setup_wizard.operations import install_fixtures + + with patch.object(install_fixtures, "get_root_of", return_value=TRANSLATED_ROOT): + records = [ + r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" + ] + + root_record, *child_records = records + self.assertEqual(root_record["item_group_name"], TRANSLATED_ROOT) + self.assertTrue(root_record["__condition"]()) + self.assertEqual({r["parent_item_group"] for r in child_records}, {TRANSLATED_ROOT}) + + with patch.object(install_fixtures, "get_root_of", return_value="All Item Groups"): + root_record = next( + r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" + ) + self.assertFalse(root_record["__condition"]()) + + def test_patch_merges_seeded_root_into_existing_root(self): + from erpnext.patches.v16_0.merge_seeded_item_group_root import execute + + self._nest_root_under(TRANSLATED_ROOT) + self.assertEqual( + frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT + ) + + execute() + + self.assertFalse(frappe.db.exists("Item Group", "All Item Groups")) + self.assertEqual( + frappe.get_all("Item Group", filters={"parent_item_group": ("is", "not set")}, pluck="name"), + [TRANSLATED_ROOT], + ) + self.assertEqual( + frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT + ) + self.test_basic_tree() + + def _nest_root_under(self, new_root): + """Recreate the tree left behind by seeding a root under a pre-existing one.""" + frappe.get_doc({"doctype": "Item Group", "item_group_name": new_root, "is_group": 1}).insert() + + ig = frappe.qb.DocType("Item Group") + frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run() + frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run() + rebuild_tree("Item Group") + + def _move_it_back(self): + group_b = frappe.get_doc("Item Group", "_Test Item Group B") + group_b.parent_item_group = "All Item Groups" + group_b.save() + self.test_basic_tree() + + def _get_no_of_children(self, item_group): + def get_no_of_children(item_groups, no_of_children): + children = [] + for ig in item_groups: + children += frappe.get_all("Item Group", filters={"parent_item_group": ig}, pluck="name") + + if len(children): + return get_no_of_children(children, no_of_children + len(children)) + else: + return no_of_children + + return get_no_of_children([item_group], 0) +>>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 0f3356ffa50e..8bc1aa515ac3 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -13,6 +13,7 @@ ) from frappe.desk.page.setup_wizard.setup_wizard import make_records from frappe.utils import cstr, getdate +from frappe.utils.nestedset import get_root_of from erpnext.accounts.doctype.account.account import RootNotEditable from erpnext.regional.address_template.setup import set_up_address_templates @@ -23,47 +24,53 @@ def read_lines(filename: str) -> list[str]: return (Path(__file__).parent.parent / "data" / filename).read_text().splitlines() +<<<<<<< HEAD def install(country=None): +======= +def get_preset_records(country=None): + root_item_group = get_root_of("Item Group") or _("All Item Groups") +>>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) records = [ # ensure at least an empty Address Template exists for this Country {"doctype": "Address Template", "country": country}, # item group { "doctype": "Item Group", - "item_group_name": _("All Item Groups"), + "item_group_name": root_item_group, "is_group": 1, "parent_item_group": "", + "__condition": lambda: not frappe.db.exists("Item Group", root_item_group), }, { "doctype": "Item Group", "item_group_name": _("Products"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, "show_in_website": 1, }, { "doctype": "Item Group", "item_group_name": _("Raw Material"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Services"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Sub Assemblies"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, { "doctype": "Item Group", "item_group_name": _("Consumable"), "is_group": 0, - "parent_item_group": _("All Item Groups"), + "parent_item_group": root_item_group, }, # Stock Entry Type { From 1602639a8065c44301b3d1d18a533f9b1cb7c6b4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 30 Jul 2026 19:22:29 +0530 Subject: [PATCH 2/2] fix: resolve backport conflicts for version-15 install() holds the preset list inline on this branch, so the root is resolved there instead of in get_preset_records. Dropping the preset-record test with it -- there is no seam to call without running the whole installer. The patch test is adapted to this branch: TestItem does not roll back between tests, so it restores the original root name, and it passes parent_item_group explicitly since ItemGroup.validate skips root-defaulting under frappe.flags.in_test. --- .../doctype/item_group/test_item_group.py | 73 ++++++------------- .../operations/install_fixtures.py | 4 - 2 files changed, 21 insertions(+), 56 deletions(-) diff --git a/erpnext/setup/doctype/item_group/test_item_group.py b/erpnext/setup/doctype/item_group/test_item_group.py index 2f14d1ec925c..09a5e64acd41 100644 --- a/erpnext/setup/doctype/item_group/test_item_group.py +++ b/erpnext/setup/doctype/item_group/test_item_group.py @@ -1,12 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -<<<<<<< HEAD import unittest -======= -from unittest.mock import patch ->>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) import frappe from frappe.utils.nestedset import ( @@ -240,32 +236,11 @@ def test_merge_group_into_leaf(self): "_Test Item Group B - 3", merge=True, ) -<<<<<<< HEAD -======= - - def test_preset_records_use_existing_root(self): - from erpnext.setup.setup_wizard.operations import install_fixtures - - with patch.object(install_fixtures, "get_root_of", return_value=TRANSLATED_ROOT): - records = [ - r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" - ] - - root_record, *child_records = records - self.assertEqual(root_record["item_group_name"], TRANSLATED_ROOT) - self.assertTrue(root_record["__condition"]()) - self.assertEqual({r["parent_item_group"] for r in child_records}, {TRANSLATED_ROOT}) - - with patch.object(install_fixtures, "get_root_of", return_value="All Item Groups"): - root_record = next( - r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group" - ) - self.assertFalse(root_record["__condition"]()) def test_patch_merges_seeded_root_into_existing_root(self): from erpnext.patches.v16_0.merge_seeded_item_group_root import execute - self._nest_root_under(TRANSLATED_ROOT) + self.nest_root_under(TRANSLATED_ROOT) self.assertEqual( frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT ) @@ -273,40 +248,34 @@ def test_patch_merges_seeded_root_into_existing_root(self): execute() self.assertFalse(frappe.db.exists("Item Group", "All Item Groups")) - self.assertEqual( - frappe.get_all("Item Group", filters={"parent_item_group": ("is", "not set")}, pluck="name"), - [TRANSLATED_ROOT], - ) + self.assertEqual(self.get_root_names(), [TRANSLATED_ROOT]) self.assertEqual( frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT ) self.test_basic_tree() - def _nest_root_under(self, new_root): + # restore the original root name for the tests that follow + frappe.rename_doc("Item Group", TRANSLATED_ROOT, "All Item Groups") + self.assertEqual(self.get_root_names(), ["All Item Groups"]) + self.test_basic_tree() + + def nest_root_under(self, new_root): """Recreate the tree left behind by seeding a root under a pre-existing one.""" - frappe.get_doc({"doctype": "Item Group", "item_group_name": new_root, "is_group": 1}).insert() + frappe.get_doc( + { + "doctype": "Item Group", + "item_group_name": new_root, + "is_group": 1, + "parent_item_group": "All Item Groups", + } + ).insert() ig = frappe.qb.DocType("Item Group") frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run() frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run() - rebuild_tree("Item Group") - - def _move_it_back(self): - group_b = frappe.get_doc("Item Group", "_Test Item Group B") - group_b.parent_item_group = "All Item Groups" - group_b.save() - self.test_basic_tree() - - def _get_no_of_children(self, item_group): - def get_no_of_children(item_groups, no_of_children): - children = [] - for ig in item_groups: - children += frappe.get_all("Item Group", filters={"parent_item_group": ig}, pluck="name") - - if len(children): - return get_no_of_children(children, no_of_children + len(children)) - else: - return no_of_children + rebuild_tree("Item Group", "parent_item_group") - return get_no_of_children([item_group], 0) ->>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) + def get_root_names(self): + return frappe.db.sql_list( + """select name from `tabItem Group` where ifnull(parent_item_group, '')=''""" + ) diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 8bc1aa515ac3..6197873c6fb2 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -24,12 +24,8 @@ def read_lines(filename: str) -> list[str]: return (Path(__file__).parent.parent / "data" / filename).read_text().splitlines() -<<<<<<< HEAD def install(country=None): -======= -def get_preset_records(country=None): root_item_group = get_root_of("Item Group") or _("All Item Groups") ->>>>>>> e7088d8981 (fix: seed standard Item Groups under the existing tree root) records = [ # ensure at least an empty Address Template exists for this Country {"doctype": "Address Template", "country": country},