From 4f9ea989c45624886219a29b9c526aa7c0d66802 Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Thu, 16 Jul 2026 14:44:49 +0530 Subject: [PATCH 1/3] feat(stock): automatically link portal users to their associated contact profiles for customers and suppliers (cherry picked from commit 337a06dfb6d529e85fa6d6c29acf90024f7390f0) --- erpnext/buying/doctype/supplier/supplier.py | 6 +- .../controllers/website_list_for_contact.py | 62 +++++++++++++++++++ erpnext/selling/doctype/customer/customer.py | 7 ++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index f0e85523ea3..0b463a36530 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -16,7 +16,10 @@ from erpnext.accounts.party import ( validate_party_accounts, validate_party_currency_before_merging, ) -from erpnext.controllers.website_list_for_contact import add_role_for_portal_user +from erpnext.controllers.website_list_for_contact import ( + add_role_for_portal_user, + link_portal_users_to_contacts, +) from erpnext.utilities.transaction_base import TransactionBase @@ -103,6 +106,7 @@ class Supplier(TransactionBase): def on_update(self): self.create_primary_contact() self.create_primary_address() + link_portal_users_to_contacts(self) def add_role_for_user(self): for portal_user in self.portal_users: diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index a62fccc752c..f0b9eadd749 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -7,6 +7,8 @@ import json import frappe from frappe import _ from frappe.modules.utils import get_module_app +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Lower from frappe.utils import cint, flt, has_common from frappe.utils.user import is_website_user @@ -306,3 +308,63 @@ def add_role_for_portal_user(portal_user, role): user_doc.add_roles(role) frappe.msgprint(_("Added {1} Role to User {0}.").format(frappe.bold(user_doc.name), role), alert=True) + + +def link_portal_users_to_contacts(doc): + """When portal users are added to Supplier/Customer, link them to the Contact profile.""" + # a User's name is its (lowercased) email, so portal_users are already the emails + portal_users = {p.user for p in doc.get("portal_users") or [] if p.user} + if not portal_users: + return + + before = doc.get_doc_before_save() + if before: + previous_users = {p.user for p in before.get("portal_users") or [] if p.user} + if portal_users == previous_users: + return + + portal_users = list(portal_users) + + contact = frappe.qb.DocType("Contact") + contact_email = frappe.qb.DocType("Contact Email") + + query = ( + frappe.qb.from_(contact) + .left_join(contact_email) + .on(contact_email.parent == contact.name) + .select(contact.name) + .distinct() + ) + + conditions = [ + contact.user.isin(portal_users), + Lower(contact.email_id).isin(portal_users), + Lower(contact_email.email_id).isin(portal_users), + ] + + query = query.where(Criterion.any(conditions)) + contacts = query.run(pluck=True) + + if not contacts: + return + + dynamic_link = frappe.qb.DocType("Dynamic Link") + existing_links = ( + frappe.qb.from_(dynamic_link) + .select(dynamic_link.parent) + .where( + (dynamic_link.parenttype == "Contact") + & (dynamic_link.parent.isin(contacts)) + & (dynamic_link.link_doctype == doc.doctype) + & (dynamic_link.link_name == doc.name) + ) + .run(pluck=True) + ) + + contacts_to_link = [name for name in contacts if name not in existing_links] + + for name in contacts_to_link: + contact_doc = frappe.get_doc("Contact", name) + if not contact_doc.has_link(doc.doctype, doc.name): + contact_doc.append("links", {"link_doctype": doc.doctype, "link_name": doc.name}) + contact_doc.save(ignore_permissions=True) diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 8b5761f5930..4d21ff94d3e 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -23,7 +23,10 @@ from erpnext.accounts.party import ( validate_party_accounts, validate_party_currency_before_merging, ) -from erpnext.controllers.website_list_for_contact import add_role_for_portal_user +from erpnext.controllers.website_list_for_contact import ( + add_role_for_portal_user, + link_portal_users_to_contacts, +) from erpnext.utilities.transaction_base import TransactionBase @@ -243,6 +246,8 @@ class Customer(TransactionBase): self.update_customer_groups() + link_portal_users_to_contacts(self) + def add_role_for_user(self): for portal_user in self.portal_users: add_role_for_portal_user(portal_user, "Customer") From 1b6ff72f0f53056622e8ba9cfa5924f228ffa7a0 Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Thu, 16 Jul 2026 23:21:47 +0530 Subject: [PATCH 2/3] test(stock): add portal user contact link verification for customer and supplier test(stock): add portal user contact link verification for customer and supplier --- .../buying/doctype/supplier/test_supplier.py | 21 ++++++++++++++ .../selling/doctype/customer/test_customer.py | 28 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index 04983721a64..719770a6853 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -208,3 +208,24 @@ class TestSupplierPortal(FrappeTestCase): _, suppliers = get_customers_suppliers("Purchase Order", user) self.assertIn(supplier.name, suppliers) + + def test_portal_user_contact_link(self): + user_email = frappe.generate_hash() + "@example.com" + user = frappe.new_doc("User") + user.email = user_email + user.first_name = "Test Portal Contact User" + user.send_welcome_email = False + user.insert(ignore_permissions=True) + + contact = frappe.new_doc("Contact") + contact.first_name = "Test Portal Contact User" + contact.add_email(user_email, is_primary=1) + contact.links = [] + contact.insert(ignore_permissions=True) + + supplier = create_supplier() + supplier.append("portal_users", {"user": user.name}) + supplier.save() + + contact.reload() + self.assertTrue(contact.has_link("Supplier", supplier.name)) diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index a8fd5ed76ca..bfd66d51d24 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -386,6 +386,34 @@ class TestCustomer(FrappeTestCase): self.assertEqual(middle, "Michael") self.assertEqual(last, "Doe") + def test_portal_user_contact_link(self): + user_email = frappe.generate_hash() + "@example.com" + user = frappe.new_doc("User") + user.email = user_email + user.first_name = "Test Portal Customer User" + user.send_welcome_email = False + user.insert(ignore_permissions=True) + + contact = frappe.new_doc("Contact") + contact.first_name = "Test Portal Customer User" + contact.add_email(user_email, is_primary=1) + contact.links = [] + contact.insert(ignore_permissions=True) + + customer = frappe.get_doc( + { + "doctype": "Customer", + "customer_name": "Test Portal Contact Customer", + "customer_type": "Individual", + "customer_group": "_Test Customer Group", + } + ) + customer.append("portal_users", {"user": user.name}) + customer.insert() + + contact.reload() + self.assertTrue(contact.has_link("Customer", customer.name)) + def get_customer_dict(customer_name): return { From 941423067140779b8e1eb94411dbb9bea9c70927 Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Fri, 17 Jul 2026 00:21:48 +0530 Subject: [PATCH 3/3] chore: fix test case user context --- erpnext/buying/doctype/supplier/test_supplier.py | 4 ++-- erpnext/selling/doctype/customer/test_customer.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index 719770a6853..e0a2a379ed8 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -204,8 +204,8 @@ class TestSupplierPortal(FrappeTestCase): supplier.append("portal_users", {"user": user}) supplier.save() - frappe.set_user(user) - _, suppliers = get_customers_suppliers("Purchase Order", user) + with self.set_user(user): + _, suppliers = get_customers_suppliers("Purchase Order", user) self.assertIn(supplier.name, suppliers) diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index bfd66d51d24..e6ed7acb508 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -29,7 +29,8 @@ class TestCustomer(FrappeTestCase): make_test_records("Item") def tearDown(self): - set_credit_limit("_Test Customer", "_Test Company", 0) + if frappe.db.exists("Customer", "_Test Customer"): + set_credit_limit("_Test Customer", "_Test Company", 0) def test_get_customer_group_details(self): doc = frappe.new_doc("Customer Group")