diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 680700abc19..142191c4fb4 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -131,7 +131,7 @@ def find_variant(template, args, variant_item_code=None): conditions = " or ".join(conditions) - from erpnext.e_commerce.product_configurator.utils import get_item_codes_by_attributes + from erpnext.e_commerce.variant_selector.utils import get_item_codes_by_attributes possible_variants = [i for i in get_item_codes_by_attributes(args, template) if i != variant_item_code] for variant in possible_variants: diff --git a/erpnext/e_commerce/api.py b/erpnext/e_commerce/api.py index 728d3362d3f..01bde2984c1 100644 --- a/erpnext/e_commerce/api.py +++ b/erpnext/e_commerce/api.py @@ -8,11 +8,22 @@ from frappe.utils import cint from erpnext.e_commerce.product_data_engine.query import ProductQuery from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder -from erpnext.setup.doctype.item_group.item_group import get_child_groups +from erpnext.setup.doctype.item_group.item_group import get_child_groups_for_website @frappe.whitelist(allow_guest=True) def get_product_filter_data(query_args=None): - """Get filtered products and discount filters.""" + """ + Returns filtered products and discount filters. + :param query_args (dict): contains filters to get products list + + Query Args filters: + search (str): Search Term. + field_filters (dict): Keys include item_group, brand, etc. + attribute_filters(dict): Keys include Color, Size, etc. + start (int): Offset items by + item_group (str): Valid Item Group + from_filters (bool): Set as True to jump to page 1 + """ if isinstance(query_args, str): query_args = json.loads(query_args) @@ -35,7 +46,7 @@ def get_product_filter_data(query_args=None): sub_categories = [] if item_group: field_filters['item_group'] = item_group - sub_categories = get_child_groups(item_group) + sub_categories = get_child_groups_for_website(item_group, immediate=True) engine = ProductQuery() try: @@ -46,7 +57,7 @@ def get_product_filter_data(query_args=None): start=start, item_group=item_group ) - except Exception as e: + except Exception: traceback = frappe.get_traceback() frappe.log_error(traceback, frappe._("Product Engine Error")) return {"exc": "Something went wrong!"} diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py index 9afca251b43..4a8e8200280 100644 --- a/erpnext/e_commerce/doctype/website_item/test_website_item.py +++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py @@ -26,6 +26,10 @@ class TestWebsiteItem(unittest.TestCase): "price_list": "_Test Price List India" }) + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + def setUp(self): if self._testMethodName in WEBITEM_DESK_TESTS: make_item("Test Web Item", { @@ -38,22 +42,13 @@ class TestWebsiteItem(unittest.TestCase): ] }) elif self._testMethodName in WEBITEM_PRICE_TESTS: - self.create_regular_web_item() + create_regular_web_item() make_web_item_price(item_code="Test Mobile Phone") make_web_pricing_rule( title="Test Pricing Rule for Test Mobile Phone", item_code="Test Mobile Phone", selling=1) - def tearDown(self): - if self._testMethodName in WEBITEM_DESK_TESTS: - frappe.get_doc("Item", "Test Web Item").delete() - elif self._testMethodName in WEBITEM_PRICE_TESTS: - frappe.delete_doc("Pricing Rule", "Test Pricing Rule for Test Mobile Phone") - frappe.get_cached_doc("Item Price", {"item_code": "Test Mobile Phone"}).delete() - frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete() - - def test_index_creation(self): "Check if index is getting created in db." from erpnext.e_commerce.doctype.website_item.website_item import on_doctype_update @@ -105,6 +100,8 @@ class TestWebsiteItem(unittest.TestCase): item.reload() self.assertEqual(item.published_in_website, 0) + item.delete() + def test_publish_variant_and_template(self): "Check if template is published on publishing variant." # template "Test Web Item" created on setUp @@ -256,7 +253,7 @@ class TestWebsiteItem(unittest.TestCase): 2) Showing stock availability disabled """ item_code = "Test Mobile Phone" - self.create_regular_web_item() + create_regular_web_item() setup_e_commerce_settings({"show_stock_availability": 1}) frappe.local.shopping_cart_settings = None @@ -298,7 +295,7 @@ class TestWebsiteItem(unittest.TestCase): from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry item_code = "Test Mobile Phone" - self.create_regular_web_item() + create_regular_web_item() setup_e_commerce_settings({"show_stock_availability": 1}) frappe.local.shopping_cart_settings = None @@ -339,7 +336,7 @@ class TestWebsiteItem(unittest.TestCase): def test_recommended_item(self): "Check if added recommended items are fetched correctly." item_code = "Test Mobile Phone" - web_item = self.create_regular_web_item(item_code) + web_item = create_regular_web_item(item_code) setup_e_commerce_settings({ "enable_recommendations": 1, @@ -347,7 +344,7 @@ class TestWebsiteItem(unittest.TestCase): }) # create recommended web item and price for it - recommended_web_item = self.create_regular_web_item("Test Mobile Phone 1") + recommended_web_item = create_regular_web_item("Test Mobile Phone 1") make_web_item_price(item_code="Test Mobile Phone 1") # add recommended item to first web item @@ -379,14 +376,14 @@ class TestWebsiteItem(unittest.TestCase): self.assertFalse(bool(recommended_items[0].get("price_info"))) # price not fetched # tear down - frappe.get_cached_doc("Item Price", {"item_code": "Test Mobile Phone 1"}).delete() web_item.delete() recommended_web_item.delete() + frappe.get_cached_doc("Item", "Test Mobile Phone 1").delete() def test_recommended_item_for_guest_user(self): "Check if added recommended items are fetched correctly for guest user." item_code = "Test Mobile Phone" - web_item = self.create_regular_web_item(item_code) + web_item = create_regular_web_item(item_code) # price visible to guests setup_e_commerce_settings({ @@ -396,7 +393,7 @@ class TestWebsiteItem(unittest.TestCase): }) # create recommended web item and price for it - recommended_web_item = self.create_regular_web_item("Test Mobile Phone 1") + recommended_web_item = create_regular_web_item("Test Mobile Phone 1") make_web_item_price(item_code="Test Mobile Phone 1") # add recommended item to first web item @@ -428,22 +425,24 @@ class TestWebsiteItem(unittest.TestCase): # tear down frappe.set_user("Administrator") - frappe.get_cached_doc("Item Price", {"item_code": "Test Mobile Phone 1"}).delete() web_item.delete() recommended_web_item.delete() + frappe.get_cached_doc("Item", "Test Mobile Phone 1").delete() - def create_regular_web_item(self, item_code=None): - "Create Regular Item and Website Item." - item_code = item_code or "Test Mobile Phone" - item = make_item(item_code) +def create_regular_web_item(item_code=None, item_args=None, web_args=None): + "Create Regular Item and Website Item." + item_code = item_code or "Test Mobile Phone" + item = make_item(item_code, properties=item_args) - if not frappe.db.exists("Website Item", {"item_code": item_code}): - web_item = make_website_item(item, save=False) - web_item.save() - else: - web_item = frappe.get_cached_doc("Website Item", {"item_code": item_code}) + if not frappe.db.exists("Website Item", {"item_code": item_code}): + web_item = make_website_item(item, save=False) + if web_args: + web_item.update(web_args) + web_item.save() + else: + web_item = frappe.get_cached_doc("Website Item", {"item_code": item_code}) - return web_item + return web_item def make_web_item_price(**kwargs): item_code = kwargs.get("item_code") diff --git a/erpnext/e_commerce/product_configurator/test_product_configurator.py b/erpnext/e_commerce/product_configurator/test_product_configurator.py deleted file mode 100644 index 1817a78e903..00000000000 --- a/erpnext/e_commerce/product_configurator/test_product_configurator.py +++ /dev/null @@ -1,144 +0,0 @@ -import frappe, unittest -from erpnext.e_commerce.product_data_engine.query import ProductQuery -from erpnext.e_commerce.doctype.website_item.website_item import make_website_item - -test_dependencies = ["Item"] -#TODO: Rename to test item variant configurator - -class TestProductConfigurator(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.create_variant_item() - - @classmethod - def create_variant_item(cls): - if not frappe.db.exists('Item', '_Test Variant Item - 2XL'): - frappe.get_doc({ - "description": "_Test Variant Item - 2XL", - "item_code": "_Test Variant Item - 2XL", - "item_name": "_Test Variant Item - 2XL", - "doctype": "Item", - "is_stock_item": 1, - "variant_of": "_Test Variant Item", - "item_group": "_Test Item Group", - "stock_uom": "_Test UOM", - "item_defaults": [{ - "company": "_Test Company", - "default_warehouse": "_Test Warehouse - _TC", - "expense_account": "_Test Account Cost for Goods Sold - _TC", - "buying_cost_center": "_Test Cost Center - _TC", - "selling_cost_center": "_Test Cost Center - _TC", - "income_account": "Sales - _TC" - }], - "attributes": [ - { - "attribute": "Test Size", - "attribute_value": "2XL" - } - ], - "show_variant_in_website": 1 - }).insert() - - def create_regular_web_item(self, name, item_group=None): - if not frappe.db.exists('Item', name): - doc = frappe.get_doc({ - "description": name, - "item_code": name, - "item_name": name, - "doctype": "Item", - "is_stock_item": 1, - "item_group": item_group or "_Test Item Group", - "stock_uom": "_Test UOM", - "item_defaults": [{ - "company": "_Test Company", - "default_warehouse": "_Test Warehouse - _TC", - "expense_account": "_Test Account Cost for Goods Sold - _TC", - "buying_cost_center": "_Test Cost Center - _TC", - "selling_cost_center": "_Test Cost Center - _TC", - "income_account": "Sales - _TC" - }], - "show_in_website": 1 - }).insert() - else: - doc = frappe.get_doc("Item", name) - return doc - - # TODO: E-commerce server side tests - # def test_product_list(self): - # template_items = frappe.get_all('Item', {'show_in_website': 1}) - # variant_items = frappe.get_all('Item', {'show_variant_in_website': 1}) - - # products_settings = frappe.get_doc('Products Settings') - # products_settings.enable_field_filters = 1 - # products_settings.append('filter_fields', {'fieldname': 'item_group'}) - # products_settings.append('filter_fields', {'fieldname': 'stock_uom'}) - # products_settings.save() - - # html = get_html_for_route('all-products') - - # soup = BeautifulSoup(html, 'html.parser') - # products_list = soup.find(class_='products-list') - # items = products_list.find_all(class_='card') - # self.assertEqual(len(items), len(template_items + variant_items)) - - # items_with_item_group = frappe.get_all('Item', {'item_group': '_Test Item Group Desktops', 'show_in_website': 1}) - # variants_with_item_group = frappe.get_all('Item', {'item_group': '_Test Item Group Desktops', 'show_variant_in_website': 1}) - - # # mock query params - # frappe.form_dict = frappe._dict({ - # 'field_filters': '{"item_group":["_Test Item Group Desktops"]}' - # }) - # html = get_html_for_route('all-products') - # soup = BeautifulSoup(html, 'html.parser') - # products_list = soup.find(class_='products-list') - # items = products_list.find_all(class_='card') - # self.assertEqual(len(items), len(items_with_item_group + variants_with_item_group)) - - - # def test_get_products_for_website(self): - # items = get_products_for_website(attribute_filters={ - # 'Test Size': ['2XL'] - # }) - # self.assertEqual(len(items), 1) - - # def test_products_in_multiple_item_groups(self): - # """Check if product is visible on multiple item group pages barring its own.""" - # from erpnext.shopping_cart.product_query import ProductQuery - - # if not frappe.db.exists("Item Group", {"name": "Tech Items"}): - # item_group_doc = frappe.get_doc({ - # "doctype": "Item Group", - # "item_group_name": "Tech Items", - # "parent_item_group": "All Item Groups", - # "show_in_website": 1 - # }).insert() - # else: - # item_group_doc = frappe.get_doc("Item Group", "Tech Items") - - # doc = self.create_regular_web_item("Portal Item", item_group="Tech Items") - # if not frappe.db.exists("Website Item Group", {"parent": "Portal Item"}): - # doc.append("website_item_groups", { - # "item_group": "_Test Item Group Desktops" - # }) - # doc.save() - - # # check if item is visible in its own Item Group's page - # engine = ProductQuery() - # result = engine.query({}, {"item_group": "Tech Items"}, None, start=0, item_group="Tech Items") - # items = result["items"] - - # self.assertEqual(len(items), 1) - # self.assertEqual(items[0].item_code, "Portal Item") - - # # check if item is visible in configured foreign Item Group's page - # engine = ProductQuery() - # result = engine.query({}, {"item_group": "_Test Item Group Desktops"}, None, start=0, item_group="_Test Item Group Desktops") - # items = result["items"] - # item_codes = [row.item_code for row in items] - - # self.assertIn(len(items), [2, 3]) - # self.assertIn("Portal Item", item_codes) - - # # teardown - # doc.delete() - # item_group_doc.delete() \ No newline at end of file diff --git a/erpnext/e_commerce/product_data_engine/filters.py b/erpnext/e_commerce/product_data_engine/filters.py index b9596231f7a..d9faf81fb63 100644 --- a/erpnext/e_commerce/product_data_engine/filters.py +++ b/erpnext/e_commerce/product_data_engine/filters.py @@ -6,7 +6,7 @@ from frappe.utils import floor class ProductFiltersBuilder: def __init__(self, item_group=None): - if not item_group or item_group == "E Commerce Settings": + if not item_group: self.doc = frappe.get_doc("E Commerce Settings") else: self.doc = frappe.get_doc("Item Group", item_group) @@ -17,36 +17,39 @@ class ProductFiltersBuilder: if not self.item_group and not self.doc.enable_field_filters: return - filter_fields = [row.fieldname for row in self.doc.filter_fields] + fields, filter_data = [], [] + filter_fields = [row.fieldname for row in self.doc.filter_fields] # fields in settings - meta = frappe.get_meta('Item') - fields = [df for df in meta.fields if df.fieldname in filter_fields] + # filter valid field filters i.e. those that exist in Item + item_meta = frappe.get_meta('Item', cached=True) + fields = [item_meta.get_field(field) for field in filter_fields if item_meta.has_field(field)] - filter_data = [] for df in fields: - filters, or_filters = {}, [] + item_filters, item_or_filters = {}, [] + link_doctype_values = self.get_filtered_link_doctype_records(df) + if df.fieldtype == "Link": if self.item_group: - or_filters.extend([ + item_or_filters.extend([ ["item_group", "=", self.item_group], - ["Website Item Group", "item_group", "=", self.item_group] + ["Website Item Group", "item_group", "=", self.item_group] # consider website item groups ]) - values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, or_filters=or_filters, distinct="True", pluck=df.fieldname) + # Get link field values attached to published items + item_filters['published_in_website'] = 1 + item_values = frappe.get_all( + "Item", + fields=[df.fieldname], + filters=item_filters, + or_filters=item_or_filters, + distinct="True", + pluck=df.fieldname + ) + + values = list(set(item_values) & link_doctype_values) # intersection of both else: - doctype = df.get_link_doctype() - - # apply enable/disable/show_in_website filter - meta = frappe.get_meta(doctype) - - if meta.has_field('enabled'): - filters['enabled'] = 1 - if meta.has_field('disabled'): - filters['disabled'] = 0 - if meta.has_field('show_in_website'): - filters['show_in_website'] = 1 - - values = [d.name for d in frappe.get_all(doctype, filters)] + # table multiselect + values = list(link_doctype_values) # Remove None if None in values: @@ -57,6 +60,36 @@ class ProductFiltersBuilder: return filter_data + def get_filtered_link_doctype_records(self, field): + """ + Get valid link doctype records depending on filters. + Apply enable/disable/show_in_website filter. + Returns: + set: A set containing valid record names + """ + link_doctype = field.get_link_doctype() + meta = frappe.get_meta(link_doctype, cached=True) if link_doctype else None + if meta: + filters = self.get_link_doctype_filters(meta) + link_doctype_values = set(d.name for d in frappe.get_all(link_doctype, filters)) + + return link_doctype_values if meta else set() + + def get_link_doctype_filters(self, meta): + "Filters for Link Doctype eg. 'show_in_website'." + filters = {} + if not meta: + return filters + + if meta.has_field('enabled'): + filters['enabled'] = 1 + if meta.has_field('disabled'): + filters['disabled'] = 0 + if meta.has_field('show_in_website'): + filters['show_in_website'] = 1 + + return filters + def get_attribute_filters(self): if not self.item_group and not self.doc.enable_attribute_filters: return @@ -98,9 +131,9 @@ class ProductFiltersBuilder: def get_discount_filters(self, discounts): discount_filters = [] - # [25.89, 60.5] + # [25.89, 60.5] min max min_discount, max_discount = discounts[0], discounts[1] - # [25, 60] + # [25, 60] rounded min max min_range_absolute, max_range_absolute = floor(min_discount), floor(max_discount) min_range = int(min_discount - (min_range_absolute % 10)) # 20 max_range = int(max_discount - (max_range_absolute % 10)) # 60 diff --git a/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py b/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py new file mode 100644 index 00000000000..264fbd8bf70 --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py @@ -0,0 +1,116 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import unittest + +from erpnext.e_commerce.api import get_product_filter_data +from erpnext.e_commerce.doctype.website_item.test_website_item import create_regular_web_item + +test_dependencies = ["Item", "Item Group"] + +class TestItemGroupProductDataEngine(unittest.TestCase): + "Test Products & Sub-Category Querying for Product Listing on Item Group Page." + + @classmethod + def setUpClass(cls): + item_codes = [ + ("Test Mobile A", "_Test Item Group B"), + ("Test Mobile B", "_Test Item Group B"), + ("Test Mobile C", "_Test Item Group B - 1"), + ("Test Mobile D", "_Test Item Group B - 1"), + ("Test Mobile E", "_Test Item Group B - 2") + ] + for item in item_codes: + item_code = item[0] + item_args = {"item_group": item[1]} + if not frappe.db.exists("Website Item", {"item_code": item_code}): + create_regular_web_item(item_code, item_args=item_args) + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + + def test_product_listing_in_item_group(self): + "Test if only products belonging to the Item Group are fetched." + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B" + }) + + items = result.get("items") + item_codes = [item.get("item_code") for item in items] + + self.assertEqual(len(items), 2) + self.assertIn("Test Mobile A", item_codes) + self.assertNotIn("Test Mobile C", item_codes) + + def test_products_in_multiple_item_groups(self): + """Test if product is visible on multiple item group pages barring its own.""" + website_item = frappe.get_doc("Website Item", {"item_code": "Test Mobile E"}) + + # show item belonging to '_Test Item Group B - 2' in '_Test Item Group B - 1' as well + website_item.append("website_item_groups", { + "item_group": "_Test Item Group B - 1" + }) + website_item.save() + + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B - 1" + }) + + items = result.get("items") + item_codes = [item.get("item_code") for item in items] + + self.assertEqual(len(items), 3) + self.assertIn("Test Mobile E", item_codes) # visible in other item groups + self.assertIn("Test Mobile C", item_codes) + self.assertIn("Test Mobile D", item_codes) + + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B - 2" + }) + + items = result.get("items") + + self.assertEqual(len(items), 1) + self.assertEqual(items[0].get("item_code"), "Test Mobile E") # visible in own item group + + def test_item_group_with_sub_groups(self): + "Test Valid Sub Item Groups in Item Group Page." + frappe.db.set_value("Item Group", "_Test Item Group B - 1", "show_in_website", 1) + frappe.db.set_value("Item Group", "_Test Item Group B - 2", "show_in_website", 0) + + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B" + }) + + self.assertTrue(bool(result.get("sub_categories"))) + + child_groups = [d.name for d in result.get("sub_categories")] + # check if child group is fetched if shown in website + self.assertIn("_Test Item Group B - 1", child_groups) + + frappe.db.set_value("Item Group", "_Test Item Group B - 2", "show_in_website", 1) + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B" + }) + child_groups = [d.name for d in result.get("sub_categories")] + + # check if child group is fetched if shown in website + self.assertIn("_Test Item Group B - 1", child_groups) + self.assertIn("_Test Item Group B - 2", child_groups) \ No newline at end of file diff --git a/erpnext/e_commerce/product_data_engine/test_product_data_engine.py b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py index 8bca04634d2..9a7cb3c3cb6 100644 --- a/erpnext/e_commerce/product_data_engine/test_product_data_engine.py +++ b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py @@ -2,37 +2,337 @@ # For license information, please see license.txt import frappe +import unittest -test_dependencies = ["Item"] +from erpnext.e_commerce.product_data_engine.query import ProductQuery +from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder +from erpnext.e_commerce.doctype.website_item.test_website_item import create_regular_web_item +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import setup_e_commerce_settings + +test_dependencies = ["Item", "Item Group"] class TestProductDataEngine(unittest.TestCase): - "Test Products Querying for Product Listing." - def test_product_list_ordering(self): - "Check if website items appear by ranking." - pass + "Test Products Querying and Filters for Product Listing." - def test_product_list_paging(self): - pass + @classmethod + def setUpClass(cls): + item_codes = [ + ("Test 11I Laptop", "Products"), # rank 1 + ("Test 12I Laptop", "Products"), # rank 2 + ("Test 13I Laptop", "Products"), # rank 3 + ("Test 14I Laptop", "Raw Material"), # rank 4 + ("Test 15I Laptop", "Raw Material"), # rank 5 + ("Test 16I Laptop", "Raw Material"), # rank 6 + ("Test 17I Laptop", "Products") # rank 7 + ] + for index, item in enumerate(item_codes, start=1): + item_code = item[0] + item_args = {"item_group": item[1]} + web_args = {"ranking": index} + if not frappe.db.exists("Website Item", {"item_code": item_code}): + create_regular_web_item(item_code, item_args=item_args, web_args=web_args) + + setup_e_commerce_settings({ + "products_per_page": 4, + "enable_field_filters": 1, + "filter_fields": [{"fieldname": "item_group"}], + "enable_attribute_filters": 1, + "filter_attributes": [{"attribute": "Test Size"}] + }) + frappe.local.shopping_cart_settings = None + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + + def test_product_list_ordering_and_paging(self): + "Test if website items appear by ranking on different pages." + engine = ProductQuery() + result = engine.query( + attributes={}, + fields={}, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + self.assertIsNotNone(items) + self.assertEqual(len(items), 4) + self.assertGreater(result.get("items_count"), 4) + + # check if items appear as per ranking set in setUpClass + self.assertEqual(items[0].get("item_code"), "Test 17I Laptop") + self.assertEqual(items[1].get("item_code"), "Test 16I Laptop") + self.assertEqual(items[2].get("item_code"), "Test 15I Laptop") + self.assertEqual(items[3].get("item_code"), "Test 14I Laptop") + + # check next page + result = engine.query( + attributes={}, + fields={}, + search_term=None, + start=4, + item_group=None + ) + items = result.get("items") + + # check if items appear as per ranking set in setUpClass on next page + self.assertEqual(items[0].get("item_code"), "Test 13I Laptop") + self.assertEqual(items[1].get("item_code"), "Test 12I Laptop") + self.assertEqual(items[2].get("item_code"), "Test 11I Laptop") + + def test_change_product_ranking(self): + "Test if item on second page appear on first if ranking is changed." + item_code = "Test 12I Laptop" + old_ranking = frappe.db.get_value("Website Item", {"item_code": item_code}, "ranking") + + # low rank, appears on second page + self.assertEqual(old_ranking, 2) + + # set ranking as highest rank + frappe.db.set_value("Website Item", {"item_code": item_code}, "ranking", 10) + + engine = ProductQuery() + result = engine.query( + attributes={}, + fields={}, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if item is the first item on the first page + self.assertEqual(items[0].get("item_code"), item_code) + self.assertEqual(items[1].get("item_code"), "Test 17I Laptop") + + # tear down + frappe.db.set_value("Website Item", {"item_code": item_code}, "ranking", old_ranking) + + def test_product_list_field_filter_builder(self): + "Test if field filters are fetched correctly." + frappe.db.set_value("Item Group", "Raw Material", "show_in_website", 0) + + filter_engine = ProductFiltersBuilder() + field_filters = filter_engine.get_field_filters() + + # Web Items belonging to 'Products' and 'Raw Material' are available + # but only 'Products' has 'show_in_website' enabled + item_group_filters = field_filters[0] + docfield = item_group_filters[0] + valid_item_groups = item_group_filters[1] + + self.assertEqual(docfield.options, "Item Group") + self.assertIn("Products", valid_item_groups) + self.assertNotIn("Raw Material", valid_item_groups) + + frappe.db.set_value("Item Group", "Raw Material", "show_in_website", 1) + field_filters = filter_engine.get_field_filters() + + #'Products' and 'Raw Materials' both have 'show_in_website' enabled + item_group_filters = field_filters[0] + docfield = item_group_filters[0] + valid_item_groups = item_group_filters[1] + + self.assertEqual(docfield.options, "Item Group") + self.assertIn("Products", valid_item_groups) + self.assertIn("Raw Material", valid_item_groups) def test_product_list_with_field_filter(self): - pass + "Test if field filters are applied correctly." + field_filters = {"item_group": "Raw Material"} + + engine = ProductQuery() + result = engine.query( + attributes={}, + fields=field_filters, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if only 'Raw Material' are fetched in the right order + self.assertEqual(len(items), 3) + self.assertEqual(items[0].get("item_code"), "Test 16I Laptop") + self.assertEqual(items[1].get("item_code"), "Test 15I Laptop") + + # def test_product_list_with_field_filter_table_multiselect(self): + # TODO + # pass + + def test_product_list_attribute_filter_builder(self): + "Test if attribute filters are fetched correctly." + create_variant_web_item() + + filter_engine = ProductFiltersBuilder() + attribute_filter = filter_engine.get_attribute_filters()[0] + attribute = attribute_filter.item_attribute_values[0] + + self.assertEqual(attribute_filter.name, "Test Size") + self.assertEqual(len(attribute_filter.item_attribute_values), 1) + self.assertEqual(attribute.attribute_value, "Large") def test_product_list_with_attribute_filter(self): - pass + "Test if attribute filters are applied correctly." + create_variant_web_item() - def test_product_list_with_discount_filter(self): - pass + attribute_filters = {"Test Size": ["Large"]} + engine = ProductQuery() + result = engine.query( + attributes=attribute_filters, + fields={}, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") - def test_product_list_with_mixed_filtes(self): - pass + # check if only items with Test Size 'Large' are fetched + self.assertEqual(len(items), 1) + self.assertEqual(items[0].get("item_code"), "Test Web Item-L") - def test_product_list_with_mixed_filtes_item_group(self): - pass + def test_product_list_discount_filter_builder(self): + "Test if discount filters are fetched correctly." + from erpnext.e_commerce.doctype.website_item.test_website_item import make_web_item_price, make_web_pricing_rule - def test_products_in_multiple_item_groups(self): - "Check if product is visible on multiple item group pages barring its own." - pass + item_code = "Test 12I Laptop" + make_web_item_price(item_code=item_code) + make_web_pricing_rule( + title=f"Test Pricing Rule for {item_code}", + item_code=item_code, + selling=1 + ) + + setup_e_commerce_settings({"show_price": 1}) + frappe.local.shopping_cart_settings = None + + engine = ProductQuery() + result = engine.query( + attributes={}, + fields={}, + search_term=None, + start=4, + item_group=None + ) + + self.assertTrue(bool(result.get("discounts"))) + + filter_engine = ProductFiltersBuilder() + discount_filters = filter_engine.get_discount_filters(result["discounts"]) + + self.assertEqual(len(discount_filters[0]), 2) + self.assertEqual(discount_filters[0][0], 10) + self.assertEqual(discount_filters[0][1], "10% and above") + + def test_product_list_with_discount_filters(self): + "Test if discount filters are applied correctly." + from erpnext.e_commerce.doctype.website_item.test_website_item import make_web_item_price, make_web_pricing_rule + + field_filters = {"discount": [10]} + + make_web_item_price(item_code="Test 12I Laptop") + make_web_pricing_rule( + title="Test Pricing Rule for Test 12I Laptop", # 10% discount + item_code="Test 12I Laptop", + selling=1 + ) + make_web_item_price(item_code="Test 13I Laptop") + make_web_pricing_rule( + title="Test Pricing Rule for Test 13I Laptop", # 15% discount + item_code="Test 13I Laptop", + discount_percentage=15, + selling=1 + ) + + setup_e_commerce_settings({"show_price": 1}) + frappe.local.shopping_cart_settings = None + + engine = ProductQuery() + result = engine.query( + attributes={}, + fields=field_filters, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if only product with 10% and above discount are fetched in the right order + self.assertEqual(len(items), 2) + self.assertEqual(items[0].get("item_code"), "Test 13I Laptop") + self.assertEqual(items[1].get("item_code"), "Test 12I Laptop") + + def test_product_list_with_api(self): + "Test products listing using API." + from erpnext.e_commerce.api import get_product_filter_data + + create_variant_web_item() + + result = get_product_filter_data(query_args={ + "field_filters": { + "item_group": "Products" + }, + "attribute_filters": { + "Test Size": ["Large"] + }, + "start": 0 + }) + + items = result.get("items") + + self.assertEqual(len(items), 1) + self.assertEqual(items[0].get("item_code"), "Test Web Item-L") def test_product_list_with_variants(self): - pass + "Test if variants are hideen on hiding variants in settings." + create_variant_web_item() + setup_e_commerce_settings({ + "enable_attribute_filters": 0, + "hide_variants": 1 + }) + frappe.local.shopping_cart_settings = None + + attribute_filters = {"Test Size": ["Large"]} + engine = ProductQuery() + result = engine.query( + attributes=attribute_filters, + fields={}, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if any variants are fetched even though published variant exists + self.assertEqual(len(items), 0) + + # tear down + setup_e_commerce_settings({ + "enable_attribute_filters": 1, + "hide_variants": 0 + }) + +def create_variant_web_item(): + "Create Variant and Template Website Items." + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.controllers.item_variant import create_variant + from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + + make_item("Test Web Item", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + { + "attribute": "Test Size" + } + ] + }) + if not frappe.db.exists("Item", "Test Web Item-L"): + variant = create_variant("Test Web Item", {"Test Size": "Large"}) + variant.save() + + if not frappe.db.exists("Website Item", {"variant_of": "Test Web Item"}): + make_website_item(variant, save=True) \ No newline at end of file diff --git a/erpnext/e_commerce/product_configurator/__init__.py b/erpnext/e_commerce/variant_selector/__init__.py similarity index 100% rename from erpnext/e_commerce/product_configurator/__init__.py rename to erpnext/e_commerce/variant_selector/__init__.py diff --git a/erpnext/e_commerce/product_configurator/item_variants_cache.py b/erpnext/e_commerce/variant_selector/item_variants_cache.py similarity index 100% rename from erpnext/e_commerce/product_configurator/item_variants_cache.py rename to erpnext/e_commerce/variant_selector/item_variants_cache.py diff --git a/erpnext/e_commerce/variant_selector/test_variant_selector.py b/erpnext/e_commerce/variant_selector/test_variant_selector.py new file mode 100644 index 00000000000..3eeca173fa3 --- /dev/null +++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py @@ -0,0 +1,10 @@ +# import frappe +import unittest +# from erpnext.e_commerce.product_data_engine.query import ProductQuery +# from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + +test_dependencies = ["Item"] + +class TestVariantSelector(unittest.TestCase): + # TODO: Variant Selector Tests + pass \ No newline at end of file diff --git a/erpnext/e_commerce/product_configurator/utils.py b/erpnext/e_commerce/variant_selector/utils.py similarity index 98% rename from erpnext/e_commerce/product_configurator/utils.py rename to erpnext/e_commerce/variant_selector/utils.py index f2d276d08a7..2e1852c0256 100644 --- a/erpnext/e_commerce/product_configurator/utils.py +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -1,6 +1,6 @@ import frappe from frappe.utils import cint -from erpnext.e_commerce.product_configurator.item_variants_cache import ItemVariantsCacheManager +from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager def get_item_codes_by_attributes(attribute_filters, template_item_code=None): items = [] diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index 6cadfa63033..5911f042f41 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -492,12 +492,7 @@ body.product-page { } .item-configurator-dialog { - .modal-header { - padding: var(--padding-md) var(--padding-xl); - } - .modal-body { - padding: 0 var(--padding-xl); padding-bottom: var(--padding-xl); .status-area { @@ -1282,13 +1277,10 @@ body.product-page { font-size: 72px; } -.modal-backdrop { - position: fixed; - top: 0; - right: 0; - left: 0; - background-color: var(--gray-100); - height: 100%; +[data-path="cart"] { + .modal-backdrop { + background-color: var(--gray-50); // lighter backdrop only on cart freeze + } } .item-thumb { diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index 7dd8c7c9db5..9bdc53be4ee 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -109,12 +109,23 @@ class ItemGroup(NestedSet, WebsiteGenerator): def delete_child_item_groups_key(self): frappe.cache().hdel("child_item_groups", self.name) -def get_child_groups(item_group_name): +def get_child_groups_for_website(item_group_name, immediate=False): """Returns child item groups *excluding* passed group.""" - item_group = frappe.get_doc("Item Group", item_group_name) - return frappe.db.sql("""select name, route - from `tabItem Group` where lft>%(lft)s and rgt<%(rgt)s - and show_in_website = 1""", {"lft": item_group.lft, "rgt": item_group.rgt}, as_dict=1) + item_group = frappe.get_cached_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1) + filters = { + "lft": [">", item_group.lft], + "rgt": ["<", item_group.rgt], + "show_in_website": 1 + } + + if immediate: + filters["parent_item_group"] = item_group_name + + return frappe.get_all( + "Item Group", + filters=filters, + fields=["name", "route"] + ) def get_child_item_groups(item_group_name): item_group = frappe.get_cached_value("Item Group", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 377225a6616..45a4fb3df7d 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -877,7 +877,7 @@ def invalidate_cache_for_item(doc): def invalidate_item_variants_cache_for_website(doc): """Rebuild ItemVariantsCacheManager via Item or Website Item.""" - from erpnext.e_commerce.product_configurator.item_variants_cache import ItemVariantsCacheManager + from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager item_code = None is_web_item = doc.get("published_in_website") or doc.get("published") diff --git a/erpnext/templates/generators/item/item_configure.html b/erpnext/templates/generators/item/item_configure.html index 9ff1d79e6d6..fcab594402b 100644 --- a/erpnext/templates/generators/item/item_configure.html +++ b/erpnext/templates/generators/item/item_configure.html @@ -3,11 +3,11 @@
{% if cart_settings.enable_variants | int %} - {% endif %} {% if cart_settings.show_contact_us_button %} diff --git a/erpnext/templates/generators/item/item_configure.js b/erpnext/templates/generators/item/item_configure.js index f47650a27e2..3220226f7fa 100644 --- a/erpnext/templates/generators/item/item_configure.js +++ b/erpnext/templates/generators/item/item_configure.js @@ -29,7 +29,7 @@ class ItemConfigure { }); this.dialog = new frappe.ui.Dialog({ - title: __('Configure {0}', [this.item_name]), + title: __('Select Variant for {0}', [this.item_name]), fields, on_hide: () => { set_continue_configuration(); @@ -280,14 +280,14 @@ class ItemConfigure { } get_next_attribute_and_values(selected_attributes) { - return this.call('erpnext.e_commerce.product_configurator.utils.get_next_attribute_and_values', { + return this.call('erpnext.e_commerce.variant_selector.utils.get_next_attribute_and_values', { item_code: this.item_code, selected_attributes }); } get_attributes_and_values() { - return this.call('erpnext.e_commerce.product_configurator.utils.get_attributes_and_values', { + return this.call('erpnext.e_commerce.variant_selector.utils.get_attributes_and_values', { item_code: this.item_code }); } @@ -311,9 +311,9 @@ function set_continue_configuration() { const { itemCode } = $btn_configure.data(); if (localStorage.getItem(`configure:${itemCode}`)) { - $btn_configure.text(__('Continue Configuration')); + $btn_configure.text(__('Continue Selection')); } else { - $btn_configure.text(__('Configure')); + $btn_configure.text(__('Select Variant')); } }