diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 6c7d88b11f5..d7613478ce6 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -290,6 +290,8 @@ class AccountAccount(models.Model): Note that it is disallowed if some lines are partially reconciled. ''' + if not self.ids: + return None partial_lines_count = self.env['account.move.line'].search_count([ ('account_id', 'in', self.ids), ('full_reconcile_id', '=', False), diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 55d7cacb274..ef7c145e072 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -1476,9 +1476,11 @@ class AccountInvoice(models.Model): values = self._prepare_refund(invoice, date_invoice=date_invoice, date=date, description=description, journal_id=journal_id) refund_invoice = self.create(values) - invoice_type = {'out_invoice': ('customer invoices credit note'), - 'in_invoice': ('vendor bill credit note')} - message = _("This %s has been created from: %s
Reason: %s") % (invoice_type[invoice.type], invoice.id, invoice.number, description) + if invoice.type == 'out_invoice': + message = _("This customer invoice credit note has been created from: %s
Reason: %s") % (invoice.id, invoice.number, description) + else: + message = _("This vendor bill credit note has been created from: %s
Reason: %s") % (invoice.id, invoice.number, description) + refund_invoice.message_post(body=message) new_invoices += refund_invoice return new_invoices @@ -1799,6 +1801,7 @@ class AccountInvoiceLine(models.Model): else: price_unit = self.product_id.lst_price self.price_unit = self.product_id.uom_id._compute_price(price_unit, self.uom_id) + self._set_currency() if self.product_id.uom_id.category_id.id != self.uom_id.category_id.id: warning = { diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index 73da6d70183..f4753ef4796 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -320,10 +320,10 @@ class account_journal(models.Model): action_name = 'action_view_bank_statement_tree' elif self.type == 'sale': action_name = 'action_invoice_tree1' - self = self.with_context(use_domain=[('type', '=', 'out_invoice')]) + self = self.with_context(use_domain=[('type', 'in', ['out_invoice', 'out_refund'])]) elif self.type == 'purchase': action_name = 'action_vendor_bill_template' - self = self.with_context(use_domain=[('type', '=', 'in_invoice')]) + self = self.with_context(use_domain=[('type', 'in', ['in_invoice', 'in_refund'])]) else: action_name = 'action_move_journal_line' diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 2f321769caa..7b038433905 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -1489,7 +1489,7 @@ class AccountPartialReconcile(models.Model): 'analytic_account_id': line.analytic_account_id.id, 'analytic_tag_ids': line.analytic_tag_ids.ids, 'tax_exigible': True, - 'amount_currency': self.amount_currency and line.currency_id.round(-line.amount_currency * amount / line.balance) or 0.0, + 'amount_currency': line.amount_currency and line.currency_id.round(-line.amount_currency * amount / line.balance) or 0.0, 'currency_id': line.currency_id.id, 'move_id': newly_created_move.id, 'partner_id': line.partner_id.id, @@ -1504,7 +1504,7 @@ class AccountPartialReconcile(models.Model): 'analytic_tag_ids': line.analytic_tag_ids.ids, 'tax_line_id': line.tax_line_id.id, 'tax_exigible': True, - 'amount_currency': self.amount_currency and line.currency_id.round(line.amount_currency * amount / line.balance) or 0.0, + 'amount_currency': line.amount_currency and line.currency_id.round(line.amount_currency * amount / line.balance) or 0.0, 'currency_id': line.currency_id.id, 'move_id': newly_created_move.id, 'partner_id': line.partner_id.id, diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 7272c1623f0..8d7a48424fe 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -164,9 +164,10 @@ class AccountChartTemplate(models.Model): of accounts had been created for it yet. """ self.ensure_one() - if request: - company_id = request.env.user.company_id.id - company = self.env['res.company'].browse(company_id) + # do not use `request.env` here, it can cause deadlocks + if request and request.session.uid: + current_user = self.env['res.users'].browse(request.uid) + company = current_user.company_id else: # fallback to company of current user, most likely __system__ # (won't work well for multi-company) @@ -184,9 +185,10 @@ class AccountChartTemplate(models.Model): rights. """ self.ensure_one() - if request: - company_id = request.env.user.company_id.id - company = self.env['res.company'].browse(company_id) + # do not use `request.env` here, it can cause deadlocks + if request and request.session.uid: + current_user = self.env['res.users'].browse(request.uid) + company = current_user.company_id else: # fallback to company of current user, most likely __system__ # (won't work well for multi-company) @@ -210,7 +212,7 @@ class AccountChartTemplate(models.Model): prop_values.extend(['account.journal,%s' % (journal_id,) for journal_id in existing_journals.ids]) accounting_props = self.env['ir.property'].search([('value_reference', 'in', prop_values)]) if accounting_props: - accounting_props.unlink() + accounting_props.sudo().unlink() # delete account, journal, tax, fiscal position and reconciliation model models_to_delete = ['account.reconcile.model', 'account.fiscal.position', 'account.tax', 'account.move', 'account.journal'] diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index efb437ea246..69f3fca8bc8 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -11,11 +11,13 @@ Tax display B2B Show line subtotals without taxes (B2B) + Tax display B2C Show line subtotals with taxes included (B2C) + diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 33408802fd7..9da1f5fb668 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -8,6 +8,7 @@ from . import test_account_validate_account_move from . import test_account_invoice_rounding from . import test_bank_statement_reconciliation from . import test_fiscal_position +from . import test_invoice_onchange from . import test_reconciliation_widget from . import test_payment from . import test_product_id_change diff --git a/addons/account/tests/test_account_fiscal_year.py b/addons/account/tests/test_account_fiscal_year.py index 85785473ee2..7fe5e6a966e 100644 --- a/addons/account/tests/test_account_fiscal_year.py +++ b/addons/account/tests/test_account_fiscal_year.py @@ -72,8 +72,15 @@ class TestFiscalPosition(AccountingTestCase): self.check_compute_fiscal_year( company, - '2016-03-01', '2016-02-29', + '2015-03-01', + '2016-02-29', + ) + + self.check_compute_fiscal_year( + company, + '2016-03-01', + '2016-03-01', '2017-02-28', ) diff --git a/addons/account/tests/test_invoice_onchange.py b/addons/account/tests/test_invoice_onchange.py new file mode 100644 index 00000000000..3a1402de503 --- /dev/null +++ b/addons/account/tests/test_invoice_onchange.py @@ -0,0 +1,33 @@ +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.tests import tagged +from odoo.tests.common import Form + + +@tagged('post_install', '-at_install') +class TestInvoiceOnchange(AccountingTestCase): + + def setUp(self): + super(TestInvoiceOnchange, self).setUp() + self.invoice_line_onchange = self.env['account.invoice.line']._onchange_spec() + self.half_currency = self.env['res.currency'].create({ + 'name': 'HALF', 'symbol': '$HALF', + 'rate_ids': [(0, 0, {'name': '1980-01-01', 'rate': 2})], + }) + self.apples_product = self.env['product.product'].create(dict( + self.env['product.product'].default_get(self.env['product.product']._fields), + lst_price=10, name='apples', + )) + + def test_invoice_currency_onchange(self): + self_ctx = self.env['account.invoice'].with_context(type='out_invoice') + with Form(self_ctx, view='account.invoice_form') as invoice_form: + invoice_form.partner_id = self.env.user.partner_id + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.product_id = self.apples_product + # Check onchange keep price_unit if currency not changed + self.assertEqual(invoice_line_form.price_unit, 10) + invoice_form.currency_id = self.half_currency + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.product_id = self.apples_product + # Check onchange gives converted price with custom currency + self.assertEqual(invoice_line_form.price_unit, 20) diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 86fc19225b8..45696253ffa 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -1387,7 +1387,7 @@ - + + + + diff --git a/addons/base_address_extended/tests/test_street_fields.py b/addons/base_address_extended/tests/test_street_fields.py index cf5598b624f..f4e37c08cc5 100644 --- a/addons/base_address_extended/tests/test_street_fields.py +++ b/addons/base_address_extended/tests/test_street_fields.py @@ -12,6 +12,7 @@ class TestStreetFields(TransactionCase): self.env.ref('base.be').write({'street_format': '%(street_name)s, %(street_number)s/%(street_number2)s'}) self.env.ref('base.us').write({'street_format': '%(street_number)s/%(street_number2)s %(street_name)s'}) self.env.ref('base.ch').write({'street_format': 'header %(street_name)s, %(street_number)s - %(street_number2)s trailer'}) + self.env.ref('base.mx').write({'street_format': '%(street_name)s %(street_number)s/%(street_number2)s'}) def create_and_assert(self, partner_name, country_id, street, street_name, street_number, street_number2): partner = self.Partner.create({'name': partner_name + '-1', 'street': street, 'country_id': country_id}) @@ -39,6 +40,7 @@ class TestStreetFields(TransactionCase): self.create_and_assert('Test00', self.env.ref('base.us').id, '40/2b Chaussee de Namur', 'Chaussee de Namur', '40', '2b') self.create_and_assert('Test01', self.env.ref('base.us').id, '40 Chaussee de Namur', 'Chaussee de Namur', '40', '') self.create_and_assert('Test02', self.env.ref('base.us').id, 'Chaussee de Namur', 'de Namur', 'Chaussee', '') + self.create_and_assert('Test03', self.env.ref('base.mx').id, 'Av. Miguel Hidalgo y Costilla 601', 'Av.', 'Miguel Hidalgo y Costilla 601', '') def test_01_header_trailer(self): self.create_and_assert('Test10', self.env.ref('base.ch').id, 'header Chaussee de Namur, 40 - 2b trailer', 'Chaussee de Namur', '40', '2b') diff --git a/addons/base_address_extended/views/base_address_extended.xml b/addons/base_address_extended/views/base_address_extended.xml index 6fc3d098868..7d2a7770ea9 100644 --- a/addons/base_address_extended/views/base_address_extended.xml +++ b/addons/base_address_extended/views/base_address_extended.xml @@ -19,13 +19,15 @@ - - -
-
- +

+ diff --git a/addons/website_membership/controllers/main.py b/addons/website_membership/controllers/main.py index f67918569cb..98f55eac68d 100644 --- a/addons/website_membership/controllers/main.py +++ b/addons/website_membership/controllers/main.py @@ -103,7 +103,7 @@ class WebsiteMembership(http.Controller): # get google maps localization of partners google_map_partner_ids = [] - if request.env.ref('website_membership.opt_index_google_map').active: + if request.website.viewref('website_membership.opt_index_google_map').active: google_map_partner_ids = MembershipLine.search(line_domain).get_published_companies(limit=2000) search_domain = [('membership_state', '=', 'free'), ('website_published', '=', True)] diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 3660b3344f4..aec6d3b17ed 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -126,14 +126,19 @@ class Website(Website): views = super(Website, self).get_switchable_related_views(key) if key == 'website_sale.product': if not request.env.user.has_group('product.group_product_variant'): - view_product_variants = request.env.ref('website_sale.product_variants') - views[:] = [v for v in views if v['id'] != view_product_variants.id] + view_product_variants = request.website.viewref('website_sale.product_variants') + views = [v for v in views if v['id'] != view_product_variants.id] return views class WebsiteSale(ProductConfiguratorController): - def _get_compute_currency_and_context(self): + def _get_compute_currency_and_context(self, product=None): + pricelist_context, pricelist = self._get_pricelist_context() + compute_currency = self._get_compute_currency(pricelist, product) + return compute_currency, pricelist_context, pricelist + + def _get_pricelist_context(self): pricelist_context = dict(request.env.context) pricelist = False if not pricelist_context.get('pricelist'): @@ -142,11 +147,12 @@ class WebsiteSale(ProductConfiguratorController): else: pricelist = request.env['product.pricelist'].browse(pricelist_context['pricelist']) - from_currency = request.env.user.company_id.currency_id - to_currency = pricelist.currency_id - compute_currency = lambda price: from_currency._convert(price, to_currency, request.env.user.company_id, fields.Date.today()) + return pricelist_context, pricelist - return compute_currency, pricelist_context, pricelist + def _get_compute_currency(self, pricelist, product=None): + from_currency = (product or request.env.user.company_id).currency_id + to_currency = pricelist.currency_id + return lambda price: from_currency._convert(price, to_currency, (product or request.env.user).company_id, fields.Date.today()) def _get_search_order(self, post): # OrderBy will be parsed in orm and so no direct sql injection @@ -189,6 +195,7 @@ class WebsiteSale(ProductConfiguratorController): '''/shop/category//page/''' ], type='http', auth="public", website=True) def shop(self, page=0, category=None, search='', ppg=False, **post): + add_qty = int(post.get('add_qty', 1)) if category: category = request.env['product.public.category'].search([('id', '=', int(category))], limit=1) if not category or not category.can_access_from_current_website(): @@ -212,7 +219,7 @@ class WebsiteSale(ProductConfiguratorController): keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, order=post.get('order')) - compute_currency, pricelist_context, pricelist = self._get_compute_currency_and_context() + pricelist_context, pricelist = self._get_pricelist_context() request.context = dict(request.context, pricelist=pricelist.id, partner=request.env.user.partner_id) @@ -250,10 +257,12 @@ class WebsiteSale(ProductConfiguratorController): if products: # get all products without limit selected_products = Product.search(domain, limit=False) - attributes = ProductAttribute.search([('attribute_line_ids.product_tmpl_id', 'in', selected_products.ids)]) + attributes = ProductAttribute.search([('attribute_line_ids.value_ids', '!=', False), ('attribute_line_ids.product_tmpl_id', 'in', selected_products.ids)]) else: attributes = ProductAttribute.browse(attributes_ids) + compute_currency = self._get_compute_currency(pricelist, products[:1]) + values = { 'search': search, 'category': category, @@ -261,6 +270,7 @@ class WebsiteSale(ProductConfiguratorController): 'attrib_set': attrib_set, 'pager': pager, 'pricelist': pricelist, + 'add_qty': add_qty, 'products': products, 'search_count': product_count, # common for all searchbox 'bins': TableCompute().process(products, ppg), @@ -281,7 +291,9 @@ class WebsiteSale(ProductConfiguratorController): if not product.can_access_from_current_website(): raise NotFound() - product_context = dict(request.env.context, + add_qty = int(kwargs.get('add_qty', 1)) + + product_context = dict(request.env.context, quantity=add_qty, active_id=product.id, partner=request.env.user.partner_id) ProductCategory = request.env['product.public.category'] @@ -299,9 +311,8 @@ class WebsiteSale(ProductConfiguratorController): pricelist = request.website.get_current_pricelist() - from_currency = request.env.user.company_id.currency_id - to_currency = pricelist.currency_id - compute_currency = lambda price: from_currency._convert(price, to_currency, request.env.user.company_id, fields.Date.today()) + def compute_currency(price): + return product.currency_id._convert(price, pricelist.currency_id, product.company_id, fields.Date.today()) if not product_context.get('pricelist'): product_context['pricelist'] = pricelist.id @@ -312,18 +323,21 @@ class WebsiteSale(ProductConfiguratorController): 'category': category, 'pricelist': pricelist, 'attrib_values': attrib_values, + # compute_currency deprecated, get from product 'compute_currency': compute_currency, 'attrib_set': attrib_set, 'keep': keep, 'categories': categs, 'main_object': product, 'product': product, + 'add_qty': add_qty, 'optional_product_ids': [p.with_context({'active_id': p.id}) for p in product.optional_product_ids], + # get_attribute_exclusions deprecated, use product method 'get_attribute_exclusions': self._get_attribute_exclusions, } return request.render("website_sale.product", values) - @http.route(['/shop/change_pricelist/'], type='http', auth="public", website=True) + @http.route(['/shop/change_pricelist/'], type='http', auth="public", website=True, sitemap=False) def pricelist_change(self, pl_id, **post): if (pl_id.selectable or pl_id == request.env.user.partner_id.property_product_pricelist) \ and request.website.is_pricelist_available(pl_id.id): @@ -331,7 +345,7 @@ class WebsiteSale(ProductConfiguratorController): request.website.sale_get_order(force_pricelist=pl_id.id) return request.redirect(request.httprequest.referrer or '/shop') - @http.route(['/shop/pricelist'], type='http', auth="public", website=True) + @http.route(['/shop/pricelist'], type='http', auth="public", website=True, sitemap=False) def pricelist(self, promo, **post): redirect = post.get('r', '/shop/cart') pricelist = request.env['product.pricelist'].sudo().search([('code', '=', promo)], limit=1) @@ -379,6 +393,7 @@ class WebsiteSale(ProductConfiguratorController): values.update({ 'website_sale_order': order, 'compute_currency': compute_currency, + 'date': fields.Date.today(), 'suggested_products': [], }) if order: @@ -395,6 +410,7 @@ class WebsiteSale(ProductConfiguratorController): @http.route(['/shop/cart/update'], type='http', auth="public", methods=['POST'], website=True, csrf=False) def cart_update(self, product_id, add_qty=1, set_qty=0, **kw): + """This route is called when adding a product to cart (no options).""" sale_order = request.website.sale_get_order(force_create=True) if sale_order.state != 'draft': request.session['sale_order_id'] = None @@ -404,20 +420,28 @@ class WebsiteSale(ProductConfiguratorController): if kw.get('product_custom_attribute_values'): product_custom_attribute_values = json.loads(kw.get('product_custom_attribute_values')) + no_variant_attribute_values = None + if kw.get('no_variant_attribute_values'): + no_variant_attribute_values = json.loads(kw.get('no_variant_attribute_values')) + sale_order._cart_update( product_id=int(product_id), add_qty=add_qty, set_qty=set_qty, - product_custom_attribute_values=product_custom_attribute_values + product_custom_attribute_values=product_custom_attribute_values, + no_variant_attribute_values=no_variant_attribute_values ) return request.redirect("/shop/cart") @http.route(['/shop/cart/update_json'], type='json', auth="public", methods=['POST'], website=True, csrf=False) def cart_update_json(self, product_id, line_id=None, add_qty=None, set_qty=None, display=True): + """This route is called when changing quantity from the cart or adding + a product from the wishlist.""" order = request.website.sale_get_order(force_create=1) if order.state != 'draft': request.website.sale_reset() return {} + value = order._cart_update(product_id=product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty) if not order.cart_quantity: @@ -434,14 +458,16 @@ class WebsiteSale(ProductConfiguratorController): value['website_sale.cart_lines'] = request.env['ir.ui.view'].render_template("website_sale.cart_lines", { 'website_sale_order': order, + # compute_currency deprecated (not used in view) 'compute_currency': lambda price: from_currency._convert( - price, to_currency, request.env.user.company_id, fields.Date.today()), + price, to_currency, order.company_id, fields.Date.today()), + 'date': fields.Date.today(), 'suggested_products': order._cart_accessories() }) value['website_sale.short_cart_summary'] = request.env['ir.ui.view'].render_template("website_sale.short_cart_summary", { 'website_sale_order': order, 'compute_currency': lambda price: from_currency._convert( - price, to_currency, request.env.user.company_id, fields.Date.today()), + price, to_currency, order.company_id, fields.Date.today()), }) return value @@ -715,7 +741,7 @@ class WebsiteSale(ProductConfiguratorController): order.order_line._compute_tax_id() request.session['sale_last_order_id'] = order.id request.website.sale_get_order(update_pricelist=True) - extra_step = request.env['ir.ui.view']._view_obj('website_sale.extra_info_option') + extra_step = request.website.viewref('website_sale.extra_info_option') if extra_step.active: return request.redirect("/shop/extra_info") @@ -727,7 +753,7 @@ class WebsiteSale(ProductConfiguratorController): @http.route(['/shop/extra_info'], type='http', auth="public", website=True) def extra_info(self, **post): # Check that this option is activated - extra_step = request.env['ir.ui.view']._view_obj('website_sale.extra_info_option') # ref but specific + extra_step = request.website.viewref('website_sale.extra_info_option') if not extra_step.active: return request.redirect("/shop/payment") @@ -1097,6 +1123,7 @@ class WebsiteSale(ProductConfiguratorController): @http.route(['/shop/cart/update_option'], type='http', auth="public", methods=['POST'], website=True, multilang=False) def cart_options_update_json(self, product_id, add_qty=1, set_qty=0, goto_shop=None, lang=None, **kw): + """This route is called when submitting the optional product modal.""" if lang: request.website = request.website.with_context(lang=lang) @@ -1160,14 +1187,42 @@ class WebsiteSale(ProductConfiguratorController): return None + # ------------------------------------------------------ + # Product Configurator + # ------------------------------------------------------ + + @http.route(auth="public") + def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs): + """Override because on the website the public user must access it.""" + return super(WebsiteSale, self).create_product_variant(product_template_id, product_template_attribute_value_ids, **kwargs) + @http.route(['/product_configurator/show_optional_products_website'], type='json', auth="public", methods=['POST'], website=True) def show_optional_products_website(self, product_id, variant_values, **kw): - return self._show_optional_products(product_id, variant_values, request.website.get_current_pricelist(), True, **kw) + """Special route to use website logic in get_combination_info override. + This route is called in JS by appending _website to the base route. + """ + kw.pop('pricelist_id') + return self.show_optional_products(product_id, variant_values, request.website.get_current_pricelist(), **kw) @http.route(['/product_configurator/optional_product_items_website'], type='json', auth="public", methods=['POST'], website=True) def optional_product_items_website(self, product_id, **kw): - return self._optional_product_items(product_id, request.website.get_current_pricelist(), **kw) + """Special route to use website logic in get_combination_info override. + This route is called in JS by appending _website to the base route. + """ + kw.pop('pricelist_id') + return self.optional_product_items(product_id, request.website.get_current_pricelist(), **kw) @http.route(['/product_configurator/get_combination_info_website'], type='json', auth="public", methods=['POST'], website=True) def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw): - return self._get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist()) + """Special route to use website logic in get_combination_info override. + This route is called in JS by appending _website to the base route. + """ + kw.pop('pricelist_id') + res = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw) + + if request.env.ref('website_sale.shop_product_carousel', raise_if_not_found=False): # IF for compatibility 12.0 + res.update(carousel=request.env['ir.ui.view'].render_template('website_sale.shop_product_carousel', values={ + 'product': request.env['product.template'].browse(res['product_template_id']), + 'product_variant': request.env['product.product'].browse(res['product_id']), + })) + return res diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index 06e9bc8595f..8bf6fedcc6a 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -2,9 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, tools, _ from odoo.addons import decimal_precision as dp - from odoo.tools.translate import html_translate -from odoo.tools import float_compare class ProductStyle(models.Model): @@ -127,19 +125,167 @@ class ProductTemplate(models.Model): "Shop > Customize and enable 'E-commerce categories' to view all e-commerce categories.") product_image_ids = fields.One2many('product.image', 'product_tmpl_id', string='Images') + # website_price deprecated, directly use _get_combination_info instead website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price')) + # website_public_price deprecated, directly use _get_combination_info instead website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price')) + # website_price_difference deprecated, directly use _get_combination_info instead website_price_difference = fields.Boolean('Website price difference', compute='_website_price') def _website_price(self): - # First filter out the ones that have no variant: - # This makes sure that every template below has a corresponding product in the zipped result. - self = self.filtered('product_variant_id') - # use mapped who returns a recordset with only itself to prefetch (and don't prefetch every product_variant_ids) - for template, product in zip(self, self.mapped('product_variant_id')): - template.website_price = product.website_price - template.website_public_price = product.website_public_price - template.website_price_difference = product.website_price_difference + current_website = self.env['website'].get_current_website() + for template in self.with_context(website_id=current_website.id): + res = template._get_combination_info(template._get_first_possible_combination()) + template.website_price = res.get('price') + template.website_public_price = res.get('list_price') + template.website_price_difference = res.get('has_discounted_price') + + @api.multi + def _has_no_variant_attributes(self): + """Return whether this `product.template` has at least one no_variant + attribute. + + :return: True if at least one no_variant attribute, False otherwise + :rtype: bool + """ + self.ensure_one() + return any(a.create_variant == 'no_variant' for a in self._get_valid_product_attributes()) + + @api.multi + def _has_is_custom_values(self): + self.ensure_one() + """Return whether this `product.template` has at least one is_custom + attribute value. + + :return: True if at least one is_custom attribute value, False otherwise + :rtype: bool + """ + return any(v.is_custom for v in self._get_valid_product_attribute_values()) + + @api.multi + def _is_quick_add_to_cart_possible(self, parent_combination=None): + """ + It's possible to quickly add to cart if there's no optional product + and there's only one possible combination, and no attribute is set + to dynamic or no_variant, and no value is set to is_custom. + + :param parent_combination: combination from which `self` is an + optional or accessory product + :type parent_combination: recordset `product.template.attribute.value` + + :return: True if it's possible to quickly add to cart, else False + :rtype: bool + """ + self.ensure_one() + + if not self._is_add_to_cart_possible(parent_combination): + return False + if len(self._get_possible_variants(parent_combination)) != 1: + return False + if self._has_no_variant_attributes(): + return False + if self.has_dynamic_attributes(): + return False + if self._has_is_custom_values(): + return False + if self.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(self._get_first_possible_combination())): + return False + return True + + @api.multi + def _get_possible_variants_sorted(self, parent_combination=None): + """Return the sorted recordset of variants that are possible. + + The order is based on the order of the attributes and their values. + + See `_get_possible_variants` for the limitations of this method with + dynamic or no_variant attributes. + + :param parent_combination: combination from which `self` is an + optional or accessory product + :type parent_combination: recordset `product.template.attribute.value` + + :return: the sorted variants that are possible + :rtype: recordset of `product.product` + """ + self.ensure_one() + + def _sort_key_attribute_value(value): + # if you change this order, keep it in sync with _order from `product.attribute` + return (value.attribute_id.sequence, value.attribute_id.id) + + def _sort_key_variant(variant): + """ + We assume all variants will have the same attributes, with only one value for each. + - first level sort: same as "product.attribute"._order + - second level sort: same as "product.attribute.value"._order + """ + keys = [] + for attribute in variant.attribute_value_ids.sorted(_sort_key_attribute_value): + # if you change this order, keep it in sync with _order from `product.attribute.value` + keys.append(attribute.sequence) + keys.append(attribute.id) + return keys + + return self._get_possible_variants(parent_combination).sorted(_sort_key_variant) + + @api.multi + def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False): + """Override for website, where we want to: + - take the website pricelist if no pricelist is set + - apply the b2b/b2c setting to the result + + This will work when adding website_id to the context, which is done + automatically when called from routes with website=True. + """ + self.ensure_one() + + current_website = False + + if self.env.context.get('website_id'): + current_website = self.env['website'].get_current_website() + if not pricelist: + pricelist = current_website.get_current_pricelist() + + combination_info = super(ProductTemplate, self)._get_combination_info(combination, product_id, add_qty, pricelist, parent_combination) + + if self.env.context.get('website_id'): + partner = self.env.user.partner_id + company_id = current_website.company_id + product = self.env['product.product'].browse(combination_info['product_id']) or self + + tax_display = self.env.user.has_group('account.group_show_line_subtotals_tax_excluded') and 'total_excluded' or 'total_included' + taxes = partner.property_account_position_id.map_tax(product.sudo().taxes_id.filtered(lambda x: x.company_id == company_id), product, partner) + + # The list_price is always the price of one. + quantity_1 = 1 + price = taxes.compute_all(combination_info['price'], pricelist.currency_id, quantity_1, product, partner)[tax_display] + if pricelist.discount_policy == 'without_discount': + list_price = taxes.compute_all(combination_info['list_price'], pricelist.currency_id, quantity_1, product, partner)[tax_display] + else: + list_price = price + has_discounted_price = pricelist.currency_id.compare_amounts(list_price, price) == 1 + + combination_info.update( + price=price, + list_price=list_price, + has_discounted_price=has_discounted_price, + ) + + return combination_info + + @api.multi + def _create_first_product_variant(self, log_warning=False): + """Create if necessary and possible and return the first product + variant for this template. + + :param log_warning: whether a warning should be logged on fail + :type log_warning: bool + + :return: the first product variant or none + :rtype: recordset of `product.product` + """ + return self._create_product_variant(self._get_first_possible_combination(), log_warning) def _default_website_sequence(self): self._cr.execute("SELECT MIN(website_sequence) FROM %s" % self._table) @@ -187,35 +333,19 @@ class Product(models.Model): website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False) + # website_price deprecated, directly use _get_combination_info instead website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price')) + # website_public_price deprecated, directly use _get_combination_info instead website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price')) + # website_price_difference deprecated, directly use _get_combination_info instead website_price_difference = fields.Boolean('Website price difference', compute='_website_price') def _website_price(self): - qty = self._context.get('quantity', 1.0) - partner = self.env.user.partner_id - current_website = self.env['website'].get_current_website() - pricelist = current_website.get_current_pricelist() - company_id = current_website.company_id - - context = dict(self._context, pricelist=pricelist.id, partner=partner) - self2 = self.with_context(context) if self._context != context else self - - ret = self.env.user.has_group('account.group_show_line_subtotals_tax_excluded') and 'total_excluded' or 'total_included' - - for p, p2 in zip(self, self2): - taxes = partner.property_account_position_id.map_tax(p.sudo().taxes_id.filtered(lambda x: x.company_id == company_id), p, partner) - p.website_price = taxes.compute_all(p2.price, pricelist.currency_id, quantity=qty, product=p2, partner=partner)[ret] - # We must convert the price_without_pricelist in the same currency than the - # website_price, otherwise the comparison doesn't make sense. Moreover, we show a price - # difference only if the website price is lower - price_without_pricelist = p.list_price - if company_id.currency_id != pricelist.currency_id: - date = fields.Date.today() - price_without_pricelist = company_id.currency_id._convert(price_without_pricelist, pricelist.currency_id, company_id, date) - price_without_pricelist = taxes.compute_all(price_without_pricelist, pricelist.currency_id)[ret] - p.website_price_difference = True if float_compare(price_without_pricelist, p.website_price, precision_rounding=pricelist.currency_id.rounding) > 0 else False - p.website_public_price = taxes.compute_all(p2.lst_price, quantity=qty, product=p2, partner=partner)[ret] + for product in self: + res = product._get_combination_info_variant() + product.website_price = res.get('price') + product.website_public_price = res.get('list_price') + product.website_price_difference = res.get('has_discounted_price') @api.multi def website_publish_button(self): diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index dba1a992d58..5f85245b1a5 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -66,11 +66,17 @@ class SaleOrder(models.Model): @api.multi def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs): + """Find the cart line matching the given parameters. + + If a product_id is given, the line will match the product only if the + line also has the same special attributes: `no_variant` attributes and + `is_custom` values. + """ self.ensure_one() product = self.env['product.product'].browse(product_id) # split lines with the same product if it has untracked attributes - if product and product.mapped('attribute_line_ids').filtered(lambda r: not r.attribute_id.create_variant == 'always') and not line_id: + if product and (product.product_tmpl_id.has_dynamic_attributes() or product.product_tmpl_id._has_no_variant_attributes()) and not line_id: return self.env['sale.order.line'] domain = [('order_id', '=', self.id), ('product_id', '=', product_id)] @@ -101,7 +107,7 @@ class SaleOrder(models.Model): product_context = dict(self.env.context) product_context.setdefault('lang', order.partner_id.lang) product_context.update({ - 'partner': order.partner_id.id, + 'partner': order.partner_id, 'quantity': qty, 'date': order.date_order, 'pricelist': order.pricelist_id.id, @@ -123,6 +129,7 @@ class SaleOrder(models.Model): @api.multi def _get_line_description(self, order_id, product_id, no_variant_attribute_values=None, custom_values=None): + """Deprecated, use `get_sale_order_line_multiline_description_sale`""" order = self.sudo().browse(order_id) product_context = dict(self.env.context) product_context.setdefault('lang', order.partner_id.lang) @@ -146,7 +153,9 @@ class SaleOrder(models.Model): def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): """ Add or set product quantity, add_qty can be negative """ self.ensure_one() - SaleOrderLineSudo = self.env['sale.order.line'].sudo() + product_context = dict(self.env.context) + product_context.setdefault('lang', self.sudo().partner_id.lang) + SaleOrderLineSudo = self.env['sale.order.line'].sudo().with_context(product_context) try: if add_qty: @@ -164,28 +173,73 @@ class SaleOrder(models.Model): request.session['sale_order_id'] = None raise UserError(_('It is forbidden to modify a sales order which is not in draft status.')) if line_id is not False: - order_lines = self._cart_find_product_line(product_id, line_id, **kwargs) - order_line = order_lines and order_lines[0] + order_line = self._cart_find_product_line(product_id, line_id, **kwargs)[:1] # Create line if no line with product_id can be located if not order_line: + # change lang to get correct name of attributes/values + product = self.env['product.product'].with_context(product_context).browse(int(product_id)) + + if not product: + raise UserError(_("The given product does not exist therefore it cannot be added to cart.")) + + no_variant_attribute_values = kwargs.get('no_variant_attribute_values') or [] + received_no_variant_values = product.env['product.template.attribute.value'].browse([int(ptav['value']) for ptav in no_variant_attribute_values]) + received_combination = product.product_template_attribute_value_ids | received_no_variant_values + product_template = product.product_tmpl_id + + # handle all cases where incorrect or incomplete data are received + combination = product_template._get_closest_possible_combination(received_combination) + + # get or create (if dynamic) the correct variant + product = product_template._create_product_variant(combination) + + if not product: + raise UserError(_("The given combination does not exist therefore it cannot be added to cart.")) + + product_id = product.id + values = self._website_product_id_change(self.id, product_id, qty=1) - custom_values = kwargs.get('product_custom_attribute_values') + # add no_variant attributes that were not received + for ptav in combination.filtered(lambda ptav: ptav.attribute_id.create_variant == 'no_variant' and ptav not in received_no_variant_values): + no_variant_attribute_values.append({ + 'value': ptav.id, + 'attribute_name': ptav.attribute_id.name, + 'attribute_value_name': ptav.name, + }) + + # save no_variant attributes values + if no_variant_attribute_values: + values['product_no_variant_attribute_value_ids'] = [ + (6, 0, [int(attribute['value']) for attribute in no_variant_attribute_values]) + ] + + # add is_custom attribute values that were not received + custom_values = kwargs.get('product_custom_attribute_values') or [] + received_custom_values = product.env['product.attribute.value'].browse([int(ptav['attribute_value_id']) for ptav in custom_values]) + + for ptav in combination.filtered(lambda ptav: ptav.is_custom and ptav.product_attribute_value_id not in received_custom_values): + custom_values.append({ + 'attribute_value_id': ptav.product_attribute_value_id.id, + 'attribute_value_name': ptav.name, + 'custom_value': '', + }) + + # save is_custom attributes values if custom_values: values['product_custom_attribute_value_ids'] = [(0, 0, { 'attribute_value_id': custom_value['attribute_value_id'], 'custom_value': custom_value['custom_value'] }) for custom_value in custom_values] - no_variant_attribute_values = kwargs.get('no_variant_attribute_values') - if no_variant_attribute_values: - values['product_no_variant_attribute_value_ids'] = [ - (6, 0, [int(attribute['value']) for attribute in no_variant_attribute_values]) - ] - - values['name'] = self._get_line_description(self.id, product_id, no_variant_attribute_values=no_variant_attribute_values, custom_values=custom_values) + # create the line order_line = SaleOrderLineSudo.create(values) + # Generate the description with everything. This is done after + # creating because the following related fields have to be set: + # - product_no_variant_attribute_value_ids + # - product_custom_attribute_value_ids + order_line.name = order_line.get_sale_order_line_multiline_description_sale(product) try: order_line._compute_tax_id() @@ -206,13 +260,12 @@ class SaleOrder(models.Model): order_line.unlink() else: # update line - values = self._website_product_id_change(self.id, product_id, qty=quantity) + no_variant_attributes_price_extra = [ptav.price_extra for ptav in order_line.product_no_variant_attribute_value_ids] + values = self.with_context(no_variant_attributes_price_extra=no_variant_attributes_price_extra)._website_product_id_change(self.id, product_id, qty=quantity) if self.pricelist_id.discount_policy == 'with_discount' and not self.env.context.get('fixed_price'): order = self.sudo().browse(self.id) - product_context = dict(self.env.context) - product_context.setdefault('lang', order.partner_id.lang) product_context.update({ - 'partner': order.partner_id.id, + 'partner': order.partner_id, 'quantity': quantity, 'date': order.date_order, 'pricelist': order.pricelist_id.id, @@ -245,15 +298,15 @@ class SaleOrder(models.Model): def _cart_accessories(self): """ Suggest accessories based on 'Accessory Products' of products in cart """ for order in self: - accessory_products = order.website_order_line.mapped('product_id.accessory_product_ids').filtered(lambda product: product.website_published) products = order.website_order_line.mapped('product_id') - accessory_products -= products - - for product in products: - for product_template in accessory_products.mapped('product_tmpl_id'): - template_accessory_products = accessory_products.filtered(lambda accessory_product: accessory_product.product_tmpl_id == product_template) - # remove from the accessories the ones that are not available for the selected product - accessory_products -= template_accessory_products - product_template.get_filtered_variants(product) + accessory_products = self.env['product.product'] + for line in order.website_order_line.filtered(lambda l: l.product_id): + combination = line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids + accessory_products |= line.product_id.accessory_product_ids.filtered(lambda product: + product.website_published and + product not in products and + product._is_variant_possible(parent_combination=combination) + ) return random.sample(accessory_products, len(accessory_products)) @@ -303,4 +356,4 @@ class SaleOrderLine(models.Model): record.name_short = record.product_id.with_context(display_default_code=False).display_name def get_description_following_lines(self): - return self.name.splitlines()[1:] \ No newline at end of file + return self.name.splitlines()[1:] diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 71e2a47b5b8..ed03f81c5de 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -151,6 +151,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM 'change select[name="country_id"]': '_onChangeCountry', 'change #shipping_use_same': '_onChangeShippingUseSame', 'click .toggle_summary': '_onToggleSummary', + 'click input.js_product_change': 'onChangeVariant', }, /** @@ -177,6 +178,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM $('input.js_product_change', product).first().trigger('change'); }); + // This has to be triggered to compute the "out of stock" feature this.triggerVariantChange(this.$el); this.$('select[name="country_id"]').change(); @@ -189,34 +191,24 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM } }); - // Do not activate image zoom for mobile devices, since it might prevent users from scrolling the page - if (!config.device.isMobile) { - var autoZoom = $('.ecom-zoomable').data('ecom-zoom-auto') || false, - factorZoom = parseFloat($('.ecom-zoomable').data('ecom-zoom-factor')) || 1.5, - attach = '#o-carousel-product'; - _.each($('.ecom-zoomable img[data-zoom]'), function (el) { - onImageLoaded(el, function () { - var $img = $(el); - if (!_.str.endsWith(el.src, el.dataset.zoomImage) || // if zoom-img != img - el.naturalWidth >= $(attach).width() * factorZoom || el.naturalHeight >= $(attach).height() * factorZoom) { - $img.zoomOdoo({event: autoZoom ? 'mouseenter' : 'click', attach: attach}); - } else { - $img.removeAttr('data-zoom'); // remove cursor - } - }); - }); - } - - function onImageLoaded(img, callback) { - $(img).on('load', function () { callback(); }); - if (img.complete) { - $(img).off('load'); - callback(); - } - } + this._startZoom(); return def; }, + /** + * The selector is different when using list view of variants. + * + * @override + */ + getSelectedVariantValues: function ($container) { + var combination = $container.find('input.js_product_change:checked') + .data('combination'); + + if (combination) { + return JSON.parse(combination); + } + return ProductConfiguratorMixin.getSelectedVariantValues.apply(this, arguments); + }, //-------------------------------------------------------------------------- // Private @@ -328,6 +320,120 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM } }); }, + /** + * This is overridden to handle the "List View of Variants" of the web shop. + * That feature allows directly selecting the variant from a list instead of selecting the + * attribute values. + * + * Since the layout is completely different, we need to fetch the product_id directly + * from the selected variant. + * + * @override + */ + _getProductId: function ($parent) { + if ($parent.find('input.js_product_change').length !== 0) { + return parseInt($parent.find('input.js_product_change:checked').val()); + } + else { + return ProductConfiguratorMixin._getProductId.apply(this, arguments); + } + }, + /** + * @private + */ + _startZoom: function () { + // Do not activate image zoom for mobile devices, since it might prevent users from scrolling the page + if (!config.device.isMobile) { + var autoZoom = $('.ecom-zoomable').data('ecom-zoom-auto') || false, + factorZoom = parseFloat($('.ecom-zoomable').data('ecom-zoom-factor')) || 1.5, + attach = '#o-carousel-product'; + _.each($('.ecom-zoomable img[data-zoom]'), function (el) { + onImageLoaded(el, function () { + var $img = $(el); + if (!_.str.endsWith(el.src, el.dataset.zoomImage) || // if zoom-img != img + el.naturalWidth >= $(attach).width() * factorZoom || el.naturalHeight >= $(attach).height() * factorZoom) { + $img.zoomOdoo({event: autoZoom ? 'mouseenter' : 'click', attach: attach}); + $img.attr('data-zoom', 1); // add cursor (if previously removed) + } else { + $img.removeAttr('data-zoom'); // remove cursor + // remove zooming but keep the attribute because + // it can potentially be set back + $img.attr('data-zoom-image', ''); + } + }); + }); + } + + function onImageLoaded(img, callback) { + // On Chrome the load event already happened at this point so we + // have to rely on complete. On Firefox it seems that the event is + // always triggered after this so we can rely on it. + // + // However on the "complete" case we still want to keep listening to + // the event because if the image is changed later (eg. product + // configurator) a new load event will be triggered (both browsers). + $(img).on('load', function () { + callback(); + }); + if (img.complete) { + callback(); + } + } + }, + /** + * On website, we display a carousel instead of only one image + * + * @override + * @private + */ + _updateProductImage: function ($productContainer, productId, productTemplateId, new_carousel) { + var $img; + var $carousel = $productContainer.find('#o-carousel-product'); + + if (new_carousel) { + // When using the web editor, don't reload this or the images won't + // be able to be edited depending on if this is done loading before + // or after the editor is ready. + if (window.location.search.indexOf('enable_editor') === -1) { + var $new_carousel = $(new_carousel); + $carousel.after($new_carousel); + $carousel.remove(); + $carousel = $new_carousel; + $carousel.carousel(0); + this._startZoom(); + // fix issue with carousel height + this.trigger_up('animation_start_demand', {$target: $carousel}); + } + } + else { // compatibility 12.0 + var model = productId ? 'product.product' : 'product.template'; + var modelId = productId || productTemplateId; + var imageSrc = '/web/image/{0}/{1}/image' + .replace("{0}", model) + .replace("{1}", modelId); + + $img = $productContainer.find('img.js_variant_img'); + $img.attr("src", imageSrc); + $img.parent().attr('data-oe-model', model).attr('data-oe-id', modelId) + .data('oe-model', model).data('oe-id', modelId); + + var $thumbnail = $productContainer.find('img.js_variant_img_small'); + if ($thumbnail.length !== 0) { // if only one, thumbnails are not displayed + $thumbnail.attr("src", "/web/image/{0}/{1}/image/90x90" + .replace('{0}', model) + .replace('{1}', modelId)); + $('.carousel').carousel(0); + } + + // reset zooming constructs + $img.filter('[data-zoom-image]').attr('data-zoom-image', $img.attr('src')); + if ($img.data('zoomOdoo') !== undefined) { + $img.data('zoomOdoo').isReady = false; + } + } + + $carousel.toggleClass('css_not_available', !this.isSelectedVariantAllowed); + }, //-------------------------------------------------------------------------- // Handlers diff --git a/addons/website_sale/static/src/js/website_sale_options.js b/addons/website_sale/static/src/js/website_sale_options.js index 7e1f01a6c0c..ab3542589af 100644 --- a/addons/website_sale/static/src/js/website_sale_options.js +++ b/addons/website_sale/static/src/js/website_sale_options.js @@ -24,6 +24,7 @@ sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend(ProductConfig this.isWebsite = true; delete this.events['change [data-attribute_exclusions]']; + delete this.events['click input.js_product_change']; delete this.events['change input.js_quantity']; delete this.events['click button.js_add_cart_json']; }, @@ -68,7 +69,7 @@ sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend(ProductConfig false ); - return productReady.done(function (productId){ + return productReady.done(function (productId) { $form.find(productSelector.join(', ')).val(productId); self.rootProduct = { diff --git a/addons/website_sale/static/src/js/website_sale_tour_buy.js b/addons/website_sale/static/src/js/website_sale_tour_buy.js index b16eaf8638b..1797a59ea9b 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_buy.js +++ b/addons/website_sale/static/src/js/website_sale_tour_buy.js @@ -44,8 +44,8 @@ tour.register('shop_buy_product', { }, { content: "add suggested", - extra_trigger: '#wrap:not(:has(#cart_products:contains("[A8767] Storage Box")))', - trigger: '.oe_cart:has(tr:contains("Steel")) a:contains("Add to Cart")', + extra_trigger: '#wrap:not(:has(#cart_products:contains("Storage Box")))', + trigger: '.oe_cart:has(tr:contains("Storage Box")) a:contains("Add to Cart")', }, { content: "add one more", @@ -53,7 +53,7 @@ tour.register('shop_buy_product', { trigger: '#cart_products tr:contains("Steel") a.js_add_cart_json:eq(1)', }, { - content: "remove Headphones", + content: "remove Storage Box", extra_trigger: '#cart_products tr:contains("Steel") input.js_quantity:propValue(2)', trigger: '#cart_products tr:contains("Storage Box") a.js_add_cart_json:first', }, diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js index 8cfb5d91a7c..f23d8927d6d 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js @@ -9,7 +9,7 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require) test: true, wait_for: base.ready() }, [{ - trigger: 'img[src*="/product.template/9"]', + trigger: 'img[src*="/product.product/10"]', run: 'click' }, { trigger: 'li.js_attribute_value span:contains(Custom)', @@ -39,7 +39,7 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require) extra_trigger: '.oe_optional_products_modal', run: 'click' }, { - trigger: '.oe_price span:contains(22.9)', + trigger: '.oe_price span:contains(22.90)', run: function (){}, // check }, { trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair)) .js_add', diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js index 11e7ed573b2..009eb44b436 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js @@ -1,6 +1,6 @@ odoo.define('website_sale.tour_shop_customize', function (require) { 'use strict'; - + var tour = require("web_tour.tour"); var base = require("web_editor.base"); @@ -19,7 +19,7 @@ odoo.define('website_sale.tour_shop_customize', function (require) { trigger: "#customize-menu a:contains(Product Attribute's Filters)", }, { - content: "select product attribute memory Steel", + content: "select product attribute Steel", extra_trigger: 'body:not(:has(#customize-menu:visible .dropdown-menu:visible))', trigger: 'form.js_attributes label:contains(Steel) input:not(:checked)', }, @@ -30,8 +30,8 @@ odoo.define('website_sale.tour_shop_customize', function (require) { }, { content: "select product", - extra_trigger: 'body:not(:has(.oe_website_sale .oe_product_cart:eq(2)))', - trigger: '.oe_product_cart a:contains("Customizable Desk")', + extra_trigger: 'body:not(:has(.oe_website_sale .oe_product_cart:eq(3)))', + trigger: '.oe_product_cart a:contains("Test Product")', }, { content: "open customize menu", @@ -79,24 +79,18 @@ odoo.define('website_sale.tour_shop_customize', function (require) { run: function () {}, // it's a check }, { - content: "check price is 750 and set quantity to 2", - trigger: ".js_product:first input.quantity:propValue(1)", - extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", - run: "text 2", + content: "check price is 750", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", + run: function () {}, }, { - content: "verify pricelist based on quantity has effect", - trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", - run: function () {}, // it's a check - }, - { - content: "check pricelit has been applied and switch to Aluminium variant", + content: "switch to another variant", trigger: ".js_product label:contains('Aluminium')", }, { content: "verify that price has changed when changing variant", - trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))", - run: function () {}, // it's a check + trigger: ".product_price .oe_price .oe_currency_value:containsExact(800.40)", + run: function () {}, }, { content: "open customize menu", @@ -112,81 +106,35 @@ odoo.define('website_sale.tour_shop_customize', function (require) { run: function () {}, // it's a check }, { - content: "check price is 750 and set quantity to 2", - trigger: ".js_product:first input.quantity:propValue(1)", - extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", - run: "text 2", - }, - { - content: "verify pricelist based on quantity has effect", - trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", - run: function () {}, // it's a check + content: "check price is 750", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", + run: function () {}, }, { content: "switch to Aluminium variant", - trigger: ".js_product label:contains('Aluminium')", + trigger: '.js_product input[data-value_name="Aluminium"]', }, { content: "verify that price has changed when changing variant", - trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(800.40)", run: function () {}, // it's a check }, { content: "switch back to Steel variant", trigger: ".js_product label:contains('Steel')", }, + { + content: "check price is 750", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", + run: function () {}, + }, { content: "click on 'Add to Cart' button", trigger: "a:contains(Add to Cart)", }, - { - content: "price is lowered by pricelist and not multiplied by quantity", - trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(600.00)", - extra_trigger: "#product_confirmation input.quantity:propValue(2)", - run: function () {}, // it's a check - }, - { - content: "set quantity to 1", - trigger: "#product_confirmation .js_add_cart_json .fa-minus", - }, - { - content: "check that product page has been updated", - trigger: ".js_product:first input.quantity:propValue(1)", - extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", - run: function () {}, // it's a check - }, - { - content: "check that add to cart modal has been updated", - trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(750.00)", - extra_trigger: "#product_confirmation input.quantity:propValue(1)", - run: function () {}, // it's a check - }, - { - content: "add an optional Warranty", - trigger: ".js_product:contains(Warranty) a:contains(Add to Cart)", - }, - { - content: "click in modal on 'Proceed to checkout' button", - extra_trigger: 'body:has(.js_product:contains(Warranty) a:contains(Add to Cart):hidden)', - trigger: '.modal-footer a:contains("Proceed to Checkout")', - }, { content: "check quantity", - trigger: '.my_cart_quantity:containsExact(2),.o_extra_menu_items .fa-plus', - run: function () {}, // it's a check - }, - { - content: "check optional product", - trigger: '.optional_product', - run: function () {}, // it's a check - }, - { - content: "remove large cabinet from cart", - trigger: '#cart_products a.js_add_cart_json:first', - }, - { - content: "check optional product is removed", - trigger: '#wrap:not(:has(.optional_product))', + trigger: '.my_cart_quantity:containsExact(1),.o_extra_menu_items .fa-plus', run: function () {}, // it's a check }, { @@ -211,6 +159,5 @@ odoo.define('website_sale.tour_shop_customize', function (require) { }, ] ); - + }); - \ No newline at end of file diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js b/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js new file mode 100644 index 00000000000..c83b45434c9 --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js @@ -0,0 +1,52 @@ +odoo.define('website_sale.tour_shop_deleted_archived_variants', function (require) { +'use strict'; + +var tour = require('web_tour.tour'); +var base = require('web_editor.base'); + +// This tour relies on a data created from the python test. +tour.register('tour_shop_deleted_archived_variants', { + test: true, + url: '/shop?search=Test Product', + wait_for: base.ready(), +}, +[ + { + content: "select Test Product", + trigger: '.oe_product_cart a:containsExact("Test Product")', + }, + { + content: "check price (3rd variant)", + trigger: '.oe_currency_value:contains("31.00")' + }, + { + content: "click on the second variant", + trigger: 'input[data-attribute_name="My Attribute"][data-value_name="My Value 2"]', + }, + { + content: "check combination is not possible", + trigger: '.js_main_product.css_not_available .css_not_available_msg:contains("This combination does not exist.")' + }, + { + content: "click on the 3rd variant to reset the warning", + trigger: 'input[data-attribute_name="My Attribute"][data-value_name="My Value 3"]', + }, + { + content: "check price (3rd variant)", + trigger: '.oe_currency_value:contains("31.00")' + }, + { + content: "click on the first variant", + trigger: 'input[data-attribute_name="My Attribute"][data-value_name="My Value 1"]', + }, + { + content: "check combination is not possible", + trigger: '.js_main_product.css_not_available .css_not_available_msg:contains("This combination does not exist.")' + }, + { + content: "check add to cart not possible", + trigger: '#add_to_cart.disabled', + run: function () {}, + } +]); +}); diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js b/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js new file mode 100644 index 00000000000..2007a5e82a1 --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js @@ -0,0 +1,36 @@ +odoo.define('website_sale.tour_shop_dynamic_variants', function (require) { +'use strict'; + +var tour = require('web_tour.tour'); +var base = require('web_editor.base'); + +// This tour relies on a data created from the python test. +tour.register('tour_shop_dynamic_variants', { + test: true, + url: '/shop?search=Dynamic Product', + wait_for: base.ready(), +}, +[ + { + content: "select Dynamic Product", + trigger: '.oe_product_cart a:containsExact("Dynamic Product")', + }, + { + content: "click on the second variant", + trigger: 'input[data-attribute_name="Dynamic Attribute"][data-value_name="Dynamic Value 2"]', + }, + { + content: "wait for variant to be loaded", + trigger: '.oe_price .oe_currency_value:contains("0.00")', + run: function () {}, + }, + { + content: "click add to cart", + trigger: '#add_to_cart', + }, + { + content: "check the variant is in the cart", + trigger: 'td.td-product_name:contains(Dynamic Product (Dynamic Value 2))', + }, +]); +}); diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js b/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js new file mode 100644 index 00000000000..765fc540ccd --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js @@ -0,0 +1,39 @@ +odoo.define('website_sale.tour_shop_no_variant_attribute', function (require) { +'use strict'; + +var tour = require('web_tour.tour'); +var base = require('web_editor.base'); + +// This tour relies on a data created from the python test. +tour.register('tour_shop_no_variant_attribute', { + test: true, + url: '/shop?search=Test Product', + wait_for: base.ready(), +}, +[ + { + content: "select Test Product", + trigger: '.oe_product_cart a:containsExact("Test Product")', + }, + { + content: "check price", + trigger: '.oe_currency_value:contains("1.00")', + run: function () {}, + }, + { + content: "add to cart", + trigger: 'a:contains(Add to Cart)', + }, + { + content: "check no_variant value is present", + trigger: '.td-product_name:contains(No Variant Attribute: No Variant Value)', + extra_trigger: '#cart_products', + run: function () {}, + }, + { + content: "check price is correct", + trigger: '.td-price:contains(11.0)', + run: function () {}, + }, +]); +}); diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_zoom.js b/addons/website_sale/static/src/js/website_sale_tour_shop_zoom.js new file mode 100644 index 00000000000..02a327ea53b --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_zoom.js @@ -0,0 +1,50 @@ +odoo.define('website_sale.tour_shop_zoom', function (require) { +'use strict'; + +var tour = require('web_tour.tour'); +var base = require('web_editor.base'); + +var imageSelector = '#o-carousel-product .carousel-item.active img'; +var imageName = "A Colorful Image"; +var nameGreen = "Forest Green"; + +// This tour relies on a data created from the python test. +tour.register('shop_zoom', { + test: true, + url: '/shop?search=' + imageName, + wait_for: base.ready(), +}, +[ + { + content: "select " + imageName, + trigger: '.oe_product_cart a:containsExact("' + imageName + '")', + }, + { + content: "click on the image", + trigger: imageSelector, + run: 'clicknoleave', + }, + { + content: "check there is no zoom on that small image", + trigger: 'body:not(:has(.zoomodoo-flyout img))', + }, + { + content: "change variant", + trigger: 'input[data-attribute_name="Beautiful Color"][data-value_name="' + nameGreen + '"]', + run: 'click', + }, + { + content: "wait for variant to be loaded", + trigger: '.oe_currency_value:contains("21.00")' + }, + { + content: "click on the image", + trigger: imageSelector, + run: 'clicknoleave', + }, + { + content: "check there is a zoom on that big image", + trigger: '.zoomodoo-flyout img', + }, +]); +}); diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss index 0b3b23bdea6..29e685b8b4a 100644 --- a/addons/website_sale/static/src/scss/website_sale.scss +++ b/addons/website_sale/static/src/scss/website_sale.scss @@ -80,7 +80,7 @@ right: 0; bottom: 0; overflow: hidden; - padding: 0 15px 24px 10px; + padding: 0 15px 0 10px; min-height: 56px; border-top: 1px solid rgba(255,255,255,0.2); background: rgba(255, 255, 255, 0.75); @@ -88,8 +88,6 @@ } .product_price { - padding: 5px 0; - position: absolute; bottom: 0; } @@ -612,6 +610,11 @@ a.no-decoration { } #o-carousel-product { + + &.css_not_available { + opacity: 0.2; + } + .carousel-inner div.item, .carousel-control-prev, .carousel-control-next { height: 400px; } diff --git a/addons/website_sale/tests/__init__.py b/addons/website_sale/tests/__init__.py index 8bb87e65936..89fc4f46d6f 100644 --- a/addons/website_sale/tests/__init__.py +++ b/addons/website_sale/tests/__init__.py @@ -1,2 +1,5 @@ +from . import test_customize from . import test_sale_process from . import test_website_sale_pricelist +from . import test_website_sale_product_attribute_value_config +from . import test_website_sale_zoom diff --git a/addons/website_sale/tests/test_customize.py b/addons/website_sale/tests/test_customize.py index e387c068a0b..11f10c4c5e0 100644 --- a/addons/website_sale/tests/test_customize.py +++ b/addons/website_sale/tests/test_customize.py @@ -2,8 +2,206 @@ import odoo.tests + @odoo.tests.common.tagged('post_install', '-at_install') class TestUi(odoo.tests.HttpCase): def test_01_admin_shop_customize_tour(self): + # create a template + product_template = self.env['product.template'].create({ + 'name': 'Test Product', + 'website_published': True, + 'list_price': 750, + }) + product_attribute = self.env.ref('product.product_attribute_1') + product_attribute_value_1 = self.env.ref('product.product_attribute_value_1') + product_attribute_value_2 = self.env.ref('product.product_attribute_value_2') + + # set attribute and attribute values on the template + self.env['product.template.attribute.line'].create([{ + 'attribute_id': product_attribute.id, + 'product_tmpl_id': product_template.id, + 'value_ids': [(6, 0, [product_attribute_value_1.id, product_attribute_value_2.id])] + }]) + + # set a different price on the variants to differentiate them + product_template_attribute_values = self.env['product.template.attribute.value'] \ + .search([('product_tmpl_id', '=', product_template.id)]) + + for ptav in product_template_attribute_values: + if ptav.name == "Steel": + ptav.price_extra = 0 + else: + ptav.price_extra = 50.4 + + product_template.create_variant_ids() + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_customize')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_customize.ready", login="admin") + + def test_02_admin_shop_custom_attribute_value_tour(self): + # Make sure pricelist rule exist + product_template = self.env.ref('product.product_product_4_product_template') + + # fix runbot, sometimes one pricelist is chosen, sometimes the other... + pricelists = self.env['website'].get_current_website().get_current_pricelist() | self.env.ref('product.list0') + + for pricelist in pricelists: + if not pricelist.item_ids.filtered(lambda i: i.product_tmpl_id == product_template and i.price_discount == 20): + self.env['product.pricelist.item'].create({ + 'base': 'list_price', + 'applied_on': '1_product', + 'pricelist_id': pricelist.id, + 'product_tmpl_id': product_template.id, + 'price_discount': 20, + 'min_quantity': 2, + 'compute_price': 'formula', + }) + + pricelist.discount_policy = 'without_discount' + + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_custom_attribute_value')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_custom_attribute_value.ready", login="admin") + + def test_03_public_tour_shop_dynamic_variants(self): + """ The goal of this test is to make sure product variants with dynamic + attributes can be created by the public user (when being added to cart). + """ + + # create the attribute + product_attribute = self.env['product.attribute'].create({ + 'name': "Dynamic Attribute", + 'create_variant': 'dynamic', + }) + + # create the attribute values + product_attribute_values = self.env['product.attribute.value'].create([{ + 'name': "Dynamic Value 1", + 'attribute_id': product_attribute.id, + 'sequence': 1, + }, { + 'name': "Dynamic Value 2", + 'attribute_id': product_attribute.id, + 'sequence': 2, + }]) + + # create the template + product_template = self.env['product.template'].create({ + 'name': 'Dynamic Product', + 'website_published': True, + 'list_price': 0, + }) + + # set attribute and attribute values on the template + self.env['product.template.attribute.line'].create([{ + 'attribute_id': product_attribute.id, + 'product_tmpl_id': product_template.id, + 'value_ids': [(6, 0, product_attribute_values.ids)] + }]) + + # set a different price on the variants to differentiate them + product_template_attribute_values = self.env['product.template.attribute.value'] \ + .search([('product_tmpl_id', '=', product_template.id)]) + + for ptav in product_template_attribute_values: + if ptav.name == "Dynamic Value 1": + ptav.price_extra = 10 + else: + # 0 to not bother with the pricelist of the public user + ptav.price_extra = 0 + + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_dynamic_variants')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_dynamic_variants.ready") + + def test_04_portal_tour_deleted_archived_variants(self): + """The goal of this test is to make sure deleted and archived variants + are shown as impossible combinations. + + Using "portal" to have various users in the tests. + """ + + # create the attribute + product_attribute = self.env['product.attribute'].create({ + 'name': "My Attribute", + 'create_variant': 'always', + }) + + # create the attribute values + product_attribute_values = self.env['product.attribute.value'].create([{ + 'name': "My Value 1", + 'attribute_id': product_attribute.id, + 'sequence': 1, + }, { + 'name': "My Value 2", + 'attribute_id': product_attribute.id, + 'sequence': 2, + }, { + 'name': "My Value 3", + 'attribute_id': product_attribute.id, + 'sequence': 3, + }]) + + # create the template + product_template = self.env['product.template'].create({ + 'name': 'Test Product', + 'website_published': True, + }) + + # set attribute and attribute values on the template + self.env['product.template.attribute.line'].create([{ + 'attribute_id': product_attribute.id, + 'product_tmpl_id': product_template.id, + 'value_ids': [(6, 0, product_attribute_values.ids)] + }]) + + # set a different price on the variants to differentiate them + product_template_attribute_values = self.env['product.template.attribute.value'] \ + .search([('product_tmpl_id', '=', product_template.id)]) + + product_template_attribute_values[0].price_extra = 10 + product_template_attribute_values[1].price_extra = 20 + product_template_attribute_values[2].price_extra = 30 + + product_template.create_variant_ids() + + # archive first and delete second + product_template.product_variant_ids[0].active = False + product_template.product_variant_ids[1].unlink() + + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_deleted_archived_variants')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_deleted_archived_variants.ready", login="portal") + + def test_05_demo_tour_no_variant_attribute(self): + """The goal of this test is to make sure attributes no_variant are + correctly added to cart. + + Using "demo" to have various users in the tests. + """ + + # create the attribute + product_attribute_no_variant = self.env['product.attribute'].create({ + 'name': "No Variant Attribute", + 'create_variant': 'no_variant', + }) + + # create the attribute value + product_attribute_value_no_variant = self.env['product.attribute.value'].create({ + 'name': "No Variant Value", + 'attribute_id': product_attribute_no_variant.id, + }) + + # create the template + product_template = self.env['product.template'].create({ + 'name': 'Test Product', + 'website_published': True, + }) + + # set attribute and attribute value on the template + ptal = self.env['product.template.attribute.line'].create([{ + 'attribute_id': product_attribute_no_variant.id, + 'product_tmpl_id': product_template.id, + 'value_ids': [(6, 0, product_attribute_value_no_variant.ids)] + }]) + + # set a price on the value + ptal.product_template_value_ids.price_extra = 10 + + product_template.create_variant_ids() + + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_no_variant_attribute')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_no_variant_attribute.ready", login="demo") diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py index 2995ca139cf..390b96e02d1 100644 --- a/addons/website_sale/tests/test_website_sale_pricelist.py +++ b/addons/website_sale/tests/test_website_sale_pricelist.py @@ -52,7 +52,7 @@ class TestWebsitePriceList(TransactionCase): ) return pls - def _test_get_pricelist_available_show(self): + def test_get_pricelist_available_show(self): show = True current_pl = False @@ -68,7 +68,7 @@ class TestWebsitePriceList(TransactionCase): self.assertEquals(len(set(pls.mapped('name')) & set(result)), len(pls), 'Test failed for %s (%s %s vs %s %s)' % (country, len(pls), pls.mapped('name'), len(result), result)) - def _test_get_pricelist_available_not_show(self): + def test_get_pricelist_available_not_show(self): show = False current_pl = False @@ -85,7 +85,7 @@ class TestWebsitePriceList(TransactionCase): self.assertEquals(len(set(pls.mapped('name')) & set(result)), len(pls), 'Test failed for %s (%s %s vs %s %s)' % (country, len(pls), pls.mapped('name'), len(result), result)) - def _test_get_pricelist_available_promocode(self): + def test_get_pricelist_available_promocode(self): christmas_pl = self.ref('website_sale.list_christmas') public_pl = self.ref('product.list0') self.args = { diff --git a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py new file mode 100644 index 00000000000..1c08075178f --- /dev/null +++ b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.sale.tests.test_sale_product_attribute_value_config import TestSaleProductAttributeValueSetup +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestWebsiteSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): + + def test_get_combination_info(self): + current_website = self.env['website'].get_current_website() + pricelist = current_website.get_current_pricelist() + + self.computer = self.computer.with_context(website_id=current_website.id) + + # make sure the pricelist has a 10% discount + pricelist.item_ids = self.env['product.pricelist.item'].create({ + 'price_discount': 10, + 'compute_price': 'formula', + }) + + discount_rate = 0.9 + + # make sure there is a 15% tax on the product + tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 15}) + self.computer.taxes_id = tax + tax_ratio = (100 + tax.amount) / 100 + + currency_ratio = 2 + pricelist.currency_id = self._setup_currency(currency_ratio) + + # ensure pricelist is set to with_discount + pricelist.discount_policy = 'with_discount' + self.computer.invalidate_cache() + + # CASE: B2B setting + self.env.ref('account.group_show_line_subtotals_tax_excluded').users |= self.env.user + self.env.ref('account.group_show_line_subtotals_tax_included').users -= self.env.user + + combination_info = self.computer._get_combination_info() + self.assertEqual(combination_info['price'], 2222 * discount_rate * currency_ratio) + self.assertEqual(combination_info['list_price'], 2222 * discount_rate * currency_ratio) + self.assertEqual(combination_info['has_discounted_price'], False) + + # CASE: B2C setting + self.env.ref('account.group_show_line_subtotals_tax_included').users |= self.env.user + self.env.ref('account.group_show_line_subtotals_tax_excluded').users -= self.env.user + + combination_info = self.computer._get_combination_info() + self.assertEqual(combination_info['price'], 2222 * discount_rate * currency_ratio * tax_ratio) + self.assertEqual(combination_info['list_price'], 2222 * discount_rate * currency_ratio * tax_ratio) + self.assertEqual(combination_info['has_discounted_price'], False) + + # CASE: pricelist 'without_discount' + pricelist.discount_policy = 'without_discount' + self.computer.invalidate_cache() + + # ideally we would need to use compare_amounts everywhere, but this is + # the only rounding where it fails without it + combination_info = self.computer._get_combination_info() + self.assertEqual(pricelist.currency_id.compare_amounts(combination_info['price'], 2222 * discount_rate * currency_ratio * tax_ratio), 0) + self.assertEqual(pricelist.currency_id.compare_amounts(combination_info['list_price'], 2222 * currency_ratio * tax_ratio), 0) + self.assertEqual(combination_info['has_discounted_price'], True) diff --git a/addons/website_sale/tests/test_website_sale_zoom.py b/addons/website_sale/tests/test_website_sale_zoom.py new file mode 100644 index 00000000000..d12a905af6d --- /dev/null +++ b/addons/website_sale/tests/test_website_sale_zoom.py @@ -0,0 +1,105 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +import io + +from PIL import Image + +import odoo.tests + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestWebsiteSaleZoom(odoo.tests.HttpCase): + + # registry_test_mode = False # uncomment to save the product to test in browser + + def test_01_admin_shop_zoom_tour(self): + color_red = '#CD5C5C' + name_red = 'Indian Red' + + color_green = '#228B22' + name_green = 'Forest Green' + + color_blue = '#4169E1' + name_blue = 'Royal Blue' + + # create the color attribute + product_attribute = self.env['product.attribute'].create({ + 'name': 'Beautiful Color', + 'type': 'color', + }) + + # create the color attribute values + attr_values = self.env['product.attribute.value'].create([{ + 'name': name_red, + 'attribute_id': product_attribute.id, + 'html_color': color_red, + 'sequence': 1, + }, { + 'name': name_green, + 'attribute_id': product_attribute.id, + 'html_color': color_green, + 'sequence': 2, + }, { + 'name': name_blue, + 'attribute_id': product_attribute.id, + 'html_color': color_blue, + 'sequence': 3, + }]) + + # first image (blue) for the template + f = io.BytesIO() + Image.new('RGB', (1920, 1080), color_blue).save(f, 'PNG') + f.seek(0) + blue_image = base64.b64encode(f.read()) + + # second image (red) for the variant 1, small image (no zoom) + f = io.BytesIO() + Image.new('RGB', (800, 500), color_red).save(f, 'PNG') + f.seek(0) + red_image = base64.b64encode(f.read()) + + # second image (green) for the variant 2, big image (zoom) + f = io.BytesIO() + Image.new('RGB', (1920, 1080), color_green).save(f, 'PNG') + f.seek(0) + green_image = base64.b64encode(f.read()) + + # create the first product and the template. + # Set image to blue (will be set on the template because it was empty) + product_red = self.env['product.product'].create({ + 'name': 'A Colorful Image', + 'image': blue_image, + 'attribute_value_ids': [(6, 0, attr_values.filtered(lambda l: l.name == name_red).ids)], + }) + + # now set the red image on the first variant + # that works because template image is not empty anymore + product_red.image = red_image + + # set the color attribute and values on the template + self.env['product.template.attribute.line'].create([{ + 'attribute_id': product_attribute.id, + 'product_tmpl_id': product_red.product_tmpl_id.id, + 'value_ids': [(6, 0, attr_values.ids)] + }]) + + # create the green variant + self.env['product.product'].create({ + 'image': green_image, + 'product_tmpl_id': product_red.product_tmpl_id.id, + 'attribute_value_ids': [(6, 0, attr_values.filtered(lambda l: l.name == name_green).ids)], + }) + + # set a different price on the variants to differentiate them + product_template_attribute_values = self.env['product.template.attribute.value'].search([('product_tmpl_id', '=', product_red.product_tmpl_id.id)]) + + for val in product_template_attribute_values: + if val.name == name_red: + val.price_extra = 10 + else: + val.price_extra = 20 + + # self.env.cr.commit() # uncomment to save the product to test in browser + + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_zoom')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_zoom.ready", login="admin") diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 4ff612c0941..8231728213b 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -29,6 +29,10 @@ + + + +
@@ -94,6 +98,11 @@