diff --git a/webshop/hooks.py b/webshop/hooks.py index dd3fece5a3..ec80136a9f 100644 --- a/webshop/hooks.py +++ b/webshop/hooks.py @@ -23,10 +23,11 @@ ] my_account_context = "webshop.webshop.shopping_cart.utils.update_my_account_context" -website_generators = ["Website Item"] +website_generators = ["Website Item", "Item Group"] override_doctype_class = { "Payment Request": "webshop.webshop.override_doctype.payment_request.PaymentRequest", + "Item Group": "webshop.webshop.doctype.override_doctype.item_group.WebshopItemGroup", } doctype_js = { diff --git a/webshop/patches/__init__.py b/webshop/patches/__init__.py new file mode 100644 index 0000000000..7a0660b4a6 --- /dev/null +++ b/webshop/patches/__init__.py @@ -0,0 +1,3 @@ + +__version__ = '0.0.1' + diff --git a/webshop/patches/convert_to_website_item_in_item_card_group_template.py b/webshop/patches/convert_to_website_item_in_item_card_group_template.py new file mode 100644 index 0000000000..40ae1979f9 --- /dev/null +++ b/webshop/patches/convert_to_website_item_in_item_card_group_template.py @@ -0,0 +1,60 @@ +import json +from typing import List, Union + +import frappe + +from webshop.webshop.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("webshop", "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: + doc.log_error("Website Item creation failed") + return None \ No newline at end of file diff --git a/webshop/patches/copy_custom_field_filters_to_website_item.py b/webshop/patches/copy_custom_field_filters_to_website_item.py new file mode 100644 index 0000000000..1f986edbf5 --- /dev/null +++ b/webshop/patches/copy_custom_field_filters_to_website_item.py @@ -0,0 +1,94 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_field + + +def execute(): + "Add Field Filters, that are not standard fields in Website Item, as Custom Fields." + + def move_table_multiselect_data(docfield): + "Copy child table data (Table Multiselect) from Item to Website Item for a docfield." + table_multiselect_data = get_table_multiselect_data(docfield) + field = docfield.fieldname + + for row in table_multiselect_data: + # add copied multiselect data rows in Website Item + web_item = frappe.db.get_value("Website Item", {"item_code": row.parent}) + web_item_doc = frappe.get_doc("Website Item", web_item) + + child_doc = frappe.new_doc(docfield.options, parent_doc=web_item_doc, parentfield=field) + + for field in ["name", "creation", "modified", "idx"]: + row[field] = None + + child_doc.update(row) + + child_doc.parenttype = "Website Item" + child_doc.parent = web_item + + child_doc.insert() + + def get_table_multiselect_data(docfield): + child_table = frappe.qb.DocType(docfield.options) + item = frappe.qb.DocType("Item") + + table_multiselect_data = ( # query table data for field + frappe.qb.from_(child_table) + .join(item) + .on(item.item_code == child_table.parent) + .select(child_table.star) + .where((child_table.parentfield == docfield.fieldname) & (item.published_in_website == 1)) + ).run(as_dict=True) + + return table_multiselect_data + + settings = frappe.get_doc("E Commerce Settings") + + if not (settings.enable_field_filters or settings.filter_fields): + return + + item_meta = frappe.get_meta("Item") + valid_item_fields = [ + df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"] + ] + + web_item_meta = frappe.get_meta("Website Item") + valid_web_item_fields = [ + df.fieldname for df in web_item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"] + ] + + for row in settings.filter_fields: + # skip if illegal field + if row.fieldname not in valid_item_fields: + continue + + # if Item field is not in Website Item, add it as a custom field + if row.fieldname not in valid_web_item_fields: + df = item_meta.get_field(row.fieldname) + create_custom_field( + "Website Item", + dict( + owner="Administrator", + fieldname=df.fieldname, + label=df.label, + fieldtype=df.fieldtype, + options=df.options, + description=df.description, + read_only=df.read_only, + no_copy=df.no_copy, + insert_after="on_backorder", + ), + ) + + # map field values + if df.fieldtype == "Table MultiSelect": + move_table_multiselect_data(df) + else: + frappe.db.sql( # nosemgrep + """ + UPDATE `tabWebsite Item` wi, `tabItem` i + SET wi.{0} = i.{0} + WHERE wi.item_code = i.item_code + """.format( + row.fieldname + ) + ) \ No newline at end of file diff --git a/webshop/patches/create_website_items.py b/webshop/patches/create_website_items.py new file mode 100644 index 0000000000..6eabce11c2 --- /dev/null +++ b/webshop/patches/create_website_items.py @@ -0,0 +1,85 @@ +import frappe + +from webshop.webshop.doctype.website_item.website_item import make_website_item + + +def execute(): + frappe.reload_doc("webshop", "doctype", "website_item") + frappe.reload_doc("webshop", "doctype", "website_item_tabbed_section") + frappe.reload_doc("webshop", "doctype", "website_offer") + frappe.reload_doc("webshop", "doctype", "recommended_items") + frappe.reload_doc("webshop", "doctype", "webshop_settings") + frappe.reload_doc("stock", "doctype", "item") + + item_fields = [ + "item_code", + "item_name", + "item_group", + "stock_uom", + "brand", + "has_variants", + "variant_of", + "description", + "weightage", + ] + web_fields_to_map = [ + "route", + "slideshow", + "website_image_alt", + "website_warehouse", + "web_long_description", + "website_content", + "website_image", + "thumbnail", + ] + + # get all valid columns (fields) from Item master DB schema + item_table_fields = frappe.db.sql("desc `tabItem`", as_dict=1) # nosemgrep + item_table_fields = [d.get("Field") for d in item_table_fields] + + # prepare fields to query from Item, check if the web field exists in Item master + web_query_fields = [] + for web_field in web_fields_to_map: + if web_field in item_table_fields: + web_query_fields.append(web_field) + item_fields.append(web_field) + + # check if the filter fields exist in Item master + or_filters = {} + for field in ["show_in_website", "show_variant_in_website"]: + if field in item_table_fields: + or_filters[field] = 1 + + if not web_query_fields or not or_filters: + # web fields to map are not present in Item master schema + # most likely a fresh installation that doesnt need this patch + return + + items = frappe.db.get_all("Item", fields=item_fields, or_filters=or_filters) + total_count = len(items) + + for count, item in enumerate(items, start=1): + if frappe.db.exists("Website Item", {"item_code": item.item_code}): + continue + + # make new website item from item (publish item) + website_item = make_website_item(item, save=False) + website_item.ranking = item.get("weightage") + + for field in web_fields_to_map: + website_item.update({field: item.get(field)}) + + website_item.save() + + # move Website Item Group & Website Specification table to Website Item + for doctype in ("Website Item Group", "Item Website Specification"): + frappe.db.set_value( + doctype, + {"parenttype": "Item", "parent": item.item_code}, # filters + {"parenttype": "Website Item", "parent": website_item.name}, # value dict + ) + + if count % 20 == 0: # commit after every 20 items + frappe.db.commit() + + frappe.utils.update_progress_bar("Creating Website Items", count, total_count) diff --git a/webshop/patches/fetch_thumbnail_in_website_items.py b/webshop/patches/fetch_thumbnail_in_website_items.py new file mode 100644 index 0000000000..e87f4f14e2 --- /dev/null +++ b/webshop/patches/fetch_thumbnail_in_website_items.py @@ -0,0 +1,11 @@ +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() \ No newline at end of file diff --git a/webshop/patches/make_homepage_products_website_items.py b/webshop/patches/make_homepage_products_website_items.py new file mode 100644 index 0000000000..7a7ddba12d --- /dev/null +++ b/webshop/patches/make_homepage_products_website_items.py @@ -0,0 +1,15 @@ +import frappe + + +def execute(): + homepage = frappe.get_doc("Homepage") + + for row in homepage.products: + web_item = frappe.db.get_value("Website Item", {"item_code": row.item_code}, "name") + if not web_item: + continue + + row.item_code = web_item + + homepage.flags.ignore_mandatory = True + homepage.save() \ No newline at end of file diff --git a/webshop/patches/populate_e_commerce_settings.py b/webshop/patches/populate_e_commerce_settings.py new file mode 100644 index 0000000000..061a981f39 --- /dev/null +++ b/webshop/patches/populate_e_commerce_settings.py @@ -0,0 +1,68 @@ +import frappe +from frappe.utils import cint + + +def execute(): + frappe.reload_doc("webshop", "doctype", "webshop_settings") + frappe.reload_doc("portal", "doctype", "website_filter_field") + frappe.reload_doc("portal", "doctype", "website_attribute") + + products_settings_fields = [ + "hide_variants", + "products_per_page", + "enable_attribute_filters", + "enable_field_filters", + ] + + shopping_cart_settings_fields = [ + "enabled", + "show_attachments", + "show_price", + "show_stock_availability", + "enable_variants", + "show_contact_us_button", + "show_quantity_in_website", + "show_apply_coupon_code_in_website", + "allow_items_not_in_stock", + "company", + "price_list", + "default_customer_group", + "quotation_series", + "enable_checkout", + "payment_success_url", + "payment_gateway_account", + "save_quotations_as_draft", + ] + + settings = frappe.get_doc("E Commerce Settings") + + def map_into_e_commerce_settings(doctype, fields): + singles = frappe.qb.DocType("Singles") + query = ( + frappe.qb.from_(singles) + .select(singles["field"], singles.value) + .where((singles.doctype == doctype) & (singles["field"].isin(fields))) + ) + data = query.run(as_dict=True) + + # {'enable_attribute_filters': '1', ...} + mapper = {row.field: row.value for row in data} + + for key, value in mapper.items(): + value = cint(value) if (value and value.isdigit()) else value + settings.update({key: value}) + + settings.save() + + # shift data to E Commerce Settings + map_into_e_commerce_settings("Products Settings", products_settings_fields) + map_into_e_commerce_settings("Shopping Cart Settings", shopping_cart_settings_fields) + + # move filters and attributes tables to E Commerce Settings from Products Settings + for doctype in ("Website Filter Field", "Website Attribute"): + frappe.db.set_value( + doctype, + {"parent": "Products Settings"}, + {"parenttype": "E Commerce Settings", "parent": "E Commerce Settings"}, + update_modified=False, + ) \ No newline at end of file diff --git a/webshop/patches/shopping_cart_to_ecommerce.py b/webshop/patches/shopping_cart_to_ecommerce.py new file mode 100644 index 0000000000..14d2bd2a23 --- /dev/null +++ b/webshop/patches/shopping_cart_to_ecommerce.py @@ -0,0 +1,9 @@ +import click +import frappe + + +def execute(): + + frappe.delete_doc("DocType", "Shopping Cart Settings", ignore_missing=True) + frappe.delete_doc("DocType", "Products Settings", ignore_missing=True) + frappe.delete_doc("DocType", "Supplier Item Group", ignore_missing=True) diff --git a/webshop/setup/install.py b/webshop/setup/install.py index 8480625b6e..71b2845301 100644 --- a/webshop/setup/install.py +++ b/webshop/setup/install.py @@ -6,80 +6,213 @@ def after_install(): - copy_from_ecommerce_settings() - drop_ecommerce_settings() - remove_ecommerce_settings_doctype() - add_custom_fields() - navbar_add_products_link() - frappe.db.commit() - say_thanks() + run_patches() + copy_from_ecommerce_settings() + drop_ecommerce_settings() + remove_ecommerce_settings_doctype() + add_custom_fields() + navbar_add_products_link() + say_thanks() def copy_from_ecommerce_settings(): - qb = frappe.qb - table = frappe.qb.Table("tabSingles") - old_doctype = "E Commerce Settings" - new_doctype = "Webshop Settings" - fields = ("field", "value") + if not frappe.db.table_exists("E Commerce Settings"): + return - entries = ( - qb.from_(table) - .select(*fields) - .where(table.doctype == old_doctype) - .run(as_dict=True) - ) + qb = frappe.qb + table = frappe.qb.Table("tabSingles") + old_doctype = "E Commerce Settings" + new_doctype = "Webshop Settings" + fields = ("field", "value") - for e in entries: - qb.into(table).insert(new_doctype, e.field, e.value).run() + entries = ( + qb.from_(table) + .select(*fields) + .where(table.doctype == old_doctype) + .run(as_dict=True) + ) + + for e in entries: + qb.into(table).insert(new_doctype, e.field, e.value).run() def drop_ecommerce_settings(): - frappe.delete_doc_if_exists("DocType", "E Commerce Settings", force=True) + frappe.delete_doc_if_exists("DocType", "E Commerce Settings", force=True) def remove_ecommerce_settings_doctype(): - table = frappe.qb.Table("tabSingles") - old_doctype = "E Commerce Settings" + if not frappe.db.table_exists("E Commerce Settings"): + return - frappe.qb.from_(table).delete().where(table.doctype == old_doctype).run() + table = frappe.qb.Table("tabSingles") + old_doctype = "E Commerce Settings" + frappe.qb.from_(table).delete().where(table.doctype == old_doctype).run() -def add_custom_fields(): - d = { - "Item": [ - { - "default": 0, - "depends_on": "published_in_website", - "fieldname": "published_in_website", - "fieldtype": "Check", - "ignore_user_permissions": 1, - "insert_after": "default_manufacturer_part_no", - "label": "Published In Website", - "read_only": 1, - } - ] - } - - return create_custom_fields(d) +def add_custom_fields(): + custom_fields = { + "Item": [ + { + "default": 0, + "depends_on": "published_in_website", + "fieldname": "published_in_website", + "fieldtype": "Check", + "ignore_user_permissions": 1, + "insert_after": "default_manufacturer_part_no", + "label": "Published In Website", + "read_only": 1, + } + ], + "Item Group": [ + { + "fieldname": "custom_website_settings", + "fieldtype": "Section Break", + "label": "Website Settings", + "insert_after": "taxes", + }, + { + "default": "0", + "description": "Make Item Group visible in website", + "fieldname": "show_in_website", + "fieldtype": "Check", + "label": "Show in Website", + "insert_after": "custom_website_settings", + }, + { + "depends_on": "show_in_website", + "fieldname": "route", + "fieldtype": "Data", + "label": "Route", + "no_copy": 1, + "unique": 1, + "insert_after": "show_in_website", + }, + { + "depends_on": "show_in_website", + "fieldname": "website_title", + "fieldtype": "Data", + "label": "Title", + "insert_after": "route", + }, + { + "depends_on": "show_in_website", + "description": "HTML / Banner that will show on the top of product list.", + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description", + "insert_after": "website_title", + }, + { + "default": "0", + "depends_on": "show_in_website", + "description": "Include Website Items belonging to child Item Groups", + "fieldname": "include_descendants", + "fieldtype": "Check", + "label": "Include Descendants", + "insert_after": "website_title", + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break", + "insert_after": "include_descendants", + }, + { + "depends_on": "show_in_website", + "fieldname": "weightage", + "fieldtype": "Int", + "label": "Weightage", + "insert_after": "column_break_16", + }, + { + "depends_on": "show_in_website", + "description": "Show this slideshow at the top of the page", + "fieldname": "slideshow", + "fieldtype": "Link", + "label": "Slideshow", + "options": "Website Slideshow", + "insert_after": "weightage", + }, + { + "depends_on": "show_in_website", + "fieldname": "website_specifications", + "fieldtype": "Table", + "label": "Website Specifications", + "options": "Item Website Specification", + "insert_after": "description", + }, + { + "collapsible": 1, + "depends_on": "show_in_website", + "fieldname": "website_filters_section", + "fieldtype": "Section Break", + "label": "Website Filters", + "insert_after": "website_specifications", + }, + { + "fieldname": "filter_fields", + "fieldtype": "Table", + "label": "Item Fields", + "options": "Website Filter Field", + "insert_after": "website_filters_section", + }, + { + "fieldname": "filter_attributes", + "fieldtype": "Table", + "label": "Attributes", + "options": "Website Attribute", + "insert_after": "filter_fields", + }, + ] + } + + return create_custom_fields(custom_fields) def navbar_add_products_link(): - website_settings = frappe.get_single("Website Settings") + website_settings = frappe.get_single("Website Settings") - if not website_settings: - return + if not website_settings: + return - website_settings.append( - "top_bar_items", - { - "label": _("Products"), - "url": "/all-products", - "right": False, - }, - ) + website_settings.append( + "top_bar_items", + { + "label": _("Products"), + "url": "/all-products", + "right": False, + }, + ) - return website_settings.save() + return website_settings.save() def say_thanks(): - click.secho("Thank you for installing Frappe Webshop!", color="green") + click.secho("Thank you for installing Frappe Webshop!", color="green") + + +patches = [ + "create_website_items", + "populate_e_commerce_settings", + "make_homepage_products_website_items", + "fetch_thumbnail_in_website_items", + "convert_to_website_item_in_item_card_group_template", + "shopping_cart_to_ecommerce" + "copy_custom_field_filters_to_website_item", +] + +def run_patches(): + # Customers migrating from v13 to v15 directly need to run all below patches + + if frappe.db.table_exists("Website Item"): + return + + frappe.flags.in_patch = True + + try: + for patch in patches: + frappe.get_attr(f"webshop.patches.after_install.{patch}.execute")() + + finally: + frappe.flags.in_patch = False + + diff --git a/webshop/templates/generators/item/item_add_to_cart.html b/webshop/templates/generators/item/item_add_to_cart.html index 0c4d75264d..937303d7d7 100644 --- a/webshop/templates/generators/item/item_add_to_cart.html +++ b/webshop/templates/generators/item/item_add_to_cart.html @@ -46,7 +46,7 @@ {{ _('In stock') }} {% if product_info.show_stock_qty and product_info.stock_qty %} - ({{ product_info.stock_qty[0][0] }}) + ({{ product_info.stock_qty }}) {% endif %} {% endif %} diff --git a/webshop/templates/generators/item_group.html b/webshop/templates/generators/item_group.html new file mode 100644 index 0000000000..956c3c51e6 --- /dev/null +++ b/webshop/templates/generators/item_group.html @@ -0,0 +1,72 @@ +{% from "erpnext/templates/includes/macros.html" import field_filter_section, attribute_filter_section, discount_range_filters %} +{% extends "templates/web.html" %} + +{% block header %} +