├── .github └── workflows │ ├── ci.yml │ └── semgroup-rules.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── webshop.png └── webshop ├── __init__.py ├── config └── __init__.py ├── hooks.py ├── modules.txt ├── patches.txt ├── patches ├── __init__.py ├── add_homepage_field.py ├── clear_cache_for_item_group_route.py ├── convert_to_website_item_in_item_card_group_template.py ├── copy_custom_field_filters_to_website_item.py ├── create_website_items.py ├── enable_allow_to_guest_view_for_item_group.py ├── fetch_thumbnail_in_website_items.py ├── make_homepage_products_website_items.py ├── populate_e_commerce_settings.py └── shopping_cart_to_ecommerce.py ├── public ├── .gitkeep ├── images │ └── cart-empty-state.png ├── js │ ├── customer_reviews.js │ ├── init.js │ ├── override │ │ ├── homepage.js │ │ └── item.js │ ├── product_ui │ │ ├── grid.js │ │ ├── list.js │ │ ├── search.js │ │ └── views.js │ ├── shopping_cart.js │ └── wishlist.js ├── scss │ ├── webshop-web.bundle.scss │ └── webshop_cart.scss └── web.bundle.js ├── setup └── install.py ├── templates ├── __init__.py ├── generators │ ├── item │ │ ├── item.html │ │ ├── item_add_to_cart.html │ │ ├── item_configure.html │ │ ├── item_configure.js │ │ ├── item_details.html │ │ ├── item_image.html │ │ ├── item_inquiry.html │ │ ├── item_inquiry.js │ │ ├── item_reviews.html │ │ └── item_specifications.html │ └── item_group.html ├── includes │ ├── cart │ │ ├── address_card.html │ │ ├── address_picker_card.html │ │ ├── cart_address.html │ │ ├── cart_address_picker.html │ │ ├── cart_dropdown.html │ │ ├── cart_items.html │ │ ├── cart_items_dropdown.html │ │ ├── cart_items_total.html │ │ ├── cart_macros.html │ │ ├── cart_payment_summary.html │ │ ├── coupon_code.html │ │ └── place_order.html │ ├── macros.html │ ├── navbar │ │ └── navbar_items.html │ ├── order │ │ ├── order_macros.html │ │ └── order_taxes.html │ └── product_page.js └── pages │ ├── __init__.py │ ├── cart.html │ ├── cart.js │ ├── cart.py │ ├── customer_reviews.html │ ├── customer_reviews.py │ ├── order.html │ ├── order.js │ ├── order.py │ ├── product_search.html │ ├── product_search.py │ ├── wishlist.html │ └── wishlist.py ├── webshop ├── __init__.py ├── api.py ├── crud_events │ ├── __init__.py │ ├── item │ │ ├── __init__.py │ │ ├── invalidate_item_variants_cache.py │ │ ├── update_website_item.py │ │ └── validate_duplicate_website_item.py │ ├── price_list │ │ ├── __init__.py │ │ └── check_impact_on_cart.py │ ├── quotation │ │ ├── __init__.py │ │ └── validate_shopping_cart_items.py │ └── tax_rule │ │ ├── __init__.py │ │ └── validate_use_for_cart.py ├── doctype │ ├── __init__.py │ ├── homepage_featured_product │ │ ├── __init__.py │ │ ├── homepage_featured_product.json │ │ └── homepage_featured_product.py │ ├── item_review │ │ ├── __init__.py │ │ ├── item_review.js │ │ ├── item_review.json │ │ ├── item_review.py │ │ └── test_item_review.py │ ├── override_doctype │ │ ├── __init__.py │ │ ├── item.py │ │ ├── item_group.py │ │ └── payment_request.py │ ├── recommended_items │ │ ├── __init__.py │ │ ├── recommended_items.json │ │ └── recommended_items.py │ ├── webshop_settings │ │ ├── __init__.py │ │ ├── test_webshop_settings.py │ │ ├── webshop_settings.js │ │ ├── webshop_settings.json │ │ └── webshop_settings.py │ ├── website_item │ │ ├── __init__.py │ │ ├── templates │ │ │ ├── website_item.html │ │ │ └── website_item_row.html │ │ ├── test_website_item.py │ │ ├── website_item.js │ │ ├── website_item.json │ │ ├── website_item.py │ │ └── website_item_list.js │ ├── website_item_tabbed_section │ │ ├── __init__.py │ │ ├── website_item_tabbed_section.json │ │ └── website_item_tabbed_section.py │ ├── website_offer │ │ ├── __init__.py │ │ ├── website_offer.json │ │ └── website_offer.py │ ├── wishlist │ │ ├── __init__.py │ │ ├── test_wishlist.py │ │ ├── wishlist.js │ │ ├── wishlist.json │ │ └── wishlist.py │ └── wishlist_item │ │ ├── __init__.py │ │ ├── wishlist_item.json │ │ └── wishlist_item.py ├── legacy_search.py ├── product_data_engine │ ├── filters.py │ ├── query.py │ ├── test_item_group_product_data_engine.py │ └── test_product_data_engine.py ├── redisearch_utils.py ├── shopping_cart │ ├── __init__.py │ ├── cart.py │ ├── product_info.py │ ├── test_shopping_cart.py │ └── utils.py ├── utils │ ├── __init__.py │ ├── portal.py │ ├── product.py │ └── setup.py ├── variant_selector │ ├── __init__.py │ ├── item_variants_cache.py │ ├── test_variant_selector.py │ └── utils.py └── web_template │ ├── __init__.py │ ├── hero_slider │ ├── __init__.py │ ├── hero_slider.html │ └── hero_slider.json │ ├── item_card_group │ ├── __init__.py │ ├── item_card_group.html │ └── item_card_group.json │ ├── product_card │ ├── __init__.py │ ├── product_card.html │ └── product_card.json │ └── product_category_cards │ ├── __init__.py │ ├── product_category_cards.html │ └── product_category_cards.json └── www ├── __init__.py ├── all-products ├── __init__.py ├── index.html ├── index.js ├── index.py └── not_found.html └── shop-by-category ├── __init__.py ├── category_card_section.html ├── index.html ├── index.js └── index.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**.css" 7 | - "**.js" 8 | - "**.md" 9 | - "**.html" 10 | - "**.csv" 11 | schedule: 12 | # Run everday at midnight UTC / 5:30 IST 13 | - cron: "0 0 * * *" 14 | 15 | env: 16 | WEBSHOP_BRANCH: ${{ github.base_ref || github.ref_name }} 17 | 18 | concurrency: 19 | group: develop-webshop-${{ github.event.number }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | tests: 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | name: Server 28 | 29 | services: 30 | redis-cache: 31 | image: redis:alpine 32 | ports: 33 | - 13000:6379 34 | redis-queue: 35 | image: redis:alpine 36 | ports: 37 | - 11000:6379 38 | mariadb: 39 | image: mariadb:10.6 40 | env: 41 | MYSQL_ROOT_PASSWORD: root 42 | ports: 43 | - 3306:3306 44 | options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 45 | 46 | steps: 47 | - name: Clone 48 | uses: actions/checkout@v3 49 | 50 | - name: Find tests 51 | run: | 52 | echo "Finding tests" 53 | grep -rn "def test" > /dev/null 54 | 55 | - name: Setup Python 56 | uses: actions/setup-python@v4 57 | with: 58 | python-version: '3.10' 59 | 60 | - name: Setup Node 61 | uses: actions/setup-node@v3 62 | with: 63 | node-version: 18 64 | check-latest: true 65 | 66 | - name: Cache pip 67 | uses: actions/cache@v4 68 | with: 69 | path: ~/.cache/pip 70 | key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} 71 | restore-keys: | 72 | ${{ runner.os }}-pip- 73 | ${{ runner.os }}- 74 | 75 | - name: Get yarn cache directory path 76 | id: yarn-cache-dir-path 77 | run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT' 78 | 79 | - uses: actions/cache@v4 80 | id: yarn-cache 81 | with: 82 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 83 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 84 | restore-keys: | 85 | ${{ runner.os }}-yarn- 86 | 87 | - name: Install MariaDB Client 88 | run: sudo apt update && sudo apt-get install mariadb-client 89 | 90 | - name: Setup 91 | run: | 92 | pip install frappe-bench 93 | bench init --frappe-branch $WEBSHOP_BRANCH --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench 94 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" 95 | mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" 96 | 97 | - name: Install 98 | working-directory: /home/runner/frappe-bench 99 | run: | 100 | bench setup requirements --dev 101 | bench get-app erpnext --branch $WEBSHOP_BRANCH 102 | bench get-app payments --branch $WEBSHOP_BRANCH 103 | bench get-app $GITHUB_WORKSPACE 104 | bench new-site --db-root-password root --admin-password admin test_site 105 | bench --site test_site install-app erpnext 106 | bench --site test_site install-app webshop 107 | bench build 108 | env: 109 | CI: 'Yes' 110 | 111 | - name: Run Tests 112 | working-directory: /home/runner/frappe-bench 113 | run: | 114 | bench --site test_site set-config allow_tests true 115 | bench --site test_site run-tests --app webshop 116 | env: 117 | TYPE: server -------------------------------------------------------------------------------- /.github/workflows/semgroup-rules.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | pull_request: { } 5 | 6 | jobs: 7 | linters: 8 | name: Frappe Linter 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: '3.10' 17 | 18 | - name: Download Semgrep rules 19 | run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules 20 | 21 | - name: Download semgrep 22 | run: pip install semgrep 23 | 24 | - name: Run Semgrep rules 25 | run: semgrep ci --config ./frappe-semgrep-rules/rules 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | *.egg-info 3 | *.pyc 4 | *.py~ 5 | *.swo 6 | *.swp 7 | *~ 8 | .DS_Store 9 | .backportrc.json 10 | .idea/ 11 | .vscode/ 12 | .wnf-lang-status 13 | __pycache__ 14 | conf.py 15 | dist/ 16 | webshop/docs/current 17 | webshop/public/dist 18 | latest_updates.json 19 | locale 20 | node_modules/ 21 | tags 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "webshop" 3 | authors = [ 4 | { name = "Frappe Technologies Pvt. Ltd.", email = "contact@frappe.io"} 5 | ] 6 | description = "Open Source eCommerce Platform" 7 | requires-python = ">=3.10" 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | dependencies = [] 11 | 12 | [build-system] 13 | requires = ["flit_core >=3.4,<4"] 14 | build-backend = "flit_core.buildapi" 15 | 16 | [tool.black] 17 | line-length = 99 18 | 19 | [tool.isort] 20 | line_length = 99 21 | multi_line_output = 3 22 | include_trailing_comma = true 23 | force_grid_wrap = 0 24 | use_parentheses = true 25 | ensure_newline_before_comments = true 26 | indent = "\t" 27 | -------------------------------------------------------------------------------- /webshop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop.png -------------------------------------------------------------------------------- /webshop/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = '0.0.1' 3 | 4 | -------------------------------------------------------------------------------- /webshop/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/config/__init__.py -------------------------------------------------------------------------------- /webshop/hooks.py: -------------------------------------------------------------------------------- 1 | from . import __version__ as _version 2 | 3 | app_name = "webshop" 4 | app_title = "Webshop" 5 | app_publisher = "Frappe Technologies Pvt. Ltd." 6 | app_description = "Open Source eCommerce Platform" 7 | app_email = "contact@frappe.io" 8 | app_license = "GNU General Public License (v3)" 9 | app_version = _version 10 | 11 | required_apps = ["payments", "erpnext"] 12 | 13 | web_include_css = "webshop-web.bundle.css" 14 | 15 | web_include_js = "web.bundle.js" 16 | 17 | after_install = "webshop.setup.install.after_install" 18 | on_logout = "webshop.webshop.shopping_cart.utils.clear_cart_count" 19 | on_session_creation = [ 20 | "webshop.webshop.utils.portal.update_debtors_account", 21 | "webshop.webshop.shopping_cart.utils.set_cart_count", 22 | ] 23 | update_website_context = [ 24 | "webshop.webshop.shopping_cart.utils.update_website_context", 25 | ] 26 | 27 | website_generators = ["Website Item", "Item Group"] 28 | 29 | override_doctype_class = { 30 | "Payment Request": "webshop.webshop.doctype.override_doctype.payment_request.PaymentRequest", 31 | "Item Group": "webshop.webshop.doctype.override_doctype.item_group.WebshopItemGroup", 32 | "Item": "webshop.webshop.doctype.override_doctype.item.WebshopItem", 33 | } 34 | 35 | doctype_js = { 36 | "Item": "public/js/override/item.js", 37 | "Homepage": "public/js/override/homepage.js", 38 | } 39 | 40 | doc_events = { 41 | "Item": { 42 | "on_update": [ 43 | "webshop.webshop.crud_events.item.update_website_item.execute", 44 | "webshop.webshop.crud_events.item.invalidate_item_variants_cache.execute", 45 | ], 46 | "before_rename": [ 47 | "webshop.webshop.crud_events.item.validate_duplicate_website_item.execute", 48 | ], 49 | "after_rename": [ 50 | "webshop.webshop.crud_events.item.invalidate_item_variants_cache.execute", 51 | ], 52 | }, 53 | "Sales Taxes and Charges Template": { 54 | "on_update": [ 55 | "webshop.webshop.doctype.webshop_settings.webshop_settings.validate_cart_settings", 56 | ], 57 | }, 58 | "Quotation": { 59 | "validate": [ 60 | "webshop.webshop.crud_events.quotation.validate_shopping_cart_items.execute", 61 | ], 62 | }, 63 | "Price List": { 64 | "validate": [ 65 | "webshop.webshop.crud_events.price_list.check_impact_on_cart.execute" 66 | ], 67 | }, 68 | "Tax Rule": { 69 | "validate": [ 70 | "webshop.webshop.crud_events.tax_rule.validate_use_for_cart.execute", 71 | ], 72 | }, 73 | } 74 | 75 | has_website_permission = { 76 | "Website Item": "webshop.webshop.doctype.website_item.website_item.has_website_permission_for_website_item", 77 | "Item Group": "webshop.webshop.doctype.website_item.website_item.has_website_permission_for_item_group" 78 | } 79 | -------------------------------------------------------------------------------- /webshop/modules.txt: -------------------------------------------------------------------------------- 1 | Webshop -------------------------------------------------------------------------------- /webshop/patches.txt: -------------------------------------------------------------------------------- 1 | [pre_model_sync] 2 | 3 | [post_model_sync] 4 | 5 | webshop.patches.add_homepage_field #09-05-2024 6 | webshop.patches.enable_allow_to_guest_view_for_item_group 7 | webshop.patches.clear_cache_for_item_group_route -------------------------------------------------------------------------------- /webshop/patches/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = '0.0.1' 3 | 4 | -------------------------------------------------------------------------------- /webshop/patches/add_homepage_field.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.custom.doctype.custom_field.custom_field import create_custom_fields 3 | 4 | 5 | def execute(): 6 | if not frappe.db.exists("DocType", "Homepage"): 7 | return 8 | if not frappe.db.exists("Custom Field", {"fieldname": "products", "dt": "Homepage"}): 9 | custom_fields = { 10 | "Homepage": [ 11 | dict( 12 | fieldname="products_section_break", 13 | label="Products", 14 | fieldtype="Section Break", 15 | insert_after="hero_section", 16 | ), 17 | dict( 18 | fieldname="products_url", 19 | label="URL for All Products", 20 | fieldtype="Data", 21 | insert_after="products_section_break", 22 | ), 23 | dict( 24 | fieldname="products", 25 | label="Products", 26 | fieldtype="Table", 27 | insert_after="products_url", 28 | options="Homepage Featured Product", 29 | ), 30 | ], 31 | } 32 | 33 | create_custom_fields(custom_fields) 34 | -------------------------------------------------------------------------------- /webshop/patches/clear_cache_for_item_group_route.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.website.utils import clear_cache 3 | 4 | def execute(): 5 | routes = frappe.get_all("Item Group", filters={"show_in_website": 1, "route": ("is", "set")}, pluck="route") 6 | for route in routes: 7 | clear_cache(route) -------------------------------------------------------------------------------- /webshop/patches/convert_to_website_item_in_item_card_group_template.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List, Union 3 | 4 | import frappe 5 | 6 | from webshop.webshop.doctype.website_item.website_item import make_website_item 7 | 8 | 9 | def execute(): 10 | """ 11 | Convert all Item links to Website Item link values in 12 | exisitng 'Item Card Group' Web Page Block data. 13 | """ 14 | frappe.reload_doc("webshop", "web_template", "item_card_group") 15 | 16 | blocks = frappe.db.get_all( 17 | "Web Page Block", 18 | filters={"web_template": "Item Card Group"}, 19 | fields=["parent", "web_template_values", "name"], 20 | ) 21 | 22 | fields = generate_fields_to_edit() 23 | 24 | for block in blocks: 25 | web_template_value = json.loads(block.get("web_template_values")) 26 | 27 | for field in fields: 28 | item = web_template_value.get(field) 29 | if not item: 30 | continue 31 | 32 | if frappe.db.exists("Website Item", {"item_code": item}): 33 | website_item = frappe.db.get_value("Website Item", {"item_code": item}) 34 | else: 35 | website_item = make_new_website_item(item) 36 | 37 | if website_item: 38 | web_template_value[field] = website_item 39 | 40 | frappe.db.set_value( 41 | "Web Page Block", block.name, "web_template_values", json.dumps(web_template_value) 42 | ) 43 | 44 | 45 | def generate_fields_to_edit() -> List: 46 | fields = [] 47 | for i in range(1, 13): 48 | fields.append(f"card_{i}_item") # fields like 'card_1_item', etc. 49 | 50 | return fields 51 | 52 | 53 | def make_new_website_item(item: str) -> Union[str, None]: 54 | try: 55 | doc = frappe.get_doc("Item", item) 56 | web_item = make_website_item(doc) # returns [website_item.name, item_name] 57 | return web_item[0] 58 | except Exception: 59 | doc.log_error("Website Item creation failed") 60 | return None -------------------------------------------------------------------------------- /webshop/patches/copy_custom_field_filters_to_website_item.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.custom.doctype.custom_field.custom_field import create_custom_field 3 | 4 | from webshop.webshop.utils.setup import has_ecommerce_fields 5 | 6 | def execute(): 7 | "Add Field Filters, that are not standard fields in Website Item, as Custom Fields." 8 | 9 | def move_table_multiselect_data(docfield): 10 | "Copy child table data (Table Multiselect) from Item to Website Item for a docfield." 11 | table_multiselect_data = get_table_multiselect_data(docfield) 12 | field = docfield.fieldname 13 | 14 | for row in table_multiselect_data: 15 | # add copied multiselect data rows in Website Item 16 | web_item = frappe.db.get_value("Website Item", {"item_code": row.parent}) 17 | web_item_doc = frappe.get_doc("Website Item", web_item) 18 | 19 | child_doc = frappe.new_doc(docfield.options, parent_doc=web_item_doc, parentfield=field) 20 | 21 | for field in ["name", "creation", "modified", "idx"]: 22 | row[field] = None 23 | 24 | child_doc.update(row) 25 | 26 | child_doc.parenttype = "Website Item" 27 | child_doc.parent = web_item 28 | 29 | child_doc.insert() 30 | 31 | def get_table_multiselect_data(docfield): 32 | child_table = frappe.qb.DocType(docfield.options) 33 | item = frappe.qb.DocType("Item") 34 | 35 | table_multiselect_data = ( # query table data for field 36 | frappe.qb.from_(child_table) 37 | .join(item) 38 | .on(item.item_code == child_table.parent) 39 | .select(child_table.star) 40 | .where((child_table.parentfield == docfield.fieldname) & (item.published_in_website == 1)) 41 | ).run(as_dict=True) 42 | 43 | return table_multiselect_data 44 | 45 | settings_doctype = "E Commerce Settings" if has_ecommerce_fields() else "Webshop Settings" 46 | 47 | settings = frappe.get_doc(settings_doctype) 48 | 49 | if not (settings.enable_field_filters or settings.filter_fields): 50 | return 51 | 52 | item_meta = frappe.get_meta("Item") 53 | valid_item_fields = [ 54 | df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"] 55 | ] 56 | 57 | web_item_meta = frappe.get_meta("Website Item") 58 | valid_web_item_fields = [ 59 | df.fieldname for df in web_item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"] 60 | ] 61 | 62 | for row in settings.filter_fields: 63 | # skip if illegal field 64 | if row.fieldname not in valid_item_fields: 65 | continue 66 | 67 | # if Item field is not in Website Item, add it as a custom field 68 | if row.fieldname not in valid_web_item_fields: 69 | df = item_meta.get_field(row.fieldname) 70 | create_custom_field( 71 | "Website Item", 72 | dict( 73 | owner="Administrator", 74 | fieldname=df.fieldname, 75 | label=df.label, 76 | fieldtype=df.fieldtype, 77 | options=df.options, 78 | description=df.description, 79 | read_only=df.read_only, 80 | no_copy=df.no_copy, 81 | insert_after="on_backorder", 82 | ), 83 | ) 84 | 85 | # map field values 86 | if df.fieldtype == "Table MultiSelect": 87 | move_table_multiselect_data(df) 88 | else: 89 | frappe.db.sql( # nosemgrep 90 | """ 91 | UPDATE `tabWebsite Item` wi, `tabItem` i 92 | SET wi.{0} = i.{0} 93 | WHERE wi.item_code = i.item_code 94 | """.format( 95 | row.fieldname 96 | ) 97 | ) -------------------------------------------------------------------------------- /webshop/patches/create_website_items.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from webshop.webshop.doctype.website_item.website_item import make_website_item 4 | 5 | 6 | def execute(): 7 | if frappe.get_all("Website Item", limit=1): 8 | return 9 | 10 | frappe.reload_doc("webshop", "doctype", "website_item") 11 | frappe.reload_doc("webshop", "doctype", "website_item_tabbed_section") 12 | frappe.reload_doc("webshop", "doctype", "website_offer") 13 | frappe.reload_doc("webshop", "doctype", "recommended_items") 14 | frappe.reload_doc("webshop", "doctype", "webshop_settings") 15 | frappe.reload_doc("stock", "doctype", "item") 16 | 17 | item_fields = [ 18 | "item_code", 19 | "item_name", 20 | "item_group", 21 | "stock_uom", 22 | "brand", 23 | "has_variants", 24 | "variant_of", 25 | "description", 26 | "weightage", 27 | ] 28 | web_fields_to_map = [ 29 | "route", 30 | "slideshow", 31 | "website_image_alt", 32 | "website_warehouse", 33 | "web_long_description", 34 | "website_content", 35 | "website_image", 36 | "thumbnail", 37 | ] 38 | 39 | # get all valid columns (fields) from Item master DB schema 40 | item_table_fields = frappe.db.sql("desc `tabItem`", as_dict=1) # nosemgrep 41 | item_table_fields = [d.get("Field") for d in item_table_fields] 42 | 43 | # prepare fields to query from Item, check if the web field exists in Item master 44 | web_query_fields = [] 45 | for web_field in web_fields_to_map: 46 | if web_field in item_table_fields: 47 | web_query_fields.append(web_field) 48 | item_fields.append(web_field) 49 | 50 | # check if the filter fields exist in Item master 51 | or_filters = {} 52 | for field in ["show_in_website", "show_variant_in_website"]: 53 | if field in item_table_fields: 54 | or_filters[field] = 1 55 | 56 | if not web_query_fields or not or_filters: 57 | # web fields to map are not present in Item master schema 58 | # most likely a fresh installation that doesnt need this patch 59 | return 60 | 61 | items = frappe.db.get_all("Item", fields=item_fields, or_filters=or_filters) 62 | total_count = len(items) 63 | 64 | for count, item in enumerate(items, start=1): 65 | if frappe.db.exists("Website Item", {"item_code": item.item_code}): 66 | continue 67 | 68 | # make new website item from item (publish item) 69 | website_item = make_website_item(item, save=False) 70 | website_item.ranking = item.get("weightage") 71 | 72 | for field in web_fields_to_map: 73 | website_item.update({field: item.get(field)}) 74 | 75 | website_item.save() 76 | 77 | # move Website Item Group & Website Specification table to Website Item 78 | for doctype in ("Website Item Group", "Item Website Specification"): 79 | frappe.db.set_value( 80 | doctype, 81 | {"parenttype": "Item", "parent": item.item_code}, # filters 82 | {"parenttype": "Website Item", "parent": website_item.name}, # value dict 83 | ) 84 | 85 | if count % 20 == 0: # commit after every 20 items 86 | frappe.db.commit() 87 | 88 | frappe.utils.update_progress_bar("Creating Website Items", count, total_count) 89 | -------------------------------------------------------------------------------- /webshop/patches/enable_allow_to_guest_view_for_item_group.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.custom.doctype.property_setter.property_setter import make_property_setter 3 | 4 | def execute(): 5 | frappe.reload_doc("setup", "doctype", "item_group") 6 | 7 | make_property_setter("Item Group", "", "has_web_view", 1, "Check", for_doctype=True, validate_fields_for_doctype=False) 8 | make_property_setter("Item Group", "", "allow_guest_to_view", 1, "Check", for_doctype=True, validate_fields_for_doctype=False) 9 | -------------------------------------------------------------------------------- /webshop/patches/fetch_thumbnail_in_website_items.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def execute(): 5 | if frappe.db.has_column("Item", "thumbnail"): 6 | website_item = frappe.qb.DocType("Website Item").as_("wi") 7 | item = frappe.qb.DocType("Item") 8 | 9 | frappe.qb.update(website_item).inner_join(item).on(website_item.item_code == item.item_code).set( 10 | website_item.thumbnail, item.thumbnail 11 | ).where(website_item.website_image.notnull() & website_item.thumbnail.isnull()).run() -------------------------------------------------------------------------------- /webshop/patches/make_homepage_products_website_items.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def execute(): 5 | if not frappe.db.exists("DocType", "Homepage"): 6 | return 7 | homepage = frappe.get_doc("Homepage") 8 | 9 | for row in homepage.products: 10 | web_item = frappe.db.get_value("Website Item", {"item_code": row.item_code}, "name") 11 | if not web_item: 12 | continue 13 | 14 | row.item_code = web_item 15 | 16 | homepage.flags.ignore_mandatory = True 17 | homepage.save() -------------------------------------------------------------------------------- /webshop/patches/populate_e_commerce_settings.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils import cint 3 | 4 | from webshop.webshop.utils.setup import has_ecommerce_fields 5 | 6 | def execute(): 7 | frappe.reload_doc("webshop", "doctype", "webshop_settings") 8 | frappe.reload_doc("portal", "doctype", "website_filter_field") 9 | frappe.reload_doc("portal", "doctype", "website_attribute") 10 | 11 | products_settings_fields = [ 12 | "hide_variants", 13 | "products_per_page", 14 | "enable_attribute_filters", 15 | "enable_field_filters", 16 | ] 17 | 18 | shopping_cart_settings_fields = [ 19 | "enabled", 20 | "show_attachments", 21 | "show_price", 22 | "show_stock_availability", 23 | "enable_variants", 24 | "show_contact_us_button", 25 | "show_quantity_in_website", 26 | "show_apply_coupon_code_in_website", 27 | "allow_items_not_in_stock", 28 | "company", 29 | "price_list", 30 | "default_customer_group", 31 | "quotation_series", 32 | "enable_checkout", 33 | "payment_success_url", 34 | "payment_gateway_account", 35 | "save_quotations_as_draft", 36 | ] 37 | 38 | settings_doctype = "E Commerce Settings" if has_ecommerce_fields() else "Webshop Settings" 39 | 40 | settings = frappe.get_doc(settings_doctype) 41 | 42 | def map_into_e_commerce_settings(doctype, fields): 43 | singles = frappe.qb.DocType("Singles") 44 | query = ( 45 | frappe.qb.from_(singles) 46 | .select(singles["field"], singles.value) 47 | .where((singles.doctype == doctype) & (singles["field"].isin(fields))) 48 | ) 49 | data = query.run(as_dict=True) 50 | 51 | # {'enable_attribute_filters': '1', ...} 52 | mapper = {row.field: row.value for row in data} 53 | 54 | for key, value in mapper.items(): 55 | value = cint(value) if (value and value.isdigit()) else value 56 | settings.update({key: value}) 57 | 58 | settings.save() 59 | 60 | # shift data to E Commerce Settings 61 | map_into_e_commerce_settings("Products Settings", products_settings_fields) 62 | map_into_e_commerce_settings("Shopping Cart Settings", shopping_cart_settings_fields) 63 | 64 | # move filters and attributes tables to E Commerce Settings from Products Settings 65 | for doctype in ("Website Filter Field", "Website Attribute"): 66 | frappe.db.set_value( 67 | doctype, 68 | {"parent": "Products Settings"}, 69 | {"parenttype": settings_doctype, "parent": settings_doctype}, 70 | update_modified=False, 71 | ) -------------------------------------------------------------------------------- /webshop/patches/shopping_cart_to_ecommerce.py: -------------------------------------------------------------------------------- 1 | import click 2 | import frappe 3 | 4 | 5 | def execute(): 6 | 7 | frappe.delete_doc("DocType", "Shopping Cart Settings", ignore_missing=True) 8 | frappe.delete_doc("DocType", "Products Settings", ignore_missing=True) 9 | frappe.delete_doc("DocType", "Supplier Item Group", ignore_missing=True) 10 | -------------------------------------------------------------------------------- /webshop/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/public/.gitkeep -------------------------------------------------------------------------------- /webshop/public/images/cart-empty-state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/public/images/cart-empty-state.png -------------------------------------------------------------------------------- /webshop/public/js/customer_reviews.js: -------------------------------------------------------------------------------- 1 | $(() => { 2 | class CustomerReviews { 3 | constructor() { 4 | this.bind_button_actions(); 5 | this.start = 0; 6 | this.page_length = 10; 7 | } 8 | 9 | bind_button_actions() { 10 | this.write_review(); 11 | this.view_more(); 12 | } 13 | 14 | write_review() { 15 | //TODO: make dialog popup on stray page 16 | $('.page_content').on('click', '.btn-write-review', (e) => { 17 | // Bind action on write a review button 18 | const $btn = $(e.currentTarget); 19 | 20 | let d = new frappe.ui.Dialog({ 21 | title: __("Write a Review"), 22 | fields: [ 23 | {fieldname: "title", fieldtype: "Data", label: "Headline", reqd: 1}, 24 | {fieldname: "rating", fieldtype: "Rating", label: "Overall Rating", reqd: 1}, 25 | {fieldtype: "Section Break"}, 26 | {fieldname: "comment", fieldtype: "Small Text", label: "Your Review"} 27 | ], 28 | primary_action: function() { 29 | let data = d.get_values(); 30 | frappe.call({ 31 | method: "webshop.webshop.doctype.item_review.item_review.add_item_review", 32 | args: { 33 | web_item: $btn.attr('data-web-item'), 34 | title: data.title, 35 | rating: data.rating, 36 | comment: data.comment 37 | }, 38 | freeze: true, 39 | freeze_message: __("Submitting Review ..."), 40 | callback: (r) => { 41 | if (!r.exc) { 42 | frappe.msgprint({ 43 | message: __("Thank you for submitting your review"), 44 | title: __("Review Submitted"), 45 | indicator: "green" 46 | }); 47 | d.hide(); 48 | location.reload(); 49 | } 50 | } 51 | }); 52 | }, 53 | primary_action_label: __("Submit") 54 | }); 55 | d.show(); 56 | }); 57 | } 58 | 59 | view_more() { 60 | $('.page_content').on('click', '.btn-view-more', (e) => { 61 | // Bind action on view more button 62 | const $btn = $(e.currentTarget); 63 | $btn.prop('disabled', true); 64 | 65 | this.start += this.page_length; 66 | let me = this; 67 | 68 | frappe.call({ 69 | method: "webshop.webshop.doctype.item_review.item_review.get_item_reviews", 70 | args: { 71 | web_item: $btn.attr('data-web-item'), 72 | start: me.start, 73 | end: me.page_length 74 | }, 75 | callback: (result) => { 76 | if (result.message) { 77 | let res = result.message; 78 | me.get_user_review_html(res.reviews); 79 | 80 | $btn.prop('disabled', false); 81 | if (res.total_reviews <= (me.start + me.page_length)) { 82 | $btn.hide(); 83 | } 84 | 85 | } 86 | } 87 | }); 88 | }); 89 | 90 | } 91 | 92 | get_user_review_html(reviews) { 93 | let me = this; 94 | let $content = $('.user-reviews'); 95 | 96 | reviews.forEach((review) => { 97 | $content.append(` 98 |
99 |
100 |

101 | ${__(review.review_title)} 102 |

103 |
104 | ${me.get_review_stars(review.rating)} 105 |
106 |
107 | 108 |
109 |

110 | ${__(review.comment)} 111 |

112 |
113 |
114 | ${__(review.customer)} 115 | 116 | ${__(review.published_on)} 117 |
118 |
119 | `); 120 | }); 121 | } 122 | 123 | get_review_stars(rating) { 124 | let stars = ``; 125 | for (let i = 1; i < 6; i++) { 126 | let fill_class = i <= rating ? 'star-click' : ''; 127 | stars += ` 128 | 129 | 130 | 131 | `; 132 | } 133 | return stars; 134 | } 135 | } 136 | 137 | new CustomerReviews(); 138 | }); 139 | -------------------------------------------------------------------------------- /webshop/public/js/init.js: -------------------------------------------------------------------------------- 1 | if (!window.webshop) window.webshop = {} 2 | if (!frappe.boot) frappe.boot = {} 3 | -------------------------------------------------------------------------------- /webshop/public/js/override/homepage.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Homepage', { 2 | setup: function(frm) { 3 | frm.set_query('item_code', 'products', function() { 4 | return { 5 | filters: {'published': 1} 6 | }; 7 | }); 8 | }, 9 | }); 10 | 11 | frappe.ui.form.on('Homepage Featured Product', { 12 | view: function(frm, cdt, cdn) { 13 | var child= locals[cdt][cdn]; 14 | if (child.item_code && child.route) { 15 | window.open('/' + child.route, '_blank'); 16 | } 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /webshop/public/js/override/item.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on("Item", { 2 | refresh: function(frm) { 3 | if (!frm.doc.__islocal) { 4 | if (!frm.doc.published_in_website) { 5 | frm.add_custom_button(__("Publish in Website"), function() { 6 | frappe.call({ 7 | method: "webshop.webshop.doctype.website_item.website_item.make_website_item", 8 | args: { 9 | doc: frm.doc, 10 | }, 11 | freeze: true, 12 | freeze_message: __("Publishing Item ..."), 13 | callback: function(result) { 14 | frappe.msgprint({ 15 | message: __("Website Item {0} has been created.", 16 | [repl('%(item)s', { 17 | item_encoded: encodeURIComponent(result.message[0]), 18 | item: result.message[1] 19 | })] 20 | ), 21 | title: __("Published"), 22 | indicator: "green" 23 | }); 24 | } 25 | }); 26 | }, __('Actions')); 27 | } else { 28 | frm.add_custom_button(__("View Website Item"), function() { 29 | frappe.db.get_value("Website Item", {item_code: frm.doc.name}, "name", (d) => { 30 | if (!d.name) frappe.throw(__("Website Item not found")); 31 | frappe.set_route("Form", "Website Item", d.name); 32 | }); 33 | }); 34 | } 35 | } 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /webshop/public/js/product_ui/grid.js: -------------------------------------------------------------------------------- 1 | webshop.ProductGrid = class { 2 | /* Options: 3 | - items: Items 4 | - settings: Webshop Settings 5 | - products_section: Products Wrapper 6 | - preference: If preference is not grid view, render but hide 7 | */ 8 | constructor(options) { 9 | Object.assign(this, options); 10 | 11 | if (this.preference !== "Grid View") { 12 | this.products_section.addClass("hidden"); 13 | } 14 | 15 | this.products_section.empty(); 16 | this.make(); 17 | } 18 | 19 | make() { 20 | let me = this; 21 | let html = ``; 22 | 23 | this.items.forEach(item => { 24 | let title = item.web_item_name || item.item_name || item.item_code || ""; 25 | title = title.length > 90 ? title.substr(0, 90) + "..." : title; 26 | 27 | html += `
`; 28 | html += me.get_image_html(item, title); 29 | html += me.get_card_body_html(item, title, me.settings); 30 | html += `
`; 31 | }); 32 | 33 | let $product_wrapper = this.products_section; 34 | $product_wrapper.append(html); 35 | } 36 | 37 | get_image_html(item, title) { 38 | let image = item.website_image; 39 | 40 | if (image) { 41 | return ` 42 |
43 | 44 | ${ title } 45 | 46 |
47 | `; 48 | } else { 49 | return ` 50 |
51 | 52 |
53 | ${ frappe.get_abbr(title) } 54 |
55 |
56 |
57 | `; 58 | } 59 | } 60 | 61 | get_card_body_html(item, title, settings) { 62 | let body_html = ` 63 |
64 |
65 | `; 66 | body_html += this.get_title(item, title); 67 | 68 | // get floating elements 69 | if (!item.has_variants) { 70 | if (settings.enable_wishlist) { 71 | body_html += this.get_wishlist_icon(item); 72 | } 73 | if (settings.enabled) { 74 | body_html += this.get_cart_indicator(item); 75 | } 76 | 77 | } 78 | 79 | body_html += `
`; 80 | body_html += `
${ item.item_group || '' }
`; 81 | 82 | if (item.formatted_price) { 83 | body_html += this.get_price_html(item); 84 | } 85 | 86 | body_html += this.get_stock_availability(item, settings); 87 | body_html += this.get_primary_button(item, settings); 88 | body_html += `
`; // close div on line 49 89 | 90 | return body_html; 91 | } 92 | 93 | get_title(item, title) { 94 | let title_html = ` 95 | 96 |
97 | ${ title || '' } 98 |
99 |
100 | `; 101 | return title_html; 102 | } 103 | 104 | get_wishlist_icon(item) { 105 | let icon_class = item.wished ? "wished" : "not-wished"; 106 | return ` 107 |
109 | 110 | 111 | 112 |
113 | `; 114 | } 115 | 116 | get_cart_indicator(item) { 117 | return ` 118 |
119 | 1 120 |
121 | `; 122 | } 123 | 124 | get_price_html(item) { 125 | let price_html = ` 126 |
127 | ${ item.formatted_price || '' } 128 | `; 129 | 130 | if (item.formatted_mrp) { 131 | price_html += ` 132 | 133 | ${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" } 134 | 135 | 136 | ${ item.discount } ${ __("OFF") } 137 | 138 | `; 139 | } 140 | price_html += `
`; 141 | return price_html; 142 | } 143 | 144 | get_stock_availability(item, settings) { 145 | if (settings.show_stock_availability && !item.has_variants) { 146 | if (item.on_backorder) { 147 | return ` 148 | 149 | ${ __("Available on backorder") } 150 | 151 | `; 152 | } else if (!item.in_stock) { 153 | return ` 154 | 155 | ${ __("Out of stock") } 156 | 157 | `; 158 | } 159 | } 160 | 161 | return ``; 162 | } 163 | 164 | get_primary_button(item, settings) { 165 | if (item.has_variants) { 166 | return ` 167 | 168 |
169 | ${ __("Explore") } 170 |
171 |
172 | `; 173 | } else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) { 174 | return ` 175 |
179 | 180 | 181 | 182 | 183 | 184 | ${ settings.enable_checkout ? __("Add to Cart") : __("Add to Quote") } 185 |
186 | 187 | 188 |
193 | ${ settings.enable_checkout ? __("Go to Cart") : __("Go to Quote") } 194 |
195 |
196 | `; 197 | } else { 198 | return ``; 199 | } 200 | } 201 | }; 202 | -------------------------------------------------------------------------------- /webshop/public/scss/webshop-web.bundle.scss: -------------------------------------------------------------------------------- 1 | @import "./webshop_cart"; -------------------------------------------------------------------------------- /webshop/public/web.bundle.js: -------------------------------------------------------------------------------- 1 | import './js/init' 2 | 3 | import './js/customer_reviews' 4 | import './js/product_ui/grid' 5 | import './js/product_ui/list' 6 | import './js/product_ui/search' 7 | import './js/product_ui/views' 8 | import './js/shopping_cart' 9 | import './js/wishlist' 10 | -------------------------------------------------------------------------------- /webshop/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/templates/__init__.py -------------------------------------------------------------------------------- /webshop/templates/generators/item/item.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/web.html" %} 2 | {% from "webshop/templates/includes/macros.html" import recommended_item_row %} 3 | 4 | {% block title %} {{ title }} {% endblock %} 5 | 6 | {% block breadcrumbs %} 7 |
8 | {% include "templates/includes/breadcrumbs.html" %} 9 |
10 | {% endblock %} 11 | 12 | {% block page_content %} 13 |
14 | {% from "webshop/templates/includes/macros.html" import product_image %} 15 |
16 |
17 | 18 |
19 | {% include "templates/generators/item/item_image.html" %} 20 | {% include "templates/generators/item/item_details.html" %} 21 |
22 |
23 |
24 |
25 | 26 | 27 |
28 | {% set show_recommended_items = recommended_items and shopping_cart.cart_settings.enable_recommendations %} 29 | {% set info_col = 'col-9' if show_recommended_items else 'col-12' %} 30 | 31 | {% set padding_top = 'pt-0' if (show_tabs and tabs) else '' %} 32 | 33 |
34 |
35 |
36 | 37 | {% if show_tabs and tabs %} 38 |
39 | 40 | {{ web_block("Section with Tabs", values=tabs, add_container=0, 41 | add_top_padding=0, add_bottom_padding=0) 42 | }} 43 |
44 | {% elif website_specifications %} 45 | {% include "templates/generators/item/item_specifications.html"%} 46 | {% endif %} 47 | 48 | 49 | {{ doc.website_content or '' }} 50 | 51 | 52 | {% if shopping_cart.cart_settings.enable_reviews and not doc.has_variants %} 53 | {% include "templates/generators/item/item_reviews.html"%} 54 | {% endif %} 55 |
56 |
57 |
58 | 59 | 60 | {% if show_recommended_items %} 61 | 69 | {% endif %} 70 | 71 |
72 | {% endblock %} 73 | 74 | {% block base_scripts %} 75 | 76 | 77 | {{ include_script("frappe-web.bundle.js") }} 78 | {{ include_script("controls.bundle.js") }} 79 | {{ include_script("dialog.bundle.js") }} 80 | {% endblock %} 81 | -------------------------------------------------------------------------------- /webshop/templates/generators/item/item_configure.html: -------------------------------------------------------------------------------- 1 | {% if shopping_cart and shopping_cart.cart_settings.enabled %} 2 | {% set cart_settings = shopping_cart.cart_settings %} 3 | 4 |
5 | {% if cart_settings.enable_variants | int %} 6 | 12 | {% endif %} 13 | {% if cart_settings.show_contact_us_button %} 14 | {% include "templates/generators/item/item_inquiry.html" %} 15 | {% endif %} 16 |
17 | 20 | {% endif %} 21 | -------------------------------------------------------------------------------- /webshop/templates/generators/item/item_details.html: -------------------------------------------------------------------------------- 1 | {% set width_class = "expand" if not slides else "" %} 2 | {% set cart_settings = shopping_cart.cart_settings %} 3 | {% set product_info = shopping_cart.product_info %} 4 | {% set price_info = product_info.get('price') or {} %} 5 | 6 |
7 |
8 | 9 |
10 | {{ _(doc.web_item_name) }} 11 |
12 | 13 | 14 | {% if cart_settings.enable_wishlist %} 15 | 21 | {% endif %} 22 |
23 | 24 |
25 |
26 | 27 |
28 |
29 | 30 |

31 | 32 | {{ _(doc.item_group) }} 33 | 34 | 35 | {{ _("Item Code") }}: 36 | 37 | {{ _(doc.item_code) }} 38 |

39 | {% if has_variants %} 40 | 41 | {% include "templates/generators/item/item_configure.html" %} 42 | {% else %} 43 | 44 | {% include "templates/generators/item/item_add_to_cart.html" %} 45 | {% endif %} 46 | 47 |
48 | {% if frappe.utils.strip_html(doc.web_long_description or '') %} 49 | {{ _(doc.web_long_description) | safe }} 50 | {% elif frappe.utils.strip_html(doc.description or '') %} 51 | {{ _(doc.description) | safe }} 52 | {% else %} 53 | {{ "" }} 54 | {% endif %} 55 |
56 |
57 | 58 | {% block base_scripts %} 59 | 60 | 61 | {% endblock %} 62 | 63 | 70 | -------------------------------------------------------------------------------- /webshop/templates/generators/item/item_image.html: -------------------------------------------------------------------------------- 1 | {% set column_size = 5 if slides else 4 %} 2 |
3 | {% if slides %} 4 |
5 | {% for item in slides %} 6 | {{ item.heading }} 8 | {% endfor %} 9 |
10 | {{ product_image(slides[0].image, 'product-image') }} 11 | 12 | 26 | {% else %} 27 | {{ product_image(doc.website_image, alt=doc.website_image_alt or doc.item_name) }} 28 | {% endif %} 29 | 30 | 31 | 32 | 41 |
42 | 77 | 109 | -------------------------------------------------------------------------------- /webshop/templates/generators/item/item_inquiry.html: -------------------------------------------------------------------------------- 1 | {% if shopping_cart and shopping_cart.cart_settings.enabled %} 2 | {% set cart_settings = shopping_cart.cart_settings %} 3 | {% if cart_settings.show_contact_us_button | int %} 4 | 7 | {% endif %} 8 | 11 | {% endif %} 12 | -------------------------------------------------------------------------------- /webshop/templates/generators/item/item_inquiry.js: -------------------------------------------------------------------------------- 1 | frappe.ready(() => { 2 | const d = new frappe.ui.Dialog({ 3 | title: __('Contact Us'), 4 | fields: [ 5 | { 6 | fieldtype: 'Data', 7 | label: __('Full Name'), 8 | fieldname: 'lead_name', 9 | reqd: 1 10 | }, 11 | { 12 | fieldtype: 'Data', 13 | label: __('Organization Name'), 14 | fieldname: 'company_name', 15 | }, 16 | { 17 | fieldtype: 'Data', 18 | label: __('Email'), 19 | fieldname: 'email_id', 20 | options: 'Email', 21 | reqd: 1 22 | }, 23 | { 24 | fieldtype: 'Data', 25 | label: __('Phone Number'), 26 | fieldname: 'phone', 27 | options: 'Phone', 28 | reqd: 1 29 | }, 30 | { 31 | fieldtype: 'Data', 32 | label: __('Subject'), 33 | fieldname: 'subject', 34 | reqd: 1 35 | }, 36 | { 37 | fieldtype: 'Text', 38 | label: __('Message'), 39 | fieldname: 'message', 40 | reqd: 1 41 | } 42 | ], 43 | primary_action: send_inquiry, 44 | primary_action_label: __('Send') 45 | }); 46 | 47 | function send_inquiry() { 48 | const values = d.get_values(); 49 | const doc = Object.assign({}, values); 50 | delete doc.subject; 51 | delete doc.message; 52 | 53 | d.hide(); 54 | 55 | frappe.call('webshop.webshop.shopping_cart.cart.create_lead_for_item_inquiry', { 56 | lead: doc, 57 | subject: values.subject, 58 | message: values.message 59 | }).then(r => { 60 | if (r.message) { 61 | d.clear(); 62 | } 63 | }); 64 | } 65 | 66 | $('.btn-inquiry').click((e) => { 67 | const $btn = $(e.target); 68 | const item_code = $btn.data('item-code'); 69 | d.set_value('subject', 'Inquiry about ' + item_code); 70 | if (!['Administrator', 'Guest'].includes(frappe.session.user)) { 71 | d.set_value('email_id', frappe.session.user); 72 | d.set_value('lead_name', frappe.get_cookie('full_name')); 73 | } 74 | 75 | d.show(); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /webshop/templates/generators/item/item_reviews.html: -------------------------------------------------------------------------------- 1 | {% from "webshop/templates/includes/macros.html" import user_review, ratings_summary %} 2 | 3 |
4 | 5 |
6 |
7 | {{ _("Customer Reviews") }} 8 |
9 | 10 |
11 | 12 | {% if frappe.session.user != "Guest" and user_is_customer %} 13 | 17 | {% endif %} 18 |
19 |
20 | 21 | 22 | {{ ratings_summary(reviews, reviews_per_rating, average_rating, average_whole_rating, for_summary=True, total_reviews=total_reviews) }} 23 | 24 | 25 | 26 |
27 | {% if reviews %} 28 | {{ user_review(reviews) }} 29 | 30 | {% if total_reviews > 4 %} 31 | 34 | {% endif %} 35 | 36 | {% else %} 37 |
38 | {{ _("No Reviews") }} 39 |
40 | {% endif %} 41 |
42 |
43 | 44 | 89 | -------------------------------------------------------------------------------- /webshop/templates/generators/item/item_specifications.html: -------------------------------------------------------------------------------- 1 | 2 | {% if website_specifications %} 3 |
4 |
5 | {% if not show_tabs %} 6 |
7 | {{ _("Product Details") }} 8 |
9 | {% endif %} 10 | 11 | {% for d in website_specifications -%} 12 | 13 | 14 | 15 | 16 | {%- endfor %} 17 |
{{ _(d.label) }}{{ _(d.description) }}
18 |
19 |
20 | {% endif %} 21 | -------------------------------------------------------------------------------- /webshop/templates/generators/item_group.html: -------------------------------------------------------------------------------- 1 | {% from "webshop/templates/includes/macros.html" import field_filter_section, attribute_filter_section, discount_range_filters %} 2 | {% extends "templates/web.html" %} 3 | 4 | {% block header %} 5 |
{{ _(item_group_name) }}
6 | {% endblock header %} 7 | 8 | {% block script %} 9 | 10 | {% endblock %} 11 | 12 | {% block breadcrumbs %} 13 |
14 | {% include "templates/includes/breadcrumbs.html" %} 15 |
16 | {% endblock %} 17 | 18 | {% block page_content %} 19 |
21 |
22 | {% if slideshow %} 23 | {{ web_block( 24 | "Hero Slider", 25 | values=slideshow, 26 | add_container=0, 27 | add_top_padding=0, 28 | add_bottom_padding=0, 29 | ) }} 30 | {% endif %} 31 | 32 | {% if description %} 33 |
{{ description or ""}}
34 | {% endif %} 35 |
36 |
37 |
38 | 39 |
40 | 41 |
42 |
43 |
44 |
{{ _('Filters') }}
45 | {{ _('Clear All') }} 46 |
47 | 48 | {{ field_filter_section(field_filters) }} 49 | 50 | 51 | {{ attribute_filter_section(attribute_filters) }} 52 | 53 |
54 | 55 |
56 |
57 |
58 | 59 | 72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/address_card.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ _('Change') }} 4 |
5 |
6 |
{{ address.title }}
7 |
8 | {{ address.display }} 9 |
10 | 11 | 12 | 13 | 14 | {{ _('Edit') }} 15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/address_picker_card.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
{{ address.title }}
7 |

8 | {{ address.display }} 9 |

10 | {{ _('Edit') }} 11 |
12 |
13 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/cart_address_picker.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ _("Shipping Address") }}
3 |
4 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/cart_dropdown.html: -------------------------------------------------------------------------------- 1 |
2 | 4 |
5 | 8 |
9 |
10 |
11 | {{ _("Item") }} 12 |
13 |
14 | {{ _("Price") }} 15 |
16 |
17 | 18 | {% if doc.items %} 19 |
20 |
21 | {% include "templates/includes/cart/cart_items_dropdown.html" %} 22 |
23 |
24 | {% else %} 25 |

{{ _("Cart is Empty") }}

26 | {% endif %} 27 |
28 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/cart_items.html: -------------------------------------------------------------------------------- 1 | {% from "webshop/templates/includes/macros.html" import product_image %} 2 | 3 | {% macro item_subtotal(item) %} 4 |
5 | {{ item.get_formatted('amount') }} 6 |
7 | 8 | {% if item.is_free_item %} 9 |
10 | 11 | {{ _('FREE') }} 12 | 13 |
14 | {% else %} 15 | 16 | {{ _('Rate:') }} {{ item.get_formatted('rate') }} 17 | 18 | {% endif %} 19 | {% endmacro %} 20 | 21 | {% for d in doc.items %} 22 | 23 | 24 |
25 |
26 | {% if d.thumbnail %} 27 | {{ product_image(d.thumbnail, alt="d.web_item_name", no_border=True) }} 28 | {% else %} 29 |
30 | {{ frappe.utils.get_abbr(d.web_item_name) or "NA" }} 31 |
32 | {% endif %} 33 |
34 | 35 |
36 |
37 | {{ d.get("web_item_name") or d.item_name }} 38 |
39 |
40 | {{ d.item_code }} 41 |
42 | {%- set variant_of = frappe.db.get_value('Item', d.item_code, 'variant_of') %} 43 | {% if variant_of %} 44 | 45 | {{ _('Variant of') }} 46 | 47 | {{ variant_of }} 48 | 49 | 50 | {% endif %} 51 | 52 |
53 | 54 |
55 |
56 |
57 | 58 | 59 | 60 | 61 |
62 | {% set disabled = 'disabled' if d.is_free_item else '' %} 63 |
64 | 65 | 68 | 69 | 70 | 72 | 73 | 74 | 77 | 78 |
79 | 80 |
81 | {% if not d.is_free_item %} 82 |
83 | 84 | 89 | 90 |
91 | {% endif %} 92 |
93 |
94 | 95 | 96 | 97 | {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %} 98 |
99 | {{ item_subtotal(d) }} 100 |
101 | {% endif %} 102 | 103 | 104 | 105 | {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %} 106 | 107 | {{ item_subtotal(d) }} 108 | 109 | {% endif %} 110 | 111 | {% endfor %} 112 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/cart_items_dropdown.html: -------------------------------------------------------------------------------- 1 | {% from "webshop/templates/includes/order/order_macros.html" import item_name_and_description_cart %} 2 | 3 | {% for d in doc.items %} 4 |
5 |
6 | {{ item_name_and_description_cart(d) }} 7 |
8 |
9 | {{ d.get_formatted("amount") }} 10 |
11 |
12 | {% endfor %} 13 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/cart_items_total.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ _("Net Total") }} 6 | 7 | 8 | {{ doc.get_formatted("total") }} 9 | 10 | 11 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/cart_macros.html: -------------------------------------------------------------------------------- 1 | {% macro show_address(address, doc, fieldname, select_address=False) %} 2 | {% set selected=address.name==doc.get(fieldname) %} 3 | 4 |
5 |
6 |
7 |
9 | {{ address.name }}
10 |
11 |
15 |
16 |
17 |
19 |
{{ address.display }}
20 |
21 |
22 | {% endmacro %} 23 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/cart_payment_summary.html: -------------------------------------------------------------------------------- 1 | 2 | {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %} 3 |
4 | {{ _("Payment Summary") }} 5 |
6 | {% endif %} 7 | 8 |
9 |
10 | {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %} 11 | 12 | 13 | {% set total_items = frappe.utils.cstr(frappe.utils.flt(doc.total_qty, 0)) %} 14 | 15 | 16 | 17 | 18 | 19 | {% for d in doc.taxes %} 20 | {% if d.tax_amount %} 21 | 22 | 25 | 28 | 29 | {% endif %} 30 | {% endfor %} 31 |
{{ _("Net Total (") + total_items + _(" Items)") }}{{ doc.get_formatted("net_total") }}
23 | {{ d.description }} 24 | 26 | {{ d.get_formatted("tax_amount") }} 27 |
32 | 33 | 34 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
{{ _("Grand Total") }}{{ doc.get_formatted("grand_total") }}
53 | {% endif %} 54 |
55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/coupon_code.html: -------------------------------------------------------------------------------- 1 | {% if coupon_code %} 2 |

Coupon code

3 |
7 |
{{ coupon_code}}
8 | 27 |
28 | {% endif %} 29 | -------------------------------------------------------------------------------- /webshop/templates/includes/cart/place_order.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if cart_settings.enable_checkout %} 4 | 7 | {% else %} 8 | 11 | {% endif %} 12 |
13 |
-------------------------------------------------------------------------------- /webshop/templates/includes/navbar/navbar_items.html: -------------------------------------------------------------------------------- 1 | {% extends 'frappe/templates/includes/navbar/navbar_items.html' %} 2 | 3 | {% block navbar_right_extension %} 4 | 12 | {% if frappe.db.get_single_value("Webshop Settings", "enable_wishlist") %} 13 | 21 | {% endif %} 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /webshop/templates/includes/order/order_macros.html: -------------------------------------------------------------------------------- 1 | {% from "webshop/templates/includes/macros.html" import product_image %} 2 | 3 | {% macro item_name_and_description(d) %} 4 |
5 |
6 |
7 | {% if d.thumbnail or d.image %} 8 | {{ product_image(d.thumbnail or d.image, no_border=True) }} 9 | {% else %} 10 |
11 | {{ frappe.utils.get_abbr(d.item_name) or "NA" }} 12 |
13 | {% endif %} 14 |
15 |
16 |
17 | {{ d.item_code }} 18 |
19 | {{ html2text(d.description) | truncate(140) }} 20 |
21 | 22 | {{ _("Qty ") }}({{ d.get_formatted("qty") }}) 23 | 24 |
25 |
26 | {% endmacro %} 27 | 28 | {% macro item_name_and_description_cart(d) %} 29 |
30 |
31 |
32 | {{ product_image_square(d.thumbnail or d.image) }} 33 |
34 |
35 |
36 | {{ d.item_name|truncate(25) }} 37 |
38 | 39 | 41 | 42 | 45 | 46 | 48 | 49 |
50 |
51 |
52 | {% endmacro %} 53 | -------------------------------------------------------------------------------- /webshop/templates/includes/order/order_taxes.html: -------------------------------------------------------------------------------- 1 | {% if doc.taxes %} 2 |
3 |
4 |
5 | {{ _("Net Total") }} 6 |
7 |
8 | {{ doc.get_formatted("net_total") }} 9 |
10 |
11 |
12 | {% endif %} 13 | 14 | {% for d in doc.taxes %} 15 | {% if d.tax_amount %} 16 |
17 |
18 |
19 | {{ d.description }} 20 |
21 |
22 | {{ d.get_formatted("tax_amount") }} 23 |
24 |
25 |
26 | {% endif %} 27 | {% endfor %} 28 | 29 | {% if doc.doctype == 'Quotation' %} 30 | {% if doc.coupon_code %} 31 |
32 |
33 |
34 | {{ _("Savings") }} 35 |
36 |
37 | {% set tot_quotation_discount = [] %} 38 | {%- for item in doc.items -%} 39 | {% if tot_quotation_discount.append((((item.price_list_rate * item.qty) 40 | * item.discount_percentage) / 100)) %} 41 | {% endif %} 42 | {% endfor %} 43 | {{ frappe.utils.fmt_money((tot_quotation_discount | sum),currency=doc.currency) }}
44 |
45 |
46 | {% endif %} 47 | {% endif %} 48 | 49 | {% if doc.doctype == 'Sales Order' %} 50 | {% if doc.coupon_code %} 51 |
52 |
53 |
54 | {{ _("Total Amount") }} 55 |
56 |
57 | 58 | {% set total_amount = [] %} 59 | {%- for item in doc.items -%} 60 | {% if total_amount.append((item.price_list_rate * item.qty)) %}{% endif %} 61 | {% endfor %} 62 | {{ frappe.utils.fmt_money((total_amount | sum),currency=doc.currency) }} 63 | 64 |
65 |
66 |
67 |
68 |
69 |
70 | {{ _("Applied Coupon Code") }} 71 |
72 |
73 | 74 | {%- for row in frappe.get_all(doctype="Coupon Code", 75 | fields=["coupon_code"], filters={ "name":doc.coupon_code}) -%} 76 | {{ row.coupon_code }} 77 | {% endfor %} 78 | 79 |
80 |
81 |
82 |
83 |
84 |
85 | {{ _("Savings") }} 86 |
87 |
88 | 89 | {% set tot_SO_discount = [] %} 90 | {%- for item in doc.items -%} 91 | {% if tot_SO_discount.append((((item.price_list_rate * item.qty) 92 | * item.discount_percentage) / 100)) %}{% endif %} 93 | {% endfor %} 94 | {{ frappe.utils.fmt_money((tot_SO_discount | sum),currency=doc.currency) }} 95 | 96 |
97 |
98 |
99 | {% endif %} 100 | {% endif %} 101 | 102 |
103 |
104 |
105 | {{ _("Grand Total") }} 106 |
107 |
108 | {{ doc.get_formatted("grand_total") }} 109 |
110 |
111 |
112 | -------------------------------------------------------------------------------- /webshop/templates/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/templates/pages/__init__.py -------------------------------------------------------------------------------- /webshop/templates/pages/cart.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/web.html" %} 2 | 3 | {% block title %} {{ _("Shopping Cart") }} {% endblock %} 4 | 5 | {% block header %}

{{ _("Shopping Cart") }}

{% endblock %} 6 | 7 | {% block header_actions %} 8 | {% endblock %} 9 | 10 | {% block page_content %} 11 | 12 | {% from "templates/includes/macros.html" import item_name_and_description %} 13 | 14 | {% if doc.items %} 15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 | {{ _('Items') }} 23 |
24 | 25 | 26 | 27 | 28 | 29 | {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %} 30 | 31 | {% endif %} 32 | 33 | 34 | 35 | 36 | {% include "templates/includes/cart/cart_items.html" %} 37 | 38 | 39 | {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %} 40 | 41 | {% include "templates/includes/cart/cart_items_total.html" %} 42 | 43 | {% endif %} 44 |
{{ _('Item') }}{{ _('Quantity') }}{{ _('Subtotal') }}
45 | 46 |
47 |
48 | {% if cart_settings.enable_checkout %} 49 | 50 | {{ _('Past Orders') }} 51 | 52 | {% else %} 53 | 54 | {{ _('Past Quotes') }} 55 | 56 | {% endif %} 57 |
58 |
59 | {% if doc.items %} 60 | 65 | {% endif %} 66 |
67 |
68 |
69 | 70 | 71 | {% if doc.items %} 72 | {% if doc.terms %} 73 |
74 |
{{ _("Terms and Conditions") }}
75 |
76 | {{ doc.terms }} 77 |
78 |
79 | {% endif %} 80 |
81 | 82 | 83 |
84 |
85 | 86 | {% set show_coupon_code = cart_settings.show_apply_coupon_code_in_website and cart_settings.enable_checkout %} 87 | {% set coupon_code = doc.coupon_code if doc.coupon_code else "" %} 88 | 89 | {% if show_coupon_code == 1%} 90 | {% if coupon_code %} 91 | {% include "templates/includes/cart/coupon_code.html" %} 92 | {% else %} 93 |
94 |
95 | 96 | 97 | 98 |
99 |
100 | {% endif %} 101 | {% endif %} 102 | 103 |
104 |
105 | {% include "templates/includes/cart/cart_payment_summary.html" %} 106 |
107 | 108 |
109 | {% include "templates/includes/cart/place_order.html" %} 110 |
111 |
112 | 113 | {% include "templates/includes/cart/cart_address.html" %} 114 |
115 |
116 | {% endif %} 117 |
118 |
119 | {% else %} 120 |
121 |
122 | Empty State 123 |
124 |
{{ _('Your cart is Empty') }}

125 | {% if cart_settings.enable_checkout %} 126 | 127 | {{ _('See past orders') }} 128 | 129 | {% else %} 130 | 131 | {{ _('See past quotations') }} 132 | 133 | {% endif %} 134 |
135 | {% endif %} 136 | 137 | {% endblock %} 138 | 139 | {% block base_scripts %} 140 | 141 | {{ include_script("frappe-web.bundle.js") }} 142 | {{ include_script("controls.bundle.js") }} 143 | {{ include_script("dialog.bundle.js") }} 144 | {% endblock %} 145 | -------------------------------------------------------------------------------- /webshop/templates/pages/cart.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors 2 | # License: GNU General Public License v3. See license.txt 3 | 4 | no_cache = 1 5 | 6 | from webshop.webshop.shopping_cart.cart import get_cart_quotation 7 | 8 | 9 | def get_context(context): 10 | context.body_class = "product-page" 11 | context.update(get_cart_quotation()) 12 | -------------------------------------------------------------------------------- /webshop/templates/pages/customer_reviews.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/web.html" %} 2 | {% from "webshop/templates/includes/macros.html" import user_review, ratings_summary %} 3 | 4 | {% block title %} {{ _("Customer Reviews") }} {% endblock %} 5 | 6 | {% block page_content %} 7 |
8 | {% if enable_reviews %} 9 | 10 |
11 |
12 | {{ _("Customer Reviews") }} 13 |
14 | 15 |
16 | 17 | {% if frappe.session.user != "Guest" and user_is_customer %} 18 | 22 | {% endif %} 23 |
24 |
25 | 26 | 27 | {{ ratings_summary(reviews, reviews_per_rating, average_rating, average_whole_rating, for_summary=True, total_reviews=total_reviews) }} 28 | 29 | 30 | 31 |
32 | {% if reviews %} 33 | {{ user_review(reviews) }} 34 | 35 | {% if not reviews | len >= total_reviews %} 36 | 40 | {% endif %} 41 | 42 | {% else %} 43 |
44 | {{ _("No Reviews") }} 45 |
46 | {% endif %} 47 |
48 | {% else %} 49 | 50 |
51 |

52 | {{ _("No Reviews") }} 53 |

54 |
55 | {% endif %} 56 |
57 | 58 | {% endblock %} 59 | 60 | {% block base_scripts %} 61 | 62 | 63 | 64 | 65 | 66 | 67 | {% endblock %} 68 | -------------------------------------------------------------------------------- /webshop/templates/pages/customer_reviews.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors 2 | # License: GNU General Public License v3. See license.txt 3 | import frappe 4 | 5 | from webshop.webshop.doctype.webshop_settings.webshop_settings import ( 6 | get_shopping_cart_settings, 7 | ) 8 | from webshop.webshop.doctype.item_review.item_review import get_item_reviews 9 | from webshop.webshop.doctype.website_item.website_item import check_if_user_is_customer 10 | 11 | 12 | def get_context(context): 13 | context.body_class = "product-page" 14 | context.no_cache = 1 15 | context.full_page = True 16 | context.reviews = None 17 | 18 | if frappe.form_dict and frappe.form_dict.get("web_item"): 19 | context.web_item = frappe.form_dict.get("web_item") 20 | context.user_is_customer = check_if_user_is_customer() 21 | context.enable_reviews = get_shopping_cart_settings().enable_reviews 22 | 23 | if context.enable_reviews: 24 | reviews_data = get_item_reviews(context.web_item) 25 | context.update(reviews_data) 26 | -------------------------------------------------------------------------------- /webshop/templates/pages/order.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ready(() => { 5 | var loyalty_points_input = document.getElementById("loyalty-point-to-redeem"); 6 | var loyalty_points_status = document.getElementById("loyalty-points-status"); 7 | 8 | if (loyalty_points_input) { 9 | loyalty_points_input.onblur = apply_loyalty_points; 10 | } 11 | 12 | function apply_loyalty_points() { 13 | var loyalty_points = parseInt(loyalty_points_input.value); 14 | 15 | if (!loyalty_points) return; 16 | 17 | const callback = async (r) => { 18 | if (!r) return; 19 | 20 | var message = "" 21 | let loyalty_amount = flt(r.message * loyalty_points); 22 | 23 | if (doc_info.grand_total && doc_info.grand_total < loyalty_amount) { 24 | let redeemable_amount = parseInt(doc_info.grand_total/r.message); 25 | message = "You can only redeem max " + redeemable_amount + " points in this order."; 26 | frappe.msgprint(__(message)); 27 | return; 28 | } 29 | 30 | message = loyalty_points + " Loyalty Points of amount "+ loyalty_amount + " is applied." 31 | loyalty_points_status.innerHTML = message; 32 | frappe.msgprint(__(message)); 33 | 34 | const args_obj = { 35 | dn: doc_info.doctype_name, 36 | dt: doc_info.doctype, 37 | submit_doc: 1, 38 | order_type: "Shopping Cart", 39 | loyalty_points, 40 | } 41 | 42 | const payment_gateway_account = await frappe.db.get_single_value('Webshop Settings', 'payment_gateway_account') 43 | 44 | if (payment_gateway_account) { 45 | args_obj.payment_gateway_account = payment_gateway_account; 46 | } 47 | 48 | const args_str = Object 49 | .entries(args_obj) 50 | .map((e) => e[0] + "=" + e[1]) 51 | .join("&"); 52 | 53 | const href_base_url = "/api/method/erpnext.accounts.doctype.payment_request.payment_request.make_payment_request" 54 | const href = href_base_url + "?" + args_str; 55 | 56 | var payment_button = document.getElementById("pay-for-order"); 57 | payment_button.innerHTML = __("Pay Remaining"); 58 | payment_button.href = href; 59 | } 60 | 61 | frappe.call({ 62 | method: "erpnext.accounts.doctype.loyalty_program.loyalty_program.get_redeemption_factor", 63 | args: { 64 | "customer": doc_info.customer 65 | }, 66 | callback, 67 | }); 68 | } 69 | }) 70 | -------------------------------------------------------------------------------- /webshop/templates/pages/order.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors 2 | # License: GNU General Public License v3. See license.txt 3 | 4 | import frappe 5 | from frappe import _ 6 | 7 | from webshop.webshop.doctype.webshop_settings.webshop_settings import show_attachments 8 | 9 | 10 | def get_context(context): 11 | context.no_cache = 1 12 | context.show_sidebar = True 13 | context.doc = frappe.get_doc(frappe.form_dict.doctype, frappe.form_dict.name) 14 | if hasattr(context.doc, "set_indicator"): 15 | context.doc.set_indicator() 16 | 17 | if show_attachments(): 18 | context.attachments = get_attachments(frappe.form_dict.doctype, frappe.form_dict.name) 19 | 20 | context.parents = frappe.form_dict.parents 21 | context.title = frappe.form_dict.name 22 | context.payment_ref = frappe.db.get_value( 23 | "Payment Request", {"reference_name": frappe.form_dict.name}, "name" 24 | ) 25 | 26 | context.enabled_checkout = frappe.get_doc("Webshop Settings").enable_checkout 27 | 28 | default_print_format = frappe.db.get_value( 29 | "Property Setter", 30 | dict(property="default_print_format", doc_type=frappe.form_dict.doctype), 31 | "value", 32 | ) 33 | if default_print_format: 34 | context.print_format = default_print_format 35 | else: 36 | context.print_format = "Standard" 37 | 38 | if not frappe.has_website_permission(context.doc): 39 | frappe.throw(_("Not Permitted"), frappe.PermissionError) 40 | 41 | # check for the loyalty program of the customer 42 | customer_loyalty_program = frappe.db.get_value( 43 | "Customer", context.doc.customer_name, "loyalty_program" 44 | ) 45 | if customer_loyalty_program: 46 | from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( 47 | get_loyalty_program_details_with_points, 48 | ) 49 | 50 | loyalty_program_details = get_loyalty_program_details_with_points( 51 | context.doc.customer_name, customer_loyalty_program 52 | ) 53 | context.available_loyalty_points = int(loyalty_program_details.get("loyalty_points")) 54 | 55 | # show Make Purchase Invoice button based on permission 56 | context.show_make_pi_button = frappe.has_permission("Purchase Invoice", "create") 57 | 58 | 59 | def get_attachments(dt, dn): 60 | return frappe.get_all( 61 | "File", 62 | fields=["name", "file_name", "file_url", "is_private"], 63 | filters={"attached_to_name": dn, "attached_to_doctype": dt, "is_private": 0}, 64 | ) 65 | -------------------------------------------------------------------------------- /webshop/templates/pages/product_search.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/web.html" %} 2 | 3 | {% block title %} {{ _("Product Search") }} {% endblock %} 4 | 5 | {% block header %}

{{ _("Product Search") }}

{% endblock %} 6 | 7 | {% block page_content %} 8 | 9 | 10 | 19 | 20 |
21 |

{{ _("Search Results") }}

22 |
23 | 24 |
25 |
26 | 30 |
31 |
32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /webshop/templates/pages/product_search.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors 2 | # License: GNU General Public License v3. See license.txt 3 | 4 | import json 5 | 6 | import frappe 7 | from frappe.utils import cint, cstr 8 | from redis.commands.search.query import Query 9 | 10 | from webshop.webshop.redisearch_utils import ( 11 | WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE, 12 | WEBSITE_ITEM_INDEX, 13 | WEBSITE_ITEM_NAME_AUTOCOMPLETE, 14 | is_redisearch_enabled, 15 | ) 16 | from webshop.webshop.shopping_cart.product_info import set_product_info_for_website 17 | from webshop.webshop.doctype.override_doctype.item_group import get_item_for_list_in_html 18 | 19 | no_cache = 1 20 | 21 | 22 | def get_context(context): 23 | context.show_search = True 24 | 25 | 26 | @frappe.whitelist(allow_guest=True) 27 | def get_product_list(search=None, start=0, limit=12): 28 | data = get_product_data(search, start, limit) 29 | 30 | for item in data: 31 | set_product_info_for_website(item) 32 | 33 | return [get_item_for_list_in_html(r) for r in data] 34 | 35 | 36 | def get_product_data(search=None, start=0, limit=12): 37 | # limit = 12 because we show 12 items in the grid view 38 | # base query 39 | query = """ 40 | SELECT 41 | web_item_name, item_name, item_code, brand, route, 42 | website_image, thumbnail, item_group, 43 | description, web_long_description as website_description, 44 | website_warehouse, ranking 45 | FROM `tabWebsite Item` 46 | WHERE published = 1 47 | """ 48 | 49 | # search term condition 50 | if search: 51 | query += """ and (item_name like %(search)s 52 | or web_item_name like %(search)s 53 | or brand like %(search)s 54 | or web_long_description like %(search)s)""" 55 | search = "%" + cstr(search) + "%" 56 | 57 | # order by 58 | query += """ ORDER BY ranking desc, modified desc limit %s offset %s""" % ( 59 | cint(limit), 60 | cint(start), 61 | ) 62 | 63 | return frappe.db.sql(query, {"search": search}, as_dict=1) # nosemgrep 64 | 65 | 66 | @frappe.whitelist(allow_guest=True) 67 | def search(query): 68 | product_results = product_search(query) 69 | category_results = get_category_suggestions(query) 70 | 71 | return { 72 | "product_results": product_results.get("results") or [], 73 | "category_results": category_results.get("results") or [], 74 | } 75 | 76 | 77 | @frappe.whitelist(allow_guest=True) 78 | def product_search(query, limit=10, fuzzy_search=True): 79 | search_results = {"from_redisearch": True, "results": []} 80 | 81 | if not is_redisearch_enabled(): 82 | # Redisearch module not enabled 83 | search_results["from_redisearch"] = False 84 | search_results["results"] = get_product_data(query, 0, limit) 85 | return search_results 86 | 87 | if not query: 88 | return search_results 89 | 90 | redis = frappe.cache() 91 | query = clean_up_query(query) 92 | 93 | # TODO: Check perf/correctness with Suggestions & Query vs only Query 94 | # TODO: Use Levenshtein Distance in Query (max=3) 95 | redisearch = redis.ft(WEBSITE_ITEM_INDEX) 96 | suggestions = redisearch.sugget( 97 | WEBSITE_ITEM_NAME_AUTOCOMPLETE, 98 | query, 99 | num=limit, 100 | fuzzy=fuzzy_search and len(query) > 3, 101 | ) 102 | 103 | # Build a query 104 | query_string = query 105 | 106 | for s in suggestions: 107 | query_string += f"|('{clean_up_query(s.string)}')" 108 | 109 | q = Query(query_string) 110 | results = redisearch.search(q) 111 | 112 | search_results["results"] = list(map(convert_to_dict, results.docs)) 113 | search_results["results"] = sorted( 114 | search_results["results"], key=lambda k: frappe.utils.cint(k["ranking"]), reverse=True 115 | ) 116 | 117 | return search_results 118 | 119 | 120 | def clean_up_query(query): 121 | return "".join(c for c in query if c.isalnum() or c.isspace()) 122 | 123 | 124 | def convert_to_dict(redis_search_doc): 125 | return redis_search_doc.__dict__ 126 | 127 | 128 | @frappe.whitelist(allow_guest=True) 129 | def get_category_suggestions(query): 130 | search_results = {"results": []} 131 | 132 | if not is_redisearch_enabled(): 133 | # Redisearch module not enabled, query db 134 | categories = frappe.db.get_all( 135 | "Item Group", 136 | filters={"name": ["like", "%{0}%".format(query)], "show_in_website": 1}, 137 | fields=["name", "route"], 138 | ) 139 | search_results["results"] = categories 140 | return search_results 141 | 142 | if not query: 143 | return search_results 144 | 145 | ac = frappe.cache().ft() 146 | suggestions = ac.sugget(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE, query, num=10, with_payloads=True) 147 | 148 | results = [json.loads(s.payload) for s in suggestions] 149 | 150 | search_results["results"] = results 151 | 152 | return search_results 153 | -------------------------------------------------------------------------------- /webshop/templates/pages/wishlist.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/web.html" %} 2 | 3 | {% block title %} {{ _("Wishlist") }} {% endblock %} 4 | 5 | {% block header %}

{{ _("Wishlist") }}

{% endblock %} 6 | 7 | {% block page_content %} 8 | {% if items %} 9 |
10 |
11 |
12 | {% from "webshop/templates/includes/macros.html" import wishlist_card %} 13 | {% for item in items %} 14 | {{ wishlist_card(item, settings) }} 15 | {% endfor %} 16 |
17 |
18 |
19 | {% else %} 20 |
21 |
22 | Empty Cart 23 |
24 |
{{ _('Wishlist is empty!') }}

25 |
26 | {% endif %} 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /webshop/templates/pages/wishlist.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors 2 | # License: GNU General Public License v3. See license.txt 3 | import frappe 4 | 5 | from webshop.webshop.doctype.webshop_settings.webshop_settings import ( 6 | get_shopping_cart_settings, 7 | ) 8 | from webshop.webshop.shopping_cart.cart import _set_price_list 9 | from erpnext.utilities.product import get_price 10 | from webshop.webshop.shopping_cart.cart import get_party 11 | 12 | 13 | def get_context(context): 14 | is_guest = frappe.session.user == "Guest" 15 | 16 | settings = get_shopping_cart_settings() 17 | items = get_wishlist_items() if not is_guest else [] 18 | selling_price_list = _set_price_list(settings) if not is_guest else None 19 | 20 | items = set_stock_price_details(items, settings, selling_price_list) 21 | 22 | context.body_class = "product-page" 23 | context.items = items 24 | context.settings = settings 25 | context.no_cache = 1 26 | 27 | 28 | def get_stock_availability(item_code, warehouse): 29 | from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses 30 | 31 | if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: 32 | warehouses = get_child_warehouses(warehouse) 33 | else: 34 | warehouses = [warehouse] if warehouse else [] 35 | 36 | stock_qty = 0.0 37 | for warehouse in warehouses: 38 | stock_qty += frappe.utils.flt( 39 | frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty") 40 | ) 41 | 42 | return bool(stock_qty) 43 | 44 | 45 | def get_wishlist_items(): 46 | if not frappe.db.exists("Wishlist", frappe.session.user): 47 | return [] 48 | 49 | return frappe.db.get_all( 50 | "Wishlist Item", 51 | filters={"parent": frappe.session.user}, 52 | fields=[ 53 | "web_item_name", 54 | "item_code", 55 | "item_name", 56 | "website_item", 57 | "warehouse", 58 | "image", 59 | "item_group", 60 | "route", 61 | ], 62 | ) 63 | 64 | 65 | def set_stock_price_details(items, settings, selling_price_list): 66 | for item in items: 67 | if settings.show_stock_availability: 68 | item.available = get_stock_availability( 69 | item.item_code, item.get("warehouse") 70 | ) 71 | 72 | party = get_party() 73 | 74 | price_details = get_price( 75 | item.item_code, 76 | selling_price_list, 77 | settings.default_customer_group, 78 | settings.company, 79 | party=party, 80 | ) 81 | 82 | if price_details: 83 | item.formatted_price = price_details.get("formatted_price") 84 | item.formatted_mrp = price_details.get("formatted_mrp") 85 | if item.formatted_mrp: 86 | item.discount = price_details.get( 87 | "formatted_discount_percent" 88 | ) or price_details.get("formatted_discount_rate") 89 | 90 | return items 91 | -------------------------------------------------------------------------------- /webshop/webshop/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 3 | # For license information, please see license.txt 4 | 5 | import json 6 | 7 | import frappe 8 | from frappe.utils import cint 9 | 10 | from webshop.webshop.product_data_engine.filters import ProductFiltersBuilder 11 | from webshop.webshop.product_data_engine.query import ProductQuery 12 | from webshop.webshop.doctype.override_doctype.item_group import get_child_groups_for_website 13 | 14 | 15 | @frappe.whitelist(allow_guest=True) 16 | def get_product_filter_data(query_args=None): 17 | """ 18 | Returns filtered products and discount filters. 19 | 20 | Args: 21 | query_args (dict): contains filters to get products list 22 | 23 | Query Args filters: 24 | search (str): Search Term. 25 | field_filters (dict): Keys include item_group, brand, etc. 26 | attribute_filters(dict): Keys include Color, Size, etc. 27 | start (int): Offset items by 28 | item_group (str): Valid Item Group 29 | from_filters (bool): Set as True to jump to page 1 30 | """ 31 | if isinstance(query_args, str): 32 | query_args = json.loads(query_args) 33 | 34 | query_args = frappe._dict(query_args or {}) 35 | 36 | if query_args: 37 | search = query_args.get("search") 38 | field_filters = query_args.get("field_filters", {}) 39 | attribute_filters = query_args.get("attribute_filters", {}) 40 | start = cint(query_args.start) if query_args.get("start") else 0 41 | item_group = query_args.get("item_group") 42 | from_filters = query_args.get("from_filters") 43 | else: 44 | search, attribute_filters, item_group, from_filters = None, None, None, None 45 | field_filters = {} 46 | start = 0 47 | 48 | # if new filter is checked, reset start to show filtered items from page 1 49 | if from_filters: 50 | start = 0 51 | 52 | sub_categories = [] 53 | if item_group: 54 | sub_categories = get_child_groups_for_website(item_group, immediate=True) 55 | 56 | engine = ProductQuery() 57 | 58 | try: 59 | result = engine.query( 60 | attribute_filters, 61 | field_filters, 62 | search_term=search, 63 | start=start, 64 | item_group=item_group, 65 | ) 66 | except Exception: 67 | frappe.log_error("Product query with filter failed") 68 | return {"exc": "Something went wrong!"} 69 | 70 | # discount filter data 71 | filters = {} 72 | discounts = result["discounts"] 73 | 74 | if discounts: 75 | filter_engine = ProductFiltersBuilder() 76 | filters["discount_filters"] = filter_engine.get_discount_filters(discounts) 77 | 78 | return { 79 | "items": result["items"] or [], 80 | "filters": filters, 81 | "settings": engine.settings, 82 | "sub_categories": sub_categories, 83 | "items_count": result["items_count"], 84 | } 85 | 86 | 87 | @frappe.whitelist(allow_guest=True) 88 | def get_guest_redirect_on_action(): 89 | return frappe.db.get_single_value("Webshop Settings", "redirect_on_action") 90 | -------------------------------------------------------------------------------- /webshop/webshop/crud_events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/crud_events/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/crud_events/item/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/crud_events/item/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/crud_events/item/invalidate_item_variants_cache.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from webshop.webshop.variant_selector.item_variants_cache import ( 3 | ItemVariantsCacheManager, 4 | ) 5 | 6 | 7 | def execute(doc, method=None, old_name=None, new_name=None, merge=False): 8 | """ 9 | Rebuild ItemVariantsCacheManager via Item or Website Item. 10 | """ 11 | item_code = None 12 | is_web_item = doc.get("published_in_website") or doc.get("published") 13 | is_published = frappe.db.get_value("Item", doc.variant_of, "published_in_website") 14 | 15 | if doc.has_variants and is_web_item: 16 | item_code = doc.item_code 17 | 18 | elif doc.variant_of and is_published: 19 | item_code = doc.variant_of 20 | 21 | if item_code: 22 | item_cache = ItemVariantsCacheManager(item_code) 23 | item_cache.rebuild_cache() 24 | -------------------------------------------------------------------------------- /webshop/webshop/crud_events/item/update_website_item.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | def execute(doc, method=None): 5 | """Update Website Item if change in Item impacts it.""" 6 | web_item = frappe.db.exists("Website Item", {"item_code": doc.item_code}) 7 | 8 | if web_item: 9 | changed = {} 10 | editable_fields = [ 11 | "item_name", 12 | "item_group", 13 | "stock_uom", 14 | "brand", 15 | "description", 16 | "disabled", 17 | ] 18 | doc_before_save = doc.get_doc_before_save() 19 | 20 | for field in editable_fields: 21 | if doc_before_save.get(field) != doc.get(field): 22 | if field == "disabled": 23 | changed["published"] = not doc.get(field) 24 | else: 25 | changed[field] = doc.get(field) 26 | 27 | if not changed: 28 | return 29 | 30 | web_item_doc = frappe.get_doc("Website Item", web_item) 31 | web_item_doc.update(changed) 32 | web_item_doc.save() 33 | -------------------------------------------------------------------------------- /webshop/webshop/crud_events/item/validate_duplicate_website_item.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | from frappe import _ 4 | from frappe.utils import get_link_to_form 5 | 6 | 7 | class DataValidationError(frappe.ValidationError): 8 | pass 9 | 10 | 11 | def execute(doc, method=None, old_name=None, new_name=None, merge=False): 12 | """ 13 | Block merge if both old and new items have website items against them. 14 | This is to avoid duplicate website items after merging. 15 | """ 16 | if not merge: 17 | return 18 | 19 | web_items = frappe.get_all( 20 | "Website Item", 21 | filters={"item_code": ["in", [old_name, new_name]]}, 22 | fields=["item_code", "name"], 23 | ) 24 | 25 | if len(web_items) <= 1: 26 | return 27 | 28 | old_web_item = [d.get("name") for d in web_items if d.get("item_code") == old_name][0] 29 | web_item_link = get_link_to_form("Website Item", old_web_item) 30 | old_name, new_name = frappe.bold(old_name), frappe.bold(new_name) 31 | 32 | msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} into {new_name}" 33 | frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError) 34 | 35 | -------------------------------------------------------------------------------- /webshop/webshop/crud_events/price_list/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/crud_events/price_list/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/crud_events/price_list/check_impact_on_cart.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from webshop.webshop.doctype.webshop_settings.webshop_settings import ( 3 | validate_cart_settings, 4 | ) 5 | 6 | 7 | def execute(doc, method=None): 8 | """ 9 | Check if Price List currency change impacts Webshop Cart 10 | """ 11 | if doc.is_new(): 12 | return 13 | 14 | doc_before_save = doc.get_doc_before_save() 15 | currency_changed = doc.currency != doc_before_save.currency 16 | affects_cart = doc.name == frappe.get_cached_value( 17 | "Webshop Settings", None, "price_list" 18 | ) 19 | 20 | if currency_changed and affects_cart: 21 | validate_cart_settings() 22 | -------------------------------------------------------------------------------- /webshop/webshop/crud_events/quotation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/crud_events/quotation/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/crud_events/quotation/validate_shopping_cart_items.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | 4 | 5 | def execute(doc, method=None): 6 | if doc.order_type != "Shopping Cart": 7 | return 8 | 9 | webshop_settings = frappe.get_cached_doc("Webshop Settings") 10 | for item in doc.items: 11 | has_web_item = frappe.db.exists("Website Item", {"item_code": item.item_code}) 12 | 13 | # If variant is unpublished but template is published: valid 14 | template = frappe.get_cached_value("Item", item.item_code, "variant_of") 15 | if template and not has_web_item: 16 | has_web_item = frappe.db.exists("Website Item", {"item_code": template}) 17 | 18 | if not has_web_item and not webshop_settings.allow_non_website_items_in_cart_quotation: 19 | frappe.throw( 20 | _( 21 | "Row #{0}: Item {1} must have a Website Item for Shopping Cart Quotations" 22 | ).format(item.idx, frappe.bold(item.item_code)), 23 | title=_("Unpublished Item"), 24 | ) 25 | -------------------------------------------------------------------------------- /webshop/webshop/crud_events/tax_rule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/crud_events/tax_rule/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/crud_events/tax_rule/validate_use_for_cart.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | from frappe.utils import cint 4 | 5 | 6 | def execute(doc, method=None): 7 | """ 8 | If shopping cart is enabled and no tax rule exists for shopping cart, enable this one 9 | """ 10 | if doc.use_for_shopping_cart: 11 | return 12 | 13 | is_enabled = cint(frappe.db.get_single_value("Webshop Settings", "enabled")) 14 | 15 | if not is_enabled: 16 | return 17 | 18 | use_for_cart = frappe.db.get_value( 19 | "Tax Rule", {"use_for_shopping_cart": 1, "name": ["!=", doc.name]} 20 | ) 21 | 22 | if not use_for_cart: 23 | return 24 | 25 | doc.use_for_shopping_cart = 1 26 | 27 | frappe.msgprint( 28 | _( 29 | """ 30 | Enabling 'Use for Shopping Cart', as Shopping Cart is enabled 31 | and there should be at least one Tax Rule for Shopping Cart 32 | """ 33 | ) 34 | ) 35 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/homepage_featured_product/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/homepage_featured_product/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/homepage_featured_product/homepage_featured_product.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2023-12-14 22:14:23.853797", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "item_code", 10 | "view", 11 | "column_break_nxmx", 12 | "item_name", 13 | "section_break_qlos", 14 | "description", 15 | "column_break_jxff", 16 | "image", 17 | "thumbnail", 18 | "route" 19 | ], 20 | "fields": [ 21 | { 22 | "fieldname": "item_code", 23 | "fieldtype": "Link", 24 | "in_list_view": 1, 25 | "label": "Item", 26 | "options": "Website Item", 27 | "reqd": 1 28 | }, 29 | { 30 | "fieldname": "column_break_nxmx", 31 | "fieldtype": "Column Break" 32 | }, 33 | { 34 | "fetch_from": "item_code.item_name", 35 | "fieldname": "item_name", 36 | "fieldtype": "Data", 37 | "in_list_view": 1, 38 | "label": "Item Name" 39 | }, 40 | { 41 | "fieldname": "view", 42 | "fieldtype": "Button", 43 | "in_list_view": 1, 44 | "label": "View" 45 | }, 46 | { 47 | "fieldname": "section_break_qlos", 48 | "fieldtype": "Section Break" 49 | }, 50 | { 51 | "fetch_from": "item_code.web_long_description", 52 | "fieldname": "description", 53 | "fieldtype": "Text Editor", 54 | "in_list_view": 1, 55 | "label": "Description" 56 | }, 57 | { 58 | "fieldname": "column_break_jxff", 59 | "fieldtype": "Column Break" 60 | }, 61 | { 62 | "fetch_from": "item_code.website_image", 63 | "fetch_if_empty": 1, 64 | "fieldname": "image", 65 | "fieldtype": "Attach Image", 66 | "label": "Image" 67 | }, 68 | { 69 | "fetch_from": "item_code.thumbnail", 70 | "fieldname": "thumbnail", 71 | "fieldtype": "Attach Image", 72 | "label": "Thumbnail" 73 | }, 74 | { 75 | "fetch_from": "item_code.route", 76 | "fieldname": "route", 77 | "fieldtype": "Small Text", 78 | "label": "Route", 79 | "read_only": 1 80 | } 81 | ], 82 | "index_web_pages_for_search": 1, 83 | "istable": 1, 84 | "links": [], 85 | "modified": "2023-12-14 22:33:25.457721", 86 | "modified_by": "Administrator", 87 | "module": "Webshop", 88 | "name": "Homepage Featured Product", 89 | "owner": "Administrator", 90 | "permissions": [], 91 | "sort_field": "modified", 92 | "sort_order": "DESC", 93 | "states": [] 94 | } -------------------------------------------------------------------------------- /webshop/webshop/doctype/homepage_featured_product/homepage_featured_product.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class HomepageFeaturedProduct(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/item_review/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/item_review/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/item_review/item_review.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Item Review', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/item_review/item_review.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "beta": 1, 4 | "creation": "2021-03-23 16:47:26.542226", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "website_item", 10 | "user", 11 | "customer", 12 | "column_break_3", 13 | "item", 14 | "published_on", 15 | "reviews_section", 16 | "review_title", 17 | "rating", 18 | "comment" 19 | ], 20 | "fields": [ 21 | { 22 | "fieldname": "website_item", 23 | "fieldtype": "Link", 24 | "label": "Website Item", 25 | "options": "Website Item", 26 | "read_only": 1, 27 | "reqd": 1 28 | }, 29 | { 30 | "fieldname": "user", 31 | "fieldtype": "Link", 32 | "in_list_view": 1, 33 | "label": "User", 34 | "options": "User", 35 | "read_only": 1 36 | }, 37 | { 38 | "fieldname": "column_break_3", 39 | "fieldtype": "Column Break" 40 | }, 41 | { 42 | "fetch_from": "website_item.item_code", 43 | "fieldname": "item", 44 | "fieldtype": "Link", 45 | "in_list_view": 1, 46 | "label": "Item", 47 | "options": "Item", 48 | "read_only": 1 49 | }, 50 | { 51 | "fieldname": "reviews_section", 52 | "fieldtype": "Section Break", 53 | "label": "Reviews" 54 | }, 55 | { 56 | "fieldname": "rating", 57 | "fieldtype": "Rating", 58 | "in_list_view": 1, 59 | "label": "Rating" 60 | }, 61 | { 62 | "fieldname": "comment", 63 | "fieldtype": "Small Text", 64 | "label": "Comment", 65 | "read_only": 1 66 | }, 67 | { 68 | "fieldname": "review_title", 69 | "fieldtype": "Data", 70 | "label": "Review Title", 71 | "read_only": 1 72 | }, 73 | { 74 | "fieldname": "customer", 75 | "fieldtype": "Link", 76 | "label": "Customer", 77 | "options": "Customer", 78 | "read_only": 1 79 | }, 80 | { 81 | "fieldname": "published_on", 82 | "fieldtype": "Data", 83 | "label": "Published on", 84 | "read_only": 1 85 | } 86 | ], 87 | "index_web_pages_for_search": 1, 88 | "links": [], 89 | "modified": "2023-10-13 17:35:32.281964", 90 | "modified_by": "Administrator", 91 | "module": "Webshop", 92 | "name": "Item Review", 93 | "owner": "Administrator", 94 | "permissions": [ 95 | { 96 | "create": 1, 97 | "delete": 1, 98 | "email": 1, 99 | "export": 1, 100 | "print": 1, 101 | "read": 1, 102 | "report": 1, 103 | "role": "System Manager", 104 | "share": 1, 105 | "write": 1 106 | }, 107 | { 108 | "create": 1, 109 | "delete": 1, 110 | "email": 1, 111 | "export": 1, 112 | "print": 1, 113 | "read": 1, 114 | "report": 1, 115 | "role": "Website Manager", 116 | "share": 1, 117 | "write": 1 118 | }, 119 | { 120 | "create": 1, 121 | "delete": 1, 122 | "email": 1, 123 | "export": 1, 124 | "print": 1, 125 | "report": 1, 126 | "role": "Customer", 127 | "share": 1 128 | } 129 | ], 130 | "sort_field": "modified", 131 | "sort_order": "DESC", 132 | "states": [], 133 | "track_changes": 1 134 | } -------------------------------------------------------------------------------- /webshop/webshop/doctype/item_review/item_review.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 3 | # For license information, please see license.txt 4 | 5 | from datetime import datetime 6 | 7 | import frappe 8 | from frappe import _ 9 | from frappe.contacts.doctype.contact.contact import get_contact_name 10 | from frappe.model.document import Document 11 | from frappe.utils import cint, flt 12 | 13 | from webshop.webshop.doctype.webshop_settings.webshop_settings import ( 14 | get_shopping_cart_settings, 15 | ) 16 | 17 | 18 | class UnverifiedReviewer(frappe.ValidationError): 19 | pass 20 | 21 | 22 | class ItemReview(Document): 23 | def after_insert(self): 24 | # regenerate cache on review creation 25 | reviews_dict = get_queried_reviews(self.website_item) 26 | set_reviews_in_cache(self.website_item, reviews_dict) 27 | 28 | def after_delete(self): 29 | # regenerate cache on review deletion 30 | reviews_dict = get_queried_reviews(self.website_item) 31 | set_reviews_in_cache(self.website_item, reviews_dict) 32 | 33 | 34 | @frappe.whitelist() 35 | def get_item_reviews(web_item, start=0, end=10, data=None): 36 | "Get Website Item Review Data." 37 | start, end = cint(start), cint(end) 38 | settings = get_shopping_cart_settings() 39 | 40 | # Get cached reviews for first page (start=0) 41 | # avoid cache when page is different 42 | from_cache = not bool(start) 43 | 44 | if not data: 45 | data = frappe._dict() 46 | 47 | if settings and settings.get("enable_reviews"): 48 | reviews_cache = frappe.cache().hget("item_reviews", web_item) 49 | if from_cache and reviews_cache: 50 | data = reviews_cache 51 | else: 52 | data = get_queried_reviews(web_item, start, end, data) 53 | if from_cache: 54 | set_reviews_in_cache(web_item, data) 55 | 56 | return data 57 | 58 | 59 | def get_queried_reviews(web_item, start=0, end=10, data=None): 60 | """ 61 | Query Website Item wise reviews and cache if needed. 62 | Cache stores only first page of reviews i.e. 10 reviews maximum. 63 | Returns: 64 | dict: Containing reviews, average ratings, % of reviews per rating and total reviews. 65 | """ 66 | if not data: 67 | data = frappe._dict() 68 | 69 | data.reviews = frappe.db.get_all( 70 | "Item Review", 71 | filters={"website_item": web_item}, 72 | fields=["*"], 73 | limit_start=start, 74 | limit_page_length=end, 75 | ) 76 | 77 | rating_data = frappe.db.get_all( 78 | "Item Review", 79 | filters={"website_item": web_item}, 80 | fields=["avg(rating*5) as average, count(*) as total"], 81 | )[0] 82 | 83 | data.average_rating = flt(rating_data.average, 5) 84 | data.average_whole_rating = flt(data.average_rating, 0) 85 | 86 | # get % of reviews per rating 87 | reviews_per_rating = [] 88 | for i in range(1, 6): 89 | count = frappe.db.get_all( 90 | "Item Review", filters={"website_item": web_item, "rating": i/5}, fields=["count(*) as count"] 91 | )[0].count 92 | 93 | percent = flt((count / rating_data.total or 1) * 100, 0) if count else 0 94 | reviews_per_rating.append(percent) 95 | 96 | data.reviews_per_rating = reviews_per_rating 97 | data.total_reviews = rating_data.total 98 | 99 | return data 100 | 101 | 102 | def set_reviews_in_cache(web_item, reviews_dict): 103 | frappe.cache().hset("item_reviews", web_item, reviews_dict) 104 | 105 | 106 | @frappe.whitelist() 107 | def add_item_review(web_item, title, rating, comment=None): 108 | """Add an Item Review by a user if non-existent.""" 109 | if frappe.session.user == "Guest": 110 | # guest user should not reach here ideally in the case they do via an API, throw error 111 | frappe.throw(_("You are not verified to write a review yet."), exc=UnverifiedReviewer) 112 | 113 | if not frappe.db.exists("Item Review", {"user": frappe.session.user, "website_item": web_item}): 114 | doc = frappe.new_doc("Item Review") 115 | doc.update( 116 | { 117 | "user": frappe.session.user, 118 | "customer": get_customer(), 119 | "website_item": web_item, 120 | "item": frappe.db.get_value("Website Item", web_item, "item_code"), 121 | "review_title": title, 122 | "rating": rating, 123 | "comment": comment, 124 | } 125 | ) 126 | doc.published_on = datetime.today().strftime("%d %B %Y") 127 | doc.save() 128 | 129 | 130 | def get_customer(silent=False): 131 | """ 132 | silent: Return customer if exists else return nothing. Dont throw error. 133 | """ 134 | user = frappe.session.user 135 | contact_name = get_contact_name(user) 136 | customer = None 137 | 138 | if contact_name: 139 | contact = frappe.get_doc("Contact", contact_name) 140 | for link in contact.links: 141 | if link.link_doctype == "Customer": 142 | customer = link.link_name 143 | break 144 | 145 | if customer: 146 | return frappe.db.get_value("Customer", customer) 147 | elif silent: 148 | return None 149 | else: 150 | # should not reach here unless via an API 151 | frappe.throw( 152 | _("You are not a verified customer yet. Please contact us to proceed."), exc=UnverifiedReviewer 153 | ) 154 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/item_review/test_item_review.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors 3 | # See license.txt 4 | import unittest 5 | 6 | import frappe 7 | from frappe.core.doctype.user_permission.test_user_permission import create_user 8 | 9 | from webshop.webshop.doctype.webshop_settings.test_webshop_settings import ( 10 | setup_webshop_settings, 11 | ) 12 | from webshop.webshop.doctype.item_review.item_review import ( 13 | UnverifiedReviewer, 14 | add_item_review, 15 | get_item_reviews, 16 | ) 17 | from webshop.webshop.doctype.website_item.website_item import make_website_item 18 | from webshop.webshop.shopping_cart.cart import get_party 19 | from erpnext.stock.doctype.item.test_item import make_item 20 | 21 | 22 | class TestItemReview(unittest.TestCase): 23 | def setUp(self): 24 | item = make_item("Test Mobile Phone") 25 | if not frappe.db.exists("Website Item", {"item_code": "Test Mobile Phone"}): 26 | make_website_item(item, save=True) 27 | 28 | frappe.set_user("Administrator") 29 | setup_webshop_settings({"enable_reviews": 1, "enabled": 1}) 30 | frappe.local.shopping_cart_settings = None 31 | 32 | def tearDown(self): 33 | frappe.set_user("Administrator") 34 | 35 | website_item_doc = frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}) 36 | reviews = frappe.get_all("Item Review", {"website_item": website_item_doc.name}) 37 | for review in reviews: 38 | frappe.delete_doc("Item Review", review.name) 39 | 40 | website_item_doc.delete() 41 | setup_webshop_settings({"enable_reviews": 0}) 42 | 43 | def test_add_and_get_item_reviews_from_customer(self): 44 | "Add / Get Reviews from a User that is a valid customer (has added to cart or purchased in the past)" 45 | # create user 46 | web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) 47 | test_user = create_user("test_reviewer@example.com", "Customer") 48 | frappe.set_user(test_user.name) 49 | 50 | # create customer and contact against user 51 | customer = get_party() 52 | 53 | # post review on "Test Mobile Phone" 54 | try: 55 | add_item_review(web_item, "Great Product", 4, "Would recommend this product") 56 | review_name = frappe.db.get_value("Item Review", {"website_item": web_item}) 57 | except Exception: 58 | self.fail(f"Error while publishing review for {web_item}") 59 | 60 | review_data = get_item_reviews(web_item, 0, 10) 61 | 62 | self.assertEqual(len(review_data.reviews), 1) 63 | self.assertTrue(review_data.average_rating) 64 | self.assertEqual(review_data.reviews_per_rating[0], 100) 65 | 66 | # tear down 67 | frappe.set_user("Administrator") 68 | frappe.delete_doc("Item Review", review_name) 69 | customer.delete() 70 | 71 | def test_add_item_review_from_non_customer(self): 72 | "Check if logged in user (who is not a customer yet) is blocked from posting reviews." 73 | web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) 74 | test_user = create_user("test_reviewer@example.com", "Customer") 75 | frappe.set_user(test_user.name) 76 | 77 | with self.assertRaises(UnverifiedReviewer): 78 | add_item_review(web_item, "Great Product", 3, "Would recommend this product") 79 | 80 | # tear down 81 | frappe.set_user("Administrator") 82 | 83 | def test_add_item_reviews_from_guest_user(self): 84 | "Check if Guest user is blocked from posting reviews." 85 | web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) 86 | frappe.set_user("Guest") 87 | 88 | with self.assertRaises(UnverifiedReviewer): 89 | add_item_review(web_item, "Great Product", 3, "Would recommend this product") 90 | 91 | # tear down 92 | frappe.set_user("Administrator") 93 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/override_doctype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/override_doctype/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/override_doctype/item.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | from frappe.utils import get_link_to_form 4 | from erpnext.stock.doctype.item.item import Item 5 | from webshop.webshop.doctype.override_doctype.item_group import invalidate_cache_for 6 | 7 | class DataValidationError(frappe.ValidationError): 8 | pass 9 | 10 | class WebshopItem(Item): 11 | def on_update(self): 12 | super(WebshopItem, self).on_update() 13 | invalidate_cache_for_item(self) 14 | super(WebshopItem, self).on_update() 15 | 16 | def before_rename(self, old_name, new_name, merge=False): 17 | self.validate_duplicate_website_item_before_merge(old_name, new_name) 18 | return super(WebshopItem, self).before_rename(old_name, new_name, merge) 19 | 20 | def validate_duplicate_website_item_before_merge(self, old_name, new_name): 21 | """ 22 | Block merge if both old and new items have website items against them. 23 | This is to avoid duplicate website items after merging. 24 | """ 25 | web_items = frappe.get_all( 26 | "Website Item", 27 | filters={"item_code": ["in", [old_name, new_name]]}, 28 | fields=["item_code", "name"], 29 | ) 30 | 31 | if len(web_items) <= 1: 32 | return 33 | 34 | old_web_item = [d.get("name") for d in web_items if d.get("item_code") == old_name][0] 35 | web_item_link = get_link_to_form("Website Item", old_web_item) 36 | old_name, new_name = frappe.bold(old_name), frappe.bold(new_name) 37 | 38 | msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} into {new_name}" 39 | frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError) 40 | 41 | def after_rename(self, old_name, new_name, merge): 42 | if self.published_in_website: 43 | invalidate_cache_for_item(self) 44 | 45 | super(WebshopItem, self).after_rename(old_name, new_name, merge) 46 | 47 | 48 | def invalidate_cache_for_item(doc): 49 | """Invalidate Item Group cache and rebuild ItemVariantsCacheManager.""" 50 | invalidate_cache_for(doc, doc.item_group) 51 | 52 | if doc.get("old_item_group") and doc.get("old_item_group") != doc.item_group: 53 | invalidate_cache_for(doc, doc.old_item_group) 54 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/override_doctype/item_group.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | from urllib.parse import quote 4 | from frappe.utils import get_url, cint 5 | from frappe.website.website_generator import WebsiteGenerator 6 | from erpnext.setup.doctype.item_group.item_group import ItemGroup 7 | from frappe.website.utils import clear_cache 8 | from webshop.webshop.product_data_engine.filters import ProductFiltersBuilder 9 | 10 | class WebshopItemGroup(ItemGroup, WebsiteGenerator): 11 | nsm_parent_field = "parent_item_group" 12 | website = frappe._dict( 13 | condition_field="show_in_website", 14 | template="templates/generators/item_group.html", 15 | no_cache=1, 16 | no_breadcrumbs=1, 17 | ) 18 | 19 | def validate(self): 20 | self.make_route() 21 | WebsiteGenerator.validate(self) 22 | super(WebshopItemGroup, self).validate() 23 | 24 | def on_update(self): 25 | invalidate_cache_for(self) 26 | super(WebshopItemGroup, self).on_update() 27 | 28 | def make_route(self): 29 | """Make website route""" 30 | if self.route: 31 | return 32 | 33 | self.route = "" 34 | if self.parent_item_group: 35 | parent_item_group = frappe.get_doc("Item Group", self.parent_item_group) 36 | 37 | # make parent route only if not root 38 | if parent_item_group.parent_item_group and parent_item_group.route: 39 | self.route = parent_item_group.route + "/" 40 | 41 | self.route += self.scrub(self.item_group_name) 42 | 43 | return self.route 44 | 45 | def on_trash(self): 46 | WebsiteGenerator.on_trash(self) 47 | super(WebshopItemGroup, self).on_trash() 48 | 49 | def get_context(self, context): 50 | context.show_search = True 51 | context.body_class = "product-page" 52 | context.page_length = ( 53 | cint(frappe.db.get_single_value("Webshop Settings", "products_per_page")) or 6 54 | ) 55 | context.search_link = "/product_search" 56 | 57 | filter_engine = ProductFiltersBuilder(self.name) 58 | 59 | context.field_filters = filter_engine.get_field_filters() 60 | context.attribute_filters = filter_engine.get_attribute_filters() 61 | 62 | context.update({"parents": get_parent_item_groups(self.parent_item_group), "title": self.name}) 63 | 64 | if self.slideshow: 65 | values = {"show_indicators": 1, "show_controls": 0, "rounded": 1, "slider_name": self.slideshow} 66 | slideshow = frappe.get_doc("Website Slideshow", self.slideshow) 67 | slides = slideshow.get({"doctype": "Website Slideshow Item"}) 68 | for index, slide in enumerate(slides): 69 | values[f"slide_{index + 1}_image"] = slide.image 70 | values[f"slide_{index + 1}_title"] = slide.heading 71 | values[f"slide_{index + 1}_subtitle"] = slide.description 72 | values[f"slide_{index + 1}_theme"] = slide.get("theme") or "Light" 73 | values[f"slide_{index + 1}_content_align"] = slide.get("content_align") or "Centre" 74 | values[f"slide_{index + 1}_primary_action"] = slide.url 75 | 76 | context.slideshow = values 77 | 78 | context.no_breadcrumbs = False 79 | context.title = self.website_title or self.name 80 | context.name = self.name 81 | context.item_group_name = self.item_group_name 82 | 83 | return context 84 | 85 | def has_website_permission(self, ptype, user, verbose=False): 86 | return ptype == "read" 87 | 88 | def get_item_for_list_in_html(context): 89 | # add missing absolute link in files 90 | # user may forget it during upload 91 | if (context.get("website_image") or "").startswith("files/"): 92 | context["website_image"] = "/" + quote(context["website_image"]) 93 | 94 | products_template = "templates/includes/products_as_list.html" 95 | 96 | return frappe.get_template(products_template).render(context) 97 | 98 | 99 | def get_parent_item_groups(item_group_name, from_item=False): 100 | settings = frappe.get_cached_doc("Webshop Settings") 101 | 102 | if settings.enable_field_filters: 103 | base_nav_page = {"name": _("Shop by Category"), "route": "/shop-by-category"} 104 | else: 105 | base_nav_page = {"name": _("All Products"), "route": "/all-products"} 106 | 107 | if from_item and frappe.request.environ.get("HTTP_REFERER"): 108 | # base page after 'Home' will vary on Item page 109 | last_page = frappe.request.environ["HTTP_REFERER"].split("/")[-1].split("?")[0] 110 | if last_page and last_page in ("shop-by-category", "all-products"): 111 | base_nav_page_title = " ".join(last_page.split("-")).title() 112 | base_nav_page = {"name": _(base_nav_page_title), "route": "/" + last_page} 113 | 114 | base_parents = [ 115 | {"name": _("Home"), "route": "/"}, 116 | base_nav_page, 117 | ] 118 | 119 | if not item_group_name: 120 | return base_parents 121 | 122 | item_group = frappe.db.get_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1) 123 | parent_groups = frappe.db.sql( 124 | """select name, route from `tabItem Group` 125 | where lft <= %s and rgt >= %s 126 | and show_in_website=1 127 | order by lft asc""", 128 | (item_group.lft, item_group.rgt), 129 | as_dict=True, 130 | ) 131 | 132 | return base_parents + parent_groups 133 | 134 | 135 | def invalidate_cache_for(doc, item_group=None): 136 | if not item_group: 137 | item_group = doc.name 138 | 139 | for d in get_parent_item_groups(item_group): 140 | item_group_name = frappe.db.get_value("Item Group", d.get("name")) 141 | if item_group_name: 142 | clear_cache(frappe.db.get_value("Item Group", item_group_name, "route")) 143 | 144 | def get_child_groups_for_website(item_group_name, immediate=False, include_self=False): 145 | """Returns child item groups *excluding* passed group.""" 146 | item_group = frappe.get_cached_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1) 147 | filters = {"lft": [">", item_group.lft], "rgt": ["<", item_group.rgt], "show_in_website": 1} 148 | 149 | if immediate: 150 | filters["parent_item_group"] = item_group_name 151 | 152 | if include_self: 153 | filters.update({"lft": [">=", item_group.lft], "rgt": ["<=", item_group.rgt]}) 154 | 155 | return frappe.get_all("Item Group", filters=filters, fields=["name", "route"], order_by="name") -------------------------------------------------------------------------------- /webshop/webshop/doctype/override_doctype/payment_request.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils import get_url 3 | 4 | from erpnext.accounts.doctype.payment_request.payment_request import ( 5 | PaymentRequest as OriginalPaymentRequest, 6 | ) 7 | 8 | 9 | class PaymentRequest(OriginalPaymentRequest): 10 | def on_payment_authorized(self, status=None): 11 | if not status: 12 | return 13 | 14 | if status not in ("Authorized", "Completed"): 15 | return 16 | 17 | if not hasattr(frappe.local, "session"): 18 | return 19 | 20 | if frappe.local.session.user == "Guest": 21 | return 22 | 23 | cart_settings = frappe.get_doc("Webshop Settings") 24 | 25 | if not cart_settings.enabled: 26 | return 27 | 28 | success_url = cart_settings.payment_success_url 29 | redirect_to = get_url("/orders/{0}".format(self.reference_name)) 30 | 31 | if success_url: 32 | redirect_to = ( 33 | { 34 | "Orders": "/orders", 35 | "Invoices": "/invoices", 36 | "My Account": "/me", 37 | } 38 | ).get(success_url, "/me") 39 | 40 | self.set_as_paid() 41 | 42 | return redirect_to 43 | 44 | @staticmethod 45 | def get_gateway_details(args): 46 | if args.order_type != "Shopping Cart": 47 | return super().get_gateway_details(args) 48 | 49 | cart_settings = frappe.get_doc("Webshop Settings") 50 | gateway_account = cart_settings.payment_gateway_account 51 | return super().get_payment_gateway_account(gateway_account) 52 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/recommended_items/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/recommended_items/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/recommended_items/recommended_items.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-07-12 20:52:12.503470", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "website_item", 9 | "website_item_name", 10 | "column_break_2", 11 | "item_code", 12 | "more_information_section", 13 | "route", 14 | "column_break_6", 15 | "website_item_image", 16 | "website_item_thumbnail" 17 | ], 18 | "fields": [ 19 | { 20 | "fieldname": "website_item", 21 | "fieldtype": "Link", 22 | "in_list_view": 1, 23 | "label": "Website Item", 24 | "options": "Website Item" 25 | }, 26 | { 27 | "fetch_from": "website_item.web_item_name", 28 | "fieldname": "website_item_name", 29 | "fieldtype": "Data", 30 | "in_list_view": 1, 31 | "label": "Website Item Name", 32 | "read_only": 1 33 | }, 34 | { 35 | "fieldname": "column_break_2", 36 | "fieldtype": "Column Break" 37 | }, 38 | { 39 | "fieldname": "more_information_section", 40 | "fieldtype": "Section Break", 41 | "label": "More Information" 42 | }, 43 | { 44 | "fetch_from": "website_item.route", 45 | "fieldname": "route", 46 | "fieldtype": "Small Text", 47 | "label": "Route", 48 | "read_only": 1 49 | }, 50 | { 51 | "fetch_from": "website_item.website_image", 52 | "fieldname": "website_item_image", 53 | "fieldtype": "Attach", 54 | "label": "Website Item Image", 55 | "read_only": 1 56 | }, 57 | { 58 | "fieldname": "column_break_6", 59 | "fieldtype": "Column Break" 60 | }, 61 | { 62 | "fetch_from": "website_item.thumbnail", 63 | "fieldname": "website_item_thumbnail", 64 | "fieldtype": "Data", 65 | "label": "Website Item Thumbnail", 66 | "read_only": 1 67 | }, 68 | { 69 | "fetch_from": "website_item.item_code", 70 | "fieldname": "item_code", 71 | "fieldtype": "Data", 72 | "label": "Item Code" 73 | } 74 | ], 75 | "index_web_pages_for_search": 1, 76 | "istable": 1, 77 | "links": [], 78 | "modified": "2022-06-28 16:44:24.718728", 79 | "modified_by": "Administrator", 80 | "module": "Webshop", 81 | "name": "Recommended Items", 82 | "owner": "Administrator", 83 | "permissions": [], 84 | "sort_field": "modified", 85 | "sort_order": "DESC", 86 | "states": [], 87 | "track_changes": 1 88 | } -------------------------------------------------------------------------------- /webshop/webshop/doctype/recommended_items/recommended_items.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | 8 | class RecommendedItems(Document): 9 | pass 10 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/webshop_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/webshop_settings/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/webshop_settings/test_webshop_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors 2 | # See license.txt 3 | import unittest 4 | 5 | import frappe 6 | 7 | from webshop.webshop.doctype.webshop_settings.webshop_settings import ( 8 | ShoppingCartSetupError, 9 | ) 10 | 11 | 12 | class TestWebshopSettings(unittest.TestCase): 13 | def tearDown(self): 14 | frappe.db.rollback() 15 | 16 | def test_tax_rule_validation(self): 17 | frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0") 18 | frappe.db.commit() # nosemgrep 19 | 20 | cart_settings = frappe.get_doc("Webshop Settings") 21 | cart_settings.enabled = 1 22 | if not frappe.db.get_value("Tax Rule", {"use_for_shopping_cart": 1}, "name"): 23 | self.assertRaises(ShoppingCartSetupError, cart_settings.validate_tax_rule) 24 | 25 | frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 1") 26 | 27 | def test_invalid_filter_fields(self): 28 | "Check if Item fields are blocked in Webshop Settings filter fields." 29 | from frappe.custom.doctype.custom_field.custom_field import create_custom_field 30 | 31 | setup_webshop_settings({"enable_field_filters": 1}) 32 | 33 | create_custom_field( 34 | "Item", 35 | dict(owner="Administrator", fieldname="test_data", label="Test", fieldtype="Data"), 36 | ) 37 | settings = frappe.get_doc("Webshop Settings") 38 | settings.append("filter_fields", {"fieldname": "test_data"}) 39 | 40 | self.assertRaises(frappe.ValidationError, settings.save) 41 | 42 | 43 | def setup_webshop_settings(values_dict): 44 | "Accepts a dict of values that updates Webshop Settings." 45 | if not values_dict: 46 | return 47 | 48 | doc = frappe.get_doc("Webshop Settings", "Webshop Settings") 49 | doc.update(values_dict) 50 | doc.save() 51 | 52 | 53 | test_dependencies = ["Tax Rule"] 54 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/webshop_settings/webshop_settings.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on("Webshop Settings", { 5 | onload: function(frm) { 6 | if(frm.doc.__onload && frm.doc.__onload.quotation_series) { 7 | frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series; 8 | frm.refresh_field("quotation_series"); 9 | } 10 | 11 | frm.set_query('payment_gateway_account', function() { 12 | return { 'filters': { 13 | 'payment_channel': ['in', ["Email", "Phone"]] 14 | } }; 15 | }); 16 | }, 17 | refresh: function(frm) { 18 | if (frm.doc.enabled) { 19 | frm.get_field('store_page_docs').$wrapper.removeClass('hide-control').html( 20 | `
${__("Follow these steps to create a landing page for your store")}: 21 | 23 | docs/store-landing-page 24 | 25 |
` 26 | ); 27 | } 28 | 29 | frappe.model.with_doctype("Website Item", () => { 30 | const web_item_meta = frappe.get_meta('Website Item'); 31 | 32 | const valid_fields = web_item_meta.fields.filter(df => 33 | ["Link", "Table MultiSelect"].includes(df.fieldtype) && !df.hidden 34 | ).map(df => 35 | ({ label: df.label, value: df.fieldname }) 36 | ); 37 | 38 | frm.get_field("filter_fields").grid.update_docfield_property( 39 | 'fieldname', 'options', valid_fields 40 | ); 41 | }); 42 | }, 43 | enabled: function(frm) { 44 | if (frm.doc.enabled === 1) { 45 | frm.set_value('enable_variants', 1); 46 | } 47 | else { 48 | frm.set_value('company', ''); 49 | frm.set_value('price_list', ''); 50 | frm.set_value('default_customer_group', ''); 51 | frm.set_value('quotation_series', ''); 52 | } 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_item/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/website_item/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_item/templates/website_item.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/web.html" %} 2 | 3 | {% block page_content %} 4 |

{{ title }}

5 | {% endblock %} 6 | 7 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_item/templates/website_item_row.html: -------------------------------------------------------------------------------- 1 |
2 | {{ doc.title or doc.name }} 3 |
4 | 5 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_item/website_item.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Website Item', { 5 | onload: (frm) => { 6 | // should never check Private 7 | frm.fields_dict["website_image"].df.is_private = 0; 8 | }, 9 | 10 | refresh: (frm) => { 11 | frm.add_custom_button(__("Prices"), function() { 12 | frappe.set_route("List", "Item Price", {"item_code": frm.doc.item_code}); 13 | }, __("View")); 14 | 15 | frm.add_custom_button(__("Stock"), function() { 16 | frappe.route_options = { 17 | "item_code": frm.doc.item_code 18 | }; 19 | frappe.set_route("query-report", "Stock Balance"); 20 | }, __("View")); 21 | 22 | frm.add_custom_button(__("Webshop Settings"), function() { 23 | frappe.set_route("Form", "Webshop Settings"); 24 | }, __("View")); 25 | }, 26 | 27 | copy_from_item_group: (frm) => { 28 | return frm.call({ 29 | doc: frm.doc, 30 | method: "copy_specification_from_item_group" 31 | }); 32 | }, 33 | 34 | set_meta_tags: (frm) => { 35 | frappe.utils.set_meta_tag(frm.doc.route); 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_item/website_item_list.js: -------------------------------------------------------------------------------- 1 | frappe.listview_settings['Website Item'] = { 2 | add_fields: ["item_name", "web_item_name", "published", "website_image", "has_variants", "variant_of"], 3 | filters: [["published", "=", "1"]], 4 | 5 | get_indicator: function(doc) { 6 | if (doc.has_variants && doc.published) { 7 | return [__("Template"), "orange", "has_variants,=,Yes|published,=,1"]; 8 | } else if (doc.has_variants && !doc.published) { 9 | return [__("Template"), "grey", "has_variants,=,Yes|published,=,0"]; 10 | } else if (doc.variant_of && doc.published) { 11 | return [__("Variant"), "blue", "published,=,1|variant_of,=," + doc.variant_of]; 12 | } else if (doc.variant_of && !doc.published) { 13 | return [__("Variant"), "grey", "published,=,0|variant_of,=," + doc.variant_of]; 14 | } else if (doc.published) { 15 | return [__("Published"), "green", "published,=,1"]; 16 | } else { 17 | return [__("Not Published"), "grey", "published,=,0"]; 18 | } 19 | } 20 | }; -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_item_tabbed_section/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/website_item_tabbed_section/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_item_tabbed_section/website_item_tabbed_section.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-03-18 20:32:15.321402", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "label", 9 | "content" 10 | ], 11 | "fields": [ 12 | { 13 | "fieldname": "label", 14 | "fieldtype": "Data", 15 | "in_list_view": 1, 16 | "label": "Label" 17 | }, 18 | { 19 | "fieldname": "content", 20 | "fieldtype": "HTML Editor", 21 | "in_list_view": 1, 22 | "label": "Content" 23 | } 24 | ], 25 | "index_web_pages_for_search": 1, 26 | "istable": 1, 27 | "links": [], 28 | "modified": "2021-03-18 20:35:26.991192", 29 | "modified_by": "Administrator", 30 | "module": "Webshop", 31 | "name": "Website Item Tabbed Section", 32 | "owner": "Administrator", 33 | "permissions": [], 34 | "sort_field": "modified", 35 | "sort_order": "DESC", 36 | "track_changes": 1 37 | } -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_item_tabbed_section/website_item_tabbed_section.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 3 | # For license information, please see license.txt 4 | 5 | # import frappe 6 | from frappe.model.document import Document 7 | 8 | 9 | class WebsiteItemTabbedSection(Document): 10 | pass 11 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_offer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/website_offer/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_offer/website_offer.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-04-21 13:37:14.162162", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "offer_title", 9 | "offer_subtitle", 10 | "offer_details" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "offer_title", 15 | "fieldtype": "Data", 16 | "in_list_view": 1, 17 | "label": "Offer Title" 18 | }, 19 | { 20 | "fieldname": "offer_subtitle", 21 | "fieldtype": "Data", 22 | "in_list_view": 1, 23 | "label": "Offer Subtitle" 24 | }, 25 | { 26 | "fieldname": "offer_details", 27 | "fieldtype": "Text Editor", 28 | "label": "Offer Details" 29 | } 30 | ], 31 | "index_web_pages_for_search": 1, 32 | "istable": 1, 33 | "links": [], 34 | "modified": "2021-04-21 13:56:04.660331", 35 | "modified_by": "Administrator", 36 | "module": "Webshop", 37 | "name": "Website Offer", 38 | "owner": "Administrator", 39 | "permissions": [], 40 | "sort_field": "modified", 41 | "sort_order": "DESC", 42 | "track_changes": 1 43 | } -------------------------------------------------------------------------------- /webshop/webshop/doctype/website_offer/website_offer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 3 | # For license information, please see license.txt 4 | 5 | import frappe 6 | from frappe.model.document import Document 7 | 8 | 9 | class WebsiteOffer(Document): 10 | pass 11 | 12 | 13 | @frappe.whitelist(allow_guest=True) 14 | def get_offer_details(offer_id): 15 | return frappe.db.get_value("Website Offer", {"name": offer_id}, ["offer_details"]) 16 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/wishlist/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/wishlist/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/wishlist/test_wishlist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors 3 | # See license.txt 4 | import unittest 5 | 6 | import frappe 7 | from frappe.core.doctype.user_permission.test_user_permission import create_user 8 | 9 | from webshop.webshop.doctype.website_item.website_item import make_website_item 10 | from webshop.webshop.doctype.wishlist.wishlist import add_to_wishlist, remove_from_wishlist 11 | from erpnext.stock.doctype.item.test_item import make_item 12 | 13 | 14 | class TestWishlist(unittest.TestCase): 15 | def setUp(self): 16 | item = make_item("Test Phone Series X") 17 | if not frappe.db.exists("Website Item", {"item_code": "Test Phone Series X"}): 18 | make_website_item(item, save=True) 19 | 20 | item = make_item("Test Phone Series Y") 21 | if not frappe.db.exists("Website Item", {"item_code": "Test Phone Series Y"}): 22 | make_website_item(item, save=True) 23 | 24 | def tearDown(self): 25 | frappe.get_cached_doc("Website Item", {"item_code": "Test Phone Series X"}).delete() 26 | frappe.get_cached_doc("Website Item", {"item_code": "Test Phone Series Y"}).delete() 27 | frappe.get_cached_doc("Item", "Test Phone Series X").delete() 28 | frappe.get_cached_doc("Item", "Test Phone Series Y").delete() 29 | 30 | def test_add_remove_items_in_wishlist(self): 31 | "Check if items are added and removed from user's wishlist." 32 | # add first item 33 | add_to_wishlist("Test Phone Series X") 34 | 35 | # check if wishlist was created and item was added 36 | self.assertTrue(frappe.db.exists("Wishlist", {"user": frappe.session.user})) 37 | self.assertTrue( 38 | frappe.db.exists( 39 | "Wishlist Item", {"item_code": "Test Phone Series X", "parent": frappe.session.user} 40 | ) 41 | ) 42 | 43 | # add second item to wishlist 44 | add_to_wishlist("Test Phone Series Y") 45 | wishlist_length = frappe.db.get_value( 46 | "Wishlist Item", {"parent": frappe.session.user}, "count(*)" 47 | ) 48 | self.assertEqual(wishlist_length, 2) 49 | 50 | remove_from_wishlist("Test Phone Series X") 51 | remove_from_wishlist("Test Phone Series Y") 52 | 53 | wishlist_length = frappe.db.get_value( 54 | "Wishlist Item", {"parent": frappe.session.user}, "count(*)" 55 | ) 56 | self.assertIsNone(frappe.db.exists("Wishlist Item", {"parent": frappe.session.user})) 57 | self.assertEqual(wishlist_length, 0) 58 | 59 | # tear down 60 | frappe.get_doc("Wishlist", {"user": frappe.session.user}).delete() 61 | 62 | def test_add_remove_in_wishlist_multiple_users(self): 63 | "Check if items are added and removed from the correct user's wishlist." 64 | test_user = create_user("test_reviewer@example.com", "Customer") 65 | test_user_1 = create_user("test_reviewer_1@example.com", "Customer") 66 | 67 | # add to wishlist for first user 68 | frappe.set_user(test_user.name) 69 | add_to_wishlist("Test Phone Series X") 70 | 71 | # add to wishlist for second user 72 | frappe.set_user(test_user_1.name) 73 | add_to_wishlist("Test Phone Series X") 74 | 75 | # check wishlist and its content for users 76 | self.assertTrue(frappe.db.exists("Wishlist", {"user": test_user.name})) 77 | self.assertTrue( 78 | frappe.db.exists( 79 | "Wishlist Item", {"item_code": "Test Phone Series X", "parent": test_user.name} 80 | ) 81 | ) 82 | 83 | self.assertTrue(frappe.db.exists("Wishlist", {"user": test_user_1.name})) 84 | self.assertTrue( 85 | frappe.db.exists( 86 | "Wishlist Item", {"item_code": "Test Phone Series X", "parent": test_user_1.name} 87 | ) 88 | ) 89 | 90 | # remove item for second user 91 | remove_from_wishlist("Test Phone Series X") 92 | 93 | # make sure item was removed for second user and not first 94 | self.assertFalse( 95 | frappe.db.exists( 96 | "Wishlist Item", {"item_code": "Test Phone Series X", "parent": test_user_1.name} 97 | ) 98 | ) 99 | self.assertTrue( 100 | frappe.db.exists( 101 | "Wishlist Item", {"item_code": "Test Phone Series X", "parent": test_user.name} 102 | ) 103 | ) 104 | 105 | # remove item for first user 106 | frappe.set_user(test_user.name) 107 | remove_from_wishlist("Test Phone Series X") 108 | self.assertFalse( 109 | frappe.db.exists( 110 | "Wishlist Item", {"item_code": "Test Phone Series X", "parent": test_user.name} 111 | ) 112 | ) 113 | 114 | # tear down 115 | frappe.set_user("Administrator") 116 | frappe.get_doc("Wishlist", {"user": test_user.name}).delete() 117 | frappe.get_doc("Wishlist", {"user": test_user_1.name}).delete() 118 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/wishlist/wishlist.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Wishlist', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/wishlist/wishlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:user", 4 | "creation": "2021-03-10 18:52:28.769126", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "user", 10 | "section_break_2", 11 | "items" 12 | ], 13 | "fields": [ 14 | { 15 | "fieldname": "user", 16 | "fieldtype": "Link", 17 | "in_list_view": 1, 18 | "label": "User", 19 | "options": "User", 20 | "reqd": 1, 21 | "unique": 1 22 | }, 23 | { 24 | "fieldname": "section_break_2", 25 | "fieldtype": "Section Break" 26 | }, 27 | { 28 | "fieldname": "items", 29 | "fieldtype": "Table", 30 | "label": "Items", 31 | "options": "Wishlist Item" 32 | } 33 | ], 34 | "in_create": 1, 35 | "index_web_pages_for_search": 1, 36 | "links": [], 37 | "modified": "2021-07-08 13:11:21.693956", 38 | "modified_by": "Administrator", 39 | "module": "Webshop", 40 | "name": "Wishlist", 41 | "owner": "Administrator", 42 | "permissions": [ 43 | { 44 | "email": 1, 45 | "export": 1, 46 | "print": 1, 47 | "read": 1, 48 | "report": 1, 49 | "role": "System Manager", 50 | "share": 1 51 | }, 52 | { 53 | "email": 1, 54 | "export": 1, 55 | "print": 1, 56 | "read": 1, 57 | "report": 1, 58 | "role": "Website Manager", 59 | "share": 1 60 | } 61 | ], 62 | "sort_field": "modified", 63 | "sort_order": "DESC", 64 | "track_changes": 1 65 | } -------------------------------------------------------------------------------- /webshop/webshop/doctype/wishlist/wishlist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 3 | # For license information, please see license.txt 4 | 5 | import frappe 6 | from frappe.model.document import Document 7 | 8 | 9 | class Wishlist(Document): 10 | pass 11 | 12 | 13 | @frappe.whitelist() 14 | def add_to_wishlist(item_code): 15 | """Insert Item into wishlist.""" 16 | 17 | if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}): 18 | return 19 | 20 | web_item_data = frappe.db.get_value( 21 | "Website Item", 22 | {"item_code": item_code}, 23 | [ 24 | "website_image", 25 | "website_warehouse", 26 | "name", 27 | "web_item_name", 28 | "item_name", 29 | "item_group", 30 | "route", 31 | ], 32 | as_dict=1, 33 | ) 34 | 35 | wished_item_dict = { 36 | "item_code": item_code, 37 | "item_name": web_item_data.get("item_name"), 38 | "item_group": web_item_data.get("item_group"), 39 | "website_item": web_item_data.get("name"), 40 | "web_item_name": web_item_data.get("web_item_name"), 41 | "image": web_item_data.get("website_image"), 42 | "warehouse": web_item_data.get("website_warehouse"), 43 | "route": web_item_data.get("route"), 44 | } 45 | 46 | if not frappe.db.exists("Wishlist", frappe.session.user): 47 | # initialise wishlist 48 | wishlist = frappe.get_doc({"doctype": "Wishlist"}) 49 | wishlist.user = frappe.session.user 50 | wishlist.append("items", wished_item_dict) 51 | wishlist.save(ignore_permissions=True) 52 | else: 53 | wishlist = frappe.get_doc("Wishlist", frappe.session.user) 54 | item = wishlist.append("items", wished_item_dict) 55 | item.db_insert() 56 | 57 | if hasattr(frappe.local, "cookie_manager"): 58 | frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist.items))) 59 | 60 | 61 | @frappe.whitelist() 62 | def remove_from_wishlist(item_code): 63 | if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}): 64 | frappe.db.delete("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}) 65 | frappe.db.commit() # nosemgrep 66 | 67 | wishlist_items = frappe.db.get_values("Wishlist Item", filters={"parent": frappe.session.user}) 68 | 69 | if hasattr(frappe.local, "cookie_manager"): 70 | frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist_items))) 71 | -------------------------------------------------------------------------------- /webshop/webshop/doctype/wishlist_item/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/doctype/wishlist_item/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/doctype/wishlist_item/wishlist_item.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-03-10 19:03:00.662714", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "item_code", 9 | "website_item", 10 | "web_item_name", 11 | "column_break_3", 12 | "item_name", 13 | "item_group", 14 | "item_details_section", 15 | "description", 16 | "column_break_7", 17 | "route", 18 | "image", 19 | "image_view", 20 | "section_break_8", 21 | "warehouse_section", 22 | "warehouse" 23 | ], 24 | "fields": [ 25 | { 26 | "fetch_from": "website_item.item_code", 27 | "fetch_if_empty": 1, 28 | "fieldname": "item_code", 29 | "fieldtype": "Link", 30 | "in_list_view": 1, 31 | "label": "Item Code", 32 | "options": "Item", 33 | "reqd": 1 34 | }, 35 | { 36 | "fieldname": "website_item", 37 | "fieldtype": "Link", 38 | "in_list_view": 1, 39 | "label": "Website Item", 40 | "options": "Website Item", 41 | "read_only": 1 42 | }, 43 | { 44 | "fieldname": "column_break_3", 45 | "fieldtype": "Column Break" 46 | }, 47 | { 48 | "fetch_from": "item_code.item_name", 49 | "fetch_if_empty": 1, 50 | "fieldname": "item_name", 51 | "fieldtype": "Data", 52 | "label": "Item Name", 53 | "read_only": 1 54 | }, 55 | { 56 | "collapsible": 1, 57 | "fieldname": "item_details_section", 58 | "fieldtype": "Section Break", 59 | "label": "Item Details", 60 | "read_only": 1 61 | }, 62 | { 63 | "fetch_from": "item_code.description", 64 | "fetch_if_empty": 1, 65 | "fieldname": "description", 66 | "fieldtype": "Text Editor", 67 | "label": "Description", 68 | "read_only": 1 69 | }, 70 | { 71 | "fieldname": "column_break_7", 72 | "fieldtype": "Column Break" 73 | }, 74 | { 75 | "fetch_from": "item_code.image", 76 | "fetch_if_empty": 1, 77 | "fieldname": "image", 78 | "fieldtype": "Attach", 79 | "hidden": 1, 80 | "label": "Image" 81 | }, 82 | { 83 | "fetch_from": "item_code.image", 84 | "fetch_if_empty": 1, 85 | "fieldname": "image_view", 86 | "fieldtype": "Image", 87 | "hidden": 1, 88 | "label": "Image View", 89 | "options": "image", 90 | "print_hide": 1 91 | }, 92 | { 93 | "fieldname": "warehouse_section", 94 | "fieldtype": "Section Break", 95 | "label": "Warehouse" 96 | }, 97 | { 98 | "fieldname": "warehouse", 99 | "fieldtype": "Link", 100 | "in_list_view": 1, 101 | "label": "Warehouse", 102 | "options": "Warehouse", 103 | "read_only": 1 104 | }, 105 | { 106 | "fieldname": "section_break_8", 107 | "fieldtype": "Section Break" 108 | }, 109 | { 110 | "fetch_from": "item_code.item_group", 111 | "fetch_if_empty": 1, 112 | "fieldname": "item_group", 113 | "fieldtype": "Link", 114 | "label": "Item Group", 115 | "options": "Item Group", 116 | "read_only": 1 117 | }, 118 | { 119 | "fetch_from": "website_item.route", 120 | "fetch_if_empty": 1, 121 | "fieldname": "route", 122 | "fieldtype": "Small Text", 123 | "label": "Route", 124 | "read_only": 1 125 | }, 126 | { 127 | "fetch_from": "website_item.web_item_name", 128 | "fetch_if_empty": 1, 129 | "fieldname": "web_item_name", 130 | "fieldtype": "Data", 131 | "label": "Website Item Name", 132 | "read_only": 1 133 | } 134 | ], 135 | "index_web_pages_for_search": 1, 136 | "istable": 1, 137 | "links": [], 138 | "modified": "2021-08-09 10:30:41.964802", 139 | "modified_by": "Administrator", 140 | "module": "Webshop", 141 | "name": "Wishlist Item", 142 | "owner": "Administrator", 143 | "permissions": [], 144 | "sort_field": "modified", 145 | "sort_order": "DESC", 146 | "track_changes": 1 147 | } -------------------------------------------------------------------------------- /webshop/webshop/doctype/wishlist_item/wishlist_item.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors 3 | # For license information, please see license.txt 4 | 5 | # import frappe 6 | from frappe.model.document import Document 7 | 8 | 9 | class WishlistItem(Document): 10 | pass 11 | -------------------------------------------------------------------------------- /webshop/webshop/legacy_search.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.search.full_text_search import FullTextSearch 3 | from frappe.utils import strip_html_tags 4 | from whoosh.analysis import StemmingAnalyzer 5 | from whoosh.fields import ID, KEYWORD, TEXT, Schema 6 | from whoosh.qparser import FieldsPlugin, MultifieldParser, WildcardPlugin 7 | from whoosh.query import Prefix 8 | 9 | # TODO: Make obsolete 10 | INDEX_NAME = "products" 11 | 12 | 13 | class ProductSearch(FullTextSearch): 14 | """Wrapper for WebsiteSearch""" 15 | 16 | def get_schema(self): 17 | return Schema( 18 | title=TEXT(stored=True, field_boost=1.5), 19 | name=ID(stored=True), 20 | path=ID(stored=True), 21 | content=TEXT(stored=True, analyzer=StemmingAnalyzer()), 22 | keywords=KEYWORD(stored=True, scorable=True, commas=True), 23 | ) 24 | 25 | def get_id(self): 26 | return "name" 27 | 28 | def get_items_to_index(self): 29 | """Get all routes to be indexed, this includes the static pages 30 | in www/ and routes from published documents 31 | 32 | Returns: 33 | self (object): FullTextSearch Instance 34 | """ 35 | items = get_all_published_items() 36 | documents = [self.get_document_to_index(item) for item in items] 37 | return documents 38 | 39 | def get_document_to_index(self, item): 40 | try: 41 | item = frappe.get_doc("Item", item) 42 | title = item.item_name 43 | keywords = [item.item_group] 44 | 45 | if item.brand: 46 | keywords.append(item.brand) 47 | 48 | if item.website_image_alt: 49 | keywords.append(item.website_image_alt) 50 | 51 | if item.has_variants and item.variant_based_on == "Item Attribute": 52 | keywords = keywords + [attr.attribute for attr in item.attributes] 53 | 54 | if item.web_long_description: 55 | content = strip_html_tags(item.web_long_description) 56 | elif item.description: 57 | content = strip_html_tags(item.description) 58 | 59 | return frappe._dict( 60 | title=title, 61 | name=item.name, 62 | path=item.route, 63 | content=content, 64 | keywords=", ".join(keywords), 65 | ) 66 | except Exception: 67 | pass 68 | 69 | def search(self, text, scope=None, limit=20): 70 | """Search from the current index 71 | 72 | Args: 73 | text (str): String to search for 74 | scope (str, optional): Scope to limit the search. Defaults to None. 75 | limit (int, optional): Limit number of search results. Defaults to 20. 76 | 77 | Returns: 78 | [List(_dict)]: Search results 79 | """ 80 | ix = self.get_index() 81 | 82 | results = None 83 | out = [] 84 | 85 | with ix.searcher() as searcher: 86 | parser = MultifieldParser(["title", "content", "keywords"], ix.schema) 87 | parser.remove_plugin_class(FieldsPlugin) 88 | parser.remove_plugin_class(WildcardPlugin) 89 | query = parser.parse(text) 90 | 91 | filter_scoped = None 92 | if scope: 93 | filter_scoped = Prefix(self.id, scope) 94 | results = searcher.search(query, limit=limit, filter=filter_scoped) 95 | 96 | for r in results: 97 | out.append(self.parse_result(r)) 98 | 99 | return out 100 | 101 | def parse_result(self, result): 102 | title_highlights = result.highlights("title") 103 | content_highlights = result.highlights("content") 104 | keyword_highlights = result.highlights("keywords") 105 | 106 | return frappe._dict( 107 | title=result["title"], 108 | path=result["path"], 109 | keywords=result["keywords"], 110 | title_highlights=title_highlights, 111 | content_highlights=content_highlights, 112 | keyword_highlights=keyword_highlights, 113 | ) 114 | 115 | 116 | def get_all_published_items(): 117 | return frappe.get_all( 118 | "Website Item", filters={"variant_of": "", "published": 1}, pluck="item_code" 119 | ) 120 | 121 | 122 | def update_index_for_path(path): 123 | search = ProductSearch(INDEX_NAME) 124 | return search.update_index_by_name(path) 125 | 126 | 127 | def remove_document_from_index(path): 128 | search = ProductSearch(INDEX_NAME) 129 | return search.remove_document_from_index(path) 130 | 131 | 132 | def build_index_for_all_routes(): 133 | search = ProductSearch(INDEX_NAME) 134 | return search.build() 135 | -------------------------------------------------------------------------------- /webshop/webshop/product_data_engine/filters.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors 2 | # License: GNU General Public License v3. See license.txt 3 | import frappe 4 | from frappe.utils import floor 5 | 6 | 7 | class ProductFiltersBuilder: 8 | def __init__(self, item_group=None): 9 | if not item_group: 10 | self.doc = frappe.get_doc("Webshop Settings") 11 | else: 12 | self.doc = frappe.get_doc("Item Group", item_group) 13 | 14 | self.item_group = item_group 15 | 16 | def get_field_filters(self): 17 | from webshop.webshop.doctype.override_doctype.item_group import get_child_groups_for_website 18 | 19 | if not self.item_group and not self.doc.enable_field_filters: 20 | return 21 | 22 | fields, filter_data = [], [] 23 | filter_fields = [row.fieldname for row in self.doc.filter_fields] # fields in settings 24 | 25 | # filter valid field filters i.e. those that exist in Website Item 26 | web_item_meta = frappe.get_meta("Website Item", cached=True) 27 | fields = [ 28 | web_item_meta.get_field(field) for field in filter_fields if web_item_meta.has_field(field) 29 | ] 30 | 31 | for df in fields: 32 | item_filters, item_or_filters = {"published": 1}, [] 33 | link_doctype_values = self.get_filtered_link_doctype_records(df) 34 | 35 | if df.fieldtype == "Link": 36 | if self.item_group: 37 | include_child = frappe.db.get_value("Item Group", self.item_group, "include_descendants") 38 | if include_child: 39 | include_groups = get_child_groups_for_website(self.item_group, include_self=True) 40 | include_groups = [x.name for x in include_groups] 41 | item_or_filters.extend( 42 | [ 43 | ["item_group", "in", include_groups], 44 | ["Website Item Group", "item_group", "=", self.item_group], # consider website item groups 45 | ] 46 | ) 47 | else: 48 | item_or_filters.extend( 49 | [ 50 | ["item_group", "=", self.item_group], 51 | ["Website Item Group", "item_group", "=", self.item_group], # consider website item groups 52 | ] 53 | ) 54 | 55 | # exclude variants if mentioned in settings 56 | if frappe.db.get_single_value("Webshop Settings", "hide_variants"): 57 | item_filters["variant_of"] = ["is", "not set"] 58 | 59 | # Get link field values attached to published items 60 | item_values = frappe.get_all( 61 | "Website Item", 62 | fields=[df.fieldname], 63 | filters=item_filters, 64 | or_filters=item_or_filters, 65 | distinct="True", 66 | pluck=df.fieldname, 67 | ) 68 | 69 | values = list(set(item_values) & link_doctype_values) # intersection of both 70 | else: 71 | # table multiselect 72 | values = list(link_doctype_values) 73 | 74 | # Remove None 75 | if None in values: 76 | values.remove(None) 77 | 78 | if values: 79 | filter_data.append([df, values]) 80 | 81 | return filter_data 82 | 83 | def get_filtered_link_doctype_records(self, field): 84 | """ 85 | Get valid link doctype records depending on filters. 86 | Apply enable/disable/show_in_website filter. 87 | Returns: 88 | set: A set containing valid record names 89 | """ 90 | link_doctype = field.get_link_doctype() 91 | meta = frappe.get_meta(link_doctype, cached=True) if link_doctype else None 92 | if meta: 93 | filters = self.get_link_doctype_filters(meta) 94 | link_doctype_values = set(d.name for d in frappe.get_all(link_doctype, filters)) 95 | 96 | return link_doctype_values if meta else set() 97 | 98 | def get_link_doctype_filters(self, meta): 99 | "Filters for Link Doctype eg. 'show_in_website'." 100 | filters = {} 101 | if not meta: 102 | return filters 103 | 104 | if meta.has_field("enabled"): 105 | filters["enabled"] = 1 106 | if meta.has_field("disabled"): 107 | filters["disabled"] = 0 108 | if meta.has_field("show_in_website"): 109 | filters["show_in_website"] = 1 110 | 111 | return filters 112 | 113 | def get_attribute_filters(self): 114 | if not self.item_group and not self.doc.enable_attribute_filters: 115 | return 116 | 117 | attributes = [row.attribute for row in self.doc.filter_attributes] 118 | 119 | if not attributes: 120 | return [] 121 | 122 | result = frappe.get_all( 123 | "Item Variant Attribute", 124 | filters={"attribute": ["in", attributes], "attribute_value": ["is", "set"]}, 125 | fields=["attribute", "attribute_value"], 126 | distinct=True, 127 | ) 128 | 129 | attribute_value_map = {} 130 | for d in result: 131 | attribute_value_map.setdefault(d.attribute, []).append(d.attribute_value) 132 | 133 | out = [] 134 | for attribute in attributes: 135 | if attribute not in attribute_value_map: 136 | continue 137 | 138 | values = attribute_value_map[attribute] 139 | out.append(frappe._dict(name=attribute, item_attribute_values=values)) 140 | 141 | return out 142 | 143 | def get_discount_filters(self, discounts): 144 | discount_filters = [] 145 | 146 | # [25.89, 60.5] min max 147 | min_discount, max_discount = discounts[0], discounts[1] 148 | # [25, 60] rounded min max 149 | min_range_absolute, max_range_absolute = floor(min_discount), floor(max_discount) 150 | 151 | min_range = int(min_discount - (min_range_absolute % 10)) # 20 152 | max_range = int(max_discount - (max_range_absolute % 10)) # 60 153 | 154 | min_range = ( 155 | (min_range + 10) if min_range != min_range_absolute else min_range 156 | ) # 30 (upper limit of 25.89 in range of 10) 157 | max_range = (max_range + 10) if max_range != max_range_absolute else max_range # 60 158 | 159 | for discount in range(min_range, (max_range + 1), 10): 160 | label = f"{discount}% and below" 161 | discount_filters.append([discount, label]) 162 | 163 | return discount_filters 164 | -------------------------------------------------------------------------------- /webshop/webshop/shopping_cart/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/shopping_cart/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/shopping_cart/product_info.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors 2 | # License: GNU General Public License v3. See license.txt 3 | 4 | import frappe 5 | 6 | from webshop.webshop.doctype.webshop_settings.webshop_settings import ( 7 | get_shopping_cart_settings, 8 | show_quantity_in_website, 9 | ) 10 | from webshop.webshop.shopping_cart.cart import _get_cart_quotation, _set_price_list 11 | from erpnext.utilities.product import (get_price) 12 | from webshop.webshop.utils.product import (get_non_stock_item_status, get_web_item_qty_in_stock) 13 | from webshop.webshop.shopping_cart.cart import get_party 14 | 15 | 16 | @frappe.whitelist(allow_guest=True) 17 | def get_product_info_for_website(item_code, skip_quotation_creation=False): 18 | """ 19 | Get product price / stock info for website 20 | """ 21 | 22 | cart_settings = get_shopping_cart_settings() 23 | if not cart_settings.enabled: 24 | # return settings even if cart is disabled 25 | return frappe._dict({"product_info": {}, "cart_settings": cart_settings}) 26 | 27 | cart_quotation = frappe._dict() 28 | if not skip_quotation_creation: 29 | cart_quotation = _get_cart_quotation() 30 | 31 | selling_price_list = ( 32 | cart_quotation.get("selling_price_list") 33 | if cart_quotation 34 | else _set_price_list(cart_settings, None) 35 | ) 36 | 37 | price = {} 38 | if cart_settings.show_price: 39 | is_guest = frappe.session.user == "Guest" 40 | party = get_party() 41 | 42 | # Show Price if logged in. 43 | # If not logged in, check if price is hidden for guest. 44 | if not is_guest or not cart_settings.hide_price_for_guest: 45 | price = get_price( 46 | item_code, 47 | selling_price_list, 48 | cart_settings.default_customer_group, 49 | cart_settings.company, 50 | party=party, 51 | ) 52 | 53 | stock_status = None 54 | 55 | if cart_settings.show_stock_availability: 56 | on_backorder = frappe.get_cached_value( 57 | "Website Item", {"item_code": item_code}, "on_backorder" 58 | ) 59 | if on_backorder: 60 | stock_status = frappe._dict({"on_backorder": True}) 61 | else: 62 | stock_status = get_web_item_qty_in_stock(item_code, "website_warehouse") 63 | 64 | product_info = { 65 | "price": price, 66 | "qty": 0, 67 | "uom": frappe.db.get_value("Item", item_code, "stock_uom"), 68 | "sales_uom": frappe.db.get_value("Item", item_code, "sales_uom"), 69 | } 70 | 71 | if stock_status: 72 | if stock_status.on_backorder: 73 | product_info["on_backorder"] = True 74 | else: 75 | product_info["stock_qty"] = stock_status.stock_qty 76 | product_info["in_stock"] = ( 77 | stock_status.in_stock 78 | if stock_status.is_stock_item 79 | else get_non_stock_item_status(item_code, "website_warehouse") 80 | ) 81 | product_info["show_stock_qty"] = show_quantity_in_website() 82 | 83 | if product_info["price"]: 84 | if frappe.session.user != "Guest": 85 | item = ( 86 | cart_quotation.get({"item_code": item_code}) if cart_quotation else None 87 | ) 88 | if item: 89 | product_info["qty"] = item[0].qty 90 | 91 | return frappe._dict({"product_info": product_info, "cart_settings": cart_settings}) 92 | 93 | 94 | def set_product_info_for_website(item): 95 | """set product price uom for website""" 96 | product_info = get_product_info_for_website( 97 | item.item_code, skip_quotation_creation=True 98 | ).get("product_info") 99 | 100 | if product_info: 101 | item.update(product_info) 102 | item["stock_uom"] = product_info.get("uom") 103 | item["sales_uom"] = product_info.get("sales_uom") 104 | if product_info.get("price"): 105 | item["price_stock_uom"] = product_info.get("price").get("formatted_price") 106 | item["price_sales_uom"] = product_info.get("price").get( 107 | "formatted_price_sales_uom" 108 | ) 109 | else: 110 | item["price_stock_uom"] = "" 111 | item["price_sales_uom"] = "" 112 | -------------------------------------------------------------------------------- /webshop/webshop/shopping_cart/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors 2 | # License: GNU General Public License v3. See license.txt 3 | import frappe 4 | 5 | from webshop.webshop.doctype.webshop_settings.webshop_settings import is_cart_enabled 6 | 7 | 8 | def show_cart_count(): 9 | if ( 10 | is_cart_enabled() 11 | and frappe.db.get_value("User", frappe.session.user, "user_type") == "Website User" 12 | ): 13 | return True 14 | 15 | return False 16 | 17 | 18 | def set_cart_count(login_manager): 19 | # since this is run only on hooks login event 20 | # make sure user is already a customer 21 | # before trying to set cart count 22 | user_is_customer = is_customer() 23 | if not user_is_customer: 24 | return 25 | 26 | if show_cart_count(): 27 | from webshop.webshop.shopping_cart.cart import set_cart_count 28 | 29 | # set_cart_count will try to fetch existing cart quotation 30 | # or create one if non existent (and create a customer too) 31 | # cart count is calculated from this quotation's items 32 | set_cart_count() 33 | 34 | 35 | def clear_cart_count(login_manager): 36 | if show_cart_count(): 37 | frappe.local.cookie_manager.delete_cookie("cart_count") 38 | 39 | 40 | def update_website_context(context): 41 | cart_enabled = is_cart_enabled() 42 | context["shopping_cart_enabled"] = cart_enabled 43 | 44 | 45 | def is_customer(): 46 | if frappe.session.user and frappe.session.user != "Guest": 47 | contact_name = frappe.get_value("Contact", {"email_id": frappe.session.user}) 48 | if contact_name: 49 | contact = frappe.get_doc("Contact", contact_name) 50 | for link in contact.links: 51 | if link.link_doctype == "Customer": 52 | return True 53 | 54 | return False 55 | -------------------------------------------------------------------------------- /webshop/webshop/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/utils/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/utils/portal.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils.nestedset import get_root_of 3 | 4 | from erpnext.portal.utils import create_customer_or_supplier 5 | 6 | from webshop.webshop.doctype.webshop_settings.webshop_settings import ( 7 | get_shopping_cart_settings, 8 | ) 9 | from webshop.webshop.shopping_cart.cart import get_debtors_account 10 | 11 | 12 | def update_debtors_account(): 13 | doc_type = debtors_account = None 14 | user = frappe.session.user 15 | 16 | if frappe.db.get_value("User", user, "user_type") != "Website User": 17 | return 18 | 19 | user_roles = frappe.get_roles() 20 | portal_settings = frappe.get_single("Portal Settings") 21 | default_role = portal_settings.default_role 22 | 23 | if default_role not in ["Customer", "Supplier"]: 24 | return 25 | 26 | if portal_settings.default_role and portal_settings.default_role in user_roles: 27 | doc_type = portal_settings.default_role 28 | 29 | if not doc_type: 30 | return 31 | 32 | if doc_type != "Customer": 33 | return 34 | 35 | if frappe.db.exists(doc_type, user): 36 | party = frappe.get_doc(doc_type, user) 37 | else: 38 | party = create_customer_or_supplier() 39 | 40 | if not party: 41 | return 42 | 43 | fullname = frappe.utils.get_fullname(user) 44 | cart_settings = get_shopping_cart_settings() 45 | 46 | party.update( 47 | { 48 | "customer_name": fullname, 49 | "customer_type": "Individual", 50 | "customer_group": cart_settings.default_customer_group, 51 | "territory": get_root_of("Territory"), 52 | } 53 | ) 54 | 55 | if cart_settings.enable_checkout: 56 | debtors_account = get_debtors_account(cart_settings) 57 | 58 | if not debtors_account: 59 | return party 60 | 61 | party.update( 62 | {"accounts": [{"company": cart_settings.company, "account": debtors_account}]} 63 | ) 64 | 65 | return party 66 | -------------------------------------------------------------------------------- /webshop/webshop/utils/product.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils import getdate, nowdate 3 | 4 | from erpnext.stock.doctype.batch.batch import get_batch_qty 5 | from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses 6 | 7 | 8 | def get_web_item_qty_in_stock(item_code, item_warehouse_field, warehouse=None): 9 | in_stock, stock_qty = 0, "" 10 | template_item_code, is_stock_item = frappe.db.get_value( 11 | "Item", item_code, ["variant_of", "is_stock_item"] 12 | ) 13 | 14 | if not warehouse: 15 | warehouse = frappe.db.get_value("Website Item", {"item_code": item_code}, item_warehouse_field) 16 | 17 | if not warehouse and template_item_code and template_item_code != item_code: 18 | warehouse = frappe.db.get_value( 19 | "Website Item", {"item_code": template_item_code}, item_warehouse_field 20 | ) 21 | 22 | if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: 23 | warehouses = get_child_warehouses(warehouse) 24 | else: 25 | warehouses = [warehouse] if warehouse else [] 26 | 27 | total_stock = 0.0 28 | if warehouses: 29 | for warehouse in warehouses: 30 | stock_qty = frappe.db.sql( 31 | """ 32 | select S.actual_qty / IFNULL(C.conversion_factor, 1) 33 | from tabBin S 34 | inner join `tabItem` I on S.item_code = I.Item_code 35 | left join `tabUOM Conversion Detail` C on I.sales_uom = C.uom and C.parent = I.Item_code 36 | where S.item_code=%s and S.warehouse=%s""", 37 | (item_code, warehouse), 38 | ) 39 | 40 | if stock_qty: 41 | total_stock += adjust_qty_for_expired_items(item_code, stock_qty, warehouse) 42 | 43 | in_stock = total_stock > 0 and 1 or 0 44 | 45 | return frappe._dict( 46 | {"in_stock": in_stock, "stock_qty": total_stock, "is_stock_item": is_stock_item} 47 | ) 48 | 49 | 50 | def adjust_qty_for_expired_items(item_code, stock_qty, warehouse): 51 | batches = frappe.get_all("Batch", filters=[{"item": item_code}], fields=["expiry_date", "name"]) 52 | expired_batches = get_expired_batches(batches) 53 | stock_qty = [list(item) for item in stock_qty] 54 | 55 | for batch in expired_batches: 56 | if warehouse: 57 | stock_qty[0][0] = max(0, stock_qty[0][0] - get_batch_qty(batch, warehouse)) 58 | else: 59 | stock_qty[0][0] = max(0, stock_qty[0][0] - qty_from_all_warehouses(get_batch_qty(batch))) 60 | 61 | if not stock_qty[0][0]: 62 | break 63 | 64 | return stock_qty[0][0] if stock_qty else 0 65 | 66 | 67 | def get_expired_batches(batches): 68 | """ 69 | :param batches: A list of dict in the form [{'expiry_date': datetime.date(20XX, 1, 1), 'name': 'batch_id'}, ...] 70 | """ 71 | return [b.name for b in batches if b.expiry_date and b.expiry_date <= getdate(nowdate())] 72 | 73 | 74 | def qty_from_all_warehouses(batch_info): 75 | """ 76 | :param batch_info: A list of dict in the form [{u'warehouse': u'Stores - I', u'qty': 0.8}, ...] 77 | """ 78 | qty = 0 79 | for batch in batch_info: 80 | qty = qty + batch.qty 81 | 82 | return qty 83 | 84 | 85 | def get_non_stock_item_status(item_code, item_warehouse_field): 86 | # if item is a product bundle, check if its bundle items are in stock 87 | if frappe.db.exists("Product Bundle", item_code): 88 | items = frappe.get_doc("Product Bundle", item_code).get_all_children() 89 | bundle_warehouse = frappe.db.get_value( 90 | "Website Item", {"item_code": item_code}, item_warehouse_field 91 | ) 92 | return all( 93 | get_web_item_qty_in_stock(d.item_code, item_warehouse_field, bundle_warehouse).in_stock 94 | for d in items 95 | ) 96 | else: 97 | return 1 98 | -------------------------------------------------------------------------------- /webshop/webshop/utils/setup.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | def has_ecommerce_fields() -> bool: 4 | table = frappe.qb.Table("tabSingles") 5 | query = ( 6 | frappe.qb.from_(table) 7 | .select(table.field) 8 | .where(table.doctype == "E Commerce Settings") 9 | .limit(1) 10 | ) 11 | 12 | data = query.run(as_dict=True) 13 | return bool(data) -------------------------------------------------------------------------------- /webshop/webshop/variant_selector/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/variant_selector/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/variant_selector/item_variants_cache.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | 4 | class ItemVariantsCacheManager: 5 | def __init__(self, item_code): 6 | self.item_code = item_code 7 | 8 | def get_item_variants_data(self): 9 | val = frappe.cache().hget("item_variants_data", self.item_code) 10 | 11 | if not val: 12 | self.build_cache() 13 | 14 | return frappe.cache().hget("item_variants_data", self.item_code) 15 | 16 | def get_attribute_value_item_map(self): 17 | val = frappe.cache().hget("attribute_value_item_map", self.item_code) 18 | 19 | if not val: 20 | self.build_cache() 21 | 22 | return frappe.cache().hget("attribute_value_item_map", self.item_code) 23 | 24 | def get_item_attribute_value_map(self): 25 | val = frappe.cache().hget("item_attribute_value_map", self.item_code) 26 | 27 | if not val: 28 | self.build_cache() 29 | 30 | return frappe.cache().hget("item_attribute_value_map", self.item_code) 31 | 32 | def get_optional_attributes(self): 33 | val = frappe.cache().hget("optional_attributes", self.item_code) 34 | 35 | if not val: 36 | self.build_cache() 37 | 38 | return frappe.cache().hget("optional_attributes", self.item_code) 39 | 40 | def get_ordered_attribute_values(self): 41 | val = frappe.cache().get_value("ordered_attribute_values_map") 42 | if val: 43 | return val 44 | 45 | all_attribute_values = frappe.get_all( 46 | "Item Attribute Value", ["attribute_value", "idx", "parent"], order_by="idx asc" 47 | ) 48 | 49 | ordered_attribute_values_map = frappe._dict({}) 50 | for d in all_attribute_values: 51 | ordered_attribute_values_map.setdefault(d.parent, []).append(d.attribute_value) 52 | 53 | frappe.cache().set_value("ordered_attribute_values_map", ordered_attribute_values_map) 54 | return ordered_attribute_values_map 55 | 56 | def build_cache(self): 57 | parent_item_code = self.item_code 58 | 59 | attributes = [ 60 | a.attribute 61 | for a in frappe.get_all( 62 | "Item Variant Attribute", {"parent": parent_item_code}, ["attribute"], order_by="idx asc" 63 | ) 64 | ] 65 | 66 | # Get Variants and tehir Attributes that are not disabled 67 | iva = frappe.qb.DocType("Item Variant Attribute") 68 | item = frappe.qb.DocType("Item") 69 | query = ( 70 | frappe.qb.from_(iva) 71 | .join(item) 72 | .on(item.name == iva.parent) 73 | .select(iva.parent, iva.attribute, iva.attribute_value) 74 | .where((iva.variant_of == parent_item_code) & (item.disabled == 0)) 75 | .orderby(iva.name) 76 | ) 77 | item_variants_data = query.run() 78 | 79 | attribute_value_item_map = frappe._dict() 80 | item_attribute_value_map = frappe._dict() 81 | 82 | for row in item_variants_data: 83 | item_code, attribute, attribute_value = row 84 | # (attr, value) => [item1, item2] 85 | attribute_value_item_map.setdefault((attribute, attribute_value), []).append(item_code) 86 | # item => {attr1: value1, attr2: value2} 87 | item_attribute_value_map.setdefault(item_code, {})[attribute] = attribute_value 88 | 89 | optional_attributes = set() 90 | for item_code, attr_dict in item_attribute_value_map.items(): 91 | for attribute in attributes: 92 | if attribute not in attr_dict: 93 | optional_attributes.add(attribute) 94 | 95 | frappe.cache().hset("attribute_value_item_map", parent_item_code, attribute_value_item_map) 96 | frappe.cache().hset("item_attribute_value_map", parent_item_code, item_attribute_value_map) 97 | frappe.cache().hset("item_variants_data", parent_item_code, item_variants_data) 98 | frappe.cache().hset("optional_attributes", parent_item_code, optional_attributes) 99 | 100 | def clear_cache(self): 101 | keys = [ 102 | "attribute_value_item_map", 103 | "item_attribute_value_map", 104 | "item_variants_data", 105 | "optional_attributes", 106 | ] 107 | 108 | for key in keys: 109 | frappe.cache().hdel(key, self.item_code) 110 | 111 | def rebuild_cache(self): 112 | self.clear_cache() 113 | enqueue_build_cache(self.item_code) 114 | 115 | 116 | def build_cache(item_code): 117 | frappe.cache().hset("item_cache_build_in_progress", item_code, 1) 118 | i = ItemVariantsCacheManager(item_code) 119 | i.build_cache() 120 | frappe.cache().hset("item_cache_build_in_progress", item_code, 0) 121 | 122 | 123 | def enqueue_build_cache(item_code): 124 | if frappe.cache().hget("item_cache_build_in_progress", item_code): 125 | return 126 | frappe.enqueue( 127 | "webshop.webshop.variant_selector.item_variants_cache.build_cache", 128 | item_code=item_code, 129 | queue="long", 130 | ) 131 | -------------------------------------------------------------------------------- /webshop/webshop/variant_selector/test_variant_selector.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.tests.utils import FrappeTestCase 3 | 4 | from erpnext.controllers.item_variant import create_variant 5 | from webshop.webshop.doctype.webshop_settings.test_webshop_settings import ( 6 | setup_webshop_settings, 7 | ) 8 | from webshop.webshop.doctype.website_item.website_item import make_website_item 9 | from webshop.webshop.variant_selector.utils import get_next_attribute_and_values 10 | from erpnext.stock.doctype.item.test_item import make_item 11 | 12 | test_dependencies = ["Item"] 13 | 14 | 15 | class TestVariantSelector(FrappeTestCase): 16 | @classmethod 17 | def setUpClass(cls): 18 | super().setUpClass() 19 | template_item = make_item( 20 | "Test-Tshirt-Temp", 21 | { 22 | "has_variant": 1, 23 | "variant_based_on": "Item Attribute", 24 | "attributes": [{"attribute": "Test Size"}, {"attribute": "Test Colour"}], 25 | }, 26 | ) 27 | 28 | # create L-R, L-G, M-R, M-G and S-R 29 | for size in ( 30 | "Large", 31 | "Medium", 32 | ): 33 | for colour in ( 34 | "Red", 35 | "Green", 36 | ): 37 | variant = create_variant("Test-Tshirt-Temp", {"Test Size": size, "Test Colour": colour}) 38 | variant.save() 39 | 40 | variant = create_variant("Test-Tshirt-Temp", {"Test Size": "Small", "Test Colour": "Red"}) 41 | variant.save() 42 | 43 | make_website_item(template_item) # publish template not variants 44 | 45 | def test_item_attributes(self): 46 | """ 47 | Test if the right attributes are fetched in the popup. 48 | (Attributes must only come from active items) 49 | 50 | Attribute selection must not be linked to Website Items. 51 | """ 52 | from webshop.webshop.variant_selector.utils import get_attributes_and_values 53 | 54 | attr_data = get_attributes_and_values("Test-Tshirt-Temp") 55 | 56 | self.assertEqual(attr_data[0]["attribute"], "Test Size") 57 | self.assertEqual(attr_data[1]["attribute"], "Test Colour") 58 | self.assertEqual(len(attr_data[0]["values"]), 3) # ['Small', 'Medium', 'Large'] 59 | self.assertEqual(len(attr_data[1]["values"]), 2) # ['Red', 'Green'] 60 | 61 | # disable small red tshirt, now there are no small tshirts. 62 | # but there are some red tshirts 63 | small_variant = frappe.get_doc("Item", "Test-Tshirt-Temp-S-R") 64 | small_variant.disabled = 1 65 | small_variant.save() # trigger cache rebuild 66 | 67 | attr_data = get_attributes_and_values("Test-Tshirt-Temp") 68 | 69 | # Only L and M attribute values must be fetched since S is disabled 70 | self.assertEqual(len(attr_data[0]["values"]), 2) # ['Medium', 'Large'] 71 | 72 | # teardown 73 | small_variant.disabled = 0 74 | small_variant.save() 75 | 76 | def test_next_item_variant_values(self): 77 | """ 78 | Test if on selecting an attribute value, the next possible values 79 | are filtered accordingly. 80 | Values that dont apply should not be fetched. 81 | E.g. 82 | There is a ** Small-Red ** Tshirt. No other colour in this size. 83 | On selecting ** Small **, only ** Red ** should be selectable next. 84 | """ 85 | next_values = get_next_attribute_and_values( 86 | "Test-Tshirt-Temp", selected_attributes={"Test Size": "Small"} 87 | ) 88 | next_colours = next_values["valid_options_for_attributes"]["Test Colour"] 89 | filtered_items = next_values["filtered_items"] 90 | 91 | self.assertEqual(len(next_colours), 1) 92 | self.assertEqual(next_colours.pop(), "Red") 93 | self.assertEqual(len(filtered_items), 1) 94 | self.assertEqual(filtered_items.pop(), "Test-Tshirt-Temp-S-R") 95 | 96 | def test_exact_match_with_price(self): 97 | """ 98 | Test price fetching and matching of variant without Website Item 99 | """ 100 | from webshop.webshop.doctype.website_item.test_website_item import make_web_item_price 101 | 102 | frappe.set_user("Administrator") 103 | setup_webshop_settings( 104 | { 105 | "company": "_Test Company", 106 | "enabled": 1, 107 | "default_customer_group": "_Test Customer Group", 108 | "price_list": "_Test Price List India", 109 | "show_price": 1, 110 | } 111 | ) 112 | 113 | make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100) 114 | 115 | frappe.local.shopping_cart_settings = None # clear cached settings values 116 | next_values = get_next_attribute_and_values( 117 | "Test-Tshirt-Temp", selected_attributes={"Test Size": "Small", "Test Colour": "Red"} 118 | ) 119 | print(">>>>", next_values) 120 | price_info = next_values["product_info"]["price"] 121 | 122 | self.assertEqual(next_values["exact_match"][0], "Test-Tshirt-Temp-S-R") 123 | self.assertEqual(next_values["exact_match"][0], "Test-Tshirt-Temp-S-R") 124 | self.assertEqual(price_info["price_list_rate"], 100.0) 125 | self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00") 126 | -------------------------------------------------------------------------------- /webshop/webshop/web_template/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/web_template/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/web_template/hero_slider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/web_template/hero_slider/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/web_template/hero_slider/hero_slider.html: -------------------------------------------------------------------------------- 1 | {%- macro slide(image, title, subtitle, action, label, index, align="Left", theme="Dark") -%} 2 | {%- set align_class = resolve_class({ 3 | 'text-right': align == 'Right', 4 | 'text-centre': align == 'Centre', 5 | 'text-left': align == 'Left', 6 | }) -%} 7 | 8 | {%- set heading_class = resolve_class({ 9 | 'text-white': theme == 'Dark', 10 | '': theme == 'Light', 11 | }) -%} 12 | 28 | {%- endmacro -%} 29 | 30 | {%- set hero_slider_id = 'id-' + frappe.utils.generate_hash('HeroSlider', 12) -%} 31 | 32 | 77 | 78 | 87 | -------------------------------------------------------------------------------- /webshop/webshop/web_template/item_card_group/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/web_template/item_card_group/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/web_template/item_card_group/item_card_group.html: -------------------------------------------------------------------------------- 1 | {% from "webshop/templates/includes/macros.html" import item_card, item_card_body %} 2 | 3 |
4 |
5 |
6 | {%- if title -%} 7 |

{{ title }}

8 | {%- endif -%} 9 | {%- if subtitle -%} 10 |

{{ subtitle }}

11 | {%- endif -%} 12 |
13 |
14 | {%- if primary_action -%} 15 | 16 | {{ primary_action_label }} 17 | 18 | {%- endif -%} 19 |
20 |
21 | 22 |
23 | {%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%} 24 | {%- set item = values['card_' + index + '_item'] -%} 25 | {%- if item -%} 26 | {%- set web_item = frappe.get_doc("Website Item", item) -%} 27 | {{ item_card( 28 | web_item, is_featured=values['card_' + index + '_featured'], 29 | is_full_width=True, align="Center" 30 | ) }} 31 | {%- endif -%} 32 | {%- endfor -%} 33 |
34 |
35 | 36 | 38 | -------------------------------------------------------------------------------- /webshop/webshop/web_template/product_card/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/web_template/product_card/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/web_template/product_card/product_card.html: -------------------------------------------------------------------------------- 1 | {%- from "webshop/templates/includes/macros.html" import item_card -%} 2 | 3 |
4 |
5 |
6 | {%- set item_doc = frappe.get_doc("Website Item", values["item"]) -%} 7 | {{ item_card(item=item_doc, is_featured=values["featured"], is_full_width=True, align="Center", template="Product Card") }} 8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /webshop/webshop/web_template/product_card/product_card.json: -------------------------------------------------------------------------------- 1 | { 2 | "__unsaved": 1, 3 | "creation": "2020-11-17 15:28:47.809342", 4 | "docstatus": 0, 5 | "doctype": "Web Template", 6 | "fields": [ 7 | { 8 | "fieldname": "item", 9 | "fieldtype": "Link", 10 | "label": "Item", 11 | "options": "Website Item", 12 | "reqd": 0 13 | }, 14 | { 15 | "fieldname": "featured", 16 | "fieldtype": "Check", 17 | "label": "Featured", 18 | "options": "", 19 | "reqd": 0 20 | } 21 | ], 22 | "idx": 0, 23 | "modified": "2024-06-17 15:15:09.902754", 24 | "modified_by": "Administrator", 25 | "module": "Webshop", 26 | "name": "Product Card", 27 | "owner": "Administrator", 28 | "standard": 1, 29 | "template": "", 30 | "type": "Component" 31 | } -------------------------------------------------------------------------------- /webshop/webshop/web_template/product_category_cards/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/webshop/web_template/product_category_cards/__init__.py -------------------------------------------------------------------------------- /webshop/webshop/web_template/product_category_cards/product_category_cards.html: -------------------------------------------------------------------------------- 1 | {%- macro card(title, image, url, text_primary=False) -%} 2 | {%- set align_class = resolve_class({ 3 | 'text-right': text_primary, 4 | 'text-centre': align == 'Center', 5 | 'text-left': align == 'Left', 6 | }) -%} 7 |
8 | {% if image %} 9 | {{ title }} 10 | {% else %} 11 |
12 | 13 | {{ frappe.utils.get_abbr(title or '') }} 14 | 15 |
16 | {% endif %} 17 | 18 |
19 | {{ title or '' }} 20 |
21 | 22 |
23 | {%- endmacro -%} 24 | 25 |
26 | {%- if title -%} 27 |

{{ title }}

28 | {%- endif -%} 29 | {%- if subtitle -%} 30 |

{{ subtitle }}

31 | {%- endif -%} 32 | 33 |
34 |
35 | {%- for index in ['1', '2', '3', '4', '5', '6', '7', '8'] -%} 36 | {%- set category = values['category_' + index] -%} 37 | {%- if category -%} 38 | {%- set category = frappe.get_doc("Item Group", category) -%} 39 | {{ card(category.name, category.image, category.route) }} 40 | {%- endif -%} 41 | {%- endfor -%} 42 |
43 |
44 |
45 | 46 | 48 | -------------------------------------------------------------------------------- /webshop/webshop/web_template/product_category_cards/product_category_cards.json: -------------------------------------------------------------------------------- 1 | { 2 | "__unsaved": 1, 3 | "creation": "2020-11-17 15:25:50.855934", 4 | "docstatus": 0, 5 | "doctype": "Web Template", 6 | "fields": [ 7 | { 8 | "fieldname": "title", 9 | "fieldtype": "Data", 10 | "label": "Title", 11 | "reqd": 1 12 | }, 13 | { 14 | "fieldname": "subtitle", 15 | "fieldtype": "Data", 16 | "label": "Subtitle", 17 | "reqd": 0 18 | }, 19 | { 20 | "fieldname": "category_1", 21 | "fieldtype": "Link", 22 | "label": "Item Group", 23 | "options": "Item Group", 24 | "reqd": 0 25 | }, 26 | { 27 | "fieldname": "category_2", 28 | "fieldtype": "Link", 29 | "label": "Item Group", 30 | "options": "Item Group", 31 | "reqd": 0 32 | }, 33 | { 34 | "fieldname": "category_3", 35 | "fieldtype": "Link", 36 | "label": "Item Group", 37 | "options": "Item Group", 38 | "reqd": 0 39 | }, 40 | { 41 | "fieldname": "category_4", 42 | "fieldtype": "Link", 43 | "label": "Item Group", 44 | "options": "Item Group", 45 | "reqd": 0 46 | }, 47 | { 48 | "fieldname": "category_5", 49 | "fieldtype": "Link", 50 | "label": "Item Group", 51 | "options": "Item Group", 52 | "reqd": 0 53 | }, 54 | { 55 | "fieldname": "category_6", 56 | "fieldtype": "Link", 57 | "label": "Item Group", 58 | "options": "Item Group", 59 | "reqd": 0 60 | }, 61 | { 62 | "fieldname": "category_7", 63 | "fieldtype": "Link", 64 | "label": "Item Group", 65 | "options": "Item Group", 66 | "reqd": 0 67 | }, 68 | { 69 | "fieldname": "category_8", 70 | "fieldtype": "Link", 71 | "label": "Item Group", 72 | "options": "Item Group", 73 | "reqd": 0 74 | } 75 | ], 76 | "idx": 0, 77 | "modified": "2023-10-16 18:02:24.342186", 78 | "modified_by": "Administrator", 79 | "module": "Webshop", 80 | "name": "Product Category Cards", 81 | "owner": "Administrator", 82 | "standard": 1, 83 | "template": "", 84 | "type": "Section" 85 | } -------------------------------------------------------------------------------- /webshop/www/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/www/__init__.py -------------------------------------------------------------------------------- /webshop/www/all-products/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/www/all-products/__init__.py -------------------------------------------------------------------------------- /webshop/www/all-products/index.html: -------------------------------------------------------------------------------- 1 | {% from "webshop/templates/includes/macros.html" import attribute_filter_section, field_filter_section, discount_range_filters %} 2 | {% extends "templates/web.html" %} 3 | 4 | {% block title %}{{ _("All Products") }}{% endblock %} 5 | {% block header %} 6 |
{{ _("All Products") }}
7 | {% endblock header %} 8 | 9 | {% block page_content %} 10 |
11 | 12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 |
20 |
{{ _('Filters') }}
21 | {{ _('Clear All') }} 22 |
23 | 24 | {% if field_filters %} 25 | {{ _(field_filter_section(field_filters)) }} 26 | {% endif %} 27 | 28 | 29 | {% if attribute_filters %} 30 | {{ _(attribute_filter_section(attribute_filters)) }} 31 | {% endif %} 32 |
33 | 34 |
35 |
36 | 37 | 50 | 51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /webshop/www/all-products/index.js: -------------------------------------------------------------------------------- 1 | $(() => { 2 | class ProductListing { 3 | constructor() { 4 | let me = this; 5 | let is_item_group_page = $(".item-group-content").data("item-group"); 6 | this.item_group = is_item_group_page || null; 7 | 8 | let view_type = localStorage.getItem("product_view") || "List View"; 9 | 10 | // Render Product Views, Filters & Search 11 | new webshop.ProductView({ 12 | view_type: view_type, 13 | products_section: $('#product-listing'), 14 | item_group: me.item_group 15 | }); 16 | 17 | this.bind_card_actions(); 18 | } 19 | 20 | bind_card_actions() { 21 | webshop.webshop.shopping_cart.bind_add_to_cart_action(); 22 | webshop.webshop.wishlist.bind_wishlist_action(); 23 | } 24 | } 25 | 26 | new ProductListing(); 27 | }); 28 | -------------------------------------------------------------------------------- /webshop/www/all-products/index.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.utils import cint 3 | 4 | from webshop.webshop.product_data_engine.filters import ProductFiltersBuilder 5 | 6 | sitemap = 1 7 | 8 | 9 | def get_context(context): 10 | # Add homepage as parent 11 | context.body_class = "product-page" 12 | context.parents = [{"name": frappe._("Home"), "route": "/"}] 13 | 14 | filter_engine = ProductFiltersBuilder() 15 | context.field_filters = filter_engine.get_field_filters() 16 | context.attribute_filters = filter_engine.get_attribute_filters() 17 | 18 | context.page_length = ( 19 | cint(frappe.db.get_single_value("Webshop Settings", "products_per_page")) or 20 20 | ) 21 | 22 | context.no_cache = 1 23 | -------------------------------------------------------------------------------- /webshop/www/all-products/not_found.html: -------------------------------------------------------------------------------- 1 |
{{ _('No products found') }}
2 | -------------------------------------------------------------------------------- /webshop/www/shop-by-category/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/webshop/5a1c9e860d4c586279a3825ff05e0d4ffba6ab06/webshop/www/shop-by-category/__init__.py -------------------------------------------------------------------------------- /webshop/www/shop-by-category/category_card_section.html: -------------------------------------------------------------------------------- 1 | {%- macro card(title, image, type, url=None, text_primary=False) -%} 2 | 3 |
4 | {% if image %} 5 | {{ title }} 6 | {% else %} 7 |
8 | 9 | {{ frappe.utils.get_abbr(title) }} 10 | 11 |
12 | {% endif %} 13 |
14 | {{ title or '' }} 15 |
16 | 17 |
18 | {%- endmacro -%} 19 | 20 |
21 |
22 | {%- for row in data -%} 23 | {%- set title = row.name -%} 24 | {%- set image = row.get("image") -%} 25 | {%- if title -%} 26 | {{ card(title, image, type, row.get("route")) }} 27 | {%- endif -%} 28 | {%- endfor -%} 29 |
30 |
-------------------------------------------------------------------------------- /webshop/www/shop-by-category/index.html: -------------------------------------------------------------------------------- 1 | {% extends "templates/web.html" %} 2 | {% block title %}{{ _('Shop by Category') }}{% endblock %} 3 | 4 | {% block head_include %} 5 | 15 | {% endblock %} 16 | 17 | {% block script %} 18 | 19 | {% endblock %} 20 | 21 | {% block page_content %} 22 |
23 |
24 | {% if slideshow %} 25 | 26 | {{ web_block( 27 | "Hero Slider", 28 | values=slideshow, 29 | add_container=0, 30 | add_top_padding=0, 31 | add_bottom_padding=0, 32 | ) }} 33 | {% endif %} 34 |
35 |
36 | {% if tabs %} 37 | 38 | {{ web_block( 39 | "Section with Tabs", 40 | values=tabs, 41 | add_container=0, 42 | add_top_padding=0, 43 | add_bottom_padding=0 44 | ) }} 45 | {% endif %} 46 |
47 |
48 | {% endblock %} -------------------------------------------------------------------------------- /webshop/www/shop-by-category/index.js: -------------------------------------------------------------------------------- 1 | $(() => { 2 | $('.category-card').on('click', (e) => { 3 | let category_type = e.currentTarget.dataset.type; 4 | let category_name = e.currentTarget.dataset.name; 5 | 6 | if (category_type != "item_group") { 7 | let filters = {}; 8 | filters[category_type] = [category_name]; 9 | window.location.href = "/all-products?field_filters=" + JSON.stringify(filters); 10 | } 11 | }); 12 | }); -------------------------------------------------------------------------------- /webshop/www/shop-by-category/index.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | 4 | sitemap = 1 5 | 6 | 7 | def get_context(context): 8 | context.body_class = "product-page" 9 | 10 | settings = frappe.get_cached_doc("Webshop Settings") 11 | context.categories_enabled = settings.enable_field_filters 12 | 13 | if context.categories_enabled: 14 | categories = [row.fieldname for row in settings.filter_fields] 15 | context.tabs = get_tabs(categories) 16 | 17 | if settings.slideshow: 18 | context.slideshow = get_slideshow(settings.slideshow) 19 | 20 | context.no_cache = 1 21 | 22 | 23 | def get_slideshow(slideshow): 24 | values = {"show_indicators": 1, "show_controls": 1, "rounded": 1, "slider_name": "Categories"} 25 | slideshow = frappe.get_cached_doc("Website Slideshow", slideshow) 26 | slides = slideshow.get({"doctype": "Website Slideshow Item"}) 27 | for index, slide in enumerate(slides, start=1): 28 | values[f"slide_{index}_image"] = slide.image 29 | values[f"slide_{index}_title"] = slide.heading 30 | values[f"slide_{index}_subtitle"] = slide.description 31 | values[f"slide_{index}_theme"] = slide.get("theme") or "Light" 32 | values[f"slide_{index}_content_align"] = slide.get("content_align") or "Centre" 33 | values[f"slide_{index}_primary_action"] = slide.url 34 | 35 | return values 36 | 37 | 38 | def get_tabs(categories): 39 | tab_values = { 40 | "title": _("Shop by Category"), 41 | } 42 | 43 | categorical_data = get_category_records(categories) 44 | for index, tab in enumerate(categorical_data, start=1): 45 | tab_values[f"tab_{index + 1}_title"] = frappe.unscrub(tab) 46 | # pre-render cards for each tab 47 | tab_values[f"tab_{index + 1}_content"] = frappe.render_template( 48 | "webshop/www/shop-by-category/category_card_section.html", 49 | {"data": categorical_data[tab], "type": tab}, 50 | ) 51 | return tab_values 52 | 53 | 54 | def get_category_records(categories): 55 | categorical_data = {} 56 | website_item_meta = frappe.get_meta("Website Item", cached=True) 57 | 58 | for category in categories: 59 | if category == "item_group": 60 | categorical_data["item_group"] = frappe.db.get_all( 61 | "Item Group", 62 | filters={"show_in_website": 1}, 63 | fields=["name", "parent_item_group", "is_group", "image", "route"], 64 | ) 65 | else: 66 | field_type = website_item_meta.get_field(category).fieldtype 67 | 68 | if field_type == "Table MultiSelect": 69 | child_doc = website_item_meta.get_field(category).options 70 | for field in frappe.get_meta(child_doc, cached=True).fields: 71 | if field.fieldtype == "Link" and field.reqd: 72 | doctype = field.options 73 | else: 74 | doctype = website_item_meta.get_field(category).options 75 | 76 | fields = ["name"] 77 | 78 | meta = frappe.get_meta(doctype, cached=True) 79 | if meta.get_field("image"): 80 | fields += ["image"] 81 | 82 | filters = {} 83 | if meta.get_field("show_in_website"): 84 | filters = {"show_in_website": 1} 85 | 86 | elif meta.get_field("custom_show_in_website"): 87 | filters = {"custom_show_in_website": 1} 88 | 89 | try: 90 | if filters: 91 | categorical_data[category] = frappe.db.get_all(doctype, fields=fields, filters=filters) 92 | else: 93 | categorical_data[category] = frappe.db.get_all(doctype, fields=fields) 94 | 95 | except BaseException: 96 | frappe.throw(_("DocType {} not found").format(doctype)) 97 | 98 | return categorical_data 99 | --------------------------------------------------------------------------------