Merge branch 'develop' into gstr_3b_export_zero_rated
This commit is contained in:
commit
8ff32da9bd
5
.github/helper/install.sh
vendored
5
.github/helper/install.sh
vendored
@ -8,7 +8,10 @@ sudo apt-get install redis-server libcups2-dev
|
||||
|
||||
pip install frappe-bench
|
||||
|
||||
git clone https://github.com/frappe/frappe --branch "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" --depth 1
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappebranch=${FRAPPE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF##*/}}}
|
||||
|
||||
git clone "https://github.com/${frappeuser}/frappe" --branch "${frappebranch}" --depth 1
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
|
15
.github/workflows/server-tests-mariadb.yml
vendored
15
.github/workflows/server-tests-mariadb.yml
vendored
@ -6,12 +6,23 @@ on:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
- '**.html'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
user:
|
||||
description: 'user'
|
||||
required: true
|
||||
default: 'frappe'
|
||||
type: string
|
||||
branch:
|
||||
description: 'Branch name'
|
||||
default: 'develop'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: server-mariadb-develop-${{ github.event.number }}
|
||||
@ -95,6 +106,8 @@ jobs:
|
||||
env:
|
||||
DB: mariadb
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Run Tests
|
||||
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator --with-coverage
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2022-01-03 18:10:11.697198",
|
||||
"creation": "2022-01-13 20:07:30.096306",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "percentage",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Percentage (%)",
|
||||
"reqd": 1
|
||||
@ -29,7 +29,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-01-03 18:10:20.029821",
|
||||
"modified": "2022-02-01 22:22:31.589523",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cost Center Allocation Percentage",
|
||||
|
@ -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
|
||||
|
@ -8,6 +8,7 @@ frappe.provide("erpnext.journal_entry");
|
||||
frappe.ui.form.on("Journal Entry", {
|
||||
setup: function(frm) {
|
||||
frm.add_fetch("bank_account", "account", "account");
|
||||
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice'];
|
||||
},
|
||||
|
||||
refresh: function(frm) {
|
||||
|
@ -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})
|
||||
|
@ -42,7 +42,6 @@ class POSInvoice(SalesInvoice):
|
||||
self.validate_serialised_or_batched_item()
|
||||
self.validate_stock_availablility()
|
||||
self.validate_return_items_qty()
|
||||
self.validate_non_stock_items()
|
||||
self.set_status()
|
||||
self.set_account_for_mode_of_payment()
|
||||
self.validate_pos()
|
||||
@ -175,9 +174,11 @@ class POSInvoice(SalesInvoice):
|
||||
def validate_stock_availablility(self):
|
||||
if self.is_return or self.docstatus != 1:
|
||||
return
|
||||
|
||||
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
|
||||
for d in self.get('items'):
|
||||
is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
|
||||
if is_service_item:
|
||||
return
|
||||
if d.serial_no:
|
||||
self.validate_pos_reserved_serial_nos(d)
|
||||
self.validate_delivered_serial_nos(d)
|
||||
@ -188,7 +189,7 @@ class POSInvoice(SalesInvoice):
|
||||
if allow_negative_stock:
|
||||
return
|
||||
|
||||
available_stock = get_stock_availability(d.item_code, d.warehouse)
|
||||
available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
|
||||
|
||||
item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
|
||||
if flt(available_stock) <= 0:
|
||||
@ -259,14 +260,6 @@ class POSInvoice(SalesInvoice):
|
||||
.format(d.idx, bold_serial_no, bold_return_against)
|
||||
)
|
||||
|
||||
def validate_non_stock_items(self):
|
||||
for d in self.get("items"):
|
||||
is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item")
|
||||
if not is_stock_item:
|
||||
if not frappe.db.exists('Product Bundle', d.item_code):
|
||||
frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.")
|
||||
.format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
|
||||
|
||||
def validate_mode_of_payment(self):
|
||||
if len(self.payments) == 0:
|
||||
frappe.throw(_("At least one mode of payment is required for POS invoice."))
|
||||
@ -506,12 +499,18 @@ class POSInvoice(SalesInvoice):
|
||||
@frappe.whitelist()
|
||||
def get_stock_availability(item_code, warehouse):
|
||||
if frappe.db.get_value('Item', item_code, 'is_stock_item'):
|
||||
is_stock_item = True
|
||||
bin_qty = get_bin_qty(item_code, warehouse)
|
||||
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
|
||||
return bin_qty - pos_sales_qty
|
||||
return bin_qty - pos_sales_qty, is_stock_item
|
||||
else:
|
||||
is_stock_item = False
|
||||
if frappe.db.exists('Product Bundle', item_code):
|
||||
return get_bundle_availability(item_code, warehouse)
|
||||
return get_bundle_availability(item_code, warehouse), is_stock_item
|
||||
else:
|
||||
# Is a service item
|
||||
return 0, is_stock_item
|
||||
|
||||
|
||||
def get_bundle_availability(bundle_item_code, warehouse):
|
||||
product_bundle = frappe.get_doc('Product Bundle', bundle_item_code)
|
||||
|
@ -537,8 +537,11 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
voucher_wise_stock_value = {}
|
||||
if self.update_stock:
|
||||
for d in frappe.get_all('Stock Ledger Entry',
|
||||
fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], filters={'voucher_no': self.name}):
|
||||
stock_ledger_entries = frappe.get_all("Stock Ledger Entry",
|
||||
fields = ["voucher_detail_no", "stock_value_difference", "warehouse"],
|
||||
filters={"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0}
|
||||
)
|
||||
for d in stock_ledger_entries:
|
||||
voucher_wise_stock_value.setdefault((d.voucher_detail_no, d.warehouse), d.stock_value_difference)
|
||||
|
||||
valuation_tax_accounts = [d.account_head for d in self.get("taxes")
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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"
|
||||
}
|
@ -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
|
||||
|
@ -249,6 +249,9 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
del filters['customer']
|
||||
else:
|
||||
del filters['supplier']
|
||||
else:
|
||||
filters.pop('customer', None)
|
||||
filters.pop('supplier', None)
|
||||
|
||||
|
||||
description_cond = ''
|
||||
|
@ -56,6 +56,12 @@ class TestQueries(unittest.TestCase):
|
||||
bundled_stock_items = query(txt="_test product bundle item 5", filters={"is_stock_item": 1})
|
||||
self.assertEqual(len(bundled_stock_items), 0)
|
||||
|
||||
# empty customer/supplier should be stripped of instead of failure
|
||||
query(txt="", filters={"customer": None})
|
||||
query(txt="", filters={"customer": ""})
|
||||
query(txt="", filters={"supplier": None})
|
||||
query(txt="", filters={"supplier": ""})
|
||||
|
||||
def test_bom_qury(self):
|
||||
query = add_default_params(queries.bom, "BOM")
|
||||
|
||||
|
86
erpnext/e_commerce/api.py
Normal file
86
erpnext/e_commerce/api.py
Normal file
@ -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")
|
@ -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", {
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
@ -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 <b>Shop by Category</b> 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": "<p class=\"alert alert-warning\">Redisearch is not loaded. If you want to use the advanced product search feature, refer <a class=\"alert-link\" href=\"https://docs.erpnext.com/docs/v13/user/manual/en/setting-up/articles/installing-redisearch\" target=\"_blank\">here</a>.</p>"
|
||||
},
|
||||
{
|
||||
"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. <b>E.g.: /login</b>",
|
||||
"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
|
||||
}
|
@ -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 <b>{1}</b> 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
|
||||
|
@ -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"]
|
8
erpnext/e_commerce/doctype/item_review/item_review.js
Normal file
8
erpnext/e_commerce/doctype/item_review/item_review.js
Normal file
@ -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) {
|
||||
|
||||
// }
|
||||
});
|
134
erpnext/e_commerce/doctype/item_review/item_review.json
Normal file
134
erpnext/e_commerce/doctype/item_review/item_review.json
Normal file
@ -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
|
||||
}
|
147
erpnext/e_commerce/doctype/item_review/item_review.py
Normal file
147
erpnext/e_commerce/doctype/item_review/item_review.py
Normal file
@ -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)
|
84
erpnext/e_commerce/doctype/item_review/test_item_review.py
Normal file
84
erpnext/e_commerce/doctype/item_review/test_item_review.py
Normal file
@ -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")
|
@ -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
|
||||
}
|
@ -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
|
@ -0,0 +1,7 @@
|
||||
{% extends "templates/web.html" %}
|
||||
|
||||
{% block page_content %}
|
||||
<h1>{{ title }}</h1>
|
||||
{% endblock %}
|
||||
|
||||
<!-- this is a sample default web page template -->
|
@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<a href="{{ doc.route }}">{{ doc.title or doc.name }}</a>
|
||||
</div>
|
||||
<!-- this is a sample default list template -->
|
538
erpnext/e_commerce/doctype/website_item/test_website_item.py
Normal file
538
erpnext/e_commerce/doctype/website_item/test_website_item.py
Normal file
@ -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"]
|
24
erpnext/e_commerce/doctype/website_item/website_item.js
Normal file
24
erpnext/e_commerce/doctype/website_item/website_item.js
Normal file
@ -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);
|
||||
}
|
||||
});
|
415
erpnext/e_commerce/doctype/website_item/website_item.json
Normal file
415
erpnext/e_commerce/doctype/website_item/website_item.json
Normal file
@ -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
|
||||
}
|
441
erpnext/e_commerce/doctype/website_item/website_item.py
Normal file
441
erpnext/e_commerce/doctype/website_item/website_item.py
Normal file
@ -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]
|
20
erpnext/e_commerce/doctype/website_item/website_item_list.js
Normal file
20
erpnext/e_commerce/doctype/website_item/website_item_list.js
Normal file
@ -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"];
|
||||
}
|
||||
}
|
||||
};
|
@ -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
|
||||
}
|
@ -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
|
43
erpnext/e_commerce/doctype/website_offer/website_offer.json
Normal file
43
erpnext/e_commerce/doctype/website_offer/website_offer.json
Normal file
@ -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
|
||||
}
|
14
erpnext/e_commerce/doctype/website_offer/website_offer.py
Normal file
14
erpnext/e_commerce/doctype/website_offer/website_offer.py
Normal file
@ -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'])
|
0
erpnext/e_commerce/doctype/wishlist/__init__.py
Normal file
0
erpnext/e_commerce/doctype/wishlist/__init__.py
Normal file
102
erpnext/e_commerce/doctype/wishlist/test_wishlist.py
Normal file
102
erpnext/e_commerce/doctype/wishlist/test_wishlist.py
Normal file
@ -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()
|
8
erpnext/e_commerce/doctype/wishlist/wishlist.js
Normal file
8
erpnext/e_commerce/doctype/wishlist/wishlist.js
Normal file
@ -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) {
|
||||
|
||||
// }
|
||||
});
|
65
erpnext/e_commerce/doctype/wishlist/wishlist.json
Normal file
65
erpnext/e_commerce/doctype/wishlist/wishlist.json
Normal file
@ -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
|
||||
}
|
68
erpnext/e_commerce/doctype/wishlist/wishlist.py
Normal file
68
erpnext/e_commerce/doctype/wishlist/wishlist.py
Normal file
@ -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)))
|
147
erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json
Normal file
147
erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json
Normal file
@ -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
|
||||
}
|
10
erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py
Normal file
10
erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py
Normal file
@ -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
|
@ -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)
|
139
erpnext/e_commerce/product_data_engine/filters.py
Normal file
139
erpnext/e_commerce/product_data_engine/filters.py
Normal file
@ -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
|
301
erpnext/e_commerce/product_data_engine/query.py
Normal file
301
erpnext/e_commerce/product_data_engine/query.py
Normal file
@ -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
|
@ -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)
|
@ -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)
|
201
erpnext/e_commerce/product_ui/grid.js
Normal file
201
erpnext/e_commerce/product_ui/grid.js
Normal file
@ -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 += `<div class="col-sm-4 item-card"><div class="card text-left">`;
|
||||
html += me.get_image_html(item, title);
|
||||
html += me.get_card_body_html(item, title, me.settings);
|
||||
html += `</div></div>`;
|
||||
});
|
||||
|
||||
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 `
|
||||
<div class="card-img-container">
|
||||
<a href="/${ item.route || '#' }" style="text-decoration: none;">
|
||||
<img class="card-img" src="${ image }" alt="${ title }">
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return `
|
||||
<div class="card-img-container">
|
||||
<a href="/${ item.route || '#' }" style="text-decoration: none;">
|
||||
<div class="card-img-top no-image">
|
||||
${ frappe.get_abbr(title) }
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
get_card_body_html(item, title, settings) {
|
||||
let body_html = `
|
||||
<div class="card-body text-left card-body-flex" style="width:100%">
|
||||
<div style="margin-top: 1rem; display: flex;">
|
||||
`;
|
||||
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 += `</div>`;
|
||||
body_html += `<div class="product-category">${ item.item_group || '' }</div>`;
|
||||
|
||||
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 += `</div>`; // close div on line 49
|
||||
|
||||
return body_html;
|
||||
}
|
||||
|
||||
get_title(item, title) {
|
||||
let title_html = `
|
||||
<a href="/${ item.route || '#' }">
|
||||
<div class="product-title">
|
||||
${ title || '' }
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
return title_html;
|
||||
}
|
||||
|
||||
get_wishlist_icon(item) {
|
||||
let icon_class = item.wished ? "wished" : "not-wished";
|
||||
return `
|
||||
<div class="like-action ${ item.wished ? "like-action-wished" : ''}"
|
||||
data-item-code="${ item.item_code }">
|
||||
<svg class="icon sm">
|
||||
<use class="${ icon_class } wish-icon" href="#icon-heart"></use>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
get_cart_indicator(item) {
|
||||
return `
|
||||
<div class="cart-indicator ${item.in_cart ? '' : 'hidden'}" data-item-code="${ item.item_code }">
|
||||
1
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
get_price_html(item) {
|
||||
let price_html = `
|
||||
<div class="product-price">
|
||||
${ item.formatted_price || '' }
|
||||
`;
|
||||
|
||||
if (item.formatted_mrp) {
|
||||
price_html += `
|
||||
<small class="striked-price">
|
||||
<s>${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" }</s>
|
||||
</small>
|
||||
<small class="ml-1 product-info-green">
|
||||
${ item.discount } OFF
|
||||
</small>
|
||||
`;
|
||||
}
|
||||
price_html += `</div>`;
|
||||
return price_html;
|
||||
}
|
||||
|
||||
get_stock_availability(item, settings) {
|
||||
if (settings.show_stock_availability && !item.has_variants) {
|
||||
if (item.on_backorder) {
|
||||
return `
|
||||
<span class="out-of-stock mb-2 mt-1" style="color: var(--primary-color)">
|
||||
${ __("Available on backorder") }
|
||||
</span>
|
||||
`;
|
||||
} else if (!item.in_stock) {
|
||||
return `
|
||||
<span class="out-of-stock mb-2 mt-1">
|
||||
${ __("Out of stock") }
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return ``;
|
||||
}
|
||||
|
||||
get_primary_button(item, settings) {
|
||||
if (item.has_variants) {
|
||||
return `
|
||||
<a href="/${ item.route || '#' }">
|
||||
<div class="btn btn-sm btn-explore-variants w-100 mt-4">
|
||||
${ __('Explore') }
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
} else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) {
|
||||
return `
|
||||
<div id="${ item.name }" class="btn
|
||||
btn-sm btn-primary btn-add-to-cart-list
|
||||
w-100 mt-2 ${ item.in_cart ? 'hidden' : '' }"
|
||||
data-item-code="${ item.item_code }">
|
||||
<span class="mr-2">
|
||||
<svg class="icon icon-md">
|
||||
<use href="#icon-assets"></use>
|
||||
</svg>
|
||||
</span>
|
||||
${ settings.enable_checkout ? __('Add to Cart') : __('Add to Quote') }
|
||||
</div>
|
||||
|
||||
<a href="/cart">
|
||||
<div id="${ item.name }" class="btn
|
||||
btn-sm btn-primary btn-add-to-cart-list
|
||||
w-100 mt-4 go-to-cart-grid
|
||||
${ item.in_cart ? '' : 'hidden' }"
|
||||
data-item-code="${ item.item_code }">
|
||||
${ settings.enable_checkout ? __('Go to Cart') : __('Go to Quote') }
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
} else {
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
};
|
204
erpnext/e_commerce/product_ui/list.js
Normal file
204
erpnext/e_commerce/product_ui/list.js
Normal file
@ -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 = `<br><br>`;
|
||||
|
||||
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 += `<div class='row list-row w-100 mb-4'>`;
|
||||
html += me.get_image_html(item, title, me.settings);
|
||||
html += me.get_row_body_html(item, title, me.settings);
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
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 += `
|
||||
<div class="col-2 border text-center rounded list-image">
|
||||
<a class="product-link product-list-link" href="/${ item.route || '#' }">
|
||||
<img itemprop="image" class="website-image h-100 w-100" alt="${ title }"
|
||||
src="${ image }">
|
||||
</a>
|
||||
${ wishlist_enabled ? this.get_wishlist_icon(item): '' }
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
image_html += `
|
||||
<div class="col-2 border text-center rounded list-image">
|
||||
<a class="product-link product-list-link" href="/${ item.route || '#' }"
|
||||
style="text-decoration: none">
|
||||
<div class="card-img-top no-image-list">
|
||||
${ frappe.get_abbr(title) }
|
||||
</div>
|
||||
</a>
|
||||
${ wishlist_enabled ? this.get_wishlist_icon(item): '' }
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return image_html;
|
||||
}
|
||||
|
||||
get_row_body_html(item, title, settings) {
|
||||
let body_html = `<div class='col-10 text-left'>`;
|
||||
body_html += this.get_title_html(item, title, settings);
|
||||
body_html += this.get_item_details(item, settings);
|
||||
body_html += `</div>`;
|
||||
return body_html;
|
||||
}
|
||||
|
||||
get_title_html(item, title, settings) {
|
||||
let title_html = `<div style="display: flex; margin-left: -15px;">`;
|
||||
title_html += `
|
||||
<div class="col-8" style="margin-right: -15px;">
|
||||
<a class="" href="/${ item.route || '#' }"
|
||||
style="color: var(--gray-800); font-weight: 500;">
|
||||
${ title }
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (settings.enabled) {
|
||||
title_html += `<div class="col-4 cart-action-container ${item.in_cart ? 'd-flex' : ''}">`;
|
||||
title_html += this.get_primary_button(item, settings);
|
||||
title_html += `</div>`;
|
||||
}
|
||||
title_html += `</div>`;
|
||||
|
||||
return title_html;
|
||||
}
|
||||
|
||||
get_item_details(item, settings) {
|
||||
let details = `
|
||||
<p class="product-code">
|
||||
${ item.item_group } | Item Code : ${ item.item_code }
|
||||
</p>
|
||||
<div class="mt-2" style="color: var(--gray-600) !important; font-size: 13px;">
|
||||
${ item.short_description || '' }
|
||||
</div>
|
||||
<div class="product-price">
|
||||
${ item.formatted_price || '' }
|
||||
`;
|
||||
|
||||
if (item.formatted_mrp) {
|
||||
details += `
|
||||
<small class="striked-price">
|
||||
<s>${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" }</s>
|
||||
</small>
|
||||
<small class="ml-1 product-info-green">
|
||||
${ item.discount } OFF
|
||||
</small>
|
||||
`;
|
||||
}
|
||||
|
||||
details += this.get_stock_availability(item, settings);
|
||||
details += `</div>`;
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
get_stock_availability(item, settings) {
|
||||
if (settings.show_stock_availability && !item.has_variants) {
|
||||
if (item.on_backorder) {
|
||||
return `
|
||||
<br>
|
||||
<span class="out-of-stock mt-2" style="color: var(--primary-color)">
|
||||
${ __("Available on backorder") }
|
||||
</span>
|
||||
`;
|
||||
} else if (!item.in_stock) {
|
||||
return `
|
||||
<br>
|
||||
<span class="out-of-stock mt-2">${ __("Out of stock") }</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
return ``;
|
||||
}
|
||||
|
||||
get_wishlist_icon(item) {
|
||||
let icon_class = item.wished ? "wished" : "not-wished";
|
||||
|
||||
return `
|
||||
<div class="like-action-list ${ item.wished ? "like-action-wished" : ''}"
|
||||
data-item-code="${ item.item_code }">
|
||||
<svg class="icon sm">
|
||||
<use class="${ icon_class } wish-icon" href="#icon-heart"></use>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
get_primary_button(item, settings) {
|
||||
if (item.has_variants) {
|
||||
return `
|
||||
<a href="/${ item.route || '#' }">
|
||||
<div class="btn btn-sm btn-explore-variants btn mb-0 mt-0">
|
||||
${ __('Explore') }
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
} else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) {
|
||||
return `
|
||||
<div id="${ item.name }" class="btn
|
||||
btn-sm btn-primary btn-add-to-cart-list mb-0
|
||||
${ item.in_cart ? 'hidden' : '' }"
|
||||
data-item-code="${ item.item_code }"
|
||||
style="margin-top: 0px !important; max-height: 30px; float: right;
|
||||
padding: 0.25rem 1rem; min-width: 135px;">
|
||||
<span class="mr-2">
|
||||
<svg class="icon icon-md">
|
||||
<use href="#icon-assets"></use>
|
||||
</svg>
|
||||
</span>
|
||||
${ settings.enable_checkout ? __('Add to Cart') : __('Add to Quote') }
|
||||
</div>
|
||||
|
||||
<div class="cart-indicator list-indicator ${item.in_cart ? '' : 'hidden'}">
|
||||
1
|
||||
</div>
|
||||
|
||||
<a href="/cart">
|
||||
<div id="${ item.name }" class="btn
|
||||
btn-sm btn-primary btn-add-to-cart-list
|
||||
ml-4 go-to-cart mb-0 mt-0
|
||||
${ item.in_cart ? '' : 'hidden' }"
|
||||
data-item-code="${ item.item_code }"
|
||||
style="padding: 0.25rem 1rem; min-width: 135px;">
|
||||
${ settings.enable_checkout ? __('Go to Cart') : __('Go to Quote') }
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
} else {
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
244
erpnext/e_commerce/product_ui/search.js
Normal file
244
erpnext/e_commerce/product_ui/search.js
Normal file
@ -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(`
|
||||
<div class="overflow-hidden shadow dropdown-menu w-100 hidden"
|
||||
id="search-results-container"
|
||||
aria-labelledby="dropdownMenuSearch"
|
||||
style="display: flex; flex-direction: column;">
|
||||
</div>
|
||||
`).find("#search-results-container");
|
||||
|
||||
this.setupCategoryContainer();
|
||||
this.setupProductsContainer();
|
||||
this.setupRecentsContainer();
|
||||
}
|
||||
|
||||
setupProductsContainer() {
|
||||
this.products_container = this.search_dropdown.append(`
|
||||
<div id="product-results mt-2">
|
||||
<div id="product-scroll" style="overflow: scroll; max-height: 300px">
|
||||
</div>
|
||||
</div>
|
||||
`).find("#product-scroll");
|
||||
}
|
||||
|
||||
setupCategoryContainer() {
|
||||
this.category_container = this.search_dropdown.append(`
|
||||
<div class="category-container mt-2 mb-1">
|
||||
<div class="category-chips">
|
||||
</div>
|
||||
</div>
|
||||
`).find(".category-chips");
|
||||
}
|
||||
|
||||
setupRecentsContainer() {
|
||||
let $recents_section = this.search_dropdown.append(`
|
||||
<div class="mb-2 mt-2 recent-searches">
|
||||
<div>
|
||||
<b>${ __("Recent") }</b>
|
||||
</div>
|
||||
</div>
|
||||
`).find(".recent-searches");
|
||||
|
||||
this.recents_container = $recents_section.append(`
|
||||
<div id="recents" style="padding: .25rem 0 1rem 0;">
|
||||
</div>
|
||||
`).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(`<span class=""text-muted">No searches yet.</span>`);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
recents.forEach((key) => {
|
||||
html += `
|
||||
<div class="recent-search mr-1" style="font-size: 13px">
|
||||
<span class="mr-2">
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" stroke="var(--gray-500)"" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.00027 5.20947V8.00017L10 10" stroke="var(--gray-500)" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
${ key }
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
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 += `
|
||||
<div class="dropdown-item" style="display: flex;">
|
||||
<img class="item-thumb col-2" src=${thumbnail} />
|
||||
<div class="col-9" style="white-space: normal;">
|
||||
<a href="/${res.route}">${res.web_item_name}</a><br>
|
||||
<span class="brand-line">${res.brand ? "by " + res.brand : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
this.products_container.html(html);
|
||||
}
|
||||
|
||||
populateCategoriesList(category_results) {
|
||||
if (!category_results || category_results.length === 0) {
|
||||
let empty_html = `
|
||||
<div class="category-container mt-2">
|
||||
<div class="category-chips">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.category_container.html(empty_html);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="mb-2">
|
||||
<b>${ __("Categories") }</b>
|
||||
</div>
|
||||
`;
|
||||
|
||||
category_results.forEach((category) => {
|
||||
html += `
|
||||
<a href="/${category.route}" class="btn btn-sm category-chip mr-2 mb-2"
|
||||
style="font-size: 13px" role="button">
|
||||
${ category.name }
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
|
||||
this.category_container.html(html);
|
||||
}
|
||||
};
|
532
erpnext/e_commerce/product_ui/views.js
Normal file
532
erpnext/e_commerce/product_ui/views.js
Normal file
@ -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(`
|
||||
<div class="toolbar d-flex">
|
||||
</div>
|
||||
`);
|
||||
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(`
|
||||
<br>
|
||||
<div id="products-${view}-area" class="row products-list ${ top_margin } ${ left_margin }"></div>
|
||||
`);
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="row product-paging-area mt-5">
|
||||
<div class="col-3">
|
||||
</div>
|
||||
<div class="col-9 text-right">
|
||||
`;
|
||||
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 += `
|
||||
<button class="btn btn-default btn-prev" data-start="${ start - page_length }"
|
||||
style="float: left" ${prev_disable}>
|
||||
${ __("Prev") }
|
||||
</button>`;
|
||||
|
||||
paging_html += `
|
||||
<button class="btn btn-default btn-next" data-start="${ start + page_length }"
|
||||
${next_disable}>
|
||||
${ __("Next") }
|
||||
</button>
|
||||
`;
|
||||
|
||||
paging_html += `</div></div>`;
|
||||
|
||||
$(".page_content").append(paging_html);
|
||||
this.bind_paging_action();
|
||||
}
|
||||
}
|
||||
|
||||
prepare_search() {
|
||||
$(".toolbar").append(`
|
||||
<div class="input-group col-8 p-0">
|
||||
<div class="dropdown w-100" id="dropdownMenuSearch">
|
||||
<input type="search" name="query" id="search-box" class="form-control font-md"
|
||||
placeholder="Search for Products"
|
||||
aria-label="Product" aria-describedby="button-addon2">
|
||||
<div class="search-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-search">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Results dropdown rendered in product_search.js -->
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
render_view_toggler() {
|
||||
$(".toolbar").append(`<div class="toggle-container col-4 p-0"></div>`);
|
||||
|
||||
["btn-list-view", "btn-grid-view"].forEach(view => {
|
||||
let icon = view === "btn-list-view" ? "list" : "image-view";
|
||||
$(".toggle-container").append(`
|
||||
<div class="form-group mb-0" id="toggle-view">
|
||||
<button id="${ icon }" class="btn ${ view } mr-2">
|
||||
<span>
|
||||
<svg class="icon icon-md">
|
||||
<use href="#icon-${ icon }"></use>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
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(`
|
||||
<div id="discount-filters" class="mb-4 filter-block pb-5">
|
||||
<div class="filter-label mb-3">${ __("Discounts") }</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
let html = `<div class="filter-options">`;
|
||||
filter_data.forEach(filter => {
|
||||
html += `
|
||||
<div class="checkbox">
|
||||
<label data-value="${ filter[0] }">
|
||||
<input type="radio"
|
||||
class="product-filter discount-filter"
|
||||
name="discount" id="${ filter[0] }"
|
||||
data-filter-name="discount"
|
||||
data-filter-value="${ filter[0] }"
|
||||
style="width: 14px !important"
|
||||
>
|
||||
<span class="label-area" for="${ filter[0] }">
|
||||
${ filter[1] }
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += `</div>`;
|
||||
|
||||
$("#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 = `
|
||||
<div class="mt-4 w-100 alert alert-error font-md">
|
||||
Something went wrong. Please refresh or contact us.
|
||||
</div>
|
||||
`;
|
||||
let no_results_section = `
|
||||
<div class="cart-empty frappe-card mt-4">
|
||||
<div class="cart-empty-state">
|
||||
<img src="/assets/erpnext/images/ui-states/cart-empty-state.png" alt="Empty Cart">
|
||||
</div>
|
||||
<div class="cart-empty-message mt-4">${ __('No products found') }</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.products_section.append(error ? error_section : no_results_section);
|
||||
}
|
||||
|
||||
render_item_sub_categories(categories) {
|
||||
if (categories && categories.length) {
|
||||
let sub_group_html = `
|
||||
<div class="sub-category-container scroll-categories">
|
||||
`;
|
||||
|
||||
categories.forEach(category => {
|
||||
sub_group_html += `
|
||||
<a href="${ category.route || '#' }" style="text-decoration: none;">
|
||||
<div class="category-pill">
|
||||
${ category.name }
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
sub_group_html += `</div>`;
|
||||
|
||||
$("#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;
|
||||
}
|
||||
};
|
210
erpnext/e_commerce/redisearch_utils.py
Normal file
210
erpnext/e_commerce/redisearch_utils.py
Normal file
@ -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()
|
0
erpnext/e_commerce/shopping_cart/__init__.py
Normal file
0
erpnext/e_commerce/shopping_cart/__init__.py
Normal file
@ -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
|
||||
|
@ -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
|
@ -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
|
@ -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)
|
0
erpnext/e_commerce/variant_selector/__init__.py
Normal file
0
erpnext/e_commerce/variant_selector/__init__.py
Normal file
@ -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]
|
117
erpnext/e_commerce/variant_selector/test_variant_selector.py
Normal file
117
erpnext/e_commerce/variant_selector/test_variant_selector.py
Normal file
@ -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")
|
218
erpnext/e_commerce/variant_selector/utils.py
Normal file
218
erpnext/e_commerce/variant_selector/utils.py
Normal file
@ -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
|
||||
|
0
erpnext/e_commerce/web_template/__init__.py
Normal file
0
erpnext/e_commerce/web_template/__init__.py
Normal file
@ -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,
|
@ -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 -%}
|
@ -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,
|
@ -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,
|
@ -6,8 +6,15 @@
|
||||
}) -%}
|
||||
<div class="card h-100">
|
||||
{% if image %}
|
||||
<img class="card-img-top" src="{{ image }}" alt="{{ title }}">
|
||||
<img class="card-img-top" src="{{ image }}" alt="{{ title }}" style="max-height: 200px;">
|
||||
{% else %}
|
||||
<div class="placeholder-div" style="max-height: 200px;">
|
||||
<span class="placeholder">
|
||||
{{ frappe.utils.get_abbr(title or '') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body text-center text-muted small">
|
||||
{{ title or '' }}
|
||||
</div>
|
@ -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,
|
@ -6,6 +6,7 @@ import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.desk.reportview import get_match_cond
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Min
|
||||
from frappe.utils import comma_and, get_link_to_form, getdate
|
||||
|
||||
|
||||
@ -60,8 +61,15 @@ class ProgramEnrollment(Document):
|
||||
frappe.throw(_("Student is already enrolled."))
|
||||
|
||||
def update_student_joining_date(self):
|
||||
date = frappe.db.sql("select min(enrollment_date) from `tabProgram Enrollment` where student= %s", self.student)
|
||||
frappe.db.set_value("Student", self.student, "joining_date", date)
|
||||
table = frappe.qb.DocType('Program Enrollment')
|
||||
date = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Min(table.enrollment_date).as_('enrollment_date'))
|
||||
.where(table.student == self.student)
|
||||
).run(as_dict=True)
|
||||
|
||||
if date:
|
||||
frappe.db.set_value("Student", self.student, "joining_date", date[0].enrollment_date)
|
||||
|
||||
def make_fee_records(self):
|
||||
from erpnext.education.api import get_fee_components
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
@ -20,6 +20,7 @@ def send_reminders_in_advance_weekly():
|
||||
|
||||
send_advance_holiday_reminders("Weekly")
|
||||
|
||||
|
||||
def send_reminders_in_advance_monthly():
|
||||
to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders"))
|
||||
frequency = frappe.db.get_single_value("HR Settings", "frequency")
|
||||
@ -28,6 +29,7 @@ def send_reminders_in_advance_monthly():
|
||||
|
||||
send_advance_holiday_reminders("Monthly")
|
||||
|
||||
|
||||
def send_advance_holiday_reminders(frequency):
|
||||
"""Send Holiday Reminders in Advance to Employees
|
||||
`frequency` (str): 'Weekly' or 'Monthly'
|
||||
@ -42,7 +44,7 @@ def send_advance_holiday_reminders(frequency):
|
||||
else:
|
||||
return
|
||||
|
||||
employees = frappe.db.get_all('Employee', pluck='name')
|
||||
employees = frappe.db.get_all('Employee', filters={'status': 'Active'}, pluck='name')
|
||||
for employee in employees:
|
||||
holidays = get_holidays_for_employee(
|
||||
employee,
|
||||
@ -51,10 +53,13 @@ def send_advance_holiday_reminders(frequency):
|
||||
raise_exception=False
|
||||
)
|
||||
|
||||
if not (holidays is None):
|
||||
send_holidays_reminder_in_advance(employee, holidays)
|
||||
send_holidays_reminder_in_advance(employee, holidays)
|
||||
|
||||
|
||||
def send_holidays_reminder_in_advance(employee, holidays):
|
||||
if not holidays:
|
||||
return
|
||||
|
||||
employee_doc = frappe.get_doc('Employee', employee)
|
||||
employee_email = get_employee_email(employee_doc)
|
||||
frequency = frappe.db.get_single_value("HR Settings", "frequency")
|
||||
@ -101,6 +106,7 @@ def send_birthday_reminders():
|
||||
reminder_text, message = get_birthday_reminder_text_and_message(others)
|
||||
send_birthday_reminder(person_email, reminder_text, others, message)
|
||||
|
||||
|
||||
def get_birthday_reminder_text_and_message(birthday_persons):
|
||||
if len(birthday_persons) == 1:
|
||||
birthday_person_text = birthday_persons[0]['name']
|
||||
@ -116,6 +122,7 @@ def get_birthday_reminder_text_and_message(birthday_persons):
|
||||
|
||||
return reminder_text, message
|
||||
|
||||
|
||||
def send_birthday_reminder(recipients, reminder_text, birthday_persons, message):
|
||||
frappe.sendmail(
|
||||
recipients=recipients,
|
||||
@ -129,10 +136,12 @@ def send_birthday_reminder(recipients, reminder_text, birthday_persons, message)
|
||||
header=_("Birthday Reminder 🎂")
|
||||
)
|
||||
|
||||
|
||||
def get_employees_who_are_born_today():
|
||||
"""Get all employee born today & group them based on their company"""
|
||||
return get_employees_having_an_event_today("birthday")
|
||||
|
||||
|
||||
def get_employees_having_an_event_today(event_type):
|
||||
"""Get all employee who have `event_type` today
|
||||
& group them based on their company. `event_type`
|
||||
@ -210,13 +219,14 @@ def send_work_anniversary_reminders():
|
||||
reminder_text, message = get_work_anniversary_reminder_text_and_message(others)
|
||||
send_work_anniversary_reminder(person_email, reminder_text, others, message)
|
||||
|
||||
|
||||
def get_work_anniversary_reminder_text_and_message(anniversary_persons):
|
||||
if len(anniversary_persons) == 1:
|
||||
anniversary_person = anniversary_persons[0]['name']
|
||||
persons_name = anniversary_person
|
||||
# Number of years completed at the company
|
||||
completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year
|
||||
anniversary_person += f" completed {completed_years} years"
|
||||
anniversary_person += f" completed {completed_years} year(s)"
|
||||
else:
|
||||
person_names_with_years = []
|
||||
names = []
|
||||
@ -225,7 +235,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
|
||||
names.append(person_text)
|
||||
# Number of years completed at the company
|
||||
completed_years = getdate().year - person['date_of_joining'].year
|
||||
person_text += f" completed {completed_years} years"
|
||||
person_text += f" completed {completed_years} year(s)"
|
||||
person_names_with_years.append(person_text)
|
||||
|
||||
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim
|
||||
@ -239,6 +249,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
|
||||
|
||||
return reminder_text, message
|
||||
|
||||
|
||||
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
|
||||
frappe.sendmail(
|
||||
recipients=recipients,
|
||||
@ -249,5 +260,5 @@ def send_work_anniversary_reminder(recipients, reminder_text, anniversary_person
|
||||
anniversary_persons=anniversary_persons,
|
||||
message=message,
|
||||
),
|
||||
header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️")
|
||||
header=_("Work Anniversary Reminder")
|
||||
)
|
||||
|
@ -36,7 +36,7 @@ class TestEmployee(unittest.TestCase):
|
||||
employee_doc.reload()
|
||||
|
||||
make_holiday_list()
|
||||
frappe.db.set_value("Company", erpnext.get_default_company(), "default_holiday_list", "Salary Slip Test Holiday List")
|
||||
frappe.db.set_value("Company", employee_doc.company, "default_holiday_list", "Salary Slip Test Holiday List")
|
||||
|
||||
frappe.db.sql("""delete from `tabSalary Structure` where name='Test Inactive Employee Salary Slip'""")
|
||||
salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly",
|
||||
|
@ -5,10 +5,12 @@ import unittest
|
||||
from datetime import timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
from frappe.utils import add_months, getdate
|
||||
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change
|
||||
from erpnext.hr.utils import get_holidays_for_employee
|
||||
|
||||
|
||||
class TestEmployeeReminders(unittest.TestCase):
|
||||
@ -46,6 +48,24 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
cls.test_employee = test_employee
|
||||
cls.test_holiday_dates = test_holiday_dates
|
||||
|
||||
# Employee without holidays in this month/week
|
||||
test_employee_2 = make_employee('test@empwithoutholiday.io', company="_Test Company")
|
||||
test_employee_2 = frappe.get_doc('Employee', test_employee_2)
|
||||
|
||||
test_holiday_list = make_holiday_list(
|
||||
'TestHolidayRemindersList2',
|
||||
holiday_dates=[
|
||||
{'holiday_date': add_months(getdate(), 1), 'description': 'test holiday1'},
|
||||
],
|
||||
from_date=add_months(getdate(), -2),
|
||||
to_date=add_months(getdate(), 2)
|
||||
)
|
||||
test_employee_2.holiday_list = test_holiday_list.name
|
||||
test_employee_2.save()
|
||||
|
||||
cls.test_employee_2 = test_employee_2
|
||||
cls.holiday_list_2 = test_holiday_list
|
||||
|
||||
@classmethod
|
||||
def get_test_holiday_dates(cls):
|
||||
today_date = getdate()
|
||||
@ -61,6 +81,7 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Clear Email Queue
|
||||
frappe.db.sql("delete from `tabEmail Queue`")
|
||||
frappe.db.sql("delete from `tabEmail Queue Recipient`")
|
||||
|
||||
def test_is_holiday(self):
|
||||
from erpnext.hr.doctype.employee.employee import is_holiday
|
||||
@ -103,11 +124,10 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message)
|
||||
|
||||
def test_work_anniversary_reminders(self):
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:]
|
||||
employee.company_email = "test@example.com"
|
||||
employee.company = "_Test Company"
|
||||
employee.save()
|
||||
make_employee("test_work_anniversary@gmail.com",
|
||||
date_of_joining="1998" + frappe.utils.nowdate()[4:],
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
from erpnext.hr.doctype.employee.employee_reminders import (
|
||||
get_employees_having_an_event_today,
|
||||
@ -115,7 +135,12 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
)
|
||||
|
||||
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
|
||||
self.assertTrue(employees_having_work_anniversary.get("_Test Company"))
|
||||
employees = employees_having_work_anniversary.get("_Test Company") or []
|
||||
user_ids = []
|
||||
for entry in employees:
|
||||
user_ids.append(entry.user_id)
|
||||
|
||||
self.assertTrue("test_work_anniversary@gmail.com" in user_ids)
|
||||
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_work_anniversary_reminders = 1
|
||||
@ -126,16 +151,24 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message)
|
||||
|
||||
def test_send_holidays_reminder_in_advance(self):
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
|
||||
from erpnext.hr.utils import get_holidays_for_employee
|
||||
def test_work_anniversary_reminder_not_sent_for_0_years(self):
|
||||
make_employee("test_work_anniversary_2@gmail.com",
|
||||
date_of_joining=getdate(),
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
set_proceed_with_frequency_change()
|
||||
hr_settings.frequency = 'Weekly'
|
||||
hr_settings.save()
|
||||
from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today
|
||||
|
||||
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
|
||||
employees = employees_having_work_anniversary.get("_Test Company") or []
|
||||
user_ids = []
|
||||
for entry in employees:
|
||||
user_ids.append(entry.user_id)
|
||||
|
||||
self.assertTrue("test_work_anniversary_2@gmail.com" not in user_ids)
|
||||
|
||||
def test_send_holidays_reminder_in_advance(self):
|
||||
setup_hr_settings('Weekly')
|
||||
|
||||
holidays = get_holidays_for_employee(
|
||||
self.test_employee.get('name'),
|
||||
@ -151,32 +184,80 @@ class TestEmployeeReminders(unittest.TestCase):
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertEqual(len(email_queue), 1)
|
||||
self.assertTrue("Holidays this Week." in email_queue[0].message)
|
||||
|
||||
def test_advance_holiday_reminders_monthly(self):
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly
|
||||
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
set_proceed_with_frequency_change()
|
||||
hr_settings.frequency = 'Monthly'
|
||||
hr_settings.save()
|
||||
setup_hr_settings('Monthly')
|
||||
|
||||
# disable emp 2, set same holiday list
|
||||
frappe.db.set_value('Employee', self.test_employee_2.name, {
|
||||
'status': 'Left',
|
||||
'holiday_list': self.test_employee.holiday_list
|
||||
})
|
||||
|
||||
send_reminders_in_advance_monthly()
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue(len(email_queue) > 0)
|
||||
|
||||
# even though emp 2 has holiday, non-active employees should not be recipients
|
||||
recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
|
||||
self.assertTrue(self.test_employee_2.user_id not in recipients)
|
||||
|
||||
# teardown: enable emp 2
|
||||
frappe.db.set_value('Employee', self.test_employee_2.name, {
|
||||
'status': 'Active',
|
||||
'holiday_list': self.holiday_list_2.name
|
||||
})
|
||||
|
||||
def test_advance_holiday_reminders_weekly(self):
|
||||
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly
|
||||
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
hr_settings.frequency = 'Weekly'
|
||||
hr_settings.save()
|
||||
setup_hr_settings('Weekly')
|
||||
|
||||
# disable emp 2, set same holiday list
|
||||
frappe.db.set_value('Employee', self.test_employee_2.name, {
|
||||
'status': 'Left',
|
||||
'holiday_list': self.test_employee.holiday_list
|
||||
})
|
||||
|
||||
send_reminders_in_advance_weekly()
|
||||
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertTrue(len(email_queue) > 0)
|
||||
|
||||
# even though emp 2 has holiday, non-active employees should not be recipients
|
||||
recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
|
||||
self.assertTrue(self.test_employee_2.user_id not in recipients)
|
||||
|
||||
# teardown: enable emp 2
|
||||
frappe.db.set_value('Employee', self.test_employee_2.name, {
|
||||
'status': 'Active',
|
||||
'holiday_list': self.holiday_list_2.name
|
||||
})
|
||||
|
||||
def test_reminder_not_sent_if_no_holdays(self):
|
||||
setup_hr_settings('Monthly')
|
||||
|
||||
# reminder not sent if there are no holidays
|
||||
holidays = get_holidays_for_employee(
|
||||
self.test_employee_2.get('name'),
|
||||
getdate(), getdate() + timedelta(days=3),
|
||||
only_non_weekly=True,
|
||||
raise_exception=False
|
||||
)
|
||||
send_holidays_reminder_in_advance(
|
||||
self.test_employee_2.get('name'),
|
||||
holidays
|
||||
)
|
||||
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
|
||||
self.assertEqual(len(email_queue), 0)
|
||||
|
||||
|
||||
def setup_hr_settings(frequency=None):
|
||||
# Get HR settings and enable advance holiday reminders
|
||||
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
|
||||
hr_settings.send_holiday_reminders = 1
|
||||
set_proceed_with_frequency_change()
|
||||
hr_settings.frequency = frequency or 'Weekly'
|
||||
hr_settings.save()
|
@ -75,10 +75,8 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
|
||||
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
set_leave_approver()
|
||||
|
||||
frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
|
||||
|
||||
def tearDown(self):
|
||||
@ -134,10 +132,11 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
|
||||
|
||||
holiday_list = make_holiday_list()
|
||||
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
|
||||
employee = get_employee()
|
||||
frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list)
|
||||
first_sunday = get_first_sunday(holiday_list)
|
||||
|
||||
leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
|
||||
leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
|
||||
leave_application.reload()
|
||||
self.assertEqual(leave_application.total_leave_days, 4)
|
||||
self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 4)
|
||||
@ -157,25 +156,28 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
|
||||
|
||||
holiday_list = make_holiday_list()
|
||||
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
|
||||
employee = get_employee()
|
||||
frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list)
|
||||
first_sunday = get_first_sunday(holiday_list)
|
||||
|
||||
# already marked attendance on a holiday should be deleted in this case
|
||||
config = {
|
||||
"doctype": "Attendance",
|
||||
"employee": "_T-Employee-00001",
|
||||
"employee": employee.name,
|
||||
"status": "Present"
|
||||
}
|
||||
attendance_on_holiday = frappe.get_doc(config)
|
||||
attendance_on_holiday.attendance_date = first_sunday
|
||||
attendance_on_holiday.flags.ignore_validate = True
|
||||
attendance_on_holiday.save()
|
||||
|
||||
# already marked attendance on a non-holiday should be updated
|
||||
attendance = frappe.get_doc(config)
|
||||
attendance.attendance_date = add_days(first_sunday, 3)
|
||||
attendance.flags.ignore_validate = True
|
||||
attendance.save()
|
||||
|
||||
leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
|
||||
leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
|
||||
leave_application.reload()
|
||||
# holiday should be excluded while marking attendance
|
||||
self.assertEqual(leave_application.total_leave_days, 3)
|
||||
@ -325,7 +327,7 @@ class TestLeaveApplication(unittest.TestCase):
|
||||
employee = get_employee()
|
||||
|
||||
default_holiday_list = make_holiday_list()
|
||||
frappe.db.set_value("Company", "_Test Company", "default_holiday_list", default_holiday_list)
|
||||
frappe.db.set_value("Company", employee.company, "default_holiday_list", default_holiday_list)
|
||||
first_sunday = get_first_sunday(default_holiday_list)
|
||||
|
||||
optional_leave_date = add_days(first_sunday, 1)
|
||||
|
@ -93,7 +93,7 @@ frappe.ui.form.on("BOM", {
|
||||
});
|
||||
}
|
||||
|
||||
if(frm.doc.docstatus!=0) {
|
||||
if(frm.doc.docstatus==1) {
|
||||
frm.add_custom_button(__("Work Order"), function() {
|
||||
frm.trigger("make_work_order");
|
||||
}, __("Create"));
|
||||
|
@ -385,6 +385,61 @@ class TestProductionPlan(ERPNextTestCase):
|
||||
# lowest most level of subassembly should be first
|
||||
self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item)
|
||||
|
||||
def test_multiple_work_order_for_production_plan_item(self):
|
||||
def create_work_order(item, pln, qty):
|
||||
# Get Production Items
|
||||
items_data = pln.get_production_items()
|
||||
|
||||
# Update qty
|
||||
items_data[(item, None, None)]["qty"] = qty
|
||||
|
||||
# Create and Submit Work Order for each item in items_data
|
||||
for key, item in items_data.items():
|
||||
if pln.sub_assembly_items:
|
||||
item['use_multi_level_bom'] = 0
|
||||
|
||||
wo_name = pln.create_work_order(item)
|
||||
wo_doc = frappe.get_doc("Work Order", wo_name)
|
||||
wo_doc.update({
|
||||
'wip_warehouse': 'Work In Progress - _TC',
|
||||
'fg_warehouse': 'Finished Goods - _TC'
|
||||
})
|
||||
wo_doc.submit()
|
||||
wo_list.append(wo_name)
|
||||
|
||||
item = "Test Production Item 1"
|
||||
raw_materials = ["Raw Material Item 1", "Raw Material Item 2"]
|
||||
|
||||
# Create BOM
|
||||
bom = make_bom(item=item, raw_materials=raw_materials)
|
||||
|
||||
# Create Production Plan
|
||||
pln = create_production_plan(item_code=bom.item, planned_qty=10)
|
||||
|
||||
# All the created Work Orders
|
||||
wo_list = []
|
||||
|
||||
# Create and Submit 1st Work Order for 5 qty
|
||||
create_work_order(item, pln, 5)
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 5)
|
||||
|
||||
# Create and Submit 2nd Work Order for 3 qty
|
||||
create_work_order(item, pln, 3)
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 8)
|
||||
|
||||
# Cancel 1st Work Order
|
||||
wo1 = frappe.get_doc("Work Order", wo_list[0])
|
||||
wo1.cancel()
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 3)
|
||||
|
||||
# Cancel 2nd Work Order
|
||||
wo2 = frappe.get_doc("Work Order", wo_list[1])
|
||||
wo2.cancel()
|
||||
pln.reload()
|
||||
self.assertEqual(pln.po_items[0].ordered_qty, 0)
|
||||
|
||||
def create_production_plan(**args):
|
||||
args = frappe._dict(args)
|
||||
|
@ -449,7 +449,13 @@ class WorkOrder(Document):
|
||||
|
||||
def update_ordered_qty(self):
|
||||
if self.production_plan and self.production_plan_item:
|
||||
qty = self.qty if self.docstatus == 1 else 0
|
||||
qty = frappe.get_value("Production Plan Item", self.production_plan_item, "ordered_qty") or 0.0
|
||||
|
||||
if self.docstatus == 1:
|
||||
qty += self.qty
|
||||
elif self.docstatus == 2:
|
||||
qty -= self.qty
|
||||
|
||||
frappe.db.set_value('Production Plan Item',
|
||||
self.production_plan_item, 'ordered_qty', qty)
|
||||
|
||||
|
@ -172,10 +172,15 @@ class ProductionPlanReport(object):
|
||||
|
||||
self.purchase_details = {}
|
||||
|
||||
for d in frappe.get_all("Purchase Order Item",
|
||||
purchased_items = frappe.get_all("Purchase Order Item",
|
||||
fields=["item_code", "min(schedule_date) as arrival_date", "qty as arrival_qty", "warehouse"],
|
||||
filters = {"item_code": ("in", self.item_codes), "warehouse": ("in", self.warehouses)},
|
||||
group_by = "item_code, warehouse"):
|
||||
filters={
|
||||
"item_code": ("in", self.item_codes),
|
||||
"warehouse": ("in", self.warehouses),
|
||||
"docstatus": 1,
|
||||
},
|
||||
group_by = "item_code, warehouse")
|
||||
for d in purchased_items:
|
||||
key = (d.item_code, d.warehouse)
|
||||
if key not in self.purchase_details:
|
||||
self.purchase_details.setdefault(key, d)
|
||||
|
@ -9,7 +9,6 @@ Manufacturing
|
||||
Stock
|
||||
Support
|
||||
Utilities
|
||||
Shopping Cart
|
||||
Assets
|
||||
Portal
|
||||
Maintenance
|
||||
@ -21,4 +20,5 @@ Quality Management
|
||||
Communication
|
||||
Loan Management
|
||||
Payroll
|
||||
Telephony
|
||||
Telephony
|
||||
E-commerce
|
||||
|
@ -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
|
||||
|
@ -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
|
72
erpnext/patches/v13_0/create_website_items.py
Normal file
72
erpnext/patches/v13_0/create_website_items.py
Normal file
@ -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)
|
16
erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py
Normal file
16
erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py
Normal file
@ -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()
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user