diff --git a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py index 5ba0691331..ca482c8c4e 100644 --- a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py +++ b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py @@ -39,9 +39,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 6a84a65e71..d72d8f7018 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -291,7 +291,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 @@ -435,13 +435,13 @@ def get_existing_payment_request_amount(ref_dt, ref_dn): """, (ref_dt, ref_dn)) return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0 -def get_gateway_details(args): +def get_gateway_details(args): # nosemgrep """return gateway and payment account of default payment gateway""" if args.get("payment_gateway_account"): 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 a16377c847..2d94bc376a 100644 --- a/erpnext/accounts/doctype/tax_rule/tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/tax_rule.py @@ -98,7 +98,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/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 14d2ccdb50..4f9ff43cd4 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -131,28 +131,6 @@ class Supplier(TransactionBase): if frappe.defaults.get_global_default('supp_master_name') == 'Supplier Name': frappe.db.set(self, "supplier_name", newdn) - 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')): - supplier = args.get('supplier_name_' + str(i)) - if supplier: - try: - doc = frappe.get_doc({ - 'doctype': self.doctype, - 'supplier_name': supplier, - 'supplier_group': _('Local'), - 'company': company - }).insert() - - if args.get('supplier_email_' + str(i)): - from erpnext.selling.doctype.customer.customer import create_contact - create_contact(supplier, 'Supplier', - doc.name, args.get('supplier_email_' + str(i))) - except frappe.NameError: - pass - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters): 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 ce3d8cfb7b..0000000000 --- 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 2bad6f8e9f..68ad702b97 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -132,7 +132,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: @@ -262,9 +262,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"] if item.variant_based_on=='Manufacturer': # don't copy manufacturer values if based on part no 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 0000000000..43cb36ca2e --- /dev/null +++ b/erpnext/e_commerce/api.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe.utils import cint + +from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder +from erpnext.e_commerce.product_data_engine.query import ProductQuery +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) + + query_args = frappe._dict(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 b38828e0d7..6302d260e0 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 0000000000..d5fb9697f8 --- /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 52% 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 4a755998dd..dd7b114289 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,81 @@ -# 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 - import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import flt +from frappe.utils import comma_and, flt, unique + +from erpnext.e_commerce.redisearch_utils 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 +116,23 @@ class ShoppingCartSettings(Document): def get_shipping_rules(self, shipping_territory): return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule") + 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=None, method=None): - frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings").run_method("validate") + 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 67% 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 c3809b30b0..86cef30d98 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,24 +1,21 @@ -# 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 import unittest import frappe -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( ShoppingCartSetupError, ) -class TestShoppingCartSettings(unittest.TestCase): +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. @@ -34,15 +31,17 @@ class TestShoppingCartSettings(unittest.TestCase): # cart_settings = self.get_cart_settings() # cart_settings.price_list = "_Test Price List Rest of the World" - # self.assertRaises(ShoppingCartSetupError, cart_settings.validate_price_list_exchange_rate) + # self.assertRaises(ShoppingCartSetupError, cart_settings.validate_exchange_rates_exist) - # from erpnext.setup.doctype.currency_exchange.test_currency_exchange import test_records as \ - # currency_exchange_records + # from erpnext.setup.doctype.currency_exchange.test_currency_exchange import ( + # test_records as currency_exchange_records, + # ) # frappe.get_doc(currency_exchange_records[0]).insert() - # cart_settings.validate_price_list_exchange_rate() + # cart_settings.validate_exchange_rates_exist() def test_tax_rule_validation(self): frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0") + frappe.db.commit() # nosemgrep cart_settings = self.get_cart_settings() cart_settings.enabled = 1 @@ -51,4 +50,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 0000000000..a57c370287 --- /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 0000000000..57f719fc3c --- /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 0000000000..966ec350e7 --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/item_review.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from datetime import datetime + +import frappe +from frappe import _ +from frappe.contacts.doctype.contact.contact import get_contact_name +from frappe.model.document import Document +from frappe.utils import cint, flt + +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 0000000000..8a4befc800 --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/test_item_review.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +import unittest + +import frappe +from frappe.core.doctype.user_permission.test_user_permission import create_user + +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import ( + setup_e_commerce_settings, +) +from erpnext.e_commerce.doctype.item_review.item_review import ( + UnverifiedReviewer, + add_item_review, + get_item_reviews, +) +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.shopping_cart.cart import get_party +from erpnext.stock.doctype.item.test_item import make_item + + +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 0000000000..06ac3dc03b --- /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 0000000000..16b6e52047 --- /dev/null +++ b/erpnext/e_commerce/doctype/recommended_items/recommended_items.py @@ -0,0 +1,9 @@ +# 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 0000000000..db123090aa --- /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 0000000000..d7014b453a --- /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 0000000000..b39e4dfb51 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest + +import frappe + +from erpnext.controllers.item_variant import create_variant +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( + get_shopping_cart_settings, +) +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import ( + setup_e_commerce_settings, +) +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website +from erpnext.stock.doctype.item.item import DataValidationError +from erpnext.stock.doctype.item.test_item import make_item + +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_user_and_customer_if_not_exists("test_contact_customer@example.com", "_Test Contact For _Test Customer") + create_regular_web_item() + make_web_item_price(item_code="Test Mobile Phone") + + # Note: When testing web item pricing rule logged-in user pricing rule must differ from guest pricing rule or test will falsely pass. + # This is because make_web_pricing_rule creates a pricing rule "selling": 1, without specifying "applicable_for". Therefor, + # when testing for logged-in user the test will get the previous pricing rule because "selling" is still true. + # + # I've attempted to mitigate this by setting applicable_for=Customer, and customer=Guest however, this only results in PermissionError failing the test. + make_web_pricing_rule( + title="Test Pricing Rule for Test Mobile Phone", + item_code="Test Mobile Phone", + selling=1) + make_web_pricing_rule( + title="Test Pricing Rule for Test Mobile Phone (Customer)", + item_code="Test Mobile Phone", + selling=1, + discount_percentage="25", + applicable_for="Customer", + customer="_Test Customer") + + 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 + + # login as customer with pricing rule + frappe.set_user("test_contact_customer@example.com") + + # 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"), 25) + self.assertEqual(price_object.get("price_list_rate"), 750) + self.assertEqual(price_object.get("formatted_mrp"), "₹ 1,000.00") + self.assertEqual(price_object.get("formatted_price"), "₹ 750.00") + self.assertEqual(price_object.get("formatted_discount_percent"), "25%") + + # switch to admin and disable show price + frappe.set_user("Administrator") + setup_e_commerce_settings({"show_price": 0}) + + # price should not be fetched for logged in user. + frappe.set_user("test_contact_customer@example.com") + 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", + "applicable_for": kwargs.get("applicable_for") or "", + "customer": kwargs.get("customer") or "", + }) + pricing_rule.insert() + else: + pricing_rule = frappe.get_doc("Pricing Rule", {"title": title}) + + return pricing_rule + + +def create_user_and_customer_if_not_exists(email, first_name = None): + if frappe.db.exists("User", email): + return + + frappe.get_doc({ + "doctype": "User", + "user_type": "Website User", + "email": email, + "send_welcome_email": 0, + "first_name": first_name or email.split("@")[0] + }).insert(ignore_permissions=True) + + contact = frappe.get_last_doc("Contact", filters={"email_id": email}) + link = contact.append('links', {}) + link.link_doctype = "Customer" + link.link_name = "_Test Customer" + link.link_title = "_Test Customer" + contact.save() + +test_dependencies = ["Price List", "Item Price", "Customer", "Contact", "Item"] 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 0000000000..741e78f4a5 --- /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 0000000000..245042addb --- /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 0000000000..62f7f49b2e --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.utils import cint, cstr, flt, random_string +from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow +from frappe.website.website_generator import WebsiteGenerator + +from erpnext.e_commerce.doctype.item_review.item_review import get_item_reviews +from erpnext.e_commerce.redisearch_utils import ( + delete_item_from_index, + insert_item_to_index, + update_index_for_item, +) +from erpnext.e_commerce.shopping_cart.cart import _set_price_list +from erpnext.setup.doctype.item_group.item_group import ( + get_parent_item_groups, + invalidate_cache_for, +) +from erpnext.utilities.product import get_price + + +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 autoname(self): + # use naming series to accomodate items with same name (different item code) + from frappe.model.naming import make_autoname + + from erpnext.setup.doctype.naming_series.naming_series import get_default_naming_series + + naming_series = get_default_naming_series("Website Item") + if not self.name and naming_series: + self.name = make_autoname(naming_series, doc=self) + + 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"): + wig = frappe.qb.DocType("Website Item Group") + query = ( + frappe.qb.from_(wig) + .select(wig.item_group) + .where( + (wig.parentfield == "website_item_groups") + & (wig.parenttype == "Website Item") + & (wig.parent == self.name) + ) + ) + result = query.run(as_list=True) + + self.old_website_item_groups = [x[0] for x in result] + + 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): + """Make a thumbnail of `website_image`""" + if frappe.flags.in_import or frappe.flags.in_migrate: + return + + 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_metatags(context) + self.set_shopping_cart_data(context) + + settings = context.shopping_cart.cart_settings + + self.get_product_details_section(context) + + if settings.get("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_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_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) + + 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): + ri = frappe.qb.DocType("Recommended Items") + wi = frappe.qb.DocType("Website Item") + + query = ( + frappe.qb.from_(ri) + .join(wi).on(ri.item_code == wi.item_code) + .select( + ri.item_code, ri.route, + ri.website_item_name, + ri.website_item_thumbnail + ).where( + (ri.parent == self.name) + & (wi.published == 1) + ).orderby(ri.idx) + ) + items = query.run(as_dict=True) + + 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 0000000000..21be9428eb --- /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 0000000000..6601dd81f2 --- /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 0000000000..91148b8b04 --- /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 + +# 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 0000000000..627d548146 --- /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 0000000000..d73c132b0e --- /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 + +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 0000000000..e69de29bb2 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 0000000000..504bb65811 --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/test_wishlist.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +import unittest + +import frappe +from frappe.core.doctype.user_permission.test_user_permission import create_user + +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 +from erpnext.stock.doctype.item.test_item import make_item + + +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 0000000000..d96e552ecd --- /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 0000000000..922924e53b --- /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 0000000000..50e3d3a339 --- /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 + +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() # nosemgrep + + 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 0000000000..e69de29bb2 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 0000000000..c0414a7f8e --- /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 0000000000..75ebccbc1b --- /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 + +# 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 5d2de78f7c..752c33e92e 100644 --- a/erpnext/shopping_cart/search.py +++ b/erpnext/e_commerce/legacy_search.py @@ -6,6 +6,7 @@ from whoosh.fields import ID, KEYWORD, TEXT, Schema from whoosh.qparser import FieldsPlugin, MultifieldParser, WildcardPlugin 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 0000000000..c4a3cb9fbe --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/filters.py @@ -0,0 +1,139 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt +import frappe +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] + + if not attributes: + return [] + + result = frappe.get_all( + "Item Variant Attribute", + filters={ + "attribute": ["in", attributes], + "attribute_value": ["is", "set"] + }, + fields=["attribute", "attribute_value"], + distinct=True + ) + + attribute_value_map = {} + for d in result: + attribute_value_map.setdefault(d.attribute, []).append(d.attribute_value) + + out = [] + for name, values in attribute_value_map.items(): + out.append(frappe._dict(name=name, item_attribute_values=values)) + return out + + 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 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 0000000000..007bf8b348 --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/query.py @@ -0,0 +1,301 @@ +# 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.doctype.item_review.item_review import get_customer +from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website +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], + ["published", "=", 1] + ] + ) + 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 0000000000..f0f7918d00 --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py @@ -0,0 +1,117 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import unittest + +import frappe + +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 0000000000..9ec336d156 --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py @@ -0,0 +1,350 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import unittest + +import frappe + +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import ( + setup_e_commerce_settings, +) +from erpnext.e_commerce.doctype.website_item.test_website_item import create_regular_web_item +from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder +from erpnext.e_commerce.product_data_engine.query import ProductQuery + +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] + attribute_values = attribute_filter.item_attribute_values + + 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 + self.assertEqual(len(items), 1) + self.assertEqual(items[0].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.controllers.item_variant import create_variant + from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + from erpnext.stock.doctype.item.test_item import make_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 0000000000..9eb1d45d5f --- /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 0000000000..691cd4d9de --- /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 0000000000..61922459e5 --- /dev/null +++ b/erpnext/e_commerce/product_ui/search.js @@ -0,0 +1,244 @@ +erpnext.ProductSearch = class { + constructor(opts) { + /* Options: search_box_id (for custom search box) */ + $.extend(this, opts); + this.MAX_RECENT_SEARCHES = 4; + this.search_box_id = this.search_box_id || "#search-box"; + this.searchBox = $(this.search_box_id); + + 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(this.search_box_id).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 0000000000..1b5c44038f --- /dev/null +++ b/erpnext/e_commerce/product_ui/views.js @@ -0,0 +1,532 @@ +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(); + + 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"); + + 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"); + + 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_utils.py b/erpnext/e_commerce/redisearch_utils.py new file mode 100644 index 0000000000..59c7f32fd4 --- /dev/null +++ b/erpnext/e_commerce/redisearch_utils.py @@ -0,0 +1,210 @@ +# 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 AutoCompleter, Client, IndexDefinition, Suggestion, TagField, TextField + +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(): + try: + 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 + except Exception: + return False + +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 0000000000..e69de29bb2 diff --git a/erpnext/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py similarity index 88% rename from erpnext/shopping_cart/cart.py rename to erpnext/e_commerce/shopping_cart/cart.py index ebbe233ca3..458cf69af7 100644 --- a/erpnext/shopping_cart/cart.py +++ b/erpnext/e_commerce/shopping_cart/cart.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import frappe import frappe.defaults from frappe import _, throw @@ -11,20 +10,20 @@ from frappe.utils import cint, cstr, flt, get_fullname from frappe.utils.nestedset import get_root_of from erpnext.accounts.utils import get_account_name -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( get_shopping_cart_settings, ) -from erpnext.utilities.product import get_qty_in_stock +from erpnext.utilities.product import get_web_item_qty_in_stock 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"))) + cart_count = cstr(cint(quotation.get("total_qty"))) if hasattr(frappe.local, "cookie_manager"): frappe.local.cookie_manager.set_cookie("cart_count", cart_count) @@ -48,7 +47,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() @@ -72,7 +71,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 @@ -92,13 +91,19 @@ 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)) + throw(_("{0} Not in Stock").format(item.item_code)) if item.qty > item_stock.stock_qty[0][0]: throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty[0][0], item.item_code)) @@ -156,19 +161,19 @@ 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), - "taxes": frappe.render_template("templates/includes/order/order_taxes.html", + "total": frappe.render_template("templates/includes/cart/cart_items_total.html", context), + "taxes_and_totals": frappe.render_template("templates/includes/cart/cart_payment_summary.html", + context) } else: return { - 'name': quotation.name, - 'shopping_cart_menu': get_shopping_cart_menu(context) + 'name': quotation.name } @frappe.whitelist() @@ -265,13 +270,36 @@ 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)) + item_code = d.item_code + fields = ["web_item_name", "thumbnail", "website_image", "description", "route"] + + # Variant Item + if not frappe.db.exists("Website Item", {"item_code": item_code}): + variant_data = frappe.db.get_values( + "Item", + filters={"item_code": item_code}, + fieldname=["variant_of", "item_name", "image"], + as_dict=True + )[0] + item_code = variant_data.variant_of + fields = fields[1:] + d.web_item_name = variant_data.item_name + + if variant_data.image: # get image from variant or template web item + d.thumbnail = variant_data.image + fields = fields[2:] + + d.update(frappe.db.get_value( + "Website Item", + {"item_code": item_code}, + fields, + as_dict=True) + ) return doc @@ -288,7 +316,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-", @@ -343,7 +371,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) @@ -420,7 +448,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 = '' @@ -557,10 +585,20 @@ def get_shipping_rules(quotation=None, cart_settings=None): if quotation.shipping_address_name: country = frappe.db.get_value("Address", quotation.shipping_address_name, "country") if country: - shipping_rules = frappe.db.sql_list("""select distinct sr.name - from `tabShipping Rule Country` src, `tabShipping Rule` sr - where src.country = %s and - sr.disabled != 1 and sr.name = src.parent""", country) + sr_country = frappe.qb.DocType("Shipping Rule Country") + sr = frappe.qb.DocType("Shipping Rule") + query = ( + frappe.qb.from_(sr_country) + .join(sr).on(sr.name == sr_country.parent) + .select(sr.name) + .distinct() + .where( + (sr_country.country == country) + & (sr.disabled != 1) + ) + ) + result = query.run(as_list=True) + shipping_rules = [x[0] for x in result] return shipping_rules diff --git a/erpnext/shopping_cart/product_info.py b/erpnext/e_commerce/shopping_cart/product_info.py similarity index 52% rename from erpnext/shopping_cart/product_info.py rename to erpnext/e_commerce/shopping_cart/product_info.py index 977f12fb9e..595fed01d2 100644 --- a/erpnext/shopping_cart/product_info.py +++ b/erpnext/e_commerce/shopping_cart/product_info.py @@ -1,15 +1,18 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - 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 import ( +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_non_stock_item_status, get_price, get_qty_in_stock +from erpnext.e_commerce.shopping_cart.cart import _get_cart_quotation, _set_price_list +from erpnext.utilities.product import ( + get_non_stock_item_status, + get_price, + get_web_item_qty_in_stock, +) @frappe.whitelist(allow_guest=True) @@ -18,7 +21,11 @@ def get_product_info_for_website(item_code, skip_quotation_creation=False): cart_settings = get_shopping_cart_settings() if not cart_settings.enabled: - return frappe._dict() + # return settings even if cart is disabled + return frappe._dict({ + "product_info": {}, + "cart_settings": cart_settings + }) cart_quotation = frappe._dict() if not skip_quotation_creation: @@ -26,25 +33,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 77% rename from erpnext/shopping_cart/test_shopping_cart.py rename to erpnext/e_commerce/shopping_cart/test_shopping_cart.py index 60c220a087..8519e68d09 100644 --- a/erpnext/shopping_cart/test_shopping_cart.py +++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py @@ -8,8 +8,14 @@ import frappe from frappe.utils import add_months, nowdate from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule -from erpnext.shopping_cart.cart import _get_cart_quotation, get_party, update_cart -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, + get_cart_quotation, + get_party, + update_cart, +) +from erpnext.tests.utils import change_settings, create_test_contact_and_address # test_dependencies = ['Payment Terms Template'] @@ -27,8 +33,14 @@ 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.db.rollback() frappe.set_user("Administrator") self.disable_shopping_cart() @@ -123,6 +135,43 @@ class TestShoppingCart(unittest.TestCase): self.remove_test_quotation(quotation) + @change_settings("E Commerce Settings",{ + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India", + "show_price": 1 + }) + def test_add_item_variant_without_web_item_to_cart(self): + "Test adding Variants having no Website Items in cart via Template Web Item." + from erpnext.controllers.item_variant import create_variant + from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + from erpnext.stock.doctype.item.test_item import make_item + + template_item = make_item("Test-Tshirt-Temp", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + {"attribute": "Test Size"}, + {"attribute": "Test Colour"} + ] + }) + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": "Small", "Test Colour": "Red" + }) + variant.save() + make_website_item(template_item) # publish template not variant + + update_cart("Test-Tshirt-Temp-S-R", 1) + + cart = get_cart_quotation() # test if cart page gets data without errors + doc = cart.get("doc") + + self.assertEqual(doc.get("items")[0].item_name, "Test-Tshirt-Temp-S-R") + + # test if items are rendered without error + frappe.render_template("templates/includes/cart/cart_items.html", cart) + def create_tax_rule(self): tax_rule = frappe.get_test_records("Tax Rule")[0] try: @@ -166,7 +215,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, @@ -196,7 +245,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 84% rename from erpnext/shopping_cart/utils.py rename to erpnext/e_commerce/shopping_cart/utils.py index 5f0c792381..e9745a44d7 100644 --- a/erpnext/shopping_cart/utils.py +++ b/erpnext/e_commerce/shopping_cart/utils.py @@ -1,10 +1,8 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt import frappe -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(): @@ -23,7 +21,7 @@ def set_cart_count(login_manager): 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 will try to fetch existing cart quotation # or create one if non existent (and create a customer too) diff --git a/erpnext/e_commerce/variant_selector/__init__.py b/erpnext/e_commerce/variant_selector/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/portal/product_configurator/item_variants_cache.py b/erpnext/e_commerce/variant_selector/item_variants_cache.py similarity index 82% rename from erpnext/portal/product_configurator/item_variants_cache.py rename to erpnext/e_commerce/variant_selector/item_variants_cache.py index 636ae8d491..bb6b3ef37f 100644 --- a/erpnext/portal/product_configurator/item_variants_cache.py +++ b/erpnext/e_commerce/variant_selector/item_variants_cache.py @@ -44,7 +44,7 @@ class ItemVariantsCacheManager: val = frappe.cache().get_value('ordered_attribute_values_map') if val: return val - all_attribute_values = frappe.db.get_all('Item Attribute Value', + all_attribute_values = frappe.get_all('Item Attribute Value', ['attribute_value', 'idx', 'parent'], order_by='idx asc') ordered_attribute_values_map = frappe._dict({}) @@ -57,22 +57,35 @@ class ItemVariantsCacheManager: def build_cache(self): parent_item_code = self.item_code - attributes = [a.attribute for a in frappe.db.get_all('Item Variant Attribute', - {'parent': parent_item_code}, ['attribute'], order_by='idx asc') + attributes = [ + a.attribute for a in frappe.get_all( + 'Item Variant Attribute', + {'parent': parent_item_code}, + ['attribute'], + order_by='idx asc' + ) ] - item_variants_data = frappe.db.get_all('Item Variant Attribute', - {'variant_of': parent_item_code}, ['parent', 'attribute', 'attribute_value'], + # join with Website Item + item_variants_data = frappe.get_all( + 'Item Variant Attribute', + {'variant_of': parent_item_code}, + ['parent', 'attribute', 'attribute_value'], order_by='name', as_list=1 ) - disabled_items = set([i.name for i in frappe.db.get_all('Item', {'disabled': 1})]) + disabled_items = set( + [i.name for i in frappe.db.get_all('Item', {'disabled': 1})] + ) - attribute_value_item_map = frappe._dict({}) - item_attribute_value_map = frappe._dict({}) + attribute_value_item_map = frappe._dict() + item_attribute_value_map = frappe._dict() + # dont consider variants that are disabled + # pull all other variants item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items] + for row in item_variants_data: item_code, attribute, attribute_value = row # (attr, value) => [item1, item2] 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 0000000000..b83961e6e1 --- /dev/null +++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py @@ -0,0 +1,117 @@ +import frappe + +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.website_item.website_item import make_website_item +from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.tests.utils import ERPNextTestCase + +test_dependencies = ["Item"] + +class TestVariantSelector(ERPNextTestCase): + + @classmethod + def setUpClass(cls): + template_item = make_item("Test-Tshirt-Temp", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + {"attribute": "Test Size"}, + {"attribute": "Test Colour"} + ] + }) + + # create L-R, L-G, M-R, M-G and S-R + for size in ("Large", "Medium",): + for colour in ("Red", "Green",): + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": size, "Test Colour": colour + }) + variant.save() + + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": "Small", "Test Colour": "Red" + }) + variant.save() + + make_website_item(template_item) # publish template not variants + + def test_item_attributes(self): + """ + Test if the right attributes are fetched in the popup. + (Attributes must only come from active items) + + Attribute selection must not be linked to Website Items. + """ + from erpnext.e_commerce.variant_selector.utils import get_attributes_and_values + + attr_data = get_attributes_and_values("Test-Tshirt-Temp") + + self.assertEqual(attr_data[0]["attribute"], "Test Size") + self.assertEqual(attr_data[1]["attribute"], "Test Colour") + self.assertEqual(len(attr_data[0]["values"]), 3) # ['Small', 'Medium', 'Large'] + self.assertEqual(len(attr_data[1]["values"]), 2) # ['Red', 'Green'] + + # disable small red tshirt, now there are no small tshirts. + # but there are some red tshirts + small_variant = frappe.get_doc("Item", "Test-Tshirt-Temp-S-R") + small_variant.disabled = 1 + small_variant.save() # trigger cache rebuild + + attr_data = get_attributes_and_values("Test-Tshirt-Temp") + + # Only L and M attribute values must be fetched since S is disabled + self.assertEqual(len(attr_data[0]["values"]), 2) # ['Medium', 'Large'] + + # teardown + small_variant.disabled = 0 + small_variant.save() + + def test_next_item_variant_values(self): + """ + Test if on selecting an attribute value, the next possible values + are filtered accordingly. + Values that dont apply should not be fetched. + E.g. + There is a ** Small-Red ** Tshirt. No other colour in this size. + On selecting ** Small **, only ** Red ** should be selectable next. + """ + next_values = get_next_attribute_and_values("Test-Tshirt-Temp", selected_attributes={"Test Size": "Small"}) + next_colours = next_values["valid_options_for_attributes"]["Test Colour"] + filtered_items = next_values["filtered_items"] + + self.assertEqual(len(next_colours), 1) + self.assertEqual(next_colours.pop(), "Red") + self.assertEqual(len(filtered_items), 1) + self.assertEqual(filtered_items.pop(), "Test-Tshirt-Temp-S-R") + + def test_exact_match_with_price(self): + """ + Test price fetching and matching of variant without Website Item + """ + from erpnext.e_commerce.doctype.website_item.test_website_item import make_web_item_price + + frappe.set_user("Administrator") + setup_e_commerce_settings({ + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India", + "show_price": 1 + }) + + make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100) + next_values = get_next_attribute_and_values( + "Test-Tshirt-Temp", + selected_attributes={"Test Size": "Small", "Test Colour": "Red"} + ) + print(">>>>", next_values) + price_info = next_values["product_info"]["price"] + + self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R") + self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R") + self.assertEqual(price_info["price_list_rate"], 100.0) + self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00") \ 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 0000000000..33802737ef --- /dev/null +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -0,0 +1,218 @@ +import frappe +from frappe.utils import cint + +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( + get_shopping_cart_settings, +) +from erpnext.e_commerce.shopping_cart.cart import _set_price_list +from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager +from erpnext.utilities.product import get_price + + +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)]) # nosemgrep + 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: + cart_settings = get_shopping_cart_settings() + product_info = get_item_variant_price_dict(exact_match[0], cart_settings) + + if product_info: + product_info["allow_items_not_in_stock"] = cint(cart_settings.allow_items_not_in_stock) + 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 + +def get_item_variant_price_dict(item_code, cart_settings): + if cart_settings.enabled and 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_list = _set_price_list(cart_settings, None) + price = get_price( + item_code, + price_list, + cart_settings.default_customer_group, + cart_settings.company + ) + return {"price": price} + + return None + diff --git a/erpnext/e_commerce/web_template/__init__.py b/erpnext/e_commerce/web_template/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 04fb1d2705..2b1807c965 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 0000000000..e69de29bb2 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 81% 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 fe061d5f5f..07952f056a 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 @@ -23,11 +23,10 @@ {%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%} {%- set item = values['card_' + index + '_item'] -%} {%- if item -%} - {%- set item = frappe.get_doc("Item", item) -%} + {%- set web_item = frappe.get_doc("Website Item", item) -%} {{ item_card( - item.item_name, item.image, item.route, item.description, - None, item.item_group, values['card_' + index + '_featured'], - True, "Center" + web_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 84% 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 ad087b0470..ad9e2a7b24 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", @@ -40,8 +37,8 @@ { "fieldname": "card_1_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -59,8 +56,8 @@ { "fieldname": "card_2_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -79,8 +76,8 @@ { "fieldname": "card_3_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -98,8 +95,8 @@ { "fieldname": "card_4_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -117,8 +114,8 @@ { "fieldname": "card_5_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -136,8 +133,8 @@ { "fieldname": "card_6_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -155,8 +152,8 @@ { "fieldname": "card_7_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -174,8 +171,8 @@ { "fieldname": "card_8_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -193,8 +190,8 @@ { "fieldname": "card_9_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -212,8 +209,8 @@ { "fieldname": "card_10_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -231,8 +228,8 @@ { "fieldname": "card_11_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -250,8 +247,8 @@ { "fieldname": "card_12_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -262,9 +259,9 @@ } ], "idx": 0, - "modified": "2020-11-19 18:48:52.633045", + "modified": "2021-12-21 14:44:59.821335", "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 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 1059c1b251..2eb73741ef 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 0000000000..e69de29bb2 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 06b76af901..6d75a8b1d5 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 ba5f63b48b..0202165d08 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 03c1a1a0b5..29bc36f384 100644 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py +++ b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py @@ -149,7 +149,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 04f5793836..0e290384b4 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" webform_list_context = "erpnext.controllers.website_list_for_contact.get_webform_list_context" calendars = ["Task", "Work Order", "Leave Application", "Sales Order", "Holiday List", "Course Schedule"] @@ -73,7 +73,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 = { @@ -237,10 +237,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/modules.txt b/erpnext/modules.txt index e62e2bcfab..c5705c1763 100644 --- a/erpnext/modules.txt +++ b/erpnext/modules.txt @@ -9,7 +9,6 @@ Manufacturing Stock Support Utilities -Shopping Cart Assets Portal Maintenance @@ -21,4 +20,5 @@ Quality Management Communication Loan Management Payroll -Telephony \ No newline at end of file +Telephony +E-commerce diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 8bd2214f38..eace7ca7f9 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -293,6 +293,9 @@ erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021 erpnext.patches.v13_0.set_operation_time_based_on_operating_cost erpnext.patches.v13_0.create_gst_payment_entry_fields #27-11-2021 erpnext.patches.v13_0.fix_invoice_statuses +erpnext.patches.v13_0.create_website_items #30-09-2021 +erpnext.patches.v13_0.populate_e_commerce_settings +erpnext.patches.v13_0.make_homepage_products_website_items erpnext.patches.v13_0.replace_supplier_item_group_with_party_specific_item erpnext.patches.v13_0.update_dates_in_tax_withholding_category erpnext.patches.v14_0.update_opportunity_currency_fields @@ -314,6 +317,7 @@ erpnext.patches.v13_0.item_naming_series_not_mandatory erpnext.patches.v14_0.delete_healthcare_doctypes erpnext.patches.v13_0.update_category_in_ltds_certificate erpnext.patches.v13_0.create_pan_field_for_india #2 +erpnext.patches.v13_0.fetch_thumbnail_in_website_items erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit erpnext.patches.v13_0.create_ksa_vat_custom_fields # 07-01-2022 erpnext.patches.v14_0.migrate_crm_settings @@ -343,3 +347,5 @@ erpnext.patches.v14_0.restore_einvoice_fields erpnext.patches.v13_0.update_sane_transfer_against erpnext.patches.v12_0.add_company_link_to_einvoice_settings erpnext.patches.v14_0.migrate_cost_center_allocations +erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template +erpnext.patches.v13_0.shopping_cart_to_ecommerce diff --git a/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py b/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py new file mode 100644 index 0000000000..d3ee3f8276 --- /dev/null +++ b/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py @@ -0,0 +1,57 @@ +import json +from typing import List, Union + +import frappe + +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + + +def execute(): + """ + Convert all Item links to Website Item link values in + exisitng 'Item Card Group' Web Page Block data. + """ + frappe.reload_doc("e_commerce", "web_template", "item_card_group") + + blocks = frappe.db.get_all( + "Web Page Block", + filters={"web_template": "Item Card Group"}, + fields=["parent", "web_template_values", "name"] + ) + + fields = generate_fields_to_edit() + + for block in blocks: + web_template_value = json.loads(block.get('web_template_values')) + + for field in fields: + item = web_template_value.get(field) + if not item: + continue + + if frappe.db.exists("Website Item", {"item_code": item}): + website_item = frappe.db.get_value("Website Item", {"item_code": item}) + else: + website_item = make_new_website_item(item) + + if website_item: + web_template_value[field] = website_item + + frappe.db.set_value("Web Page Block", block.name, "web_template_values", json.dumps(web_template_value)) + +def generate_fields_to_edit() -> List: + fields = [] + for i in range(1, 13): + fields.append(f"card_{i}_item") # fields like 'card_1_item', etc. + + return fields + +def make_new_website_item(item: str) -> Union[str, None]: + try: + doc = frappe.get_doc("Item", item) + web_item = make_website_item(doc) # returns [website_item.name, item_name] + return web_item[0] + except Exception: + title = f"{item}: Error while converting to Website Item " + frappe.log_error(title + "for Item Card Group Template" + "\n\n" + frappe.get_traceback(), title=title) + return None 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 0000000000..da162a3ab1 --- /dev/null +++ b/erpnext/patches/v13_0/create_website_items.py @@ -0,0 +1,72 @@ +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("e_commerce", "doctype", "recommended_items") + frappe.reload_doc("e_commerce", "doctype", "e_commerce_settings") + 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", "thumbnail"] + + # get all valid columns (fields) from Item master DB schema + item_table_fields = frappe.db.sql("desc `tabItem`", as_dict=1) # nosemgrep + 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 + ) + total_count = len(items) + + for count, item in enumerate(items, start=1): + if frappe.db.exists("Website Item", {"item_code": item.item_code}): + continue + + # make new 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"): + frappe.db.set_value( + doctype, + {"parenttype": "Item", "parent": item.item_code}, # filters + {"parenttype": "Website Item", "parent": website_item.name} # value dict + ) + + if count % 20 == 0: # commit after every 20 items + frappe.db.commit() + + frappe.utils.update_progress_bar('Creating Website Items', count, total_count) diff --git a/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py b/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py new file mode 100644 index 0000000000..32ad542cf8 --- /dev/null +++ b/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + if frappe.db.has_column("Item", "thumbnail"): + website_item = frappe.qb.DocType("Website Item").as_("wi") + item = frappe.qb.DocType("Item") + + frappe.qb.update(website_item).inner_join(item).on( + website_item.item_code == item.item_code + ).set( + website_item.thumbnail, item.thumbnail + ).where( + website_item.website_image.notnull() + & website_item.thumbnail.isnull() + ).run() 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 0000000000..7a7ddba12d --- /dev/null +++ b/erpnext/patches/v13_0/make_homepage_products_website_items.py @@ -0,0 +1,15 @@ +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 0000000000..8f9ee512fd --- /dev/null +++ b/erpnext/patches/v13_0/populate_e_commerce_settings.py @@ -0,0 +1,62 @@ +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): + singles = frappe.qb.DocType("Singles") + query = ( + frappe.qb.from_(singles) + .select( + singles["field"], singles.value + ).where( + (singles.doctype == doctype) + & (singles["field"].isin(fields)) + ) + ) + data = query.run(as_dict=True) + + # {'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.set_value( + doctype, + {"parent": "Products Settings"}, + { + "parenttype": "E Commerce Settings", + "parent": "E Commerce Settings" + }, + update_modified=False + ) diff --git a/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py b/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py new file mode 100644 index 0000000000..35710a9bb4 --- /dev/null +++ b/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py @@ -0,0 +1,29 @@ +import click +import frappe + + +def execute(): + + frappe.delete_doc("DocType", "Shopping Cart Settings", ignore_missing=True) + frappe.delete_doc("DocType", "Products Settings", ignore_missing=True) + frappe.delete_doc("DocType", "Supplier Item Group", ignore_missing=True) + + if frappe.db.get_single_value("E Commerce Settings", "enabled"): + notify_users() + + +def notify_users(): + + click.secho( + "Shopping cart and Product settings are merged into E-commerce settings.\n" + "Checkout the documentation to learn more:" + "https://docs.erpnext.com/docs/v13/user/manual/en/e_commerce/set_up_e_commerce", + fg="yellow", + ) + + note = frappe.new_doc("Note") + note.title = "New E-Commerce Module" + note.public = 1 + note.notify_on_login = 1 + note.content = """

You are seeing this message because Shopping Cart is enabled on your site.


Shopping Cart Settings and Products settings are now merged into "E Commerce Settings".


You can learn about new and improved E-Commerce features in the official documentation.

  1. https://docs.erpnext.com/docs/v13/user/manual/en/e_commerce/set_up_e_commerce


""" + note.insert(ignore_mandatory=True) diff --git a/erpnext/portal/doctype/homepage/homepage.js b/erpnext/portal/doctype/homepage/homepage.js index c7c66e0055..59f808a315 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 ad27278dc6..73f816d4d4 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 1e056a6dac..8092ba208a 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 01c32efec9..63789e35b5 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 2f8b037164..0000000000 --- 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 2cf8431497..0000000000 --- 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 0e106c634b..0000000000 --- a/erpnext/portal/doctype/products_settings/products_settings.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import frappe -from frappe import _ -from frappe.model.document import Document -from frappe.utils import cint - - -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 66026fc046..0000000000 --- a/erpnext/portal/doctype/products_settings/test_products_settings.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -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 2874dc432c..eed33ec10e 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 b478489920..0000000000 --- a/erpnext/portal/product_configurator/test_product_configurator.py +++ /dev/null @@ -1,143 +0,0 @@ -import unittest - -import frappe -from bs4 import BeautifulSoup -from frappe.utils import get_html_for_route - -from erpnext.portal.product_configurator.utils import get_products_for_website - -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 cf623c8d42..0000000000 --- a/erpnext/portal/product_configurator/utils.py +++ /dev/null @@ -1,446 +0,0 @@ -import frappe -from frappe.utils import cint - -from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager -from erpnext.setup.doctype.item_group.item_group import get_child_groups -from erpnext.shopping_cart.product_info import get_product_info_for_website - - -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 974b51ef0a..24bcab445a 100644 --- a/erpnext/portal/utils.py +++ b/erpnext/portal/utils.py @@ -1,10 +1,10 @@ import frappe from frappe.utils.nestedset import get_root_of -from erpnext.shopping_cart.cart import get_debtors_account -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( +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 def set_default_role(doc, method): diff --git a/erpnext/public/build.json b/erpnext/public/build.json index f8e817770d..569910dd9d 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -7,7 +7,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", @@ -65,5 +66,11 @@ "js/hierarchy-chart.min.js": [ "public/js/hierarchy_chart/hierarchy_chart_desktop.js", "public/js/hierarchy_chart/hierarchy_chart_mobile.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" ] } diff --git a/erpnext/templates/includes/cart.js b/erpnext/public/js/cart.js similarity index 85% rename from erpnext/templates/includes/cart.js rename to erpnext/public/js/cart.js index c390cd171d..69357ee6ef 100644 --- a/erpnext/templates/includes/cart.js +++ b/erpnext/public/js/cart.js @@ -4,8 +4,8 @@ // js inside blog page // 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; $.extend(shopping_cart, { show_error: function(title, text) { @@ -18,8 +18,8 @@ $.extend(shopping_cart, { shopping_cart.bind_place_order(); shopping_cart.bind_request_quotation(); shopping_cart.bind_change_qty(); + shopping_cart.bind_remove_cart_item(); shopping_cart.bind_change_notes(); - shopping_cart.bind_dropdown_cart_buttons(); shopping_cart.bind_coupon_code(); }, @@ -48,7 +48,7 @@ $.extend(shopping_cart, { const address_name = $card.closest('[data-address-name]').attr('data-address-name'); frappe.call({ type: "POST", - method: "erpnext.shopping_cart.cart.update_cart_address", + method: "erpnext.e_commerce.shopping_cart.cart.update_cart_address", freeze: true, args: { address_type, @@ -57,7 +57,7 @@ $.extend(shopping_cart, { callback: function(r) { d.hide(); if (!r.exc) { - $(".cart-tax-items").html(r.message.taxes); + $(".cart-tax-items").html(r.message.total); shopping_cart.parent.find( `.address-container[data-address-type="${address_type}"]` ).html(r.message.address); @@ -129,8 +129,14 @@ $.extend(shopping_cart, { } } input.val(newVal); + + let notes = input.closest("td").siblings().find(".notes").text().trim(); var item_code = input.attr("data-item-code"); - shopping_cart.shopping_cart_update({item_code, qty: newVal}); + shopping_cart.shopping_cart_update({ + item_code, + qty: newVal, + additional_notes: notes + }); }); }, @@ -148,6 +154,18 @@ $.extend(shopping_cart, { }); }, + bind_remove_cart_item: function() { + $(".cart-items").on("click", ".remove-cart-item", (e) => { + const $remove_cart_item_btn = $(e.currentTarget); + var item_code = $remove_cart_item_btn.data("item-code"); + + shopping_cart.shopping_cart_update({ + item_code: item_code, + qty: 0 + }); + }); + }, + render_tax_row: function($cart_taxes, doc, shipping_rules) { var shipping_selector; if(shipping_rules) { @@ -185,7 +203,7 @@ $.extend(shopping_cart, { return frappe.call({ btn: btn, type: "POST", - method: "erpnext.shopping_cart.cart.apply_shipping_rule", + method: "erpnext.e_commerce.shopping_cart.cart.apply_shipping_rule", args: { shipping_rule: rule }, callback: function(r) { if(!r.exc) { @@ -196,12 +214,15 @@ $.extend(shopping_cart, { }, place_order: function(btn) { + shopping_cart.freeze(); + return frappe.call({ type: "POST", - method: "erpnext.shopping_cart.cart.place_order", + method: "erpnext.e_commerce.shopping_cart.cart.place_order", btn: btn, callback: function(r) { if(r.exc) { + shopping_cart.unfreeze(); var msg = ""; if(r._server_messages) { msg = JSON.parse(r._server_messages || []).join("
"); @@ -212,7 +233,6 @@ $.extend(shopping_cart, { .html(msg || frappe._("Something went wrong!")) .toggle(true); } else { - $('.cart-container table').hide(); $(btn).hide(); window.location.href = '/orders/' + encodeURIComponent(r.message); } @@ -221,12 +241,15 @@ $.extend(shopping_cart, { }, request_quotation: function(btn) { + shopping_cart.freeze(); + return frappe.call({ type: "POST", - method: "erpnext.shopping_cart.cart.request_for_quotation", + method: "erpnext.e_commerce.shopping_cart.cart.request_for_quotation", btn: btn, callback: function(r) { if(r.exc) { + shopping_cart.unfreeze(); var msg = ""; if(r._server_messages) { msg = JSON.parse(r._server_messages || []).join("
"); @@ -237,7 +260,6 @@ $.extend(shopping_cart, { .html(msg || frappe._("Something went wrong!")) .toggle(true); } else { - $('.cart-container table').hide(); $(btn).hide(); window.location.href = '/quotations/' + encodeURIComponent(r.message); } @@ -254,7 +276,7 @@ $.extend(shopping_cart, { apply_coupon_code: function(btn) { return frappe.call({ type: "POST", - method: "erpnext.shopping_cart.cart.apply_coupon_code", + method: "erpnext.e_commerce.shopping_cart.cart.apply_coupon_code", btn: btn, args : { applied_code : $('.txtcoupon').val(), @@ -270,7 +292,9 @@ $.extend(shopping_cart, { }); frappe.ready(function() { - $(".cart-icon").hide(); + if (window.location.pathname === "/cart") { + $(".cart-icon").hide(); + } shopping_cart.parent = $(".cart-container"); shopping_cart.bind_events(); }); diff --git a/erpnext/public/js/conf.js b/erpnext/public/js/conf.js index eb709e5e85..a0f56a2d07 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/customer_reviews.js b/erpnext/public/js/customer_reviews.js new file mode 100644 index 0000000000..e13ded6b48 --- /dev/null +++ b/erpnext/public/js/customer_reviews.js @@ -0,0 +1,138 @@ +$(() => { + class CustomerReviews { + constructor() { + this.bind_button_actions(); + this.start = 0; + this.page_length = 10; + } + + bind_button_actions() { + this.write_review(); + this.view_more(); + } + + write_review() { + //TODO: make dialog popup on stray page + $('.page_content').on('click', '.btn-write-review', (e) => { + // Bind action on write a review button + const $btn = $(e.currentTarget); + + let d = new frappe.ui.Dialog({ + title: __("Write a Review"), + fields: [ + {fieldname: "title", fieldtype: "Data", label: "Headline", reqd: 1}, + {fieldname: "rating", fieldtype: "Rating", label: "Overall Rating", reqd: 1}, + {fieldtype: "Section Break"}, + {fieldname: "comment", fieldtype: "Small Text", label: "Your Review"} + ], + primary_action: function() { + let data = d.get_values(); + frappe.call({ + method: "erpnext.e_commerce.doctype.item_review.item_review.add_item_review", + args: { + web_item: $btn.attr('data-web-item'), + title: data.title, + rating: data.rating, + comment: data.comment + }, + freeze: true, + freeze_message: __("Submitting Review ..."), + callback: (r) => { + if (!r.exc) { + frappe.msgprint({ + message: __("Thank you for submitting your review"), + title: __("Review Submitted"), + indicator: "green" + }); + d.hide(); + location.reload(); + } + } + }); + }, + primary_action_label: __('Submit') + }); + d.show(); + }); + } + + view_more() { + $('.page_content').on('click', '.btn-view-more', (e) => { + // Bind action on view more button + const $btn = $(e.currentTarget); + $btn.prop('disabled', true); + + this.start += this.page_length; + let me = this; + + frappe.call({ + method: "erpnext.e_commerce.doctype.item_review.item_review.get_item_reviews", + args: { + web_item: $btn.attr('data-web-item'), + start: me.start, + end: me.page_length + }, + callback: (result) => { + if (result.message) { + let res = result.message; + me.get_user_review_html(res.reviews); + + $btn.prop('disabled', false); + if (res.total_reviews <= (me.start + me.page_length)) { + $btn.hide(); + } + + } + } + }); + }); + + } + + get_user_review_html(reviews) { + let me = this; + let $content = $('.user-reviews'); + + reviews.forEach((review) => { + $content.append(` +
+
+

+ ${__(review.review_title)} +

+
+ ${me.get_review_stars(review.rating)} +
+
+ +
+

+ ${__(review.comment)} +

+
+
+ ${__(review.customer)} + + ${__(review.published_on)} +
+
+ `); + }); + } + + get_review_stars(rating) { + let stars = ``; + for (let i = 1; i < 6; i++) { + let fill_class = i <= rating ? 'star-click' : ''; + stars += ` + + + + `; + } + return stars; + } + } + + new CustomerReviews(); +}); \ No newline at end of file diff --git a/erpnext/public/js/erpnext-web.bundle.js b/erpnext/public/js/erpnext-web.bundle.js index 7db6967923..576abd2686 100644 --- a/erpnext/public/js/erpnext-web.bundle.js +++ b/erpnext/public/js/erpnext-web.bundle.js @@ -1,2 +1,9 @@ import "./website_utils"; +import "./wishlist"; import "./shopping_cart"; +import "./cart"; +import "./customer_reviews"; +import "../../e_commerce/product_ui/list"; +import "../../e_commerce/product_ui/views"; +import "../../e_commerce/product_ui/grid"; +import "../../e_commerce/product_ui/search"; \ No newline at end of file diff --git a/erpnext/public/js/shopping_cart.js b/erpnext/public/js/shopping_cart.js index 6a923ae423..d14740c106 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,9 @@ $.extend(shopping_cart, { } }, - set_cart_count: function() { + set_cart_count: function(animate=false) { + $(".intermediate-empty-cart").remove(); + var cart_count = frappe.get_cookie("cart_count"); if(frappe.session.user==="Guest") { cart_count = 0; @@ -118,24 +121,37 @@ $.extend(shopping_cart, { if(parseInt(cart_count) === 0 || cart_count === undefined) { $cart.css("display", "none"); - $(".cart-items").html('Cart is Empty'); $(".cart-tax-items").hide(); $(".btn-place-order").hide(); - $(".cart-addresses").hide(); + $(".cart-payment-addresses").hide(); + + let intermediate_empty_cart_msg = ` +
+ ${ __("Cart is Empty") } +
+ `; + $(".cart-table").after(intermediate_empty_cart_msg); } else { $cart.css("display", "inline"); + $("#cart-count").text(cart_count); } 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,10 +159,12 @@ $.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); + $(".cart-tax-items").html(r.message.total); + $(".payment-summary").html(r.message.taxes_and_totals); + shopping_cart.set_cart_count(); + if (cart_dropdown != true) { $(".cart-icon").hide(); } @@ -155,35 +173,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 0000000000..f6599e9f6d --- /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 fef1e76154..4b645b9dde 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); } @@ -71,9 +72,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; @@ -83,6 +96,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%; @@ -96,14 +122,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 { @@ -136,15 +176,75 @@ 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; } } [data-doctype="Item Group"], -#page-all-products { +#page-index { .page-header { font-size: 20px; font-weight: 700; @@ -184,28 +284,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 { @@ -242,7 +390,7 @@ body.product-page { max-height: 430px; } - overflow: scroll; + overflow: auto; } .item-slideshow-image { @@ -261,29 +409,116 @@ 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 { + min-height: 100px; + 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; + padding-left: 10px; + 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 { @@ -323,20 +558,73 @@ 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); + 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 { @@ -350,6 +638,7 @@ body.product-page { display: flex; flex-direction: column; justify-content: space-between; + height: fit-content; } .cart-items-header { @@ -357,6 +646,10 @@ body.product-page { } .cart-table { + tr { + margin-bottom: 1rem; + } + th, tr, td { border-color: var(--border-color); border-width: 1px; @@ -374,71 +667,200 @@ body.product-page { color: var(--text-color); } + .cart-item-image { + width: 20%; + min-width: 100px; + img { + max-height: 112px; + } + } + .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; + } +} + +.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-empty.frappe-card { @@ -454,7 +876,7 @@ body.product-page { .address-card { .card-title { - font-size: var(--text-base); + font-size: 14px; font-weight: 500; } @@ -463,27 +885,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 { @@ -493,3 +925,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 d74d5a6df9..7742f26ad1 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -258,30 +258,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 daab6fbb8f..eebde766d3 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -287,7 +287,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 8b53902d32..31a95896bc 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 92d00bcb38..0000000000 --- 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 a8f0674b1f..45b4db81f1 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 9f1eb753d9..4f92240c84 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -1,21 +1,17 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import copy from urllib.parse import quote import frappe from frappe import _ -from frappe.utils import cint, cstr, nowdate +from frappe.utils import cint from frappe.utils.nestedset import NestedSet from frappe.website.utils import clear_cache from frappe.website.website_generator import WebsiteGenerator -from erpnext.shopping_cart.filters import ProductFiltersBuilder -from erpnext.shopping_cart.product_info import set_product_info_for_website -from erpnext.shopping_cart.product_query import ProductQuery -from erpnext.utilities.product import get_qty_in_stock +from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder class ItemGroup(NestedSet, WebsiteGenerator): @@ -67,30 +63,11 @@ class ItemGroup(NestedSet, WebsiteGenerator): self.delete_child_item_groups_key() 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() @@ -114,15 +91,16 @@ 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 @@ -133,91 +111,24 @@ class ItemGroup(NestedSet, WebsiteGenerator): from erpnext.stock.doctype.item.item import validate_item_default_company_links validate_item_default_company_links(self.item_group_defaults) -@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) @@ -233,31 +144,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 f00dc947d2..0000000000 --- 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 37eb67b1d7..0000000000 --- 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 358b921831..74c1bd835d 100644 --- a/erpnext/setup/setup_wizard/operations/company_setup.py +++ b/erpnext/setup/setup_wizard/operations/company_setup.py @@ -29,10 +29,10 @@ def create_fiscal_year_and_company(args): 'domain': args.get('domains')[0] }).insert() -def enable_shopping_cart(args): +def enable_shopping_cart(args): # nosemgrep # 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 9dbf49eae7..cd2738aeaa 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -535,8 +535,8 @@ def create_bank_account(args): # bank account same as a CoA entry pass -def update_shopping_cart_settings(args): - shopping_cart = frappe.get_doc("Shopping Cart Settings") +def update_shopping_cart_settings(args): # nosemgrep + 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 7a4bb20136..0000000000 --- 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 ef0badc8c8..0000000000 --- a/erpnext/shopping_cart/filters.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - - -import frappe - - -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_filters(self): - attributes = [row.attribute for row in self.doc.filter_attributes] - - if not attributes: - return [] - - result = frappe.db.sql( - """ - select - distinct attribute, attribute_value - from - `tabItem Variant Attribute` - where - attribute in %(attributes)s - and attribute_value is not null - """, - {"attributes": attributes}, - as_dict=1, - ) - - attribute_value_map = {} - for d in result: - attribute_value_map.setdefault(d.attribute, []).append(d.attribute_value) - - out = [] - for name, values in attribute_value_map.items(): - out.append(frappe._dict(name=name, item_attribute_values=values)) - return out diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py deleted file mode 100644 index 5cc0505aed..0000000000 --- a/erpnext/shopping_cart/product_query.py +++ /dev/null @@ -1,161 +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 86c702c539..2a30ca11fb 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); } }); @@ -392,13 +396,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 2d28cc09f9..e71cdb37cf 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -117,24 +117,8 @@ "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", + "more_information_section", + "published_in_website", "total_projected_qty" ], "fields": [ @@ -856,125 +840,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", @@ -1017,10 +882,18 @@ "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 }, { "default": "1", @@ -1036,7 +909,6 @@ "label": "Create Grouped Asset" } ], - "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 d99fadca46..b9e8b3f2f1 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -2,12 +2,12 @@ # License: GNU General Public License v3. See license.txt import copy -import itertools import json from typing import List import frappe from frappe import _ +from frappe.model.document import Document from frappe.utils import ( cint, cstr, @@ -17,13 +17,9 @@ from frappe.utils import ( getdate, now_datetime, nowtime, - random_string, strip, ) from frappe.utils.html_utils import clean_html -from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow -from frappe.website.utils import clear_cache -from frappe.website.website_generator import WebsiteGenerator import erpnext from erpnext.controllers.item_variant import ( @@ -33,10 +29,7 @@ from erpnext.controllers.item_variant import ( make_variant_item_code, validate_item_variant_attributes, ) -from erpnext.setup.doctype.item_group.item_group import ( - get_parent_item_groups, - invalidate_cache_for, -) +from erpnext.setup.doctype.item_group.item_group import invalidate_cache_for from erpnext.stock.doctype.item_default.item_default import ItemDefault @@ -51,18 +44,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() @@ -103,8 +89,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 @@ -130,8 +114,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() @@ -140,21 +122,17 @@ class Item(WebsiteGenerator): self.validate_item_defaults() 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.update_variants() self.update_item_price() - self.update_template_item() + self.update_website_item() def validate_description(self): '''Clean HTML description if set''' @@ -216,97 +194,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): - """Validate if the website image is a public file""" - - if frappe.flags.in_import: - return - - 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): - """Make a thumbnail of `website_image`""" - - if frappe.flags.in_import: - return - - 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: @@ -330,167 +217,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): if not self.is_new() and self.has_value_changed("stock_uom"): self.uoms = [] @@ -507,9 +233,29 @@ class Item(WebsiteGenerator): "conversion_factor": 1 }) - 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'): @@ -641,7 +387,6 @@ class Item(WebsiteGenerator): ) 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}): @@ -652,15 +397,8 @@ 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: @@ -668,9 +406,8 @@ class Item(WebsiteGenerator): frappe.msgprint(_("It can take upto few hours for accurate stock values to be visible after merging items."), indicator="orange", title="Note") - 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) @@ -710,7 +447,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) @@ -732,16 +503,6 @@ class Item(WebsiteGenerator): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", existing_allow_negative_stock) - @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 @@ -765,25 +526,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} @@ -1034,47 +776,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({ @@ -1189,14 +890,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) @@ -1204,12 +900,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: @@ -1333,10 +1031,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 0957ce0615..fc45ba99c4 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -536,7 +536,7 @@ class TestItem(ERPNextTestCase): "check if index is getting created in db" indices = frappe.db.sql("show index from tabItem", as_dict=1) - expected_columns = {"item_code", "item_name", "item_group", "route"} + expected_columns = {"item_code", "item_name", "item_group"} for index in indices: expected_columns.discard(index.get("Column_name")) diff --git a/erpnext/stock/doctype/item/test_records.json b/erpnext/stock/doctype/item/test_records.json index 6cec85288f..91c77d5152 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 488920aadb..5e1f7d5322 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js @@ -7,9 +7,8 @@ frappe.ui.form.on('Item Variant Settings', { const existing_fields = frm.doc.fields.map(row => row.field_name); const exclude_fields = [...existing_fields, "naming_series", "item_code", "item_name", - "show_in_website", "show_variant_in_website", "standard_rate", "opening_stock", "image", - "variant_of", "valuation_rate", "barcodes", "website_image", "thumbnail", - "website_specifiations", "web_long_description", "has_variants", "attributes"]; + "published_in_website", "standard_rate", "opening_stock", "image", + "variant_of", "valuation_rate", "barcodes", "has_variants", "attributes"]; const exclude_field_types = ['HTML', 'Section Break', 'Column Break', 'Button', 'Read Only']; 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 f63498b9ac..be1517eb58 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py @@ -1,4 +1,4 @@ -# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt @@ -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 74b823ada3..8a3172e9e2 100644 --- a/erpnext/stock/doctype/price_list/price_list.py +++ b/erpnext/stock/doctype/price_list/price_list.py @@ -36,14 +36,14 @@ 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 ( + "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 5ee316786c..0000000000 --- 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 17f6880293..4070d40d47 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,25 +10,70 @@ {% 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 %} + {{ include_script("frappe-web.bundle.js") }} {{ include_script("controls.bundle.js") }} {{ include_script("dialog.bundle.js") }} diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html index 167c848eff..8000a2446b 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 b61ac73072..e97a275fbd 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 8eadb84289..231ae0587e 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 { ? `