From 5eaafd3025b1710054e0699d0e78ce0ba1347d0d Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 17:33:35 +0530 Subject: [PATCH 1/4] test: add coverage for Campaign naming and UTM mirroring --- erpnext/crm/doctype/campaign/test_campaign.py | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index 8876e640475..3e98474eed8 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -1,9 +1,31 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe + +import frappe from erpnext.tests.utils import ERPNextTestSuite class TestCampaign(ERPNextTestSuite): - pass + """Campaign names itself from the campaign name (or a naming series) and mirrors + itself into a UTM Campaign.""" + + def setUp(self): + frappe.set_user("Administrator") + + def make_campaign(self, **fields): + doc = frappe.new_doc("Campaign") + doc.campaign_name = fields.pop("campaign_name", f"_Test Campaign {frappe.generate_hash(length=6)}") + doc.update(fields) + return doc.insert() + + def test_autoname_uses_the_campaign_name_by_default(self): + campaign = self.make_campaign(campaign_name="_Test Campaign Named") + self.assertEqual(campaign.name, "_Test Campaign Named") + + def test_inserting_mirrors_into_a_utm_campaign(self): + campaign = self.make_campaign(campaign_name="_Test Campaign UTM", description="Spring push") + self.assertTrue(frappe.db.exists("UTM Campaign", campaign.campaign_name)) + utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) + self.assertEqual(utm.campaign_description, "Spring push") + self.assertEqual(utm.crm_campaign, campaign.campaign_name) From 9fdcfd5f588bc8de6e11666c96a4a9715406287a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 17:40:16 +0530 Subject: [PATCH 2/4] fix: link UTM Campaign to the Campaign's document name, not campaign_name --- erpnext/crm/doctype/campaign/campaign.py | 16 +++++++--------- erpnext/crm/doctype/campaign/test_campaign.py | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/erpnext/crm/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py index f9834d2ccef..d4fd77a70f0 100644 --- a/erpnext/crm/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -26,23 +26,21 @@ class Campaign(Document): # end: auto-generated types def after_insert(self): - try: - mc = frappe.get_doc("UTM Campaign", self.campaign_name) - except frappe.DoesNotExistError: - mc = frappe.new_doc("UTM Campaign") - mc.name = self.campaign_name - mc.campaign_description = self.description - mc.crm_campaign = self.campaign_name - mc.save(ignore_permissions=True) + self.sync_utm_campaign() def on_change(self): + self.sync_utm_campaign() + + def sync_utm_campaign(self): try: mc = frappe.get_doc("UTM Campaign", self.campaign_name) except frappe.DoesNotExistError: mc = frappe.new_doc("UTM Campaign") mc.name = self.campaign_name mc.campaign_description = self.description - mc.crm_campaign = self.campaign_name + # link to this Campaign by its document name, which differs from campaign_name + # when a naming series is used + mc.crm_campaign = self.name mc.save(ignore_permissions=True) def autoname(self): diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index 3e98474eed8..ed906c04aef 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -23,9 +23,22 @@ class TestCampaign(ERPNextTestSuite): campaign = self.make_campaign(campaign_name="_Test Campaign Named") self.assertEqual(campaign.name, "_Test Campaign Named") + def test_autoname_uses_naming_series_when_configured(self): + # regression: with a naming series the document name differs from campaign_name, + # and the UTM sync must still link back to a valid Campaign (self.name) + original = frappe.defaults.get_global_default("campaign_naming_by") + frappe.defaults.set_global_default("campaign_naming_by", "Naming Series") + try: + campaign = self.make_campaign(naming_series="SAL-CAM-.YYYY.-") + self.assertTrue(campaign.name.startswith("SAL-CAM-")) + utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) + self.assertEqual(utm.crm_campaign, campaign.name) + finally: + frappe.defaults.set_global_default("campaign_naming_by", original or "") + def test_inserting_mirrors_into_a_utm_campaign(self): campaign = self.make_campaign(campaign_name="_Test Campaign UTM", description="Spring push") self.assertTrue(frappe.db.exists("UTM Campaign", campaign.campaign_name)) utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) self.assertEqual(utm.campaign_description, "Spring push") - self.assertEqual(utm.crm_campaign, campaign.campaign_name) + self.assertEqual(utm.crm_campaign, campaign.name) From ca9dcbf2d70a0500bae44cccc3ce8cea6d38fb29 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 18:45:58 +0530 Subject: [PATCH 3/4] fix: reuse the existing UTM Campaign mirror when campaign_name is edited --- erpnext/crm/doctype/campaign/campaign.py | 8 +++++--- erpnext/crm/doctype/campaign/test_campaign.py | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/erpnext/crm/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py index d4fd77a70f0..e89b1aa79f6 100644 --- a/erpnext/crm/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -32,14 +32,16 @@ class Campaign(Document): self.sync_utm_campaign() def sync_utm_campaign(self): + # look up the existing mirror by the stable Campaign link first, so editing + # campaign_name updates that mirror instead of creating a duplicate + existing = frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}) or self.campaign_name try: - mc = frappe.get_doc("UTM Campaign", self.campaign_name) + mc = frappe.get_doc("UTM Campaign", existing) except frappe.DoesNotExistError: mc = frappe.new_doc("UTM Campaign") mc.name = self.campaign_name mc.campaign_description = self.description - # link to this Campaign by its document name, which differs from campaign_name - # when a naming series is used + # link by the document name, which differs from campaign_name when a naming series is used mc.crm_campaign = self.name mc.save(ignore_permissions=True) diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index ed906c04aef..169ecc75765 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -42,3 +42,11 @@ class TestCampaign(ERPNextTestSuite): utm = frappe.get_doc("UTM Campaign", campaign.campaign_name) self.assertEqual(utm.campaign_description, "Spring push") self.assertEqual(utm.crm_campaign, campaign.name) + + def test_editing_campaign_name_reuses_the_same_utm_campaign(self): + campaign = self.make_campaign(campaign_name="_Test Campaign Rename A") + campaign.campaign_name = "_Test Campaign Rename B" + campaign.save() + # the edit updates the existing mirror rather than creating a second one + mirrors = frappe.get_all("UTM Campaign", filters={"crm_campaign": campaign.name}) + self.assertEqual(len(mirrors), 1) From 2b1e922183f9dfe8f0776b40760aef2d9d0f53c2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 19:42:13 +0530 Subject: [PATCH 4/4] fix: don't hijack another Campaign's UTM mirror when display names collide --- erpnext/crm/doctype/campaign/campaign.py | 26 +++++++++++++------ erpnext/crm/doctype/campaign/test_campaign.py | 18 +++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/erpnext/crm/doctype/campaign/campaign.py b/erpnext/crm/doctype/campaign/campaign.py index e89b1aa79f6..4953be544a5 100644 --- a/erpnext/crm/doctype/campaign/campaign.py +++ b/erpnext/crm/doctype/campaign/campaign.py @@ -32,19 +32,29 @@ class Campaign(Document): self.sync_utm_campaign() def sync_utm_campaign(self): - # look up the existing mirror by the stable Campaign link first, so editing - # campaign_name updates that mirror instead of creating a duplicate - existing = frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}) or self.campaign_name - try: - mc = frappe.get_doc("UTM Campaign", existing) - except frappe.DoesNotExistError: - mc = frappe.new_doc("UTM Campaign") - mc.name = self.campaign_name + mc = self.get_utm_campaign_mirror() mc.campaign_description = self.description # link by the document name, which differs from campaign_name when a naming series is used mc.crm_campaign = self.name mc.save(ignore_permissions=True) + def get_utm_campaign_mirror(self): + # the mirror already linked to this Campaign, if any (survives campaign_name edits) + if owned := frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}): + return frappe.get_doc("UTM Campaign", owned) + + # reuse a same-named mirror only when it isn't already owned by another Campaign, + # otherwise two Campaigns sharing a display name would hijack each other's mirror + if frappe.db.exists("UTM Campaign", self.campaign_name): + same_name = frappe.get_doc("UTM Campaign", self.campaign_name) + if not same_name.crm_campaign or same_name.crm_campaign == self.name: + return same_name + + # create a fresh mirror, keeping its name unique when the display name is taken + mc = frappe.new_doc("UTM Campaign") + mc.name = self.name if frappe.db.exists("UTM Campaign", self.campaign_name) else self.campaign_name + return mc + def autoname(self): if frappe.defaults.get_global_default("campaign_naming_by") != "Naming Series": self.name = self.campaign_name diff --git a/erpnext/crm/doctype/campaign/test_campaign.py b/erpnext/crm/doctype/campaign/test_campaign.py index 169ecc75765..e3f41e9958f 100644 --- a/erpnext/crm/doctype/campaign/test_campaign.py +++ b/erpnext/crm/doctype/campaign/test_campaign.py @@ -50,3 +50,21 @@ class TestCampaign(ERPNextTestSuite): # the edit updates the existing mirror rather than creating a second one mirrors = frappe.get_all("UTM Campaign", filters={"crm_campaign": campaign.name}) self.assertEqual(len(mirrors), 1) + + def test_two_campaigns_sharing_a_name_do_not_hijack_each_others_mirror(self): + # a naming series lets two Campaigns share a display name; each must keep its own mirror + original = frappe.defaults.get_global_default("campaign_naming_by") + frappe.defaults.set_global_default("campaign_naming_by", "Naming Series") + try: + first = self.make_campaign(campaign_name="_Test Shared Mirror", naming_series="SAL-CAM-.YYYY.-") + second = self.make_campaign(campaign_name="_Test Shared Mirror", naming_series="SAL-CAM-.YYYY.-") + finally: + frappe.defaults.set_global_default("campaign_naming_by", original or "") + + # the first Campaign's mirror is untouched; the second gets a distinct one + self.assertEqual( + frappe.db.get_value("UTM Campaign", "_Test Shared Mirror", "crm_campaign"), first.name + ) + second_mirror = frappe.db.get_value("UTM Campaign", {"crm_campaign": second.name}) + self.assertTrue(second_mirror) + self.assertNotEqual(second_mirror, "_Test Shared Mirror")