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 index abc1c4eab0b..fc2a56567b7 100644 --- a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json @@ -329,7 +329,7 @@ "fieldname": "redisearch_warning", "fieldtype": "HTML", "label": "Redisearch Warning", - "options": "

Redisearch module not loaded. If you want to use advanced product search features, refer documentation here.

" + "options": "

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

" }, { "default": "0", @@ -379,7 +379,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-08-24 13:40:15.294696", + "modified": "2021-08-24 21:10:45.669526", "modified_by": "Administrator", "module": "E-commerce", "name": "E Commerce Settings", diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py index 1e54146658d..cade54d1060 100644 --- a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py @@ -3,7 +3,7 @@ # For license information, please see license.txt import frappe -from frappe.utils import cint, comma_and +from frappe.utils import comma_and from frappe import _, msgprint from frappe.model.document import Document from frappe.utils import unique diff --git a/erpnext/e_commerce/doctype/item_review/item_review.py b/erpnext/e_commerce/doctype/item_review/item_review.py index b0d8beba756..3f34c4a124b 100644 --- a/erpnext/e_commerce/doctype/item_review/item_review.py +++ b/erpnext/e_commerce/doctype/item_review/item_review.py @@ -15,44 +15,94 @@ class UnverifiedReviewer(frappe.ValidationError): pass class ItemReview(Document): - pass + 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, end, data=None): +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() - settings = get_shopping_cart_settings() - if settings and settings.get("enable_reviews"): - data.reviews = frappe.db.get_all("Item Review", filters={"website_item": web_item}, - fields=["*"], limit_start=cint(start), limit_page_length=cint(end)) + 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) - 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) + return data - # 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 +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() - percent = flt((count / rating_data.total or 1) * 100, 0) if count else 0 - reviews_per_rating.append(percent) + data.reviews = frappe.db.get_all( + "Item Review", + filters={"website_item": web_item}, + fields=["*"], + limit_start=start, + limit_page_length=end + ) - data.reviews_per_rating = reviews_per_rating - data.total_reviews = rating_data.total + rating_data = frappe.db.get_all( + "Item Review", + filters={"website_item": web_item}, + fields=["avg(rating) as average, count(*) as total"] + )[0] - return data + 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": - frappe.throw(_("You are not verified to write a review yet. Please contact us for verification."), - exc=UnverifiedReviewer) + # 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({ @@ -88,5 +138,6 @@ def get_customer(silent=False): elif silent: return None else: - frappe.throw(_("You are not verified to write a review yet. Please contact us for verification."), - exc=UnverifiedReviewer) \ No newline at end of file + # 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/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py index 42161269407..4f3f220e097 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.py +++ b/erpnext/e_commerce/doctype/website_item/website_item.py @@ -204,7 +204,9 @@ class WebsiteItem(WebsiteGenerator): self.get_product_details_section(context) if settings.enable_reviews: - get_item_reviews(self.name, 0, 4, context) + 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}): @@ -219,32 +221,38 @@ class WebsiteItem(WebsiteGenerator): return context def set_variant_context(self, context): - if self.has_variants: - context.no_cache = True + if not self.has_variants: + return - # load variants - # also used in set_attribute_context - context.variants = frappe.get_all( - "Item", - filters={"variant_of": self.item_code, "published_in_website": 1}, - order_by="name asc") + context.no_cache = True + variant = frappe.form_dict.variant - 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] + # load variants + # also used in set_attribute_context + context.variants = frappe.get_all( + "Item", + filters={ + "variant_of": self.item_code, + "published_in_website": 1 + }, + order_by="name asc") - if variant: - context.variant = frappe.get_doc("Item", variant) + # the case when the item is opened for the first time from its list + if not variant and context.variants: + variant = context.variants[0] - 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] + if variant: + context.variant = frappe.get_doc("Item", variant) + fields = ("website_image", "website_image_alt", "web_long_description", "description", + "website_specifications") - context[fieldname] = value + for fieldname in fields: + if context.variant.get(fieldname): + value = context.variant.get(fieldname) + if isinstance(value, list): + value = [d.as_dict() for d in value] + + context[fieldname] = value if self.slideshow: if context.variant and context.variant.slideshow: @@ -253,48 +261,57 @@ class WebsiteItem(WebsiteGenerator): context.update(get_slideshow(self)) def set_attribute_context(self, context): - if self.has_variants: - attribute_values_available = {} - context.attribute_values = {} - context.selected_attributes = {} + if not self.has_variants: + return - # 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 + attribute_values_available = {} + context.attribute_values = {} + context.selected_attributes = {} - for attr in v.attributes: - values = attribute_values_available.setdefault(attr.attribute, []) - if attr.attribute_value not in values: - values.append(attr.attribute_value) + # load attributes + self.set_selected_attributes(context.variants, context, attribute_values_available) - if v.name == context.variant.name: - context.selected_attributes[attr.attribute] = attr.attribute_value + # filter attributes, order based on attribute table + item = frappe.get_cached_doc("Item", self.item_code) + self.set_attribute_values(item.attributes, context, attribute_values_available) - # filter attributes, order based on attribute table - item = frappe.get_cached_doc("Item", self.item_code) - for attr in item.attributes: - values = context.attribute_values.setdefault(attr.attribute, []) + context.variant_info = json.dumps(context.variants) - 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) + 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"]) - 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"): + # make an attribute-value map for easier access in templates + variant.attribute_map = frappe._dict( + { attr.attribute : attr.value for attr in variant.attributes} + ) - if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): - values.append(attr_value.attribute_value) + for attr in variant.attributes: + values = attribute_values_available.setdefault(attr.attribute, []) + if attr.value not in values: + values.append(attr.value) - context.variant_info = json.dumps(context.variants) + if variant.name == context.variant.name: + context.selected_attributes[attr.attribute] = attr.value + + def set_attribute_values(self, attributes, context, attribute_values_available): + for attr in attributes: + values = context.attribute_values.setdefault(attr.attribute, []) + + if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")): + for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt): + values.append(val) + else: + # get list of values defined (for sequence) + for attr_value in frappe.db.get_all("Item Attribute Value", + fields=["attribute_value"], + filters={"parent": attr.attribute}, order_by="idx asc"): + + if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): + values.append(attr_value.attribute_value) def set_disabled_attributes(self, context): """Disable selection options of attribute combinations that do not result in a variant""" diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.py b/erpnext/e_commerce/doctype/wishlist/wishlist.py index 40b5ad90e1e..276ecae10db 100644 --- a/erpnext/e_commerce/doctype/wishlist/wishlist.py +++ b/erpnext/e_commerce/doctype/wishlist/wishlist.py @@ -50,16 +50,19 @@ def add_to_wishlist(item_code): @frappe.whitelist() def remove_from_wishlist(item_code): if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}): - frappe.db.sql(""" - DELETE - FROM `tabWishlist Item` - WHERE - item_code=%(item_code)s - and parent='%(user)s' - """ % {"item_code": frappe.db.escape(item_code), "user": frappe.session.user}) - + frappe.db.delete( + "Wishlist Item", + { + "item_code": item_code, + "parent": frappe.session.user + } + ) frappe.db.commit() - wishlist = frappe.get_doc("Wishlist", frappe.session.user) + 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 + frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist_items))) \ No newline at end of file diff --git a/erpnext/e_commerce/product_data_engine/query.py b/erpnext/e_commerce/product_data_engine/query.py index 5c67272b968..0ac90906e5d 100644 --- a/erpnext/e_commerce/product_data_engine/query.py +++ b/erpnext/e_commerce/product_data_engine/query.py @@ -2,9 +2,10 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe.utils import flt from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website from erpnext.e_commerce.doctype.item_review.item_review import get_customer -from frappe.utils import flt +from erpnext.utilities.product import get_non_stock_item_status from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website @@ -235,12 +236,22 @@ class ProductQuery: 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("website_warehouse"): - stock_qty = frappe.utils.flt( - frappe.db.get_value("Bin", {"item_code": item.item_code, "warehouse": item.get("website_warehouse")}, - "actual_qty")) - item.in_stock = bool(stock_qty) + 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) diff --git a/erpnext/e_commerce/shopping_cart/product_info.py b/erpnext/e_commerce/shopping_cart/product_info.py index c66abbaf6ff..3ebf6d2f8d9 100644 --- a/erpnext/e_commerce/shopping_cart/product_info.py +++ b/erpnext/e_commerce/shopping_cart/product_info.py @@ -24,7 +24,7 @@ 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 = [] + price = {} if cart_settings.show_price: is_guest = frappe.session.user == "Guest" # Show Price if logged in. diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index 7753378d7b4..8648cdbf0b5 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -188,13 +188,6 @@ body.product-page { font-weight: 600; } - .out-of-stock { - font-weight: 500; - font-size: 14px; - line-height: 20px; - color: #F47A7A; - } - .item-card { padding: var(--padding-sm); min-width: 300px; @@ -450,6 +443,10 @@ body.product-page { .r-item-image { width: 40%; + .product-image { + padding: 2px 15px; + } + .no-image-r-item { display: flex; justify-content: center; background-color: var(--gray-200); @@ -464,7 +461,6 @@ body.product-page { .r-item-info { font-size: 14px; - padding-left: 8px; padding-right: 0; width: 60%; @@ -1322,6 +1318,13 @@ body.product-page { font-weight: 500; } +.out-of-stock { + font-weight: 500; + font-size: 14px; + line-height: 20px; + color: #F47A7A; +} + .mt-minus-2 { margin-top: -2rem; } diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html index d52168e6571..399240b435b 100644 --- a/erpnext/templates/generators/item/item_add_to_cart.html +++ b/erpnext/templates/generators/item/item_add_to_cart.html @@ -35,8 +35,8 @@ {% if cart_settings.show_stock_availability %}
{% if product_info.in_stock == 0 %} - - {{ _('Not in stock') }} + + {{ _('Out of stock') }} {% elif product_info.in_stock == 1 %} diff --git a/erpnext/templates/generators/item/item_configure.js b/erpnext/templates/generators/item/item_configure.js index 3220226f7fa..10cd416d0bf 100644 --- a/erpnext/templates/generators/item/item_configure.js +++ b/erpnext/templates/generators/item/item_configure.js @@ -214,7 +214,7 @@ class ItemConfigure { ? `