From c2d126fdbfe11b5961e2d533ac6b2d854bd8ff7c Mon Sep 17 00:00:00 2001 From: Guru107 Date: Mon, 27 Jul 2026 11:37:42 +0530 Subject: [PATCH 1/5] feat: surface NPD Tooling in the Project Connections tab Extend the Project form's dashboard via the override_doctype_dashboards hook to add a 'Tooling' group linking NPD Tooling. NPD Tooling links to Project through its 'project' field, matching the Project dashboard's default fieldname, so the connection count resolves automatically. The override is additive and idempotent. Verified on v15 and v16 (72/72). --- npd_project_module/hooks.py | 6 ++-- npd_project_module/tests/test_npd_tooling.py | 16 ++++++++++ .../utils/dashboard_overrides.py | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 npd_project_module/utils/dashboard_overrides.py diff --git a/npd_project_module/hooks.py b/npd_project_module/hooks.py index e820aaf..8e97ffc 100644 --- a/npd_project_module/hooks.py +++ b/npd_project_module/hooks.py @@ -187,9 +187,9 @@ # each overriding function accepts a `data` argument; # generated from the base implementation of the doctype dashboard, # along with any modifications made in other Frappe apps -# override_doctype_dashboards = { -# "Task": "npd_project_module.task.get_dashboard_data" -# } +override_doctype_dashboards = { + "Project": "npd_project_module.utils.dashboard_overrides.get_project_dashboard_data" +} # exempt linked doctypes from being automatically cancelled # diff --git a/npd_project_module/tests/test_npd_tooling.py b/npd_project_module/tests/test_npd_tooling.py index 339444f..1764e31 100644 --- a/npd_project_module/tests/test_npd_tooling.py +++ b/npd_project_module/tests/test_npd_tooling.py @@ -29,6 +29,7 @@ make_test_item, make_test_project_with_parts, ) +from npd_project_module.utils.dashboard_overrides import get_project_dashboard_data class TestNPDTooling(NPDProjectModuleTestSuite): @@ -513,6 +514,21 @@ def test_fully_recovered_within_rounding_tolerance(self): self.assertEqual(compute_recovery_status(1000, 999.995), "Fully Recovered") +class TestProjectDashboardConnection(NPDProjectModuleTestSuite): + """The Project form's Connections should surface NPD Tooling.""" + + def test_adds_tooling_group(self): + data = get_project_dashboard_data({"transactions": []}) + self.assertTrue(any("NPD Tooling" in (g.get("items") or []) for g in data["transactions"])) + + def test_idempotent(self): + # Running twice (or over data that already lists it) must not duplicate the entry. + once = get_project_dashboard_data({"transactions": []}) + twice = get_project_dashboard_data(once) + count = sum((g.get("items") or []).count("NPD Tooling") for g in twice["transactions"]) + self.assertEqual(count, 1) + + class TestRecoveredComputation(NPDProjectModuleTestSuite): """Pure-logic tests for combining invoice payments with manual advance allocations.""" diff --git a/npd_project_module/utils/dashboard_overrides.py b/npd_project_module/utils/dashboard_overrides.py new file mode 100644 index 0000000..6c07b32 --- /dev/null +++ b/npd_project_module/utils/dashboard_overrides.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026, Guru107 and contributors +# For license information, please see license.txt + +"""Dashboard (Connections) overrides for core doctypes.""" + +import frappe +from frappe import _ + + +def get_project_dashboard_data(data): + """Add a "Tooling" group with NPD Tooling to the Project form's Connections. + + NPD Tooling links to Project through its `project` field, which matches the Project + dashboard's default fieldname, so the connection count resolves automatically. + """ + data = frappe._dict(data or {}) + data.setdefault("transactions", []) + + if not _has_item(data.transactions, "NPD Tooling"): + data.transactions.append({"label": _("Tooling"), "items": ["NPD Tooling"]}) + + return data + + +def _has_item(transactions, doctype): + """True if `doctype` already appears in any transactions group (avoids duplicates).""" + for group in transactions: + if doctype in frappe._dict(group).get("items", []): + return True + return False From d0c034fee57fd482c811cd7e8b81d0c358b12364 Mon Sep 17 00:00:00 2001 From: Guru107 Date: Mon, 27 Jul 2026 11:57:19 +0530 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20add=20Project=E2=86=92NPD=20Too?= =?UTF-8?q?ling=20connection=20via=20custom=20DocType=20Link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the override_doctype_dashboards hook with a custom DocType Link on Project, created idempotently in after_install (and removed in before_uninstall). This is the declarative, UI-visible Frappe mechanism for Connections and matches the app's existing after_install pattern for Project customizations. The custom (custom=1) link is preserved across bench migrate (verified), and its link_fieldname 'project' matches the Project dashboard's default fieldname so the connection count resolves automatically. Removes utils/dashboard_overrides.py and its hook; tests now assert the link surfaces in Project's dashboard data and that creation is idempotent. --- npd_project_module/hooks.py | 9 ++++-- npd_project_module/install/after_install.py | 29 ++++++++++++++++++ npd_project_module/tests/test_npd_tooling.py | 27 ++++++++++------- .../uninstall/before_uninstall.py | 15 ++++++++++ .../utils/dashboard_overrides.py | 30 ------------------- 5 files changed, 67 insertions(+), 43 deletions(-) delete mode 100644 npd_project_module/utils/dashboard_overrides.py diff --git a/npd_project_module/hooks.py b/npd_project_module/hooks.py index 8e97ffc..2df9e86 100644 --- a/npd_project_module/hooks.py +++ b/npd_project_module/hooks.py @@ -187,9 +187,12 @@ # each overriding function accepts a `data` argument; # generated from the base implementation of the doctype dashboard, # along with any modifications made in other Frappe apps -override_doctype_dashboards = { - "Project": "npd_project_module.utils.dashboard_overrides.get_project_dashboard_data" -} +# override_doctype_dashboards = { +# "Task": "npd_project_module.task.get_dashboard_data" +# } +# +# The Project → NPD Tooling connection is added as a custom DocType Link in +# install/after_install.py (add_project_tooling_connection) instead of via this hook. # exempt linked doctypes from being automatically cancelled # diff --git a/npd_project_module/install/after_install.py b/npd_project_module/install/after_install.py index 62f092b..f2c90d7 100644 --- a/npd_project_module/install/after_install.py +++ b/npd_project_module/install/after_install.py @@ -43,6 +43,35 @@ def create_tooling_setup(): else: print(f" ✓ Item Group '{item_group}' already exists") + add_project_tooling_connection() + + +def add_project_tooling_connection(): + """Surface NPD Tooling in the Project form's Connections via a custom DocType Link. + + NPD Tooling links to Project through its `project` field, matching the Project + dashboard's default fieldname, so the connection count resolves automatically. A + custom (custom=1) link is preserved across `bench migrate`. Idempotent. + """ + if frappe.db.exists("DocType Link", {"parent": "Project", "link_doctype": "NPD Tooling", "custom": 1}): + print(" ✓ Project → NPD Tooling connection already present") + return + + frappe.get_doc( + { + "doctype": "DocType Link", + "parent": "Project", + "parenttype": "DocType", + "parentfield": "links", + "link_doctype": "NPD Tooling", + "link_fieldname": "project", + "group": "Tooling", + "custom": 1, + } + ).insert(ignore_permissions=True) + frappe.clear_cache(doctype="Project") + print(" ✓ Project → NPD Tooling connection added") + def create_task_custom_fields(): """ diff --git a/npd_project_module/tests/test_npd_tooling.py b/npd_project_module/tests/test_npd_tooling.py index 1764e31..7da4d6f 100644 --- a/npd_project_module/tests/test_npd_tooling.py +++ b/npd_project_module/tests/test_npd_tooling.py @@ -16,6 +16,7 @@ import frappe from frappe.utils import add_days, getdate, today +from npd_project_module.install.after_install import add_project_tooling_connection from npd_project_module.npd_project_module.doctype.npd_tooling.npd_tooling import ( compute_recovered, compute_recovery_status, @@ -29,7 +30,6 @@ make_test_item, make_test_project_with_parts, ) -from npd_project_module.utils.dashboard_overrides import get_project_dashboard_data class TestNPDTooling(NPDProjectModuleTestSuite): @@ -515,18 +515,25 @@ def test_fully_recovered_within_rounding_tolerance(self): class TestProjectDashboardConnection(NPDProjectModuleTestSuite): - """The Project form's Connections should surface NPD Tooling.""" + """The Project form's Connections should surface NPD Tooling via a custom DocType Link.""" - def test_adds_tooling_group(self): - data = get_project_dashboard_data({"transactions": []}) - self.assertTrue(any("NPD Tooling" in (g.get("items") or []) for g in data["transactions"])) + def _tooling_in_connections(self): + data = frappe.get_meta("Project").get_dashboard_data() + return any("NPD Tooling" in (g.get("items") or []) for g in data.get("transactions", [])) + + def test_connection_present(self): + add_project_tooling_connection() + frappe.clear_cache(doctype="Project") + self.assertTrue( + frappe.db.exists("DocType Link", {"parent": "Project", "link_doctype": "NPD Tooling"}) + ) + self.assertTrue(self._tooling_in_connections()) def test_idempotent(self): - # Running twice (or over data that already lists it) must not duplicate the entry. - once = get_project_dashboard_data({"transactions": []}) - twice = get_project_dashboard_data(once) - count = sum((g.get("items") or []).count("NPD Tooling") for g in twice["transactions"]) - self.assertEqual(count, 1) + add_project_tooling_connection() + add_project_tooling_connection() # second call must not duplicate + links = frappe.get_all("DocType Link", filters={"parent": "Project", "link_doctype": "NPD Tooling"}) + self.assertEqual(len(links), 1) class TestRecoveredComputation(NPDProjectModuleTestSuite): diff --git a/npd_project_module/uninstall/before_uninstall.py b/npd_project_module/uninstall/before_uninstall.py index 6ed7ebb..88c6261 100644 --- a/npd_project_module/uninstall/before_uninstall.py +++ b/npd_project_module/uninstall/before_uninstall.py @@ -124,6 +124,21 @@ def remove_tooling_setup(): except Exception as e: print(f" ✗ Error removing Item Group {item_group}: {e!s}") + # Remove the Project → NPD Tooling connection (custom DocType Link). + try: + links = frappe.get_all( + "DocType Link", + filters={"parent": "Project", "link_doctype": "NPD Tooling", "custom": 1}, + pluck="name", + ) + for name in links: + frappe.delete_doc("DocType Link", name, force=True, ignore_permissions=True) + if links: + frappe.clear_cache(doctype="Project") + print(" ✓ Removed Project → NPD Tooling connection") + except Exception as e: + print(f" ✗ Error removing Project → NPD Tooling connection: {e!s}") + def remove_custom_doctype(): """ diff --git a/npd_project_module/utils/dashboard_overrides.py b/npd_project_module/utils/dashboard_overrides.py deleted file mode 100644 index 6c07b32..0000000 --- a/npd_project_module/utils/dashboard_overrides.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) 2026, Guru107 and contributors -# For license information, please see license.txt - -"""Dashboard (Connections) overrides for core doctypes.""" - -import frappe -from frappe import _ - - -def get_project_dashboard_data(data): - """Add a "Tooling" group with NPD Tooling to the Project form's Connections. - - NPD Tooling links to Project through its `project` field, which matches the Project - dashboard's default fieldname, so the connection count resolves automatically. - """ - data = frappe._dict(data or {}) - data.setdefault("transactions", []) - - if not _has_item(data.transactions, "NPD Tooling"): - data.transactions.append({"label": _("Tooling"), "items": ["NPD Tooling"]}) - - return data - - -def _has_item(transactions, doctype): - """True if `doctype` already appears in any transactions group (avoids duplicates).""" - for group in transactions: - if doctype in frappe._dict(group).get("items", []): - return True - return False From 4024d3f4eb81d0e0ef737bfc31b48b0ab448401f Mon Sep 17 00:00:00 2001 From: Guru107 Date: Mon, 27 Jul 2026 12:09:32 +0530 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20add=20migrate=20patch=20for=20Proje?= =?UTF-8?q?ct=E2=86=92NPD=20Tooling=20connection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit after_install adds the connection on fresh installs, but existing sites that already applied the earlier tooling patch won't get it from that (already-logged) patch. Add a dedicated patch so bench migrate ensures the connection on existing installs. Reuses the idempotent helper; verified on v15 and v16 (removed the link, migrated, patch re-added it). --- npd_project_module/patches.txt | 1 + .../v1_0/add_project_tooling_connection.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 npd_project_module/patches/v1_0/add_project_tooling_connection.py diff --git a/npd_project_module/patches.txt b/npd_project_module/patches.txt index 6fe8032..e1aa9f6 100644 --- a/npd_project_module/patches.txt +++ b/npd_project_module/patches.txt @@ -6,3 +6,4 @@ # Patches added in this section will be executed after doctypes are migrated # Note: Initial setup (custom fields, child tables) is handled in after_install hook npd_project_module.patches.v1_0.create_tooling_item_group +npd_project_module.patches.v1_0.add_project_tooling_connection diff --git a/npd_project_module/patches/v1_0/add_project_tooling_connection.py b/npd_project_module/patches/v1_0/add_project_tooling_connection.py new file mode 100644 index 0000000..c0241c9 --- /dev/null +++ b/npd_project_module/patches/v1_0/add_project_tooling_connection.py @@ -0,0 +1,16 @@ +# Copyright (c) 2026, Guru107 and contributors +# For license information, please see license.txt + +""" +Add the Project → NPD Tooling connection on existing installs. + +Sites that ran the earlier tooling patch before this connection existed won't get it +from that (already-applied) patch, so this dedicated patch ensures it on `bench migrate`. +Reuses the idempotent helper from after_install. +""" + +from npd_project_module.install.after_install import add_project_tooling_connection + + +def execute(): + add_project_tooling_connection() From b2df1ad83693adffcccc1d67aecfc9c8dd50ef97 Mon Sep 17 00:00:00 2001 From: Guru107 Date: Mon, 27 Jul 2026 07:14:17 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20review=20of=20Project?= =?UTF-8?q?=E2=86=92NPD=20Tooling=20connection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durability (spec review): custom=1 only protects a DocType Link from Customize Form rewrites, not from a doctype re-import — when project.json changes, Frappe reloads it via delete_doc(for_reload=True), which drops every DocType Link row on Project, custom ones included. The one-shot patch could not recover from that once logged. Replace it with an after_migrate hook that re-asserts the connection on every migrate (verified: deleted the link on a site whose Patch Log already contained the old patch, migrated, connection restored and visible in Connections). Idempotency guard: match on parent + link_doctype instead of pinning custom=1, so a site carrying the same connection as a standard (custom=0) row from fixtures no longer gets a duplicate listed in Connections. Uninstall still deletes only the custom rows this app creates. Standards review: - Call add_project_tooling_connection() from after_install() directly rather than nesting it in create_tooling_setup(), so the already-shipped create_tooling_item_group patch keeps its original meaning; split the uninstall counterpart out as remove_project_tooling_connection(). Both docstrings are accurate again. - Extract PROJECT_TOOLING_LINK as the single spelling of the connection's identity, shared by the install guard, uninstall and tests — the guard and the idempotency assertion can no longer disagree. - Give TestProjectDashboardConnection setUp/tearDown so it stops leaving a DocType Link on the core Project doctype behind; move it to the end of the file and widen the module docstring to cover it. Full app suite 72/72 on v15; pre-commit clean. Co-Authored-By: Claude Opus 5 --- npd_project_module/hooks.py | 10 ++- npd_project_module/install/after_install.py | 26 +++++-- npd_project_module/install/after_migrate.py | 24 ++++++ npd_project_module/patches.txt | 1 - .../v1_0/add_project_tooling_connection.py | 16 ---- npd_project_module/tests/test_npd_tooling.py | 73 ++++++++++++------- .../uninstall/before_uninstall.py | 15 +++- 7 files changed, 113 insertions(+), 52 deletions(-) create mode 100644 npd_project_module/install/after_migrate.py delete mode 100644 npd_project_module/patches/v1_0/add_project_tooling_connection.py diff --git a/npd_project_module/hooks.py b/npd_project_module/hooks.py index 2df9e86..78067b8 100644 --- a/npd_project_module/hooks.py +++ b/npd_project_module/hooks.py @@ -88,6 +88,13 @@ # before_install = "npd_project_module.install.before_install" after_install = "npd_project_module.install.after_install.after_install" +# Migration +# ------------ + +# Re-asserts the Project → NPD Tooling connection, which an ERPNext upgrade that +# re-imports project.json would otherwise drop. See install/after_migrate.py. +after_migrate = "npd_project_module.install.after_migrate.after_migrate" + # Uninstallation # ------------ @@ -192,7 +199,8 @@ # } # # The Project → NPD Tooling connection is added as a custom DocType Link in -# install/after_install.py (add_project_tooling_connection) instead of via this hook. +# install/after_install.py (add_project_tooling_connection) instead of via this hook, +# and re-asserted by the after_migrate hook above. # exempt linked doctypes from being automatically cancelled # diff --git a/npd_project_module/install/after_install.py b/npd_project_module/install/after_install.py index f2c90d7..215dfba 100644 --- a/npd_project_module/install/after_install.py +++ b/npd_project_module/install/after_install.py @@ -4,6 +4,12 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +# Identity of the Project → NPD Tooling connection (a DocType Link row on Project). +# Shared by the install guard, the uninstall cleanup and the tests so the three can +# never drift apart. Deliberately does not pin `custom` — see +# add_project_tooling_connection. +PROJECT_TOOLING_LINK = {"parent": "Project", "link_doctype": "NPD Tooling"} + def after_install(): """ @@ -15,6 +21,7 @@ def after_install(): create_item_custom_fields() create_npd_template() create_tooling_setup() + add_project_tooling_connection() frappe.db.commit() print("NPD Project Module: Custom fields and configurations created successfully") @@ -43,27 +50,32 @@ def create_tooling_setup(): else: print(f" ✓ Item Group '{item_group}' already exists") - add_project_tooling_connection() - def add_project_tooling_connection(): """Surface NPD Tooling in the Project form's Connections via a custom DocType Link. NPD Tooling links to Project through its `project` field, matching the Project - dashboard's default fieldname, so the connection count resolves automatically. A - custom (custom=1) link is preserved across `bench migrate`. Idempotent. + dashboard's default fieldname, so the connection count resolves automatically. + + The guard matches on parent + link_doctype only, not on `custom=1`: a site that + ships the same connection through fixtures or `export-customizations` has a + standard (custom=0) row, and adding a custom one alongside it would list NPD + Tooling twice in Connections. + + Idempotent, and re-asserted on every `bench migrate` (see install/after_migrate.py) + because re-importing `project.json` drops every DocType Link row on Project, + custom ones included. """ - if frappe.db.exists("DocType Link", {"parent": "Project", "link_doctype": "NPD Tooling", "custom": 1}): + if frappe.db.exists("DocType Link", PROJECT_TOOLING_LINK): print(" ✓ Project → NPD Tooling connection already present") return frappe.get_doc( { "doctype": "DocType Link", - "parent": "Project", + **PROJECT_TOOLING_LINK, "parenttype": "DocType", "parentfield": "links", - "link_doctype": "NPD Tooling", "link_fieldname": "project", "group": "Tooling", "custom": 1, diff --git a/npd_project_module/install/after_migrate.py b/npd_project_module/install/after_migrate.py new file mode 100644 index 0000000..5302374 --- /dev/null +++ b/npd_project_module/install/after_migrate.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026, Guru107 and contributors +# For license information, please see license.txt + +import frappe + +from npd_project_module.install.after_install import add_project_tooling_connection + + +def after_migrate(): + """ + Re-assert this app's customizations on the core Project doctype after every migrate. + + The Project → NPD Tooling connection is a DocType Link row parented to Project. + `custom=1` protects it from Customize Form rewrites, but not from a doctype + re-import: when `project.json` changes (any ERPNext upgrade that touches Project), + Frappe reloads it via `delete_doc(..., for_reload=True)`, which drops *every* + DocType Link row on Project, custom ones included. A one-shot patch cannot recover + from that, so the connection is re-created here on each `bench migrate` instead. + + Runs after doctype sync and after patches, and the helper is idempotent, so this is + a no-op on the migrates where nothing was dropped. + """ + add_project_tooling_connection() + frappe.db.commit() diff --git a/npd_project_module/patches.txt b/npd_project_module/patches.txt index e1aa9f6..6fe8032 100644 --- a/npd_project_module/patches.txt +++ b/npd_project_module/patches.txt @@ -6,4 +6,3 @@ # Patches added in this section will be executed after doctypes are migrated # Note: Initial setup (custom fields, child tables) is handled in after_install hook npd_project_module.patches.v1_0.create_tooling_item_group -npd_project_module.patches.v1_0.add_project_tooling_connection diff --git a/npd_project_module/patches/v1_0/add_project_tooling_connection.py b/npd_project_module/patches/v1_0/add_project_tooling_connection.py deleted file mode 100644 index c0241c9..0000000 --- a/npd_project_module/patches/v1_0/add_project_tooling_connection.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (c) 2026, Guru107 and contributors -# For license information, please see license.txt - -""" -Add the Project → NPD Tooling connection on existing installs. - -Sites that ran the earlier tooling patch before this connection existed won't get it -from that (already-applied) patch, so this dedicated patch ensures it on `bench migrate`. -Reuses the idempotent helper from after_install. -""" - -from npd_project_module.install.after_install import add_project_tooling_connection - - -def execute(): - add_project_tooling_connection() diff --git a/npd_project_module/tests/test_npd_tooling.py b/npd_project_module/tests/test_npd_tooling.py index 7da4d6f..c72453e 100644 --- a/npd_project_module/tests/test_npd_tooling.py +++ b/npd_project_module/tests/test_npd_tooling.py @@ -2,7 +2,8 @@ # For license information, please see license.txt """ -Unit tests for the NPD Tooling order doctype and Tooling Recovery Register report. +Unit tests for the NPD Tooling order doctype, the Tooling Recovery Register report, and +the Project → NPD Tooling connection this app adds to the Project form. An NPD Tooling record is a customer-PO tooling order with child tool lines (a part can need several tools, each possibly from a different supplier). Recovery combines money paid @@ -10,13 +11,17 @@ invoice), versus the total tooling amount, so it counts even before a tax invoice exists and never double-counts. The double-count-free combination is unit-tested purely; order totals, ageing, and manual-allocation recovery are tested end-to-end, with the Payment -Entry helper skipping gracefully if the site lacks the accounting fixtures. +Entry helper skipping gracefully if the site lacks the accounting fixtures. The Project +connection is checked through the Project dashboard data the form actually renders. """ import frappe from frappe.utils import add_days, getdate, today -from npd_project_module.install.after_install import add_project_tooling_connection +from npd_project_module.install.after_install import ( + PROJECT_TOOLING_LINK, + add_project_tooling_connection, +) from npd_project_module.npd_project_module.doctype.npd_tooling.npd_tooling import ( compute_recovered, compute_recovery_status, @@ -514,28 +519,6 @@ def test_fully_recovered_within_rounding_tolerance(self): self.assertEqual(compute_recovery_status(1000, 999.995), "Fully Recovered") -class TestProjectDashboardConnection(NPDProjectModuleTestSuite): - """The Project form's Connections should surface NPD Tooling via a custom DocType Link.""" - - def _tooling_in_connections(self): - data = frappe.get_meta("Project").get_dashboard_data() - return any("NPD Tooling" in (g.get("items") or []) for g in data.get("transactions", [])) - - def test_connection_present(self): - add_project_tooling_connection() - frappe.clear_cache(doctype="Project") - self.assertTrue( - frappe.db.exists("DocType Link", {"parent": "Project", "link_doctype": "NPD Tooling"}) - ) - self.assertTrue(self._tooling_in_connections()) - - def test_idempotent(self): - add_project_tooling_connection() - add_project_tooling_connection() # second call must not duplicate - links = frappe.get_all("DocType Link", filters={"parent": "Project", "link_doctype": "NPD Tooling"}) - self.assertEqual(len(links), 1) - - class TestRecoveredComputation(NPDProjectModuleTestSuite): """Pure-logic tests for combining invoice payments with manual advance allocations.""" @@ -562,3 +545,43 @@ def test_invoice_fully_paid(self): def test_unpaid_invoice_contributes_zero(self): # A fully-outstanding invoice means nothing has been recovered through it. self.assertEqual(compute_recovered([(1000, 1000)], [], set()), 0) + + +class TestProjectDashboardConnection(NPDProjectModuleTestSuite): + """The Project form's Connections should surface NPD Tooling via a custom DocType Link. + + These tests write to the core Project doctype, so setUp records the links already on + the site and tearDown deletes only what the test itself added — the site is left + exactly as it was found, whether or not the app was already installed on it. + """ + + def setUp(self): + super().setUp() + self.preexisting_links = set(self._tooling_links()) + + def tearDown(self): + for name in set(self._tooling_links()) - self.preexisting_links: + frappe.delete_doc("DocType Link", name, force=True, ignore_permissions=True) + frappe.db.commit() + frappe.clear_cache(doctype="Project") + super().tearDown() + + def _tooling_links(self): + """Names of every DocType Link connecting Project to NPD Tooling.""" + return frappe.get_all("DocType Link", filters=PROJECT_TOOLING_LINK, pluck="name") + + def _tooling_in_connections(self): + """Whether NPD Tooling shows up in the dashboard data the Project form renders.""" + transactions = frappe.get_meta("Project").get_dashboard_data().get("transactions", []) + return any("NPD Tooling" in (group.get("items") or []) for group in transactions) + + def test_connection_present(self): + add_project_tooling_connection() + frappe.clear_cache(doctype="Project") + self.assertTrue(self._tooling_links()) + self.assertTrue(self._tooling_in_connections()) + + def test_idempotent(self): + add_project_tooling_connection() + add_project_tooling_connection() # second call must not duplicate + self.assertEqual(len(self._tooling_links()), 1) diff --git a/npd_project_module/uninstall/before_uninstall.py b/npd_project_module/uninstall/before_uninstall.py index 88c6261..ae9bf10 100644 --- a/npd_project_module/uninstall/before_uninstall.py +++ b/npd_project_module/uninstall/before_uninstall.py @@ -3,6 +3,8 @@ import frappe +from npd_project_module.install.after_install import PROJECT_TOOLING_LINK + def before_uninstall(): """ @@ -15,6 +17,7 @@ def before_uninstall(): remove_npd_template() remove_custom_report() remove_tooling_setup() + remove_project_tooling_connection() remove_custom_doctype() frappe.db.commit() @@ -124,11 +127,19 @@ def remove_tooling_setup(): except Exception as e: print(f" ✗ Error removing Item Group {item_group}: {e!s}") - # Remove the Project → NPD Tooling connection (custom DocType Link). + +def remove_project_tooling_connection(): + """ + Remove the Project → NPD Tooling connection from the Project form's Connections. + + Only the custom (custom=1) DocType Link rows this app creates are deleted; a + standard link that reached the site through its own fixtures or customizations is + left in place, since this app did not put it there. + """ try: links = frappe.get_all( "DocType Link", - filters={"parent": "Project", "link_doctype": "NPD Tooling", "custom": 1}, + filters={**PROJECT_TOOLING_LINK, "custom": 1}, pluck="name", ) for name in links: From a2e6f9c2bbb4f0bd8514732ec9e6e4a5b07a3ff9 Mon Sep 17 00:00:00 2001 From: Guru107 Date: Mon, 27 Jul 2026 07:32:41 +0000 Subject: [PATCH 5/5] fix: drop manual commit from after_migrate hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frappe's Linters workflow runs the frappe-semgrep-rules, which block frappe-manual-commit. The commit was redundant anyway: migrate invokes after_migrate hooks from post_schema_updates, which is decorated @atomic and already commits on success and rolls back if the phase raises — so the manual commit was also partially defeating that rollback. Verified the connection still survives without it: deleted the link, ran bench migrate, confirmed the row and the Connections entry from a fresh console session. Co-Authored-By: Claude Opus 5 --- npd_project_module/install/after_migrate.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/npd_project_module/install/after_migrate.py b/npd_project_module/install/after_migrate.py index 5302374..ac465b8 100644 --- a/npd_project_module/install/after_migrate.py +++ b/npd_project_module/install/after_migrate.py @@ -1,8 +1,6 @@ # Copyright (c) 2026, Guru107 and contributors # For license information, please see license.txt -import frappe - from npd_project_module.install.after_install import add_project_tooling_connection @@ -18,7 +16,8 @@ def after_migrate(): from that, so the connection is re-created here on each `bench migrate` instead. Runs after doctype sync and after patches, and the helper is idempotent, so this is - a no-op on the migrates where nothing was dropped. + a no-op on the migrates where nothing was dropped. No manual commit: migrate calls + these hooks from its `@atomic` post_schema_updates, which commits on success and + rolls back if anything in the phase raises. """ add_project_tooling_connection() - frappe.db.commit()