diff --git a/erpnext/buying/doctype/supplier/patches/__init__.py b/erpnext/buying/doctype/supplier/patches/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/buying/doctype/supplier/patches/migrate_supplier_portal_users.py b/erpnext/buying/doctype/supplier/patches/migrate_supplier_portal_users.py new file mode 100644 index 00000000000..5834952d5b6 --- /dev/null +++ b/erpnext/buying/doctype/supplier/patches/migrate_supplier_portal_users.py @@ -0,0 +1,56 @@ +import os + +import frappe + +in_ci = os.environ.get("CI") + + +def execute(): + try: + contacts = get_portal_user_contacts() + add_portal_users(contacts) + except Exception: + frappe.db.rollback() + frappe.log_error("Failed to migrate portal users") + + if in_ci: # TODO: better way to handle this. + raise + + +def get_portal_user_contacts(): + contact = frappe.qb.DocType("Contact") + dynamic_link = frappe.qb.DocType("Dynamic Link") + + return ( + frappe.qb.from_(contact) + .inner_join(dynamic_link) + .on(contact.name == dynamic_link.parent) + .select( + (dynamic_link.link_doctype).as_("doctype"), + (dynamic_link.link_name).as_("parent"), + (contact.email_id).as_("portal_user"), + ) + .where( + (dynamic_link.parenttype == "Contact") + & (dynamic_link.link_doctype.isin(["Supplier", "Customer"])) + ) + ).run(as_dict=True) + + +def add_portal_users(contacts): + for contact in contacts: + user = frappe.db.get_value("User", {"email": contact.portal_user}, "name") + if not user: + continue + + roles = frappe.get_roles(user) + required_role = contact.doctype + if required_role not in roles: + continue + + portal_user_doc = frappe.new_doc("Portal User") + portal_user_doc.parenttype = contact.doctype + portal_user_doc.parentfield = "portal_users" + portal_user_doc.parent = contact.parent + portal_user_doc.user = user + portal_user_doc.insert() diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index 1ae6f036474..a536578b2eb 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -42,6 +42,14 @@ frappe.ui.form.on("Supplier", { } }; }); + + frm.set_query("user", "portal_users", function(doc) { + return { + filters: { + "ignore_user_type": true, + } + }; + }); }, refresh: function (frm) { diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json index f0097898f65..b3b6185e355 100644 --- a/erpnext/buying/doctype/supplier/supplier.json +++ b/erpnext/buying/doctype/supplier/supplier.json @@ -68,7 +68,10 @@ "on_hold", "hold_type", "column_break_59", - "release_date" + "release_date", + "portal_users_tab", + "portal_users", + "column_break_1mqv" ], "fields": [ { @@ -445,6 +448,21 @@ { "fieldname": "column_break_59", "fieldtype": "Column Break" + }, + { + "fieldname": "portal_users_tab", + "fieldtype": "Tab Break", + "label": "Portal Users" + }, + { + "fieldname": "portal_users", + "fieldtype": "Table", + "label": "Supplier Portal Users", + "options": "Portal User" + }, + { + "fieldname": "column_break_1mqv", + "fieldtype": "Column Break" } ], "icon": "fa fa-user", @@ -457,7 +475,7 @@ "link_fieldname": "party" } ], - "modified": "2023-05-09 15:34:13.408932", + "modified": "2023-06-26 14:20:00.961554", "modified_by": "Administrator", "module": "Buying", "name": "Supplier", @@ -489,7 +507,6 @@ "read": 1, "report": 1, "role": "Purchase Master Manager", - "set_user_permissions": 1, "share": 1, "write": 1 }, diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 01b5c8f064f..31bf439dbb4 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -16,6 +16,7 @@ from erpnext.accounts.party import ( # noqa get_timeline_data, validate_party_accounts, ) +from erpnext.controllers.website_list_for_contact import add_role_for_portal_user from erpnext.utilities.transaction_base import TransactionBase @@ -46,12 +47,35 @@ class Supplier(TransactionBase): self.name = set_name_from_naming_options(frappe.get_meta(self.doctype).autoname, self) def on_update(self): - if not self.naming_series: - self.naming_series = "" - self.create_primary_contact() self.create_primary_address() + def add_role_for_user(self): + for portal_user in self.portal_users: + add_role_for_portal_user(portal_user, "Supplier") + + def _add_supplier_role(self, portal_user): + if not portal_user.is_new(): + return + + user_doc = frappe.get_doc("User", portal_user.user) + roles = {r.role for r in user_doc.roles} + + if "Supplier" in roles: + return + + if "System Manager" not in frappe.get_roles(): + frappe.msgprint( + _("Please add 'Supplier' role to user {0}.").format(portal_user.user), + alert=True, + ) + return + + user_doc.add_roles("Supplier") + frappe.msgprint( + _("Added Supplier Role to User {0}.").format(frappe.bold(user_doc.name)), alert=True + ) + def validate(self): self.flags.is_new_doc = self.is_new() @@ -62,6 +86,7 @@ class Supplier(TransactionBase): validate_party_accounts(self) self.validate_internal_supplier() + self.add_role_for_user() @frappe.whitelist() def get_supplier_group_details(self): diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index 7a205ac20ce..7be1d834a65 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -7,6 +7,7 @@ from frappe.custom.doctype.property_setter.property_setter import make_property_ from frappe.test_runner import make_test_records from erpnext.accounts.party import get_due_date +from erpnext.controllers.website_list_for_contact import get_customers_suppliers from erpnext.exceptions import PartyDisabled test_dependencies = ["Payment Term", "Payment Terms Template"] @@ -195,6 +196,9 @@ class TestSupplier(FrappeTestCase): def create_supplier(**args): args = frappe._dict(args) + if not args.supplier_name: + args.supplier_name = frappe.generate_hash() + if frappe.db.exists("Supplier", args.supplier_name): return frappe.get_doc("Supplier", args.supplier_name) @@ -209,3 +213,25 @@ def create_supplier(**args): ).insert() return doc + + +class TestSupplierPortal(FrappeTestCase): + def test_portal_user_can_access_supplier_data(self): + + supplier = create_supplier() + + user = frappe.generate_hash() + "@example.com" + frappe.new_doc( + "User", + first_name="Supplier Portal User", + email=user, + send_welcome_email=False, + ).insert() + + supplier.append("portal_users", {"user": user}) + supplier.save() + + frappe.set_user(user) + _, suppliers = get_customers_suppliers("Purchase Order", user) + + self.assertIn(supplier.name, suppliers) diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index 7c3c38706dc..642722ae6bf 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -232,22 +232,8 @@ def get_customers_suppliers(doctype, user): has_supplier_field = meta.has_field("supplier") if has_common(["Supplier", "Customer"], frappe.get_roles(user)): - contacts = frappe.db.sql( - """ - select - `tabContact`.email_id, - `tabDynamic Link`.link_doctype, - `tabDynamic Link`.link_name - from - `tabContact`, `tabDynamic Link` - where - `tabContact`.name=`tabDynamic Link`.parent and `tabContact`.email_id =%s - """, - user, - as_dict=1, - ) - customers = [c.link_name for c in contacts if c.link_doctype == "Customer"] - suppliers = [c.link_name for c in contacts if c.link_doctype == "Supplier"] + suppliers = get_parents_for_user("Supplier") + customers = get_parents_for_user("Customer") elif frappe.has_permission(doctype, "read", user=user): customer_list = frappe.get_list("Customer") customers = suppliers = [customer.name for customer in customer_list] @@ -255,6 +241,17 @@ def get_customers_suppliers(doctype, user): return customers if has_customer_field else None, suppliers if has_supplier_field else None +def get_parents_for_user(parenttype: str) -> list[str]: + portal_user = frappe.qb.DocType("Portal User") + + return ( + frappe.qb.from_(portal_user) + .select(portal_user.parent) + .where(portal_user.user == frappe.session.user) + .where(portal_user.parenttype == parenttype) + ).run(pluck="name") + + def has_website_permission(doc, ptype, user, verbose=False): doctype = doc.doctype customers, suppliers = get_customers_suppliers(doctype, user) @@ -282,3 +279,28 @@ def get_customer_field_name(doctype): return "party_name" else: return "customer" + + +def add_role_for_portal_user(portal_user, role): + """When a new portal user is added, give appropriate roles to user if + posssible, else warn user to add roles.""" + if not portal_user.is_new(): + return + + user_doc = frappe.get_doc("User", portal_user.user) + roles = {r.role for r in user_doc.roles} + + if role in roles: + return + + if "System Manager" not in frappe.get_roles(): + frappe.msgprint( + _("Please add {1} role to user {0}.").format(portal_user.user, role), + alert=True, + ) + return + + user_doc.add_roles(role) + frappe.msgprint( + _("Added {1} Role to User {0}.").format(frappe.bold(user_doc.name), role), alert=True + ) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 8c0fa6bbcda..fe6346e81e8 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -339,4 +339,5 @@ execute:frappe.delete_doc('DocType', 'Cash Flow Mapper', ignore_missing=True) execute:frappe.delete_doc('DocType', 'Cash Flow Mapping Template', ignore_missing=True) execute:frappe.delete_doc('DocType', 'Cash Flow Mapping Accounts', ignore_missing=True) erpnext.patches.v14_0.cleanup_workspaces -erpnext.patches.v14_0.set_report_in_process_SOA +erpnext.patches.v14_0.set_report_in_process_SOA +erpnext.buying.doctype.supplier.patches.migrate_supplier_portal_users diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index b53f339229b..3a446e171a5 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -63,6 +63,14 @@ frappe.ui.form.on("Customer", { } } }); + + frm.set_query("user", "portal_users", function() { + return { + filters: { + "ignore_user_type": true, + } + }; + }); }, customer_primary_address: function(frm){ if(frm.doc.customer_primary_address){ diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index 72a1594b2d2..edfe0050dee 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -81,7 +81,9 @@ "dn_required", "column_break_53", "is_frozen", - "disabled" + "disabled", + "portal_users_tab", + "portal_users" ], "fields": [ { @@ -555,6 +557,17 @@ { "fieldname": "column_break_54", "fieldtype": "Column Break" + }, + { + "fieldname": "portal_users_tab", + "fieldtype": "Tab Break", + "label": "Portal Users" + }, + { + "fieldname": "portal_users", + "fieldtype": "Table", + "label": "Customer Portal Users", + "options": "Portal User" } ], "icon": "fa fa-user", @@ -568,7 +581,7 @@ "link_fieldname": "party" } ], - "modified": "2023-05-09 15:38:40.255193", + "modified": "2023-06-22 13:21:10.678382", "modified_by": "Administrator", "module": "Selling", "name": "Customer", @@ -607,7 +620,6 @@ "read": 1, "report": 1, "role": "Sales Master Manager", - "set_user_permissions": 1, "share": 1, "write": 1 }, diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 6367e3cb6a5..555db59b082 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -22,6 +22,7 @@ from erpnext.accounts.party import ( # noqa get_timeline_data, validate_party_accounts, ) +from erpnext.controllers.website_list_for_contact import add_role_for_portal_user from erpnext.utilities.transaction_base import TransactionBase @@ -82,6 +83,7 @@ class Customer(TransactionBase): self.check_customer_group_change() self.validate_default_bank_account() self.validate_internal_customer() + self.add_role_for_user() # set loyalty program tier if frappe.db.exists("Customer", self.name): @@ -170,6 +172,10 @@ class Customer(TransactionBase): self.update_customer_groups() + def add_role_for_user(self): + for portal_user in self.portal_users: + add_role_for_portal_user(portal_user, "Customer") + def update_customer_groups(self): ignore_doctypes = ["Lead", "Opportunity", "POS Profile", "Tax Rule", "Pricing Rule"] if frappe.flags.customer_group_changed: diff --git a/erpnext/tests/test_webform.py b/erpnext/tests/test_webform.py index 202467b5450..af50a057b8a 100644 --- a/erpnext/tests/test_webform.py +++ b/erpnext/tests/test_webform.py @@ -3,18 +3,21 @@ import unittest import frappe from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.buying.doctype.supplier.test_supplier import create_supplier class TestWebsite(unittest.TestCase): def test_permission_for_custom_doctype(self): create_user("Supplier 1", "supplier1@gmail.com") create_user("Supplier 2", "supplier2@gmail.com") - create_supplier_with_contact( - "Supplier1", "All Supplier Groups", "Supplier 1", "supplier1@gmail.com" - ) - create_supplier_with_contact( - "Supplier2", "All Supplier Groups", "Supplier 2", "supplier2@gmail.com" - ) + + supplier1 = create_supplier(supplier_name="Supplier1") + supplier2 = create_supplier(supplier_name="Supplier2") + supplier1.append("portal_users", {"user": "supplier1@gmail.com"}) + supplier1.save() + supplier2.append("portal_users", {"user": "supplier2@gmail.com"}) + supplier2.save() + po1 = create_purchase_order(supplier="Supplier1") po2 = create_purchase_order(supplier="Supplier2") @@ -61,21 +64,6 @@ def create_user(name, email): ).insert(ignore_if_duplicate=True) -def create_supplier_with_contact(name, group, contact_name, contact_email): - supplier = frappe.get_doc( - {"doctype": "Supplier", "supplier_name": name, "supplier_group": group} - ).insert(ignore_if_duplicate=True) - - if not frappe.db.exists("Contact", contact_name + "-1-" + name): - new_contact = frappe.new_doc("Contact") - new_contact.first_name = contact_name - new_contact.is_primary_contact = (True,) - new_contact.append("links", {"link_doctype": "Supplier", "link_name": supplier.name}) - new_contact.append("email_ids", {"email_id": contact_email, "is_primary": 1}) - - new_contact.insert(ignore_mandatory=True) - - def create_custom_doctype(): frappe.get_doc( { diff --git a/erpnext/utilities/doctype/portal_user/__init__.py b/erpnext/utilities/doctype/portal_user/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/utilities/doctype/portal_user/portal_user.json b/erpnext/utilities/doctype/portal_user/portal_user.json new file mode 100644 index 00000000000..361166cb653 --- /dev/null +++ b/erpnext/utilities/doctype/portal_user/portal_user.json @@ -0,0 +1,34 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-06-20 14:01:35.362233", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1, + "search_index": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-06-26 14:15:34.695605", + "modified_by": "Administrator", + "module": "Utilities", + "name": "Portal User", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/utilities/doctype/portal_user/portal_user.py b/erpnext/utilities/doctype/portal_user/portal_user.py new file mode 100644 index 00000000000..2e0064d1981 --- /dev/null +++ b/erpnext/utilities/doctype/portal_user/portal_user.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PortalUser(Document): + pass