chore: Drive E-commerce via Website Item

- Removed Shopping Cart Settings
- Portal fully driven via E Commerce Settings
- All Item listing querying will happen via ProductQuery engine only
- Product Listing via Website Items
- removed redundant code
- Moved all website logic from Item to Website Item
This commit is contained in:
marination
2021-02-16 18:45:36 +05:30
parent 939b0dd67d
commit eef9cf152f
33 changed files with 667 additions and 1099 deletions

View File

@@ -1,8 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals
import frappe
import frappe.defaults
from frappe import _, throw
@@ -11,10 +9,8 @@ from frappe.contacts.doctype.contact.contact import get_contact_name
from frappe.utils import cint, cstr, flt, get_fullname
from frappe.utils.nestedset import get_root_of
from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import get_shopping_cart_settings
from erpnext.accounts.utils import get_account_name
from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
get_shopping_cart_settings,
)
from erpnext.utilities.product import get_qty_in_stock
@@ -22,7 +18,7 @@ class WebsitePriceListMissingError(frappe.ValidationError):
pass
def set_cart_count(quotation=None):
if cint(frappe.db.get_singles_value("Shopping Cart Settings", "enabled")):
if cint(frappe.db.get_singles_value("E Commerce Settings", "enabled")):
if not quotation:
quotation = _get_cart_quotation()
cart_count = cstr(len(quotation.get("items")))
@@ -49,7 +45,7 @@ def get_cart_quotation(doc=None):
"shipping_addresses": get_shipping_addresses(party),
"billing_addresses": get_billing_addresses(party),
"shipping_rules": get_applicable_shipping_rules(party),
"cart_settings": frappe.get_cached_doc("Shopping Cart Settings")
"cart_settings": frappe.get_cached_doc("E Commerce Settings")
}
@frappe.whitelist()
@@ -73,7 +69,7 @@ def get_billing_addresses(party=None):
@frappe.whitelist()
def place_order():
quotation = _get_cart_quotation()
cart_settings = frappe.db.get_value("Shopping Cart Settings", None,
cart_settings = frappe.db.get_value("E Commerce Settings", None,
["company", "allow_items_not_in_stock"], as_dict=1)
quotation.company = cart_settings.company
@@ -263,7 +259,7 @@ 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):
@@ -286,7 +282,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-",
@@ -341,7 +337,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)
@@ -418,7 +414,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 = ''

View File

@@ -1,38 +0,0 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.ui.form.on("Shopping Cart 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;
frm.refresh_field("quotation_series");
}
frm.set_query('payment_gateway_account', function() {
return { 'filters': { 'payment_channel': "Email" } };
});
},
refresh: function(frm) {
if (frm.doc.enabled) {
frm.get_field('store_page_docs').$wrapper.removeClass('hide-control').html(
`<div>${__("Follow these steps to create a landing page for your store")}:
<a href="https://docs.erpnext.com/docs/user/manual/en/website/store-landing-page"
style="color: var(--gray-600)">
docs/store-landing-page
</a>
</div>`
);
}
},
enabled: function(frm) {
if (frm.doc.enabled === 1) {
frm.set_value('enable_variants', 1);
}
else {
frm.set_value('company', '');
frm.set_value('price_list', '');
frm.set_value('default_customer_group', '');
frm.set_value('quotation_series', '');
}
}
});

View File

@@ -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
}

View File

@@ -1,85 +0,0 @@
# 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
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt
class ShoppingCartSetupError(frappe.ValidationError): pass
class ShoppingCartSettings(Document):
def onload(self):
self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series")
def validate(self):
if self.enabled:
self.validate_price_list_exchange_rate()
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
if not self.enabled or not self.company or not self.price_list:
return # this function is also called from hooks, check values again
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
price_list_currency = frappe.db.get_value("Price List", self.price_list, "currency")
if not company_currency:
msg = f"Please specify currency in Company {self.company}"
frappe.throw(_(msg), title=_("Missing Currency"), exc=ShoppingCartSetupError)
if not price_list_currency:
msg = f"Please specify currency in Price List {frappe.bold(self.price_list)}"
frappe.throw(_(msg), title=_("Missing Currency"), exc=ShoppingCartSetupError)
if price_list_currency != company_currency:
from_currency, to_currency = price_list_currency, company_currency
# Get exchange rate checks Currency Exchange Records too
exchange_rate = get_exchange_rate(from_currency, to_currency, args="for_selling")
if not flt(exchange_rate):
msg = f"Missing Currency Exchange Rates for {from_currency}-{to_currency}"
frappe.throw(_(msg), title=_("Missing"), exc=ShoppingCartSetupError)
def validate_tax_rule(self):
if not frappe.db.get_value("Tax Rule", {"use_for_shopping_cart" : 1}, "name"):
frappe.throw(frappe._("Set Tax Rule for shopping cart"), ShoppingCartSetupError)
def get_tax_master(self, billing_territory):
tax_master = self.get_name_from_territory(billing_territory, "sales_taxes_and_charges_masters",
"sales_taxes_and_charges_master")
return tax_master and tax_master[0] or None
def get_shipping_rules(self, shipping_territory):
return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule")
def validate_cart_settings(doc=None, method=None):
frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings").run_method("validate")
def 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")
return frappe.local.shopping_cart_settings
@frappe.whitelist(allow_guest=True)
def is_cart_enabled():
return get_shopping_cart_settings().enabled
def show_quantity_in_website():
return get_shopping_cart_settings().show_quantity_in_website
def check_shopping_cart_enabled():
if not get_shopping_cart_settings().enabled:
frappe.throw(_("You need to enable Shopping Cart"), ShoppingCartSetupError)
def show_attachments():
return get_shopping_cart_settings().show_attachments

View File

@@ -1,56 +0,0 @@
# 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
from __future__ import unicode_literals
import unittest
import frappe
from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
ShoppingCartSetupError,
)
class TestShoppingCartSettings(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",
"company": "_Test Company"})
# NOTE: Exchangrate API has all enabled currencies that ERPNext supports.
# We aren't checking just currency exchange record anymore
# while validating price list currency exchange rate to that of company.
# The API is being used to fetch the rate which again almost always
# gives back a valid value (for valid currencies).
# This makes the test obsolete.
# Commenting because im not sure if there's a better test we can write
# def test_exchange_rate_exists(self):
# frappe.db.sql("""delete from `tabCurrency Exchange`""")
# 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)
# 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()
def test_tax_rule_validation(self):
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0")
frappe.db.commit()
cart_settings = self.get_cart_settings()
cart_settings.enabled = 1
if not frappe.db.get_value("Tax Rule", {"use_for_shopping_cart": 1}, "name"):
self.assertRaises(ShoppingCartSetupError, cart_settings.validate_tax_rule)
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 1")
test_dependencies = ["Tax Rule"]

View File

@@ -1,17 +1,14 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals
import frappe
from erpnext.shopping_cart.cart import _get_cart_quotation, _set_price_list
from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
get_shopping_cart_settings,
show_quantity_in_website,
show_quantity_in_website
)
from erpnext.utilities.product import get_non_stock_item_status, get_price, get_qty_in_stock
from erpnext.utilities.product import get_price, get_qty_in_stock, get_non_stock_item_status
@frappe.whitelist(allow_guest=True)
def get_product_info_for_website(item_code, skip_quotation_creation=False):

View File

@@ -10,26 +10,22 @@ 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
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
filters (list)
or_filters (list)
"""
def __init__(self):
self.settings = frappe.get_doc("E Commerce 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])
self.fields = ['wi.name', 'wi.item_name', 'wi.item_code', 'wi.website_image', 'wi.variant_of',
'wi.has_variants', 'wi.item_group', 'wi.image', 'wi.web_long_description', 'wi.description',
'wi.route']
self.conditions = ""
self.or_conditions = ""
self.substitutions = []
def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None):
"""Summary
@@ -57,51 +53,14 @@ class ProductQuery:
filters=[["Website Item Group", "item_group", "=", item_group]]
)
self.query_fields = (", ").join(self.fields)
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))]
result = self.query_items_with_attributes(attributes, start)
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)
result = self.query_items(self.conditions, self.or_conditions,
self.substitutions, start=start)
# add price info in results
for item in result:
product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info')
if product_info:
@@ -109,6 +68,51 @@ class ProductQuery:
return result
def query_items(self, conditions, or_conditions, substitutions, start=0):
"""Build a query to fetch Website Items based on field filters."""
return frappe.db.sql("""
select distinct {query_fields}
from
`tabWebsite Item` wi, `tabItem Variant Attribute` iva
where
wi.published = 1
{conditions}
{or_conditions}
limit {limit} offset {start}
""".format(
query_fields=self.query_fields,
conditions=conditions,
or_conditions=or_conditions,
limit=self.page_length,
start=start),
tuple(substitutions),
as_dict=1)
def query_items_with_attributes(self, attributes, start=0):
"""Build a query to fetch Website Items based on field & attribute filters."""
all_items = []
self.conditions += " and iva.parent = wi.item_code"
for attribute, values in attributes.items():
if not isinstance(values, list): values = [values]
conditions_copy = self.conditions
substitutions_copy = self.substitutions.copy()
conditions_copy += " and iva.attribute = '{0}' and iva.attribute_value in ({1})" \
.format(attribute, (", ").join(['%s'] * len(values)))
substitutions_copy.extend(values)
items = self.query_items(conditions_copy, self.or_conditions, substitutions_copy, start=start)
items_dict = {item.name: item for item in items}
# TODO: Replace Variants by their parent templates
all_items.append(set(items_dict.keys()))
result = [items_dict.get(item) for item in list(set.intersection(*all_items))]
return result
def build_fields_filters(self, filters):
"""Build filters for field values
@@ -130,10 +134,11 @@ class ProductQuery:
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])
self.conditions += " and wi.{0} in ({1})".format(field, (', ').join(['%s'] * len(values)))
self.substitutions.extend(values)
else:
# `=` will be faster than `IN` for most cases
self.filters.append([field, '=', values])
self.conditions += " and wi.{0} = '{1}'".format(field, values)
def build_search_filters(self, search_term):
"""Query search term in specified fields
@@ -158,4 +163,5 @@ class ProductQuery:
# Build or filters for query
search = '%{}%'.format(search_term)
self.or_filters += [[field, 'like', search] for field in search_fields]
for field in search_fields:
self.or_conditions += " or {0} like '{1}'".format(field, search)

View File

@@ -167,7 +167,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,
@@ -197,7 +197,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

View File

@@ -1,14 +1,9 @@
# 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
from __future__ import unicode_literals
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():
if (is_cart_enabled() and