diff --git a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py index 06987a8a4a5..32e4ff29690 100644 --- a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py +++ b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py @@ -41,9 +41,6 @@ def test_create_test_data(): "selling_cost_center": "Main - _TC", "income_account": "Sales - _TC" }], - "show_in_website": 1, - "route":"-test-tesla-car", - "website_warehouse": "Stores - _TC" }) item.insert() # create test item price diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index f83cb375fcf..a6352631cd3 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -286,7 +286,7 @@ class PaymentRequest(Document): if not status: return - shopping_cart_settings = frappe.get_doc("Shopping Cart Settings") + shopping_cart_settings = frappe.get_doc("E Commerce Settings") if status in ["Authorized", "Completed"]: redirect_to = None @@ -436,7 +436,7 @@ def get_gateway_details(args): return get_payment_gateway_account(args.get("payment_gateway_account")) if args.order_type == "Shopping Cart": - payment_gateway_account = frappe.get_doc("Shopping Cart Settings").payment_gateway_account + payment_gateway_account = frappe.get_doc("E Commerce Settings").payment_gateway_account return get_payment_gateway_account(payment_gateway_account) gateway_account = get_payment_gateway_account({"is_default": 1}) diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.py b/erpnext/accounts/doctype/tax_rule/tax_rule.py index 58142318177..bc0c1222e12 100644 --- a/erpnext/accounts/doctype/tax_rule/tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/tax_rule.py @@ -100,7 +100,7 @@ class TaxRule(Document): def validate_use_for_shopping_cart(self): '''If shopping cart is enabled and no tax rule exists for shopping cart, enable this one''' if (not self.use_for_shopping_cart - and cint(frappe.db.get_single_value('Shopping Cart Settings', 'enabled')) + and cint(frappe.db.get_single_value('E Commerce Settings', 'enabled')) and not frappe.db.get_value('Tax Rule', {'use_for_shopping_cart': 1, 'name': ['!=', self.name]})): self.use_for_shopping_cart = 1 diff --git a/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json b/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json deleted file mode 100644 index ce3d8cfb7b2..00000000000 --- a/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "add_more_button": 1, - "app": "ERPNext", - "creation": "2019-11-15 14:45:32.626641", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [ - { - "label": "Learn More", - "video_id": "zsrrVDk6VBs" - } - ], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 3, - "modified": "2019-12-09 17:54:18.452038", - "modified_by": "Administrator", - "name": "Add A Few Suppliers", - "owner": "Administrator", - "ref_doctype": "Supplier", - "slide_desc": "", - "slide_fields": [ - { - "align": "", - "fieldname": "supplier_name", - "fieldtype": "Data", - "label": "Supplier Name", - "placeholder": "", - "reqd": 1 - }, - { - "align": "", - "fieldtype": "Column Break", - "reqd": 0 - }, - { - "align": "", - "fieldname": "supplier_email", - "fieldtype": "Data", - "label": "Supplier Email", - "reqd": 1 - } - ], - "slide_order": 50, - "slide_title": "Add A Few Suppliers", - "slide_type": "Create" -} \ No newline at end of file diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 8c361a2e561..9907a8a9fa1 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.portal.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: @@ -261,9 +261,8 @@ def generate_keyed_value_combinations(args): def copy_attributes_to_variant(item, variant): # copy non no-copy fields - exclude_fields = ["naming_series", "item_code", "item_name", "show_in_website", - "show_variant_in_website", "opening_stock", "variant_of", "valuation_rate", - "has_variants", "attributes"] + exclude_fields = ["naming_series", "item_code", "item_name", "published_in_website", + "opening_stock", "variant_of", "valuation_rate", "has_variants", "attributes"] if item.variant_based_on=='Manufacturer': # don't copy manufacturer values if based on part no diff --git a/erpnext/demo/data/drug_list.json b/erpnext/demo/data/drug_list.json index e91c30d199a..c7c06c946de 100644 --- a/erpnext/demo/data/drug_list.json +++ b/erpnext/demo/data/drug_list.json @@ -54,7 +54,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -138,7 +137,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -220,7 +218,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -302,7 +299,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -384,7 +380,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -466,7 +461,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -548,7 +542,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -630,7 +623,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -712,7 +704,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -794,7 +785,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -876,7 +866,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -958,7 +947,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1040,7 +1028,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1122,7 +1109,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1204,7 +1190,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1286,7 +1271,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1368,7 +1352,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1450,7 +1433,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1532,7 +1514,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1614,7 +1595,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1696,7 +1676,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1778,7 +1757,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1860,7 +1838,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -1942,7 +1919,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2024,7 +2000,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2106,7 +2081,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2188,7 +2162,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2270,7 +2243,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2352,7 +2324,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2434,7 +2405,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2516,7 +2486,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2598,7 +2567,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2680,7 +2648,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2762,7 +2729,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2844,7 +2810,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -2926,7 +2891,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3008,7 +2972,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3092,7 +3055,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3174,7 +3136,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3256,7 +3217,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3338,7 +3298,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3420,7 +3379,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3502,7 +3460,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3584,7 +3541,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3666,7 +3622,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3748,7 +3703,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3830,7 +3784,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3912,7 +3865,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -3994,7 +3946,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4076,7 +4027,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4158,7 +4108,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4240,7 +4189,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4322,7 +4270,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4404,7 +4351,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4486,7 +4432,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4568,7 +4513,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4650,7 +4594,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4732,7 +4675,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4814,7 +4756,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4896,7 +4837,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -4978,7 +4918,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -5060,7 +4999,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, @@ -5142,7 +5080,6 @@ "safety_stock": 0.0, "selling_cost_center": null, "serial_no_series": null, - "show_in_website": 0, "show_variant_in_website": 0, "slideshow": null, "standard_rate": 0.0, diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/e_commerce/__init__.py similarity index 100% rename from erpnext/portal/doctype/products_settings/__init__.py rename to erpnext/e_commerce/__init__.py diff --git a/erpnext/e_commerce/api.py b/erpnext/e_commerce/api.py new file mode 100644 index 00000000000..01bde2984c1 --- /dev/null +++ b/erpnext/e_commerce/api.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import json +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_for_website + +@frappe.whitelist(allow_guest=True) +def get_product_filter_data(query_args=None): + """ + 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) + + if query_args: + search = query_args.get("search") + field_filters = query_args.get("field_filters", {}) + attribute_filters = query_args.get("attribute_filters", {}) + start = cint(query_args.start) if query_args.get("start") else 0 + item_group = query_args.get("item_group") + from_filters = query_args.get("from_filters") + else: + search, attribute_filters, item_group, from_filters = None, None, None, None + field_filters = {} + start = 0 + + # if new filter is checked, reset start to show filtered items from page 1 + if from_filters: + start = 0 + + sub_categories = [] + if item_group: + field_filters['item_group'] = item_group + sub_categories = get_child_groups_for_website(item_group, immediate=True) + + engine = ProductQuery() + try: + result = engine.query( + attribute_filters, + field_filters, + search_term=search, + start=start, + item_group=item_group + ) + except Exception: + traceback = frappe.get_traceback() + frappe.log_error(traceback, frappe._("Product Engine Error")) + return {"exc": "Something went wrong!"} + + # discount filter data + filters = {} + discounts = result["discounts"] + + if discounts: + filter_engine = ProductFiltersBuilder() + filters["discount_filters"] = filter_engine.get_discount_filters(discounts) + + return { + "items": result["items"] or [], + "filters": filters, + "settings": engine.settings, + "sub_categories": sub_categories, + "items_count": result["items_count"] + } + +@frappe.whitelist(allow_guest=True) +def get_guest_redirect_on_action(): + return frappe.db.get_single_value("E Commerce Settings", "redirect_on_action") \ No newline at end of file diff --git a/erpnext/portal/product_configurator/__init__.py b/erpnext/e_commerce/doctype/__init__.py similarity index 100% rename from erpnext/portal/product_configurator/__init__.py rename to erpnext/e_commerce/doctype/__init__.py diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/__init__.py b/erpnext/e_commerce/doctype/e_commerce_settings/__init__.py similarity index 100% rename from erpnext/shopping_cart/doctype/shopping_cart_settings/__init__.py rename to erpnext/e_commerce/doctype/e_commerce_settings/__init__.py diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js similarity index 60% rename from erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js rename to erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js index b38828e0d75..6302d260e0a 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js @@ -1,7 +1,7 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// License: GNU General Public License v3. See license.txt +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt -frappe.ui.form.on("Shopping Cart Settings", { +frappe.ui.form.on("E Commerce Settings", { onload: function(frm) { if(frm.doc.__onload && frm.doc.__onload.quotation_series) { frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series; @@ -23,6 +23,21 @@ frappe.ui.form.on("Shopping Cart Settings", { ` ); } + + frappe.model.with_doctype("Item", () => { + const web_item_meta = frappe.get_meta('Website Item'); + + const valid_fields = web_item_meta.fields.filter( + df => ["Link", "Table MultiSelect"].includes(df.fieldtype) && !df.hidden + ).map(df => ({ label: df.label, value: df.fieldname })); + + frm.fields_dict.filter_fields.grid.update_docfield_property( + 'fieldname', 'fieldtype', 'Select' + ); + frm.fields_dict.filter_fields.grid.update_docfield_property( + 'fieldname', 'options', valid_fields + ); + }); }, enabled: function(frm) { if (frm.doc.enabled === 1) { diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json new file mode 100644 index 00000000000..d5fb9697f89 --- /dev/null +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json @@ -0,0 +1,393 @@ +{ + "actions": [], + "creation": "2021-02-10 17:13:39.139103", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "products_per_page", + "filter_categories_section", + "enable_field_filters", + "filter_fields", + "enable_attribute_filters", + "filter_attributes", + "display_settings_section", + "hide_variants", + "enable_variants", + "show_price", + "column_break_9", + "show_stock_availability", + "show_quantity_in_website", + "allow_items_not_in_stock", + "column_break_13", + "show_apply_coupon_code_in_website", + "show_contact_us_button", + "show_attachments", + "section_break_18", + "company", + "price_list", + "enabled", + "store_page_docs", + "column_break_21", + "default_customer_group", + "quotation_series", + "checkout_settings_section", + "enable_checkout", + "show_price_in_quotation", + "column_break_27", + "save_quotations_as_draft", + "payment_gateway_account", + "payment_success_url", + "add_ons_section", + "enable_wishlist", + "column_break_22", + "enable_reviews", + "column_break_23", + "enable_recommendations", + "item_search_settings_section", + "redisearch_warning", + "search_index_fields", + "show_categories_in_search_autocomplete", + "is_redisearch_loaded", + "shop_by_category_section", + "slideshow", + "guest_display_settings_section", + "hide_price_for_guest", + "redirect_on_action" + ], + "fields": [ + { + "default": "6", + "fieldname": "products_per_page", + "fieldtype": "Int", + "label": "Products per Page" + }, + { + "collapsible": 1, + "fieldname": "filter_categories_section", + "fieldtype": "Section Break", + "label": "Filters and Categories" + }, + { + "default": "0", + "fieldname": "hide_variants", + "fieldtype": "Check", + "label": "Hide Variants" + }, + { + "default": "0", + "description": "The field filters will also work as categories in the Shop by Category page.", + "fieldname": "enable_field_filters", + "fieldtype": "Check", + "label": "Enable Field Filters (Categories)" + }, + { + "default": "0", + "fieldname": "enable_attribute_filters", + "fieldtype": "Check", + "label": "Enable Attribute Filters" + }, + { + "depends_on": "enable_field_filters", + "fieldname": "filter_fields", + "fieldtype": "Table", + "label": "Website Item Fields", + "options": "Website Filter Field" + }, + { + "depends_on": "enable_attribute_filters", + "fieldname": "filter_attributes", + "fieldtype": "Table", + "label": "Attributes", + "options": "Website Attribute" + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enable Shopping Cart" + }, + { + "depends_on": "doc.enabled", + "fieldname": "store_page_docs", + "fieldtype": "HTML" + }, + { + "fieldname": "display_settings_section", + "fieldtype": "Section Break", + "label": "Display Settings" + }, + { + "default": "0", + "fieldname": "show_attachments", + "fieldtype": "Check", + "label": "Show Public Attachments" + }, + { + "default": "0", + "fieldname": "show_price", + "fieldtype": "Check", + "label": "Show Price" + }, + { + "default": "0", + "fieldname": "show_stock_availability", + "fieldtype": "Check", + "label": "Show Stock Availability" + }, + { + "default": "0", + "fieldname": "enable_variants", + "fieldtype": "Check", + "label": "Enable Variant Selection" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "show_contact_us_button", + "fieldtype": "Check", + "label": "Show Contact Us Button" + }, + { + "default": "0", + "depends_on": "show_stock_availability", + "fieldname": "show_quantity_in_website", + "fieldtype": "Check", + "label": "Show Stock Quantity" + }, + { + "default": "0", + "fieldname": "show_apply_coupon_code_in_website", + "fieldtype": "Check", + "label": "Show Apply Coupon Code" + }, + { + "default": "0", + "fieldname": "allow_items_not_in_stock", + "fieldtype": "Check", + "label": "Allow items not in stock to be added to cart" + }, + { + "fieldname": "section_break_18", + "fieldtype": "Section Break", + "label": "Shopping Cart" + }, + { + "depends_on": "enabled", + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "mandatory_depends_on": "eval: doc.enabled === 1", + "options": "Company", + "remember_last_selected_value": 1 + }, + { + "depends_on": "enabled", + "description": "Prices will not be shown if Price List is not set", + "fieldname": "price_list", + "fieldtype": "Link", + "label": "Price List", + "mandatory_depends_on": "eval: doc.enabled === 1", + "options": "Price List" + }, + { + "fieldname": "column_break_21", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "default_customer_group", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Default Customer Group", + "mandatory_depends_on": "eval: doc.enabled === 1", + "options": "Customer Group" + }, + { + "depends_on": "enabled", + "fieldname": "quotation_series", + "fieldtype": "Select", + "label": "Quotation Series", + "mandatory_depends_on": "eval: doc.enabled === 1" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval:doc.enable_checkout", + "depends_on": "enabled", + "fieldname": "checkout_settings_section", + "fieldtype": "Section Break", + "label": "Checkout Settings" + }, + { + "default": "0", + "fieldname": "enable_checkout", + "fieldtype": "Check", + "label": "Enable Checkout" + }, + { + "default": "Orders", + "depends_on": "enable_checkout", + "description": "After payment completion redirect user to selected page.", + "fieldname": "payment_success_url", + "fieldtype": "Select", + "label": "Payment Success Url", + "mandatory_depends_on": "enable_checkout", + "options": "\nOrders\nInvoices\nMy Account" + }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval: doc.enable_checkout == 0", + "fieldname": "save_quotations_as_draft", + "fieldtype": "Check", + "label": "Save Quotations as Draft" + }, + { + "depends_on": "enable_checkout", + "fieldname": "payment_gateway_account", + "fieldtype": "Link", + "label": "Payment Gateway Account", + "mandatory_depends_on": "enable_checkout", + "options": "Payment Gateway Account" + }, + { + "collapsible": 1, + "depends_on": "enable_field_filters", + "fieldname": "shop_by_category_section", + "fieldtype": "Section Break", + "label": "Shop by Category" + }, + { + "fieldname": "slideshow", + "fieldtype": "Link", + "label": "Slideshow", + "options": "Website Slideshow" + }, + { + "collapsible": 1, + "fieldname": "add_ons_section", + "fieldtype": "Section Break", + "label": "Add-ons" + }, + { + "default": "0", + "fieldname": "enable_wishlist", + "fieldtype": "Check", + "label": "Enable Wishlist" + }, + { + "default": "0", + "fieldname": "enable_reviews", + "fieldtype": "Check", + "label": "Enable Reviews and Ratings" + }, + { + "fieldname": "search_index_fields", + "fieldtype": "Small Text", + "label": "Search Index Fields", + "read_only_depends_on": "eval:!doc.is_redisearch_loaded" + }, + { + "collapsible": 1, + "fieldname": "item_search_settings_section", + "fieldtype": "Section Break", + "label": "Item Search Settings" + }, + { + "default": "1", + "fieldname": "show_categories_in_search_autocomplete", + "fieldtype": "Check", + "label": "Show Categories in Search Autocomplete", + "read_only_depends_on": "eval:!doc.is_redisearch_loaded" + }, + { + "default": "0", + "fieldname": "is_redisearch_loaded", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Redisearch Loaded" + }, + { + "depends_on": "eval:!doc.is_redisearch_loaded", + "fieldname": "redisearch_warning", + "fieldtype": "HTML", + "label": "Redisearch Warning", + "options": "

Redisearch is not loaded. If you want to use the advanced product search feature, refer here.

" + }, + { + "default": "0", + "depends_on": "eval:doc.show_price", + "fieldname": "hide_price_for_guest", + "fieldtype": "Check", + "label": "Hide Price for Guest" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "collapsible": 1, + "fieldname": "guest_display_settings_section", + "fieldtype": "Section Break", + "label": "Guest Display Settings" + }, + { + "description": "Link to redirect Guest on actions that need login such as add to cart, wishlist, etc. E.g.: /login", + "fieldname": "redirect_on_action", + "fieldtype": "Data", + "label": "Redirect on Action" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enable_recommendations", + "fieldtype": "Check", + "label": "Enable Recommendations" + }, + { + "default": "0", + "depends_on": "eval: doc.enable_checkout == 0", + "fieldname": "show_price_in_quotation", + "fieldtype": "Check", + "label": "Show Price in Quotation" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2021-09-02 14:02:44.785824", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "E Commerce Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py similarity index 51% rename from erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py rename to erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py index efed1968a14..89727bebe49 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py @@ -1,25 +1,77 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt from __future__ import unicode_literals import frappe from frappe import _, msgprint -from frappe.utils import flt +from frappe.utils import flt, comma_and from frappe.model.document import Document -from frappe.utils import get_datetime, get_datetime_str, now_datetime +from frappe.utils import unique +from erpnext.e_commerce.redisearch import create_website_items_index, get_indexable_web_fields, is_search_module_loaded class ShoppingCartSetupError(frappe.ValidationError): pass -class ShoppingCartSettings(Document): +class ECommerceSettings(Document): def onload(self): self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series") + self.is_redisearch_loaded = is_search_module_loaded() def validate(self): + self.validate_field_filters() + self.validate_attribute_filters() + self.validate_checkout() + self.validate_search_index_fields() + if self.enabled: self.validate_price_list_exchange_rate() + frappe.clear_document_cache("E Commerce Settings", "E Commerce Settings") + + def validate_field_filters(self): + if not (self.enable_field_filters and self.filter_fields): + return + + item_meta = frappe.get_meta("Item") + valid_fields = [df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]] + + for f in self.filter_fields: + if f.fieldname not in valid_fields: + frappe.throw(_("Filter Fields Row #{0}: Fieldname {1} must be of type 'Link' or 'Table MultiSelect'").format(f.idx, f.fieldname)) + + def validate_attribute_filters(self): + if not (self.enable_attribute_filters and self.filter_attributes): + return + + # if attribute filters are enabled, hide_variants should be disabled + self.hide_variants = 0 + + def validate_checkout(self): + if self.enable_checkout and not self.payment_gateway_account: + self.enable_checkout = 0 + + def validate_search_index_fields(self): + if not self.search_index_fields: + return + + fields = self.search_index_fields.replace(' ', '') + fields = unique(fields.strip(',').split(',')) # Remove extra ',' and remove duplicates + + # All fields should be indexable + allowed_indexable_fields = get_indexable_web_fields() + + if not (set(fields).issubset(allowed_indexable_fields)): + invalid_fields = list(set(fields).difference(allowed_indexable_fields)) + num_invalid_fields = len(invalid_fields) + invalid_fields = comma_and(invalid_fields) + + if num_invalid_fields > 1: + frappe.throw(_("{0} are not valid options for Search Index Field.").format(frappe.bold(invalid_fields))) + else: + frappe.throw(_("{0} is not a valid option for Search Index Field.").format(frappe.bold(invalid_fields))) + + self.search_index_fields = ','.join(fields) + def validate_price_list_exchange_rate(self): "Check if exchange rate exists for Price List currency (to Company's currency)." from erpnext.setup.utils import get_exchange_rate @@ -60,12 +112,23 @@ class ShoppingCartSettings(Document): def get_shipping_rules(self, shipping_territory): return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule") -def validate_cart_settings(doc=None, method=None): - frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings").run_method("validate") + def on_change(self): + old_doc = self.get_doc_before_save() + + if old_doc: + old_fields = old_doc.search_index_fields + new_fields = self.search_index_fields + + # if search index fields get changed + if not (new_fields == old_fields): + create_website_items_index() + +def validate_cart_settings(doc, method): + frappe.get_doc("E Commerce Settings", "E Commerce Settings").run_method("validate") def get_shopping_cart_settings(): if not getattr(frappe.local, "shopping_cart_settings", None): - frappe.local.shopping_cart_settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings") + frappe.local.shopping_cart_settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings") return frappe.local.shopping_cart_settings diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py similarity index 75% rename from erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py rename to erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py index 9965e1af672..34eb2ab3532 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py +++ b/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py @@ -1,19 +1,18 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -# For license information, please see license.txt - +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt from __future__ import unicode_literals import frappe import unittest -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ShoppingCartSetupError -class TestShoppingCartSettings(unittest.TestCase): +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ShoppingCartSetupError + +class TestECommerceSettings(unittest.TestCase): def setUp(self): frappe.db.sql("""delete from `tabSingles` where doctype="Shipping Cart Settings" """) def get_cart_settings(self): - return frappe.get_doc({"doctype": "Shopping Cart Settings", + return frappe.get_doc({"doctype": "E Commerce Settings", "company": "_Test Company"}) # NOTE: Exchangrate API has all enabled currencies that ERPNext supports. @@ -47,4 +46,13 @@ class TestShoppingCartSettings(unittest.TestCase): frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 1") +def setup_e_commerce_settings(values_dict): + "Accepts a dict of values that updates E Commerce Settings." + if not values_dict: + return + + doc = frappe.get_doc("E Commerce Settings", "E Commerce Settings") + doc.update(values_dict) + doc.save() + test_dependencies = ["Tax Rule"] diff --git a/erpnext/shopping_cart/web_template/hero_slider/__init__.py b/erpnext/e_commerce/doctype/item_review/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/hero_slider/__init__.py rename to erpnext/e_commerce/doctype/item_review/__init__.py diff --git a/erpnext/e_commerce/doctype/item_review/item_review.js b/erpnext/e_commerce/doctype/item_review/item_review.js new file mode 100644 index 00000000000..a57c370287b --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/item_review.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Item Review', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/e_commerce/doctype/item_review/item_review.json b/erpnext/e_commerce/doctype/item_review/item_review.json new file mode 100644 index 00000000000..57f719fc3c4 --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/item_review.json @@ -0,0 +1,134 @@ +{ + "actions": [], + "beta": 1, + "creation": "2021-03-23 16:47:26.542226", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "website_item", + "user", + "customer", + "column_break_3", + "item", + "published_on", + "reviews_section", + "review_title", + "rating", + "comment" + ], + "fields": [ + { + "fieldname": "website_item", + "fieldtype": "Link", + "label": "Website Item", + "options": "Website Item", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fetch_from": "website_item.item_code", + "fieldname": "item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item", + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "reviews_section", + "fieldtype": "Section Break", + "label": "Reviews" + }, + { + "fieldname": "rating", + "fieldtype": "Rating", + "in_list_view": 1, + "label": "Rating", + "read_only": 1 + }, + { + "fieldname": "comment", + "fieldtype": "Small Text", + "label": "Comment", + "read_only": 1 + }, + { + "fieldname": "review_title", + "fieldtype": "Data", + "label": "Review Title", + "read_only": 1 + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer", + "read_only": 1 + }, + { + "fieldname": "published_on", + "fieldtype": "Data", + "label": "Published on", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-08-10 12:08:58.119691", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Item Review", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "Customer", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/item_review/item_review.py b/erpnext/e_commerce/doctype/item_review/item_review.py new file mode 100644 index 00000000000..3f34c4a124b --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/item_review.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +from datetime import datetime +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.contacts.doctype.contact.contact import get_contact_name +from frappe.utils import flt, cint +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import get_shopping_cart_settings + +class UnverifiedReviewer(frappe.ValidationError): + pass + +class ItemReview(Document): + def after_insert(self): + # regenerate cache on review creation + reviews_dict = get_queried_reviews(self.website_item) + set_reviews_in_cache(self.website_item, reviews_dict) + + def after_delete(self): + # regenerate cache on review deletion + reviews_dict = get_queried_reviews(self.website_item) + set_reviews_in_cache(self.website_item, reviews_dict) + + +@frappe.whitelist() +def get_item_reviews(web_item, start=0, end=10, data=None): + "Get Website Item Review Data." + start, end = cint(start), cint(end) + settings = get_shopping_cart_settings() + + # Get cached reviews for first page (start=0) + # avoid cache when page is different + from_cache = not bool(start) + + if not data: + data = frappe._dict() + + if settings and settings.get("enable_reviews"): + reviews_cache = frappe.cache().hget("item_reviews", web_item) + if from_cache and reviews_cache: + data = reviews_cache + else: + data = get_queried_reviews(web_item, start, end, data) + if from_cache: + set_reviews_in_cache(web_item, data) + + return data + +def get_queried_reviews(web_item, start=0, end=10, data=None): + """ + Query Website Item wise reviews and cache if needed. + Cache stores only first page of reviews i.e. 10 reviews maximum. + Returns: + dict: Containing reviews, average ratings, % of reviews per rating and total reviews. + """ + if not data: + data = frappe._dict() + + data.reviews = frappe.db.get_all( + "Item Review", + filters={"website_item": web_item}, + fields=["*"], + limit_start=start, + limit_page_length=end + ) + + rating_data = frappe.db.get_all( + "Item Review", + filters={"website_item": web_item}, + fields=["avg(rating) as average, count(*) as total"] + )[0] + + data.average_rating = flt(rating_data.average, 1) + data.average_whole_rating = flt(data.average_rating, 0) + + # get % of reviews per rating + reviews_per_rating = [] + for i in range(1,6): + count = frappe.db.get_all( + "Item Review", + filters={"website_item": web_item, "rating": i}, + fields=["count(*) as count"] + )[0].count + + percent = flt((count / rating_data.total or 1) * 100, 0) if count else 0 + reviews_per_rating.append(percent) + + data.reviews_per_rating = reviews_per_rating + data.total_reviews = rating_data.total + + return data + +def set_reviews_in_cache(web_item, reviews_dict): + frappe.cache().hset("item_reviews", web_item, reviews_dict) + +@frappe.whitelist() +def add_item_review(web_item, title, rating, comment=None): + """ Add an Item Review by a user if non-existent. """ + if frappe.session.user == "Guest": + # guest user should not reach here ideally in the case they do via an API, throw error + frappe.throw(_("You are not verified to write a review yet."), exc=UnverifiedReviewer) + + if not frappe.db.exists("Item Review", {"user": frappe.session.user, "website_item": web_item}): + doc = frappe.get_doc({ + "doctype": "Item Review", + "user": frappe.session.user, + "customer": get_customer(), + "website_item": web_item, + "item": frappe.db.get_value("Website Item", web_item, "item_code"), + "review_title": title, + "rating": rating, + "comment": comment + }) + doc.published_on = datetime.today().strftime("%d %B %Y") + doc.insert() + +def get_customer(silent=False): + """ + silent: Return customer if exists else return nothing. Dont throw error. + """ + user = frappe.session.user + contact_name = get_contact_name(user) + customer = None + + if contact_name: + contact = frappe.get_doc('Contact', contact_name) + for link in contact.links: + if link.link_doctype == "Customer": + customer = link.link_name + break + + if customer: + return frappe.db.get_value("Customer", customer) + elif silent: + return None + else: + # should not reach here unless via an API + frappe.throw(_("You are not a verified customer yet. Please contact us to proceed."), + exc=UnverifiedReviewer) diff --git a/erpnext/e_commerce/doctype/item_review/test_item_review.py b/erpnext/e_commerce/doctype/item_review/test_item_review.py new file mode 100644 index 00000000000..b19f42ada10 --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/test_item_review.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +import frappe +import unittest + +from frappe.core.doctype.user_permission.test_user_permission import create_user + +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.doctype.item_review.item_review import get_item_reviews, \ + add_item_review, UnverifiedReviewer +from erpnext.e_commerce.shopping_cart.cart import get_party +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import setup_e_commerce_settings + +class TestItemReview(unittest.TestCase): + def setUp(self): + item = make_item("Test Mobile Phone") + if not frappe.db.exists("Website Item", {"item_code": "Test Mobile Phone"}): + make_website_item(item, save=True) + + setup_e_commerce_settings({"enable_reviews": 1}) + frappe.local.shopping_cart_settings = None + + def tearDown(self): + frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete() + setup_e_commerce_settings({"enable_reviews": 0}) + + def test_add_and_get_item_reviews_from_customer(self): + "Add / Get Reviews from a User that is a valid customer (has added to cart or purchased in the past)" + # create user + web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) + test_user = create_user("test_reviewer@example.com", "Customer") + frappe.set_user(test_user.name) + + # create customer and contact against user + customer = get_party() + + # post review on "Test Mobile Phone" + try: + add_item_review(web_item, "Great Product", 3, "Would recommend this product") + review_name = frappe.db.get_value("Item Review", {"website_item": web_item}) + except Exception: + self.fail(f"Error while publishing review for {web_item}") + + review_data = get_item_reviews(web_item, 0, 10) + + self.assertEqual(len(review_data.reviews), 1) + self.assertEqual(review_data.average_rating, 3) + self.assertEqual(review_data.reviews_per_rating[2], 100) + + # tear down + frappe.set_user("Administrator") + frappe.delete_doc("Item Review", review_name) + customer.delete() + + def test_add_item_review_from_non_customer(self): + "Check if logged in user (who is not a customer yet) is blocked from posting reviews." + web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) + test_user = create_user("test_reviewer@example.com", "Customer") + frappe.set_user(test_user.name) + + with self.assertRaises(UnverifiedReviewer): + add_item_review(web_item, "Great Product", 3, "Would recommend this product") + + # tear down + frappe.set_user("Administrator") + + def test_add_item_reviews_from_guest_user(self): + "Check if Guest user is blocked from posting reviews." + web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) + frappe.set_user("Guest") + + with self.assertRaises(UnverifiedReviewer): + add_item_review(web_item, "Great Product", 3, "Would recommend this product") + + # tear down + frappe.set_user("Administrator") diff --git a/erpnext/shopping_cart/web_template/item_card_group/__init__.py b/erpnext/e_commerce/doctype/recommended_items/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/item_card_group/__init__.py rename to erpnext/e_commerce/doctype/recommended_items/__init__.py diff --git a/erpnext/e_commerce/doctype/recommended_items/recommended_items.json b/erpnext/e_commerce/doctype/recommended_items/recommended_items.json new file mode 100644 index 00000000000..06ac3dc03b7 --- /dev/null +++ b/erpnext/e_commerce/doctype/recommended_items/recommended_items.json @@ -0,0 +1,87 @@ +{ + "actions": [], + "creation": "2021-07-12 20:52:12.503470", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "website_item", + "website_item_name", + "column_break_2", + "item_code", + "more_information_section", + "route", + "column_break_6", + "website_item_image", + "website_item_thumbnail" + ], + "fields": [ + { + "fieldname": "website_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Website Item", + "options": "Website Item" + }, + { + "fetch_from": "website_item.web_item_name", + "fieldname": "website_item_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Website Item Name", + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "more_information_section", + "fieldtype": "Section Break", + "label": "More Information" + }, + { + "fetch_from": "website_item.route", + "fieldname": "route", + "fieldtype": "Small Text", + "label": "Route", + "read_only": 1 + }, + { + "fetch_from": "website_item.image", + "fieldname": "website_item_image", + "fieldtype": "Attach", + "label": "Website Item Image", + "read_only": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fetch_from": "website_item.thumbnail", + "fieldname": "website_item_thumbnail", + "fieldtype": "Data", + "label": "Website Item Thumbnail", + "read_only": 1 + }, + { + "fetch_from": "website_item.item_code", + "fieldname": "item_code", + "fieldtype": "Data", + "label": "Item Code" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-07-13 21:02:19.031652", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Recommended Items", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/recommended_items/recommended_items.py b/erpnext/e_commerce/doctype/recommended_items/recommended_items.py new file mode 100644 index 00000000000..9782abdec63 --- /dev/null +++ b/erpnext/e_commerce/doctype/recommended_items/recommended_items.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class RecommendedItems(Document): + pass diff --git a/erpnext/shopping_cart/web_template/product_card/__init__.py b/erpnext/e_commerce/doctype/website_item/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/product_card/__init__.py rename to erpnext/e_commerce/doctype/website_item/__init__.py diff --git a/erpnext/e_commerce/doctype/website_item/templates/website_item.html b/erpnext/e_commerce/doctype/website_item/templates/website_item.html new file mode 100644 index 00000000000..db123090aae --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/templates/website_item.html @@ -0,0 +1,7 @@ +{% extends "templates/web.html" %} + +{% block page_content %} +

{{ title }}

+{% endblock %} + + \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html b/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html new file mode 100644 index 00000000000..d7014b453ab --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html @@ -0,0 +1,4 @@ +
+ {{ doc.title or doc.name }} +
+ diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py new file mode 100644 index 00000000000..4a8e8200280 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py @@ -0,0 +1,490 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from erpnext.stock.doctype.item.item import DataValidationError +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.controllers.item_variant import create_variant +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import setup_e_commerce_settings +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import get_shopping_cart_settings +from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website + +WEBITEM_DESK_TESTS = ("test_website_item_desk_item_sync", "test_publish_variant_and_template") +WEBITEM_PRICE_TESTS = ('test_website_item_price_for_logged_in_user', 'test_website_item_price_for_guest_user') + +class TestWebsiteItem(unittest.TestCase): + @classmethod + def setUpClass(cls): + setup_e_commerce_settings({ + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "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", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + { + "attribute": "Test Size" + } + ] + }) + elif self._testMethodName in WEBITEM_PRICE_TESTS: + 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 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 + on_doctype_update() + + indices = frappe.db.sql("show index from `tabWebsite Item`", as_dict=1) + expected_columns = {"route", "item_group", "brand"} + for index in indices: + expected_columns.discard(index.get("Column_name")) + + if expected_columns: + self.fail(f"Expected db index on these columns: {', '.join(expected_columns)}") + + def test_website_item_desk_item_sync(self): + "Check creation/updation/deletion of Website Item and its impact on Item master." + web_item = None + item = make_item("Test Web Item") # will return item if exists + try: + web_item = make_website_item(item, save=False) + web_item.save() + except Exception: + self.fail(f"Error while creating website item for {item}") + + # check if website item was created + self.assertTrue(bool(web_item)) + self.assertTrue(bool(web_item.route)) + + item.reload() + self.assertEqual(web_item.published, 1) + self.assertEqual(item.published_in_website, 1) # check if item was back updated + self.assertEqual(web_item.item_group, item.item_group) + + # check if changing item data changes it in website item + item.item_name = "Test Web Item 1" + item.stock_uom = "Unit" + item.save() + web_item.reload() + self.assertEqual(web_item.item_name, item.item_name) + self.assertEqual(web_item.stock_uom, item.stock_uom) + + # check if disabling item unpublished website item + item.disabled = 1 + item.save() + web_item.reload() + self.assertEqual(web_item.published, 0) + + # check if website item deletion, unpublishes desk item + web_item.delete() + 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 + variant = create_variant("Test Web Item", {"Test Size": "Large"}) + variant.save() + + # check if template is not published + self.assertIsNone(frappe.db.exists("Website Item", {"item_code": variant.variant_of})) + + variant_web_item = make_website_item(variant, save=False) + variant_web_item.save() + + # check if template is published + try: + template_web_item = frappe.get_doc("Website Item", {"item_code": variant.variant_of}) + except frappe.DoesNotExistError: + self.fail(f"Template of {variant.item_code}, {variant.variant_of} not published") + + # teardown + variant_web_item.delete() + template_web_item.delete() + variant.delete() + + def test_impact_on_merging_items(self): + "Check if merging items is blocked if old and new items both have website items" + first_item = make_item("Test First Item") + second_item = make_item("Test Second Item") + + first_web_item = make_website_item(first_item, save=False) + first_web_item.save() + second_web_item = make_website_item(second_item, save=False) + second_web_item.save() + + with self.assertRaises(DataValidationError): + frappe.rename_doc("Item", "Test First Item", "Test Second Item", merge=True) + + # tear down + second_web_item.delete() + first_web_item.delete() + second_item.delete() + first_item.delete() + + # Website Item Portal Tests Begin + + def test_website_item_breadcrumbs(self): + "Check if breadcrumbs include homepage, product listing navigation page, parent item group(s) and item group." + from erpnext.setup.doctype.item_group.item_group import get_parent_item_groups + + item_code = "Test Breadcrumb Item" + item = make_item(item_code, { + "item_group": "_Test Item Group B - 1", + }) + + 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}) + + 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", "show_in_website", 1) + + breadcrumbs = get_parent_item_groups(item.item_group) + + self.assertEqual(breadcrumbs[0]["name"], "Home") + self.assertEqual(breadcrumbs[1]["name"], "Shop by Category") + self.assertEqual(breadcrumbs[2]["name"], "_Test Item Group B") # parent item group + self.assertEqual(breadcrumbs[3]["name"], "_Test Item Group B - 1") + + # tear down + web_item.delete() + item.delete() + + def test_website_item_price_for_logged_in_user(self): + "Check if price details are fetched correctly while logged in." + item_code = "Test Mobile Phone" + + # show price in e commerce settings + setup_e_commerce_settings({"show_price": 1}) + + # price and pricing rule added via setUp + + # check if price and slashed price is fetched correctly + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertTrue(bool(data.product_info["price"])) + + price_object = data.product_info["price"] + self.assertEqual(price_object.get("discount_percent"), 10) + self.assertEqual(price_object.get("price_list_rate"), 900) + self.assertEqual(price_object.get("formatted_mrp"), "₹ 1,000.00") + self.assertEqual(price_object.get("formatted_price"), "₹ 900.00") + self.assertEqual(price_object.get("formatted_discount_percent"), "10%") + + # disable show price + setup_e_commerce_settings({"show_price": 0}) + + # price should not be fetched + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertFalse(bool(data.product_info["price"])) + + # tear down + frappe.set_user("Administrator") + + def test_website_item_price_for_guest_user(self): + "Check if price details are fetched correctly for guest user." + item_code = "Test Mobile Phone" + + # show price for guest user in e commerce settings + setup_e_commerce_settings({ + "show_price": 1, + "hide_price_for_guest": 0 + }) + + # price and pricing rule added via setUp + + # switch to guest user + frappe.set_user("Guest") + + # price should be fetched + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertTrue(bool(data.product_info["price"])) + + price_object = data.product_info["price"] + self.assertEqual(price_object.get("discount_percent"), 10) + self.assertEqual(price_object.get("price_list_rate"), 900) + + # hide price for guest user + frappe.set_user("Administrator") + setup_e_commerce_settings({"hide_price_for_guest": 1}) + frappe.set_user("Guest") + + # price should not be fetched + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertFalse(bool(data.product_info["price"])) + + # tear down + frappe.set_user("Administrator") + + def test_website_item_stock_when_out_of_stock(self): + """ + Check if stock details are fetched correctly for empty inventory when: + 1) Showing stock availability enabled: + - Warehouse unset + - Warehouse set + 2) Showing stock availability disabled + """ + item_code = "Test Mobile Phone" + create_regular_web_item() + setup_e_commerce_settings({"show_stock_availability": 1}) + + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + + # check if stock details are fetched and item not in stock without warehouse set + self.assertFalse(bool(data.product_info["in_stock"])) + self.assertFalse(bool(data.product_info["stock_qty"])) + + # set warehouse + frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "_Test Warehouse - _TC") + + # check if stock details are fetched and item not in stock with warehouse set + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertFalse(bool(data.product_info["in_stock"])) + self.assertEqual(data.product_info["stock_qty"][0][0], 0) + + # disable show stock availability + setup_e_commerce_settings({"show_stock_availability": 0}) + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + + # check if stock detail attributes are not fetched if stock availability is hidden + self.assertIsNone(data.product_info.get("in_stock")) + self.assertIsNone(data.product_info.get("stock_qty")) + self.assertIsNone(data.product_info.get("show_stock_qty")) + + # tear down + frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete() + + def test_website_item_stock_when_in_stock(self): + """ + Check if stock details are fetched correctly for available inventory when: + 1) Showing stock availability enabled: + - Warehouse set + - Warehouse unset + 2) Showing stock availability disabled + """ + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = "Test Mobile Phone" + create_regular_web_item() + setup_e_commerce_settings({"show_stock_availability": 1}) + frappe.local.shopping_cart_settings = None + + # set warehouse + frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "_Test Warehouse - _TC") + + # stock up item + stock_entry = make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=2, rate=100) + + # check if stock details are fetched and item is in stock with warehouse set + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertTrue(bool(data.product_info["in_stock"])) + self.assertEqual(data.product_info["stock_qty"][0][0], 2) + + # unset warehouse + frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "") + + # check if stock details are fetched and item not in stock without warehouse set + # (even though it has stock in some warehouse) + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertFalse(bool(data.product_info["in_stock"])) + self.assertFalse(bool(data.product_info["stock_qty"])) + + # disable show stock availability + setup_e_commerce_settings({"show_stock_availability": 0}) + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + + # check if stock detail attributes are not fetched if stock availability is hidden + self.assertIsNone(data.product_info.get("in_stock")) + self.assertIsNone(data.product_info.get("stock_qty")) + self.assertIsNone(data.product_info.get("show_stock_qty")) + + # tear down + stock_entry.cancel() + frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete() + + def test_recommended_item(self): + "Check if added recommended items are fetched correctly." + item_code = "Test Mobile Phone" + web_item = create_regular_web_item(item_code) + + setup_e_commerce_settings({ + "enable_recommendations": 1, + "show_price": 1 + }) + + # create recommended web item and price for it + 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 + web_item.append("recommended_items", {"website_item": recommended_web_item.name}) + web_item.save() + + frappe.local.shopping_cart_settings = None + e_commerce_settings = get_shopping_cart_settings() + recommended_items = web_item.get_recommended_items(e_commerce_settings) + + # test results if show price is enabled + self.assertEqual(len(recommended_items), 1) + recomm_item = recommended_items[0] + self.assertEqual(recomm_item.get("website_item_name"), "Test Mobile Phone 1") + self.assertTrue(bool(recomm_item.get("price_info"))) # price fetched + + price_info = recomm_item.get("price_info") + self.assertEqual(price_info.get("price_list_rate"), 1000) + self.assertEqual(price_info.get("formatted_price"), "₹ 1,000.00") + + # test results if show price is disabled + setup_e_commerce_settings({"show_price": 0}) + + frappe.local.shopping_cart_settings = None + e_commerce_settings = get_shopping_cart_settings() + recommended_items = web_item.get_recommended_items(e_commerce_settings) + + self.assertEqual(len(recommended_items), 1) + self.assertFalse(bool(recommended_items[0].get("price_info"))) # price not fetched + + # tear down + 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 = create_regular_web_item(item_code) + + # price visible to guests + setup_e_commerce_settings({ + "enable_recommendations": 1, + "show_price": 1, + "hide_price_for_guest": 0 + }) + + # create recommended web item and price for it + 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 + web_item.append("recommended_items", {"website_item": recommended_web_item.name}) + web_item.save() + + frappe.set_user("Guest") + + frappe.local.shopping_cart_settings = None + e_commerce_settings = get_shopping_cart_settings() + recommended_items = web_item.get_recommended_items(e_commerce_settings) + + # test results if show price is enabled + self.assertEqual(len(recommended_items), 1) + self.assertTrue(bool(recommended_items[0].get("price_info"))) # price fetched + + # price hidden from guests + frappe.set_user("Administrator") + setup_e_commerce_settings({"hide_price_for_guest": 1}) + frappe.set_user("Guest") + + frappe.local.shopping_cart_settings = None + e_commerce_settings = get_shopping_cart_settings() + recommended_items = web_item.get_recommended_items(e_commerce_settings) + + # test results if show price is enabled + self.assertEqual(len(recommended_items), 1) + self.assertFalse(bool(recommended_items[0].get("price_info"))) # price fetched + + # tear down + frappe.set_user("Administrator") + web_item.delete() + recommended_web_item.delete() + frappe.get_cached_doc("Item", "Test Mobile Phone 1").delete() + +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) + 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 + +def make_web_item_price(**kwargs): + item_code = kwargs.get("item_code") + if not item_code: + return + + if not frappe.db.exists("Item Price", {"item_code": item_code}): + item_price = frappe.get_doc({ + "doctype": "Item Price", + "item_code": item_code, + "price_list": kwargs.get("price_list") or "_Test Price List India", + "price_list_rate": kwargs.get("price_list_rate") or 1000 + }) + item_price.insert() + else: + item_price = frappe.get_cached_doc("Item Price", {"item_code": item_code}) + + return item_price + +def make_web_pricing_rule(**kwargs): + title = kwargs.get("title") + if not title: + return + + if not frappe.db.exists("Pricing Rule", title): + pricing_rule = frappe.get_doc({ + "doctype": "Pricing Rule", + "title": title, + "apply_on": kwargs.get("apply_on") or "Item Code", + "items": [{ + "item_code": kwargs.get("item_code") + }], + "selling": kwargs.get("selling") or 0, + "buying": kwargs.get("buying") or 0, + "rate_or_discount": kwargs.get("rate_or_discount") or "Discount Percentage", + "discount_percentage": kwargs.get("discount_percentage") or 10, + "company": kwargs.get("company") or "_Test Company", + "currency": kwargs.get("currency") or "INR", + "for_price_list": kwargs.get("price_list") or "_Test Price List India" + }) + pricing_rule.insert() + else: + pricing_rule = frappe.get_doc("Pricing Rule", {"title": title}) + + return pricing_rule \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js new file mode 100644 index 00000000000..741e78f4a55 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item.js @@ -0,0 +1,24 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Website Item', { + onload: function(frm) { + // should never check Private + frm.fields_dict["website_image"].df.is_private = 0; + }, + + image: function() { + refresh_field("image_view"); + }, + + copy_from_item_group: function(frm) { + return frm.call({ + doc: frm.doc, + method: "copy_specification_from_item_group" + }); + }, + + set_meta_tags(frm) { + frappe.utils.set_meta_tag(frm.doc.route); + } +}); diff --git a/erpnext/e_commerce/doctype/website_item/website_item.json b/erpnext/e_commerce/doctype/website_item/website_item.json new file mode 100644 index 00000000000..245042addb8 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item.json @@ -0,0 +1,415 @@ +{ + "actions": [], + "allow_guest_to_view": 1, + "allow_import": 1, + "autoname": "naming_series", + "creation": "2021-02-09 21:06:14.441698", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "web_item_name", + "route", + "has_variants", + "variant_of", + "published", + "column_break_3", + "item_code", + "item_name", + "item_group", + "stock_uom", + "column_break_11", + "description", + "brand", + "image", + "display_section", + "website_image", + "website_image_alt", + "column_break_13", + "slideshow", + "thumbnail", + "stock_information_section", + "website_warehouse", + "column_break_24", + "on_backorder", + "section_break_17", + "short_description", + "web_long_description", + "column_break_27", + "website_specifications", + "copy_from_item_group", + "display_additional_information_section", + "show_tabbed_section", + "tabs", + "recommended_items_section", + "recommended_items", + "offers_section", + "offers", + "section_break_6", + "ranking", + "set_meta_tags", + "column_break_22", + "website_item_groups", + "advanced_display_section", + "website_content" + ], + "fields": [ + { + "description": "Website display name", + "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, + "fieldname": "web_item_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Website Item Name", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code", + "options": "Item", + "read_only_depends_on": "eval:!doc.__islocal", + "reqd": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_6", + "fieldtype": "Section Break", + "label": "Search and SEO" + }, + { + "fieldname": "route", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Route", + "no_copy": 1 + }, + { + "description": "Items with higher ranking will be shown higher", + "fieldname": "ranking", + "fieldtype": "Int", + "label": "Ranking" + }, + { + "description": "Show a slideshow at the top of the page", + "fieldname": "slideshow", + "fieldtype": "Link", + "label": "Slideshow", + "options": "Website Slideshow" + }, + { + "description": "Item Image (if not slideshow)", + "fieldname": "website_image", + "fieldtype": "Attach", + "label": "Website Image" + }, + { + "description": "Image Alternative Text", + "fieldname": "website_image_alt", + "fieldtype": "Data", + "label": "Image Description" + }, + { + "fieldname": "thumbnail", + "fieldtype": "Data", + "label": "Thumbnail", + "read_only": 1 + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "description": "Show Stock availability based on this warehouse.", + "fieldname": "website_warehouse", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Website Warehouse", + "options": "Warehouse" + }, + { + "description": "List this Item in multiple groups on the website.", + "fieldname": "website_item_groups", + "fieldtype": "Table", + "label": "Website Item Groups", + "options": "Website Item Group" + }, + { + "fieldname": "set_meta_tags", + "fieldtype": "Button", + "label": "Set Meta Tags" + }, + { + "fieldname": "section_break_17", + "fieldtype": "Section Break", + "label": "Display Information" + }, + { + "fieldname": "copy_from_item_group", + "fieldtype": "Button", + "label": "Copy From Item Group" + }, + { + "fieldname": "website_specifications", + "fieldtype": "Table", + "label": "Website Specifications", + "options": "Item Website Specification" + }, + { + "fieldname": "web_long_description", + "fieldtype": "Text Editor", + "label": "Website Description" + }, + { + "description": "You can use any valid Bootstrap 4 markup in this field. It will be shown on your Item Page.", + "fieldname": "website_content", + "fieldtype": "HTML Editor", + "label": "Website Content" + }, + { + "fetch_from": "item_code.item_group", + "fieldname": "item_group", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Group", + "options": "Item Group", + "read_only": 1 + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "hidden": 1, + "in_preview": 1, + "label": "Image", + "print_hide": 1 + }, + { + "default": "1", + "fieldname": "published", + "fieldtype": "Check", + "label": "Published" + }, + { + "default": "0", + "depends_on": "has_variants", + "fetch_from": "item_code.has_variants", + "fieldname": "has_variants", + "fieldtype": "Check", + "in_standard_filter": 1, + "label": "Has Variants", + "no_copy": 1, + "read_only": 1 + }, + { + "depends_on": "variant_of", + "fetch_from": "item_code.variant_of", + "fieldname": "variant_of", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_standard_filter": 1, + "label": "Variant Of", + "options": "Item", + "read_only": 1, + "search_index": 1, + "set_only_once": 1 + }, + { + "fetch_from": "item_code.stock_uom", + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, + { + "depends_on": "brand", + "fetch_from": "item_code.brand", + "fieldname": "brand", + "fieldtype": "Link", + "label": "Brand", + "options": "Brand" + }, + { + "collapsible": 1, + "fieldname": "advanced_display_section", + "fieldtype": "Section Break", + "label": "Advanced Display Content" + }, + { + "fieldname": "display_section", + "fieldtype": "Section Break", + "label": "Display Images" + }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fetch_from": "item_code.description", + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Item Description", + "read_only": 1 + }, + { + "default": "WEB-ITM-.####", + "fieldname": "naming_series", + "fieldtype": "Select", + "hidden": 1, + "label": "Naming Series", + "no_copy": 1, + "options": "WEB-ITM-.####", + "print_hide": 1 + }, + { + "fieldname": "display_additional_information_section", + "fieldtype": "Section Break", + "label": "Display Additional Information" + }, + { + "depends_on": "show_tabbed_section", + "fieldname": "tabs", + "fieldtype": "Table", + "label": "Tabs", + "options": "Website Item Tabbed Section" + }, + { + "default": "0", + "fieldname": "show_tabbed_section", + "fieldtype": "Check", + "label": "Add Section with Tabs" + }, + { + "collapsible": 1, + "fieldname": "offers_section", + "fieldtype": "Section Break", + "label": "Offers" + }, + { + "fieldname": "offers", + "fieldtype": "Table", + "label": "Offers to Display", + "options": "Website Offer" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, + { + "description": "Short Description for List View", + "fieldname": "short_description", + "fieldtype": "Small Text", + "label": "Short Website Description" + }, + { + "collapsible": 1, + "fieldname": "recommended_items_section", + "fieldtype": "Section Break", + "label": "Recommended Items" + }, + { + "fieldname": "recommended_items", + "fieldtype": "Table", + "label": "Recommended/Similar Items", + "options": "Recommended Items" + }, + { + "fieldname": "stock_information_section", + "fieldtype": "Section Break", + "label": "Stock Information" + }, + { + "fieldname": "column_break_24", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Indicate that Item is available on backorder and not usually pre-stocked", + "fieldname": "on_backorder", + "fieldtype": "Check", + "label": "On Backorder" + } + ], + "has_web_view": 1, + "image_field": "image", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-09-02 13:08:41.942726", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Website Item", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "web_item_name, item_code, item_group", + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "web_item_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py new file mode 100644 index 00000000000..2d71907b2c9 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item.py @@ -0,0 +1,524 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import json +import itertools +from frappe import _ + +from frappe.website.website_generator import WebsiteGenerator +from frappe.utils import cstr, random_string, cint, flt +from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow + +from erpnext.setup.doctype.item_group.item_group import (get_parent_item_groups, invalidate_cache_for) +from erpnext.e_commerce.doctype.item_review.item_review import get_item_reviews +from erpnext.e_commerce.shopping_cart.cart import _set_price_list +from erpnext.utilities.product import get_price + +# SEARCH +from erpnext.e_commerce.redisearch import ( + insert_item_to_index, + update_index_for_item, + delete_item_from_index +) + +class WebsiteItem(WebsiteGenerator): + website = frappe._dict( + page_title_field="web_item_name", + condition_field="published", + template="templates/generators/item/item.html", + no_cache=1 + ) + + def onload(self): + super(WebsiteItem, self).onload() + + def validate(self): + super(WebsiteItem, self).validate() + + if not self.item_code: + frappe.throw(_("Item Code is required"), title=_("Mandatory")) + + self.validate_duplicate_website_item() + self.validate_website_image() + self.make_thumbnail() + self.publish_unpublish_desk_item(publish=True) + + if not self.get("__islocal"): + self.old_website_item_groups = frappe.db.sql_list(""" + select + item_group + from + `tabWebsite Item Group` + where + parentfield='website_item_groups' + and parenttype='Website Item' + and parent=%s + """, self.name) + + def on_update(self): + invalidate_cache_for_web_item(self) + self.update_template_item() + + def on_trash(self): + super(WebsiteItem, self).on_trash() + delete_item_from_index(self) + self.publish_unpublish_desk_item(publish=False) + + def validate_duplicate_website_item(self): + existing_web_item = frappe.db.exists("Website Item", {"item_code": self.item_code}) + if existing_web_item and existing_web_item != self.name: + message = _("Website Item already exists against Item {0}").format(frappe.bold(self.item_code)) + frappe.throw(message, title=_("Already Published")) + + def publish_unpublish_desk_item(self, publish=True): + if frappe.db.get_value("Item", self.item_code, "published_in_website") and publish: + return # if already published don't publish again + frappe.db.set_value("Item", self.item_code, "published_in_website", publish) + + def make_route(self): + """Called from set_route in WebsiteGenerator.""" + if not self.route: + return cstr(frappe.db.get_value('Item Group', self.item_group, + 'route')) + '/' + self.scrub((self.item_name if self.item_name else self.item_code) + '-' + random_string(5)) + + def update_template_item(self): + """Publish Template Item if Variant is published.""" + if self.variant_of: + if self.published: + # show template + template_item = frappe.get_doc("Item", self.variant_of) + + if not template_item.published_in_website: + template_item.flags.ignore_permissions = True + make_website_item(template_item) + + def validate_website_image(self): + if frappe.flags.in_import: + return + + """Validate if the website image is a public file""" + auto_set_website_image = False + if not self.website_image and self.image: + auto_set_website_image = True + self.website_image = self.image + + if not self.website_image: + return + + # find if website image url exists as public + file_doc = frappe.get_all( + "File", + filters={ + "file_url": self.website_image + }, + fields=["name", "is_private"], + order_by="is_private asc", + limit_page_length=1 + ) + + if file_doc: + file_doc = file_doc[0] + + if not file_doc: + if not auto_set_website_image: + frappe.msgprint(_("Website Image {0} attached to Item {1} cannot be found").format(self.website_image, self.name)) + + self.website_image = None + + elif file_doc.is_private: + if not auto_set_website_image: + frappe.msgprint(_("Website Image should be a public file or website URL")) + + self.website_image = None + + def make_thumbnail(self): + if frappe.flags.in_import: + return + + """Make a thumbnail of `website_image`""" + import requests.exceptions + + if not self.is_new() and self.website_image != frappe.db.get_value(self.doctype, self.name, "website_image"): + self.thumbnail = None + + if self.website_image and not self.thumbnail: + file_doc = None + + try: + file_doc = frappe.get_doc("File", { + "file_url": self.website_image, + "attached_to_doctype": "Website Item", + "attached_to_name": self.name + }) + except frappe.DoesNotExistError: + pass + # cleanup + frappe.local.message_log.pop() + + except requests.exceptions.HTTPError: + frappe.msgprint(_("Warning: Invalid attachment {0}").format(self.website_image)) + self.website_image = None + + except requests.exceptions.SSLError: + frappe.msgprint( + _("Warning: Invalid SSL certificate on attachment {0}").format(self.website_image)) + self.website_image = None + + # for CSV import + if self.website_image and not file_doc: + try: + file_doc = frappe.get_doc({ + "doctype": "File", + "file_url": self.website_image, + "attached_to_doctype": "Website Item", + "attached_to_name": self.name + }).save() + + except IOError: + self.website_image = None + + if file_doc: + if not file_doc.thumbnail_url: + file_doc.make_thumbnail() + + self.thumbnail = file_doc.thumbnail_url + + def get_context(self, context): + context.show_search = True + context.search_link = "/search" + context.body_class = "product-page" + + context.parents = get_parent_item_groups(self.item_group, from_item=True) # breadcumbs + self.attributes = frappe.get_all("Item Variant Attribute", + fields=["attribute", "attribute_value"], + filters={"parent": self.item_code}) + + if self.slideshow: + context.update(get_slideshow(self)) + + self.set_variant_context(context) + self.set_attribute_context(context) + self.set_disabled_attributes(context) + self.set_metatags(context) + self.set_shopping_cart_data(context) + + settings = context.shopping_cart.cart_settings + + self.get_product_details_section(context) + + if settings.enable_reviews: + reviews_data = get_item_reviews(self.name) + context.update(reviews_data) + context.reviews = context.reviews[:4] + + context.wished = False + if frappe.db.exists("Wishlist Item", {"item_code": self.item_code, "parent": frappe.session.user}): + context.wished = True + + context.user_is_customer = check_if_user_is_customer() + + context.recommended_items = None + if settings and settings.enable_recommendations: + context.recommended_items = self.get_recommended_items(settings) + + return context + + def set_variant_context(self, context): + if not self.has_variants: + return + + context.no_cache = True + variant = frappe.form_dict.variant + + # load variants + # also used in set_attribute_context + context.variants = frappe.get_all( + "Item", + filters={ + "variant_of": self.item_code, + "published_in_website": 1 + }, + order_by="name asc") + + # the case when the item is opened for the first time from its list + if not variant and context.variants: + variant = context.variants[0] + + if variant: + context.variant = frappe.get_doc("Item", variant) + fields = ("website_image", "website_image_alt", "web_long_description", "description", + "website_specifications") + + for fieldname in fields: + if context.variant.get(fieldname): + value = context.variant.get(fieldname) + if isinstance(value, list): + value = [d.as_dict() for d in value] + + context[fieldname] = value + + if self.slideshow and context.variant and context.variant.slideshow: + context.update(get_slideshow(context.variant)) + + + def set_attribute_context(self, context): + if not self.has_variants: + return + + attribute_values_available = {} + context.attribute_values = {} + context.selected_attributes = {} + + # load attributes + self.set_selected_attributes(context.variants, context, attribute_values_available) + + # filter attributes, order based on attribute table + item = frappe.get_cached_doc("Item", self.item_code) + self.set_attribute_values(item.attributes, context, attribute_values_available) + + context.variant_info = json.dumps(context.variants) + + def set_selected_attributes(self, variants, context, attribute_values_available): + for variant in variants: + variant.attributes = frappe.get_all( + "Item Variant Attribute", + filters={"parent": variant.name}, + fields=["attribute", "attribute_value as value"]) + + # make an attribute-value map for easier access in templates + variant.attribute_map = frappe._dict( + {attr.attribute : attr.value for attr in variant.attributes} + ) + + for attr in variant.attributes: + values = attribute_values_available.setdefault(attr.attribute, []) + if attr.value not in values: + values.append(attr.value) + + if variant.name == context.variant.name: + context.selected_attributes[attr.attribute] = attr.value + + def set_attribute_values(self, attributes, context, attribute_values_available): + for attr in attributes: + values = context.attribute_values.setdefault(attr.attribute, []) + + if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")): + for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt): + values.append(val) + else: + # get list of values defined (for sequence) + for attr_value in frappe.db.get_all("Item Attribute Value", + fields=["attribute_value"], + filters={"parent": attr.attribute}, order_by="idx asc"): + + if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): + values.append(attr_value.attribute_value) + + def set_disabled_attributes(self, context): + """Disable selection options of attribute combinations that do not result in a variant""" + + if not self.attributes or not self.has_variants: + return + + context.disabled_attributes = {} + attributes = [attr.attribute for attr in self.attributes] + + def find_variant(combination): + for variant in context.variants: + if len(variant.attributes) < len(attributes): + continue + + if "combination" not in variant: + ref_combination = [] + + for attr in variant.attributes: + idx = attributes.index(attr.attribute) + ref_combination.insert(idx, attr.attribute_value) + + variant["combination"] = ref_combination + + if not (set(combination) - set(variant["combination"])): + # check if the combination is a subset of a variant combination + # eg. [Blue, 0.5] is a possible combination if exists [Blue, Large, 0.5] + return True + + for i, attr in enumerate(self.attributes): + if i == 0: + continue + + combination_source = [] + + # loop through previous attributes + for prev_attr in self.attributes[:i]: + combination_source.append([context.selected_attributes.get(prev_attr.attribute)]) + + combination_source.append(context.attribute_values[attr.attribute]) + + for combination in itertools.product(*combination_source): + if not find_variant(combination): + context.disabled_attributes.setdefault(attr.attribute, []).append(combination[-1]) + + def set_metatags(self, context): + context.metatags = frappe._dict({}) + + safe_description = frappe.utils.to_markdown(self.description) + + context.metatags.url = frappe.utils.get_url() + '/' + context.route + + if context.website_image: + if context.website_image.startswith('http'): + url = context.website_image + else: + url = frappe.utils.get_url() + context.website_image + context.metatags.image = url + + context.metatags.description = safe_description[:300] + + context.metatags.title = self.web_item_name or self.item_name or self.item_code + + context.metatags['og:type'] = 'product' + context.metatags['og:site_name'] = 'ERPNext' + + def set_shopping_cart_data(self, context): + from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website + context.shopping_cart = get_product_info_for_website(self.item_code, skip_quotation_creation=True) + + @frappe.whitelist() + def copy_specification_from_item_group(self): + self.set("website_specifications", []) + if self.item_group: + for label, desc in frappe.db.get_values("Item Website Specification", + {"parent": self.item_group}, ["label", "description"]): + row = self.append("website_specifications") + row.label = label + row.description = desc + + def get_product_details_section(self, context): + """ Get section with tabs or website specifications. """ + context.show_tabs = self.show_tabbed_section + if self.show_tabbed_section and (self.tabs or self.website_specifications): + context.tabs = self.get_tabs() + else: + context.website_specifications = self.website_specifications + + def get_tabs(self): + tab_values = {} + tab_values["tab_1_title"] = "Product Details" + tab_values["tab_1_content"] = frappe.render_template( + "templates/generators/item/item_specifications.html", + { + "website_specifications": self.website_specifications, + "show_tabs": self.show_tabbed_section + }) + + for row in self.tabs: + tab_values[f"tab_{row.idx + 1}_title"] = _(row.label) + tab_values[f"tab_{row.idx + 1}_content"] = row.content + + return tab_values + + def get_recommended_items(self, settings): + items = frappe.db.sql(f""" + select + ri.website_item_thumbnail, ri.website_item_name, + ri.route, ri.item_code + from + `tabRecommended Items` ri, `tabWebsite Item` wi + where + ri.item_code = wi.item_code + and ri.parent = '{self.name}' + and wi.published = 1 + order by ri.idx + """, as_dict=1) + + if settings.show_price: + is_guest = frappe.session.user == "Guest" + # Show Price if logged in. + # If not logged in and price is hidden for guest, skip price fetch. + if is_guest and settings.hide_price_for_guest: + return items + + selling_price_list = _set_price_list(settings, None) + for item in items: + item.price_info = get_price( + item.item_code, + selling_price_list, + settings.default_customer_group, + settings.company + ) + + return items + +def invalidate_cache_for_web_item(doc): + """Invalidate Website Item Group cache and rebuild ItemVariantsCacheManager.""" + from erpnext.stock.doctype.item.item import invalidate_item_variants_cache_for_website + + invalidate_cache_for(doc, doc.item_group) + + website_item_groups = list(set((doc.get("old_website_item_groups") or []) + + [d.item_group for d in doc.get({"doctype": "Website Item Group"}) if d.item_group])) + + for item_group in website_item_groups: + invalidate_cache_for(doc, item_group) + + # Update Search Cache + update_index_for_item(doc) + + invalidate_item_variants_cache_for_website(doc) + +def on_doctype_update(): + # since route is a Text column, it needs a length for indexing + frappe.db.add_index("Website Item", ["route(500)"]) + + frappe.db.add_index("Website Item", ["item_group"]) + frappe.db.add_index("Website Item", ["brand"]) + +def check_if_user_is_customer(user=None): + from frappe.contacts.doctype.contact.contact import get_contact_name + + if not user: + user = frappe.session.user + + contact_name = get_contact_name(user) + customer = None + + if contact_name: + contact = frappe.get_doc('Contact', contact_name) + for link in contact.links: + if link.link_doctype == "Customer": + customer = link.link_name + break + + return True if customer else False + +@frappe.whitelist() +def make_website_item(doc, save=True): + if not doc: + return + + if isinstance(doc, str): + doc = json.loads(doc) + + if frappe.db.exists("Website Item", {"item_code": doc.get("item_code")}): + message = _("Website Item already exists against {0}").format(frappe.bold(doc.get("item_code"))) + frappe.throw(message, title=_("Already Published")) + + website_item = frappe.new_doc("Website Item") + website_item.web_item_name = doc.get("item_name") + + fields_to_map = ["item_code", "item_name", "item_group", "stock_uom", "brand", "image", + "has_variants", "variant_of", "description"] + for field in fields_to_map: + website_item.update({field: doc.get(field)}) + + if not save: + return website_item + + website_item.save() + + # Add to search cache + insert_item_to_index(website_item) + + return [website_item.name, website_item.web_item_name] \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item/website_item_list.js b/erpnext/e_commerce/doctype/website_item/website_item_list.js new file mode 100644 index 00000000000..21be9428eb6 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item_list.js @@ -0,0 +1,20 @@ +frappe.listview_settings['Website Item'] = { + add_fields: ["item_name", "web_item_name", "published", "image", "has_variants", "variant_of"], + filters: [["published", "=", "1"]], + + get_indicator: function(doc) { + if (doc.has_variants && doc.published) { + return [__("Template"), "orange", "has_variants,=,Yes|published,=,1"]; + } else if (doc.has_variants && !doc.published) { + return [__("Template"), "grey", "has_variants,=,Yes|published,=,0"]; + } else if (doc.variant_of && doc.published) { + return [__("Variant"), "blue", "published,=,1|variant_of,=," + doc.variant_of]; + } else if (doc.variant_of && !doc.published) { + return [__("Variant"), "grey", "published,=,0|variant_of,=," + doc.variant_of]; + } else if (doc.published) { + return [__("Published"), "green", "published,=,1"]; + } else { + return [__("Not Published"), "grey", "published,=,0"]; + } + } +}; \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/product_category_cards/__init__.py b/erpnext/e_commerce/doctype/website_item_tabbed_section/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/product_category_cards/__init__.py rename to erpnext/e_commerce/doctype/website_item_tabbed_section/__init__.py diff --git a/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json new file mode 100644 index 00000000000..6601dd81f21 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json @@ -0,0 +1,37 @@ +{ + "actions": [], + "creation": "2021-03-18 20:32:15.321402", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "content" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + }, + { + "fieldname": "content", + "fieldtype": "HTML Editor", + "in_list_view": 1, + "label": "Content" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-03-18 20:35:26.991192", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Website Item Tabbed Section", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py new file mode 100644 index 00000000000..8459e625cfd --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class WebsiteItemTabbedSection(Document): + pass diff --git a/erpnext/shopping_cart/web_template/product_card/product_card.html b/erpnext/e_commerce/doctype/website_offer/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/product_card/product_card.html rename to erpnext/e_commerce/doctype/website_offer/__init__.py diff --git a/erpnext/e_commerce/doctype/website_offer/website_offer.json b/erpnext/e_commerce/doctype/website_offer/website_offer.json new file mode 100644 index 00000000000..627d548146b --- /dev/null +++ b/erpnext/e_commerce/doctype/website_offer/website_offer.json @@ -0,0 +1,43 @@ +{ + "actions": [], + "creation": "2021-04-21 13:37:14.162162", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "offer_title", + "offer_subtitle", + "offer_details" + ], + "fields": [ + { + "fieldname": "offer_title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Offer Title" + }, + { + "fieldname": "offer_subtitle", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Offer Subtitle" + }, + { + "fieldname": "offer_details", + "fieldtype": "Text Editor", + "label": "Offer Details" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-04-21 13:56:04.660331", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Website Offer", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_offer/website_offer.py b/erpnext/e_commerce/doctype/website_offer/website_offer.py new file mode 100644 index 00000000000..e446f85d65f --- /dev/null +++ b/erpnext/e_commerce/doctype/website_offer/website_offer.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document + +class WebsiteOffer(Document): + pass + +@frappe.whitelist(allow_guest=True) +def get_offer_details(offer_id): + return frappe.db.get_value('Website Offer', {'name': offer_id}, ['offer_details']) diff --git a/erpnext/e_commerce/doctype/wishlist/__init__.py b/erpnext/e_commerce/doctype/wishlist/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/doctype/wishlist/test_wishlist.py b/erpnext/e_commerce/doctype/wishlist/test_wishlist.py new file mode 100644 index 00000000000..42cf8683d99 --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/test_wishlist.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +import frappe +import unittest + +from frappe.core.doctype.user_permission.test_user_permission import create_user + +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.doctype.wishlist.wishlist import add_to_wishlist, remove_from_wishlist + +class TestWishlist(unittest.TestCase): + def setUp(self): + item = make_item("Test Phone Series X") + if not frappe.db.exists("Website Item", {"item_code": "Test Phone Series X"}): + make_website_item(item, save=True) + + item = make_item("Test Phone Series Y") + if not frappe.db.exists("Website Item", {"item_code": "Test Phone Series Y"}): + make_website_item(item, save=True) + + def tearDown(self): + frappe.get_cached_doc("Website Item", {"item_code": "Test Phone Series X"}).delete() + frappe.get_cached_doc("Website Item", {"item_code": "Test Phone Series Y"}).delete() + frappe.get_cached_doc("Item", "Test Phone Series X").delete() + frappe.get_cached_doc("Item", "Test Phone Series Y").delete() + + def test_add_remove_items_in_wishlist(self): + "Check if items are added and removed from user's wishlist." + # add first item + add_to_wishlist("Test Phone Series X") + + # check if wishlist was created and item was added + self.assertTrue(frappe.db.exists("Wishlist", {"user": frappe.session.user})) + self.assertTrue(frappe.db.exists("Wishlist Item", {"item_code": "Test Phone Series X", "parent": frappe.session.user})) + + # add second item to wishlist + add_to_wishlist("Test Phone Series Y") + wishlist_length = frappe.db.get_value( + "Wishlist Item", + {"parent": frappe.session.user}, + "count(*)" + ) + self.assertEqual(wishlist_length, 2) + + remove_from_wishlist("Test Phone Series X") + remove_from_wishlist("Test Phone Series Y") + + wishlist_length = frappe.db.get_value( + "Wishlist Item", + {"parent": frappe.session.user}, + "count(*)" + ) + self.assertIsNone(frappe.db.exists("Wishlist Item", {"parent": frappe.session.user})) + self.assertEqual(wishlist_length, 0) + + # tear down + frappe.get_doc("Wishlist", {"user": frappe.session.user}).delete() + + def test_add_remove_in_wishlist_multiple_users(self): + "Check if items are added and removed from the correct user's wishlist." + test_user = create_user("test_reviewer@example.com", "Customer") + test_user_1 = create_user("test_reviewer_1@example.com", "Customer") + + # add to wishlist for first user + frappe.set_user(test_user.name) + add_to_wishlist("Test Phone Series X") + + # add to wishlist for second user + frappe.set_user(test_user_1.name) + add_to_wishlist("Test Phone Series X") + + # check wishlist and its content for users + self.assertTrue(frappe.db.exists("Wishlist", {"user": test_user.name})) + self.assertTrue(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user.name})) + + self.assertTrue(frappe.db.exists("Wishlist", {"user": test_user_1.name})) + self.assertTrue(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user_1.name})) + + # remove item for second user + remove_from_wishlist("Test Phone Series X") + + # make sure item was removed for second user and not first + self.assertFalse(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user_1.name})) + self.assertTrue(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user.name})) + + # remove item for first user + frappe.set_user(test_user.name) + remove_from_wishlist("Test Phone Series X") + self.assertFalse(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user.name})) + + # tear down + frappe.set_user("Administrator") + frappe.get_doc("Wishlist", {"user": test_user.name}).delete() + frappe.get_doc("Wishlist", {"user": test_user_1.name}).delete() \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.js b/erpnext/e_commerce/doctype/wishlist/wishlist.js new file mode 100644 index 00000000000..d96e552ecdb --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/wishlist.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Wishlist', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.json b/erpnext/e_commerce/doctype/wishlist/wishlist.json new file mode 100644 index 00000000000..922924e53b0 --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/wishlist.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "autoname": "field:user", + "creation": "2021-03-10 18:52:28.769126", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "section_break_2", + "items" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Wishlist Item" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-07-08 13:11:21.693956", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Wishlist", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Website Manager", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.py b/erpnext/e_commerce/doctype/wishlist/wishlist.py new file mode 100644 index 00000000000..276ecae10db --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/wishlist.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document + +class Wishlist(Document): + pass + +@frappe.whitelist() +def add_to_wishlist(item_code): + """Insert Item into wishlist.""" + + if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}): + return + + web_item_data = frappe.db.get_value( + "Website Item", + {"item_code": item_code}, + ["image", "website_warehouse", "name", "web_item_name", "item_name", "item_group", "route"], + as_dict=1) + + wished_item_dict = { + "item_code": item_code, + "item_name": web_item_data.get("item_name"), + "item_group": web_item_data.get("item_group"), + "website_item": web_item_data.get("name"), + "web_item_name": web_item_data.get("web_item_name"), + "image": web_item_data.get("image"), + "warehouse": web_item_data.get("website_warehouse"), + "route": web_item_data.get("route") + } + + if not frappe.db.exists("Wishlist", frappe.session.user): + # initialise wishlist + wishlist = frappe.get_doc({"doctype": "Wishlist"}) + wishlist.user = frappe.session.user + wishlist.append("items", wished_item_dict) + wishlist.save(ignore_permissions=True) + else: + wishlist = frappe.get_doc("Wishlist", frappe.session.user) + item = wishlist.append('items', wished_item_dict) + item.db_insert() + + if hasattr(frappe.local, "cookie_manager"): + frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist.items))) + +@frappe.whitelist() +def remove_from_wishlist(item_code): + if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}): + frappe.db.delete( + "Wishlist Item", + { + "item_code": item_code, + "parent": frappe.session.user + } + ) + frappe.db.commit() + + wishlist_items = frappe.db.get_values( + "Wishlist Item", + filters={"parent": frappe.session.user} + ) + + if hasattr(frappe.local, "cookie_manager"): + frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist_items))) \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/wishlist_item/__init__.py b/erpnext/e_commerce/doctype/wishlist_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json new file mode 100644 index 00000000000..c0414a7f8ed --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json @@ -0,0 +1,147 @@ +{ + "actions": [], + "creation": "2021-03-10 19:03:00.662714", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "website_item", + "web_item_name", + "column_break_3", + "item_name", + "item_group", + "item_details_section", + "description", + "column_break_7", + "route", + "image", + "image_view", + "section_break_8", + "warehouse_section", + "warehouse" + ], + "fields": [ + { + "fetch_from": "website_item.item_code", + "fetch_if_empty": 1, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fieldname": "website_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Website Item", + "options": "Website Item", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "item_details_section", + "fieldtype": "Section Break", + "label": "Item Details", + "read_only": 1 + }, + { + "fetch_from": "item_code.description", + "fetch_if_empty": 1, + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description", + "read_only": 1 + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "fetch_from": "item_code.image", + "fetch_if_empty": 1, + "fieldname": "image", + "fieldtype": "Attach", + "hidden": 1, + "label": "Image" + }, + { + "fetch_from": "item_code.image", + "fetch_if_empty": 1, + "fieldname": "image_view", + "fieldtype": "Image", + "hidden": 1, + "label": "Image View", + "options": "image", + "print_hide": 1 + }, + { + "fieldname": "warehouse_section", + "fieldtype": "Section Break", + "label": "Warehouse" + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse", + "read_only": 1 + }, + { + "fieldname": "section_break_8", + "fieldtype": "Section Break" + }, + { + "fetch_from": "item_code.item_group", + "fetch_if_empty": 1, + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "read_only": 1 + }, + { + "fetch_from": "website_item.route", + "fetch_if_empty": 1, + "fieldname": "route", + "fieldtype": "Small Text", + "label": "Route", + "read_only": 1 + }, + { + "fetch_from": "website_item.web_item_name", + "fetch_if_empty": 1, + "fieldname": "web_item_name", + "fieldtype": "Data", + "label": "Website Item Name", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-08-09 10:30:41.964802", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Wishlist Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py new file mode 100644 index 00000000000..ee08cfea597 --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class WishlistItem(Document): + pass diff --git a/erpnext/shopping_cart/search.py b/erpnext/e_commerce/legacy_search.py similarity index 96% rename from erpnext/shopping_cart/search.py rename to erpnext/e_commerce/legacy_search.py index 9f674dcebf8..d303b342372 100644 --- a/erpnext/shopping_cart/search.py +++ b/erpnext/e_commerce/legacy_search.py @@ -6,6 +6,7 @@ from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin from whoosh.analysis import StemmingAnalyzer from whoosh.query import Prefix +# TODO: Make obsolete INDEX_NAME = "products" class ProductSearch(FullTextSearch): @@ -111,7 +112,7 @@ class ProductSearch(FullTextSearch): ) def get_all_published_items(): - return frappe.get_all("Item", filters={"variant_of": "", "show_in_website": 1},pluck="name") + return frappe.get_all("Website Item", filters={"variant_of": "", "published": 1}, pluck="item_code") def update_index_for_path(path): search = ProductSearch(INDEX_NAME) diff --git a/erpnext/e_commerce/product_data_engine/filters.py b/erpnext/e_commerce/product_data_engine/filters.py new file mode 100644 index 00000000000..fc58b44f0ee --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/filters.py @@ -0,0 +1,149 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt +import frappe +from frappe import _dict +from frappe.utils import floor + +class ProductFiltersBuilder: + def __init__(self, item_group=None): + if not item_group: + self.doc = frappe.get_doc("E Commerce Settings") + else: + self.doc = frappe.get_doc("Item Group", item_group) + + self.item_group = item_group + + def get_field_filters(self): + if not self.item_group and not self.doc.enable_field_filters: + return + + fields, filter_data = [], [] + filter_fields = [row.fieldname for row in self.doc.filter_fields] # fields in settings + + # 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)] + + for df in fields: + item_filters, item_or_filters = {}, [] + link_doctype_values = self.get_filtered_link_doctype_records(df) + + if df.fieldtype == "Link": + if self.item_group: + item_or_filters.extend([ + ["item_group", "=", self.item_group], + ["Website Item Group", "item_group", "=", self.item_group] # consider website item groups + ]) + + # 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: + # table multiselect + values = list(link_doctype_values) + + # Remove None + if None in values: + values.remove(None) + + if values: + filter_data.append([df, values]) + + 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 + + attributes = [row.attribute for row in self.doc.filter_attributes] + attribute_docs = [ + frappe.get_doc('Item Attribute', attribute) for attribute in attributes + ] + + valid_attributes = [] + + for attr_doc in attribute_docs: + selected_attributes = [] + for attr in attr_doc.item_attribute_values: + or_filters = [] + filters= [ + ["Item Variant Attribute", "attribute", "=", attr.parent], + ["Item Variant Attribute", "attribute_value", "=", attr.attribute_value] + ] + if self.item_group: + or_filters.extend([ + ["item_group", "=", self.item_group], + ["Website Item Group", "item_group", "=", self.item_group] + ]) + + if frappe.db.get_all("Item", filters, or_filters=or_filters, limit=1): + selected_attributes.append(attr) + + if selected_attributes: + valid_attributes.append( + _dict( + item_attribute_values=selected_attributes, + name=attr_doc.name + ) + ) + + return valid_attributes + + def get_discount_filters(self, discounts): + discount_filters = [] + + # [25.89, 60.5] min max + min_discount, max_discount = discounts[0], discounts[1] + # [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 + + min_range = (min_range + 10) if min_range != min_range_absolute else min_range # 30 (upper limit of 25.89 in range of 10) + max_range = (max_range + 10) if max_range != max_range_absolute else max_range # 60 + + for discount in range(min_range, (max_range + 1), 10): + label = f"{discount}% and below" + discount_filters.append([discount, label]) + + return discount_filters \ No newline at end of file diff --git a/erpnext/e_commerce/product_data_engine/query.py b/erpnext/e_commerce/product_data_engine/query.py new file mode 100644 index 00000000000..331fd351542 --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/query.py @@ -0,0 +1,297 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils import flt +from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website +from erpnext.e_commerce.doctype.item_review.item_review import get_customer +from erpnext.utilities.product import get_non_stock_item_status + +class ProductQuery: + """Query engine for product listing + + Attributes: + fields (list): Fields to fetch in query + conditions (string): Conditions for query building + or_conditions (string): Search conditions + page_length (Int): Length of page for the query + settings (Document): E Commerce Settings DocType + """ + def __init__(self): + self.settings = frappe.get_doc("E Commerce Settings") + self.page_length = self.settings.products_per_page or 20 + + self.or_filters = [] + self.filters = [["published", "=", 1]] + self.fields = [ + "web_item_name", "name", "item_name", "item_code", "website_image", + "variant_of", "has_variants", "item_group", "image", "web_long_description", + "short_description", "route", "website_warehouse", "ranking", "on_backorder" + ] + + def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None): + """ + Args: + attributes (dict, optional): Item Attribute filters + fields (dict, optional): Field level filters + search_term (str, optional): Search term to lookup + start (int, optional): Page start + + Returns: + dict: Dict containing items, item count & discount range + """ + # track if discounts included in field filters + self.filter_with_discount = bool(fields.get("discount")) + result, discount_list, website_item_groups, cart_items, count = [], [], [], [], 0 + + website_item_groups = self.get_website_item_group_results(item_group, website_item_groups) + + if fields: + self.build_fields_filters(fields) + if search_term: + self.build_search_filters(search_term) + if self.settings.hide_variants: + self.filters.append(["variant_of", "is", "not set"]) + + # query results + if attributes: + result, count = self.query_items_with_attributes(attributes, start) + else: + result, count = self.query_items(start=start) + + result = self.combine_web_item_group_results(item_group, result, website_item_groups) + + # sort combined results by ranking + result = sorted(result, key=lambda x: x.get("ranking"), reverse=True) + + if self.settings.enabled: + cart_items = self.get_cart_items() + + result, discount_list = self.add_display_details(result, discount_list, cart_items) + + discounts = [] + if discount_list: + discounts = [min(discount_list), max(discount_list)] + + result = self.filter_results_by_discount(fields, result) + + return { + "items": result, + "items_count": count, + "discounts": discounts + } + + def query_items(self, start=0): + """Build a query to fetch Website Items based on field filters.""" + # MySQL does not support offset without limit, + # frappe does not accept two parameters for limit + # https://dev.mysql.com/doc/refman/8.0/en/select.html#id4651989 + count_items = frappe.db.get_all( + "Website Item", + filters=self.filters, + or_filters=self.or_filters, + limit_page_length=184467440737095516, + limit_start=start, # get all items from this offset for total count ahead + order_by="ranking desc") + count = len(count_items) + + # If discounts included, return all rows. + # Slice after filtering rows with discount (See `filter_results_by_discount`). + # Slicing before hand will miss discounted items on the 3rd or 4th page. + # Discounts are fetched on computing Pricing Rules so we cannot query them directly. + page_length = 184467440737095516 if self.filter_with_discount else self.page_length + + items = frappe.db.get_all( + "Website Item", + fields=self.fields, + filters=self.filters, + or_filters=self.or_filters, + limit_page_length=page_length, + limit_start=start, + order_by="ranking desc") + + return items, count + + def query_items_with_attributes(self, attributes, start=0): + """Build a query to fetch Website Items based on field & attribute filters.""" + item_codes = [] + + for attribute, values in attributes.items(): + if not isinstance(values, list): + values = [values] + + # get items that have selected attribute & value + item_code_list = frappe.db.get_all( + "Item", + fields=["item_code"], + filters=[ + ["published_in_website", "=", 1], + ["Item Variant Attribute", "attribute", "=", attribute], + ["Item Variant Attribute", "attribute_value", "in", values] + ]) + item_codes.append({x.item_code for x in item_code_list}) + + if item_codes: + item_codes = list(set.intersection(*item_codes)) + self.filters.append(["item_code", "in", item_codes]) + + items, count = self.query_items(start=start) + + return items, count + + def build_fields_filters(self, filters): + """Build filters for field values + + Args: + filters (dict): Filters + """ + for field, values in filters.items(): + if not values or field == "discount": + continue + + # handle multiselect fields in filter addition + meta = frappe.get_meta('Website Item', cached=True) + df = meta.get_field(field) + + if df.fieldtype == 'Table MultiSelect': + child_doctype = df.options + child_meta = frappe.get_meta(child_doctype, cached=True) + fields = child_meta.get("fields") + if fields: + self.filters.append([child_doctype, fields[0].fieldname, 'IN', values]) + elif isinstance(values, list): + # If value is a list use `IN` query + self.filters.append([field, "in", values]) + else: + # `=` will be faster than `IN` for most cases + self.filters.append([field, "=", values]) + + def build_search_filters(self, search_term): + """Query search term in specified fields + + Args: + search_term (str): Search candidate + """ + # Default fields to search from + default_fields = {'item_code', 'item_name', 'web_long_description', 'item_group'} + + # Get meta search fields + meta = frappe.get_meta("Website Item") + meta_fields = set(meta.get_search_fields()) + + # Join the meta fields and default fields set + search_fields = default_fields.union(meta_fields) + if frappe.db.count('Website Item', cache=True) > 50000: + search_fields.discard('web_long_description') + + # Build or filters for query + search = '%{}%'.format(search_term) + for field in search_fields: + self.or_filters.append([field, "like", search]) + + def get_website_item_group_results(self, item_group, website_item_groups): + """Get Web Items for Item Group Page via Website Item Groups.""" + if item_group: + website_item_groups = frappe.db.get_all( + "Website Item", + fields=self.fields + ["`tabWebsite Item Group`.parent as wig_parent"], + filters=[["Website Item Group", "item_group", "=", item_group]] + ) + return website_item_groups + + def add_display_details(self, result, discount_list, cart_items): + """Add price and availability details in result.""" + for item in result: + product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') + + if product_info and product_info['price']: + # update/mutate item and discount_list objects + self.get_price_discount_info(item, product_info['price'], discount_list) + + if self.settings.show_stock_availability: + self.get_stock_availability(item) + + item.in_cart = item.item_code in cart_items + + item.wished = False + if frappe.db.exists("Wishlist Item", {"item_code": item.item_code, "parent": frappe.session.user}): + item.wished = True + + return result, discount_list + + def get_price_discount_info(self, item, price_object, discount_list): + """Modify item object and add price details.""" + fields = ["formatted_mrp", "formatted_price", "price_list_rate"] + for field in fields: + item[field] = price_object.get(field) + + if price_object.get('discount_percent'): + item.discount_percent = flt(price_object.discount_percent) + discount_list.append(price_object.discount_percent) + + if item.formatted_mrp: + item.discount = price_object.get('formatted_discount_percent') or \ + price_object.get('formatted_discount_rate') + + def get_stock_availability(self, item): + """Modify item object and add stock details.""" + item.in_stock = False + warehouse = item.get("website_warehouse") + is_stock_item = frappe.get_cached_value("Item", item.item_code, "is_stock_item") + + if item.get("on_backorder"): + return + + if not is_stock_item: + if warehouse: + # product bundle case + item.in_stock = get_non_stock_item_status(item.item_code, "website_warehouse") + else: + item.in_stock = True + elif warehouse: + # stock item and has warehouse + actual_qty = frappe.db.get_value( + "Bin", + {"item_code": item.item_code,"warehouse": item.get("website_warehouse")}, + "actual_qty") + item.in_stock = bool(flt(actual_qty)) + + def get_cart_items(self): + customer = get_customer(silent=True) + if customer: + quotation = frappe.get_all("Quotation", fields=["name"], filters= + {"party_name": customer, "order_type": "Shopping Cart", "docstatus": 0}, + order_by="modified desc", limit_page_length=1) + if quotation: + items = frappe.get_all( + "Quotation Item", + fields=["item_code"], + filters={ + "parent": quotation[0].get("name") + }) + items = [row.item_code for row in items] + return items + + return [] + + def combine_web_item_group_results(self, item_group, result, website_item_groups): + """Combine results with context of website item groups into item results.""" + if item_group and website_item_groups: + items_list = {row.name for row in result} + for row in website_item_groups: + if row.wig_parent not in items_list: + result.append(row) + + return result + + def filter_results_by_discount(self, fields, result): + if fields and fields.get("discount"): + discount_percent = frappe.utils.flt(fields["discount"][0]) + result = [row for row in result if row.get("discount_percent") and row.discount_percent >= discount_percent] + + if self.filter_with_discount: + # no limit was added to results while querying + # slice results manually + result[:self.page_length] + + return result \ No newline at end of file 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 new file mode 100644 index 00000000000..201fa1148e7 --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py @@ -0,0 +1,344 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import unittest + +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 and Filters for Product Listing." + + @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"}], + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India" + }) + 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): + "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] + attributes = attribute_filter.item_attribute_values + + attribute_values = [d.attribute_value for d in attributes] + + self.assertEqual(attribute_filter.name, "Test Size") + self.assertGreater(len(attribute_values), 0) + self.assertIn("Large", attribute_values) + + def test_product_list_with_attribute_filter(self): + "Test if attribute filters are applied correctly." + create_variant_web_item() + + 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 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_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 + + 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 below") + + 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 below 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): + "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_ui/grid.js b/erpnext/e_commerce/product_ui/grid.js new file mode 100644 index 00000000000..9eb1d45d5f5 --- /dev/null +++ b/erpnext/e_commerce/product_ui/grid.js @@ -0,0 +1,201 @@ +erpnext.ProductGrid = class { + /* Options: + - items: Items + - settings: E Commerce Settings + - products_section: Products Wrapper + - preference: If preference is not grid view, render but hide + */ + constructor(options) { + Object.assign(this, options); + + if (this.preference !== "Grid View") { + this.products_section.addClass("hidden"); + } + + this.products_section.empty(); + this.make(); + } + + make() { + let me = this; + let html = ``; + + this.items.forEach(item => { + let title = item.web_item_name || item.item_name || item.item_code || ""; + title = title.length > 90 ? title.substr(0, 90) + "..." : title; + + html += `
`; + html += me.get_image_html(item, title); + html += me.get_card_body_html(item, title, me.settings); + html += `
`; + }); + + let $product_wrapper = this.products_section; + $product_wrapper.append(html); + } + + get_image_html(item, title) { + let image = item.website_image || item.image; + + if (image) { + return ` +
+ + ${ title } + +
+ `; + } else { + return ` +
+ +
+ ${ frappe.get_abbr(title) } +
+
+
+ `; + } + } + + get_card_body_html(item, title, settings) { + let body_html = ` +
+
+ `; + body_html += this.get_title(item, title); + + // get floating elements + if (!item.has_variants) { + if (settings.enable_wishlist) { + body_html += this.get_wishlist_icon(item); + } + if (settings.enabled) { + body_html += this.get_cart_indicator(item); + } + + } + + body_html += `
`; + body_html += `
${ item.item_group || '' }
`; + + if (item.formatted_price) { + body_html += this.get_price_html(item); + } + + body_html += this.get_stock_availability(item, settings); + body_html += this.get_primary_button(item, settings); + body_html += `
`; // close div on line 49 + + return body_html; + } + + get_title(item, title) { + let title_html = ` + +
+ ${ title || '' } +
+
+ `; + return title_html; + } + + get_wishlist_icon(item) { + let icon_class = item.wished ? "wished" : "not-wished"; + return ` +
+ + + +
+ `; + } + + get_cart_indicator(item) { + return ` +
+ 1 +
+ `; + } + + get_price_html(item) { + let price_html = ` +
+ ${ item.formatted_price || '' } + `; + + if (item.formatted_mrp) { + price_html += ` + + ${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" } + + + ${ item.discount } OFF + + `; + } + price_html += `
`; + return price_html; + } + + get_stock_availability(item, settings) { + if (settings.show_stock_availability && !item.has_variants) { + if (item.on_backorder) { + return ` + + ${ __("Available on backorder") } + + `; + } else if (!item.in_stock) { + return ` + + ${ __("Out of stock") } + + `; + } + } + + return ``; + } + + get_primary_button(item, settings) { + if (item.has_variants) { + return ` + +
+ ${ __('Explore') } +
+
+ `; + } else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) { + return ` +
+ + + + + + ${ settings.enable_checkout ? __('Add to Cart') : __('Add to Quote') } +
+ + +
+ ${ settings.enable_checkout ? __('Go to Cart') : __('Go to Quote') } +
+
+ `; + } else { + return ``; + } + } +}; \ No newline at end of file diff --git a/erpnext/e_commerce/product_ui/list.js b/erpnext/e_commerce/product_ui/list.js new file mode 100644 index 00000000000..691cd4d9de1 --- /dev/null +++ b/erpnext/e_commerce/product_ui/list.js @@ -0,0 +1,204 @@ +erpnext.ProductList = class { + /* Options: + - items: Items + - settings: E Commerce Settings + - products_section: Products Wrapper + - preference: If preference is not list view, render but hide + */ + constructor(options) { + Object.assign(this, options); + + if (this.preference !== "List View") { + this.products_section.addClass("hidden"); + } + + this.products_section.empty(); + this.make(); + } + + make() { + let me = this; + let html = `

`; + + this.items.forEach(item => { + let title = item.web_item_name || item.item_name || item.item_code || ""; + title = title.length > 200 ? title.substr(0, 200) + "..." : title; + + html += `
`; + html += me.get_image_html(item, title, me.settings); + html += me.get_row_body_html(item, title, me.settings); + html += `
`; + }); + + let $product_wrapper = this.products_section; + $product_wrapper.append(html); + } + + get_image_html(item, title, settings) { + let image = item.website_image || item.image; + let wishlist_enabled = !item.has_variants && settings.enable_wishlist; + let image_html = ``; + + if (image) { + image_html += ` +
+ + ${ title } + + ${ wishlist_enabled ? this.get_wishlist_icon(item): '' } +
+ `; + } else { + image_html += ` +
+ +
+ ${ frappe.get_abbr(title) } +
+
+ ${ wishlist_enabled ? this.get_wishlist_icon(item): '' } +
+ `; + } + + return image_html; + } + + get_row_body_html(item, title, settings) { + let body_html = `
`; + body_html += this.get_title_html(item, title, settings); + body_html += this.get_item_details(item, settings); + body_html += `
`; + return body_html; + } + + get_title_html(item, title, settings) { + let title_html = `
`; + title_html += ` +
+ + ${ title } + +
+ `; + + if (settings.enabled) { + title_html += `
`; + title_html += this.get_primary_button(item, settings); + title_html += `
`; + } + title_html += `
`; + + return title_html; + } + + get_item_details(item, settings) { + let details = ` +

+ ${ item.item_group } | Item Code : ${ item.item_code } +

+
+ ${ item.short_description || '' } +
+
+ ${ item.formatted_price || '' } + `; + + if (item.formatted_mrp) { + details += ` + + ${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" } + + + ${ item.discount } OFF + + `; + } + + details += this.get_stock_availability(item, settings); + details += `
`; + + return details; + } + + get_stock_availability(item, settings) { + if (settings.show_stock_availability && !item.has_variants) { + if (item.on_backorder) { + return ` +
+ + ${ __("Available on backorder") } + + `; + } else if (!item.in_stock) { + return ` +
+ ${ __("Out of stock") } + `; + } + } + return ``; + } + + get_wishlist_icon(item) { + let icon_class = item.wished ? "wished" : "not-wished"; + + return ` +
+ + + +
+ `; + } + + get_primary_button(item, settings) { + if (item.has_variants) { + return ` + +
+ ${ __('Explore') } +
+
+ `; + } else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) { + return ` +
+ + + + + + ${ settings.enable_checkout ? __('Add to Cart') : __('Add to Quote') } +
+ +
+ 1 +
+ + +
+ ${ settings.enable_checkout ? __('Go to Cart') : __('Go to Quote') } +
+
+ `; + } else { + return ``; + } + } + +}; \ No newline at end of file diff --git a/erpnext/e_commerce/product_ui/search.js b/erpnext/e_commerce/product_ui/search.js new file mode 100644 index 00000000000..9bae1c10bc4 --- /dev/null +++ b/erpnext/e_commerce/product_ui/search.js @@ -0,0 +1,241 @@ +erpnext.ProductSearch = class { + constructor() { + this.MAX_RECENT_SEARCHES = 4; + this.searchBox = $("#search-box"); + + this.setupSearchDropDown(); + this.bindSearchAction(); + } + + setupSearchDropDown() { + this.search_area = $("#dropdownMenuSearch"); + this.setupSearchResultContainer(); + this.populateRecentSearches(); + } + + bindSearchAction() { + let me = this; + + // Show Search dropdown + this.searchBox.on("focus", () => { + this.search_dropdown.removeClass("hidden"); + }); + + // If click occurs outside search input/results, hide results. + // Click can happen anywhere on the page + $("body").on("click", (e) => { + let searchEvent = $(e.target).closest('#search-box').length; + let resultsEvent = $(e.target).closest('#search-results-container').length; + let isResultHidden = this.search_dropdown.hasClass("hidden"); + + if (!searchEvent && !resultsEvent && !isResultHidden) { + this.search_dropdown.addClass("hidden"); + } + }); + + // Process search input + this.searchBox.on("input", (e) => { + let query = e.target.value; + + if (query.length == 0) { + me.populateResults(null); + me.populateCategoriesList(null); + } + + if (query.length < 3 || !query.length) return; + + frappe.call({ + method: "erpnext.templates.pages.product_search.search", + args: { + query: query + }, + callback: (data) => { + let product_results = null, category_results = null; + + // Populate product results + product_results = data.message ? data.message.product_results : null; + me.populateResults(product_results); + + // Populate categories + if (me.category_container) { + category_results = data.message ? data.message.category_results : null; + me.populateCategoriesList(category_results); + } + + // Populate recent search chips only on successful queries + if (!$.isEmptyObject(product_results) || !$.isEmptyObject(category_results)) { + me.setRecentSearches(query); + } + } + }); + + this.search_dropdown.removeClass("hidden"); + }); + } + + setupSearchResultContainer() { + this.search_dropdown = this.search_area.append(` + + `).find("#search-results-container"); + + this.setupCategoryContainer(); + this.setupProductsContainer(); + this.setupRecentsContainer(); + } + + setupProductsContainer() { + this.products_container = this.search_dropdown.append(` +
+
+
+
+ `).find("#product-scroll"); + } + + setupCategoryContainer() { + this.category_container = this.search_dropdown.append(` +
+
+
+
+ `).find(".category-chips"); + } + + setupRecentsContainer() { + let $recents_section = this.search_dropdown.append(` +
+
+ ${ __("Recent") } +
+
+ `).find(".recent-searches"); + + this.recents_container = $recents_section.append(` +
+
+ `).find("#recents"); + } + + getRecentSearches() { + return JSON.parse(localStorage.getItem("recent_searches") || "[]"); + } + + attachEventListenersToChips() { + let me = this; + const chips = $(".recent-search"); + window.chips = chips; + + for (let chip of chips) { + chip.addEventListener("click", () => { + me.searchBox[0].value = chip.innerText.trim(); + + // Start search with `recent query` + me.searchBox.trigger("input"); + me.searchBox.focus(); + }); + } + } + + setRecentSearches(query) { + let recents = this.getRecentSearches(); + if (recents.length >= this.MAX_RECENT_SEARCHES) { + // Remove the `first` query + recents.splice(0, 1); + } + + if (recents.indexOf(query) >= 0) { + return; + } + + recents.push(query); + localStorage.setItem("recent_searches", JSON.stringify(recents)); + + this.populateRecentSearches(); + } + + populateRecentSearches() { + let recents = this.getRecentSearches(); + + if (!recents.length) { + this.recents_container.html(`No searches yet.`); + return; + } + + let html = ""; + recents.forEach((key) => { + html += ` + + `; + }); + + this.recents_container.html(html); + this.attachEventListenersToChips(); + } + + populateResults(product_results) { + if (!product_results || product_results.length === 0) { + let empty_html = ``; + this.products_container.html(empty_html); + return; + } + + let html = ""; + + product_results.forEach((res) => { + let thumbnail = res.thumbnail || '/assets/erpnext/images/ui-states/cart-empty-state.png'; + html += ` + + `; + }); + + this.products_container.html(html); + } + + populateCategoriesList(category_results) { + if (!category_results || category_results.length === 0) { + let empty_html = ` +
+
+
+
+ `; + this.category_container.html(empty_html); + return; + } + + let html = ` +
+ ${ __("Categories") } +
+ `; + + category_results.forEach((category) => { + html += ` + + ${ category.name } + + `; + }); + + this.category_container.html(html); + } +}; \ No newline at end of file diff --git a/erpnext/e_commerce/product_ui/views.js b/erpnext/e_commerce/product_ui/views.js new file mode 100644 index 00000000000..99b91afac17 --- /dev/null +++ b/erpnext/e_commerce/product_ui/views.js @@ -0,0 +1,538 @@ +erpnext.ProductView = class { + /* Options: + - View Type + - Products Section Wrapper, + - Item Group: If its an Item Group page + */ + constructor(options) { + Object.assign(this, options); + this.preference = this.view_type; + this.make(); + } + + make(from_filters=false) { + this.products_section.empty(); + this.prepare_toolbar(); + this.get_item_filter_data(from_filters); + } + + prepare_toolbar() { + this.products_section.append(` +
+
+ `); + this.prepare_search(); + this.prepare_view_toggler(); + + frappe.require('/assets/js/e-commerce.min.js', function() { + new erpnext.ProductSearch(); + }); + } + + prepare_view_toggler() { + + if (!$("#list").length || !$("#image-view").length) { + this.render_view_toggler(); + this.bind_view_toggler_actions(); + this.set_view_state(); + } + } + + get_item_filter_data(from_filters=false) { + // Get and render all Product related views + let me = this; + this.from_filters = from_filters; + let args = this.get_query_filters(); + + this.disable_view_toggler(true); + + frappe.call({ + method: "erpnext.e_commerce.api.get_product_filter_data", + args: { + query_args: args + }, + callback: function(result) { + if (!result || result.exc || !result.message || result.message.exc) { + me.render_no_products_section(true); + } else { + // Sub Category results are independent of Items + if (me.item_group && result.message["sub_categories"].length) { + me.render_item_sub_categories(result.message["sub_categories"]); + } + + if (!result.message["items"].length) { + // if result has no items or result is empty + me.render_no_products_section(); + } else { + // Add discount filters + me.re_render_discount_filters(result.message["filters"].discount_filters); + + // Render views + me.render_list_view(result.message["items"], result.message["settings"]); + me.render_grid_view(result.message["items"], result.message["settings"]); + + me.products = result.message["items"]; + me.product_count = result.message["items_count"]; + } + + // Bind filter actions + if (!from_filters) { + // If `get_product_filter_data` was triggered after checking a filter, + // don't touch filters unnecessarily, only data must change + // filter persistence is handle on filter change event + me.bind_filters(); + me.restore_filters_state(); + } + + // Bottom paging + me.add_paging_section(result.message["settings"]); + } + + me.disable_view_toggler(false); + } + }); + } + + disable_view_toggler(disable=false) { + $('#list').prop('disabled', disable); + $('#image-view').prop('disabled', disable); + } + + render_grid_view(items, settings) { + // loop over data and add grid html to it + let me = this; + this.prepare_product_area_wrapper("grid"); + + frappe.require('/assets/js/e-commerce.min.js', function() { + new erpnext.ProductGrid({ + items: items, + products_section: $("#products-grid-area"), + settings: settings, + preference: me.preference + }); + }); + } + + render_list_view(items, settings) { + let me = this; + this.prepare_product_area_wrapper("list"); + + frappe.require('/assets/js/e-commerce.min.js', function() { + new erpnext.ProductList({ + items: items, + products_section: $("#products-list-area"), + settings: settings, + preference: me.preference + }); + }); + } + + prepare_product_area_wrapper(view) { + let left_margin = view == "list" ? "ml-2" : ""; + let top_margin = view == "list" ? "mt-6" : "mt-minus-1"; + return this.products_section.append(` +
+
+ `); + } + + get_query_filters() { + const filters = frappe.utils.get_query_params(); + let {field_filters, attribute_filters} = filters; + + field_filters = field_filters ? JSON.parse(field_filters) : {}; + attribute_filters = attribute_filters ? JSON.parse(attribute_filters) : {}; + + return { + field_filters: field_filters, + attribute_filters: attribute_filters, + item_group: this.item_group, + start: filters.start || null, + from_filters: this.from_filters || false + }; + } + + add_paging_section(settings) { + $(".product-paging-area").remove(); + + if (this.products) { + let paging_html = ` +
+
+
+
+ `; + let query_params = frappe.utils.get_query_params(); + let start = query_params.start ? cint(JSON.parse(query_params.start)) : 0; + let page_length = settings.products_per_page || 0; + + let prev_disable = start > 0 ? "" : "disabled"; + let next_disable = (this.product_count > page_length) ? "" : "disabled"; + + paging_html += ` + `; + + paging_html += ` + + `; + + paging_html += `
`; + + $(".page_content").append(paging_html); + this.bind_paging_action(); + } + } + + prepare_search() { + $(".toolbar").append(` +
+ +
+ `); + } + + render_view_toggler() { + $(".toolbar").append(`
`); + + ["btn-list-view", "btn-grid-view"].forEach(view => { + let icon = view === "btn-list-view" ? "list" : "image-view"; + $(".toggle-container").append(` +
+ +
+ `); + }); + } + + bind_view_toggler_actions() { + $("#list").click(function() { + let $btn = $(this); + $btn.removeClass('btn-primary'); + $btn.addClass('btn-primary'); + $(".btn-grid-view").removeClass('btn-primary'); + + $("#products-grid-area").addClass("hidden"); + $("#products-list-area").removeClass("hidden"); + localStorage.setItem("product_view", "List View"); + }); + + $("#image-view").click(function() { + let $btn = $(this); + $btn.removeClass('btn-primary'); + $btn.addClass('btn-primary'); + $(".btn-list-view").removeClass('btn-primary'); + + $("#products-list-area").addClass("hidden"); + $("#products-grid-area").removeClass("hidden"); + localStorage.setItem("product_view", "Grid View"); + }); + } + + set_view_state() { + if (this.preference === "List View") { + $("#list").addClass('btn-primary'); + $("#image-view").removeClass('btn-primary'); + } else { + $("#image-view").addClass('btn-primary'); + $("#list").removeClass('btn-primary'); + } + } + + bind_paging_action() { + let me = this; + $('.btn-prev, .btn-next').click((e) => { + const $btn = $(e.target); + me.from_filters = false; + + $btn.prop('disabled', true); + const start = $btn.data('start'); + + let query_params = frappe.utils.get_query_params(); + query_params.start = start; + let path = window.location.pathname + '?' + frappe.utils.get_url_from_dict(query_params); + window.location.href = path; + }); + } + + re_render_discount_filters(filter_data) { + this.get_discount_filter_html(filter_data); + if (this.from_filters) { + // Bind filter action if triggered via filters + // if not from filter action, page load will bind actions + this.bind_discount_filter_action(); + } + // discount filters are rendered with Items (later) + // unlike the other filters + this.restore_discount_filter(); + } + + get_discount_filter_html(filter_data) { + $("#discount-filters").remove(); + if (filter_data) { + $("#product-filters").append(` +
+
${ __("Discounts") }
+
+ `); + + let html = `
`; + filter_data.forEach(filter => { + html += ` +
+ +
+ `; + }); + html += `
`; + + $("#discount-filters").append(html); + } + } + + restore_discount_filter() { + const filters = frappe.utils.get_query_params(); + let field_filters = filters.field_filters; + if (!field_filters) return; + + field_filters = JSON.parse(field_filters); + + if (field_filters && field_filters["discount"]) { + const values = field_filters["discount"]; + const selector = values.map(value => { + return `input[data-filter-name="discount"][data-filter-value="${value}"]`; + }).join(','); + $(selector).prop('checked', true); + this.field_filters = field_filters; + } + } + + bind_discount_filter_action() { + let me = this; + $('.discount-filter').on('change', (e) => { + const $checkbox = $(e.target); + const is_checked = $checkbox.is(':checked'); + + const { + filterValue: filter_value + } = $checkbox.data(); + + delete this.field_filters["discount"]; + + if (is_checked) { + this.field_filters["discount"] = []; + this.field_filters["discount"].push(filter_value); + } + + if (this.field_filters["discount"].length === 0) { + delete this.field_filters["discount"]; + } + + me.change_route_with_filters(); + }); + } + + bind_filters() { + let me = this; + this.field_filters = {}; + this.attribute_filters = {}; + + $('.product-filter').on('change', (e) => { + me.from_filters = true; + + const $checkbox = $(e.target); + const is_checked = $checkbox.is(':checked'); + + if ($checkbox.is('.attribute-filter')) { + const { + attributeName: attribute_name, + attributeValue: attribute_value + } = $checkbox.data(); + + if (is_checked) { + this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name] || []; + this.attribute_filters[attribute_name].push(attribute_value); + } else { + this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name] || []; + this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name].filter(v => v !== attribute_value); + } + + if (this.attribute_filters[attribute_name].length === 0) { + delete this.attribute_filters[attribute_name]; + } + } else if ($checkbox.is('.field-filter') || $checkbox.is('.discount-filter')) { + const { + filterName: filter_name, + filterValue: filter_value + } = $checkbox.data(); + + if ($checkbox.is('.discount-filter')) { + // clear previous discount filter to accomodate new + delete this.field_filters["discount"]; + } + if (is_checked) { + this.field_filters[filter_name] = this.field_filters[filter_name] || []; + if (!in_list(this.field_filters[filter_name], filter_value)) { + this.field_filters[filter_name].push(filter_value); + } + } else { + this.field_filters[filter_name] = this.field_filters[filter_name] || []; + this.field_filters[filter_name] = this.field_filters[filter_name].filter(v => v !== filter_value); + } + + if (this.field_filters[filter_name].length === 0) { + delete this.field_filters[filter_name]; + } + } + + me.change_route_with_filters(); + }); + } + + change_route_with_filters() { + let route_params = frappe.utils.get_query_params(); + + let start = this.if_key_exists(route_params.start) || 0; + if (this.from_filters) { + start = 0; // show items from first page if new filters are triggered + } + + const query_string = this.get_query_string({ + start: start, + field_filters: JSON.stringify(this.if_key_exists(this.field_filters)), + attribute_filters: JSON.stringify(this.if_key_exists(this.attribute_filters)), + }); + window.history.pushState('filters', '', `${location.pathname}?` + query_string); + + $('.page_content input').prop('disabled', true); + + this.make(true); + $('.page_content input').prop('disabled', false); + } + + restore_filters_state() { + const filters = frappe.utils.get_query_params(); + let {field_filters, attribute_filters} = filters; + + if (field_filters) { + field_filters = JSON.parse(field_filters); + for (let fieldname in field_filters) { + const values = field_filters[fieldname]; + const selector = values.map(value => { + return `input[data-filter-name="${fieldname}"][data-filter-value="${value}"]`; + }).join(','); + $(selector).prop('checked', true); + } + this.field_filters = field_filters; + } + if (attribute_filters) { + attribute_filters = JSON.parse(attribute_filters); + for (let attribute in attribute_filters) { + const values = attribute_filters[attribute]; + const selector = values.map(value => { + return `input[data-attribute-name="${attribute}"][data-attribute-value="${value}"]`; + }).join(','); + $(selector).prop('checked', true); + } + this.attribute_filters = attribute_filters; + } + } + + render_no_products_section(error=false) { + let error_section = ` +
+ Something went wrong. Please refresh or contact us. +
+ `; + let no_results_section = ` +
+
+ Empty Cart +
+
${ __('No products found') }

+
+ `; + + this.products_section.append(error ? error_section : no_results_section); + } + + render_item_sub_categories(categories) { + if (categories && categories.length) { + let sub_group_html = ` +
+ `; + + categories.forEach(category => { + sub_group_html += ` + +
+ ${ category.name } +
+
+ `; + }); + sub_group_html += `
`; + + $("#product-listing").prepend(sub_group_html); + } + } + + get_query_string(object) { + const url = new URLSearchParams(); + for (let key in object) { + const value = object[key]; + if (value) { + url.append(key, value); + } + } + return url.toString(); + } + + if_key_exists(obj) { + let exists = false; + for (let key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key) && obj[key]) { + exists = true; + break; + } + } + return exists ? obj : undefined; + } +}; \ No newline at end of file diff --git a/erpnext/e_commerce/redisearch.py b/erpnext/e_commerce/redisearch.py new file mode 100644 index 00000000000..d0626a36827 --- /dev/null +++ b/erpnext/e_commerce/redisearch.py @@ -0,0 +1,208 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils.redis_wrapper import RedisWrapper +from redisearch import (Client, AutoCompleter, Suggestion, IndexDefinition, TextField, TagField) + +WEBSITE_ITEM_INDEX = 'website_items_index' +WEBSITE_ITEM_KEY_PREFIX = 'website_item:' +WEBSITE_ITEM_NAME_AUTOCOMPLETE = 'website_items_name_dict' +WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE = 'website_items_category_dict' + +def get_indexable_web_fields(): + "Return valid fields from Website Item that can be searched for." + web_item_meta = frappe.get_meta("Website Item", cached=True) + valid_fields = filter( + lambda df: df.fieldtype in ("Link", "Table MultiSelect", "Data", "Small Text", "Text Editor"), + web_item_meta.fields) + + return [df.fieldname for df in valid_fields] + +def is_search_module_loaded(): + cache = frappe.cache() + out = cache.execute_command('MODULE LIST') + + parsed_output = " ".join( + (" ".join([s.decode() for s in o if not isinstance(s, int)]) for o in out) + ) + + return "search" in parsed_output + +def if_redisearch_loaded(function): + "Decorator to check if Redisearch is loaded." + def wrapper(*args, **kwargs): + if is_search_module_loaded(): + func = function(*args, **kwargs) + return func + return + + return wrapper + +def make_key(key): + return "{0}|{1}".format(frappe.conf.db_name, key).encode('utf-8') + +@if_redisearch_loaded +def create_website_items_index(): + "Creates Index Definition." + + # CREATE index + client = Client(make_key(WEBSITE_ITEM_INDEX), conn=frappe.cache()) + + # DROP if already exists + try: + client.drop_index() + except Exception: + pass + + idx_def = IndexDefinition([make_key(WEBSITE_ITEM_KEY_PREFIX)]) + + # Based on e-commerce settings + idx_fields = frappe.db.get_single_value( + 'E Commerce Settings', + 'search_index_fields' + ) + idx_fields = idx_fields.split(',') if idx_fields else [] + + if 'web_item_name' in idx_fields: + idx_fields.remove('web_item_name') + + idx_fields = list(map(to_search_field, idx_fields)) + + client.create_index( + [TextField("web_item_name", sortable=True)] + idx_fields, + definition=idx_def, + ) + + reindex_all_web_items() + define_autocomplete_dictionary() + +def to_search_field(field): + if field == "tags": + return TagField("tags", separator=",") + + return TextField(field) + +@if_redisearch_loaded +def insert_item_to_index(website_item_doc): + # Insert item to index + key = get_cache_key(website_item_doc.name) + cache = frappe.cache() + web_item = create_web_item_map(website_item_doc) + + for k, v in web_item.items(): + super(RedisWrapper, cache).hset(make_key(key), k, v) + + insert_to_name_ac(website_item_doc.web_item_name, website_item_doc.name) + +@if_redisearch_loaded +def insert_to_name_ac(web_name, doc_name): + ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=frappe.cache()) + ac.add_suggestions(Suggestion(web_name, payload=doc_name)) + +def create_web_item_map(website_item_doc): + fields_to_index = get_fields_indexed() + web_item = {} + + for f in fields_to_index: + web_item[f] = website_item_doc.get(f) or '' + + return web_item + +@if_redisearch_loaded +def update_index_for_item(website_item_doc): + # Reinsert to Cache + insert_item_to_index(website_item_doc) + define_autocomplete_dictionary() + +@if_redisearch_loaded +def delete_item_from_index(website_item_doc): + cache = frappe.cache() + key = get_cache_key(website_item_doc.name) + + try: + cache.delete(key) + except Exception: + return False + + delete_from_ac_dict(website_item_doc) + return True + +@if_redisearch_loaded +def delete_from_ac_dict(website_item_doc): + '''Removes this items's name from autocomplete dictionary''' + cache = frappe.cache() + name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache) + name_ac.delete(website_item_doc.web_item_name) + +@if_redisearch_loaded +def define_autocomplete_dictionary(): + """Creates an autocomplete search dictionary for `name`. + Also creats autocomplete dictionary for `categories` if + checked in E Commerce Settings""" + + cache = frappe.cache() + name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache) + cat_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=cache) + + ac_categories = frappe.db.get_single_value( + 'E Commerce Settings', + 'show_categories_in_search_autocomplete' + ) + + # Delete both autocomplete dicts + try: + cache.delete(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE)) + cache.delete(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE)) + except Exception: + return False + + items = frappe.get_all( + 'Website Item', + fields=['web_item_name', 'item_group'], + filters={"published": 1} + ) + + for item in items: + name_ac.add_suggestions(Suggestion(item.web_item_name)) + if ac_categories and item.item_group: + cat_ac.add_suggestions(Suggestion(item.item_group)) + + return True + +@if_redisearch_loaded +def reindex_all_web_items(): + items = frappe.get_all( + 'Website Item', + fields=get_fields_indexed(), + filters={"published": True} + ) + + cache = frappe.cache() + for item in items: + web_item = create_web_item_map(item) + key = make_key(get_cache_key(item.name)) + + for k, v in web_item.items(): + super(RedisWrapper, cache).hset(key, k, v) + +def get_cache_key(name): + name = frappe.scrub(name) + return f"{WEBSITE_ITEM_KEY_PREFIX}{name}" + +def get_fields_indexed(): + fields_to_index = frappe.db.get_single_value( + 'E Commerce Settings', + 'search_index_fields' + ) + fields_to_index = fields_to_index.split(',') if fields_to_index else [] + + mandatory_fields = ['name', 'web_item_name', 'route', 'thumbnail', 'ranking'] + fields_to_index = fields_to_index + mandatory_fields + + return fields_to_index + +# TODO: Remove later +# # Figure out a way to run this at startup +define_autocomplete_dictionary() +create_website_items_index() diff --git a/erpnext/e_commerce/shopping_cart/__init__.py b/erpnext/e_commerce/shopping_cart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py similarity index 93% rename from erpnext/shopping_cart/cart.py rename to erpnext/e_commerce/shopping_cart/cart.py index e9f4bd57a6a..b7ba6e50b37 100644 --- a/erpnext/shopping_cart/cart.py +++ b/erpnext/e_commerce/shopping_cart/cart.py @@ -7,10 +7,10 @@ from frappe import throw, _ import frappe.defaults from frappe.utils import cint, flt, get_fullname, cstr from frappe.contacts.doctype.address.address import get_address_display -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import get_shopping_cart_settings +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import get_shopping_cart_settings from frappe.utils.nestedset import get_root_of from erpnext.accounts.utils import get_account_name -from erpnext.utilities.product import get_qty_in_stock +from erpnext.utilities.product import get_web_item_qty_in_stock from frappe.contacts.doctype.contact.contact import get_contact_name @@ -18,7 +18,7 @@ class WebsitePriceListMissingError(frappe.ValidationError): pass def set_cart_count(quotation=None): - if cint(frappe.db.get_singles_value("Shopping Cart Settings", "enabled")): + if cint(frappe.db.get_singles_value("E Commerce Settings", "enabled")): if not quotation: quotation = _get_cart_quotation() cart_count = cstr(len(quotation.get("items"))) @@ -45,7 +45,7 @@ def get_cart_quotation(doc=None): "shipping_addresses": get_shipping_addresses(party), "billing_addresses": get_billing_addresses(party), "shipping_rules": get_applicable_shipping_rules(party), - "cart_settings": frappe.get_cached_doc("Shopping Cart Settings") + "cart_settings": frappe.get_cached_doc("E Commerce Settings") } @frappe.whitelist() @@ -69,7 +69,7 @@ def get_billing_addresses(party=None): @frappe.whitelist() def place_order(): quotation = _get_cart_quotation() - cart_settings = frappe.db.get_value("Shopping Cart Settings", None, + cart_settings = frappe.db.get_value("E Commerce Settings", None, ["company", "allow_items_not_in_stock"], as_dict=1) quotation.company = cart_settings.company @@ -89,11 +89,17 @@ def place_order(): if not cint(cart_settings.allow_items_not_in_stock): for item in sales_order.get("items"): - item.reserved_warehouse, is_stock_item = frappe.db.get_value("Item", - item.item_code, ["website_warehouse", "is_stock_item"]) + item.warehouse = frappe.db.get_value( + "Website Item", + { + "item_code": item.item_code + }, + "website_warehouse" + ) + is_stock_item = frappe.db.get_value("Item", item.item_code, "is_stock_item") if is_stock_item: - item_stock = get_qty_in_stock(item.item_code, "website_warehouse") + item_stock = get_web_item_qty_in_stock(item.item_code, "website_warehouse") if not cint(item_stock.in_stock): throw(_("{1} Not in Stock").format(item.item_code)) if item.qty > item_stock.stock_qty[0][0]: @@ -153,9 +159,8 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False): set_cart_count(quotation) - context = get_cart_quotation(quotation) - if cint(with_items): + context = get_cart_quotation(quotation) return { "items": frappe.render_template("templates/includes/cart/cart_items.html", context), @@ -164,8 +169,7 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False): } else: return { - 'name': quotation.name, - 'shopping_cart_menu': get_shopping_cart_menu(context) + 'name': quotation.name } @frappe.whitelist() @@ -259,13 +263,17 @@ def guess_territory(): territory = frappe.db.get_value("Territory", geoip_country) return territory or \ - frappe.db.get_value("Shopping Cart Settings", None, "territory") or \ + frappe.db.get_value("E Commerce Settings", None, "territory") or \ get_root_of("Territory") def decorate_quotation_doc(doc): for d in doc.get("items", []): - d.update(frappe.db.get_value("Item", d.item_code, - ["thumbnail", "website_image", "description", "route"], as_dict=True)) + d.update(frappe.db.get_value( + "Website Item", + {"item_code": d.item_code}, + ["web_item_name", "thumbnail", "website_image", "description", "route"], + as_dict=True) + ) return doc @@ -282,7 +290,7 @@ def _get_cart_quotation(party=None): if quotation: qdoc = frappe.get_doc("Quotation", quotation[0].name) else: - company = frappe.db.get_value("Shopping Cart Settings", None, ["company"]) + company = frappe.db.get_value("E Commerce Settings", None, ["company"]) qdoc = frappe.get_doc({ "doctype": "Quotation", "naming_series": get_shopping_cart_settings().quotation_series or "QTN-CART-", @@ -337,7 +345,7 @@ def apply_cart_settings(party=None, quotation=None): if not quotation: quotation = _get_cart_quotation(party) - cart_settings = frappe.get_doc("Shopping Cart Settings") + cart_settings = frappe.get_doc("E Commerce Settings") set_price_list_and_rate(quotation, cart_settings) @@ -414,7 +422,7 @@ def get_party(user=None): party_doctype = contact.links[0].link_doctype party = contact.links[0].link_name - cart_settings = frappe.get_doc("Shopping Cart Settings") + cart_settings = frappe.get_doc("E Commerce Settings") debtors_account = '' diff --git a/erpnext/shopping_cart/product_info.py b/erpnext/e_commerce/shopping_cart/product_info.py similarity index 58% rename from erpnext/shopping_cart/product_info.py rename to erpnext/e_commerce/shopping_cart/product_info.py index 6c9e531a4d1..838449009e4 100644 --- a/erpnext/shopping_cart/product_info.py +++ b/erpnext/e_commerce/shopping_cart/product_info.py @@ -4,10 +4,10 @@ from __future__ import unicode_literals import frappe -from erpnext.shopping_cart.cart import _get_cart_quotation, _set_price_list -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings \ +from erpnext.e_commerce.shopping_cart.cart import _get_cart_quotation, _set_price_list +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings \ import get_shopping_cart_settings, show_quantity_in_website -from erpnext.utilities.product import get_price, get_qty_in_stock, get_non_stock_item_status +from erpnext.utilities.product import get_price, get_web_item_qty_in_stock, get_non_stock_item_status @frappe.whitelist(allow_guest=True) def get_product_info_for_website(item_code, skip_quotation_creation=False): @@ -23,25 +23,43 @@ def get_product_info_for_website(item_code, skip_quotation_creation=False): selling_price_list = cart_quotation.get("selling_price_list") if cart_quotation else _set_price_list(cart_settings, None) - price = get_price( - item_code, - selling_price_list, - cart_settings.default_customer_group, - cart_settings.company - ) + price = {} + if cart_settings.show_price: + is_guest = frappe.session.user == "Guest" + # Show Price if logged in. + # If not logged in, check if price is hidden for guest. + if not is_guest or not cart_settings.hide_price_for_guest: + price = get_price( + item_code, + selling_price_list, + cart_settings.default_customer_group, + cart_settings.company + ) - stock_status = get_qty_in_stock(item_code, "website_warehouse") + stock_status = None + + if cart_settings.show_stock_availability: + on_backorder = frappe.get_cached_value("Website Item", {"item_code": item_code}, "on_backorder") + if on_backorder: + stock_status = frappe._dict({"on_backorder": True}) + else: + stock_status = get_web_item_qty_in_stock(item_code, "website_warehouse") product_info = { "price": price, - "stock_qty": stock_status.stock_qty, - "in_stock": stock_status.in_stock if stock_status.is_stock_item else get_non_stock_item_status(item_code, "website_warehouse"), "qty": 0, "uom": frappe.db.get_value("Item", item_code, "stock_uom"), - "show_stock_qty": show_quantity_in_website(), "sales_uom": frappe.db.get_value("Item", item_code, "sales_uom") } + if stock_status: + if stock_status.on_backorder: + product_info["on_backorder"] = True + else: + product_info["stock_qty"] = stock_status.stock_qty + product_info["in_stock"] = stock_status.in_stock if stock_status.is_stock_item else get_non_stock_item_status(item_code, "website_warehouse") + product_info["show_stock_qty"] = show_quantity_in_website() + if product_info["price"]: if frappe.session.user != "Guest": item = cart_quotation.get({"item_code": item_code}) if cart_quotation else None diff --git a/erpnext/shopping_cart/test_shopping_cart.py b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py similarity index 92% rename from erpnext/shopping_cart/test_shopping_cart.py rename to erpnext/e_commerce/shopping_cart/test_shopping_cart.py index ac61aebc564..c077646c218 100644 --- a/erpnext/shopping_cart/test_shopping_cart.py +++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py @@ -5,10 +5,10 @@ from __future__ import unicode_literals import unittest import frappe from frappe.utils import nowdate, add_months -from erpnext.shopping_cart.cart import _get_cart_quotation, update_cart, get_party from erpnext.tests.utils import create_test_contact_and_address +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.shopping_cart.cart import _get_cart_quotation, update_cart, get_party from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule - # test_dependencies = ['Payment Terms Template'] class TestShoppingCart(unittest.TestCase): @@ -25,6 +25,11 @@ class TestShoppingCart(unittest.TestCase): frappe.set_user("Administrator") create_test_contact_and_address() self.enable_shopping_cart() + if not frappe.db.exists("Website Item", {"item_code": "_Test Item"}): + make_website_item(frappe.get_cached_doc("Item", "_Test Item")) + + if not frappe.db.exists("Website Item", {"item_code": "_Test Item 2"}): + make_website_item(frappe.get_cached_doc("Item", "_Test Item 2")) def tearDown(self): frappe.set_user("Administrator") @@ -164,7 +169,7 @@ class TestShoppingCart(unittest.TestCase): # helper functions def enable_shopping_cart(self): - settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings") + settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings") settings.update({ "enabled": 1, @@ -194,7 +199,7 @@ class TestShoppingCart(unittest.TestCase): frappe.local.shopping_cart_settings = None def disable_shopping_cart(self): - settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings") + settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings") settings.enabled = 0 settings.save() frappe.local.shopping_cart_settings = None diff --git a/erpnext/shopping_cart/utils.py b/erpnext/e_commerce/shopping_cart/utils.py similarity index 87% rename from erpnext/shopping_cart/utils.py rename to erpnext/e_commerce/shopping_cart/utils.py index 0e1466fd1fa..75a338653a5 100644 --- a/erpnext/shopping_cart/utils.py +++ b/erpnext/e_commerce/shopping_cart/utils.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import frappe.defaults -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import is_cart_enabled +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import is_cart_enabled def show_cart_count(): if (is_cart_enabled() and @@ -18,7 +18,7 @@ def set_cart_count(login_manager): role, parties = check_customer_or_supplier() if role == 'Supplier': return if show_cart_count(): - from erpnext.shopping_cart.cart import set_cart_count + from erpnext.e_commerce.shopping_cart.cart import set_cart_count set_cart_count() def clear_cart_count(login_manager): diff --git a/erpnext/e_commerce/variant_selector/__init__.py b/erpnext/e_commerce/variant_selector/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/portal/product_configurator/item_variants_cache.py b/erpnext/e_commerce/variant_selector/item_variants_cache.py similarity index 100% rename from erpnext/portal/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/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py new file mode 100644 index 00000000000..2e1852c0256 --- /dev/null +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -0,0 +1,195 @@ +import frappe +from frappe.utils import cint +from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager + +def get_item_codes_by_attributes(attribute_filters, template_item_code=None): + items = [] + + for attribute, values in attribute_filters.items(): + attribute_values = values + + if not isinstance(attribute_values, list): + attribute_values = [attribute_values] + + if not attribute_values: + continue + + wheres = [] + query_values = [] + for attribute_value in attribute_values: + wheres.append('( attribute = %s and attribute_value = %s )') + query_values += [attribute, attribute_value] + + attribute_query = ' or '.join(wheres) + + if template_item_code: + variant_of_query = 'AND t2.variant_of = %s' + query_values.append(template_item_code) + else: + variant_of_query = '' + + query = ''' + SELECT + t1.parent + FROM + `tabItem Variant Attribute` t1 + WHERE + 1 = 1 + AND ( + {attribute_query} + ) + AND EXISTS ( + SELECT + 1 + FROM + `tabItem` t2 + WHERE + t2.name = t1.parent + {variant_of_query} + ) + GROUP BY + t1.parent + ORDER BY + NULL + '''.format(attribute_query=attribute_query, variant_of_query=variant_of_query) + + item_codes = set([r[0] for r in frappe.db.sql(query, query_values)]) + items.append(item_codes) + + res = list(set.intersection(*items)) + + return res + +@frappe.whitelist(allow_guest=True) +def get_attributes_and_values(item_code): + '''Build a list of attributes and their possible values. + This will ignore the values upon selection of which there cannot exist one item. + ''' + item_cache = ItemVariantsCacheManager(item_code) + item_variants_data = item_cache.get_item_variants_data() + + attributes = get_item_attributes(item_code) + attribute_list = [a.attribute for a in attributes] + + valid_options = {} + for item_code, attribute, attribute_value in item_variants_data: + if attribute in attribute_list: + valid_options.setdefault(attribute, set()).add(attribute_value) + + item_attribute_values = frappe.db.get_all('Item Attribute Value', + ['parent', 'attribute_value', 'idx'], order_by='parent asc, idx asc') + ordered_attribute_value_map = frappe._dict() + for iv in item_attribute_values: + ordered_attribute_value_map.setdefault(iv.parent, []).append(iv.attribute_value) + + # build attribute values in idx order + for attr in attributes: + valid_attribute_values = valid_options.get(attr.attribute, []) + ordered_values = ordered_attribute_value_map.get(attr.attribute, []) + attr['values'] = [v for v in ordered_values if v in valid_attribute_values] + + return attributes + + +@frappe.whitelist(allow_guest=True) +def get_next_attribute_and_values(item_code, selected_attributes): + '''Find the count of Items that match the selected attributes. + Also, find the attribute values that are not applicable for further searching. + If less than equal to 10 items are found, return item_codes of those items. + If one item is matched exactly, return item_code of that item. + ''' + selected_attributes = frappe.parse_json(selected_attributes) + + item_cache = ItemVariantsCacheManager(item_code) + item_variants_data = item_cache.get_item_variants_data() + + attributes = get_item_attributes(item_code) + attribute_list = [a.attribute for a in attributes] + filtered_items = get_items_with_selected_attributes(item_code, selected_attributes) + + next_attribute = None + + for attribute in attribute_list: + if attribute not in selected_attributes: + next_attribute = attribute + break + + valid_options_for_attributes = frappe._dict() + + for a in attribute_list: + valid_options_for_attributes[a] = set() + + selected_attribute = selected_attributes.get(a, None) + if selected_attribute: + # already selected attribute values are valid options + valid_options_for_attributes[a].add(selected_attribute) + + for row in item_variants_data: + item_code, attribute, attribute_value = row + if item_code in filtered_items and attribute not in selected_attributes and attribute in attribute_list: + valid_options_for_attributes[attribute].add(attribute_value) + + optional_attributes = item_cache.get_optional_attributes() + exact_match = [] + # search for exact match if all selected attributes are required attributes + if len(selected_attributes.keys()) >= (len(attribute_list) - len(optional_attributes)): + item_attribute_value_map = item_cache.get_item_attribute_value_map() + for item_code, attr_dict in item_attribute_value_map.items(): + if item_code in filtered_items and set(attr_dict.keys()) == set(selected_attributes.keys()): + exact_match.append(item_code) + + filtered_items_count = len(filtered_items) + + # get product info if exact match + from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website + if exact_match: + data = get_product_info_for_website(exact_match[0]) + product_info = data.product_info + if product_info: + product_info["allow_items_not_in_stock"] = cint(data.cart_settings.allow_items_not_in_stock) + if not data.cart_settings.show_price: + product_info = None + else: + product_info = None + + return { + 'next_attribute': next_attribute, + 'valid_options_for_attributes': valid_options_for_attributes, + 'filtered_items_count': filtered_items_count, + 'filtered_items': filtered_items if filtered_items_count < 10 else [], + 'exact_match': exact_match, + 'product_info': product_info + } + + +def get_items_with_selected_attributes(item_code, selected_attributes): + item_cache = ItemVariantsCacheManager(item_code) + attribute_value_item_map = item_cache.get_attribute_value_item_map() + + items = [] + for attribute, value in selected_attributes.items(): + filtered_items = attribute_value_item_map.get((attribute, value), []) + items.append(set(filtered_items)) + + return set.intersection(*items) + +# utilities + +def get_item_attributes(item_code): + attributes = frappe.db.get_all('Item Variant Attribute', + fields=['attribute'], + filters={ + 'parenttype': 'Item', + 'parent': item_code + }, + order_by='idx asc' + ) + + optional_attributes = ItemVariantsCacheManager(item_code).get_optional_attributes() + + for a in attributes: + if a.attribute in optional_attributes: + a.optional = True + + return attributes + diff --git a/erpnext/e_commerce/web_template/__init__.py b/erpnext/e_commerce/web_template/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/web_template/hero_slider/__init__.py b/erpnext/e_commerce/web_template/hero_slider/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html b/erpnext/e_commerce/web_template/hero_slider/hero_slider.html similarity index 100% rename from erpnext/shopping_cart/web_template/hero_slider/hero_slider.html rename to erpnext/e_commerce/web_template/hero_slider/hero_slider.html diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json b/erpnext/e_commerce/web_template/hero_slider/hero_slider.json similarity index 98% rename from erpnext/shopping_cart/web_template/hero_slider/hero_slider.json rename to erpnext/e_commerce/web_template/hero_slider/hero_slider.json index 04fb1d27059..2b1807c9651 100644 --- a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json +++ b/erpnext/e_commerce/web_template/hero_slider/hero_slider.json @@ -1,4 +1,5 @@ { + "__unsaved": 1, "creation": "2020-11-17 15:21:51.207221", "docstatus": 0, "doctype": "Web Template", @@ -273,9 +274,9 @@ } ], "idx": 2, - "modified": "2020-12-29 12:30:02.794994", + "modified": "2021-02-24 15:57:05.889709", "modified_by": "Administrator", - "module": "Shopping Cart", + "module": "E-commerce", "name": "Hero Slider", "owner": "Administrator", "standard": 1, diff --git a/erpnext/e_commerce/web_template/item_card_group/__init__.py b/erpnext/e_commerce/web_template/item_card_group/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html b/erpnext/e_commerce/web_template/item_card_group/item_card_group.html similarity index 86% rename from erpnext/shopping_cart/web_template/item_card_group/item_card_group.html rename to erpnext/e_commerce/web_template/item_card_group/item_card_group.html index fe061d5f5f5..33d7bccc23a 100644 --- a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html +++ b/erpnext/e_commerce/web_template/item_card_group/item_card_group.html @@ -25,9 +25,8 @@ {%- if item -%} {%- set item = frappe.get_doc("Item", item) -%} {{ item_card( - item.item_name, item.image, item.route, item.description, - None, item.item_group, values['card_' + index + '_featured'], - True, "Center" + item, is_featured=values['card_' + index + '_featured'], + is_full_width=True, align="Center" ) }} {%- endif -%} {%- endfor -%} diff --git a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json b/erpnext/e_commerce/web_template/item_card_group/item_card_group.json similarity index 97% rename from erpnext/shopping_cart/web_template/item_card_group/item_card_group.json rename to erpnext/e_commerce/web_template/item_card_group/item_card_group.json index ad087b04704..724c4379121 100644 --- a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json +++ b/erpnext/e_commerce/web_template/item_card_group/item_card_group.json @@ -17,15 +17,12 @@ "reqd": 0 }, { - "__unsaved": 1, "fieldname": "primary_action_label", "fieldtype": "Data", "label": "Primary Action Label", "reqd": 0 }, { - "__islocal": 1, - "__unsaved": 1, "fieldname": "primary_action", "fieldtype": "Data", "label": "Primary Action", @@ -262,9 +259,9 @@ } ], "idx": 0, - "modified": "2020-11-19 18:48:52.633045", + "modified": "2021-02-24 16:05:31.242342", "modified_by": "Administrator", - "module": "Shopping Cart", + "module": "E-commerce", "name": "Item Card Group", "owner": "Administrator", "standard": 1, diff --git a/erpnext/e_commerce/web_template/product_card/__init__.py b/erpnext/e_commerce/web_template/product_card/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/web_template/product_card/product_card.html b/erpnext/e_commerce/web_template/product_card/product_card.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/product_card/product_card.json b/erpnext/e_commerce/web_template/product_card/product_card.json similarity index 82% rename from erpnext/shopping_cart/web_template/product_card/product_card.json rename to erpnext/e_commerce/web_template/product_card/product_card.json index 1059c1b2519..2eb73741efb 100644 --- a/erpnext/shopping_cart/web_template/product_card/product_card.json +++ b/erpnext/e_commerce/web_template/product_card/product_card.json @@ -5,7 +5,6 @@ "doctype": "Web Template", "fields": [ { - "__unsaved": 1, "fieldname": "item", "fieldtype": "Link", "label": "Item", @@ -13,7 +12,6 @@ "reqd": 0 }, { - "__unsaved": 1, "fieldname": "featured", "fieldtype": "Check", "label": "Featured", @@ -22,9 +20,9 @@ } ], "idx": 0, - "modified": "2020-11-17 15:33:34.982515", + "modified": "2021-02-24 16:05:17.926610", "modified_by": "Administrator", - "module": "Shopping Cart", + "module": "E-commerce", "name": "Product Card", "owner": "Administrator", "standard": 1, diff --git a/erpnext/e_commerce/web_template/product_category_cards/__init__.py b/erpnext/e_commerce/web_template/product_category_cards/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html similarity index 81% rename from erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html rename to erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html index 06b76af9018..6d75a8b1d5e 100644 --- a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html +++ b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html @@ -6,8 +6,15 @@ }) -%}
{% if image %} - {{ title }} + {{ title }} + {% else %} +
+ + {{ frappe.utils.get_abbr(title or '') }} + +
{% endif %} +
{{ title or '' }}
diff --git a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json similarity index 95% rename from erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json rename to erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json index ba5f63b48b2..0202165d08e 100644 --- a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json +++ b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json @@ -74,9 +74,9 @@ } ], "idx": 0, - "modified": "2020-11-18 17:26:28.726260", + "modified": "2021-02-24 16:03:33.835635", "modified_by": "Administrator", - "module": "Shopping Cart", + "module": "E-commerce", "name": "Product Category Cards", "owner": "Administrator", "standard": 1, diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py index 148c1a6a166..cf44fc472ad 100644 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py +++ b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py @@ -143,7 +143,6 @@ def create_item_code(amazon_item_json, sku): item.description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title item.brand = new_brand item.manufacturer = new_manufacturer - item.web_long_description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title item.image = amazon_item_json.Product.AttributeSets.ItemAttributes.SmallImage.URL diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 370fdcd5c5e..9b9cb439fea 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -51,15 +51,15 @@ additional_print_settings = "erpnext.controllers.print_settings.get_print_settin on_session_creation = [ "erpnext.portal.utils.create_customer_or_supplier", - "erpnext.shopping_cart.utils.set_cart_count" + "erpnext.e_commerce.shopping_cart.utils.set_cart_count" ] -on_logout = "erpnext.shopping_cart.utils.clear_cart_count" +on_logout = "erpnext.e_commerce.shopping_cart.utils.clear_cart_count" treeviews = ['Account', 'Cost Center', 'Warehouse', 'Item Group', 'Customer Group', 'Sales Person', 'Territory', 'Assessment Group', 'Department'] # website -update_website_context = ["erpnext.shopping_cart.utils.update_website_context", "erpnext.education.doctype.education_settings.education_settings.update_website_context"] -my_account_context = "erpnext.shopping_cart.utils.update_my_account_context" +update_website_context = ["erpnext.e_commerce.shopping_cart.utils.update_website_context", "erpnext.education.doctype.education_settings.education_settings.update_website_context"] +my_account_context = "erpnext.e_commerce.shopping_cart.utils.update_my_account_context" calendars = ["Task", "Work Order", "Leave Application", "Sales Order", "Holiday List", "Course Schedule"] @@ -75,7 +75,7 @@ domains = { 'Services': 'erpnext.domains.services', } -website_generators = ["Item Group", "Item", "BOM", "Sales Partner", +website_generators = ["Item Group", "Website Item", "BOM", "Sales Partner", "Job Opening", "Student Admission"] website_context = { @@ -249,10 +249,7 @@ doc_events = { ] }, "Sales Taxes and Charges Template": { - "on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings" - }, - "Website Settings": { - "validate": "erpnext.portal.doctype.products_settings.products_settings.home_page_is_products" + "on_update": "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.validate_cart_settings" }, "Tax Category": { "validate": "erpnext.regional.india.utils.validate_tax_category" diff --git a/erpnext/hub_node/doctype/marketplace_settings/marketplace_settings.py b/erpnext/hub_node/doctype/marketplace_settings/marketplace_settings.py index 91c7bf5850a..64bc49a7e9c 100644 --- a/erpnext/hub_node/doctype/marketplace_settings/marketplace_settings.py +++ b/erpnext/hub_node/doctype/marketplace_settings/marketplace_settings.py @@ -8,7 +8,6 @@ from frappe.model.document import Document from frappe.utils import add_years, now, get_datetime, get_datetime_str, cint from frappe import _ from frappe.frappeclient import FrappeClient -from erpnext.utilities.product import get_price, get_qty_in_stock from six import string_types class MarketplaceSettings(Document): diff --git a/erpnext/modules.txt b/erpnext/modules.txt index 62f5dce8460..3ffa5f292cc 100644 --- a/erpnext/modules.txt +++ b/erpnext/modules.txt @@ -9,7 +9,6 @@ Manufacturing Stock Support Utilities -Shopping Cart Assets Portal Maintenance @@ -26,4 +25,5 @@ Quality Management Communication Loan Management Payroll -Telephony \ No newline at end of file +Telephony +E-commerce \ No newline at end of file diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 4c361f634ca..e33c1c8f137 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -308,3 +308,6 @@ erpnext.patches.v13_0.migrate_stripe_api erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries erpnext.patches.v13_0.set_operation_time_based_on_operating_cost erpnext.patches.v13_0.validate_options_for_data_field +erpnext.patches.v13_0.create_website_items +erpnext.patches.v13_0.populate_e_commerce_settings +erpnext.patches.v13_0.make_homepage_products_website_items diff --git a/erpnext/patches/v13_0/create_website_items.py b/erpnext/patches/v13_0/create_website_items.py new file mode 100644 index 00000000000..79c1418190f --- /dev/null +++ b/erpnext/patches/v13_0/create_website_items.py @@ -0,0 +1,70 @@ +import frappe +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + +def execute(): + frappe.reload_doc("e_commerce", "doctype", "website_item") + frappe.reload_doc("e_commerce", "doctype", "website_item_tabbed_section") + frappe.reload_doc("e_commerce", "doctype", "website_offer") + frappe.reload_doc("stock", "doctype", "item") + + item_fields = ["item_code", "item_name", "item_group", "stock_uom", "brand", "image", + "has_variants", "variant_of", "description", "weightage"] + web_fields_to_map = ["route", "slideshow", "website_image_alt", + "website_warehouse", "web_long_description", "website_content"] + + item_table_fields = frappe.db.sql("desc `tabItem`", as_dict=1) + item_table_fields = [d.get('Field') for d in item_table_fields] + + # prepare fields to query from Item, check if the web field exists in Item master + web_query_fields = [] + for web_field in web_fields_to_map: + if web_field in item_table_fields: + web_query_fields.append(web_field) + item_fields.append(web_field) + + # check if the filter fields exist in Item master + or_filters = {} + for field in ["show_in_website", "show_variant_in_website"]: + if field in item_table_fields: + or_filters[field] = 1 + + if not web_query_fields or not or_filters: + # web fields to map are not present in Item master schema + # most likely a fresh installation that doesnt need this patch + return + + items = frappe.db.get_all( + "Item", + fields=item_fields, + or_filters=or_filters + ) + + count = 0 + for item in items: + if frappe.db.exists("Website Item", {"item_code": item.item_code}): + continue + + # make website item from item (publish item) + website_item = make_website_item(item, save=False) + website_item.ranking = item.get("weightage") + for field in web_fields_to_map: + website_item.update({field: item.get(field)}) + website_item.save() + + # move Website Item Group & Website Specification table to Website Item + for doctype in ("Website Item Group", "Item Website Specification"): + web_item, item_code = website_item.name, item.item_code + frappe.db.sql(f""" + Update + `tab{doctype}` + set + parenttype = 'Website Item', + parent = '{web_item}' + where + parenttype = 'Item' + and parent = '{item_code}' + """) + + count += 1 + if count % 20 == 0: # commit after every 20 items + frappe.db.commit() \ No newline at end of file diff --git a/erpnext/patches/v13_0/make_homepage_products_website_items.py b/erpnext/patches/v13_0/make_homepage_products_website_items.py new file mode 100644 index 00000000000..8be5875b78d --- /dev/null +++ b/erpnext/patches/v13_0/make_homepage_products_website_items.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + homepage = frappe.get_doc("Homepage") + + for row in homepage.products: + web_item = frappe.db.get_value("Website Item", {"item_code": row.item_code}, "name") + if not web_item: + continue + + row.item_code = web_item + + homepage.flags.ignore_mandatory = True + homepage.save() \ No newline at end of file diff --git a/erpnext/patches/v13_0/populate_e_commerce_settings.py b/erpnext/patches/v13_0/populate_e_commerce_settings.py new file mode 100644 index 00000000000..1565ac2033e --- /dev/null +++ b/erpnext/patches/v13_0/populate_e_commerce_settings.py @@ -0,0 +1,60 @@ +from __future__ import unicode_literals +import frappe +from frappe.utils import cint + +def execute(): + frappe.reload_doc("e_commerce", "doctype", "e_commerce_settings") + frappe.reload_doc("portal", "doctype", "website_filter_field") + frappe.reload_doc("portal", "doctype", "website_attribute") + + products_settings_fields = [ + "hide_variants", "products_per_page", + "enable_attribute_filters", "enable_field_filters" + ] + + shopping_cart_settings_fields = [ + "enabled", "show_attachments", "show_price", + "show_stock_availability", "enable_variants", "show_contact_us_button", + "show_quantity_in_website", "show_apply_coupon_code_in_website", + "allow_items_not_in_stock", "company", "price_list", "default_customer_group", + "quotation_series", "enable_checkout", "payment_success_url", + "payment_gateway_account", "save_quotations_as_draft" + ] + + settings = frappe.get_doc("E Commerce Settings") + + def map_into_e_commerce_settings(doctype, fields): + data = frappe.db.sql(""" + Select + field, value + from `tabSingles` + where + doctype='{doctype}' + and field in ({fields}) + """.format( + doctype=doctype, + fields=(",").join(['%s'] * len(fields)) + ), tuple(fields), as_dict=1) + + # {'enable_attribute_filters': '1', ...} + mapper = {row.field: row.value for row in data} + + for key, value in mapper.items(): + value = cint(value) if (value and value.isdigit()) else value + settings.update({key: value}) + + settings.save() + + # shift data to E Commerce Settings + map_into_e_commerce_settings("Products Settings", products_settings_fields) + map_into_e_commerce_settings("Shopping Cart Settings", shopping_cart_settings_fields) + + # move filters and attributes tables to E Commerce Settings from Products Settings + for doctype in ("Website Filter Field", "Website Attribute"): + frappe.db.sql("""Update `tab{doctype}` + set + parenttype = 'E Commerce Settings', + parent = 'E Commerce Settings' + where + parent = 'Products Settings' + """.format(doctype=doctype)) \ No newline at end of file diff --git a/erpnext/portal/doctype/homepage/homepage.js b/erpnext/portal/doctype/homepage/homepage.js index c7c66e00556..59f808a3158 100644 --- a/erpnext/portal/doctype/homepage/homepage.js +++ b/erpnext/portal/doctype/homepage/homepage.js @@ -3,9 +3,9 @@ frappe.ui.form.on('Homepage', { setup: function(frm) { - frm.fields_dict["products"].grid.get_field("item_code").get_query = function(){ + frm.fields_dict["products"].grid.get_field("item").get_query = function() { return { - filters: {'show_in_website': 1} + filters: {'published': 1} } } }, @@ -21,11 +21,10 @@ frappe.ui.form.on('Homepage', { }); frappe.ui.form.on('Homepage Featured Product', { - - view: function(frm, cdt, cdn){ - var child= locals[cdt][cdn] - if(child.item_code && frm.doc.products_url){ - window.location.href = frm.doc.products_url + '/' + encodeURIComponent(child.item_code); + view: function(frm, cdt, cdn) { + var child= locals[cdt][cdn]; + if (child.item_code && child.route) { + window.open('/' + child.route, '_blank'); } } }); diff --git a/erpnext/portal/doctype/homepage/homepage.json b/erpnext/portal/doctype/homepage/homepage.json index ad27278dc69..73f816d4d49 100644 --- a/erpnext/portal/doctype/homepage/homepage.json +++ b/erpnext/portal/doctype/homepage/homepage.json @@ -1,518 +1,143 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "", + "actions": [], "beta": 1, "creation": "2016-04-22 05:27:52.109319", - "custom": 0, - "docstatus": 0, "doctype": "DocType", "document_type": "Setup", - "editable_grid": 0, "engine": "InnoDB", + "field_order": [ + "company", + "hero_section_based_on", + "column_break_2", + "title", + "section_break_4", + "tag_line", + "description", + "hero_image", + "slideshow", + "hero_section", + "products_section", + "products_url", + "products" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "company", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Company", - "length": 0, - "no_copy": 0, "options": "Company", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "hero_section_based_on", "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Hero Section Based On", - "length": 0, - "no_copy": 0, - "options": "Default\nSlideshow\nHomepage Section", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "Default\nSlideshow\nHomepage Section" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_2", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", "fieldname": "title", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Title", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Title" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", "fieldname": "section_break_4", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Hero Section", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Hero Section" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Default'", "description": "Company Tagline for website homepage", "fieldname": "tag_line", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Tag Line", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Default'", "description": "Company Description for website homepage", "fieldname": "description", "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Description", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Default'", "fieldname": "hero_image", "fieldtype": "Attach Image", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Hero Image", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Hero Image" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Slideshow'", - "description": "", "fieldname": "slideshow", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Homepage Slideshow", - "length": 0, - "no_copy": 0, - "options": "Website Slideshow", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "Website Slideshow" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Homepage Section'", "fieldname": "hero_section", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Homepage Section", - "length": 0, - "no_copy": 0, - "options": "Homepage Section", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "Homepage Section" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", "fieldname": "products_section", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Products", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Products" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "/products", + "default": "/all-products", "fieldname": "products_url", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "URL for \"All Products\"", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "URL for \"All Products\"" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "description": "Products to be shown on website homepage", "fieldname": "products", "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Products", - "length": 0, - "no_copy": 0, "options": "Homepage Featured Product", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "40px" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2019-03-02 23:12:59.676202", + "links": [], + "modified": "2021-02-18 13:29:29.531639", "modified_by": "Administrator", "module": "Portal", "name": "Homepage", - "name_case": "", "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, - "report": 0, "role": "System Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 }, { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, - "report": 0, "role": "Administrator", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 } ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, "sort_field": "modified", "sort_order": "DESC", "title_field": "company", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/portal/doctype/homepage/homepage.py b/erpnext/portal/doctype/homepage/homepage.py index 54ea7c62df4..8c646abd04e 100644 --- a/erpnext/portal/doctype/homepage/homepage.py +++ b/erpnext/portal/doctype/homepage/homepage.py @@ -14,12 +14,14 @@ class Homepage(Document): delete_page_cache('home') def setup_items(self): - for d in frappe.get_all('Item', fields=['name', 'item_name', 'description', 'image'], - filters={'show_in_website': 1}, limit=3): + for d in frappe.get_all('Website Item', fields=['name', 'item_name', 'description', 'image', 'route'], + filters={'published': 1}, limit=3): - doc = frappe.get_doc('Item', d.name) + doc = frappe.get_doc('Website Item', d.name) if not doc.route: # set missing route doc.save() self.append('products', dict(item_code=d.name, - item_name=d.item_name, description=d.description, image=d.image)) + item_name=d.item_name, description=d.description, + image=d.image, route=d.route)) + diff --git a/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json b/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json index 01c32efec9d..63789e35b56 100644 --- a/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json +++ b/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json @@ -25,10 +25,10 @@ "fieldtype": "Link", "in_filter": 1, "in_list_view": 1, - "label": "Item Code", + "label": "Item", "oldfieldname": "item_code", "oldfieldtype": "Link", - "options": "Item", + "options": "Website Item", "print_width": "150px", "reqd": 1, "search_index": 1, @@ -63,7 +63,7 @@ "collapsible": 1, "fieldname": "section_break_5", "fieldtype": "Section Break", - "label": "Description" + "label": "Details" }, { "fetch_from": "item_code.web_long_description", @@ -89,12 +89,14 @@ "label": "Image" }, { + "fetch_from": "item_code.thumbnail", "fieldname": "thumbnail", "fieldtype": "Attach Image", "hidden": 1, "label": "Thumbnail" }, { + "fetch_from": "item_code.route", "fieldname": "route", "fieldtype": "Small Text", "label": "route", @@ -104,7 +106,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-25 15:27:49.573537", + "modified": "2021-02-18 13:05:50.669311", "modified_by": "Administrator", "module": "Portal", "name": "Homepage Featured Product", diff --git a/erpnext/portal/doctype/products_settings/products_settings.js b/erpnext/portal/doctype/products_settings/products_settings.js deleted file mode 100644 index 2f8b0371648..00000000000 --- a/erpnext/portal/doctype/products_settings/products_settings.js +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Products Settings', { - refresh: function(frm) { - frappe.model.with_doctype('Item', () => { - const item_meta = frappe.get_meta('Item'); - - const valid_fields = item_meta.fields.filter( - df => ['Link', 'Table MultiSelect'].includes(df.fieldtype) && !df.hidden - ).map(df => ({ label: df.label, value: df.fieldname })); - - frm.fields_dict.filter_fields.grid.update_docfield_property( - 'fieldname', 'fieldtype', 'Select' - ); - frm.fields_dict.filter_fields.grid.update_docfield_property( - 'fieldname', 'options', valid_fields - ); - }); - } -}); diff --git a/erpnext/portal/doctype/products_settings/products_settings.json b/erpnext/portal/doctype/products_settings/products_settings.json deleted file mode 100644 index 2cf8431497c..00000000000 --- a/erpnext/portal/doctype/products_settings/products_settings.json +++ /dev/null @@ -1,389 +0,0 @@ -{ - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-04-22 09:11:55.272398", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 0, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "If checked, the Home page will be the default Item Group for the website", - "fieldname": "home_page_is_products", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Home Page is Products", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "show_availability_status", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Show Availability Status", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_5", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Product Page", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "6", - "fieldname": "products_per_page", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Products per Page", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "enable_field_filters", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Enable Field Filters", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "enable_field_filters", - "fieldname": "filter_fields", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Item Fields", - "length": 0, - "no_copy": 0, - "options": "Website Filter Field", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "enable_attribute_filters", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Enable Attribute Filters", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "enable_attribute_filters", - "fieldname": "filter_attributes", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Attributes", - "length": 0, - "no_copy": 0, - "options": "Website Attribute", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "hide_variants", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Hide Variants", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2019-03-07 19:18:31.822309", - "modified_by": "Administrator", - "module": "Portal", - "name": "Products Settings", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 0, - "role": "Website Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 -} \ No newline at end of file diff --git a/erpnext/portal/doctype/products_settings/products_settings.py b/erpnext/portal/doctype/products_settings/products_settings.py deleted file mode 100644 index 9a708924ae0..00000000000 --- a/erpnext/portal/doctype/products_settings/products_settings.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe.utils import cint -from frappe import _ -from frappe.model.document import Document - -class ProductsSettings(Document): - def validate(self): - if self.home_page_is_products: - frappe.db.set_value("Website Settings", None, "home_page", "products") - elif frappe.db.get_single_value("Website Settings", "home_page") == 'products': - frappe.db.set_value("Website Settings", None, "home_page", "home") - - self.validate_field_filters() - self.validate_attribute_filters() - frappe.clear_document_cache("Product Settings", "Product Settings") - - def validate_field_filters(self): - if not (self.enable_field_filters and self.filter_fields): return - - item_meta = frappe.get_meta('Item') - valid_fields = [df.fieldname for df in item_meta.fields if df.fieldtype in ['Link', 'Table MultiSelect']] - - for f in self.filter_fields: - if f.fieldname not in valid_fields: - frappe.throw(_('Filter Fields Row #{0}: Fieldname {1} must be of type "Link" or "Table MultiSelect"').format(f.idx, f.fieldname)) - - def validate_attribute_filters(self): - if not (self.enable_attribute_filters and self.filter_attributes): return - - # if attribute filters are enabled, hide_variants should be disabled - self.hide_variants = 0 - - -def home_page_is_products(doc, method): - '''Called on saving Website Settings''' - home_page_is_products = cint(frappe.db.get_single_value('Products Settings', 'home_page_is_products')) - if home_page_is_products: - doc.home_page = 'products' diff --git a/erpnext/portal/doctype/products_settings/test_products_settings.py b/erpnext/portal/doctype/products_settings/test_products_settings.py deleted file mode 100644 index d04a0098829..00000000000 --- a/erpnext/portal/doctype/products_settings/test_products_settings.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt -from __future__ import unicode_literals - -import frappe -import unittest - -class TestProductsSettings(unittest.TestCase): - pass diff --git a/erpnext/portal/doctype/website_attribute/website_attribute.json b/erpnext/portal/doctype/website_attribute/website_attribute.json index 2874dc432c7..eed33ec10e4 100644 --- a/erpnext/portal/doctype/website_attribute/website_attribute.json +++ b/erpnext/portal/doctype/website_attribute/website_attribute.json @@ -1,76 +1,32 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2019-01-01 13:04:54.479079", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2019-01-01 13:04:54.479079", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "attribute" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "attribute", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Attribute", - "length": 0, - "no_copy": 0, - "options": "Item Attribute", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "attribute", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Attribute", + "options": "Item Attribute", + "reqd": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2019-01-01 13:04:59.715572", - "modified_by": "Administrator", - "module": "Portal", - "name": "Website Attribute", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "istable": 1, + "links": [], + "modified": "2021-02-18 13:18:57.810536", + "modified_by": "Administrator", + "module": "Portal", + "name": "Website Attribute", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/portal/product_configurator/test_product_configurator.py b/erpnext/portal/product_configurator/test_product_configurator.py deleted file mode 100644 index 28e064218b1..00000000000 --- a/erpnext/portal/product_configurator/test_product_configurator.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import unicode_literals - -from bs4 import BeautifulSoup -import frappe, unittest -from frappe.utils import set_request, get_html_for_route -from frappe.website.render import render -from erpnext.portal.product_configurator.utils import get_products_for_website -from erpnext.stock.doctype.item.test_item import make_item_variant - -test_dependencies = ["Item"] - -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 - - 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() - items = engine.query({}, {"item_group": "Tech Items"}, None, start=0, item_group="Tech 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() - items = engine.query({}, {"item_group": "_Test Item Group Desktops"}, None, start=0, item_group="_Test Item Group Desktops") - 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() diff --git a/erpnext/portal/product_configurator/utils.py b/erpnext/portal/product_configurator/utils.py deleted file mode 100644 index d60b1a2b05e..00000000000 --- a/erpnext/portal/product_configurator/utils.py +++ /dev/null @@ -1,444 +0,0 @@ -import frappe -from frappe.utils import cint -from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager -from erpnext.shopping_cart.product_info import get_product_info_for_website -from erpnext.setup.doctype.item_group.item_group import get_child_groups - -def get_field_filter_data(): - product_settings = get_product_settings() - filter_fields = [row.fieldname for row in product_settings.filter_fields] - - meta = frappe.get_meta('Item') - fields = [df for df in meta.fields if df.fieldname in filter_fields] - - filter_data = [] - for f in fields: - doctype = f.get_link_doctype() - - # apply enable/disable/show_in_website filter - meta = frappe.get_meta(doctype) - 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 - - values = [d.name for d in frappe.get_all(doctype, filters)] - filter_data.append([f, values]) - - return filter_data - - -def get_attribute_filter_data(): - product_settings = get_product_settings() - attributes = [row.attribute for row in product_settings.filter_attributes] - attribute_docs = [ - frappe.get_doc('Item Attribute', attribute) for attribute in attributes - ] - - # mark attribute values as checked if they are present in the request url - if frappe.form_dict: - for attr in attribute_docs: - if attr.name in frappe.form_dict: - value = frappe.form_dict[attr.name] - if value: - enabled_values = value.split(',') - else: - enabled_values = [] - - for v in enabled_values: - for item_attribute_row in attr.item_attribute_values: - if v == item_attribute_row.attribute_value: - item_attribute_row.checked = True - - return attribute_docs - - -def get_products_for_website(field_filters=None, attribute_filters=None, search=None): - if attribute_filters: - item_codes = get_item_codes_by_attributes(attribute_filters) - items_by_attributes = get_items([['name', 'in', item_codes]]) - - if field_filters: - items_by_fields = get_items_by_fields(field_filters) - - if attribute_filters and not field_filters: - return items_by_attributes - - if field_filters and not attribute_filters: - return items_by_fields - - if field_filters and attribute_filters: - items_intersection = [] - item_codes_in_attribute = [item.name for item in items_by_attributes] - - for item in items_by_fields: - if item.name in item_codes_in_attribute: - items_intersection.append(item) - - return items_intersection - - if search: - return get_items(search=search) - - return get_items() - - -@frappe.whitelist(allow_guest=True) -def get_products_html_for_website(field_filters=None, attribute_filters=None): - field_filters = frappe.parse_json(field_filters) - attribute_filters = frappe.parse_json(attribute_filters) - set_item_group_filters(field_filters) - - items = get_products_for_website(field_filters, attribute_filters) - html = ''.join(get_html_for_items(items)) - - if not items: - html = frappe.render_template('erpnext/www/all-products/not_found.html', {}) - - return html - -def set_item_group_filters(field_filters): - if field_filters is not None and 'item_group' in field_filters: - field_filters['item_group'] = [ig[0] for ig in get_child_groups(field_filters['item_group'])] - - -def get_item_codes_by_attributes(attribute_filters, template_item_code=None): - items = [] - - for attribute, values in attribute_filters.items(): - attribute_values = values - - if not isinstance(attribute_values, list): - attribute_values = [attribute_values] - - if not attribute_values: continue - - wheres = [] - query_values = [] - for attribute_value in attribute_values: - wheres.append('( attribute = %s and attribute_value = %s )') - query_values += [attribute, attribute_value] - - attribute_query = ' or '.join(wheres) - - if template_item_code: - variant_of_query = 'AND t2.variant_of = %s' - query_values.append(template_item_code) - else: - variant_of_query = '' - - query = ''' - SELECT - t1.parent - FROM - `tabItem Variant Attribute` t1 - WHERE - 1 = 1 - AND ( - {attribute_query} - ) - AND EXISTS ( - SELECT - 1 - FROM - `tabItem` t2 - WHERE - t2.name = t1.parent - {variant_of_query} - ) - GROUP BY - t1.parent - ORDER BY - NULL - '''.format(attribute_query=attribute_query, variant_of_query=variant_of_query) - - item_codes = set([r[0] for r in frappe.db.sql(query, query_values)]) - items.append(item_codes) - - res = list(set.intersection(*items)) - - return res - - -@frappe.whitelist(allow_guest=True) -def get_attributes_and_values(item_code): - '''Build a list of attributes and their possible values. - This will ignore the values upon selection of which there cannot exist one item. - ''' - item_cache = ItemVariantsCacheManager(item_code) - item_variants_data = item_cache.get_item_variants_data() - - attributes = get_item_attributes(item_code) - attribute_list = [a.attribute for a in attributes] - - valid_options = {} - for item_code, attribute, attribute_value in item_variants_data: - if attribute in attribute_list: - valid_options.setdefault(attribute, set()).add(attribute_value) - - item_attribute_values = frappe.db.get_all('Item Attribute Value', - ['parent', 'attribute_value', 'idx'], order_by='parent asc, idx asc') - ordered_attribute_value_map = frappe._dict() - for iv in item_attribute_values: - ordered_attribute_value_map.setdefault(iv.parent, []).append(iv.attribute_value) - - # build attribute values in idx order - for attr in attributes: - valid_attribute_values = valid_options.get(attr.attribute, []) - ordered_values = ordered_attribute_value_map.get(attr.attribute, []) - attr['values'] = [v for v in ordered_values if v in valid_attribute_values] - - return attributes - - -@frappe.whitelist(allow_guest=True) -def get_next_attribute_and_values(item_code, selected_attributes): - '''Find the count of Items that match the selected attributes. - Also, find the attribute values that are not applicable for further searching. - If less than equal to 10 items are found, return item_codes of those items. - If one item is matched exactly, return item_code of that item. - ''' - selected_attributes = frappe.parse_json(selected_attributes) - - item_cache = ItemVariantsCacheManager(item_code) - item_variants_data = item_cache.get_item_variants_data() - - attributes = get_item_attributes(item_code) - attribute_list = [a.attribute for a in attributes] - filtered_items = get_items_with_selected_attributes(item_code, selected_attributes) - - next_attribute = None - - for attribute in attribute_list: - if attribute not in selected_attributes: - next_attribute = attribute - break - - valid_options_for_attributes = frappe._dict({}) - - for a in attribute_list: - valid_options_for_attributes[a] = set() - - selected_attribute = selected_attributes.get(a, None) - if selected_attribute: - # already selected attribute values are valid options - valid_options_for_attributes[a].add(selected_attribute) - - for row in item_variants_data: - item_code, attribute, attribute_value = row - if item_code in filtered_items and attribute not in selected_attributes and attribute in attribute_list: - valid_options_for_attributes[attribute].add(attribute_value) - - optional_attributes = item_cache.get_optional_attributes() - exact_match = [] - # search for exact match if all selected attributes are required attributes - if len(selected_attributes.keys()) >= (len(attribute_list) - len(optional_attributes)): - item_attribute_value_map = item_cache.get_item_attribute_value_map() - for item_code, attr_dict in item_attribute_value_map.items(): - if item_code in filtered_items and set(attr_dict.keys()) == set(selected_attributes.keys()): - exact_match.append(item_code) - - filtered_items_count = len(filtered_items) - - # get product info if exact match - from erpnext.shopping_cart.product_info import get_product_info_for_website - if exact_match: - data = get_product_info_for_website(exact_match[0]) - product_info = data.product_info - if product_info: - product_info["allow_items_not_in_stock"] = cint(data.cart_settings.allow_items_not_in_stock) - if not data.cart_settings.show_price: - product_info = None - else: - product_info = None - - return { - 'next_attribute': next_attribute, - 'valid_options_for_attributes': valid_options_for_attributes, - 'filtered_items_count': filtered_items_count, - 'filtered_items': filtered_items if filtered_items_count < 10 else [], - 'exact_match': exact_match, - 'product_info': product_info - } - - -def get_items_with_selected_attributes(item_code, selected_attributes): - item_cache = ItemVariantsCacheManager(item_code) - attribute_value_item_map = item_cache.get_attribute_value_item_map() - - items = [] - for attribute, value in selected_attributes.items(): - filtered_items = attribute_value_item_map.get((attribute, value), []) - items.append(set(filtered_items)) - - return set.intersection(*items) - - -def get_items_by_fields(field_filters): - meta = frappe.get_meta('Item') - filters = [] - for fieldname, values in field_filters.items(): - if not values: continue - - _doctype = 'Item' - _fieldname = fieldname - - df = meta.get_field(fieldname) - if df.fieldtype == 'Table MultiSelect': - child_doctype = df.options - child_meta = frappe.get_meta(child_doctype) - fields = child_meta.get("fields", { "fieldtype": "Link", "in_list_view": 1 }) - if fields: - _doctype = child_doctype - _fieldname = fields[0].fieldname - - if len(values) == 1: - filters.append([_doctype, _fieldname, '=', values[0]]) - else: - filters.append([_doctype, _fieldname, 'in', values]) - - return get_items(filters) - - -def get_items(filters=None, search=None): - start = frappe.form_dict.get('start', 0) - products_settings = get_product_settings() - page_length = products_settings.products_per_page - - filters = filters or [] - # convert to list of filters - if isinstance(filters, dict): - filters = [['Item', fieldname, '=', value] for fieldname, value in filters.items()] - - enabled_items_filter = get_conditions({ 'disabled': 0 }, 'and') - - show_in_website_condition = '' - if products_settings.hide_variants: - show_in_website_condition = get_conditions({'show_in_website': 1 }, 'and') - else: - show_in_website_condition = get_conditions([ - ['show_in_website', '=', 1], - ['show_variant_in_website', '=', 1] - ], 'or') - - search_condition = '' - if search: - # Default fields to search from - default_fields = {'name', 'item_name', 'description', 'item_group'} - - # Get meta search fields - meta = frappe.get_meta("Item") - meta_fields = set(meta.get_search_fields()) - - # Join the meta fields and default fields set - search_fields = default_fields.union(meta_fields) - try: - if frappe.db.count('Item', cache=True) > 50000: - search_fields.remove('description') - except KeyError: - pass - - # Build or filters for query - search = '%{}%'.format(search) - or_filters = [[field, 'like', search] for field in search_fields] - - search_condition = get_conditions(or_filters, 'or') - - filter_condition = get_conditions(filters, 'and') - - where_conditions = ' and '.join( - [condition for condition in [enabled_items_filter, show_in_website_condition, \ - search_condition, filter_condition] if condition] - ) - - left_joins = [] - for f in filters: - if len(f) == 4 and f[0] != 'Item': - left_joins.append(f[0]) - - left_join = ' '.join(['LEFT JOIN `tab{0}` on (`tab{0}`.parent = `tabItem`.name)'.format(l) for l in left_joins]) - - results = frappe.db.sql(''' - SELECT - `tabItem`.`name`, `tabItem`.`item_name`, `tabItem`.`item_code`, - `tabItem`.`website_image`, `tabItem`.`image`, - `tabItem`.`web_long_description`, `tabItem`.`description`, - `tabItem`.`route`, `tabItem`.`item_group` - FROM - `tabItem` - {left_join} - WHERE - {where_conditions} - GROUP BY - `tabItem`.`name` - ORDER BY - `tabItem`.`weightage` DESC - LIMIT - {page_length} - OFFSET - {start} - '''.format( - where_conditions=where_conditions, - start=start, - page_length=page_length, - left_join=left_join - ) - , as_dict=1) - - for r in results: - r.description = r.web_long_description or r.description - r.image = r.website_image or r.image - product_info = get_product_info_for_website(r.item_code, skip_quotation_creation=True).get('product_info') - if product_info: - r.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None - - return results - - -def get_conditions(filter_list, and_or='and'): - from frappe.model.db_query import DatabaseQuery - - if not filter_list: - return '' - - conditions = [] - DatabaseQuery('Item').build_filter_conditions(filter_list, conditions, ignore_permissions=True) - join_by = ' {0} '.format(and_or) - - return '(' + join_by.join(conditions) + ')' - -# utilities - -def get_item_attributes(item_code): - attributes = frappe.db.get_all('Item Variant Attribute', - fields=['attribute'], - filters={ - 'parenttype': 'Item', - 'parent': item_code - }, - order_by='idx asc' - ) - - optional_attributes = ItemVariantsCacheManager(item_code).get_optional_attributes() - - for a in attributes: - if a.attribute in optional_attributes: - a.optional = True - - return attributes - -def get_html_for_items(items): - html = [] - for item in items: - html.append(frappe.render_template('erpnext/www/all-products/item_row.html', { - 'item': item - })) - return html - -def get_product_settings(): - doc = frappe.get_cached_doc('Products Settings') - doc.products_per_page = doc.products_per_page or 20 - return doc diff --git a/erpnext/portal/utils.py b/erpnext/portal/utils.py index d6d44694203..c459f8b9d65 100644 --- a/erpnext/portal/utils.py +++ b/erpnext/portal/utils.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import frappe -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import get_shopping_cart_settings -from erpnext.shopping_cart.cart import get_debtors_account +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import get_shopping_cart_settings +from erpnext.e_commerce.shopping_cart.cart import get_debtors_account from frappe.utils.nestedset import get_root_of def set_default_role(doc, method): diff --git a/erpnext/public/build.json b/erpnext/public/build.json index 6b70dab8037..4cf9bb11eff 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -11,7 +11,8 @@ ], "js/erpnext-web.min.js": [ "public/js/website_utils.js", - "public/js/shopping_cart.js" + "public/js/shopping_cart.js", + "public/js/wishlist.js" ], "css/erpnext-web.css": [ "public/scss/website.scss", @@ -70,6 +71,12 @@ "public/js/bank_reconciliation_tool/number_card.js", "public/js/bank_reconciliation_tool/dialog_manager.js" ], + "js/e-commerce.min.js": [ + "e_commerce/product_ui/views.js", + "e_commerce/product_ui/grid.js", + "e_commerce/product_ui/list.js", + "e_commerce/product_ui/search.js" + ], "js/hierarchy-chart.min.js": [ "public/js/hierarchy_chart/hierarchy_chart_desktop.js", "public/js/hierarchy_chart/hierarchy_chart_mobile.js" diff --git a/erpnext/public/js/conf.js b/erpnext/public/js/conf.js index eb709e5e85e..a0f56a2d07f 100644 --- a/erpnext/public/js/conf.js +++ b/erpnext/public/js/conf.js @@ -21,6 +21,6 @@ $.extend(frappe.breadcrumbs.module_map, { 'Geo': 'Settings', 'Portal': 'Website', 'Utilities': 'Settings', - 'Shopping Cart': 'Website', + 'E-commerce': 'Website', 'Contacts': 'CRM' }); diff --git a/erpnext/public/js/shopping_cart.js b/erpnext/public/js/shopping_cart.js index 6a923ae4234..d99063b0454 100644 --- a/erpnext/public/js/shopping_cart.js +++ b/erpnext/public/js/shopping_cart.js @@ -2,8 +2,8 @@ // License: GNU General Public License v3. See license.txt // shopping cart -frappe.provide("erpnext.shopping_cart"); -var shopping_cart = erpnext.shopping_cart; +frappe.provide("erpnext.e_commerce.shopping_cart"); +var shopping_cart = erpnext.e_commerce.shopping_cart; var getParams = function (url) { var params = []; @@ -51,10 +51,10 @@ frappe.ready(function() { if (referral_sales_partner) { $(".txtreferral_sales_partner").val(referral_sales_partner); } + // update login shopping_cart.show_shoppingcart_dropdown(); shopping_cart.set_cart_count(); - shopping_cart.bind_dropdown_cart_buttons(); shopping_cart.show_cart_navbar(); }); @@ -63,7 +63,7 @@ $.extend(shopping_cart, { $(".shopping-cart").on('shown.bs.dropdown', function() { if (!$('.shopping-cart-menu .cart-container').length) { return frappe.call({ - method: 'erpnext.shopping_cart.cart.get_shopping_cart_menu', + method: 'erpnext.e_commerce.shopping_cart.cart.get_shopping_cart_menu', callback: function(r) { if (r.message) { $('.shopping-cart-menu').html(r.message); @@ -75,15 +75,18 @@ $.extend(shopping_cart, { }, update_cart: function(opts) { - if(frappe.session.user==="Guest") { - if(localStorage) { + if (frappe.session.user==="Guest") { + if (localStorage) { localStorage.setItem("last_visited", window.location.pathname); } - window.location.href = "/login"; + frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => { + window.location.href = res.message || "/login"; + }); } else { + shopping_cart.freeze(); return frappe.call({ type: "POST", - method: "erpnext.shopping_cart.cart.update_cart", + method: "erpnext.e_commerce.shopping_cart.cart.update_cart", args: { item_code: opts.item_code, qty: opts.qty, @@ -92,10 +95,8 @@ $.extend(shopping_cart, { }, btn: opts.btn, callback: function(r) { - shopping_cart.set_cart_count(); - if (r.message.shopping_cart_menu) { - $('.shopping-cart-menu').html(r.message.shopping_cart_menu); - } + shopping_cart.unfreeze(); + shopping_cart.set_cart_count(true); if(opts.callback) opts.callback(r); } @@ -103,7 +104,7 @@ $.extend(shopping_cart, { } }, - set_cart_count: function() { + set_cart_count: function(animate=false) { var cart_count = frappe.get_cookie("cart_count"); if(frappe.session.user==="Guest") { cart_count = 0; @@ -121,7 +122,7 @@ $.extend(shopping_cart, { $(".cart-items").html('Cart is Empty'); $(".cart-tax-items").hide(); $(".btn-place-order").hide(); - $(".cart-addresses").hide(); + $(".cart-payment-addresses").hide(); } else { $cart.css("display", "inline"); @@ -129,13 +130,19 @@ $.extend(shopping_cart, { if(cart_count) { $badge.html(cart_count); + + if (animate) { + $cart.addClass("cart-animate"); + setTimeout(() => { + $cart.removeClass("cart-animate"); + }, 500); + } } else { $badge.remove(); } }, shopping_cart_update: function({item_code, qty, cart_dropdown, additional_notes}) { - frappe.freeze(); shopping_cart.update_cart({ item_code, qty, @@ -143,7 +150,6 @@ $.extend(shopping_cart, { with_items: 1, btn: this, callback: function(r) { - frappe.unfreeze(); if(!r.exc) { $(".cart-items").html(r.message.items); $(".cart-tax-items").html(r.message.taxes); @@ -155,35 +161,71 @@ $.extend(shopping_cart, { }); }, - - bind_dropdown_cart_buttons: function () { - $(".cart-icon").on('click', '.number-spinner button', function () { - var btn = $(this), - input = btn.closest('.number-spinner').find('input'), - oldValue = input.val().trim(), - newVal = 0; - - if (btn.attr('data-dir') == 'up') { - newVal = parseInt(oldValue) + 1; - } else { - if (oldValue > 1) { - newVal = parseInt(oldValue) - 1; - } - } - input.val(newVal); - var item_code = input.attr("data-item-code"); - shopping_cart.shopping_cart_update({item_code, qty: newVal, cart_dropdown: true}); - return false; - }); - - }, - show_cart_navbar: function () { frappe.call({ - method: "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.is_cart_enabled", + method: "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.is_cart_enabled", callback: function(r) { $(".shopping-cart").toggleClass('hidden', r.message ? false : true); } }); + }, + + toggle_button_class(button, remove, add) { + button.removeClass(remove); + button.addClass(add); + }, + + bind_add_to_cart_action() { + $('.page_content').on('click', '.btn-add-to-cart-list', (e) => { + const $btn = $(e.currentTarget); + $btn.prop('disabled', true); + + if (frappe.session.user==="Guest") { + if (localStorage) { + localStorage.setItem("last_visited", window.location.pathname); + } + frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => { + window.location.href = res.message || "/login"; + }); + return; + } + + $btn.addClass('hidden'); + $btn.closest('.cart-action-container').addClass('d-flex'); + $btn.parent().find('.go-to-cart').removeClass('hidden'); + $btn.parent().find('.go-to-cart-grid').removeClass('hidden'); + $btn.parent().find('.cart-indicator').removeClass('hidden'); + + const item_code = $btn.data('item-code'); + erpnext.e_commerce.shopping_cart.update_cart({ + item_code, + qty: 1 + }); + + }); + }, + + freeze() { + if (window.location.pathname !== "/cart") return; + + if (!$('#freeze').length) { + let freeze = $('') + .appendTo("body"); + + setTimeout(function() { + freeze.addClass("show"); + }, 1); + } else { + $("#freeze").addClass("show"); + } + }, + + unfreeze() { + if ($('#freeze').length) { + let freeze = $('#freeze').removeClass("show"); + setTimeout(function() { + freeze.remove(); + }, 1); + } } }); diff --git a/erpnext/public/js/wishlist.js b/erpnext/public/js/wishlist.js new file mode 100644 index 00000000000..f6599e9f6d1 --- /dev/null +++ b/erpnext/public/js/wishlist.js @@ -0,0 +1,204 @@ +frappe.provide("erpnext.e_commerce.wishlist"); +var wishlist = erpnext.e_commerce.wishlist; + +frappe.provide("erpnext.e_commerce.shopping_cart"); +var shopping_cart = erpnext.e_commerce.shopping_cart; + +$.extend(wishlist, { + set_wishlist_count: function(animate=false) { + // set badge count for wishlist icon + var wish_count = frappe.get_cookie("wish_count"); + if (frappe.session.user==="Guest") { + wish_count = 0; + } + + if (wish_count) { + $(".wishlist").toggleClass('hidden', false); + } + + var $wishlist = $('.wishlist-icon'); + var $badge = $wishlist.find("#wish-count"); + + if (parseInt(wish_count) === 0 || wish_count === undefined) { + $wishlist.css("display", "none"); + } else { + $wishlist.css("display", "inline"); + } + if (wish_count) { + $badge.html(wish_count); + if (animate) { + $wishlist.addClass('cart-animate'); + setTimeout(() => { + $wishlist.removeClass('cart-animate'); + }, 500); + } + } else { + $badge.remove(); + } + }, + + bind_move_to_cart_action: function() { + // move item to cart from wishlist + $('.page_content').on("click", ".btn-add-to-cart", (e) => { + const $move_to_cart_btn = $(e.currentTarget); + let item_code = $move_to_cart_btn.data("item-code"); + + shopping_cart.shopping_cart_update({ + item_code, + qty: 1, + cart_dropdown: true + }); + + let success_action = function() { + const $card_wrapper = $move_to_cart_btn.closest(".wishlist-card"); + $card_wrapper.addClass("wish-removed"); + }; + let args = { item_code: item_code }; + this.add_remove_from_wishlist("remove", args, success_action, null, true); + }); + }, + + bind_remove_action: function() { + // remove item from wishlist + let me = this; + + $('.page_content').on("click", ".remove-wish", (e) => { + const $remove_wish_btn = $(e.currentTarget); + let item_code = $remove_wish_btn.data("item-code"); + + let success_action = function() { + const $card_wrapper = $remove_wish_btn.closest(".wishlist-card"); + $card_wrapper.addClass("wish-removed"); + if (frappe.get_cookie("wish_count") == 0) { + $(".page_content").empty(); + me.render_empty_state(); + } + }; + let args = { item_code: item_code }; + this.add_remove_from_wishlist("remove", args, success_action); + }); + }, + + bind_wishlist_action() { + // 'wish'('like') or 'unwish' item in product listing + $('.page_content').on('click', '.like-action, .like-action-list', (e) => { + const $btn = $(e.currentTarget); + this.wishlist_action($btn); + }); + }, + + wishlist_action(btn) { + const $wish_icon = btn.find('.wish-icon'); + let me = this; + + if (frappe.session.user==="Guest") { + if (localStorage) { + localStorage.setItem("last_visited", window.location.pathname); + } + this.redirect_guest(); + return; + } + + let success_action = function() { + erpnext.e_commerce.wishlist.set_wishlist_count(true); + }; + + if ($wish_icon.hasClass('wished')) { + // un-wish item + btn.removeClass("like-animate"); + btn.addClass("like-action-wished"); + this.toggle_button_class($wish_icon, 'wished', 'not-wished'); + + let args = { item_code: btn.data('item-code') }; + let failure_action = function() { + me.toggle_button_class($wish_icon, 'not-wished', 'wished'); + }; + this.add_remove_from_wishlist("remove", args, success_action, failure_action); + } else { + // wish item + btn.addClass("like-animate"); + btn.addClass("like-action-wished"); + this.toggle_button_class($wish_icon, 'not-wished', 'wished'); + + let args = {item_code: btn.data('item-code')}; + let failure_action = function() { + me.toggle_button_class($wish_icon, 'wished', 'not-wished'); + }; + this.add_remove_from_wishlist("add", args, success_action, failure_action); + } + }, + + toggle_button_class(button, remove, add) { + button.removeClass(remove); + button.addClass(add); + }, + + add_remove_from_wishlist(action, args, success_action, failure_action, async=false) { + /* AJAX call to add or remove Item from Wishlist + action: "add" or "remove" + args: args for method (item_code, price, formatted_price), + success_action: method to execute on successs, + failure_action: method to execute on failure, + async: make call asynchronously (true/false). */ + if (frappe.session.user==="Guest") { + if (localStorage) { + localStorage.setItem("last_visited", window.location.pathname); + } + this.redirect_guest(); + } else { + let method = "erpnext.e_commerce.doctype.wishlist.wishlist.add_to_wishlist"; + if (action === "remove") { + method = "erpnext.e_commerce.doctype.wishlist.wishlist.remove_from_wishlist"; + } + + frappe.call({ + async: async, + type: "POST", + method: method, + args: args, + callback: function (r) { + if (r.exc) { + if (failure_action && (typeof failure_action === 'function')) { + failure_action(); + } + frappe.msgprint({ + message: __("Sorry, something went wrong. Please refresh."), + indicator: "red", title: __("Note") + }); + } else if (success_action && (typeof success_action === 'function')) { + success_action(); + } + } + }); + } + }, + + redirect_guest() { + frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => { + window.location.href = res.message || "/login"; + }); + }, + + render_empty_state() { + $(".page_content").append(` +
+
+ Empty Cart +
+
${ __('Wishlist is empty !') }

+
+ `); + } + +}); + +frappe.ready(function() { + if (window.location.pathname !== "/wishlist") { + $(".wishlist").toggleClass('hidden', true); + wishlist.set_wishlist_count(); + } else { + wishlist.bind_move_to_cart_action(); + wishlist.bind_remove_action(); + } + +}); \ No newline at end of file diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index 490a7c4af73..fbb26a8008c 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -1,16 +1,17 @@ @import "frappe/public/scss/common/mixins"; -body.product-page { - background: var(--gray-50); +:root { + --green-info: #38A160; + --product-bg-color: white; + --body-bg-color: var(--gray-50); } +body.product-page { + background: var(--body-bg-color); +} .item-breadcrumbs { .breadcrumb-container { - ol.breadcrumb { - background-color: var(--gray-50) !important; - } - a { color: var(--gray-900); } @@ -61,9 +62,21 @@ body.product-page { } } +.no-image-item { + height: 340px; + width: 340px; + background: var(--gray-100); + border-radius: var(--border-radius); + font-size: 2rem; + color: var(--gray-500); + display: flex; + align-items: center; + justify-content: center; +} + .item-card-group-section { .card { - height: 360px; + height: 100%; align-items: center; justify-content: center; @@ -73,6 +86,19 @@ body.product-page { } } + .card:hover, .card:focus-within { + .btn-add-to-cart-list { + visibility: visible; + } + .like-action { + visibility: visible; + } + .btn-explore-variants { + visibility: visible; + } + } + + .card-img-container { height: 210px; width: 100%; @@ -86,14 +112,28 @@ body.product-page { .no-image { @include flex(flex, center, center, null); - height: 200px; - margin: 0 auto; - margin-top: var(--margin-xl); + height: 220px; + background: var(--gray-100); + width: 100%; + border-radius: var(--border-radius) var(--border-radius) 0 0; + font-size: 2rem; + color: var(--gray-500); + } + + .no-image-list { + @include flex(flex, center, center, null); + height: 150px; background: var(--gray-100); - width: 80%; border-radius: var(--border-radius); font-size: 2rem; color: var(--gray-500); + margin-top: 15px; + margin-bottom: 15px; + } + + .card-body-flex { + display: flex; + flex-direction: column; } .product-title { @@ -126,10 +166,70 @@ body.product-page { font-weight: 600; color: var(--text-color); margin: var(--margin-sm) 0; + margin-bottom: auto !important; + + .striked-price { + font-weight: 500; + font-size: 15px; + color: var(--gray-500); + } + } + + .product-info-green { + color: var(--green-info); + font-weight: 600; } .item-card { padding: var(--padding-sm); + min-width: 300px; + } + + .wishlist-card { + padding: var(--padding-sm); + min-width: 260px; + .card-body-flex { + display: flex; + flex-direction: column; + } + } +} + +#products-list-area, #products-grid-area { + padding: 0 5px; +} + +.list-row { + background-color: white; + padding-bottom: 1rem; + padding-top: 1.5rem !important; + border-radius: 8px; + border-bottom: 1px solid var(--gray-50); + + &:hover, &:focus-within { + box-shadow: 0px 16px 60px rgba(0, 0, 0, 0.08), 0px 8px 30px -20px rgba(0, 0, 0, 0.04); + transition: box-shadow 400ms; + + .btn-add-to-cart-list { + visibility: visible; + } + .like-action-list { + visibility: visible; + } + .btn-explore-variants { + visibility: visible; + } + } + + .product-code { + padding-top: 0 !important; + } + + .btn-explore-variants { + min-width: 135px; + max-height: 30px; + float: right; + padding: 0.25rem 1rem; } } @@ -174,28 +274,76 @@ body.product-page { } } +.product-filter { + width: 14px !important; + height: 14px !important; +} + +.discount-filter { + &:before { + width: 14px !important; + height: 14px !important; + } +} + +.list-image { + border: none !important; + overflow: hidden; + max-height: 200px; + background-color: white; +} + .product-container { @include card($padding: var(--padding-md)); - min-height: 70vh; + background-color: var(--product-bg-color) !important; + min-height: fit-content; .product-details { - max-width: 40%; - margin-left: -30px; + max-width: 50%; .btn-add-to-cart { - font-size: var(--text-base); + font-size: 14px; + } + } + + &.item-main { + .product-image { + width: 100%; + } + } + + .expand { + max-width: 100% !important; // expand in absence of slideshow + } + + @media (max-width: 789px) { + .product-details { + max-width: 90% !important; + + .btn-add-to-cart { + font-size: 14px; + } + } + } + + .btn-add-to-wishlist { + svg use { + stroke: #F47A7A; + } + } + + .btn-view-in-wishlist { + svg use { + fill: #F47A7A; + stroke: none; } } .product-title { - font-size: 24px; + font-size: 16px; font-weight: 600; color: var(--text-color); - } - - .product-code { - color: var(--text-muted); - font-size: 13px; + padding: 0 !important; } .product-description { @@ -232,7 +380,7 @@ body.product-page { max-height: 430px; } - overflow: scroll; + overflow: auto; } .item-slideshow-image { @@ -251,29 +399,114 @@ body.product-page { .item-cart { .product-price { - font-size: 20px; + font-size: 22px; color: var(--text-color); font-weight: 600; .formatted-price { color: var(--text-muted); - font-size: var(--text-base); + font-size: 14px; } } .no-stock { font-size: var(--text-base); } + + .offers-heading { + font-size: 16px !important; + color: var(--text-color); + .tag-icon { + --icon-stroke: var(--gray-500); + } + } + + .w-30-40 { + width: 30%; + + @media (max-width: 992px) { + width: 40%; + } + } + } + + .tab-content { + font-size: 14px; + } +} + +// Item Recommendations +.recommended-item-section { + padding-right: 0; + + .recommendation-header { + font-size: 16px; + font-weight: 500 + } + + .recommendation-container { + padding: .5rem; + min-height: 0px; + + .r-item-image { + width: 40%; + + .r-product-image { + padding: 2px 15px; + } + + .no-image-r-item { + display: flex; justify-content: center; + background-color: var(--gray-200); + align-items: center; + color: var(--gray-400); + margin-top: .15rem; + border-radius: 6px; + height: 100%; + font-size: 24px; + } + } + + .r-item-info { + font-size: 14px; + padding-right: 0; + width: 60%; + + a { + color: var(--gray-800); + font-weight: 400; + } + + .item-price { + font-size: 15px; + font-weight: 600; + color: var(--text-color); + } + + .striked-item-price { + font-weight: 500; + color: var(--gray-500); + } + } + } +} + +.product-code { + padding: .5rem 0; + color: var(--text-muted); + font-size: 14px; + .product-item-group { + padding-right: .25rem; + border-right: solid 1px var(--text-muted); + } + + .product-item-code { + padding-left: .5rem; } } .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 { @@ -313,20 +546,74 @@ body.product-page { } } -.cart-icon { - .cart-badge { - position: relative; - top: -10px; - left: -12px; - background: var(--red-600); - width: 16px; - align-items: center; - height: 16px; - font-size: 10px; - border-radius: 50%; +.sub-category-container { + padding-bottom: .5rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid var(--table-border-color); + + .heading { + color: var(--gray-500); } } +.scroll-categories { + white-space: nowrap; + overflow-x: auto; + + .category-pill { + margin: 0px 4px; + display: inline-block; + padding: 6px 12px; + background-color: #ecf5fe; + width: fit-content; + font-size: 14px; + border-radius: 18px; + color: var(--blue-500); + } +} + + +.shopping-badge { + position: relative; + top: -10px; + left: -12px; + background: var(--red-600); + width: 16px; + align-items: center; + height: 16px; + font-size: 10px; + border-radius: 50%; +} + + +.cart-animate { + animation: wiggle 0.5s linear; +} +@keyframes wiggle { + 8%, + 41% { + transform: translateX(-10px); + } + 25%, + 58% { + transform: translate(10px); + } + 75% { + transform: translate(-5px); + } + 92% { + transform: translate(5px); + } + 0%, + 100% { + transform: translate(0); + } +} + +.total-discount { + font-size: 14px; + color: var(--primary-color) !important; +} #page-cart { .shopping-cart-header { @@ -340,6 +627,7 @@ body.product-page { display: flex; flex-direction: column; justify-content: space-between; + height: fit-content; } .cart-items-header { @@ -347,6 +635,10 @@ body.product-page { } .cart-table { + tr { + margin-bottom: 1rem; + } + th, tr, td { border-color: var(--border-color); border-width: 1px; @@ -364,71 +656,200 @@ body.product-page { color: var(--text-color); } + .cart-item-image { + width: 20%; + min-width: 100px; + img { + max-height: 112px; + } + + .no-image-cart-item { + max-height: 112px; + display: flex; justify-content: center; + background-color: var(--gray-200); + align-items: center; + color: var(--gray-400); + margin-top: .15rem; + border-radius: 6px; + height: 100%; + font-size: 24px; + } + } + .cart-items { .item-title { - font-size: var(--text-base); + width: 80%; + font-size: 14px; font-weight: 500; color: var(--text-color); } .item-subtitle { color: var(--text-muted); - font-size: var(--text-md); + font-size: 13px; } .item-subtotal { - font-size: var(--text-base); + font-size: 14px; font-weight: 500; } + .sm-item-subtotal { + font-size: 14px; + font-weight: 500; + display: none; + + @media (max-width: 992px) { + display: unset !important; + } + } + .item-rate { - font-size: var(--text-md); + font-size: 13px; color: var(--text-muted); } - textarea { - width: 40%; + .free-tag { + padding: 4px 8px; + border-radius: 4px; + background-color: var(--dark-green-50); } + + textarea { + width: 80%; + height: 60px; + font-size: 14px; + } + } .cart-tax-items { .item-grand-total { font-size: 16px; - font-weight: 600; + font-weight: 700; color: var(--text-color); } } + + .column-sm-view { + @media (max-width: 992px) { + display: none !important; + } + } + + .item-column { + width: 50%; + @media (max-width: 992px) { + width: 70%; + } + } + + .remove-cart-item { + border-radius: 6px; + border: 1px solid var(--gray-100); + width: 28px; + height: 28px; + font-weight: 300; + color: var(--gray-700); + background-color: var(--gray-100); + float: right; + cursor: pointer; + margin-top: .25rem; + justify-content: center; + } + + .remove-cart-item-logo { + margin-top: 2px; + margin-left: 2.2px; + fill: var(--gray-700) !important; + } } - .cart-addresses { + .cart-payment-addresses { hr { border-color: var(--border-color); } } + .payment-summary { + h6 { + padding-bottom: 1rem; + border-bottom: solid 1px var(--gray-200); + } + + table { + font-size: 14px; + td { + padding: 0; + padding-top: 0.35rem !important; + border: none !important; + } + + &.grand-total { + border-top: solid 1px var(--gray-200); + } + } + + .bill-label { + color: var(--gray-600); + } + + .bill-content { + font-weight: 500; + &.net-total { + font-size: 16px; + font-weight: 600; + } + } + + .btn-coupon-code { + font-size: 14px; + border: dashed 1px var(--gray-400); + box-shadow: none; + } + } + .number-spinner { width: 75%; + min-width: 105px; .cart-btn { border: none; background: var(--gray-100); box-shadow: none; + width: 24px; height: 28px; align-items: center; + justify-content: center; display: flex; + font-size: 20px; + font-weight: 300; + color: var(--gray-700); } .cart-qty { height: 28px; - font-size: var(--text-md); + font-size: 13px; + &:disabled { + background: var(--gray-100); + opacity: 0.65; + } } } .place-order-container { .btn-place-order { - width: 62%; + float: right; } } } + + .t-and-c-container { + padding: 1.5rem; + } + + .t-and-c-terms { + font-size: 14px; + } } .cart-empty.frappe-card { @@ -444,7 +865,7 @@ body.product-page { .address-card { .card-title { - font-size: var(--text-base); + font-size: 14px; font-weight: 500; } @@ -453,27 +874,37 @@ body.product-page { } .card-text { - font-size: var(--text-md); + font-size: 13px; color: var(--gray-700); } .card-link { - font-size: var(--text-md); + font-size: 13px; svg use { - stroke: var(--blue-500); + stroke: var(--primary-color); } } .btn-change-address { - color: var(--blue-500); + border: 1px solid var(--primary-color); + color: var(--primary-color); + box-shadow: none; } } +.address-header { + margin-top: .15rem;padding: 0; +} + +.btn-new-address { + float: right; + font-size: 15px !important; + color: var(--primary-color) !important; +} + .btn-new-address:hover, .btn-change-address:hover { - box-shadow: none; - color: var(--blue-500) !important; - border: 1px solid var(--blue-500); + color: var(--primary-color) !important; } .modal .address-card { @@ -483,3 +914,451 @@ body.product-page { border: 1px solid var(--dark-border-color); } } + +.cart-indicator { + position: absolute; + text-align: center; + width: 22px; + height: 22px; + left: calc(100% - 40px); + top: 22px; + + border-radius: 66px; + box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); + background: white; + color: var(--primary-color); + font-size: 14px; + + &.list-indicator { + position: unset; + margin-left: auto; + } +} + + +.like-action { + visibility: hidden; + text-align: center; + position: absolute; + cursor: pointer; + width: 28px; + height: 28px; + left: 20px; + top: 20px; + + /* White */ + background: white; + box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); + border-radius: 66px; + + &.like-action-wished { + visibility: visible !important; + } + + @media (max-width: 992px) { + visibility: visible !important; + } +} + +.like-action-list { + visibility: hidden; + text-align: center; + position: absolute; + cursor: pointer; + width: 28px; + height: 28px; + left: 20px; + top: 0; + + /* White */ + background: white; + box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); + border-radius: 66px; + + &.like-action-wished { + visibility: visible !important; + } + + @media (max-width: 992px) { + visibility: visible !important; + } +} + +.like-action-item-fp { + visibility: visible !important; + position: unset; + float: right; +} + +.like-animate { + animation: expand cubic-bezier(0.04, 0.4, 0.5, 0.95) 1.6s forwards 1; +} + +@keyframes expand { + 30% { + transform: scale(1.3); + } + 50% { + transform: scale(0.8); + } + 70% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } + } + +.not-wished { + cursor: pointer; + stroke: #F47A7A !important; + + &:hover { + fill: #F47A7A; + } +} + +.wished { + stroke: none; + fill: #F47A7A !important; +} + +.list-row-checkbox { + &:before { + display: none; + } + + &:checked:before { + display: block; + z-index: 1; + } +} + +#pay-for-order { + padding: .5rem 1rem; // Pay button in SO +} + +.btn-explore-variants { + visibility: hidden; + box-shadow: none; + margin: var(--margin-sm) 0; + width: 90px; + max-height: 50px; // to avoid resizing on window resize + flex: none; + transition: 0.3s ease; + + color: white; + background-color: var(--orange-500); + border: 1px solid var(--orange-500); + font-size: 13px; + + &:hover { + color: white; + } +} + +.btn-add-to-cart-list{ + visibility: hidden; + box-shadow: none; + margin: var(--margin-sm) 0; + // margin-top: auto !important; + max-height: 50px; // to avoid resizing on window resize + flex: none; + transition: 0.3s ease; + + font-size: 13px; + + &:hover { + color: white; + } + + @media (max-width: 992px) { + visibility: visible !important; + } +} + +.go-to-cart-grid { + max-height: 30px; + margin-top: 1rem !important; +} + +.go-to-cart { + max-height: 30px; + float: right; +} + +.remove-wish { + background-color: white; + position: absolute; + cursor: pointer; + top:10px; + right: 20px; + width: 32px; + height: 32px; + + border-radius: 50%; + border: 1px solid var(--gray-100); + box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); +} + +.wish-removed { + display: none; +} + +.item-website-specification { + font-size: .875rem; + .product-title { + font-size: 18px; + } + + .table { + width: 70%; + } + + td { + border: none !important; + } + + .spec-label { + color: var(--gray-600); + } + + .spec-content { + color: var(--gray-800); + } +} + +.reviews-full-page { + padding: 1rem 2rem; +} + +.ratings-reviews-section { + border-top: 1px solid #E2E6E9; + padding: .5rem 1rem; +} + +.reviews-header { + font-size: 20px; + font-weight: 600; + color: var(--gray-800); + display: flex; + align-items: center; + padding: 0; +} + +.btn-write-review { + float: right; + padding: .5rem 1rem; + font-size: 14px; + font-weight: 400; + border: none !important; + box-shadow: none; + + color: var(--gray-900); + background-color: var(--gray-100); + + &:hover { + box-shadow: var(--btn-shadow); + } +} + +.btn-view-more { + font-size: 14px; +} + +.rating-summary-section { + display: flex; +} + +.rating-summary-title { + margin-top: 0.15rem; + font-size: 18px; +} + +.rating-summary-numbers { + display: flex; + flex-direction: column; + align-items: center; + + border-right: solid 1px var(--gray-100); +} + +.user-review-title { + margin-top: 0.15rem; + font-size: 15px; + font-weight: 600; +} + +.rating { + --star-fill: var(--gray-300); + .star-hover { + --star-fill: var(--yellow-100); + } + .star-click { + --star-fill: var(--yellow-300); + } +} + +.ratings-pill { + background-color: var(--gray-100); + padding: .5rem 1rem; + border-radius: 66px; +} + +.review { + max-width: 80%; + line-height: 1.6; + padding-bottom: 0.5rem; + border-bottom: 1px solid #E2E6E9; +} + +.review-signature { + display: flex; + font-size: 13px; + color: var(--gray-500); + font-weight: 400; + + .reviewer { + padding-right: 8px; + color: var(--gray-600); + } +} + +.rating-progress-bar-section { + padding-bottom: 2rem; + + .rating-bar-title { + margin-left: -15px; + } + + .rating-progress-bar { + margin-bottom: 4px; + height: 7px; + margin-top: 6px; + + .progress-bar-cosmetic { + background-color: var(--gray-600); + border-radius: var(--border-radius); + } + } +} + +.offer-container { + font-size: 14px; +} + +#search-results-container { + border: 1px solid var(--gray-200); + padding: .25rem 1rem; + + .category-chip { + background-color: var(--gray-100); + border: none !important; + box-shadow: none; + } + + .recent-search { + padding: .5rem .5rem; + border-radius: var(--border-radius); + + &:hover { + background-color: var(--gray-100); + } + } +} + +#search-box { + background-color: white; + height: 100%; + padding-left: 2.5rem; + border: 1px solid var(--gray-200); +} + +.search-icon { + position: absolute; + left: 0; + top: 0; + width: 2.5rem; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + padding-bottom: 1px; +} + +#toggle-view { + float: right; + + .btn-primary { + background-color: var(--gray-600); + box-shadow: 0 0 0 0.2rem var(--gray-400); + } +} + +.placeholder-div { + height:80%; + width: -webkit-fill-available; + padding: 50px; + text-align: center; + background-color: #F9FAFA; + border-top-left-radius: calc(0.75rem - 1px); + border-top-right-radius: calc(0.75rem - 1px); +} +.placeholder { + font-size: 72px; +} + +[data-path="cart"] { + .modal-backdrop { + background-color: var(--gray-50); // lighter backdrop only on cart freeze + } +} + +.item-thumb { + height: 50px; + max-width: 80px; + min-width: 80px; + object-fit: cover; +} + +.brand-line { + color: gray; +} + +.btn-next, .btn-prev { + font-size: 14px; +} + +.alert-error { + color: #e27a84; + background-color: #fff6f7; + border-color: #f5c6cb; +} + +.font-md { + font-size: 14px !important; +} + +.in-green { + color: var(--green-info) !important; + font-weight: 500; +} + +.has-stock { + font-weight: 400 !important; +} + +.out-of-stock { + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: #F47A7A; +} + +.mt-minus-2 { + margin-top: -2rem; +} + +.mt-minus-1 { + margin-top: -1rem; +} \ No newline at end of file diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 27e9f08e8d6..737723bbfbe 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -286,30 +286,6 @@ class Customer(TransactionBase): .format(frappe.bold(self.customer_name)) ) - def create_onboarding_docs(self, args): - defaults = frappe.defaults.get_defaults() - company = defaults.get('company') or \ - frappe.db.get_single_value('Global Defaults', 'default_company') - - for i in range(1, args.get('max_count')): - customer = args.get('customer_name_' + str(i)) - if customer: - try: - doc = frappe.get_doc({ - 'doctype': self.doctype, - 'customer_name': customer, - 'customer_type': 'Company', - 'customer_group': _('Commercial'), - 'territory': defaults.get('country'), - 'company': company - }).insert() - - if args.get('customer_email_' + str(i)): - create_contact(customer, self.doctype, - doc.name, args.get("customer_email_" + str(i))) - except frappe.NameError: - pass - def create_contact(contact, party_type, party, email): """Create contact based on given contact name""" contact = contact.split(' ') diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 6cd03dd2791..8439a702f19 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -273,7 +273,7 @@ def _make_customer(source_name, ignore_permissions=False): customer = frappe.get_doc(customer_doclist) customer.flags.ignore_permissions = ignore_permissions if quotation.get("party_name") == "Shopping Cart": - customer.customer_group = frappe.db.get_value("Shopping Cart Settings", None, + customer.customer_group = frappe.db.get_value("E Commerce Settings", None, "default_customer_group") try: diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 8b53902d32f..31a95896bc1 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -649,7 +649,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-02-23 01:13:54.670763", + "modified": "2021-07-15 12:40:51.074820", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json b/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json deleted file mode 100644 index 92d00bcb380..00000000000 --- a/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "add_more_button": 1, - "app": "ERPNext", - "creation": "2019-11-15 14:44:10.065014", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [ - { - "label": "Learn More", - "video_id": "zsrrVDk6VBs" - } - ], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 3, - "modified": "2019-12-09 17:54:01.686006", - "modified_by": "Administrator", - "name": "Add A Few Customers", - "owner": "Administrator", - "ref_doctype": "Customer", - "slide_desc": "", - "slide_fields": [ - { - "align": "", - "fieldname": "customer_name", - "fieldtype": "Data", - "label": "Customer Name", - "placeholder": "", - "reqd": 1 - }, - { - "align": "", - "fieldtype": "Column Break", - "reqd": 0 - }, - { - "align": "", - "fieldname": "customer_email", - "fieldtype": "Data", - "label": "Email ID", - "reqd": 1 - } - ], - "slide_order": 40, - "slide_title": "Add A Few Customers", - "slide_type": "Create" -} \ No newline at end of file diff --git a/erpnext/setup/doctype/brand/brand.json b/erpnext/setup/doctype/brand/brand.json index a8f0674b1f8..45b4db81f1f 100644 --- a/erpnext/setup/doctype/brand/brand.json +++ b/erpnext/setup/doctype/brand/brand.json @@ -1,270 +1,111 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 1, - "autoname": "field:brand", - "beta": 0, - "creation": "2013-02-22 01:27:54", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:brand", + "creation": "2013-02-22 01:27:54", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "brand", + "image", + "description", + "defaults", + "brand_defaults" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 1, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "brand", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Brand Name", - "length": 0, - "no_copy": 0, - "oldfieldname": "brand", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, + "allow_in_quick_entry": 1, + "fieldname": "brand", + "fieldtype": "Data", + "label": "Brand Name", + "oldfieldname": "brand", + "oldfieldtype": "Data", + "reqd": 1, "unique": 1 - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "description", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "oldfieldname": "description", - "oldfieldtype": "Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "description", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text", "width": "300px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "defaults", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Defaults", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "defaults", + "fieldtype": "Section Break", + "label": "Defaults" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "brand_defaults", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Brand Defaults", - "length": 0, - "no_copy": 0, - "options": "Item Default", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "brand_defaults", + "fieldtype": "Table", + "label": "Brand Defaults", + "options": "Item Default" + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "hidden": 1, + "label": "Image" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-certificate", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-10-23 23:18:06.067612", - "modified_by": "Administrator", - "module": "Setup", - "name": "Brand", - "owner": "Administrator", + ], + "icon": "fa fa-certificate", + "idx": 1, + "image_field": "image", + "links": [], + "modified": "2021-03-01 15:57:30.005783", + "modified_by": "Administrator", + "module": "Setup", + "name": "Brand", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 1, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Item Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "import": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Item Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Stock User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Purchase User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Purchase User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User" } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 1, - "sort_order": "ASC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 -} + ], + "quick_entry": 1, + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "ASC" +} \ No newline at end of file diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index 5fcad00af16..bca30e061d6 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -10,11 +10,8 @@ from frappe.utils.nestedset import NestedSet from frappe.website.website_generator import WebsiteGenerator from frappe.website.render import clear_cache from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow -from erpnext.shopping_cart.product_info import set_product_info_for_website -from erpnext.utilities.product import get_qty_in_stock from six.moves.urllib.parse import quote -from erpnext.shopping_cart.product_query import ProductQuery -from erpnext.shopping_cart.filters import ProductFiltersBuilder +from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder class ItemGroup(NestedSet, WebsiteGenerator): nsm_parent_field = 'parent_item_group' @@ -69,34 +66,15 @@ class ItemGroup(NestedSet, WebsiteGenerator): frappe.throw(frappe._("An item exists with same name ({0}), please change the item group name or rename the item").format(self.name), frappe.NameError) def get_context(self, context): - context.show_search=True - context.page_length = cint(frappe.db.get_single_value('Products Settings', 'products_per_page')) or 6 + context.show_search = True + context.body_class = "product-page" + context.page_length = cint(frappe.db.get_single_value('E Commerce Settings', 'products_per_page')) or 6 context.search_link = '/product_search' - if frappe.form_dict: - search = frappe.form_dict.search - field_filters = frappe.parse_json(frappe.form_dict.field_filters) - attribute_filters = frappe.parse_json(frappe.form_dict.attribute_filters) - start = frappe.parse_json(frappe.form_dict.start) - else: - search = None - attribute_filters = None - field_filters = {} - start = 0 - - if not field_filters: - field_filters = {} - - # Ensure the query remains within current item group & sub group - field_filters['item_group'] = [ig[0] for ig in get_child_groups(self.name)] - - engine = ProductQuery() - context.items = engine.query(attribute_filters, field_filters, search, start, item_group=self.name) - filter_engine = ProductFiltersBuilder(self.name) context.field_filters = filter_engine.get_field_filters() - context.attribute_filters = filter_engine.get_attribute_fitlers() + context.attribute_filters = filter_engine.get_attribute_filters() context.update({ "parents": get_parent_item_groups(self.parent_item_group), @@ -116,106 +94,40 @@ class ItemGroup(NestedSet, WebsiteGenerator): values[f"slide_{index + 1}_image"] = slide.image values[f"slide_{index + 1}_title"] = slide.heading values[f"slide_{index + 1}_subtitle"] = slide.description - values[f"slide_{index + 1}_theme"] = slide.theme or "Light" - values[f"slide_{index + 1}_content_align"] = slide.content_align or "Centre" - values[f"slide_{index + 1}_primary_action_label"] = slide.label + values[f"slide_{index + 1}_theme"] = slide.get("theme") or "Light" + values[f"slide_{index + 1}_content_align"] = slide.get("content_align") or "Centre" values[f"slide_{index + 1}_primary_action"] = slide.url context.slideshow = values - context.breadcrumbs = 0 + context.no_breadcrumbs = False context.title = self.website_title or self.name + context.name = self.name + context.item_group_name = self.item_group_name return context def delete_child_item_groups_key(self): frappe.cache().hdel("child_item_groups", self.name) -@frappe.whitelist(allow_guest=True) -def get_product_list_for_group(product_group=None, start=0, limit=10, search=None): - if product_group: - item_group = frappe.get_cached_doc('Item Group', product_group) - if item_group.is_group: - # return child item groups if the type is of "Is Group" - return get_child_groups_for_list_in_html(item_group, start, limit, search) +def get_child_groups_for_website(item_group_name, immediate=False): + """Returns child item groups *excluding* passed group.""" + 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 + } - child_groups = ", ".join(frappe.db.escape(i[0]) for i in get_child_groups(product_group)) + if immediate: + filters["parent_item_group"] = item_group_name - # base query - query = """select I.name, I.item_name, I.item_code, I.route, I.image, I.website_image, I.thumbnail, I.item_group, - I.description, I.web_long_description as website_description, I.is_stock_item, - case when (S.actual_qty - S.reserved_qty) > 0 then 1 else 0 end as in_stock, I.website_warehouse, - I.has_batch_no - from `tabItem` I - left join tabBin S on I.item_code = S.item_code and I.website_warehouse = S.warehouse - where I.show_in_website = 1 - and I.disabled = 0 - and (I.end_of_life is null or I.end_of_life='0000-00-00' or I.end_of_life > %(today)s) - and (I.variant_of = '' or I.variant_of is null) - and (I.item_group in ({child_groups}) - or I.name in (select parent from `tabWebsite Item Group` where item_group in ({child_groups}))) - """.format(child_groups=child_groups) - # search term condition - if search: - query += """ and (I.web_long_description like %(search)s - or I.item_name like %(search)s - or I.name like %(search)s)""" - search = "%" + cstr(search) + "%" - - query += """order by I.weightage desc, in_stock desc, I.modified desc limit %s, %s""" % (cint(start), cint(limit)) - - data = frappe.db.sql(query, {"product_group": product_group,"search": search, "today": nowdate()}, as_dict=1) - data = adjust_qty_for_expired_items(data) - - if cint(frappe.db.get_single_value("Shopping Cart Settings", "enabled")): - for item in data: - set_product_info_for_website(item) - - return data - -def get_child_groups_for_list_in_html(item_group, start, limit, search): - search_filters = None - if search_filters: - search_filters = [ - dict(name = ('like', '%{}%'.format(search))), - dict(description = ('like', '%{}%'.format(search))) - ] - data = frappe.db.get_all('Item Group', - fields = ['name', 'route', 'description', 'image'], - filters = dict( - show_in_website = 1, - parent_item_group = item_group.name, - lft = ('>', item_group.lft), - rgt = ('<', item_group.rgt), - ), - or_filters = search_filters, - order_by = 'weightage desc, name asc', - start = start, - limit = limit + return frappe.get_all( + "Item Group", + filters=filters, + fields=["name", "route"] ) - return data - -def adjust_qty_for_expired_items(data): - adjusted_data = [] - - for item in data: - if item.get('has_batch_no') and item.get('website_warehouse'): - stock_qty_dict = get_qty_in_stock( - item.get('name'), 'website_warehouse', item.get('website_warehouse')) - qty = stock_qty_dict.stock_qty[0][0] if stock_qty_dict.stock_qty else 0 - item['in_stock'] = 1 if qty else 0 - adjusted_data.append(item) - - return adjusted_data - - -def get_child_groups(item_group_name): - item_group = frappe.get_doc("Item Group", item_group_name) - return frappe.db.sql("""select name - from `tabItem Group` where lft>=%(lft)s and rgt<=%(rgt)s - and show_in_website = 1""", {"lft": item_group.lft, "rgt": item_group.rgt}) - def get_child_item_groups(item_group_name): item_group = frappe.get_cached_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1) @@ -231,31 +143,33 @@ def get_item_for_list_in_html(context): if (context.get("website_image") or "").startswith("files/"): context["website_image"] = "/" + quote(context["website_image"]) - context["show_availability_status"] = cint(frappe.db.get_single_value('Products Settings', + context["show_availability_status"] = cint(frappe.db.get_single_value('E Commerce Settings', 'show_availability_status')) products_template = 'templates/includes/products_as_list.html' return frappe.get_template(products_template).render(context) -def get_group_item_count(item_group): - child_groups = ", ".join('"' + i[0] + '"' for i in get_child_groups(item_group)) - return frappe.db.sql("""select count(*) from `tabItem` - where docstatus = 0 and show_in_website = 1 - and (item_group in (%s) - or name in (select parent from `tabWebsite Item Group` - where item_group in (%s))) """ % (child_groups, child_groups))[0][0] +def get_parent_item_groups(item_group_name, from_item=False): + base_nav_page = {"name": _("Shop by Category"), "route":"/shop-by-category"} + + if from_item and frappe.request.environ.get("HTTP_REFERER"): + # base page after 'Home' will vary on Item page + last_page = frappe.request.environ["HTTP_REFERER"].split('/')[-1] + if last_page and last_page in ("shop-by-category", "all-products"): + base_nav_page_title = " ".join(last_page.split("-")).title() + base_nav_page = {"name": _(base_nav_page_title), "route":"/"+last_page} -def get_parent_item_groups(item_group_name): base_parents = [ - {"name": frappe._("Home"), "route":"/"}, - {"name": frappe._("All Products"), "route":"/all-products"}, + {"name": _("Home"), "route":"/"}, + base_nav_page, ] + if not item_group_name: return base_parents - item_group = frappe.get_doc("Item Group", item_group_name) + item_group = frappe.db.get_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1) parent_groups = frappe.db.sql("""select name, route from `tabItem Group` where lft <= %s and rgt >= %s and show_in_website=1 diff --git a/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json b/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json deleted file mode 100644 index f00dc947d2c..00000000000 --- a/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "add_more_button": 0, - "app": "ERPNext", - "creation": "2019-12-04 19:21:39.995776", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 3, - "modified": "2019-12-09 17:53:53.849953", - "modified_by": "Administrator", - "name": "Welcome back to ERPNext!", - "owner": "Administrator", - "slide_desc": "

Let's continue where you left from!

", - "slide_fields": [], - "slide_module": "Setup", - "slide_order": 0, - "slide_title": "Welcome back to ERPNext!", - "slide_type": "Continue" -} \ No newline at end of file diff --git a/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json b/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json deleted file mode 100644 index 37eb67b1d7e..00000000000 --- a/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "add_more_button": 0, - "app": "ERPNext", - "creation": "2019-11-26 17:01:26.671859", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 0, - "modified": "2019-12-22 21:26:28.414597", - "modified_by": "Administrator", - "name": "Welcome to ERPNext!", - "owner": "Administrator", - "slide_desc": "
Setting up an ERP can be overwhelming. But don't worry, we have got your back! This wizard will help you onboard to ERPNext in a short time!
", - "slide_fields": [], - "slide_module": "Setup", - "slide_order": 1, - "slide_title": "Welcome to ERPNext!", - "slide_type": "Information" -} \ No newline at end of file diff --git a/erpnext/setup/setup_wizard/operations/company_setup.py b/erpnext/setup/setup_wizard/operations/company_setup.py index 4833d93c4a7..9bfde7be7d8 100644 --- a/erpnext/setup/setup_wizard/operations/company_setup.py +++ b/erpnext/setup/setup_wizard/operations/company_setup.py @@ -34,7 +34,7 @@ def create_fiscal_year_and_company(args): def enable_shopping_cart(args): # Needs price_lists frappe.get_doc({ - "doctype": "Shopping Cart Settings", + "doctype": "E Commerce Settings", "enabled": 1, 'company': args.get('company_name') , 'price_list': frappe.db.get_value("Price List", {"selling": 1}), diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 3dcb63867c1..7d69625206c 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -513,7 +513,7 @@ def create_bank_account(args): pass def update_shopping_cart_settings(args): - shopping_cart = frappe.get_doc("Shopping Cart Settings") + shopping_cart = frappe.get_doc("E Commerce Settings") shopping_cart.update({ "enabled": 1, 'company': args.company_name, diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json deleted file mode 100644 index 7a4bb20136f..00000000000 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "actions": [], - "creation": "2013-06-19 15:57:32", - "description": "Default settings for Shopping Cart", - "doctype": "DocType", - "document_type": "System", - "engine": "InnoDB", - "field_order": [ - "enabled", - "store_page_docs", - "display_settings", - "show_attachments", - "show_price", - "show_stock_availability", - "enable_variants", - "column_break_7", - "show_contact_us_button", - "show_quantity_in_website", - "show_apply_coupon_code_in_website", - "allow_items_not_in_stock", - "section_break_2", - "company", - "price_list", - "column_break_4", - "default_customer_group", - "quotation_series", - "section_break_8", - "enable_checkout", - "save_quotations_as_draft", - "column_break_11", - "payment_gateway_account", - "payment_success_url" - ], - "fields": [ - { - "default": "0", - "fieldname": "enabled", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Enable Shopping Cart" - }, - { - "fieldname": "display_settings", - "fieldtype": "Section Break", - "label": "Display Settings" - }, - { - "default": "0", - "fieldname": "show_attachments", - "fieldtype": "Check", - "label": "Show Public Attachments" - }, - { - "default": "0", - "fieldname": "show_price", - "fieldtype": "Check", - "label": "Show Price" - }, - { - "default": "0", - "fieldname": "show_stock_availability", - "fieldtype": "Check", - "label": "Show Stock Availability" - }, - { - "default": "0", - "fieldname": "show_contact_us_button", - "fieldtype": "Check", - "label": "Show Contact Us Button" - }, - { - "default": "0", - "depends_on": "show_stock_availability", - "fieldname": "show_quantity_in_website", - "fieldtype": "Check", - "label": "Show Stock Quantity" - }, - { - "default": "0", - "fieldname": "show_apply_coupon_code_in_website", - "fieldtype": "Check", - "label": "Show Apply Coupon Code" - }, - { - "default": "0", - "fieldname": "allow_items_not_in_stock", - "fieldtype": "Check", - "label": "Allow items not in stock to be added to cart" - }, - { - "depends_on": "enabled", - "fieldname": "section_break_2", - "fieldtype": "Section Break" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Company", - "mandatory_depends_on": "eval: doc.enabled === 1", - "options": "Company", - "remember_last_selected_value": 1 - }, - { - "description": "Prices will not be shown if Price List is not set", - "fieldname": "price_list", - "fieldtype": "Link", - "label": "Price List", - "mandatory_depends_on": "eval: doc.enabled === 1", - "options": "Price List" - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "fieldname": "default_customer_group", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Default Customer Group", - "mandatory_depends_on": "eval: doc.enabled === 1", - "options": "Customer Group" - }, - { - "fieldname": "quotation_series", - "fieldtype": "Select", - "label": "Quotation Series", - "mandatory_depends_on": "eval: doc.enabled === 1" - }, - { - "collapsible": 1, - "collapsible_depends_on": "eval:doc.enable_checkout", - "depends_on": "enabled", - "fieldname": "section_break_8", - "fieldtype": "Section Break", - "label": "Checkout Settings" - }, - { - "default": "0", - "fieldname": "enable_checkout", - "fieldtype": "Check", - "label": "Enable Checkout" - }, - { - "default": "Orders", - "depends_on": "enable_checkout", - "description": "After payment completion redirect user to selected page.", - "fieldname": "payment_success_url", - "fieldtype": "Select", - "label": "Payment Success Url", - "mandatory_depends_on": "enable_checkout", - "options": "\nOrders\nInvoices\nMy Account" - }, - { - "fieldname": "column_break_11", - "fieldtype": "Column Break" - }, - { - "depends_on": "enable_checkout", - "fieldname": "payment_gateway_account", - "fieldtype": "Link", - "label": "Payment Gateway Account", - "mandatory_depends_on": "enable_checkout", - "options": "Payment Gateway Account" - }, - { - "fieldname": "column_break_7", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "enable_variants", - "fieldtype": "Check", - "label": "Enable Variants" - }, - { - "default": "0", - "depends_on": "eval: doc.enable_checkout == 0", - "fieldname": "save_quotations_as_draft", - "fieldtype": "Check", - "label": "Save Quotations as Draft" - }, - { - "depends_on": "doc.enabled", - "fieldname": "store_page_docs", - "fieldtype": "HTML" - } - ], - "icon": "fa fa-shopping-cart", - "idx": 1, - "issingle": 1, - "links": [], - "modified": "2021-03-02 17:34:57.642565", - "modified_by": "Administrator", - "module": "Shopping Cart", - "name": "Shopping Cart Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "Website Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "ASC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/shopping_cart/filters.py b/erpnext/shopping_cart/filters.py deleted file mode 100644 index 7dfa09e2d62..00000000000 --- a/erpnext/shopping_cart/filters.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -from __future__ import unicode_literals -import frappe -from frappe import _dict - -class ProductFiltersBuilder: - def __init__(self, item_group=None): - if not item_group or item_group == "Products Settings": - self.doc = frappe.get_doc("Products Settings") - else: - self.doc = frappe.get_doc("Item Group", item_group) - - self.item_group = item_group - - def get_field_filters(self): - filter_fields = [row.fieldname for row in self.doc.filter_fields] - - meta = frappe.get_meta('Item') - fields = [df for df in meta.fields if df.fieldname in filter_fields] - - filter_data = [] - for df in fields: - filters, or_filters = {}, [] - if df.fieldtype == "Link": - if self.item_group: - or_filters.extend([ - ["item_group", "=", self.item_group], - ["Website Item Group", "item_group", "=", self.item_group] - ]) - - values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, or_filters=or_filters, distinct="True", pluck=df.fieldname) - 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)] - - # Remove None - if None in values: - values.remove(None) - - if values: - filter_data.append([df, values]) - - return filter_data - - def get_attribute_fitlers(self): - attributes = [row.attribute for row in self.doc.filter_attributes] - attribute_docs = [ - frappe.get_doc('Item Attribute', attribute) for attribute in attributes - ] - - valid_attributes = [] - - for attr_doc in attribute_docs: - selected_attributes = [] - for attr in attr_doc.item_attribute_values: - or_filters = [] - filters= [ - ["Item Variant Attribute", "attribute", "=", attr.parent], - ["Item Variant Attribute", "attribute_value", "=", attr.attribute_value] - ] - if self.item_group: - or_filters.extend([ - ["item_group", "=", self.item_group], - ["Website Item Group", "item_group", "=", self.item_group] - ]) - - if frappe.db.get_all("Item", filters, or_filters=or_filters, limit=1): - selected_attributes.append(attr) - - if selected_attributes: - valid_attributes.append( - _dict( - item_attribute_values=selected_attributes, - name=attr_doc.name - ) - ) - - return valid_attributes diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py deleted file mode 100644 index 6c92d967d0c..00000000000 --- a/erpnext/shopping_cart/product_query.py +++ /dev/null @@ -1,159 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -import frappe -from erpnext.shopping_cart.product_info import get_product_info_for_website - -class ProductQuery: - """Query engine for product listing - - Attributes: - cart_settings (Document): Settings for Cart - fields (list): Fields to fetch in query - filters (TYPE): Description - or_filters (list): Description - page_length (Int): Length of page for the query - settings (Document): Products Settings DocType - filters (list) - or_filters (list) - """ - - def __init__(self): - self.settings = frappe.get_doc("Products Settings") - self.cart_settings = frappe.get_doc("Shopping Cart Settings") - self.page_length = self.settings.products_per_page or 20 - self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', - 'item_group', 'image', 'web_long_description', 'description', 'route', 'weightage'] - self.filters = [] - self.or_filters = [['show_in_website', '=', 1]] - if not self.settings.get('hide_variants'): - self.or_filters.append(['show_variant_in_website', '=', 1]) - - def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None): - """Summary - - Args: - attributes (dict, optional): Item Attribute filters - fields (dict, optional): Field level filters - search_term (str, optional): Search term to lookup - start (int, optional): Page start - - Returns: - list: List of results with set fields - """ - if fields: self.build_fields_filters(fields) - if search_term: self.build_search_filters(search_term) - - result = [] - website_item_groups = [] - - # if from item group page consider website item group table - if item_group: - website_item_groups = frappe.db.get_all( - "Item", - fields=self.fields + ["`tabWebsite Item Group`.parent as wig_parent"], - filters=[["Website Item Group", "item_group", "=", item_group]] - ) - - if attributes: - all_items = [] - for attribute, values in attributes.items(): - if not isinstance(values, list): - values = [values] - - items = frappe.get_all( - "Item", - fields=self.fields, - filters=[ - *self.filters, - ["Item Variant Attribute", "attribute", "=", attribute], - ["Item Variant Attribute", "attribute_value", "in", values], - ], - or_filters=self.or_filters, - start=start, - limit=self.page_length, - order_by="weightage desc" - ) - - items_dict = {item.name: item for item in items} - - all_items.append(set(items_dict.keys())) - - result = [items_dict.get(item) for item in list(set.intersection(*all_items))] - else: - result = frappe.get_all( - "Item", - fields=self.fields, - filters=self.filters, - or_filters=self.or_filters, - start=start, - limit=self.page_length, - order_by="weightage desc" - ) - - # Combine results having context of website item groups into item results - if item_group and website_item_groups: - items_list = {row.name for row in result} - for row in website_item_groups: - if row.wig_parent not in items_list: - result.append(row) - - result = sorted(result, key=lambda x: x.get("weightage"), reverse=True) - - for item in result: - product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') - if product_info: - item.formatted_price = (product_info.get('price') or {}).get('formatted_price') - - return result - - def build_fields_filters(self, filters): - """Build filters for field values - - Args: - filters (dict): Filters - """ - for field, values in filters.items(): - if not values: - continue - - # handle multiselect fields in filter addition - meta = frappe.get_meta('Item', cached=True) - df = meta.get_field(field) - if df.fieldtype == 'Table MultiSelect': - child_doctype = df.options - child_meta = frappe.get_meta(child_doctype, cached=True) - fields = child_meta.get("fields") - if fields: - self.filters.append([child_doctype, fields[0].fieldname, 'IN', values]) - elif isinstance(values, list): - # If value is a list use `IN` query - self.filters.append([field, 'IN', values]) - else: - # `=` will be faster than `IN` for most cases - self.filters.append([field, '=', values]) - - def build_search_filters(self, search_term): - """Query search term in specified fields - - Args: - search_term (str): Search candidate - """ - # Default fields to search from - default_fields = {'name', 'item_name', 'description', 'item_group'} - - # Get meta search fields - meta = frappe.get_meta("Item") - meta_fields = set(meta.get_search_fields()) - - # Join the meta fields and default fields set - search_fields = default_fields.union(meta_fields) - try: - if frappe.db.count('Item', cache=True) > 50000: - search_fields.remove('description') - except KeyError: - pass - - # Build or filters for query - search = '%{}%'.format(search_term) - self.or_filters += [[field, 'like', search] for field in search_fields] diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 2bf749a8152..e346ea87214 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -17,8 +17,6 @@ frappe.ui.form.on("Item", { frm.fields_dict["attributes"].grid.set_column_disp("attribute_value", true); } - // should never check Private - frm.fields_dict["website_image"].df.is_private = 0; if (frm.doc.is_fixed_asset) { frm.trigger("set_asset_naming_series"); } @@ -91,6 +89,29 @@ frappe.ui.form.on("Item", { erpnext.toggle_naming_series(); } + if (!frm.doc.published_in_website) { + frm.add_custom_button(__("Publish in Website"), function() { + frappe.call({ + method: "erpnext.e_commerce.doctype.website_item.website_item.make_website_item", + args: {doc: frm.doc}, + freeze: true, + freeze_message: __("Publishing Item ..."), + callback: function(result) { + frappe.msgprint({ + message: __("Website Item {0} has been created.", + [repl('%(item)s', { + item_encoded: encodeURIComponent(result.message[0]), + item: result.message[1] + })] + ), + title: __("Published"), + indicator: "green" + }); + } + }); + }, __('Actions')); + } + erpnext.item.edit_prices_button(frm); erpnext.item.toggle_attributes(frm); @@ -182,25 +203,8 @@ frappe.ui.form.on("Item", { } }, - copy_from_item_group: function(frm) { - return frm.call({ - doc: frm.doc, - method: "copy_specification_from_item_group" - }); - }, - has_variants: function(frm) { erpnext.item.toggle_attributes(frm); - }, - - show_in_website: function(frm) { - if (frm.doc.default_warehouse && !frm.doc.website_warehouse){ - frm.set_value("website_warehouse", frm.doc.default_warehouse); - } - }, - - set_meta_tags(frm) { - frappe.utils.set_meta_tag(frm.doc.route); } }); @@ -393,13 +397,15 @@ $.extend(erpnext.item, { edit_prices_button: function(frm) { frm.add_custom_button(__("Add / Edit Prices"), function() { frappe.set_route("List", "Item Price", {"item_code": frm.doc.name}); - }, __("View")); + }, __("Actions")); }, - weight_to_validate: function(frm){ - if((frm.doc.nett_weight || frm.doc.gross_weight) && !frm.doc.weight_uom) { - frappe.msgprint(__('Weight is mentioned,\nPlease mention "Weight UOM" too')); - frappe.validated = 0; + weight_to_validate: function(frm) { + if (frm.doc.weight_per_unit && !frm.doc.weight_uom) { + frappe.msgprint({ + message: __("Please mention 'Weight UOM' along with Weight."), + title: __("Note") + }); } }, diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index db5caf91645..3dcb2122c70 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -116,30 +116,14 @@ "customer_code", "default_item_manufacturer", "default_manufacturer_part_no", - "website_section", - "show_in_website", - "show_variant_in_website", - "route", - "weightage", - "slideshow", - "website_image", - "website_image_alt", - "thumbnail", - "cb72", - "website_warehouse", - "website_item_groups", - "set_meta_tags", - "sb72", - "copy_from_item_group", - "website_specifications", - "web_long_description", - "website_content", - "total_projected_qty", "hub_publishing_sb", "publish_in_hub", "hub_category_to_publish", "hub_warehouse", - "synced_with_hub" + "synced_with_hub", + "more_information_section", + "published_in_website", + "total_projected_qty" ], "fields": [ { @@ -869,125 +853,6 @@ "no_copy": 1, "print_hide": 1 }, - { - "collapsible": 1, - "depends_on": "eval:!doc.is_fixed_asset", - "fieldname": "website_section", - "fieldtype": "Section Break", - "label": "Website", - "options": "fa fa-globe" - }, - { - "default": "0", - "depends_on": "eval:!doc.variant_of", - "fieldname": "show_in_website", - "fieldtype": "Check", - "label": "Show in Website", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "variant_of", - "fieldname": "show_variant_in_website", - "fieldtype": "Check", - "label": "Show in Website (Variant)", - "search_index": 1 - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "route", - "fieldtype": "Small Text", - "label": "Route", - "no_copy": 1 - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "Items with higher weightage will be shown higher", - "fieldname": "weightage", - "fieldtype": "Int", - "label": "Weightage" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "Show a slideshow at the top of the page", - "fieldname": "slideshow", - "fieldtype": "Link", - "label": "Slideshow", - "options": "Website Slideshow" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "Item Image (if not slideshow)", - "fieldname": "website_image", - "fieldtype": "Attach", - "label": "Website Image" - }, - { - "fieldname": "thumbnail", - "fieldtype": "Data", - "label": "Thumbnail", - "read_only": 1 - }, - { - "fieldname": "cb72", - "fieldtype": "Column Break" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "Show \"In Stock\" or \"Not in Stock\" based on stock available in this warehouse.", - "fieldname": "website_warehouse", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Website Warehouse", - "options": "Warehouse" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "List this Item in multiple groups on the website.", - "fieldname": "website_item_groups", - "fieldtype": "Table", - "label": "Website Item Groups", - "options": "Website Item Group" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "set_meta_tags", - "fieldtype": "Button", - "label": "Set Meta Tags" - }, - { - "collapsible": 1, - "collapsible_depends_on": "website_specifications", - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "sb72", - "fieldtype": "Section Break", - "label": "Website Specifications" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "copy_from_item_group", - "fieldtype": "Button", - "label": "Copy From Item Group" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "website_specifications", - "fieldtype": "Table", - "label": "Website Specifications", - "options": "Item Website Specification" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "web_long_description", - "fieldtype": "Text Editor", - "label": "Website Description" - }, - { - "description": "You can use any valid Bootstrap 4 markup in this field. It will be shown on your Item Page.", - "fieldname": "website_content", - "fieldtype": "HTML Editor", - "label": "Website Content" - }, { "fieldname": "total_projected_qty", "fieldtype": "Float", @@ -1065,13 +930,20 @@ "read_only": 1 }, { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "website_image_alt", - "fieldtype": "Data", - "label": "Image Description" + "collapsible": 1, + "fieldname": "more_information_section", + "fieldtype": "Section Break", + "label": "More Information" + }, + { + "default": "0", + "depends_on": "published_in_website", + "fieldname": "published_in_website", + "fieldtype": "Check", + "label": "Published in Website", + "read_only": 1 } ], - "has_web_view": 1, "icon": "fa fa-tag", "idx": 2, "image_field": "image", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index ca587bfe82c..d18a34c9159 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -13,11 +13,9 @@ from frappe import _, msgprint from frappe.utils import (cint, cstr, flt, formatdate, getdate, now_datetime, random_string, strip, get_link_to_form, nowtime) from frappe.utils.html_utils import clean_html -from frappe.website.doctype.website_slideshow.website_slideshow import \ - get_slideshow from frappe.website.render import clear_cache -from frappe.website.website_generator import WebsiteGenerator +from frappe.model.document import Document class DuplicateReorderRows(frappe.ValidationError): @@ -31,18 +29,11 @@ class StockExistsForTemplate(frappe.ValidationError): class InvalidBarcode(frappe.ValidationError): pass +class DataValidationError(frappe.ValidationError): + pass -class Item(WebsiteGenerator): - website = frappe._dict( - page_title_field="item_name", - condition_field="show_in_website", - template="templates/generators/item/item.html", - no_cache=1 - ) - +class Item(Document): def onload(self): - super(Item, self).onload() - self.set_onload('stock_exists', self.stock_ledger_created()) self.set_asset_naming_series() @@ -83,8 +74,6 @@ class Item(WebsiteGenerator): self.set_opening_stock() def validate(self): - super(Item, self).validate() - if not self.item_name: self.item_name = self.item_code @@ -111,8 +100,6 @@ class Item(WebsiteGenerator): self.validate_attributes() self.validate_variant_attributes() self.validate_variant_based_on_change() - self.validate_website_image() - self.make_thumbnail() self.validate_fixed_asset() self.validate_retain_sample() self.validate_uom_conversion_factor() @@ -121,22 +108,18 @@ class Item(WebsiteGenerator): self.update_defaults_from_item_group() self.validate_auto_reorder_enabled_in_stock_settings() self.cant_change() - self.update_show_in_website() self.validate_item_tax_net_rate_range() set_item_tax_from_hsn_code(self) if not self.is_new(): self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group") - self.old_website_item_groups = frappe.db.sql_list("""select item_group - from `tabWebsite Item Group` - where parentfield='website_item_groups' and parenttype='Item' and parent=%s""", self.name) def on_update(self): invalidate_cache_for_item(self) self.validate_name_with_item_group() self.update_variants() self.update_item_price() - self.update_template_item() + self.update_website_item() def validate_description(self): '''Clean HTML description if set''' @@ -196,95 +179,6 @@ class Item(WebsiteGenerator): stock_entry.add_comment("Comment", _("Opening Stock")) - def make_route(self): - if not self.route: - return cstr(frappe.db.get_value('Item Group', self.item_group, - 'route')) + '/' + self.scrub((self.item_name or self.item_code) + '-' + random_string(5)) - - def validate_website_image(self): - if frappe.flags.in_import: - return - - """Validate if the website image is a public file""" - auto_set_website_image = False - if not self.website_image and self.image: - auto_set_website_image = True - self.website_image = self.image - - if not self.website_image: - return - - # find if website image url exists as public - file_doc = frappe.get_all("File", filters={ - "file_url": self.website_image - }, fields=["name", "is_private"], order_by="is_private asc", limit_page_length=1) - - if file_doc: - file_doc = file_doc[0] - - if not file_doc: - if not auto_set_website_image: - frappe.msgprint(_("Website Image {0} attached to Item {1} cannot be found").format(self.website_image, self.name)) - - self.website_image = None - - elif file_doc.is_private: - if not auto_set_website_image: - frappe.msgprint(_("Website Image should be a public file or website URL")) - - self.website_image = None - - def make_thumbnail(self): - if frappe.flags.in_import: - return - - """Make a thumbnail of `website_image`""" - import requests.exceptions - - if not self.is_new() and self.website_image != frappe.db.get_value(self.doctype, self.name, "website_image"): - self.thumbnail = None - - if self.website_image and not self.thumbnail: - file_doc = None - - try: - file_doc = frappe.get_doc("File", { - "file_url": self.website_image, - "attached_to_doctype": "Item", - "attached_to_name": self.name - }) - except frappe.DoesNotExistError: - # cleanup - frappe.local.message_log.pop() - - except requests.exceptions.HTTPError: - frappe.msgprint(_("Warning: Invalid attachment {0}").format(self.website_image)) - self.website_image = None - - except requests.exceptions.SSLError: - frappe.msgprint( - _("Warning: Invalid SSL certificate on attachment {0}").format(self.website_image)) - self.website_image = None - - # for CSV import - if self.website_image and not file_doc: - try: - file_doc = frappe.get_doc({ - "doctype": "File", - "file_url": self.website_image, - "attached_to_doctype": "Item", - "attached_to_name": self.name - }).save() - - except IOError: - self.website_image = None - - if file_doc: - if not file_doc.thumbnail_url: - file_doc.make_thumbnail() - - self.thumbnail = file_doc.thumbnail_url - def validate_fixed_asset(self): if self.is_fixed_asset: if self.is_stock_item: @@ -308,167 +202,6 @@ class Item(WebsiteGenerator): frappe.throw(_("{0} Retain Sample is based on batch, please check Has Batch No to retain sample of item").format( self.item_code)) - def get_context(self, context): - context.show_search = True - context.search_link = '/product_search' - - context.parents = get_parent_item_groups(self.item_group) - context.body_class = "product-page" - - self.set_variant_context(context) - self.set_attribute_context(context) - self.set_disabled_attributes(context) - self.set_metatags(context) - self.set_shopping_cart_data(context) - - return context - - def set_variant_context(self, context): - if self.has_variants: - context.no_cache = True - - # load variants - # also used in set_attribute_context - context.variants = frappe.get_all("Item", - filters={"variant_of": self.name, "show_variant_in_website": 1}, - order_by="name asc") - - variant = frappe.form_dict.variant - if not variant and context.variants: - # the case when the item is opened for the first time from its list - variant = context.variants[0] - - if variant: - context.variant = frappe.get_doc("Item", variant) - - for fieldname in ("website_image", "website_image_alt", "web_long_description", "description", - "website_specifications"): - if context.variant.get(fieldname): - value = context.variant.get(fieldname) - if isinstance(value, list): - value = [d.as_dict() for d in value] - - context[fieldname] = value - - if self.slideshow: - if context.variant and context.variant.slideshow: - context.update(get_slideshow(context.variant)) - else: - context.update(get_slideshow(self)) - - def set_attribute_context(self, context): - if not self.has_variants: - return - - attribute_values_available = {} - context.attribute_values = {} - context.selected_attributes = {} - - # load attributes - for v in context.variants: - v.attributes = frappe.get_all("Item Variant Attribute", - fields=["attribute", "attribute_value"], - filters={"parent": v.name}) - # make a map for easier access in templates - v.attribute_map = frappe._dict({}) - for attr in v.attributes: - v.attribute_map[attr.attribute] = attr.attribute_value - - for attr in v.attributes: - values = attribute_values_available.setdefault(attr.attribute, []) - if attr.attribute_value not in values: - values.append(attr.attribute_value) - - if v.name == context.variant.name: - context.selected_attributes[attr.attribute] = attr.attribute_value - - # filter attributes, order based on attribute table - for attr in self.attributes: - values = context.attribute_values.setdefault(attr.attribute, []) - - if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")): - for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt): - values.append(val) - - else: - # get list of values defined (for sequence) - for attr_value in frappe.db.get_all("Item Attribute Value", - fields=["attribute_value"], - filters={"parent": attr.attribute}, order_by="idx asc"): - - if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): - values.append(attr_value.attribute_value) - - context.variant_info = json.dumps(context.variants) - - def set_disabled_attributes(self, context): - """Disable selection options of attribute combinations that do not result in a variant""" - if not self.attributes or not self.has_variants: - return - - context.disabled_attributes = {} - attributes = [attr.attribute for attr in self.attributes] - - def find_variant(combination): - for variant in context.variants: - if len(variant.attributes) < len(attributes): - continue - - if "combination" not in variant: - ref_combination = [] - - for attr in variant.attributes: - idx = attributes.index(attr.attribute) - ref_combination.insert(idx, attr.attribute_value) - - variant["combination"] = ref_combination - - if not (set(combination) - set(variant["combination"])): - # check if the combination is a subset of a variant combination - # eg. [Blue, 0.5] is a possible combination if exists [Blue, Large, 0.5] - return True - - for i, attr in enumerate(self.attributes): - if i == 0: - continue - - combination_source = [] - - # loop through previous attributes - for prev_attr in self.attributes[:i]: - combination_source.append([context.selected_attributes.get(prev_attr.attribute)]) - - combination_source.append(context.attribute_values[attr.attribute]) - - for combination in itertools.product(*combination_source): - if not find_variant(combination): - context.disabled_attributes.setdefault(attr.attribute, []).append(combination[-1]) - - def set_metatags(self, context): - context.metatags = frappe._dict({}) - - safe_description = frappe.utils.to_markdown(self.description) - - context.metatags.url = frappe.utils.get_url() + '/' + context.route - - if context.website_image: - if context.website_image.startswith('http'): - url = context.website_image - else: - url = frappe.utils.get_url() + context.website_image - context.metatags.image = url - - context.metatags.description = safe_description[:300] - - context.metatags.title = self.item_name or self.item_code - - context.metatags['og:type'] = 'product' - context.metatags['og:site_name'] = 'ERPNext' - - def set_shopping_cart_data(self, context): - from erpnext.shopping_cart.product_info import get_product_info_for_website - context.shopping_cart = get_product_info_for_website(self.name, skip_quotation_creation=True) - def add_default_uom_in_conversion_factor_table(self): uom_conv_list = [d.uom for d in self.get("uoms")] if self.stock_uom not in uom_conv_list: @@ -483,9 +216,29 @@ class Item(WebsiteGenerator): [self.remove(d) for d in to_remove] - def update_show_in_website(self): - if self.disabled: - self.show_in_website = False + def update_website_item(self): + """Update Website Item if change in Item impacts it.""" + web_item = frappe.db.exists("Website Item", {"item_code": self.item_code}) + + if web_item: + changed = {} + editable_fields = ["item_name", "item_group", "stock_uom", "brand", "description", + "disabled"] + doc_before_save = self.get_doc_before_save() + + for field in editable_fields: + if doc_before_save.get(field) != self.get(field): + if field == "disabled": + changed["published"] = not self.get(field) + else: + changed[field] = self.get(field) + + if not changed: + return + + web_item_doc = frappe.get_doc("Website Item", web_item) + web_item_doc.update(changed) + web_item_doc.save() def validate_item_tax_net_rate_range(self): for tax in self.get('taxes'): @@ -615,7 +368,6 @@ class Item(WebsiteGenerator): (self.item_name, self.description, self.brand, self.name)) def on_trash(self): - super(Item, self).on_trash() frappe.db.sql("""delete from tabBin where item_code=%s""", self.name) frappe.db.sql("delete from `tabItem Price` where item_code=%s", self.name) for variant_of in frappe.get_all("Item", filters={"variant_of": self.name}): @@ -626,23 +378,15 @@ class Item(WebsiteGenerator): frappe.db.set_value("Item", old_name, "item_name", new_name) if merge: - # Validate properties before merging - if not frappe.db.exists("Item", new_name): - frappe.throw(_("Item {0} does not exist").format(new_name)) - - field_list = ["stock_uom", "is_stock_item", "has_serial_no", "has_batch_no"] - new_properties = [cstr(d) for d in frappe.db.get_value("Item", new_name, field_list)] - if new_properties != [cstr(self.get(fld)) for fld in field_list]: - frappe.throw(_("To merge, following properties must be same for both items") - + ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list])) + self.validate_properties_before_merge(new_name) + self.validate_duplicate_website_item_before_merge(old_name, new_name) def after_rename(self, old_name, new_name, merge): if merge: self.validate_duplicate_item_in_stock_reconciliation(old_name, new_name) - if self.route: + if self.published_in_website: invalidate_cache_for_item(self) - clear_cache(self.route) frappe.db.set_value("Item", new_name, "item_code", new_name) @@ -682,7 +426,41 @@ class Item(WebsiteGenerator): msg += _("Note: To merge the items, create a separate Stock Reconciliation for the old item {0}").format( frappe.bold(old_name)) - frappe.throw(_(msg), title=_("Merge not allowed")) + frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError) + + def validate_properties_before_merge(self, new_name): + # Validate properties before merging + if not frappe.db.exists("Item", new_name): + frappe.throw(_("Item {0} does not exist").format(new_name)) + + field_list = ["stock_uom", "is_stock_item", "has_serial_no", "has_batch_no"] + new_properties = [cstr(d) for d in frappe.db.get_value("Item", new_name, field_list)] + + if new_properties != [cstr(self.get(field)) for field in field_list]: + msg = _("To merge, following properties must be same for both items") + msg += ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list]) + frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError) + + def validate_duplicate_website_item_before_merge(self, old_name, new_name): + """ + Block merge if both old and new items have website items against them. + This is to avoid duplicate website items after merging. + """ + web_items = frappe.get_all( + "Website Item", + filters={ + "item_code": ["in", [old_name, new_name]] + }, + fields=["item_code", "name"]) + + if len(web_items) <= 1: + return + + old_web_item = [d.get("name") for d in web_items if d.get("item_code") == old_name][0] + web_item_link = get_link_to_form("Website Item", old_web_item) + + msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} and {new_name}" + frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError) def set_last_purchase_rate(self, new_name): last_purchase_rate = get_last_purchase_details(new_name).get("base_net_rate", 0) @@ -706,16 +484,6 @@ class Item(WebsiteGenerator): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", existing_allow_negative_stock) frappe.db.auto_commit_on_many_writes = 0 - @frappe.whitelist() - def copy_specification_from_item_group(self): - self.set("website_specifications", []) - if self.item_group: - for label, desc in frappe.db.get_values("Item Website Specification", - {"parent": self.item_group}, ["label", "description"]): - row = self.append("website_specifications") - row.label = label - row.description = desc - def update_bom_item_desc(self): if self.is_new(): return @@ -739,25 +507,6 @@ class Item(WebsiteGenerator): where item_code = %s and docstatus < 2 """, (self.description, self.name)) - def update_template_item(self): - """Set Show in Website for Template Item if True for its Variant""" - if not self.variant_of: - return - - if self.show_in_website: - self.show_variant_in_website = 1 - self.show_in_website = 0 - - if self.show_variant_in_website: - # show template - template_item = frappe.get_doc("Item", self.variant_of) - - if not template_item.show_in_website: - template_item.show_in_website = 1 - template_item.flags.dont_update_variants = True - template_item.flags.ignore_permissions = True - template_item.save() - def validate_item_defaults(self): companies = {row.company for row in self.item_defaults} @@ -1004,47 +753,6 @@ class Item(WebsiteGenerator): if not enabled: frappe.msgprint(msg=_("You have to enable auto re-order in Stock Settings to maintain re-order levels."), title=_("Enable Auto Re-Order"), indicator="orange") - def create_onboarding_docs(self, args): - company = frappe.defaults.get_defaults().get('company') or \ - frappe.db.get_single_value('Global Defaults', 'default_company') - - for i in range(1, args.get('max_count')): - item = args.get('item_' + str(i)) - if item: - default_warehouse = '' - default_warehouse = frappe.db.get_value('Warehouse', filters={ - 'warehouse_name': _('Finished Goods'), - 'company': company - }) - - try: - frappe.get_doc({ - 'doctype': self.doctype, - 'item_code': item, - 'item_name': item, - 'description': item, - 'show_in_website': 1, - 'is_sales_item': 1, - 'is_purchase_item': 1, - 'is_stock_item': 1, - 'item_group': _('Products'), - 'stock_uom': _(args.get('item_uom_' + str(i))), - 'item_defaults': [{ - 'default_warehouse': default_warehouse, - 'company': company - }] - }).insert() - - except frappe.NameError: - pass - else: - if args.get('item_price_' + str(i)): - item_price = flt(args.get('item_price_' + str(i))) - - price_list_name = frappe.db.get_value('Price List', {'selling': 1}) - make_item_price(item, price_list_name, item_price) - price_list_name = frappe.db.get_value('Price List', {'buying': 1}) - make_item_price(item, price_list_name, item_price) def make_item_price(item, price_list_name, item_price): frappe.get_doc({ @@ -1159,14 +867,9 @@ def get_last_purchase_details(item_code, doc_name=None, conversion_rate=1.0): def invalidate_cache_for_item(doc): + """Invalidate Item Group cache and rebuild ItemVariantsCacheManager.""" invalidate_cache_for(doc, doc.item_group) - website_item_groups = list(set((doc.get("old_website_item_groups") or []) - + [d.item_group for d in doc.get({"doctype": "Website Item Group"}) if d.item_group])) - - for item_group in website_item_groups: - invalidate_cache_for(doc, item_group) - if doc.get("old_item_group") and doc.get("old_item_group") != doc.item_group: invalidate_cache_for(doc, doc.old_item_group) @@ -1174,12 +877,14 @@ def invalidate_cache_for_item(doc): def invalidate_item_variants_cache_for_website(doc): - from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager + """Rebuild ItemVariantsCacheManager via Item or Website Item.""" + from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager item_code = None - if doc.has_variants and doc.show_in_website: - item_code = doc.name - elif doc.variant_of and frappe.db.get_value('Item', doc.variant_of, 'show_in_website'): + is_web_item = doc.get("published_in_website") or doc.get("published") + if doc.has_variants and is_web_item: + item_code = doc.item_code + elif doc.variant_of and frappe.db.get_value('Item', doc.variant_of, 'published_in_website'): item_code = doc.variant_of if item_code: @@ -1303,10 +1008,6 @@ def update_variants(variants, template, publish_progress=True): if publish_progress: frappe.publish_progress(count / total * 100, title=_("Updating Variants...")) -def on_doctype_update(): - # since route is a Text column, it needs a length for indexing - frappe.db.add_index("Item", ["route(500)"]) - @erpnext.allow_regional def set_item_tax_from_hsn_code(item): pass diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 7a9985d7f07..d0bf2a5c665 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -506,19 +506,6 @@ class TestItem(unittest.TestCase): self.assertIsInstance(count, int) self.assertTrue(count >= 0) - def test_index_creation(self): - "check if index is getting created in db" - from erpnext.stock.doctype.item.item import on_doctype_update - on_doctype_update() - - indices = frappe.db.sql("show index from tabItem", as_dict=1) - expected_columns = {"item_code", "item_name", "item_group", "route"} - for index in indices: - expected_columns.discard(index.get("Column_name")) - - if expected_columns: - self.fail(f"Expected db index on these columns: {', '.join(expected_columns)}") - def test_attribute_completions(self): expected_attrs = {"Small", "Extra Small", "Extra Large", "Large", "2XL", "Medium"} diff --git a/erpnext/stock/doctype/item/test_records.json b/erpnext/stock/doctype/item/test_records.json index 6cec85288fe..91c77d51529 100644 --- a/erpnext/stock/doctype/item/test_records.json +++ b/erpnext/stock/doctype/item/test_records.json @@ -40,9 +40,7 @@ "conversion_factor": 10.0 } ], - "stock_uom": "_Test UOM", - "show_in_website": 1, - "website_warehouse": "_Test Warehouse - _TC" + "stock_uom": "_Test UOM" }, { "description": "_Test Item 2", @@ -56,8 +54,6 @@ "item_group": "_Test Item Group", "item_name": "_Test Item 2", "stock_uom": "_Test UOM", - "show_in_website": 1, - "website_warehouse": "_Test Warehouse - _TC", "gst_hsn_code": "999800", "opening_stock": 10, "valuation_rate": 100, @@ -311,8 +307,7 @@ "warehouse_reorder_level": 20, "warehouse_reorder_qty": 20 } - ], - "show_in_website": 1 + ] }, { "description": "_Test Item 1", @@ -344,9 +339,7 @@ "warehouse_reorder_qty": 20 } ], - "stock_uom": "_Test UOM", - "show_in_website": 1, - "website_warehouse": "_Test Warehouse Group-C1 - _TC" + "stock_uom": "_Test UOM" }, { "description": "_Test Item With Item Tax Template", diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js index e8fb34732fc..cb9abc08e25 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js @@ -4,8 +4,8 @@ frappe.ui.form.on('Item Variant Settings', { setup: function(frm) { const allow_fields = []; - const exclude_fields = ["naming_series", "item_code", "item_name", "show_in_website", - "show_variant_in_website", "opening_stock", "variant_of", "valuation_rate"]; + const exclude_fields = ["naming_series", "item_code", "item_name", "published_in_website", + "opening_stock", "variant_of", "valuation_rate"]; frappe.model.with_doctype('Item', () => { frappe.get_meta('Item').fields.forEach(d => { diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py index 78f1131b769..714a89f749e 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py @@ -13,10 +13,9 @@ class ItemVariantSettings(Document): def set_default_fields(self): self.fields = [] fields = frappe.get_meta('Item').fields - exclude_fields = {"naming_series", "item_code", "item_name", "show_in_website", - "show_variant_in_website", "standard_rate", "opening_stock", "image", "description", + exclude_fields = {"naming_series", "item_code", "item_name", "published_in_website", + "standard_rate", "opening_stock", "image", "description", "variant_of", "valuation_rate", "description", "barcodes", - "website_image", "thumbnail", "website_specifiations", "web_long_description", "has_variants", "attributes"} for d in fields: diff --git a/erpnext/stock/doctype/price_list/price_list.py b/erpnext/stock/doctype/price_list/price_list.py index 002d3d898eb..e4fc291493e 100644 --- a/erpnext/stock/doctype/price_list/price_list.py +++ b/erpnext/stock/doctype/price_list/price_list.py @@ -36,12 +36,12 @@ class PriceList(Document): (self.currency, cint(self.buying), cint(self.selling), self.name)) def check_impact_on_shopping_cart(self): - "Check if Price List currency change impacts Shopping Cart." - from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import validate_cart_settings + "Check if Price List currency change impacts E Commerce Cart." + from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import validate_cart_settings doc_before_save = self.get_doc_before_save() currency_changed = self.currency != doc_before_save.currency - affects_cart = self.name == frappe.get_cached_value("Shopping Cart Settings", None, "price_list") + affects_cart = self.name == frappe.get_cached_value("E Commerce Settings", None, "price_list") if currency_changed and affects_cart: validate_cart_settings() diff --git a/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json b/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json deleted file mode 100644 index 5ee316786ca..00000000000 --- a/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "add_more_button": 1, - "app": "ERPNext", - "creation": "2019-11-15 14:41:12.007359", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 3, - "modified": "2019-12-09 17:54:09.602885", - "modified_by": "Administrator", - "name": "Add A Few Products You Buy Or Sell", - "owner": "Administrator", - "ref_doctype": "Item", - "slide_desc": "", - "slide_fields": [ - { - "align": "", - "fieldname": "item", - "fieldtype": "Data", - "label": "Item", - "placeholder": "Product Name", - "reqd": 1 - }, - { - "align": "", - "fieldname": "item_price", - "fieldtype": "Currency", - "label": "Item Price", - "reqd": 1 - }, - { - "align": "", - "fieldtype": "Column Break", - "reqd": 0 - }, - { - "align": "", - "fieldname": "uom", - "fieldtype": "Link", - "label": "UOM", - "options": "UOM", - "reqd": 1 - } - ], - "slide_order": 30, - "slide_title": "Add A Few Products You Buy Or Sell", - "slide_type": "Create" -} \ No newline at end of file diff --git a/erpnext/templates/generators/item/item.html b/erpnext/templates/generators/item/item.html index 663ea79f4ec..89436343c7a 100644 --- a/erpnext/templates/generators/item/item.html +++ b/erpnext/templates/generators/item/item.html @@ -1,4 +1,5 @@ {% extends "templates/web.html" %} +{% from "erpnext/templates/includes/macros.html" import recommended_item_row %} {% block title %} {{ title }} {% endblock %} @@ -9,21 +10,65 @@ {% endblock %} {% block page_content %} -
+
{% from "erpnext/templates/includes/macros.html" import product_image %}
+
{% include "templates/generators/item/item_image.html" %} {% include "templates/generators/item/item_details.html" %}
- - {% include "templates/generators/item/item_specifications.html" %} - - {{ doc.website_content or '' }}
+ + +
+ {% set show_recommended_items = recommended_items and shopping_cart.cart_settings.enable_recommendations %} + {% set info_col = 'col-9' if show_recommended_items else 'col-12' %} + + {% set padding_top = 'pt-0' if (show_tabs and tabs) else '' %} + +
+
+
+ + {% if show_tabs and tabs %} +
+ + {{ web_block("Section with Tabs", values=tabs, add_container=0, + add_top_padding=0, add_bottom_padding=0) + }} +
+ {% elif website_specifications %} + {% include "templates/generators/item/item_specifications.html"%} + {% endif %} + + + {{ doc.website_content or '' }} + + + {% if shopping_cart.cart_settings.enable_reviews and not doc.has_variants %} + {% include "templates/generators/item/item_reviews.html"%} + {% endif %} +
+
+
+ + + {% if show_recommended_items %} + + {% endif %} + +
{% endblock %} {% block base_scripts %} diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html index 167c848eff1..8000a2446be 100644 --- a/erpnext/templates/generators/item/item_add_to_cart.html +++ b/erpnext/templates/generators/item/item_add_to_cart.html @@ -5,54 +5,115 @@
+ {% if cart_settings.show_price and product_info.price %} -
- {{ product_info.price.formatted_price_sales_uom }} - ({{ product_info.price.formatted_price }} / {{ product_info.uom }}) -
+ {% set price_info = product_info.price %} + +
+ + {{ price_info.formatted_price_sales_uom }} + + + {% if price_info.formatted_mrp %} + + MRP {{ price_info.formatted_mrp }} + + + -{{ price_info.get("formatted_discount_percent") or price_info.get("formatted_discount_rate")}} + + {% endif %} + + + + ({{ price_info.formatted_price }} / {{ product_info.uom }}) + +
{% else %} {{ _("UOM") }} : {{ product_info.uom }} {% endif %} {% if cart_settings.show_stock_availability %} -
- {% if product_info.in_stock == 0 %} - - {{ _('Not in stock') }} - +
+ {% if product_info.get("on_backorder") %} + + {{ _('Available on backorder') }} + + {% elif product_info.in_stock == 0 %} + + {{ _('Out of stock') }} + {% elif product_info.in_stock == 1 %} - - {{ _('In stock') }} - {% if product_info.show_stock_qty and product_info.stock_qty %} - ({{ product_info.stock_qty[0][0] }}) - {% endif %} - + + {{ _('In stock') }} + {% if product_info.show_stock_qty and product_info.stock_qty %} + ({{ product_info.stock_qty[0][0] }}) + {% endif %} + {% endif %}
{% endif %} -
- {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %} - - {{ _("View in Cart") }} - - - {% endif %} - {% if cart_settings.show_contact_us_button %} - {% include "templates/generators/item/item_inquiry.html" %} - {% endif %} +
+

+ {{ _(offer.offer_title) }}: + {{ _(offer.offer_subtitle) if offer.offer_subtitle else '' }} + + {{ _("More") }} + +

+
+ {% endfor %} +
+ {% endif %} + + +
+
+ + {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %} + + + {% endif %} + + + {% if cart_settings.show_contact_us_button %} + {% include "templates/generators/item/item_inquiry.html" %} + {% endif %} +
@@ -60,10 +121,11 @@ {% endif %} diff --git a/erpnext/templates/generators/item/item_configure.html b/erpnext/templates/generators/item/item_configure.html index b61ac73072d..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 8eadb842899..b5f92989ef4 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(); @@ -201,7 +201,7 @@ class ItemConfigure { ${frappe.utils.icon('assets', 'md')} - ${__("Add to Cart")}s + ${__("Add to Cart")} ` : ''; @@ -214,7 +214,7 @@ class ItemConfigure { ? `