Product description
" + >>> template.list_price = round(Decimal('9.99') / (1 + tax.rate), 4) + >>> template.account_category = account_category + >>> template.categories.append(Category(sub_category.id)) + >>> template.country_of_origin = china + >>> _ = template.tariff_codes.new(tariff_code=tariff1) + >>> _ = template.tariff_codes.new(tariff_code=tariff2) + >>> template.weight = 10 + >>> template.weight_uom, = Uom.find([('name', '=', "Carat")]) + >>> template.save() + >>> product1, = template.products + >>> product1.suffix_code = 'PROD1' + >>> product1.save() + + >>> template = ProductTemplate() + >>> template.name = "Product 2" + >>> template.default_uom = unit + >>> template.type = 'service' + >>> template.salable = True + >>> template.list_price = round(Decimal('20') / (1 + tax.rate), 4) + >>> template.account_category = account_category + >>> template.categories.append(Category(category2.id)) + >>> template.save() + >>> product2, = template.products + >>> product2.suffix_code = 'PROD2' + >>> product2.save() + + >>> variant = ProductTemplate() + >>> variant.name = "Variant" + >>> variant.code = "VAR" + >>> variant.default_uom = unit + >>> variant.type = 'goods' + >>> variant.salable = True + >>> variant.list_price = round(Decimal('50') / (1 + tax.rate), 4) + >>> variant.attribute_set = attribute_set + >>> variant.account_category = account_category + >>> variant.categories.append(Category(category1.id)) + >>> variant.categories.append(Category(category2.id)) + >>> image = variant.images.new(web_shop=True) + >>> image.image = urllib.request.urlopen('https://picsum.photos/200').read() + >>> variant1, = variant.products + >>> variant1.suffix_code = "1" + >>> variant1.attributes = { + ... 'color': 'blue', + ... 'check': True, + ... } + >>> variant2 = variant.products.new() + >>> variant2.suffix_code = "2" + >>> variant2.attributes = { + ... 'color': 'red', + ... 'check': False, + ... } + >>> variant.save() + >>> variant1, variant2 = variant.products + + >>> image = variant1.images.new(web_shop=True, template=variant) + >>> image.image = urllib.request.urlopen('https://picsum.photos/200').read() + >>> variant1.save() + + >>> image = variant2.images.new(web_shop=True, template=variant) + >>> image.image = urllib.request.urlopen('https://picsum.photos/200').read() + >>> variant2.save() + +Create carrier:: + + >>> carrier_template = ProductTemplate() + >>> carrier_template.name = 'Carrier Product' + >>> carrier_template.default_uom = unit + >>> carrier_template.type = 'service' + >>> carrier_template.salable = True + >>> carrier_template.list_price = Decimal('3') + >>> carrier_template.account_category = account_category_shipping + >>> carrier_template.save() + >>> carrier_product, = carrier_template.products + >>> carrier_product.cost_price = Decimal('2') + >>> carrier_product.save() + + >>> carrier = Carrier() + >>> party = Party(name='Carrier') + >>> party.save() + >>> carrier.party = party + >>> carrier.carrier_product = carrier_product + >>> carrier.save() + +Fill warehouse:: + + >>> inventory = Inventory() + >>> inventory.location, = Location.find([('code', '=', 'STO')]) + >>> line = inventory.lines.new() + >>> line.product = product1 + >>> line.quantity = 10 + >>> line = inventory.lines.new() + >>> line.product = variant1 + >>> line.quantity = 5 + >>> inventory.click('confirm') + >>> inventory.state + 'done' + +Set categories, products and attributes to web shop:: + + >>> web_shop.categories.extend([ + ... Category(category1.id), + ... Category(sub_category.id), + ... Category(category2.id)]) + >>> web_shop.products.extend([ + ... Product(product1.id), + ... Product(product2.id), + ... Product(variant1.id), + ... Product(variant2.id)]) + >>> web_shop.save() + +Run update product:: + + >>> cron_update_product, = Cron.find([ + ... ('method', '=', 'web.shop|shopify_update_product'), + ... ]) + >>> cron_update_product.click('run_once') + + >>> category1.reload() + >>> len(category1.shopify_identifiers) + 1 + >>> category2.reload() + >>> len(category2.shopify_identifiers) + 1 + + >>> product1.reload() + >>> len(product1.shopify_identifiers) + 1 + >>> len(product1.template.shopify_identifiers) + 1 + >>> product2.reload() + >>> len(product2.shopify_identifiers) + 1 + >>> len(product2.template.shopify_identifiers) + 1 + >>> variant1.reload() + >>> len(variant1.shopify_identifiers) + 1 + >>> variant2.reload() + >>> len(variant2.shopify_identifiers) + 1 + >>> variant.reload() + >>> len(variant.shopify_identifiers) + 1 + >>> all(i.shopify_identifiers for i in variant.images) + True + +Run update inventory:: + + >>> cron_update_inventory, = Cron.find([ + ... ('method', '=', 'web.shop|shopify_update_inventory'), + ... ]) + >>> cron_update_inventory.click('run_once') + +Check inventory item:: + + >>> inventory_items = ProductInventoryItem.find([]) + >>> inventory_item_ids = [i.shopify_identifier + ... for inv in inventory_items for i in inv.shopify_identifiers] + >>> inventory_levels = location.inventory_levels() + >>> sorted(l.available for l in inventory_levels + ... if l.available and l.inventory_item_id in inventory_item_ids) + [5, 10] + +Remove a category, a product and an image:: + + >>> _ = web_shop.categories.pop(web_shop.categories.index(category2)) + >>> _ = web_shop.products.pop(web_shop.products.index(product2)) + >>> web_shop.save() + >>> variant2.images.remove(variant2.images[0]) + >>> variant2.save() + +Rename a category:: + + >>> sub_category.name = "Sub-category" + >>> sub_category.save() + >>> identifier, = sub_category.shopify_identifiers + >>> bool(identifier.to_update) + True + +Update attribute:: + + >>> attribute, = [a for a in attribute_set.attributes if a.name == 'color'] + >>> attribute.selection += "\ngreen:Green" + >>> attribute.save() + +Run update product:: + + >>> cron_update_product, = Cron.find([ + ... ('method', '=', 'web.shop|shopify_update_product'), + ... ]) + >>> cron_update_product.click('run_once') + + >>> category1.reload() + >>> len(category1.shopify_identifiers) + 1 + >>> category2.reload() + >>> len(category2.shopify_identifiers) + 0 + + >>> sub_category.reload() + >>> identifier, = sub_category.shopify_identifiers + >>> bool(identifier.to_update) + False + + >>> product1.reload() + >>> len(product1.shopify_identifiers) + 1 + >>> len(product1.template.shopify_identifiers) + 1 + >>> product2.reload() + >>> len(product2.shopify_identifiers) + 0 + >>> len(product2.template.shopify_identifiers) + 0 + >>> variant1.reload() + >>> len(variant1.shopify_identifiers) + 1 + >>> variant2.reload() + >>> len(variant2.shopify_identifiers) + 1 + >>> variant.reload() + >>> len(variant.shopify_identifiers) + 1 + >>> all(i.shopify_identifiers for i in variant1.images) + True + >>> any(i.shopify_identifiers for i in variant2.images) + False + +Create an order on Shopify:: + + >>> customer = shopify.Customer() + >>> customer.last_name = "Customer" + >>> customer.email = ( + ... ''.join(random.choice(string.ascii_letters) for _ in range(10)) + ... + '@example.com') + >>> customer.addresses = [{ + ... 'address1': "Street", + ... 'city': "City", + ... 'country': "Belgium", + ... }] + >>> customer.save() + True + + >>> order = shopify.Order.create({ + ... 'customer': customer.to_dict(), + ... 'shipping_address': customer.addresses[0].to_dict(), + ... 'billing_address': customer.addresses[0].to_dict(), + ... 'line_items': [{ + ... 'variant_id': product1.shopify_identifiers[0].shopify_identifier, + ... 'quantity': 1, + ... }, { + ... 'variant_id': product1.shopify_identifiers[0].shopify_identifier, + ... 'quantity': 1, + ... }, { + ... 'variant_id': variant1.shopify_identifiers[0].shopify_identifier, + ... 'quantity': 5, + ... }], + ... 'financial_status': 'authorized', + ... 'transactions': [{ + ... 'kind': 'authorization', + ... 'status': 'success', + ... 'amount': '258.98', + ... 'test': True, + ... }], + ... 'discount_codes': [{ + ... 'code': 'CODE', + ... 'amount': '15', + ... 'type': 'fixed_amount', + ... }], + ... 'shipping_lines': [{ + ... 'code': 'SHIP', + ... 'title': "Shipping", + ... 'price': '4.00', + ... }], + ... }) + >>> order.total_price + '258.98' + >>> order.financial_status + 'authorized' + >>> order.fulfillment_status + +Run fetch order:: + + >>> with config.set_context(shopify_orders=order.id): + ... cron_fetch_order, = Cron.find([ + ... ('method', '=', 'web.shop|shopify_fetch_order'), + ... ]) + ... cron_fetch_order.click('run_once') + + >>> sale, = Sale.find([]) + >>> sale.shopify_tax_adjustment + Decimal('0.01') + >>> len(sale.lines) + 4 + >>> sorted([l.unit_price for l in sale.lines]) + [Decimal('4.0000'), Decimal('8.5727'), Decimal('8.5727'), Decimal('42.9309')] + >>> sale.total_amount + Decimal('258.98') + >>> len(sale.payments) + 1 + >>> payment, = sale.payments + >>> payment.state + 'processing' + >>> payment.amount + Decimal('258.98') + >>> sale.carrier == carrier + True + >>> sale.state + 'quotation' + +Capture partially:: + + >>> transaction = order.capture('100.00') + + >>> with config.set_context(shopify_orders=order.id): + ... cron_update_order, = Cron.find([ + ... ('method', '=', 'web.shop|shopify_update_order'), + ... ]) + ... cron_update_order.click('run_once') + + >>> sale.reload() + >>> len(sale.payments) + 1 + >>> payment, = sale.payments + >>> payment.state + 'processing' + >>> payment.amount + Decimal('100.00') + >>> sale.state + 'quotation' + +Capture remaining:: + + >>> transaction = order.capture('158.98') + + >>> with config.set_context(shopify_orders=order.id): + ... cron_update_order, = Cron.find([ + ... ('method', '=', 'web.shop|shopify_update_order'), + ... ]) + ... cron_update_order.click('run_once') + + >>> sale.reload() + >>> len(sale.payments) + 1 + >>> payment, = sale.payments + >>> payment.state + 'succeeded' + >>> sale.state + 'processing' + >>> len(sale.invoices) + 0 + +Make a partial shipment:: + + >>> shipment, = sale.shipments + >>> move, = [m for m in shipment.inventory_moves if m.product == variant1] + >>> move.quantity = 3 + >>> shipment.click('pick') + >>> shipment.click('pack') + >>> shipment.click('done') + >>> shipment.state + 'done' + + >>> sale.reload() + >>> len(sale.invoices) + 0 + + >>> order.reload() + >>> order.fulfillment_status + 'partial' + >>> len(order.fulfillments) + 1 + >>> order.financial_status + 'paid' + +Cancel remaining shipment:: + + >>> shipment, = [s for s in sale.shipments if s.state != 'done'] + >>> shipment.click('cancel') + >>> shipment.state + 'cancelled' + + >>> sale.reload() + >>> sale.shipment_state + 'exception' + >>> len(sale.invoices) + 0 + + >>> order.reload() + >>> order.fulfillment_status + 'partial' + >>> len(order.fulfillments) + 1 + >>> order.financial_status + 'paid' + +Ignore shipment exception:: + + >>> shipment_exception = Wizard('sale.handle.shipment.exception', [sale]) + >>> move = shipment_exception.form.recreate_moves.pop() + >>> shipment_exception.execute('handle') + + >>> order.reload() + >>> order.fulfillment_status + 'partial' + >>> len(order.fulfillments) + 1 + >>> order.financial_status + 'partially_refunded' + + >>> sale.reload() + >>> invoice, = sale.invoices + >>> invoice.total_amount + Decimal('164.53') + >>> payment, = sale.payments + >>> payment.state + 'succeeded' + +Correct taxes as partial invoice can get rounding gap:: + + >>> tax_line, = invoice.taxes + >>> tax_line.amount += payment.amount - invoice.total_amount + >>> invoice.save() + >>> invoice.total_amount == payment.amount + True + +Post invoice:: + + >>> invoice.click('post') + >>> invoice.state + 'paid' + >>> sale.reload() + >>> sale.state + 'done' + >>> order.reload() + >>> bool(order.closed_at) + True + +Clean up:: + + >>> order.destroy() + >>> for product in ShopifyIdentifier.find( + ... [('record', 'like', 'product.template,%')]): + ... shopify.Product.find(product.shopify_identifier).destroy() + >>> for category in ShopifyIdentifier.find( + ... [('record', 'like', 'product.category,%')]): + ... shopify.CustomCollection.find(category.shopify_identifier).destroy() + >>> time.sleep(1) + >>> customer.destroy() + + >>> shopify.ShopifyResource.clear_session() diff --git a/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst new file mode 100644 index 0000000000..b7525ef11a --- /dev/null +++ b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst @@ -0,0 +1,226 @@ +======================================== +Web Shop Shopify Secondary Unit Scenario +======================================== + +Imports:: + + >>> import datetime as dt + >>> import os + >>> import random + >>> import string + >>> import time + >>> import urllib.request + >>> from decimal import Decimal + + >>> import shopify + >>> from shopify.api_version import ApiVersion + + >>> from proteus import Model, Wizard + >>> from trytond.tests.tools import activate_modules + >>> from trytond.modules.company.tests.tools import ( + ... create_company, get_company) + >>> from trytond.modules.account.tests.tools import ( + ... create_fiscalyear, create_chart, get_accounts) + >>> from trytond.modules.account_invoice.tests.tools import ( + ... set_fiscalyear_invoice_sequences) + +Activate modules:: + + >>> config = activate_modules([ + ... 'web_shop_shopify', + ... 'sale_secondary_unit', + ... ]) + + >>> Account = Model.get('account.account') + >>> Category = Model.get('product.category') + >>> Cron = Model.get('ir.cron') + >>> Location = Model.get('stock.location') + >>> PaymentJournal = Model.get('account.payment.journal') + >>> Product = Model.get('product.product') + >>> ProductTemplate = Model.get('product.template') + >>> Sale = Model.get('sale.sale') + >>> ShopifyIdentifier = Model.get('web.shop.shopify_identifier') + >>> Uom = Model.get('product.uom') + >>> WebShop = Model.get('web.shop') + +Create company:: + + >>> _ = create_company() + >>> company = get_company() + +Create chart of accounts:: + + >>> _ = create_chart(company) + >>> accounts = get_accounts(company) + +Create fiscal year:: + + >>> fiscalyear = set_fiscalyear_invoice_sequences( + ... create_fiscalyear(company)) + >>> fiscalyear.click('create_period') + +Create payment journal:: + + >>> shopify_account = Account(parent=accounts['receivable'].parent) + >>> shopify_account.name = "Shopify" + >>> shopify_account.type = accounts['receivable'].type + >>> shopify_account.reconcile = True + >>> shopify_account.save() + + >>> payment_journal = PaymentJournal() + >>> payment_journal.name = "Shopify" + >>> payment_journal.process_method = 'shopify' + >>> payment_journal.save() + +Define a web shop:: + + >>> web_shop = WebShop(name="Web Shop") + >>> web_shop.type = 'shopify' + >>> web_shop.shopify_url = os.getenv('SHOPIFY_URL') + >>> web_shop.shopify_password = os.getenv('SHOPIFY_PASSWORD') + >>> web_shop.shopify_version = sorted(ApiVersion.versions, reverse=True)[1] + >>> shop_warehouse = web_shop.shopify_warehouses.new() + >>> shop_warehouse.warehouse, = Location.find([('type', '=', 'warehouse')]) + >>> shopify_payment_journal = web_shop.shopify_payment_journals.new() + >>> shopify_payment_journal.journal = payment_journal + >>> web_shop.save() + + >>> shopify.ShopifyResource.activate_session(shopify.Session( + ... web_shop.shopify_url, + ... web_shop.shopify_version, + ... web_shop.shopify_password)) + + >>> location = shopify.Location.find_first() + + >>> shop_warehouse, = web_shop.shopify_warehouses + >>> shop_warehouse.shopify_id = str(location.id) + >>> web_shop.save() + +Create categories:: + + >>> category = Category(name="Category") + >>> category.save() + + >>> account_category = Category(name="Account Category") + >>> account_category.accounting = True + >>> account_category.account_expense = accounts['expense'] + >>> account_category.account_revenue = accounts['revenue'] + >>> account_category.save() + +Create product:: + + >>> unit, = Uom.find([('name', '=', "Unit")]) + >>> unit.digits = 2 + >>> unit.rounding = 0.01 + >>> unit.save() + >>> cm, = Uom.find([('name', '=', "Centimeter")]) + >>> cm, = cm.duplicate(default={'digits': 0, 'rounding': 1}) + + >>> template = ProductTemplate() + >>> template.name = "Product 1" + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.salable = True + >>> template.sale_secondary_uom = cm + >>> template.sale_secondary_uom_factor = 25 + >>> template.list_price = Decimal('100.0000') + >>> template.account_category = account_category + >>> template.categories.append(Category(category.id)) + >>> template.save() + >>> product, = template.products + >>> product.suffix_code = 'PROD' + >>> product.save() + +Set categories, products and attributes to web shop:: + + >>> web_shop.categories.append(Category(category.id)) + >>> web_shop.products.append(Product(product.id)) + >>> web_shop.save() + +Run update product:: + + >>> cron_update_product, = Cron.find([ + ... ('method', '=', 'web.shop|shopify_update_product'), + ... ]) + >>> cron_update_product.click('run_once') + +Create an order on Shopify:: + + >>> customer = shopify.Customer() + >>> customer.last_name = "Customer" + >>> customer.email = ( + ... ''.join(random.choice(string.ascii_letters) for _ in range(10)) + ... + '@example.com') + >>> customer.addresses = [{ + ... 'address1': "Street", + ... 'city': "City", + ... }] + >>> customer.save() + True + + >>> order = shopify.Order.create({ + ... 'customer': customer.to_dict(), + ... 'shipping_address': customer.addresses[0].to_dict(), + ... 'billing_address': customer.addresses[0].to_dict(), + ... 'line_items': [{ + ... 'variant_id': product.shopify_identifiers[0].shopify_identifier, + ... 'quantity': 50, + ... }], + ... 'financial_status': 'authorized', + ... 'transactions': [{ + ... 'kind': 'authorization', + ... 'status': 'success', + ... 'amount': '202.00', + ... 'test': True, + ... }], + ... 'shipping_lines': [{ + ... 'code': 'SHIP', + ... 'title': "Shipping", + ... 'price': '2.00', + ... }], + ... }) + >>> order.total_price + '202.00' + >>> order.financial_status + 'authorized' + +Run fetch order:: + + >>> with config.set_context(shopify_orders=order.id): + ... cron_fetch_order, = Cron.find([ + ... ('method', '=', 'web.shop|shopify_fetch_order'), + ... ]) + ... cron_fetch_order.click('run_once') + + >>> sale, = Sale.find([]) + >>> len(sale.lines) + 2 + >>> sale.total_amount + Decimal('202.00') + >>> line, = [l for l in sale.lines if l.product] + >>> line.quantity + 2.0 + >>> line.unit == unit + True + >>> line.unit_price + Decimal('100.0000') + >>> line.secondary_quantity + 50.0 + >>> line.secondary_unit == cm + True + >>> line.secondary_unit_price + Decimal('4.0000') + +Clean up:: + + >>> order.destroy() + >>> for product in ShopifyIdentifier.find( + ... [('record', 'like', 'product.template,%')]): + ... shopify.Product.find(product.shopify_identifier).destroy() + >>> for category in ShopifyIdentifier.find( + ... [('record', 'like', 'product.category,%')]): + ... shopify.CustomCollection.find(category.shopify_identifier).destroy() + >>> time.sleep(2) + >>> customer.destroy() + + >>> shopify.ShopifyResource.clear_session() diff --git a/modules/web_shop_shopify/tests/test_module.py b/modules/web_shop_shopify/tests/test_module.py new file mode 100644 index 0000000000..b0ee4d9544 --- /dev/null +++ b/modules/web_shop_shopify/tests/test_module.py @@ -0,0 +1,12 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +from trytond.tests.test_tryton import ModuleTestCase + + +class WebShopShopifyTestCase(ModuleTestCase): + 'Test Web Shop Shopify module' + module = 'web_shop_shopify' + + +del ModuleTestCase diff --git a/modules/web_shop_shopify/tests/test_scenario.py b/modules/web_shop_shopify/tests/test_scenario.py new file mode 100644 index 0000000000..6b9f6c423f --- /dev/null +++ b/modules/web_shop_shopify/tests/test_scenario.py @@ -0,0 +1,10 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +import os + +from trytond.tests.test_tryton import load_doc_tests + +if os.getenv('SHOPIFY_PASSWORD') and os.getenv('SHOPIFY_URL'): + def load_tests(*args, **kwargs): + return load_doc_tests(__name__, __file__, *args, **kwargs) diff --git a/modules/web_shop_shopify/tox.ini b/modules/web_shop_shopify/tox.ini new file mode 100644 index 0000000000..91426e5b25 --- /dev/null +++ b/modules/web_shop_shopify/tox.ini @@ -0,0 +1,17 @@ +[tox] +envlist = {py37,py38,py39,py310}-{sqlite,postgresql} + +[testenv] +extras = test +commands = + coverage run --include=./**/web_shop_shopify/* -m unittest discover -s tests + coverage report --include=./**/web_shop_shopify/* --omit=*/tests/* +deps = + coverage + postgresql: psycopg2 >= 2.7.0 +setenv = + sqlite: TRYTOND_DATABASE_URI={env:SQLITE_URI:sqlite://} + postgresql: TRYTOND_DATABASE_URI={env:POSTGRESQL_URI:postgresql://} + sqlite: DB_NAME={env:SQLITE_NAME::memory:} + postgresql: DB_NAME={env:POSTGRESQL_NAME:test} +install_command = pip install --pre --find-links https://trydevpi.tryton.org/?local_version={env:CI_JOB_ID:{env:CI_BUILD_NUMBER:}.{env:CI_JOB_NUMBER:}} {opts} {packages} diff --git a/modules/web_shop_shopify/tryton.cfg b/modules/web_shop_shopify/tryton.cfg new file mode 100644 index 0000000000..f320dd87e4 --- /dev/null +++ b/modules/web_shop_shopify/tryton.cfg @@ -0,0 +1,30 @@ +[tryton] +version=6.7.0 +depends: + account_payment + currency + ir + party + product + product_attribute + sale + sale_amendment + sale_payment + stock + web_shop +extras_depend: + customs + product_image + product_image_attribute + product_measurements + sale_discount + sale_invoice_grouping + sale_secondary_unit + sale_shipment_cost + stock_package_shipping +xml: + ir.xml + product.xml + web.xml + stock.xml + message.xml diff --git a/modules/web_shop_shopify/view/product_attribute_set_form.xml b/modules/web_shop_shopify/view/product_attribute_set_form.xml new file mode 100644 index 0000000000..4154b93cf8 --- /dev/null +++ b/modules/web_shop_shopify/view/product_attribute_set_form.xml @@ -0,0 +1,12 @@ + + + +We received a request to reset the password for your account: ${user.email}
+ + Reset Password + ++ The link will expire in . +
+Button is not working? Paste this into your browser:
+${user.get_email_reset_password_url()}
+If you didn't make this request, you can ignore this email.
+Button is not working? Paste this into your browser:
+${user.get_email_validation_url()}
+If you received this email by mistake, just ignore it.
+