From 2b1e922183f9dfe8f0776b40760aef2d9d0f53c2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 4 Jul 2026 19:42:13 +0530 Subject: [PATCH] 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")