diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-01-09 15:44:05 +0000 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-01-09 15:56:53 +0000 |
| commit | 8aa8548d8ab68feb13f3bde51c557db84877e809 (patch) | |
| tree | 930790c1c84a4412db777986f6b2077808560d95 /addons | |
| parent | 55a48e341580a6a400c36b6ba0d7b12ff9997f12 (diff) | |
| parent | 3e4138deaa23b3631dc3fce33cd6d1b3be28ffbd (diff) | |
[MERGE] forward port branch 12.0 up to 3e4138deaa2
closes odoo/odoo#30045
Diffstat (limited to 'addons')
190 files changed, 5683 insertions, 1329 deletions
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: <a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a><br>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: <a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a><br>Reason: %s") % (invoice.id, invoice.number, description) + else: + message = _("This vendor bill credit note has been created from: <a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a><br>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 @@ <record id="group_show_line_subtotals_tax_excluded" model="res.groups"> <field name="name">Tax display B2B</field> <field name="comment">Show line subtotals without taxes (B2B)</field> + <field name="category_id" ref="base.module_category_hidden"/> </record> <record id="group_show_line_subtotals_tax_included" model="res.groups"> <field name="name">Tax display B2C</field> <field name="comment">Show line subtotals with taxes included (B2C)</field> + <field name="category_id" ref="base.module_category_hidden"/> </record> <record id="base.group_public" model="res.groups"> 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 @@ <act_window id="action_move_line_select_tax_audit" - name="Journal Items" + name="Journal Items for Tax Audit" context="{'search_default_account_id': [active_id]}" res_model="account.move.line" src_model="account.account" diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py index 51870ebeeb3..28e9f285c1d 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -51,7 +51,7 @@ class AccountPayment(models.Model): @api.onchange('amount','currency_id') def _onchange_amount(self): res = super(AccountPayment, self)._onchange_amount() - self.check_amount_in_words = self.currency_id.amount_to_text(self.amount) + self.check_amount_in_words = self.currency_id.amount_to_text(self.amount) if self.currency_id else '' return res def _check_communication(self, payment_method_id, communication): diff --git a/addons/account_facturx/data/facturx_templates.xml b/addons/account_facturx/data/facturx_templates.xml index 303de923ee7..393c2fa8c20 100644 --- a/addons/account_facturx/data/facturx_templates.xml +++ b/addons/account_facturx/data/facturx_templates.xml @@ -25,7 +25,13 @@ <!-- Amounts. --> <ram:SpecifiedLineTradeAgreement> - <t t-set="taxes" t-value="line.invoice_line_tax_ids.compute_all(line.price_unit)"/> + <t t-set="taxes" + t-value="line.invoice_line_tax_ids.compute_all( + line.price_unit, + currency=line.currency_id, + quantity=line.quantity, + product=line.product_id, + partner=record.partner_id)"/> <ram:GrossPriceProductTradePrice> <ram:ChargeAmount t-att-currencyID="currency.name" diff --git a/addons/base_address_extended/data/base_address_extended_data.xml b/addons/base_address_extended/data/base_address_extended_data.xml index 1e76cb4c5df..42a5a779589 100644 --- a/addons/base_address_extended/data/base_address_extended_data.xml +++ b/addons/base_address_extended/data/base_address_extended_data.xml @@ -4,5 +4,8 @@ <record id="base.nl" model="res.country"> <field eval="'%(street_name)s %(street_number)s/%(street_number2)s'" name="street_format" /> </record> + <record id="base.mx" model="res.country"> + <field eval="'%(street_name)s %(street_number)s/%(street_number2)s'" name="street_format" /> + </record> </data> </odoo> 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 @@ <field name="inherit_id" ref="base.view_partner_form"/> <field name="arch" type="xml"> <xpath expr="//field[@name='street']" position="replace"> - <field name="street" invisible="1"/> - <field name="street_name" placeholder="Street Name..." attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> - <div class="o_row"> - <label for="street_number" class="oe_edit_only"/> + <div> + <field name="street" class="oe_read_only"/> + </div> + <field name="street_name" placeholder="Street Name..." attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}" class="oe_edit_only"/> + <div class="oe_edit_only o_row"> + <label for="street_number"/> <span> </span> <field name="street_number" attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> - <label for="street_number2" class="oe_edit_only"/> + <label for="street_number2"/> <field name="street_number2" attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> </div> </xpath> diff --git a/addons/base_automation/models/__init__.py b/addons/base_automation/models/__init__.py index dd3d1a0ad3c..edef98c31b6 100644 --- a/addons/base_automation/models/__init__.py +++ b/addons/base_automation/models/__init__.py @@ -3,3 +3,4 @@ from . import base_automation from . import ir_actions +from . import ir_demo diff --git a/addons/base_automation/models/ir_demo.py b/addons/base_automation/models/ir_demo.py new file mode 100644 index 00000000000..e951c04ba33 --- /dev/null +++ b/addons/base_automation/models/ir_demo.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class IrDemo(models.TransientModel): + _inherit = 'ir.demo' + + def install_demo(self): + # Prevent the registry to reload while loading demo data, since `create` calls + # `_update_registry`. + self.pool.ready = False + try: + result = super().install_demo() + finally: + self.pool.ready = True + + # Reload the registry + self.env['base.automation']._update_registry() + + return result diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index c986850b9c1..a82e07a5de5 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -36,7 +36,7 @@ <div class="content-group"> <div class="mt16" groups="base.group_no_one"> <label for="external_report_layout_id" string="Template" class="col-3 col-lg-3 o_light_label"/> - <field name="external_report_layout_id" class="oe_inline"/> + <field name="external_report_layout_id" domain="[('type','=', 'qweb')]" class="oe_inline"/> </div> <div class="mt16"> <button name="change_report_template" string="Change Document Template" type="object" class="oe_link" icon="fa-arrow-right"/> diff --git a/addons/event_sale/__manifest__.py b/addons/event_sale/__manifest__.py index 2293b52dfb5..839ef948898 100644 --- a/addons/event_sale/__manifest__.py +++ b/addons/event_sale/__manifest__.py @@ -21,6 +21,7 @@ this event. 'depends': ['event', 'sale_management'], 'data': [ 'views/event_views.xml', + 'views/event_views_template.xml', 'views/product_views.xml', 'views/sale_order_views.xml', 'data/event_sale_data.xml', diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py index c41a6218f10..b749f615293 100644 --- a/addons/event_sale/models/sale_order.py +++ b/addons/event_sale/models/sale_order.py @@ -86,6 +86,6 @@ class SaleOrderLine(models.Model): lang=self.order_id.partner_id.lang, ) - return ticket.get_ticket_multiline_description_sale() + return ticket.get_ticket_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants() else: return super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product) diff --git a/addons/event_sale/static/src/js/tour.js b/addons/event_sale/static/src/js/tour.js new file mode 100644 index 00000000000..148e1e14032 --- /dev/null +++ b/addons/event_sale/static/src/js/tour.js @@ -0,0 +1,13 @@ +odoo.define("event_sale.tour", function (require) { + "use strict"; + + var tour = require('web_tour.tour'); + require('sale.tour'); + + var step_index = _.findIndex(tour.tours.sale_tour.steps, function (step) { + return (step.id === "form_button_save_clicked"); + }); + tour.tours.sale_tour.steps.splice(step_index, 0, { + trigger: ".modal-dialog .modal-footer .btn-primary:contains('Save & Close')", + }); +}); diff --git a/addons/event_sale/views/event_views_template.xml b/addons/event_sale/views/event_views_template.xml new file mode 100644 index 00000000000..ae3ffef9752 --- /dev/null +++ b/addons/event_sale/views/event_views_template.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="assets_backend_inherit_event_sale" inherit_id="web.assets_backend" name="Event sale backend assets"> + <xpath expr="script[last()]" position="after"> + <script type="text/javascript" src="/event_sale/static/src/js/tour.js"></script> + </xpath> + </template> +</odoo> diff --git a/addons/fleet/models/fleet_vehicle.py b/addons/fleet/models/fleet_vehicle.py index 9dfd593362f..e5101c76360 100644 --- a/addons/fleet/models/fleet_vehicle.py +++ b/addons/fleet/models/fleet_vehicle.py @@ -229,9 +229,11 @@ class FleetVehicle(models.Model): def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): domain = args or [] domain = expression.AND([domain, [('name', operator, name)]]) - partner_ids = self.env['res.partner']._search([('name', operator, name)], access_rights_uid=name_get_uid) - if partner_ids: - domain = expression.OR([domain, ['|', ('driver_id', 'in', partner_ids), ('driver_id', '=', False)]]) + # we don't want to override the domain's filter on driver_id if present + if not any(['driver_id' in element for element in domain]): + partner_ids = self.env['res.partner']._search([('name', operator, name)], access_rights_uid=name_get_uid) + if partner_ids: + domain = expression.OR([domain, ['|', ('driver_id', 'in', partner_ids), ('driver_id', '=', False)]]) rec = self._search(domain, limit=limit, access_rights_uid=name_get_uid) return self.browse(rec).name_get() diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py index fc18220eae4..1237a990e40 100644 --- a/addons/gamification/models/challenge.py +++ b/addons/gamification/models/challenge.py @@ -344,12 +344,12 @@ class Challenge(models.Model): date_clause = "" query_params = [line.id] if start_date: - date_clause += "AND g.start_date = %s" + date_clause += " AND g.start_date = %s" query_params.append(start_date) if end_date: - date_clause += "AND g.end_date = %s" + date_clause += " AND g.end_date = %s" query_params.append(end_date) - + query = """SELECT u.id AS user_id FROM res_users u LEFT JOIN gamification_goal g diff --git a/addons/hr_attendance/__init__.py b/addons/hr_attendance/__init__.py index dc5e6b693d1..7d34c7c054a 100644 --- a/addons/hr_attendance/__init__.py +++ b/addons/hr_attendance/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import controllers from . import models diff --git a/addons/hr_attendance/controllers/__init__.py b/addons/hr_attendance/controllers/__init__.py new file mode 100644 index 00000000000..65a8c12013d --- /dev/null +++ b/addons/hr_attendance/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import main diff --git a/addons/hr_attendance/controllers/main.py b/addons/hr_attendance/controllers/main.py new file mode 100644 index 00000000000..66534933c60 --- /dev/null +++ b/addons/hr_attendance/controllers/main.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request + + +class HrAttendance(http.Controller): + @http.route('/hr_attendance/kiosk_keepalive', auth='user', type='json') + def kiosk_keepalive(self): + request.httprequest.session.modified = True + return {} diff --git a/addons/hr_attendance/static/src/js/kiosk_mode.js b/addons/hr_attendance/static/src/js/kiosk_mode.js index 07b03aa8b97..cbb84282eff 100644 --- a/addons/hr_attendance/static/src/js/kiosk_mode.js +++ b/addons/hr_attendance/static/src/js/kiosk_mode.js @@ -69,7 +69,7 @@ var KioskMode = AbstractAction.extend({ _callServer: function () { // Make a call to the database to avoid the auto close of the session - return ajax.rpc("/web/webclient/version_info", {}); + return ajax.rpc("/hr_attendance/kiosk_keepalive", {}); }, }); diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index c76b0b832ed..31aab06c21a 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -250,6 +250,12 @@ class HolidaysRequest(models.Model): ('duration_check', "CHECK ( number_of_days >= 0 )", "If you want to change the number of days you should use the 'period' mode"), ] + @api.onchange('holiday_status_id') + def _onchange_holiday_status_id(self): + self.request_unit_half = False + self.request_unit_hours = False + self.request_unit_custom = False + @api.onchange('request_date_from_period', 'request_hour_from', 'request_hour_to', 'request_date_from', 'request_date_to', 'employee_id') @@ -319,7 +325,7 @@ class HolidaysRequest(models.Model): @api.onchange('holiday_type') def _onchange_type(self): if self.holiday_type == 'employee' and not self.employee_id: - self.employee_id = self.env.user.employee_ids[0].id + self.employee_id = self.env.user.employee_ids[:1].id self.mode_company_id = False self.category_id = False elif self.holiday_type == 'company' and not self.mode_company_id: @@ -329,7 +335,7 @@ class HolidaysRequest(models.Model): elif self.holiday_type == 'department' and not self.department_id: self.employee_id = False self.mode_company_id = False - self.department_id = self.env.user.employee_ids[0].department_id.id + self.department_id = self.env.user.employee_ids[:1].department_id.id self.category_id = False elif self.holiday_type == 'category': self.employee_id = False @@ -342,7 +348,7 @@ class HolidaysRequest(models.Model): if self.employee_id: self.department_id = self.employee_id.department_id - @api.onchange('date_from', 'date_to') + @api.onchange('date_from', 'date_to', 'employee_id') def _onchange_leave_dates(self): if self.date_from and self.date_to: self.number_of_days = self._get_number_of_days(self.date_from, self.date_to, self.employee_id.id) @@ -360,8 +366,11 @@ class HolidaysRequest(models.Model): def _compute_number_of_hours_display(self): for holiday in self: calendar = holiday.employee_id.resource_calendar_id or self.env.user.company_id.resource_calendar_id - number_of_hours = calendar.get_work_hours_count(self.date_from, self.date_to) - holiday.number_of_hours_display = number_of_hours or (holiday.number_of_days * HOURS_PER_DAY) + if holiday.date_from and holiday.date_to: + number_of_hours = calendar.get_work_hours_count(holiday.date_from, holiday.date_to) + holiday.number_of_hours_display = number_of_hours or (holiday.number_of_days * HOURS_PER_DAY) + else: + holiday.number_of_hours_display = 0 @api.multi @api.depends('state', 'employee_id', 'department_id') diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py index 3f9572b2614..af3baf9830b 100644 --- a/addons/hr_holidays/models/hr_leave_type.py +++ b/addons/hr_holidays/models/hr_leave_type.py @@ -99,7 +99,7 @@ class HolidaysType(models.Model): @api.multi @api.depends('validity_start', 'validity_stop') def _compute_valid(self): - dt = self._context.get('default_date_from') or fields.Datetime.now() + dt = self._context.get('default_date_from') or fields.Date.context_today(self) for holiday_type in self: if holiday_type.validity_start and holiday_type.validity_stop: @@ -110,7 +110,7 @@ class HolidaysType(models.Model): holiday_type.valid = True def _search_valid(self, operator, value): - dt = self._context.get('default_date_from') or fields.Datetime.now() + dt = self._context.get('default_date_from') or fields.Date.context_today(self) signs = ['>=', '<='] if operator == '=' else ['<=', '>='] diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml index 77f0a223f0c..ba3d05e03f0 100644 --- a/addons/hr_holidays/views/hr_leave_views.xml +++ b/addons/hr_holidays/views/hr_leave_views.xml @@ -287,7 +287,7 @@ <field name="name">hr.holidays.view.tree</field> <field name="model">hr.leave</field> <field name="arch" type="xml"> - <tree decoration-danger="state == 'refuse'" decoration-info="state == ' draft'" string="Leave Requests" + <tree decoration-danger="state == 'refuse'" decoration-info="state == 'draft'" string="Leave Requests" decoration-bf="message_needaction == True"> <field name="employee_id"/> <field name="department_id"/> diff --git a/addons/iap/models/iap.py b/addons/iap/models/iap.py index bf426114d04..0d39bbbdf83 100644 --- a/addons/iap/models/iap.py +++ b/addons/iap/models/iap.py @@ -67,7 +67,7 @@ def jsonrpc(url, method='call', params=None, timeout=15): e.data = response['error']['data'] raise e return response.get('result') - except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, requests.exceptions.Timeout) as e: + except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, requests.exceptions.Timeout, requests.exceptions.HTTPError) as e: raise exceptions.AccessError('The url that this service requested returned an error. Please contact the author the app. The url it tried to contact was ' + url) #---------------------------------------------------------- diff --git a/addons/im_livechat/static/src/scss/im_livechat_history.scss b/addons/im_livechat/static/src/scss/im_livechat_history.scss index 31421eec3e0..6fa231c58cb 100644 --- a/addons/im_livechat/static/src/scss/im_livechat_history.scss +++ b/addons/im_livechat/static/src/scss/im_livechat_history.scss @@ -1,4 +1,6 @@ .o_history_container{ + table-layout: fixed; + width: 200% !important; > tbody > tr > td { padding: 0px !important; } diff --git a/addons/l10n_be/data/account_chart_template_data.xml b/addons/l10n_be/data/account_chart_template_data.xml index 0b813ea780d..28975355dca 100644 --- a/addons/l10n_be/data/account_chart_template_data.xml +++ b/addons/l10n_be/data/account_chart_template_data.xml @@ -7,6 +7,6 @@ <field name="cash_account_code_prefix">570</field> <field name="transfer_account_code_prefix">580</field> <field name="currency_id" ref="base.EUR"/> - <field name="spoken_languages" eval="'nl_BE'"/> + <field name="spoken_languages" eval="'nl_BE;nl_NL'"/> </record> </odoo> diff --git a/addons/l10n_be/i18n_extra/nl_BE.po b/addons/l10n_be/i18n_extra/nl.po index 2a4b067ee33..2a4b067ee33 100644 --- a/addons/l10n_be/i18n_extra/nl_BE.po +++ b/addons/l10n_be/i18n_extra/nl.po diff --git a/addons/l10n_ca/data/account_chart_template_data.xml b/addons/l10n_ca/data/account_chart_template_data.xml index 71b465940b3..24ecb640074 100644 --- a/addons/l10n_ca/data/account_chart_template_data.xml +++ b/addons/l10n_ca/data/account_chart_template_data.xml @@ -9,6 +9,6 @@ <field name="transfer_account_code_prefix">113</field> <field name="currency_id" ref="base.CAD"/> <field name="use_anglo_saxon" eval="True"/> - <field name="spoken_languages" eval="'fr_CA'"/> + <field name="spoken_languages" eval="'fr_FR;fr_CA'"/> </record> </odoo> diff --git a/addons/l10n_ca/i18n_extra/fr_CA.po b/addons/l10n_ca/i18n_extra/fr.po index 98d6c7a8838..98d6c7a8838 100644 --- a/addons/l10n_ca/i18n_extra/fr_CA.po +++ b/addons/l10n_ca/i18n_extra/fr.po diff --git a/addons/l10n_nl/data/account_data.xml b/addons/l10n_nl/data/account_data.xml index 55cccae643d..78531cb730b 100644 --- a/addons/l10n_nl/data/account_data.xml +++ b/addons/l10n_nl/data/account_data.xml @@ -9,6 +9,9 @@ <record id="tax_group_6" model="account.tax.group"> <field name="name">BTW 6%</field> </record> + <record id="tax_group_9" model="account.tax.group"> + <field name="name">BTW 9%</field> + </record> <record id="tax_group_21" model="account.tax.group"> <field name="name">BTW 21%</field> </record> @@ -18,6 +21,9 @@ <record id="tax_group_6_eu" model="account.tax.group"> <field name="name">BTW 6% EU</field> </record> + <record id="tax_group_9_eu" model="account.tax.group"> + <field name="name">BTW 9% EU</field> + </record> <record id="tax_group_21_eu" model="account.tax.group"> <field name="name">BTW 21% EU</field> </record> @@ -27,6 +33,9 @@ <record id="tax_group_6_niet_eu" model="account.tax.group"> <field name="name">BTW 6% Niet EU</field> </record> + <record id="tax_group_9_niet_eu" model="account.tax.group"> + <field name="name">BTW 9% Niet EU</field> + </record> <record id="tax_group_21_niet_eu" model="account.tax.group"> <field name="name">BTW 21% Niet EU</field> </record> diff --git a/addons/l10n_nl/data/account_fiscal_position_tax_template.xml b/addons/l10n_nl/data/account_fiscal_position_tax_template.xml index 954e098cd89..c026f355bbb 100644 --- a/addons/l10n_nl/data/account_fiscal_position_tax_template.xml +++ b/addons/l10n_nl/data/account_fiscal_position_tax_template.xml @@ -16,6 +16,11 @@ <field name="tax_src_id" ref="btw_6"/> <field name="tax_dest_id" ref="btw_X1"/> </record> + <record id="position_tax_extracom_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_non_eu"/> + <field name="tax_src_id" ref="btw_9"/> + <field name="tax_dest_id" ref="btw_X1"/> + </record> <record id="position_tax_extracom_21" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_non_eu"/> <field name="tax_src_id" ref="btw_21"/> @@ -36,6 +41,11 @@ <field name="tax_src_id" ref="btw_6_d"/> <field name="tax_dest_id" ref="btw_X3"/> </record> + <record id="position_tax_extracom_d_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_non_eu"/> + <field name="tax_src_id" ref="btw_9_d"/> + <field name="tax_dest_id" ref="btw_X3"/> + </record> <record id="position_tax_extracom_d_21" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_non_eu"/> <field name="tax_src_id" ref="btw_21_d"/> @@ -52,6 +62,11 @@ <field name="tax_src_id" ref="btw_6_buy"/> <field name="tax_dest_id" ref="btw_E1"/> </record> + <record id="position_tax_extracom_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_non_eu"/> + <field name="tax_src_id" ref="btw_9_buy"/> + <field name="tax_dest_id" ref="btw_E1"/> + </record> <record id="position_tax_extracom_7" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_non_eu"/> <field name="tax_src_id" ref="btw_21_buy"/> @@ -67,6 +82,11 @@ <field name="tax_src_id" ref="btw_6_buy_d"/> <field name="tax_dest_id" ref="btw_E1"/> </record> + <record id="position_tax_extracom_d_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_non_eu"/> + <field name="tax_src_id" ref="btw_9_buy_d"/> + <field name="tax_dest_id" ref="btw_E1"/> + </record> <record id="position_tax_extracom_d_7" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_non_eu"/> <field name="tax_src_id" ref="btw_21_buy_d"/> @@ -89,6 +109,11 @@ <field name="tax_src_id" ref="btw_6"/> <field name="tax_dest_id" ref="btw_X0_diensten"/> </record> + <record id="position_tax_intracom_2_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_eu"/> + <field name="tax_src_id" ref="btw_9"/> + <field name="tax_dest_id" ref="btw_X0"/> + </record> <record id="position_tax_intracom_3" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_eu"/> <field name="tax_src_id" ref="btw_21"/> @@ -109,6 +134,11 @@ <field name="tax_src_id" ref="btw_6_d"/> <field name="tax_dest_id" ref="btw_X2"/> </record> + <record id="position_tax_intracom_d_2_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_eu"/> + <field name="tax_src_id" ref="btw_9_d"/> + <field name="tax_dest_id" ref="btw_X2"/> + </record> <record id="position_tax_intracom_d_3" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_eu"/> <field name="tax_src_id" ref="btw_21_d"/> @@ -124,6 +154,11 @@ <field name="tax_src_id" ref="btw_6_buy"/> <field name="tax_dest_id" ref="btw_I_6"/> </record> + <record id="position_tax_intracom_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_eu"/> + <field name="tax_src_id" ref="btw_9_buy"/> + <field name="tax_dest_id" ref="btw_I_9"/> + </record> <record id="position_tax_intracom_7" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_eu"/> <field name="tax_src_id" ref="btw_21_buy"/> @@ -139,6 +174,11 @@ <field name="tax_src_id" ref="btw_6_buy_d"/> <field name="tax_dest_id" ref="btw_I_6"/> </record> + <record id="position_tax_intracom_d_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_eu"/> + <field name="tax_src_id" ref="btw_9_buy_d"/> + <field name="tax_dest_id" ref="btw_I_9"/> + </record> <record id="position_tax_intracom_d_7" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_eu"/> <field name="tax_src_id" ref="btw_21_buy_d"/> diff --git a/addons/l10n_nl/data/account_tax_template.xml b/addons/l10n_nl/data/account_tax_template.xml index bebc212da6f..0eccb372d52 100644 --- a/addons/l10n_nl/data/account_tax_template.xml +++ b/addons/l10n_nl/data/account_tax_template.xml @@ -20,7 +20,7 @@ <record id="btw_6" model="account.tax.template"> <field name="sequence">10</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Verkopen/omzet laag</field> + <field name="name">Verkopen/omzet laag 6%</field> <field name="description">6% BTW</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -30,6 +30,19 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_05'),ref('tag_nl_22')])]"/> <field name="tax_group_id" ref="tax_group_6"/> </record> + <record id="btw_9" model="account.tax.template"> + <field name="sequence">10</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Verkopen/omzet laag 9%</field> + <field name="description">9% BTW</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_payable_l"/> + <field name="account_id" ref="vat_payable_l"/> + <field name="type_tax_use">sale</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_05'),ref('tag_nl_22')])]"/> + <field name="tax_group_id" ref="tax_group_9"/> + </record> <record id="btw_21" model="account.tax.template"> <field name="sequence">5</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> @@ -73,7 +86,7 @@ <record id="btw_6_d" model="account.tax.template"> <field name="sequence">10</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Verkopen/omzet laag diensten</field> + <field name="name">Verkopen/omzet laag diensten 6%</field> <field name="description">6% BTW diensten</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -83,6 +96,19 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_05'),ref('tag_nl_22')])]"/> <field name="tax_group_id" ref="tax_group_6"/> </record> + <record id="btw_9_d" model="account.tax.template"> + <field name="sequence">10</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Verkopen/omzet laag diensten 9%</field> + <field name="description">9% BTW diensten</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_payable_l_d"/> + <field name="account_id" ref="vat_payable_l_d"/> + <field name="type_tax_use">sale</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_05'),ref('tag_nl_22')])]"/> + <field name="tax_group_id" ref="tax_group_9"/> + </record> <record id="btw_21_d" model="account.tax.template"> <field name="sequence">6</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> @@ -113,7 +139,7 @@ <record id="btw_6_buy" model="account.tax.template"> <field name="sequence">10</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">BTW te vorderen laag (inkopen)</field> + <field name="name">BTW te vorderen laag (inkopen) 6%</field> <field name="description">6% BTW</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -123,10 +149,24 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> <field name="tax_group_id" ref="tax_group_6"/> </record> + <record id="btw_9_buy" model="account.tax.template"> + <field name="sequence">10</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">BTW te vorderen laag (inkopen) 9%</field> + <field name="description">9% BTW</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_refund_l"/> + <field name="account_id" ref="vat_refund_l"/> + <field name="type_tax_use">purchase</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> + <field name="tax_group_id" ref="tax_group_9"/> + </record> + <record id="btw_6_buy_incl" model="account.tax.template"> <field name="sequence">10</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">BTW te vorderen laag (inkopen incl. BTW)</field> + <field name="name">BTW te vorderen laag (inkopen incl. BTW) 6%</field> <field name="description">6% BTW Incl.</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -137,6 +177,20 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> <field name="tax_group_id" ref="tax_group_6"/> </record> + <record id="btw_9_buy_incl" model="account.tax.template"> + <field name="sequence">10</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">BTW te vorderen laag (inkopen incl. BTW) 9%</field> + <field name="description">9% BTW Incl.</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="price_include">True</field> + <field name="refund_account_id" ref="vat_refund_l"/> + <field name="account_id" ref="vat_refund_l"/> + <field name="type_tax_use">purchase</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> + <field name="tax_group_id" ref="tax_group_9"/> + </record> <record id="btw_21_buy" model="account.tax.template"> <field name="sequence">5</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> @@ -181,7 +235,7 @@ <record id="btw_6_buy_d" model="account.tax.template"> <field name="sequence">10</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">BTW te vorderen laag (inkopen) diensten</field> + <field name="name">BTW te vorderen laag (inkopen) diensten 6%</field> <field name="description">6% BTW diensten</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -191,6 +245,19 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> <field name="tax_group_id" ref="tax_group_6"/> </record> + <record id="btw_9_buy_d" model="account.tax.template"> + <field name="sequence">10</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">BTW te vorderen laag (inkopen) diensten 9%</field> + <field name="description">9% BTW diensten</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_refund_l_d"/> + <field name="account_id" ref="vat_refund_l_d"/> + <field name="type_tax_use">purchase</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> + <field name="tax_group_id" ref="tax_group_9"/> + </record> <record id="btw_21_buy_d" model="account.tax.template"> <field name="sequence">6</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> @@ -231,7 +298,7 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_08')])]"/> <field name="tax_group_id" ref="tax_group_0"/> </record> - <record id="btw_ink_0_1" model="account.tax.template"> + <record id="btw_ink_0_1" model="account.tax.template"> <field name="sequence">98</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="name">BTW te vorderen verlegd (inkopen1)</field> @@ -276,7 +343,7 @@ <record id="btw_I_6_1" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="sequence">21</field> - <field name="name">Inkopen import binnen EU laag(1)</field> + <field name="name">Inkopen import binnen EU laag(1) 6%</field> <field name="description">Inkopen import binnen EU laag(1)</field> <field eval="-6" name="amount"/> <field name="amount_type">percent</field> @@ -286,10 +353,23 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_30')])]"/> <field name="tax_group_id" ref="tax_group_6_eu"/> </record> + <record id="btw_I_9_1" model="account.tax.template"> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="sequence">21</field> + <field name="name">Inkopen import binnen EU laag(1) 9%</field> + <field name="description">Inkopen import binnen EU laag(1)</field> + <field eval="-9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_payable_l_eu"/> + <field name="account_id" ref="vat_payable_l_eu"/> + <field name="type_tax_use">none</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_30')])]"/> + <field name="tax_group_id" ref="tax_group_9_eu"/> + </record> <record id="btw_I_6_2" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="sequence">22</field> - <field name="name">Inkopen import binnen EU laag(2)</field> + <field name="name">Inkopen import binnen EU laag(2) 6%</field> <field name="description">Inkopen import binnen EU laag(2)</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -299,10 +379,23 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> <field name="tax_group_id" ref="tax_group_6_eu"/> </record> + <record id="btw_I_9_2" model="account.tax.template"> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="sequence">22</field> + <field name="name">Inkopen import binnen EU laag(2) 9%</field> + <field name="description">Inkopen import binnen EU laag(2)</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_refund_l_eu"/> + <field name="account_id" ref="vat_refund_l_eu"/> + <field name="type_tax_use">none</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> + <field name="tax_group_id" ref="tax_group_9_eu"/> + </record> <record id="btw_I_6" model="account.tax.template"> <field name="sequence">20</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Inkopen import binnen EU laag</field> + <field name="name">Inkopen import binnen EU laag 6%</field> <field name="description">6% BTW import binnen EU</field> <field eval="6" name="amount"/> <field name="amount_type">group</field> @@ -311,6 +404,18 @@ <field name="children_tax_ids" eval="[(6, 0, [ref('btw_I_6_1'), ref('btw_I_6_2')])]"/> <field name="tax_group_id" ref="tax_group_6_eu"/> </record> + <record id="btw_I_9" model="account.tax.template"> + <field name="sequence">20</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Inkopen import binnen EU laag 9%</field> + <field name="description">9% BTW import binnen EU</field> + <field eval="9" name="amount"/> + <field name="amount_type">group</field> + <field name="type_tax_use">purchase</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_17')])]"/> + <field name="children_tax_ids" eval="[(6, 0, [ref('btw_I_9_1'), ref('btw_I_9_2')])]"/> + <field name="tax_group_id" ref="tax_group_9_eu"/> + </record> <record id="btw_I_21_1" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="sequence">21</field> @@ -431,7 +536,7 @@ <record id="btw_I_6_d_1" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="sequence">21</field> - <field name="name">Inkopen import binnen EU laag(1) diensten</field> + <field name="name">Inkopen import binnen EU laag(1) diensten 6%</field> <field name="description">Inkopen import binnen EU laag(1) diensten</field> <field eval="-6" name="amount"/> <field name="amount_type">percent</field> @@ -441,10 +546,23 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_30')])]"/> <field name="tax_group_id" ref="tax_group_6_eu"/> </record> + <record id="btw_I_9_d_1" model="account.tax.template"> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="sequence">21</field> + <field name="name">Inkopen import binnen EU laag(1) diensten 9%</field> + <field name="description">Inkopen import binnen EU laag(1) diensten</field> + <field eval="-9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_payable_l_d_eu"/> + <field name="account_id" ref="vat_payable_l_d_eu"/> + <field name="type_tax_use">none</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_30')])]"/> + <field name="tax_group_id" ref="tax_group_9_eu"/> + </record> <record id="btw_I_6_d_2" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="sequence">22</field> - <field name="name">Inkopen import binnen EU laag(2) diensten</field> + <field name="name">Inkopen import binnen EU laag(2) diensten 6%</field> <field name="description">Inkopen import binnen EU laag(2) diensten</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -454,10 +572,23 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> <field name="tax_group_id" ref="tax_group_6_eu"/> </record> + <record id="btw_I_9_d_2" model="account.tax.template"> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="sequence">22</field> + <field name="name">Inkopen import binnen EU laag(2) diensten 9%</field> + <field name="description">Inkopen import binnen EU laag(2) diensten</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_refund_l_d_eu"/> + <field name="account_id" ref="vat_refund_l_d_eu"/> + <field name="type_tax_use">none</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> + <field name="tax_group_id" ref="tax_group_9_eu"/> + </record> <record id="btw_I_6_d" model="account.tax.template"> <field name="sequence">20</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Inkopen import binnen EU laag diensten</field> + <field name="name">Inkopen import binnen EU laag diensten 6%</field> <field name="description">6% BTW import binnen EU diensten</field> <field eval="6" name="amount"/> <field name="amount_type">group</field> @@ -466,6 +597,18 @@ <field name="children_tax_ids" eval="[(6, 0, [ref('btw_I_6_d_1'), ref('btw_I_6_d_2')])]"/> <field name="tax_group_id" ref="tax_group_6_eu"/> </record> + <record id="btw_I_9_d" model="account.tax.template"> + <field name="sequence">20</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Inkopen import binnen EU laag diensten 9%</field> + <field name="description">9% BTW import binnen EU diensten</field> + <field eval="9" name="amount"/> + <field name="amount_type">group</field> + <field name="type_tax_use">purchase</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_17')])]"/> + <field name="children_tax_ids" eval="[(6, 0, [ref('btw_I_9_d_1'), ref('btw_I_9_d_2')])]"/> + <field name="tax_group_id" ref="tax_group_9_eu"/> + </record> <record id="btw_I_21_d_1" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="sequence">21</field> @@ -547,7 +690,7 @@ <!-- BTW inkoop --> <record id="btw_E1_1" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Inkopen import buiten EU laag(1)</field> + <field name="name">Inkopen import buiten EU laag(1) 6%</field> <field name="description">Inkopen import buiten EU laag(1)</field> <field eval="-6" name="amount"/> <field name="amount_type">percent</field> @@ -558,9 +701,22 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_29')])]"/> <field name="tax_group_id" ref="tax_group_6_niet_eu"/> </record> + <record id="btw_E1_1_9" model="account.tax.template"> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Inkopen import buiten EU laag(1) 9%</field> + <field name="description">Inkopen import buiten EU laag(1)</field> + <field eval="-9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_payable_l_non_eu"/> + <field name="account_id" ref="vat_payable_l_non_eu"/> + <field name="sequence">21</field> + <field name="type_tax_use">none</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_29')])]"/> + <field name="tax_group_id" ref="tax_group_9_niet_eu"/> + </record> <record id="btw_E1_2" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Inkopen import buiten EU laag(2)</field> + <field name="name">Inkopen import buiten EU laag(2) 6%</field> <field name="description">Inkopen import buiten EU laag(2)</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -571,10 +727,23 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> <field name="tax_group_id" ref="tax_group_6_niet_eu"/> </record> + <record id="btw_E1_2_9" model="account.tax.template"> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Inkopen import buiten EU laag(2) 9%</field> + <field name="description">Inkopen import buiten EU laag(2)</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_refund_l_non_eu"/> + <field name="account_id" ref="vat_refund_l_non_eu"/> + <field name="sequence">22</field> + <field name="type_tax_use">none</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> + <field name="tax_group_id" ref="tax_group_9_niet_eu"/> + </record> <record id="btw_E1" model="account.tax.template"> <field name="sequence">20</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Inkopen import buiten EU laag</field> + <field name="name">Inkopen import buiten EU laag 6%</field> <field name="description">BTW import buiten EU laag inkopen</field> <field eval="6" name="amount"/> <field name="amount_type">group</field> @@ -583,6 +752,18 @@ <field name="children_tax_ids" eval="[(6, 0, [ref('btw_E1_1'), ref('btw_E1_2')])]"/> <field name="tax_group_id" ref="tax_group_6_niet_eu"/> </record> + <record id="btw_E1_9" model="account.tax.template"> + <field name="sequence">20</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Inkopen import buiten EU laag 9%</field> + <field name="description">BTW import buiten EU laag inkopen</field> + <field eval="9" name="amount"/> + <field name="amount_type">group</field> + <field name="type_tax_use">purchase</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_16')])]"/> + <field name="children_tax_ids" eval="[(6, 0, [ref('btw_E1_1_9'), ref('btw_E1_2_9')])]"/> + <field name="tax_group_id" ref="tax_group_9_niet_eu"/> + </record> <record id="btw_E2_1" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="name">Inkopen import buiten EU hoog(1)</field> @@ -689,7 +870,7 @@ <!-- BTW inkoop diensten --> <record id="btw_E1_d_1" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Inkopen import buiten EU laag(1) diensten</field> + <field name="name">Inkopen import buiten EU laag(1) diensten 6%</field> <field name="description">Inkopen import buiten EU laag(1) diensten</field> <field eval="-6" name="amount"/> <field name="amount_type">percent</field> @@ -700,9 +881,22 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_29')])]"/> <field name="tax_group_id" ref="tax_group_6_niet_eu"/> </record> + <record id="btw_E1_d_1_9" model="account.tax.template"> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Inkopen import buiten EU laag(1) diensten 9%</field> + <field name="description">Inkopen import buiten EU laag(1) diensten</field> + <field eval="-9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_payable_l_d_non_eu"/> + <field name="account_id" ref="vat_payable_l_d_non_eu"/> + <field name="sequence">21</field> + <field name="type_tax_use">none</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_29')])]"/> + <field name="tax_group_id" ref="tax_group_9_niet_eu"/> + </record> <record id="btw_E1_d_2" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Inkopen import buiten EU laag(2) diensten</field> + <field name="name">Inkopen import buiten EU laag(2) diensten 6%</field> <field name="description">Inkopen import buiten EU laag(2) diensten</field> <field eval="6" name="amount"/> <field name="amount_type">percent</field> @@ -713,10 +907,23 @@ <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> <field name="tax_group_id" ref="tax_group_6_niet_eu"/> </record> + <record id="btw_E1_d_2_9" model="account.tax.template"> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Inkopen import buiten EU laag(2) diensten 9%</field> + <field name="description">Inkopen import buiten EU laag(2) diensten</field> + <field eval="9" name="amount"/> + <field name="amount_type">percent</field> + <field name="refund_account_id" ref="vat_refund_l_d_non_eu"/> + <field name="account_id" ref="vat_refund_l_d_non_eu"/> + <field name="sequence">22</field> + <field name="type_tax_use">none</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/> + <field name="tax_group_id" ref="tax_group_9_niet_eu"/> + </record> <record id="btw_E1_d" model="account.tax.template"> <field name="sequence">20</field> <field name="chart_template_id" ref="l10nnl_chart_template"/> - <field name="name">Inkopen import buiten EU laag diensten</field> + <field name="name">Inkopen import buiten EU laag diensten 6%</field> <field name="description">BTW import buiten EU laag inkopen diensten</field> <field eval="6" name="amount"/> <field name="amount_type">group</field> @@ -725,6 +932,18 @@ <field name="children_tax_ids" eval="[(6, 0, [ref('btw_E1_d_1'), ref('btw_E1_d_2')])]"/> <field name="tax_group_id" ref="tax_group_6_niet_eu"/> </record> + <record id="btw_E1_d_9" model="account.tax.template"> + <field name="sequence">20</field> + <field name="chart_template_id" ref="l10nnl_chart_template"/> + <field name="name">Inkopen import buiten EU laag diensten 9%</field> + <field name="description">BTW import buiten EU laag inkopen diensten</field> + <field eval="9" name="amount"/> + <field name="amount_type">group</field> + <field name="type_tax_use">purchase</field> + <field name="tag_ids" eval="[(6,0,[ref('tag_nl_16')])]"/> + <field name="children_tax_ids" eval="[(6, 0, [ref('btw_E1_d_1_9'), ref('btw_E1_d_2_9')])]"/> + <field name="tax_group_id" ref="tax_group_9_niet_eu"/> + </record> <record id="btw_E2_d_1" model="account.tax.template"> <field name="chart_template_id" ref="l10nnl_chart_template"/> <field name="name">Inkopen import buiten EU hoog(1) diensten</field> diff --git a/addons/l10n_no/data/account_data.xml b/addons/l10n_no/data/account_data.xml index 72f40f19ae7..b0c9d690ce6 100644 --- a/addons/l10n_no/data/account_data.xml +++ b/addons/l10n_no/data/account_data.xml @@ -9,6 +9,9 @@ <record id="tax_group_10" model="account.tax.group"> <field name="name">MVA 10%</field> </record> + <record id="tax_group_12" model="account.tax.group"> + <field name="name">MVA 12%</field> + </record> <record id="tax_group_15" model="account.tax.group"> <field name="name">MVA 15%</field> </record> diff --git a/addons/l10n_no/data/account_tax_data.xml b/addons/l10n_no/data/account_tax_data.xml index 2609ee6f0bc..814f585514e 100644 --- a/addons/l10n_no/data/account_tax_data.xml +++ b/addons/l10n_no/data/account_tax_data.xml @@ -103,7 +103,19 @@ <field name="account_id" ref="chart2704"/> <field name="refund_account_id" ref="chart2704"/> <field name="tag_ids" eval="[(6,0,[ref('tag_no_06'),ref('tag_no_15')])]"/> - <field name="tax_group_id" ref="tax_group_10"/> + <field name="tax_group_id" ref="tax_group_10"/> + </record> + <record id="tax3b" model="account.tax.template"> + <field name="chart_template_id" ref="no_chart_template"/> + <field name="name">Utgående 12% mva</field> + <field name="description">Utgående 12% mva</field> + <field name="amount">12</field> + <field name="amount_type">percent</field> + <field name="type_tax_use">sale</field> + <field name="account_id" ref="chart2704"/> + <field name="refund_account_id" ref="chart2704"/> + <field name="tag_ids" eval="[(6,0,[ref('tag_no_06'),ref('tag_no_15')])]"/> + <field name="tax_group_id" ref="tax_group_12"/> </record> <record id="tax4" model="account.tax.template"> <field name="chart_template_id" ref="no_chart_template"/> @@ -139,9 +151,20 @@ <field name="account_id" ref="chart2714"/> <field name="refund_account_id" ref="chart2714"/> <field name="tag_ids" eval="[(6,0,[ref('tag_no_09'),ref('tag_no_12')])]"/> - <field name="tax_group_id" ref="tax_group_10"/> + <field name="tax_group_id" ref="tax_group_10"/> + </record> + <record id="tax6b" model="account.tax.template"> + <field name="chart_template_id" ref="no_chart_template"/> + <field name="name">Inngående 12% mva</field> + <field name="description">Inngående 12% mva</field> + <field name="amount">12</field> + <field name="amount_type">percent</field> + <field name="type_tax_use">purchase</field> + <field name="account_id" ref="chart2714"/> + <field name="refund_account_id" ref="chart2714"/> + <field name="tag_ids" eval="[(6,0,[ref('tag_no_09'),ref('tag_no_12')])]"/> + <field name="tax_group_id" ref="tax_group_12"/> </record> - <record id="tax7" model="account.tax.template"> <field name="chart_template_id" ref="no_chart_template"/> <field name="name">Inngående 0% mva</field> diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index 05e61a912dd..d2083aa1b65 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -314,7 +314,7 @@ <div> <p> <strong><span t-attf-class="fa #{activity.activity_type_id.icon}"/></strong> <span t-field="activity.activity_type_id.name"/> done - by <span t-field="activity.user_id.name"/><span t-if="activity.summary">:</span> + by <span t-field="activity.env.user.name"/><span t-if="activity.summary">:</span> <span t-if="activity.summary" t-field="activity.summary"/> </p> <div t-field="activity.note" style="margin-left: 8px;"/> diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index f7384a87b8f..99996615094 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -280,6 +280,7 @@ class Channel(models.Model): channel_partner = self.mapped('channel_last_seen_partner_ids').filtered(lambda cp: cp.partner_id == self.env.user.partner_id) if not channel_partner: return self.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id})]}) + return False @api.multi def action_unfollow(self): @@ -852,12 +853,12 @@ class Channel(models.Model): @api.multi def channel_join_and_get_info(self): self.ensure_one() - if self.channel_type == 'channel' and not self.email_send: + added = self.action_follow() + if added and self.channel_type == 'channel' and not self.email_send: notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,) self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") - self.action_follow() - if self.moderation_guidelines: + if added and self.moderation_guidelines: self._send_guidelines(self.env.user.partner_id) channel_info = self.channel_info('join')[0] diff --git a/addons/mail/static/src/js/tools/debug_manager.js b/addons/mail/static/src/js/tools/debug_manager.js index 4c768e8bb2d..452a062806f 100644 --- a/addons/mail/static/src/js/tools/debug_manager.js +++ b/addons/mail/static/src/js/tools/debug_manager.js @@ -23,6 +23,10 @@ DebugManager.include({ views: [[false, 'list'], [false, 'form']], type: 'ir.actions.act_window', domain: [['res_id', '=', selectedIDs[0]], ['model', '=', this._controller.modelName]], + context: { + default_res_model: this._controller.modelName, + default_res_id: selectedIDs[0], + }, }); }, }); diff --git a/addons/mail/static/tests/tools/debug_manager_tests.js b/addons/mail/static/tests/tools/debug_manager_tests.js index fa7aecd40d1..8b9312ca733 100644 --- a/addons/mail/static/tests/tools/debug_manager_tests.js +++ b/addons/mail/static/tests/tools/debug_manager_tests.js @@ -14,6 +14,10 @@ QUnit.module('Mail DebugManager', {}, function () { intercepts: { do_action: function (event) { assert.deepEqual(event.data.action, { + context: { + default_res_model: "testModel", + default_res_id: 5, + }, res_model: 'mail.message', name: "Manage Messages", views: [[false, 'list'], [false, 'form']], diff --git a/addons/mail/wizard/mail_resend_message.py b/addons/mail/wizard/mail_resend_message.py index c8ea6bb73c3..12255735871 100644 --- a/addons/mail/wizard/mail_resend_message.py +++ b/addons/mail/wizard/mail_resend_message.py @@ -68,7 +68,7 @@ class MailResendMessage(models.TransientModel): rdata = [] for pid, cid, active, pshare, ctype, notif, groups in self.env['mail.followers']._get_recipient_data(None, False, pids=to_send.ids): if pid and notif == 'email' or not notif: - pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups} + pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups or []} if not pshare and notif: # has an user and is not shared, is therefore user rdata.append(dict(pdata, type='user')) elif pshare and notif: # has an user and is shared, is therefore portal diff --git a/addons/mail_bot/static/src/js/mailbot_service.js b/addons/mail_bot/static/src/js/mailbot_service.js index 8863ea94ba0..5312e5d5129 100644 --- a/addons/mail_bot/static/src/js/mailbot_service.js +++ b/addons/mail_bot/static/src/js/mailbot_service.js @@ -12,15 +12,9 @@ var MailBotService = AbstractService.extend({ * @override */ start: function () { - var self = this; + this._hasRequest = (window.Notification && window.Notification.permission === "default") || false; if ('odoobot_initialized' in session && ! session.odoobot_initialized) { - setTimeout(function () { - session.odoobot_initialized = true; - self._rpc({ - model: 'mail.channel', - method: 'init_odoobot', - }); - }, 2*60*1000); + this._showOdoobotTimeout(); } }, @@ -60,7 +54,31 @@ var MailBotService = AbstractService.extend({ * @returns {boolean} */ isRequestingForNativeNotifications: function () { - return window.Notification && window.Notification.permission === "default"; + return this._hasRequest; + }, + /** + * Called when user either accepts or refuses push notifications. + */ + removeRequest: function () { + this._hasRequest = false; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _showOdoobotTimeout: function () { + var self = this; + setTimeout(function () { + session.odoobot_initialized = true; + self._rpc({ + model: 'mail.channel', + method: 'init_odoobot', + }); + }, 2*60*1000); }, }); diff --git a/addons/mail_bot/static/src/js/systray_messaging_menu.js b/addons/mail_bot/static/src/js/systray_messaging_menu.js index f7e64ff751e..3b7ee2f9e38 100644 --- a/addons/mail_bot/static/src/js/systray_messaging_menu.js +++ b/addons/mail_bot/static/src/js/systray_messaging_menu.js @@ -56,6 +56,7 @@ return MessagingMenu.include({ * @param {string} value */ _handleResponseNotificationPermission: function (value) { + this.call('mailbot_service', 'removeRequest'); if (value !== 'granted') { this.call('bus_service', 'sendNotification', _t('Permission denied'), _t('Odoo will not have the permission to send native notifications on this device.')); diff --git a/addons/mail_bot/static/tests/systray_messaging_menu_tests.js b/addons/mail_bot/static/tests/systray_messaging_menu_tests.js index 88f5c161f66..d22c9ca80da 100644 --- a/addons/mail_bot/static/tests/systray_messaging_menu_tests.js +++ b/addons/mail_bot/static/tests/systray_messaging_menu_tests.js @@ -24,31 +24,44 @@ QUnit.module('MessagingMenu', { }, }; - // Patch mailbot_service so that it does do not do any RPC, and - // the detection of push notifications permissions can be simulated in - // the test cases. By default, shows 'OdooBot has a request' + this.services = _.extend({}, mailTestUtils.getMailServices(), { + mailbot_service: MailBotService + }); + + // By default, permission are to ask user for push notification ("default"). + // Use requestPermissionDef to simulate permission change, e.g. "granted" + this.requestPermissionDef = $.Deferred(); + this.ORIGINAL_WINDOW_NOTIFICATION = window.Notification; + window.Notification = { + permission: "default", + requestPermission: function () { + return self.requestPermissionDef; + }, + }; + + // Patch mailbot_service so that it does do not do any RPC this.isMailbotRequesting = true; testUtils.mock.patch(MailBotService, { /** * @override */ - start: function () {}, + _showOdoobotTimeout: function () {}, + }); + + // Patch Bus Service so that it does not play any audio (may raise + // Uncaught rejected Promise due to Chrome autoplay policy: https://goo.gl/xX8pDD) + testUtils.patch(this.services.bus_service, { /** * @override - * @returns {boolean} */ - isRequestingForNativeNotifications: function () { - return self.isMailbotRequesting; - }, - }); - - this.services = _.extend({}, mailTestUtils.getMailServices(), { - mailbot_service: MailBotService + _beep: function () {}, }); }, afterEach: function () { - // unpatch MailBotService + // unpatch MailBotService and BusService testUtils.mock.unpatch(MailBotService); + testUtils.mock.unpatch(this.services.bus_service); + window.Notification = this.ORIGINAL_WINDOW_NOTIFICATION; } }); @@ -79,10 +92,10 @@ QUnit.test('messaging menu widget: rendering with OdooBot has a request', functi messagingMenu.destroy(); }); -QUnit.test('messaging menu widget: rendering without OdooBot has a request', function (assert) { +QUnit.test('messaging menu widget: rendering without OdooBot has a request (denied)', function (assert) { assert.expect(3); - this.isMailbotRequesting = false; + window.Notification.permission = 'denied'; var messagingMenu = new MessagingMenu(); testUtils.mock.addMockEnvironment(messagingMenu, { @@ -102,6 +115,60 @@ QUnit.test('messaging menu widget: rendering without OdooBot has a request', fun messagingMenu.destroy(); }); +QUnit.test('messaging menu widget: rendering without OdooBot has a request (accepted)', function (assert) { + assert.expect(3); + + window.Notification.permission = 'granted'; + + var messagingMenu = new MessagingMenu(); + testUtils.addMockEnvironment(messagingMenu, { + data: this.data, + services: this.services, + }); + messagingMenu.appendTo($('#qunit-fixture')); + + assert.containsOnce(messagingMenu, '.o_notification_counter', + "should display a notification counter next to the messaging menu"); + assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '0', + "should display a counter of '0' next to the messaging menu"); + testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + assert.containsNone(messagingMenu, '.o_preview_info', + "should display no preview in the messaging menu"); + + messagingMenu.destroy(); +}); + +QUnit.test('messaging menu widget: respond to notification prompt', function (assert) { + assert.expect(4); + + var messagingMenu = new MessagingMenu(); + testUtils.addMockEnvironment(messagingMenu, { + data: this.data, + services: this.services, + }); + messagingMenu.appendTo($('#qunit-fixture')); + + assert.containsOnce(messagingMenu, '.o_notification_counter', + "should display a notification counter next to the messaging menu"); + assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '1', + "should display a counter of '1' next to the messaging menu"); + + testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + testUtils.dom.click(messagingMenu.$('.o_preview_info')); + + // simulate "default" response, which is equivalent to "Not Now" in Firefox. + this.requestPermissionDef.resolve("default"); + + assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '0', + "should display a counter of '0' next to the messaging menu"); + + testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + assert.containsNone(messagingMenu, '.o_preview_info', + "should display no preview in the messaging menu"); + + messagingMenu.destroy(); +}); + }); }); diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index b756f77957f..63d7a0c5156 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -291,7 +291,7 @@ class MrpBomLine(models.Model): @api.onchange('parent_product_tmpl_id') def onchange_parent_product(self): return {'domain': {'attribute_value_ids': [ - ('id', 'in', self.parent_product_tmpl_id.mapped('attribute_line_ids.value_ids.id')), + ('id', 'in', self.parent_product_tmpl_id._get_valid_product_attribute_values().ids), ('attribute_id.create_variant', '!=', 'no_variant') ]}} diff --git a/addons/mrp/wizard/mrp_product_produce.py b/addons/mrp/wizard/mrp_product_produce.py index 1e3e017b1c1..90568cc7fbf 100644 --- a/addons/mrp/wizard/mrp_product_produce.py +++ b/addons/mrp/wizard/mrp_product_produce.py @@ -46,7 +46,7 @@ class MrpProductProduce(models.TransientModel): product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure') lot_id = fields.Many2one('stock.production.lot', string='Lot/Serial Number') produce_line_ids = fields.One2many('mrp.product.produce.line', 'product_produce_id', string='Product to Track') - product_tracking = fields.Selection(related="product_id.tracking", readonly=False) + product_tracking = fields.Selection(related="product_id.tracking", readonly=True) def action_generate_serial(self): self.ensure_one() diff --git a/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js b/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js index c36d2f1a29c..50872153d8c 100644 --- a/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js +++ b/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js @@ -56,6 +56,12 @@ var PartnerAutocompleteMixin = { }); } + odooSuggestions = _.filter(odooSuggestions, function (suggestion) { + return !suggestion.ignored; + }); + _.each(odooSuggestions, function(suggestion){ + delete suggestion.ignored; + }); return def.resolve(odooSuggestions); }; diff --git a/addons/payment/wizards/payment_acquirer_onboarding_wizard.py b/addons/payment/wizards/payment_acquirer_onboarding_wizard.py index e30d43ae861..b53b25130c6 100644 --- a/addons/payment/wizards/payment_acquirer_onboarding_wizard.py +++ b/addons/payment/wizards/payment_acquirer_onboarding_wizard.py @@ -123,6 +123,11 @@ class PaymentWizard(models.TransientModel): }) if self.payment_method == 'manual': manual_acquirer = self._get_manual_payment_acquirer(new_env) + if not manual_acquirer: + raise UserError(_( + 'No manual payment method could be found for this company. ' + + 'Please create one from the Payment Acquirer menu.' + )) manual_acquirer.name = self.manual_name manual_acquirer.post_msg = self.manual_post_msg manual_acquirer.website_published = True diff --git a/addons/portal/static/src/scss/bootstrap_overridden.scss b/addons/portal/static/src/scss/bootstrap_overridden.scss index 8f129e7bee4..ff6af6eeb51 100644 --- a/addons/portal/static/src/scss/bootstrap_overridden.scss +++ b/addons/portal/static/src/scss/bootstrap_overridden.scss @@ -21,3 +21,8 @@ $font-size-sm: (12 / 16) * 1rem; $btn-padding-y-sm: (1 / 16) * 1rem; $btn-padding-x-sm: (5 / 16) * 1rem; + +// Modals + +$modal-lg: $o-modal-lg; +$modal-md: $o-modal-md; diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss index af3882c74ae..26beabb5865 100644 --- a/addons/portal/static/src/scss/portal.scss +++ b/addons/portal/static/src/scss/portal.scss @@ -168,7 +168,7 @@ hr { .input-group { flex-flow: row nowrap; } -.list-group-item:not([class*="list-group-item-"]) { +.list-group-item:not([class*="list-group-item-"]):not(.active) { color: color-yiq($list-group-bg); } diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 6827424deea..02f118e2bae 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -28,7 +28,7 @@ <nav class="navbar navbar-expand-md navbar-light bg-light"> <div class="container"> <a href="/" class="navbar-brand logo"> - <span t-field="res_company.logo" t-options="{'widget': 'image'}" t-att-alt="'Logo of %s' % res_company.name" t-att-title="res_company.name"/> + <img t-att-src="'/logo.png?company=%s' % res_company.id" t-att-alt="'Logo of %s' % res_company.name" t-att-title="res_company.name"/> </a> <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#top_menu_collapse"> <span class="navbar-toggler-icon"/> diff --git a/addons/product/data/product_demo.xml b/addons/product/data/product_demo.xml index 1572481e9ef..c1b75e8290e 100644 --- a/addons/product/data/product_demo.xml +++ b/addons/product/data/product_demo.xml @@ -1,6 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> <data noupdate="1"> + <!-- We want to activate product variant by default for easier demoing. --> + <record id="base.group_user" model="res.groups"> + <field name="implied_ids" eval="[(4, ref('product.group_product_variant'))]"/> + </record> <record id="product_category_2" model="product.category"> <field name="parent_id" ref="product.product_category_all"/> @@ -124,26 +128,32 @@ <record id="product_attribute_1" model="product.attribute"> <field name="name">Legs</field> + <field name="sequence">1</field> </record> <record id="product_attribute_value_1" model="product.attribute.value"> <field name="name">Steel</field> <field name="attribute_id" ref="product_attribute_1"/> + <field name="sequence">1</field> </record> <record id="product_attribute_value_2" model="product.attribute.value"> <field name="name">Aluminium</field> <field name="attribute_id" ref="product_attribute_1"/> + <field name="sequence">2</field> </record> <record id="product_attribute_2" model="product.attribute"> <field name="name">Color</field> + <field name="sequence">2</field> </record> <record id="product_attribute_value_3" model="product.attribute.value"> <field name="name">White</field> <field name="attribute_id" ref="product_attribute_2"/> + <field name="sequence">1</field> </record> <record id="product_attribute_value_4" model="product.attribute.value"> <field name="name">Black</field> <field name="attribute_id" ref="product_attribute_2"/> + <field name="sequence">2</field> </record> <record id="product_attribute_3" model="product.attribute"> diff --git a/addons/product/models/product.py b/addons/product/models/product.py index c9b31fa57dc..7b8a60a9787 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging import re from odoo import api, fields, models, tools, _ @@ -11,6 +12,8 @@ from odoo.addons import decimal_precision as dp from odoo.tools import float_compare +_logger = logging.getLogger(__name__) + class ProductCategory(models.Model): _name = "product.category" @@ -83,13 +86,16 @@ class ProductProduct(models.Model): _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'default_code, name, id' + # price: total price, context dependent (partner, pricelist, quantity) price = fields.Float( 'Price', compute='_compute_product_price', digits=dp.get_precision('Product Price'), inverse='_set_product_price') + # price_extra: catalog extra value only, sum of variant extra attributes price_extra = fields.Float( 'Variant Price Extra', compute='_compute_product_price_extra', digits=dp.get_precision('Product Price'), help="This is the sum of the extra price of all attributes") + # lst_price: catalog value + extra, context dependent (uom) lst_price = fields.Float( 'Public Price', compute='_compute_product_lst_price', digits=dp.get_precision('Product Price'), inverse='_set_product_lst_price', @@ -161,8 +167,8 @@ class ProductProduct(models.Model): pricelist_id_or_name = self._context.get('pricelist') if pricelist_id_or_name: pricelist = None - partner = self._context.get('partner', False) - quantity = self._context.get('quantity', 1.0) + partner = self.env.context.get('partner', False) + quantity = self.env.context.get('quantity', 1.0) # Support context pricelists specified as display_name or ID for compatibility if isinstance(pricelist_id_or_name, str): @@ -276,11 +282,29 @@ class ProductProduct(models.Model): else: self.product_tmpl_id.image = image + @api.depends('product_tmpl_id', 'attribute_value_ids') def _compute_product_template_attribute_value_ids(self): + # Fetch and pre-map the values first for performance. It assumes there + # won't be too many values, but there might be a lot of products. + values = self.env['product.template.attribute.value'].search([ + ('product_tmpl_id', 'in', self.mapped('product_tmpl_id').ids), + ('product_attribute_value_id', 'in', self.mapped('attribute_value_ids').ids), + ]) + + values_per_template = {} + for ptav in values: + pt_id = ptav.product_tmpl_id.id + if pt_id not in values_per_template: + values_per_template[pt_id] = {} + values_per_template[pt_id][ptav.product_attribute_value_id.id] = ptav + for product in self: - product.product_template_attribute_value_ids = self.env['product.template.attribute.value']._search([ - ('product_tmpl_id', '=', product.product_tmpl_id.id), - ('product_attribute_value_id', 'in', product.attribute_value_ids.ids)]) + product.product_template_attribute_value_ids = self.env['product.template.attribute.value'] + for pav in product.attribute_value_ids: + if product.product_tmpl_id.id not in values_per_template or pav.id not in values_per_template[product.product_tmpl_id.id]: + _logger.warning("A matching product.template.attribute.value was not found for the product.attribute.value #%s on the template #%s" % (pav.id, product.product_tmpl_id.id)) + else: + product.product_template_attribute_value_ids += values_per_template[product.product_tmpl_id.id][pav.id] @api.one def _get_pricelist_items(self): @@ -387,17 +411,36 @@ class ProductProduct(models.Model): self.check_access_rule("read") result = [] + + # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields + # Use `load=False` to not call `name_get` for the `product_tmpl_id` + self.sudo().read(['name', 'default_code', 'product_tmpl_id', 'attribute_value_ids'], load=False) + + product_template_ids = self.sudo().mapped('product_tmpl_id').ids + + if partner_ids: + supplier_info = self.env['product.supplierinfo'].sudo().search([ + ('product_tmpl_id', 'in', product_template_ids), + ('name', 'in', partner_ids), + ]) + # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields + # Use `load=False` to not call `name_get` for the `product_tmpl_id` and `product_id` + supplier_info.sudo().read(['product_tmpl_id', 'product_id', 'product_name', 'product_code'], load=False) + supplier_info_by_template = {} + for r in supplier_info: + supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r) for product in self.sudo(): # display only the attributes with multiple possible values on the template - variable_attributes = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id') + variable_attributes = product.attribute_value_ids.filtered(lambda v: len(v.attribute_id.value_ids) > 1).mapped('attribute_id') variant = product.attribute_value_ids._variant_name(variable_attributes) name = variant and "%s (%s)" % (product.name, variant) or product.name sellers = [] if partner_ids: - sellers = [x for x in product.seller_ids if (x.name.id in partner_ids) and (x.product_id == product)] + product_supplier_info = supplier_info_by_template.get(product.product_tmpl_id, []) + sellers = [x for x in product_supplier_info if x.product_id and x.product_id == product] if not sellers: - sellers = [x for x in product.seller_ids if (x.name.id in partner_ids) and not x.product_id] + sellers = [x for x in product_supplier_info if not x.product_id] # Filter out sellers based on the company. This is done afterwards for a better # code readability. At this point, only a few sellers should remain, so it should # not be a performance issue. @@ -614,9 +657,34 @@ class ProductProduct(models.Model): :return: True if the attibutes and values are correct, False instead """ self.ensure_one() - values = self.attribute_value_ids.filtered(lambda v: v.attribute_id.create_variant != 'no_variant') + values = self.attribute_value_ids attributes = values.mapped('attribute_id') - return attributes == valid_attributes and values <= valid_values + if attributes != valid_attributes: + return False + for value in values: + if value not in valid_values: + return False + return True + + @api.multi + def _is_variant_possible(self, parent_combination=None): + """Return whether the variant is possible based on its own combination, + and optionally a parent combination. + + See `_is_combination_possible` for more information. + + This will always exclude variants for templates that have `no_variant` + attributes because the variant itself will not be the full combination. + + :param parent_combination: combination from which `self` is an + optional or accessory product. + :type parent_combination: recordset `product.template.attribute.value` + + :return: ẁhether the variant is possible based on its own combination + :rtype: bool + """ + self.ensure_one() + return self.product_tmpl_id._is_combination_possible(self.product_template_attribute_value_ids, parent_combination=parent_combination) @api.multi def toggle_active(self): diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py index 7551e57f61f..ed1f70b4bc4 100644 --- a/addons/product/models/product_attribute.py +++ b/addons/product/models/product_attribute.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.osv import expression from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError, ValidationError @@ -11,11 +10,13 @@ from odoo.osv import expression class ProductAttribute(models.Model): _name = "product.attribute" _description = "Product Attribute" - _order = 'sequence, name' + # if you change this _order, keep it in sync with the method + # `_sort_key_attribute_value` in `product.template` + _order = 'sequence, id' name = fields.Char('Attribute', required=True, translate=True) value_ids = fields.One2many('product.attribute.value', 'attribute_id', 'Values', copy=True) - sequence = fields.Integer('Sequence', help="Determine the display order") + sequence = fields.Integer('Sequence', help="Determine the display order", index=True) attribute_line_ids = fields.One2many('product.template.attribute.line', 'attribute_id', 'Lines') create_variant = fields.Selection([ ('no_variant', 'Never'), @@ -25,15 +26,43 @@ class ProductAttribute(models.Model): string="Create Variants", help="Check this if you want to create multiple variants for this attribute.", required=True) + @api.multi + def _without_no_variant_attributes(self): + return self.filtered(lambda pa: pa.create_variant != 'no_variant') + + @api.multi + def write(self, vals): + """Override to make sure attribute type can't be changed if it's used on + a product template. + + This is important to prevent because changing the type would make + existing combinations invalid without recomputing them, and recomputing + them might take too long and we don't want to change products without + the user knowing about it.""" + if 'create_variant' in vals: + products = self._get_related_product_templates() + if products: + message = ', '.join(products.mapped('name')) + raise UserError(_('You are trying to change the type of an attribute value still referenced on at least one product template: %s') % message) + return super(ProductAttribute, self).write(vals) + + @api.multi + def _get_related_product_templates(self): + return self.env['product.template'].with_context(active_test=False).search([ + ('attribute_line_ids.attribute_id', 'in', self.ids), + ]) + class ProductAttributeValue(models.Model): _name = "product.attribute.value" + # if you change this _order, keep it in sync with the method + # `_sort_key_variant` in `product.template' _order = 'attribute_id, sequence, id' _description = 'Attribute Value' name = fields.Char(string='Value', required=True, translate=True) - sequence = fields.Integer(string='Sequence', help="Determine the display order") - attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='cascade', required=True) + sequence = fields.Integer(string='Sequence', help="Determine the display order", index=True) + attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='cascade', required=True, index=True) _sql_constraints = [ ('value_company_uniq', 'unique (name, attribute_id)', 'This attribute value already exists !') @@ -51,11 +80,21 @@ class ProductAttributeValue(models.Model): @api.multi def unlink(self): - linked_products = self.env['product.product'].with_context(active_test=False).search([('attribute_value_ids', 'in', self.ids)]) + linked_products = self._get_related_product_templates() if linked_products: raise UserError(_('The operation cannot be completed:\nYou are trying to delete an attribute value with a reference on a product variant.')) return super(ProductAttributeValue, self).unlink() + @api.multi + def _without_no_variant_attributes(self): + return self.filtered(lambda pav: pav.attribute_id.create_variant != 'no_variant') + + @api.multi + def _get_related_product_templates(self): + return self.env['product.template'].with_context(active_test=False).search([ + ('attribute_line_ids.value_ids', 'in', self.ids), + ]) + class ProductTemplateAttributeLine(models.Model): """Attributes available on product.template with their selected values in a m2m. @@ -64,9 +103,10 @@ class ProductTemplateAttributeLine(models.Model): _name = "product.template.attribute.line" _rec_name = 'attribute_id' _description = 'Product Template Attribute Line' + _order = 'attribute_id, id' - product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True) - attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='restrict', required=True) + product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True, index=True) + attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='restrict', required=True, index=True) value_ids = fields.Many2many('product.attribute.value', string='Attribute Values') product_template_value_ids = fields.Many2many( 'product.template.attribute.value', @@ -76,7 +116,7 @@ class ProductTemplateAttributeLine(models.Model): @api.constrains('value_ids', 'attribute_id') def _check_valid_attribute(self): - if any(line.value_ids > line.attribute_id.value_ids for line in self): + if any(not line.value_ids or line.value_ids > line.attribute_id.value_ids for line in self): raise ValidationError(_('You cannot use this attribute with the following value.')) return True @@ -152,13 +192,17 @@ class ProductTemplateAttributeLine(models.Model): return self.browse(attribute_ids).name_get() return super(ProductTemplateAttributeLine, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + @api.multi + def _without_no_variant_attributes(self): + return self.filtered(lambda ptal: ptal.attribute_id.create_variant != 'no_variant') + class ProductTemplateAttributeValue(models.Model): """Materialized relationship between attribute values and product template generated by the product.template.attribute.line""" _name = "product.template.attribute.value" - _order = 'sequence, attribute_id, id' + _order = 'product_attribute_value_id, id' _description = 'Product Attribute Value' name = fields.Char('Value', related="product_attribute_value_id.name") @@ -192,6 +236,10 @@ class ProductTemplateAttributeValue(models.Model): return super(ProductTemplateAttributeValue, self).name_get() return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self] + @api.multi + def _without_no_variant_attributes(self): + return self.filtered(lambda ptav: ptav.attribute_id.create_variant != 'no_variant') + class ProductTemplateAttributeExclusion(models.Model): _name = "product.template.attribute.exclusion" diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 441c92da00f..69568ff9bea 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -2,14 +2,12 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import itertools -import operator -import psycopg2 from odoo.addons import decimal_precision as dp from odoo import api, fields, models, tools, _ -from odoo.exceptions import ValidationError, RedirectWarning, except_orm -from odoo.exceptions import UserError +from odoo.exceptions import ValidationError, RedirectWarning, UserError +from odoo.osv import expression class ProductTemplate(models.Model): @@ -66,13 +64,16 @@ class ProductTemplate(models.Model): 'res.currency', 'Currency', compute='_compute_currency_id') # price fields + # price: total template price, context dependent (partner, pricelist, quantity) price = fields.Float( 'Price', compute='_compute_template_price', inverse='_set_template_price', digits=dp.get_precision('Product Price')) + # list_price: catalog price, user defined list_price = fields.Float( 'Sale Price', default=1.0, digits=dp.get_precision('Product Price'), help="Price at which the product is sold to customers.") + # lst_price: catalog price for template, but including extra for variants lst_price = fields.Float( 'Public Price', related='list_price', readonly=False, digits=dp.get_precision('Product Price')) @@ -177,8 +178,8 @@ class ProductTemplate(models.Model): pricelist_id_or_name = self._context.get('pricelist') if pricelist_id_or_name: pricelist = None - partner = self._context.get('partner') - quantity = self._context.get('quantity', 1.0) + partner = self.env.context.get('partner') + quantity = self.env.context.get('quantity', 1.0) # Support context pricelists specified as display_name or ID for compatibility if isinstance(pricelist_id_or_name, str): @@ -379,6 +380,8 @@ class ProductTemplate(models.Model): @api.multi def name_get(self): + # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields + self.read(['name', 'default_code']) return [(template.id, '%s%s' % (template.default_code and '[%s] ' % template.default_code or '', template.name)) for template in self] @@ -455,14 +458,16 @@ class ProductTemplate(models.Model): def create_variant_ids(self): Product = self.env["product.product"] - variants_to_create = [] - variants_to_activate = [] - variants_to_unlink = [] - for tmpl_id in self.with_context(active_test=False): + # Handle the variants for each template separately. This will be + # less efficient when called on a lot of products with few variants + # but it is better when there's a lot of variants on one template. + variants_to_create = [] + variants_to_activate = self.env['product.product'] + variants_to_unlink = self.env['product.product'] # adding an attribute with only one value should not recreate product # write this attribute on every product to make sure we don't lose them - variant_alone = tmpl_id.attribute_line_ids.filtered(lambda line: line.attribute_id.create_variant == 'always' and len(line.value_ids) == 1).mapped('value_ids') + variant_alone = tmpl_id._get_valid_product_template_attribute_lines().filtered(lambda line: line.attribute_id.create_variant == 'always' and len(line.value_ids) == 1).mapped('value_ids') for value_id in variant_alone: updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id')) updated_products.write({'attribute_value_ids': [(4, value_id.id)]}) @@ -472,15 +477,15 @@ class ProductTemplate(models.Model): # process. # Technical note: if there is no attribute, a variant is still created because # 'not any([])' and 'set([]) not in set([])' are True. - if not any(attrib.create_variant == 'dynamic' for attrib in tmpl_id.mapped('attribute_line_ids.attribute_id')): - # Iterator containing all possible attribute values combination + if not tmpl_id.has_dynamic_attributes(): + # Iterator containing all possible `product.attribute.value` combination # The iterator is used to avoid MemoryError in case of a huge number of combination. all_variants = itertools.product(*( - line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant != 'no_variant' + line.value_ids.ids for line in tmpl_id._get_valid_product_template_attribute_lines()._without_no_variant_attributes() )) - # Set containing existing attribute values combination + # Set containing existing `product.attribute.value` combination existing_variants = { - frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids) + frozenset(variant.attribute_value_ids.ids) for variant in tmpl_id.product_variant_ids } # For each possible variant, create if it doesn't exist yet. @@ -493,107 +498,436 @@ class ProductTemplate(models.Model): }) if len(variants_to_create) > 1000: raise UserError(_( - 'The number of variants to generate is too high. ' - 'You should either not generate variants for each combination or generate them on demand from the sales order. ' - 'To do so, open the form view of attributes and change the mode of *Create Variants*.')) + 'The number of variants to generate is too high. ' + 'You should either not generate variants for each combination or generate them on demand from the sales order. ' + 'To do so, open the form view of attributes and change the mode of *Create Variants*.')) # Check existing variants if any needs to be activated or unlinked. # - if the product is not active and has valid attributes and attribute values, it # should be activated # - if the product does not have valid attributes or attribute values, it should be # deleted - valid_value_ids = tmpl_id.mapped('attribute_line_ids.value_ids').filtered( - lambda v: v.attribute_id.create_variant != 'no_variant' - ) - valid_attribute_ids = valid_value_ids.mapped('attribute_id') + valid_value_ids = tmpl_id._get_valid_product_attribute_values()._without_no_variant_attributes() + valid_attribute_ids = tmpl_id._get_valid_product_attributes()._without_no_variant_attributes() for product_id in tmpl_id.product_variant_ids: if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids): if not product_id.active: - variants_to_activate.append(product_id) + variants_to_activate += product_id else: - variants_to_unlink.append(product_id) + variants_to_unlink += product_id - if variants_to_activate: - Product.concat(*variants_to_activate).write({'active': True}) + if variants_to_activate: + variants_to_activate.write({'active': True}) - # create new products - if variants_to_create: - Product.create(variants_to_create) + # create new products + if variants_to_create: + Product.create(variants_to_create) - # unlink or inactive product - for variant in variants_to_unlink: + # unlink or inactive product + # try in batch first because it is much faster try: with self._cr.savepoint(), tools.mute_logger('odoo.sql_db'): - variant.unlink() - # We catch all kind of exception to be sure that the operation doesn't fail. - except (psycopg2.Error, except_orm): - variant.write({'active': False}) - pass + variants_to_unlink.unlink() + except Exception: + # fall back to one by one if batch is not possible + for variant in variants_to_unlink: + try: + with self._cr.savepoint(), tools.mute_logger('odoo.sql_db'): + variant.unlink() + # We catch all kind of exception to be sure that the operation doesn't fail. + except Exception: + # Note: this can still fail if something is preventing from archiving. + # This is the case from existing stock reordering rules. + variant.write({'active': False}) return True def has_dynamic_attributes(self): - return self.attribute_line_ids and \ - any(create_variant == 'dynamic' - for create_variant in self.mapped('attribute_line_ids.value_ids.attribute_id.create_variant')) + """Return whether this `product.template` has at least one dynamic + attribute. + + :return: True if at least one dynamic attribute, False otherwise + :rtype: bool + """ + self.ensure_one() + return any(a.create_variant == 'dynamic' for a in self._get_valid_product_attributes()) @api.multi - def get_filtered_variants(self, reference_product=None): + def _get_valid_product_template_attribute_lines(self): + """A product template attribute line is considered valid if it has at + least one possible value. + + Those with only one value are considered valid, even though they should + not appear on the configurator itself (unless they have an is_custom + value to input), indeed single value attributes can be used to filter + products among others based on that attribute/value. + + This method is necessary because it was previously possible to save a + line without any value on it, so the database might not be consistent in + that regard. + + :return: all the valid product template attribute lines of this template + :rtype: recordset `product.template.attribute.line` """ - Will filter availability (excluded) for the product - combinations (ex: color: white excludes size: large). - - Will also filter availability (excluded) for the parent - product if specified (meaning that this product is a an optionnal or - accessory product of the reference_product). - Args: - reference_product (product.product): The reference product that has - the current product as an option or accessory product. - Returns: - The filtered list of product variants + self.ensure_one() + return self.attribute_line_ids.filtered(lambda ptal: ptal.value_ids) + + @api.multi + def _get_valid_product_attributes(self): + """A product attribute is considered valid for a template if it + has at least one possible value set on the template. + + See `_get_valid_product_template_attribute_lines`. + + :return: all the valid product attributes of this template + :rtype: recordset `product.attribute` """ self.ensure_one() - product_template_attribute_values = self.env['product.template.attribute.value'].search([('product_tmpl_id', '=', self.id)]) + product_attributes = self.env['product.attribute'] + for ptal in self._get_valid_product_template_attribute_lines(): + product_attributes |= ptal.attribute_id + return product_attributes + + @api.multi + def _get_valid_product_attribute_values(self): + """A product attribute value is considered valid for a template if it is + defined on a product template attribute line. + + :return: all the valid product attribute values of this template + :rtype: recordset `product.attribute.value` + """ + self.ensure_one() + return self._get_valid_product_template_attribute_lines().mapped('value_ids') + + @api.multi + def _get_possible_variants(self, parent_combination=None): + """Return the existing variants that are possible. + + For dynamic attributes, it will only return the variants that have been + created already. For no_variant attributes, it will return an empty + recordset because the variants themselves are not a full combination. + + :param parent_combination: combination from which `self` is an + optional or accessory product. + :type parent_combination: recordset `product.template.attribute.value` + + :return: the existing variants that are possible. + :rtype: recordset of `product.product` + """ + self.ensure_one() + return self.product_variant_ids.filtered(lambda p: p._is_variant_possible(parent_combination)) + + @api.multi + def get_filtered_variants(self, reference_product=None): + """deprecated, use _get_possible_variants instead""" + self.ensure_one() + + parent_combination = self.env['product.template.attribute.value'] + if reference_product: # append the reference_product if provided - product_template_attribute_values |= reference_product.product_template_attribute_value_ids - if reference_product._context.get('no_variant_attribute_values'): + parent_combination |= reference_product.product_template_attribute_value_ids + if reference_product.env.context.get('no_variant_attribute_values'): # Add "no_variant" attribute values' exclusions # They are kept in the context since they are not linked to this product variant - product_template_attribute_values |= reference_product._context.get('no_variant_attribute_values') - - product_variants = self.product_variant_ids - for product_template_attribute_value in product_template_attribute_values: - # CASE 1: The whole product is excluded when no attribute values are selected in the parent product - # returns empty recordset of product.product if so. What is checked is: - # If the product_attribute value doesn't belong to self (i.e. belongs to the reference product) - # and self is the excluded product template on the exclusion lines - # and the exclusions is on the product without specified product attribute values (i.e. the whole product is excluded) - if product_template_attribute_value.product_tmpl_id != self \ - and self in product_template_attribute_value.exclude_for.mapped('product_tmpl_id') \ - and any(not exclude_for.value_ids - for exclude_for in product_template_attribute_value.exclude_for.filtered( - lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self)): - return self.env['product.product'] - - # CASE 2: Check if some of the product.template.attribute.value of the product are excluded - # for this prodcut. A variant could be excluded: - # - Either by itself (eg: The office chair with iron legs excludes the color white) - # - Or by the reference product (eg: The customizable desk with iron legs excludes the office chair with aluminium legs) - for excluded in product_template_attribute_value.exclude_for.filtered( - lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self): - product_variants -= product_variants.filtered( - lambda variant: - # 1/ Check the applicability of the exclusion - # i.e: the restriction comes from the parent - # OR the restriction is on a product_attribute_value that this variant has, eg: - # if the office chair with iron legs excludes the color white, we must check - # that this variant has iron legs to check the exclusion - (product_template_attribute_value.product_tmpl_id != self or product_template_attribute_value in variant.product_template_attribute_value_ids) and - # 2/ Check the variant has one of the excluded attribute values - any(attribute_value in excluded.value_ids for attribute_value in variant.product_template_attribute_value_ids)) - - return product_variants + parent_combination |= reference_product.env.context.get('no_variant_attribute_values') + return self._get_possible_variants(parent_combination) + + @api.multi + def _get_attribute_exclusions(self, parent_combination=None): + """Return the list of attribute exclusions of a product. + + :param parent_combination: the combination from which + `self` is an optional or accessory product. Indeed exclusions + rules on one product can concern another product. + :type parent_combination: recordset `product.template.attribute.value` + + :return: dict of exclusions + - exclusions: from this product itself + - parent_exclusions: from the parent_combination + - archived_combinations: variants that are archived + - existing_combinations: variants that are existing (as opposed to + deleted, because deleted need to be considered impossible if + there are no dynamic attributes). + - has_dynamic_attributes: whether there is a dynamic attribute + - no_variant_product_template_attribute_value_ids: values that are + no_variant + """ + self.ensure_one() + return { + 'exclusions': self._get_own_attribute_exclusions(), + 'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination), + 'archived_combinations': self._get_archived_combinations(), + 'has_dynamic_attributes': self.has_dynamic_attributes(), + 'existing_combinations': self._get_existing_combinations(), + 'no_variant_product_template_attribute_value_ids': self._get_no_variant_product_template_attribute_values(), + } + + @api.multi + def _get_own_attribute_exclusions(self): + """Get exclusions coming from the current template. + + Dictionnary, each ptav is a key, and for each of them the value is + an array with the other ptav that they exclude (empty if no exclusion). + """ + self.ensure_one() + product_template_attribute_values = self._get_valid_product_template_attribute_lines().mapped('product_template_value_ids') + return { + ptav.id: [ + value_id + for filter_line in ptav.exclude_for.filtered( + lambda filter_line: filter_line.product_tmpl_id == self + ) for value_id in filter_line.value_ids.ids + ] + for ptav in product_template_attribute_values + } + + @api.multi + def _get_parent_attribute_exclusions(self, parent_combination): + """Get exclusions coming from the parent combination. + + Array, each element is a ptav that is excluded because of the parent. + """ + self.ensure_one() + if not parent_combination: + return [] + return [ + value_id + for filter_line in parent_combination.mapped('exclude_for').filtered( + lambda filter_line: filter_line.product_tmpl_id == self + ) for value_id in filter_line.value_ids.ids + ] + + @api.multi + def _get_archived_combinations(self): + self.ensure_one() + """Get archived combinations. + + Array, each element is an array with ids of an archived combination. + """ + valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes() + valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes() + + # Search only among those having the right set of attributes. + domain = [('product_tmpl_id', '=', self.id), ('active', '=', False)] + for pa in valid_attribute_ids: + domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain]) + archived_variants = self.env['product.product'].search(domain) + + archived_variants = archived_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids)) + + return [archived_variant.product_template_attribute_value_ids.ids + for archived_variant in archived_variants] + + @api.multi + def _get_existing_combinations(self): + self.ensure_one() + """Get existing combinations. + + Needed because when not using dynamic attributes, the combination is + not ok if it doesn't exist (= if the variant has been deleted). + + Array, each element is an array with ids of an existing combination. + """ + valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes() + valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes() + + # Search only among those having the right set of attributes. + domain = [('product_tmpl_id', '=', self.id), ('active', '=', True)] + for pa in valid_attribute_ids: + domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain]) + existing_variants = self.env['product.product'].search(domain) + + existing_variants = existing_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids)) + + return [variant.product_template_attribute_value_ids.ids + for variant in existing_variants] + + @api.multi + def _get_no_variant_product_template_attribute_values(self): + self.ensure_one() + product_template_attribute_values = self._get_valid_product_template_attribute_lines().mapped('product_template_value_ids') + return product_template_attribute_values.filtered( + lambda v: v.attribute_id.create_variant == 'no_variant' + ).ids + + @api.multi + def _is_combination_possible(self, combination, parent_combination=None): + """ + The combination is possible if it is not excluded by any rule + coming from the current template, not excluded by any rule from the + parent_combination (if given), and there should not be any archived + variant with the exact same combination. + + If the template does not have any dynamic attribute, the combination + is also not possible if the matching variant has been deleted. + + Moreover the attributes of the combination must excatly match the + attributes allowed on the template. + + :param combination: the combination to check for possibility + :type combination: recordset `product.template.attribute.value` + + :param parent_combination: combination from which `self` is an + optional or accessory product. + :type parent_combination: recordset `product.template.attribute.value` + + :return: whether the combination is possible + :rtype: bool + """ + self.ensure_one() + + if len(combination) != len(self._get_valid_product_template_attribute_lines()): + # number of attribute values passed is different than the + # configuration of attributes on the template + return False + + if self._get_valid_product_attributes() != combination.mapped('attribute_id'): + # combination has different attributes than the ones configured on the template + return False + + variant = self._get_variant_for_combination(combination) + if not self.has_dynamic_attributes() and not variant: + # the variant has been deleted + return False + + exclusions = self._get_own_attribute_exclusions() + if exclusions: + # exclude if the current value is in an exclusion, + # and the value excluding it is also in the combination + for ptav in combination: + for exclusion in exclusions.get(ptav.id): + if exclusion in combination.ids: + return False + + parent_exclusions = self._get_parent_attribute_exclusions(parent_combination) + if parent_exclusions: + for exclusion in parent_exclusions: + if exclusion in combination.ids: + return False + + filtered_combination = combination._without_no_variant_attributes() + archived_combinations = self._get_archived_combinations() + if archived_combinations and filtered_combination.ids in archived_combinations: + return False + + return True + + @api.multi + def _get_variant_for_combination(self, combination): + """Get the variant matching the combination. + + All of the values in combination must be present in the variant, and the + variant should not have more attributes. Ignore the attributes that are + not supposed to create variants. + + :param combination: recordset of `product.template.attribute.value` + + :return: the variant if found, else empty + :rtype: recordset `product.product` + """ + self.ensure_one() + + filtered_combination = combination._without_no_variant_attributes() + + # If there are a lot of variants on this template, it is much faster to + # build a query than using the existing o2m. + domain = [('product_tmpl_id', '=', self.id)] + for ptav in filtered_combination: + domain = expression.AND([[('attribute_value_ids.id', '=', ptav.product_attribute_value_id.id)], domain]) + res = self.env['product.product'].search(domain) + + # The domain above is checking for the `product.attribute.value`, but we + # need to make sure it's the same `product.template.attribute.value`. + # Also there should theorically be only 0 or 1 but an existing database + # might not be consistent so we need to make sure to take max 1. + return res.filtered( + lambda v: v.product_template_attribute_value_ids == filtered_combination + )[:1] + + @api.multi + def _get_first_possible_combination(self, parent_combination=None, necessary_values=None): + """ + Iterate the attributes and values in order and stop at the first + combination of values that is possible. + + When encountering an impossible combination, try to change the + value of latest attributes first. + + Ignore attributes that have no values. + + Note this method return the same result (empty recordset) if no + combination is possible at all which would be considered a negative + result, or if there are no attribute lines on the template in which + case the "empty combination" is actually a possible combination. + Therefore the result of this method when empty should be tested + with `_is_combination_possible` if it's important to know if the + resulting empty combination is actually possible or not. + + :param parent_combination: combination from which `self` is an + optional or accessory product. + :type parent_combination: recordset `product.template.attribute.value` + + :param necessary_values: values that must be in the returned combination + :type necessary_values: recordset of `product.template.attribute.value` + + :return: the first possible combination found, or empty if none possible + :rtype: recordset of `product.template.attribute.value` + """ + self.ensure_one() + + if not self.active: + return self.env['product.template.attribute.value'] + + attribute_lines = self._get_valid_product_template_attribute_lines() + + def iterate_attribute_lines(attribute_lines, combination): + """ + :param attribute_lines: recordset of product.template.attribute.line + that are still to iterate + :param combination: recordset of product.template.attribute.value + that have to be tested for possibility + + :return: the first possible combination found, or empty + :rtype: recordset of `product.template.attribute.value` + """ + if not attribute_lines: + if self._is_combination_possible(combination, parent_combination): + return combination + else: + return self.env['product.template.attribute.value'] + for cur in attribute_lines[0].product_template_value_ids: + res = iterate_attribute_lines(attribute_lines[1:], combination + cur) + if res and all(v in res for v in (necessary_values or [])): + return res + return self.env['product.template.attribute.value'] + + return iterate_attribute_lines(attribute_lines, self.env['product.template.attribute.value']) + + @api.multi + def _get_closest_possible_combination(self, combination): + """Get the first possible combination that is the closest to the given + combination. + + If the given combination is incomplete, try to complete it. + + If the given combination is invalid, try to remove values from it before + completing it. + + See `_get_first_possible_combination` note about empty result. + + :param combination: the values to include if they are possible + :type combination: recordset `product.template.attribute.value` + + :return: the first possible combination that is including as much + elements as possible from the given combination. + :rtype: recordset of product.template.attribute.value + """ + while True: + result = self._get_first_possible_combination(necessary_values=combination) + if result or not combination: + return result + combination = combination[:-1] @api.model def get_empty_list_help(self, help): diff --git a/addons/product/tests/common.py b/addons/product/tests/common.py index 50831e2a88c..8bfec6ff2d8 100644 --- a/addons/product/tests/common.py +++ b/addons/product/tests/common.py @@ -73,6 +73,7 @@ class TestProductCommon(common.SavepointCase): 'uom_po_id': cls.uom_unit.id, 'attribute_line_ids': [(0, 0, { 'attribute_id': cls.prod_att_1.id, + 'value_ids': [(6, 0, [cls.prod_attr1_v1.id, cls.prod_attr1_v2.id])] })] }) cls.product_7 = Product.create({ diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py index eeb1275420a..19070a1c887 100644 --- a/addons/product/tests/test_product_attribute_value_config.py +++ b/addons/product/tests/test_product_attribute_value_config.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.tests import tagged from odoo.tests.common import TransactionCase -class TestProductAttributeValueConfig(TransactionCase): +class TestProductAttributeValueSetup(TransactionCase): def setUp(self): - super(TestProductAttributeValueConfig, self).setUp() + super(TestProductAttributeValueSetup, self).setUp() self.computer = self.env['product.template'].create({ - 'name': 'Super Computer' + 'name': 'Super Computer', + 'price': 2000, }) self._add_ssd_attribute() @@ -26,145 +28,368 @@ class TestProductAttributeValueConfig(TransactionCase): self.computer_case.create_variant_ids() - def test_product_template_attribute_values_creation(self): - self.assertEqual(len(self.computer_ssd_attribute_lines.product_template_value_ids), 2, - 'Product attribute values (ssd) were not automatically created') - self.assertEqual(len(self.computer_ram_attribute_lines.product_template_value_ids), 3, - 'Product attribute values (ram) were not automatically created') - self.assertEqual(len(self.computer_hdd_attribute_lines.product_template_value_ids), 3, - 'Product attribute values (hdd) were not automatically created') - self.assertEqual(len(self.computer_case_size_attribute_lines.product_template_value_ids), 3, - 'Product attribute values (size) were not automatically created') - - def test_product_filtered_exclude_for(self): - """ - Super Computer has 18 variants total (2 ssd * 3 ram * 3 hdd) - RAM 16 excudes HDD 1, that matches 2 variants: - - SSD 256 RAM 16 HDD 1 - - SSD 512 RAM 16 HDD 1 - - => There has to be 16 variants left when filtered - """ - self._add_ram_exclude_for() - self.assertEqual(len(self.computer.get_filtered_variants()), 16) - self.assertFalse(self._get_variant_for_attribute_values(self.computer, [self.ssd_256, self.ram_16, self.hdd_1])) - self.assertFalse(self._get_variant_for_attribute_values(self.computer, [self.ssd_512, self.ram_16, self.hdd_1])) - - def test_children_product_filtered_exclude_for(self): - """ - Super Computer Case has 3 variants total (3 size) - Reference product Computer with HDD 4 excludes Size M - The following variant will be excluded: - - Size M - - => There has to be 2 variants left when filtered - """ - - self._add_hdd_excludes_computer_case() - hdd_4_variant = self.computer.product_variant_ids.filtered( - lambda variant: self.hdd_4 in variant.product_template_attribute_value_ids.mapped('product_attribute_value_id'))[0] - self.assertEqual(len(self.computer_case.get_filtered_variants(hdd_4_variant)), 2) - self.assertFalse(self._get_variant_for_attribute_values(self.computer_case, [self.size_m], hdd_4_variant)) - def _add_ssd_attribute(self): - self.ssd_attribute = self.env['product.attribute'].create({'name': 'Memory'}) + self.ssd_attribute = self.env['product.attribute'].create({'name': 'Memory', 'sequence': 1}) self.ssd_256 = self.env['product.attribute.value'].create({ 'name': '256 GB', - 'attribute_id': self.ssd_attribute.id + 'attribute_id': self.ssd_attribute.id, + 'sequence': 1, }) self.ssd_512 = self.env['product.attribute.value'].create({ 'name': '512 GB', - 'attribute_id': self.ssd_attribute.id + 'attribute_id': self.ssd_attribute.id, + 'sequence': 2, }) + + self._add_ssd_attribute_line() + + def _add_ssd_attribute_line(self): self.computer_ssd_attribute_lines = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.computer.id, 'attribute_id': self.ssd_attribute.id, - 'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])] + 'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])], }) + self.computer_ssd_attribute_lines.product_template_value_ids[0].price_extra = 200 + self.computer_ssd_attribute_lines.product_template_value_ids[1].price_extra = 400 def _add_ram_attribute(self): - self.ram_attribute = self.env['product.attribute'].create({'name': 'RAM'}) + self.ram_attribute = self.env['product.attribute'].create({'name': 'RAM', 'sequence': 2}) self.ram_8 = self.env['product.attribute.value'].create({ 'name': '8 GB', - 'attribute_id': self.ram_attribute.id + 'attribute_id': self.ram_attribute.id, + 'sequence': 1, }) self.ram_16 = self.env['product.attribute.value'].create({ 'name': '16 GB', - 'attribute_id': self.ram_attribute.id + 'attribute_id': self.ram_attribute.id, + 'sequence': 2, }) self.ram_32 = self.env['product.attribute.value'].create({ 'name': '32 GB', - 'attribute_id': self.ram_attribute.id + 'attribute_id': self.ram_attribute.id, + 'sequence': 3, }) self.computer_ram_attribute_lines = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.computer.id, 'attribute_id': self.ram_attribute.id, - 'value_ids': [(6, 0, [self.ram_8.id, self.ram_16.id, self.ram_32.id])] + 'value_ids': [(6, 0, [self.ram_8.id, self.ram_16.id, self.ram_32.id])], }) + self.computer_ram_attribute_lines.product_template_value_ids[0].price_extra = 20 + self.computer_ram_attribute_lines.product_template_value_ids[1].price_extra = 40 + self.computer_ram_attribute_lines.product_template_value_ids[2].price_extra = 80 def _add_hdd_attribute(self): - self.hdd_attribute = self.env['product.attribute'].create({'name': 'HDD'}) + self.hdd_attribute = self.env['product.attribute'].create({'name': 'HDD', 'sequence': 3}) self.hdd_1 = self.env['product.attribute.value'].create({ 'name': '1 To', - 'attribute_id': self.hdd_attribute.id + 'attribute_id': self.hdd_attribute.id, + 'sequence': 1, }) self.hdd_2 = self.env['product.attribute.value'].create({ 'name': '2 To', - 'attribute_id': self.hdd_attribute.id + 'attribute_id': self.hdd_attribute.id, + 'sequence': 2, }) self.hdd_4 = self.env['product.attribute.value'].create({ 'name': '4 To', - 'attribute_id': self.hdd_attribute.id + 'attribute_id': self.hdd_attribute.id, + 'sequence': 3, }) + + self._add_hdd_attribute_line() + + def _add_hdd_attribute_line(self): self.computer_hdd_attribute_lines = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.computer.id, 'attribute_id': self.hdd_attribute.id, - 'value_ids': [(6, 0, [self.hdd_1.id, self.hdd_2.id, self.hdd_4.id])] + 'value_ids': [(6, 0, [self.hdd_1.id, self.hdd_2.id, self.hdd_4.id])], }) + self.computer_hdd_attribute_lines.product_template_value_ids[0].price_extra = 2 + self.computer_hdd_attribute_lines.product_template_value_ids[1].price_extra = 4 + self.computer_hdd_attribute_lines.product_template_value_ids[2].price_extra = 8 def _add_ram_exclude_for(self): - self.ram_16_excludes_hdd_1 = self.env['product.template.attribute.exclusion'].create({ - 'product_tmpl_id': self.computer.id, - 'value_ids': [(6, 0, [self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_1).id])] - }) self._get_product_value_id(self.computer_ram_attribute_lines, self.ram_16).update({ - 'exclude_for': [(6, 0, [self.ram_16_excludes_hdd_1.id])] + 'exclude_for': [(0, 0, { + 'product_tmpl_id': self.computer.id, + 'value_ids': [(6, 0, [self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_1).id])] + })] }) def _add_size_attribute(self): - self.size_attribute = self.env['product.attribute'].create({'name': 'Size'}) + self.size_attribute = self.env['product.attribute'].create({'name': 'Size', 'sequence': 4}) self.size_m = self.env['product.attribute.value'].create({ 'name': 'M', - 'attribute_id': self.size_attribute.id + 'attribute_id': self.size_attribute.id, + 'sequence': 1, }) self.size_l = self.env['product.attribute.value'].create({ 'name': 'L', - 'attribute_id': self.size_attribute.id + 'attribute_id': self.size_attribute.id, + 'sequence': 2, }) self.size_xl = self.env['product.attribute.value'].create({ 'name': 'XL', - 'attribute_id': self.size_attribute.id + 'attribute_id': self.size_attribute.id, + 'sequence': 3, }) self.computer_case_size_attribute_lines = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.computer_case.id, 'attribute_id': self.size_attribute.id, - 'value_ids': [(6, 0, [self.size_m.id, self.size_l.id, self.size_xl.id])] - }) - - def _add_hdd_excludes_computer_case(self): - self.hdd_4_excludes_computer_case_m = self.env['product.template.attribute.exclusion'].create({ - 'product_tmpl_id': self.computer_case.id, - 'value_ids': [(6, 0, [self._get_product_value_id(self.computer_case_size_attribute_lines, self.size_m).id])] - }) - self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_4).update({ - 'exclude_for': [(6, 0, [self.hdd_4_excludes_computer_case_m.id])] + 'value_ids': [(6, 0, [self.size_m.id, self.size_l.id, self.size_xl.id])], }) def _get_product_value_id(self, product_template_attribute_lines, product_attribute_value): return product_template_attribute_lines.product_template_value_ids.filtered( lambda product_value_id: product_value_id.product_attribute_value_id == product_attribute_value)[0] - def _get_variant_for_attribute_values(self, product, attribute_values, reference_product=None): - return product.get_filtered_variants(reference_product).filtered( - lambda variant: - all(attribute_value in variant.attribute_value_ids for attribute_value in attribute_values)) + def _get_product_template_attribute_value(self, product_attribute_value, model=False): + """ + Return the `product.template.attribute.value` matching + `product_attribute_value` for self. + + :param: recordset of one product.attribute.value + :return: recordset of one product.template.attribute.value if found + else empty + """ + if not model: + model = self.computer + return model._get_valid_product_template_attribute_lines().filtered( + lambda l: l.attribute_id == product_attribute_value.attribute_id + ).product_template_value_ids.filtered( + lambda v: v.product_attribute_value_id == product_attribute_value + ) + + def _add_exclude(self, m1, m2, product_template=False): + m1.update({ + 'exclude_for': [(0, 0, { + 'product_tmpl_id': (product_template or self.computer).id, + 'value_ids': [(6, 0, [m2.id])] + })] + }) + + +@tagged('post_install', '-at_install') +class TestProductAttributeValueConfig(TestProductAttributeValueSetup): + + def test_product_template_attribute_values_creation(self): + self.assertEqual(len(self.computer_ssd_attribute_lines.product_template_value_ids), 2, + 'Product attribute values (ssd) were not automatically created') + self.assertEqual(len(self.computer_ram_attribute_lines.product_template_value_ids), 3, + 'Product attribute values (ram) were not automatically created') + self.assertEqual(len(self.computer_hdd_attribute_lines.product_template_value_ids), 3, + 'Product attribute values (hdd) were not automatically created') + self.assertEqual(len(self.computer_case_size_attribute_lines.product_template_value_ids), 3, + 'Product attribute values (size) were not automatically created') + + def test_get_variant_for_combination(self): + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_ram_16 = self._get_product_template_attribute_value(self.ram_16) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + + # completely defined variant + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 + ok_variant = self.computer._get_variant_for_combination(combination) + self.assertEqual(ok_variant.product_template_attribute_value_ids, combination) + + # over defined variant + combination = computer_ssd_256 + computer_ram_8 + computer_ram_16 + computer_hdd_1 + variant = self.computer._get_variant_for_combination(combination) + self.assertEqual(len(variant), 0) + + # under defined variant + combination = computer_ssd_256 + computer_ram_8 + variant = self.computer._get_variant_for_combination(combination) + self.assertEqual(len(variant), 0) + + # also test _has_valid_attributes (case ok): + valid_value_ids = self.computer._get_valid_product_attribute_values()._without_no_variant_attributes() + valid_attribute_ids = self.computer._get_valid_product_attributes()._without_no_variant_attributes() + self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids)) + + # also test _has_valid_attributes (case not ok): + self.assertFalse(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids - self.hdd_1)) + + def test_product_filtered_exclude_for(self): + """ + Super Computer has 18 variants total (2 ssd * 3 ram * 3 hdd) + RAM 16 excudes HDD 1, that matches 2 variants: + - SSD 256 RAM 16 HDD 1 + - SSD 512 RAM 16 HDD 1 + + => There has to be 16 variants left when filtered + """ + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_ram_16 = self._get_product_template_attribute_value(self.ram_16) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + + self.assertEqual(len(self.computer._get_possible_variants()), 18) + self._add_ram_exclude_for() + self.assertEqual(len(self.computer._get_possible_variants()), 16) + self.assertTrue(self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)._is_variant_possible()) + self.assertFalse(self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_16 + computer_hdd_1)._is_variant_possible()) + self.assertFalse(self.computer._get_variant_for_combination(computer_ssd_512 + computer_ram_16 + computer_hdd_1)._is_variant_possible()) + + def test_children_product_filtered_exclude_for(self): + """ + Super Computer Case has 3 variants total (3 size) + Reference product Computer with HDD 4 excludes Size M + The following variant will be excluded: + - Size M + + => There has to be 2 variants left when filtered + """ + computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4) + computer_size_m = self._get_product_template_attribute_value(self.size_m, self.computer_case) + self._add_exclude(computer_hdd_4, computer_size_m, self.computer_case) + self.assertEqual(len(self.computer_case._get_possible_variants(computer_hdd_4)), 2) + self.assertFalse(self.computer_case._get_variant_for_combination(computer_size_m)._is_variant_possible(computer_hdd_4)) + + def test_is_combination_possible(self): + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_ram_16 = self._get_product_template_attribute_value(self.ram_16) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + self._add_exclude(computer_ram_16, computer_hdd_1) + + # CASE: basic + self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) + + # CASE: ram 16 excluding hdd1 + self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16 + computer_hdd_1)) + + # CASE: under defined combination + self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16)) + + # CASE: no combination, no variant, just return the only variant + mouse = self.env['product.template'].create({'name': 'Mouse'}) + self.assertTrue(mouse._is_combination_possible(self.env['product.template.attribute.value'])) + + # prep work for the last part of the test + color_attribute = self.env['product.attribute'].create({'name': 'Color'}) + color_red = self.env['product.attribute.value'].create({ + 'name': 'Red', + 'attribute_id': color_attribute.id, + }) + color_green = self.env['product.attribute.value'].create({ + 'name': 'Green', + 'attribute_id': color_attribute.id, + }) + self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': mouse.id, + 'attribute_id': color_attribute.id, + 'value_ids': [(6, 0, [color_red.id, color_green.id])], + }) + + mouse.create_variant_ids() + + mouse_color_red = self._get_product_template_attribute_value(color_red, mouse) + mouse_color_green = self._get_product_template_attribute_value(color_green, mouse) + + self._add_exclude(computer_ssd_256, mouse_color_green, mouse) + + variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1) + + # CASE: wrong attributes (mouse_color_red not on computer) + self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16 + mouse_color_red)) + + # CASE: parent ok + self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1, mouse_color_red)) + self.assertTrue(mouse._is_combination_possible(mouse_color_red, computer_ssd_256 + computer_ram_8 + computer_hdd_1)) + + # CASE: parent exclusion but good direction (parent is directional) + self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1, mouse_color_green)) + + # CASE: parent exclusion and wrong direction (parent is directional) + self.assertFalse(mouse._is_combination_possible(mouse_color_green, computer_ssd_256 + computer_ram_8 + computer_hdd_1)) + + # CASE: deleted combination + variant.unlink() + self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) + + def test_get_first_possible_combination(self): + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_ram_16 = self._get_product_template_attribute_value(self.ram_16) + computer_ram_32 = self._get_product_template_attribute_value(self.ram_32) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2) + computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4) + self._add_exclude(computer_ram_16, computer_hdd_1) + + # ram_8 is allowed with the other attributes + self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1) + + # Below invalidate cache between every test to force reload the + # o2m attribute_line_ids to order it by the new sequence + + # Give priority to ram_16 but it is not allowed by hdd_1 so it should return hhd_2 instead + computer_ram_16.product_attribute_value_id.sequence = -1 + self.computer.invalidate_cache() # need o2m to be reordered + self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_16 + computer_hdd_2) + + # Give priority to ram_32 and is allowed with the rest so it should return it + computer_ram_16.product_attribute_value_id.sequence = 2 + computer_ram_32.product_attribute_value_id.sequence = -1 + self.computer.invalidate_cache() # need o2m to be reordered + self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_32 + computer_hdd_1) + + # Give priority to ram_16 but now it is not allowing any hdd so it should return ram_8 instead + computer_ram_32.product_attribute_value_id.sequence = 3 + computer_ram_16.product_attribute_value_id.sequence = -1 + self.computer.invalidate_cache() # need o2m to be reordered + self._add_exclude(computer_ram_16, computer_hdd_2) + self._add_exclude(computer_ram_16, computer_hdd_4) + self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1) + + # Only the last combination is possible + computer_ram_16.product_attribute_value_id.sequence = 2 + self.computer.invalidate_cache() # need o2m to be reordered + self._add_exclude(computer_ram_8, computer_hdd_1) + self._add_exclude(computer_ram_8, computer_hdd_2) + self._add_exclude(computer_ram_8, computer_hdd_4) + self._add_exclude(computer_ram_32, computer_hdd_1) + self._add_exclude(computer_ram_32, computer_hdd_2) + self._add_exclude(computer_ram_32, computer_ssd_256) + self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_512 + computer_ram_32 + computer_hdd_4) + + # No possible combination + self._add_exclude(computer_ram_32, computer_hdd_4) + self.assertEqual(self.computer._get_first_possible_combination(), self.env['product.template.attribute.value']) + + def test_get_closest_possible_combination(self): + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_ram_16 = self._get_product_template_attribute_value(self.ram_16) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2) + computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4) + self._add_exclude(computer_ram_16, computer_hdd_1) + + # CASE nothing special + self.assertEqual(self.computer._get_closest_possible_combination(None), + computer_ssd_256 + computer_ram_8 + computer_hdd_1) + + # CASE contains computer_hdd_1 + self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1), + computer_ssd_256 + computer_ram_8 + computer_hdd_1) + + # CASE contains computer_hdd_2 + self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2), + computer_ssd_256 + computer_ram_8 + computer_hdd_2) + + # CASE contains computer_hdd_2, computer_ram_16 + self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2 + computer_ram_16), + computer_ssd_256 + computer_ram_16 + computer_hdd_2) + + # CASE invalid combination (excluded): + self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1 + computer_ram_16), + computer_ssd_256 + computer_ram_8 + computer_hdd_1) + + # CASE invalid combination (too much): + self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2), + computer_ssd_256 + computer_ram_8 + computer_hdd_4) diff --git a/addons/product/views/product_attribute_views.xml b/addons/product/views/product_attribute_views.xml index 1cc2da77c0f..0db1c369887 100644 --- a/addons/product/views/product_attribute_views.xml +++ b/addons/product/views/product_attribute_views.xml @@ -27,7 +27,7 @@ <group name="values_ids"> <label for="value_ids" string="Attribute Values"/> <field name="value_ids" widget="one2many_list" nolabel="1"> - <tree string="Values" editable="top"> + <tree string="Values" editable="bottom"> <field name="sequence" widget="handle"/> <field name="name"/> </tree> @@ -49,7 +49,7 @@ <field name="name">product.attribute.value.tree</field> <field name="model">product.attribute.value</field> <field name="arch" type="xml"> - <tree string="Variant Values" editable="top"> + <tree string="Variant Values" editable="bottom"> <field name="sequence" widget="handle"/> <field name="attribute_id"/> <field name="name"/> diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index 066c4251634..3d2a4306f66 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -71,15 +71,65 @@ <group groups="product.group_product_pricelist"> <div name="pricelist_item" groups="product.group_product_pricelist"> <separator string="Pricing"/> - <field name="item_ids" nolabel="1" context="{'default_base':'list_price', 'default_applied_on' :'1_product'}"> + <field name="item_ids" nolabel="1" mode="tree,kanban" context="{'default_base':'list_price', 'default_applied_on' :'1_product'}"> <tree string="Pricelist Items" editable="bottom"> <field name="pricelist_id" string="Pricelist" required='1'/> - <field name="fixed_price" string="Price" required='1'/> + <field name="currency_id" invisible="1"/> + <field name="fixed_price" string="Price" required='1' widget='monetary' options="{'currency_field': 'currency_id'}"/> <field name="min_quantity"/> <field name="date_start"/> <field name="date_end"/> <field name="applied_on" invisible="1"/> </tree> + <kanban string="Pricelist Items" class="o_kanban_mobile"> + <field name="pricelist_id"/> + <field name="currency_id"/> + <field name="fixed_price"/> + <field name="min_quantity"/> + <field name="date_start"/> + <field name="date_end"/> + <templates> + <t t-name="kanban-box"> + <div t-attf-class="oe_kanban_global_click"> + <div class="row"> + <div class="col-12"> + <strong> + <field name='pricelist_id'/>: <field name="fixed_price" widget='monetary' options="{'currency_field': 'currency_id'}"/> + </strong> + </div> + </div> + <div class="row"> + <div class="col-12"> + <span><t t-esc='record.min_quantity.string'/>: <t t-esc='record.min_quantity.value'/></span> + </div> + </div> + <span t-if='record.date_start.value || record.date_end.value'> + <div class="row"> + <div class="col-12"> + Valid <span t-if='record.date_start.value'> from <t t-esc='record.date_start.value'/></span><span t-if='record.date_end.value'> until <t t-esc='record.date_end.value'/></span> + </div> + </div> + </span> + </div> + </t> + </templates> + </kanban> + <form string="Pricelist Items"> + <h1><field name="name"/></h1> + <group> + <group> + <field name="applied_on" invisible="1"/> + <field name="pricelist_id" string="Pricelist" required='1'/> + <field name="currency_id" invisible="1"/> + <field name="fixed_price" string="Price" required='1' widget='monetary' options="{'currency_field': 'currency_id'}"/> + </group> + <group> + <field name="min_quantity"/> + <field name="date_start"/> + <field name="date_end"/> + </group> + </group> + </form> </field> </div> </group> diff --git a/addons/purchase/report/purchase_report.py b/addons/purchase/report/purchase_report.py index 4b9ca15207b..28c25cecaca 100644 --- a/addons/purchase/report/purchase_report.py +++ b/addons/purchase/report/purchase_report.py @@ -79,10 +79,10 @@ class PurchaseReport(models.Model): extract(epoch from age(s.date_approve,s.date_order))/(24*60*60)::decimal(16,2) as delay, extract(epoch from age(l.date_planned,s.date_order))/(24*60*60)::decimal(16,2) as delay_pass, count(*) as nbr_lines, - sum(l.price_unit / COALESCE(cr.rate, 1.0) * l.product_qty)::decimal(16,2) as price_total, - avg(100.0 * (l.price_unit / COALESCE(cr.rate,1.0) * l.product_qty) / NULLIF(ip.value_float*l.product_qty/u.factor*u2.factor, 0.0))::decimal(16,2) as negociation, + sum(l.price_unit / COALESCE(NULLIF(cr.rate, 0), 1.0) * l.product_qty)::decimal(16,2) as price_total, + avg(100.0 * (l.price_unit / COALESCE(NULLIF(cr.rate, 0),1.0) * l.product_qty) / NULLIF(ip.value_float*l.product_qty/u.factor*u2.factor, 0.0))::decimal(16,2) as negociation, sum(ip.value_float*l.product_qty/u.factor*u2.factor)::decimal(16,2) as price_standard, - (sum(l.product_qty * l.price_unit / COALESCE(cr.rate, 1.0))/NULLIF(sum(l.product_qty/u.factor*u2.factor),0.0))::decimal(16,2) as price_average, + (sum(l.product_qty * l.price_unit / COALESCE(NULLIF(cr.rate, 0), 1.0))/NULLIF(sum(l.product_qty/u.factor*u2.factor),0.0))::decimal(16,2) as price_average, partner.country_id as country_id, partner.commercial_partner_id as commercial_partner_id, analytic_account.id as account_analytic_id, diff --git a/addons/purchase/views/portal_templates.xml b/addons/purchase/views/portal_templates.xml index c125d4e18a8..b6a9a0a0847 100644 --- a/addons/purchase/views/portal_templates.xml +++ b/addons/purchase/views/portal_templates.xml @@ -68,7 +68,12 @@ <div class="row"> <div class="col-lg-12"> <h4> - Purchase Order + <t t-if="order.state in ['draft', 'sent']"> + Request for Quotation + </t> + <t t-else="1"> + Purchase Order + </t> <span t-esc="order.name"/> </h4> </div> diff --git a/addons/purchase_requisition/models/stock.py b/addons/purchase_requisition/models/stock.py index b41b674fb4a..fd42537b8a1 100644 --- a/addons/purchase_requisition/models/stock.py +++ b/addons/purchase_requisition/models/stock.py @@ -48,7 +48,7 @@ class StockMove(models.Model): def _get_upstream_documents_and_responsibles(self, visited): if self.requisition_line_ids: - return [(requisition_line.requisition_id, requisition_line.requisition_id.user_id, visited) for requisition_line in self.requisition_line_ids if requisition_line.state not in ('done', 'cancel')] + return [(requisition_line.requisition_id, requisition_line.requisition_id.user_id, visited) for requisition_line in self.requisition_line_ids if requisition_line.requisition_id.state not in ('done', 'cancel')] else: return super(StockMove, self)._get_upstream_documents_and_responsibles(visited) diff --git a/addons/sale/controllers/portal.py b/addons/sale/controllers/portal.py index 117a0598c08..88084288b0a 100644 --- a/addons/sale/controllers/portal.py +++ b/addons/sale/controllers/portal.py @@ -168,6 +168,8 @@ class CustomerPortal(CustomerPortal): 'partner_id': order_sudo.partner_id.id, 'report_type': 'html', } + if order_sudo.company_id: + values['res_company'] = order_sudo.company_id if order_sudo.has_to_be_paid(): domain = expression.AND([ diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py index 9a9d397b4dd..5a5ae1b26d9 100644 --- a/addons/sale/controllers/product_configurator.py +++ b/addons/sale/controllers/product_configurator.py @@ -8,146 +8,125 @@ from odoo.http import request class ProductConfiguratorController(http.Controller): @http.route(['/product_configurator/configure'], type='json', auth="user", methods=['POST']) def configure(self, product_id, pricelist_id, **kw): + add_qty = int(kw.get('add_qty', 1)) product_template = request.env['product.template'].browse(int(product_id)) to_currency = product_template.currency_id pricelist = self._get_pricelist(pricelist_id) + if pricelist: product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id) to_currency = pricelist.currency_id return request.env['ir.ui.view'].render_template("sale.product_configurator_configure", { 'product': product_template, + # to_currency deprecated, get it from the pricelist or product directly 'to_currency': to_currency, 'pricelist': pricelist, + 'add_qty': add_qty, + # get_attribute_exclusions deprecated, use product method 'get_attribute_exclusions': self._get_attribute_exclusions }) @http.route(['/product_configurator/show_optional_products'], type='json', auth="user", methods=['POST']) def show_optional_products(self, product_id, variant_values, pricelist_id, **kw): - return self._show_optional_products(product_id, variant_values, self._get_pricelist(pricelist_id), False, **kw) + pricelist = self._get_pricelist(pricelist_id) + return self._show_optional_products(product_id, variant_values, pricelist, False, **kw) @http.route(['/product_configurator/optional_product_items'], type='json', auth="user", methods=['POST']) def optional_product_items(self, product_id, pricelist_id, **kw): - return self._optional_product_items(product_id, self._get_pricelist(pricelist_id), **kw) + pricelist = self._get_pricelist(pricelist_id) + return self._optional_product_items(product_id, pricelist, **kw) @http.route(['/product_configurator/get_combination_info'], type='json', auth="user", methods=['POST']) def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw): - return self._get_combination_info(product_template_id, product_id, combination, add_qty, self._get_pricelist(pricelist_id)) + combination = request.env['product.template.attribute.value'].browse(combination) + pricelist = self._get_pricelist(pricelist_id) + return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist) + + @http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST']) + def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs): + return request.env['product.template'].browse(int(product_template_id)).create_product_variant(product_template_attribute_value_ids) def _optional_product_items(self, product_id, pricelist, **kw): + add_qty = int(kw.get('add_qty', 1)) product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id)) to_currency = product.currency_id + if pricelist: to_currency = pricelist.currency_id + parent_combination = product.product_template_attribute_value_ids + if product.env.context.get('no_variant_attribute_values'): + # Add "no_variant" attribute values' exclusions + # They are kept in the context since they are not linked to this product variant + parent_combination |= product.env.context.get('no_variant_attribute_values') + return request.env['ir.ui.view'].render_template("sale.optional_product_items", { + # product deprecated, it's not used in the view 'product': product, + # reference_product deprecated, use parent_combination instead 'reference_product': product, + 'parent_combination': parent_combination, 'pricelist': pricelist, + # to_currency deprecated, get from pricelist or product 'to_currency': to_currency, + # get_attribute_exclusions deprecated, use product method 'get_attribute_exclusions': self._get_attribute_exclusions, + 'add_qty': add_qty, }) def _show_optional_products(self, product_id, variant_values, pricelist, handle_stock, **kw): - product = request.env['product.product'].browse(int(product_id)) - from_currency = request.env.user.company_id.currency_id - to_currency = pricelist.currency_id if pricelist else product.currency_id + product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id)) + combination = request.env['product.template.attribute.value'].browse(variant_values) + has_optional_products = product.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination)) + + if not has_optional_products: + return False + + add_qty = int(kw.get('add_qty', 1)) + to_currency = (pricelist or product).currency_id company = request.env['res.company'].browse(request.env.context.get('company_id')) or request.env['res.users']._get_company() date = request.env.context.get('date') or fields.Date.today() - compute_currency = lambda price: from_currency._convert(price, to_currency, company, date) - product = product.with_context(self._get_product_context(pricelist, **kw)) - no_variant_attribute_values = request.env['product.template.attribute.value'].browse(variant_values).filtered( + def compute_currency(price): + return product.currency_id._convert(price, to_currency, company, date) + + no_variant_attribute_values = combination.filtered( lambda product_template_attribute_value: product_template_attribute_value.attribute_id.create_variant == 'no_variant' ) if no_variant_attribute_values: product = product.with_context(no_variant_attribute_values=no_variant_attribute_values) - has_optional_products = False - for optional_product in product.optional_product_ids: - if optional_product.has_dynamic_attributes() or optional_product.get_filtered_variants(product): - has_optional_products = True - break - - if not has_optional_products: - return False - else: - return request.env['ir.ui.view'].render_template("sale.optional_products_modal", { - 'product': product, - 'reference_product': product, - 'variant_values': variant_values, - 'pricelist': pricelist, - 'compute_currency': compute_currency, - 'to_currency': to_currency, - 'handle_stock': handle_stock, - 'get_attribute_exclusions': self._get_attribute_exclusions, - }) + return request.env['ir.ui.view'].render_template("sale.optional_products_modal", { + 'product': product, + 'combination': combination, + 'add_qty': add_qty, + # reference_product deprecated, use combination instead + 'reference_product': product, + 'variant_values': variant_values, + 'pricelist': pricelist, + # compute_currency deprecated, get from pricelist or product + 'compute_currency': compute_currency, + # to_currency deprecated, get from pricelist or product + 'to_currency': to_currency, + 'handle_stock': handle_stock, + # get_attribute_exclusions deprecated, use product method + 'get_attribute_exclusions': self._get_attribute_exclusions, + }) def _get_attribute_exclusions(self, product, reference_product=None): - """ list of attribute exclusions of a product - - Args: - - product (product.template): The base product template - - reference_product (product.product): The reference product from which 'product' is an optional or accessory product - - :return: dict of exclusions - exclusions.exclusions: exclusions within this product - exclusions.parent_exclusions: exclusions coming from the reference_product - """ - - product_attribute_values = request.env['product.template.attribute.value'].search([ - ('product_tmpl_id', '=', product.id), - ('product_attribute_value_id', 'in', product.attribute_line_ids.mapped('value_ids').ids), - ]) - - # array of all the excluded value_ids of all the filter lines for this product - mapped_exclusions = { - product_attribute_value.id: [ - value_id - for filter_line in product_attribute_value.exclude_for.filtered( - lambda filter_line: filter_line.product_tmpl_id == product - ) for value_id in filter_line.value_ids.ids - ] - for product_attribute_value in product_attribute_values - } - - parent_exclusions = [] + """deprecated, use product method""" + parent_combination = request.env['product.template.attribute.value'] if reference_product: - parent_attribute_value_ids = reference_product.product_template_attribute_value_ids - if parent_attribute_value_ids and reference_product._context.get('no_variant_attribute_values'): + parent_combination |= reference_product.product_template_attribute_value_ids + if reference_product.env.context.get('no_variant_attribute_values'): # Add "no_variant" attribute values' exclusions # They are kept in the context since they are not linked to this product variant - parent_attribute_value_ids |= reference_product._context.get('no_variant_attribute_values') - - parent_exclusions = [ - value_id - for filter_line in parent_attribute_value_ids.mapped('exclude_for').filtered( - lambda filter_line: filter_line.product_tmpl_id == product - ) for value_id in filter_line.value_ids.ids] - - # Query all archived products for this template - archived_combinations = request.env['product.product'].search( - [('product_tmpl_id', '=', product.id), ('active', '=', False)]) - - if archived_combinations: - # Old archived variants could have a different set of attributes and are not relevant here - # -> filter them out - attribute_ids = product_attribute_values.mapped('attribute_id') - archived_combinations = archived_combinations.filtered( - lambda product: all( - attribute_id in product.mapped('product_template_attribute_value_ids.attribute_id') - for attribute_id in attribute_ids - ) - ) - - return { - 'exclusions': mapped_exclusions, - 'parent_exclusions': parent_exclusions, - 'archived_combinations': [archived_combination.product_template_attribute_value_ids.ids - for archived_combination in archived_combinations] - } + parent_combination |= reference_product.env.context.get('no_variant_attribute_values') + return product._get_attribute_exclusions(parent_combination) def _get_product_context(self, pricelist=None, **kw): + """deprecated, can be removed in master""" product_context = dict(request.context) if pricelist: if not product_context.get('pricelist'): @@ -157,64 +136,9 @@ class ProductConfiguratorController(http.Controller): return product_context def _get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist, **kw): - product_template_attribute_values = request.env['product.template.attribute.value'].browse(combination) - filtered_product_template_attribute_values = product_template_attribute_values.filtered( - lambda product_attribute_value: product_attribute_value.attribute_id.create_variant != 'no_variant' - ) - context = { - 'quantity': add_qty, - 'pricelist': pricelist.id if pricelist else None, - 'current_attributes_price_extra': [product_attribute_value.price_extra or 0.0 for product_attribute_value in product_template_attribute_values] - } - - product_template = request.env['product.template'].with_context(context).browse(product_template_id) - - Product = request.env['product.product'].with_context(context) - if product_id and not combination: - product = Product.browse(product_id) - else: - products = Product.search([ - ('product_tmpl_id', '=', product_template_id) - ]) - product = products.filtered( - lambda product: all(product_attribute_value in product.product_template_attribute_value_ids - for product_attribute_value in filtered_product_template_attribute_values) - ) - - product_id = None - list_price = product_template.price_compute('list_price')[product_template.id] - price = product_template.price - if(product): - product = product.with_context( - no_variant_attributes_price_extra=[product_attribute_value.price_extra or 0.0 - for product_attribute_value in product_template_attribute_values.filtered( - lambda product_attribute_value: product_attribute_value.attribute_id.create_variant == 'no_variant' - ) - ] - ) - product_id = product.id - list_price = product.price_compute('list_price')[product.id] - price = product.price - - display_name = [product_template.name] - if filtered_product_template_attribute_values: - display_name.append(' (') - display_name.append(', '.join(filtered_product_template_attribute_values.mapped('name'))) - display_name.append(')') - - if pricelist and pricelist.currency_id != product_template.currency_id: - list_price = product_template.currency_id._convert( - list_price, pricelist.currency_id, - request.env.user.company_id, fields.Date.today() - ) - - return { - 'product_id': product_id, - 'product_template_id': product_template.id, - 'display_name': ''.join(display_name), - 'price': price, - 'list_price': list_price - } - - def _get_pricelist(self, pricelist_id): - return request.env['product.pricelist'].browse(int(pricelist_id)) if pricelist_id and pricelist_id != '0' else None + """deprecated, use product method""" + combination = request.env['product.template.attribute.value'].browse(combination) + return request.env['product.template'].browse(product_template_id)._get_combination_info(combination, product_id, add_qty, pricelist) + + def _get_pricelist(self, pricelist_id, pricelist_fallback=False): + return request.env['product.pricelist'].browse(int(pricelist_id or 0)) diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml index 9f7106b6e71..0cd6ee4f40b 100644 --- a/addons/sale/data/sale_demo.xml +++ b/addons/sale/data/sale_demo.xml @@ -750,6 +750,7 @@ Thanks!</field> <field name="name">Custom</field> <field name="attribute_id" ref="product.product_attribute_1"/> <field name="is_custom">True</field> + <field name="sequence">3</field> </record> <record id="product.product_template_attribute_line_1" model="product.template.attribute.line"> diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py index 9bac794a098..8fb938582a0 100644 --- a/addons/sale/models/analytic.py +++ b/addons/sale/models/analytic.py @@ -39,7 +39,7 @@ class AccountAnalyticLine(models.Model): self.ensure_one() if self.product_id.expense_policy == 'sales_price': return self.product_id.with_context( - partner=order.partner_id.id, + partner=order.partner_id, date_order=order.date_order, pricelist=order.pricelist_id.id, uom=self.product_uom_id.id diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 9d54d991b86..a489b03b952 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -56,6 +56,14 @@ class ProductProduct(models.Model): def _get_invoice_policy(self): return self.invoice_policy + @api.multi + def _get_combination_info_variant(self, add_qty=1, pricelist=False, parent_combination=False): + """Return the variant info based on its combination. + See `_get_combination_info` for more information. + """ + self.ensure_one() + return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination) + class ProductAttribute(models.Model): _inherit = "product.attribute" diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py index f7f21afc455..4da13305464 100644 --- a/addons/sale/models/product_template.py +++ b/addons/sale/models/product_template.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import json +import logging + from odoo import api, fields, models, _ from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP from odoo.tools.float_utils import float_round -import json + +_logger = logging.getLogger(__name__) class ProductTemplate(models.Model): @@ -58,21 +62,88 @@ class ProductTemplate(models.Model): } return action - def create_product_variant(self, product_template_attribute_value_ids): + @api.multi + def _create_product_variant(self, combination, log_warning=False): + """ Create if necessary and possible and return the product variant + matching the given combination for this template. + + It is possible to create only if the template has dynamic attributes + and the combination itself is possible. + + :param combination: the combination for which to get or create variant. + The combination must contain all necessary attributes, including + those of type no_variant. Indeed even though those attributes won't + be included in the variant if newly created, they are needed when + checking if the combination is possible. + :type combination: recordset of `product.template.attribute.value` + + :param log_warning: whether a warning should be logged on fail + :type log_warning: bool + + :return: the product variant matching the combination or none + :rtype: recordset of `product.product` + """ self.ensure_one() - attribute_value_ids = \ - self.env['product.template.attribute.value'] \ - .browse(json.loads(product_template_attribute_value_ids)) \ - .mapped('product_attribute_value_id') \ - .filtered(lambda attribute_value_id: attribute_value_id.attribute_id.create_variant != 'no_variant') + Product = self.env['product.product'] + + product_variant = self._get_variant_for_combination(combination) + if product_variant: + return product_variant + + if not self.has_dynamic_attributes(): + if log_warning: + _logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id)) + return Product - product_variant = self.env['product.product'].create({ + if not self._is_combination_possible(combination): + if log_warning: + _logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id)) + return Product + + attribute_values = combination.mapped('product_attribute_value_id')._without_no_variant_attributes() + + return Product.sudo().create({ 'product_tmpl_id': self.id, - 'attribute_value_ids': [(6, 0, attribute_value_ids.ids)] + 'attribute_value_ids': [(6, 0, attribute_values.ids)] }) - return product_variant.id + @api.multi + def create_product_variant(self, product_template_attribute_value_ids): + """ Create if necessary and possible and return the id of the product + variant matching the given combination for this template. + + Note AWA: Known "exploit" issues with this method: + + - This method could be used by an unauthenticated user to generate a + lot of useless variants. Unfortunately, after discussing the + matter with ODO, there's no easy and user-friendly way to block + that behavior. + + We would have to use captcha/server actions to clean/... that + are all not user-friendly/overkill mechanisms. + + - This method could be used to try to guess what product variant ids + are created in the system and what product template ids are + configured as "dynamic", but that does not seem like a big deal. + + The error messages are identical on purpose to avoid giving too much + information to a potential attacker: + - returning 0 when failing + - returning the variant id whether it already existed or not + + :param product_template_attribute_value_ids: the combination for which + to get or create variant + :type product_template_attribute_value_ids: json encoded list of id + of `product.template.attribute.value` + + :return: id of the product variant matching the combination or 0 + :rtype: int + """ + combination = self.env['product.template.attribute.value'] \ + .browse(json.loads(product_template_attribute_value_ids)) + + return self._create_product_variant(combination, log_warning=True).id or 0 @api.onchange('type') def _onchange_type(self): @@ -96,3 +167,132 @@ class ProductTemplate(models.Model): 'template': '/sale/static/xls/product_pricelist_several.xls' }] return res + + @api.multi + def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False): + """ Return info about a given combination. + + Note: this method does not take into account whether the combination is + actually possible. + + :param combination: recordset of `product.template.attribute.value` + + :param product_id: id of a `product.product`. If no `combination` + is set, the method will try to load the variant `product_id` if + it exists instead of finding a variant based on the combination. + + If there is no combination, that means we definitely want a + variant and not something that will have no_variant set. + + :param add_qty: float with the quantity for which to get the info, + indeed some pricelist rules might depend on it. + + :param pricelist: `product.pricelist` the pricelist to use + (can be none, eg. from SO if no partner and no pricelist selected) + + :param parent_combination: if no combination and no product_id are + given, it will try to find the first possible combination, taking + into account parent_combination (if set) for the exclusion rules. + + :return: dict with product/combination info: + + - product_id: the variant id matching the combination (if it exists) + + - product_template_id: the current template id + + - display_name: the name of the combination + + - price: the computed price of the combination, take the catalog + price if no pricelist is given + + - list_price: the catalog price of the combination, but this is + not the "real" list_price, it has price_extra included (so + it's actually more closely related to `lst_price`), and it + is converted to the pricelist currency (if given) + + - has_discounted_price: True if the pricelist discount policy says + the price does not include the discount and there is actually a + discount applied (price < list_price), else False + """ + self.ensure_one() + + quantity = self.env.context.get('quantity', add_qty) + context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False) + product_template = self.with_context(context) + + combination = combination or product_template.env['product.template.attribute.value'] + + if not product_id and not combination: + combination = product_template._get_first_possible_combination(parent_combination) + + if product_id and not combination: + product = product_template.env['product.product'].browse(product_id) + else: + product = product_template._get_variant_for_combination(combination) + + if product: + # We need to add the price_extra for the attributes that are not + # in the variant, typically those of type no_variant, but it is + # possible that a no_variant attribute is still in a variant if + # the type of the attribute has been changed after creation. + no_variant_attributes_price_extra = [ + ptav.price_extra for ptav in combination.filtered( + lambda ptav: + ptav.price_extra and + ptav not in product.product_template_attribute_value_ids + ) + ] + if no_variant_attributes_price_extra: + product = product.with_context( + no_variant_attributes_price_extra=no_variant_attributes_price_extra + ) + list_price = product.price_compute('list_price')[product.id] + price = product.price if pricelist else list_price + else: + product_template = product_template.with_context(current_attributes_price_extra=[v.price_extra or 0.0 for v in combination]) + list_price = product_template.price_compute('list_price')[product_template.id] + price = product_template.price if pricelist else list_price + + display_name = product_template.name + + filtered_combination = combination._without_no_variant_attributes() + if filtered_combination: + display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name'))) + + if pricelist and pricelist.currency_id != product_template.currency_id: + list_price = product_template.currency_id._convert( + list_price, pricelist.currency_id, product_template.company_id, + fields.Date.today() + ) + + price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price + has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1 + + return { + 'product_id': product.id, + 'product_template_id': product_template.id, + 'display_name': display_name, + 'price': price, + 'list_price': list_price, + 'has_discounted_price': has_discounted_price, + } + + @api.multi + def _is_add_to_cart_possible(self, parent_combination=None): + """ + It's possible to add to cart (potentially after configuration) if + there is at least one possible combination, or if there is no + `product.template.attribute.line` at all. + + :param parent_combination: the 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 add to cart, else False + :rtype: bool + """ + self.ensure_one() + if not self.active: + return False + combination = self._get_first_possible_combination(parent_combination) + return True if combination else self._is_combination_possible(combination, parent_combination) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index bc4343e5b7d..5ab5355ac24 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -381,11 +381,13 @@ class SaleOrder(models.Model): if self.env.context.get('mail_activity_automation_skip'): return super(SaleOrder, self)._write(values) - res = super(SaleOrder, self)._write(values) if 'invoice_status' in values: - self.activity_unlink(['sale.mail_act_sale_upsell']) if values['invoice_status'] == 'upselling': - for order in self.filtered('user_id'): + filtered_self = self.search([('id', 'in', self.ids), + ('user_id', '!=', False), + ('invoice_status', '!=', 'upselling')]) + filtered_self.activity_unlink(['sale.mail_act_sale_upsell']) + for order in filtered_self: order.activity_schedule( 'sale.mail_act_sale_upsell', user_id=order.user_id.id, @@ -393,7 +395,7 @@ class SaleOrder(models.Model): order._name, order.id, order.name, order.partner_id._name, order.partner_id.id, order.partner_id.display_name)) - return res + return super(SaleOrder, self)._write(values) @api.multi def copy_data(self, default=None): @@ -649,9 +651,10 @@ class SaleOrder(models.Model): self.force_quotation_send() # create an analytic account if at least an expense product - if any([expense_policy != 'no' for expense_policy in self.order_line.mapped('product_id.expense_policy')]): - if not self.analytic_account_id: - self._create_analytic_account() + for order in self: + if any([expense_policy != 'no' for expense_policy in order.order_line.mapped('product_id.expense_policy')]): + if not order.analytic_account_id: + order._create_analytic_account() return True @@ -1110,9 +1113,16 @@ class SaleOrderLine(models.Model): product_uom_qty = fields.Float(string='Ordered Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0) product_uom = fields.Many2one('uom.uom', string='Unit of Measure') product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string='User entered custom product attribute values') + # M2M holding the values of product.attribute with create_variant field set to 'no_variant' # It allows keeping track of the extra_price associated to those attribute values and add them to the SO line description + # Note: If the attributes are changed on the template, some or all records + # in `product_no_variant_attribute_value_ids` will be removed here, even + # from existing or locked sales order lines. Thus this field can only be + # relied on initially, but it cannot be used to recompute anything later + # because the result might be different then. product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants') + # Non-stored related field to allow portal user to see the image of the product he has ordered product_image = fields.Binary('Product Image', related="product_id.image", store=False, readonly=False) @@ -1362,11 +1372,20 @@ class SaleOrderLine(models.Model): # TO DO: move me in master/saas-16 on sale.order # awa: don't know if it's still the case since we need the "product_no_variant_attribute_value_ids" field now # to be able to compute the full price - if self.product_no_variant_attribute_value_ids: - product = product.with_context(no_variant_attributes_price_extra=[ - no_variant_attribute_value.price_extra or 0 - for no_variant_attribute_value in self.product_no_variant_attribute_value_ids - ]) + + # it is possible that a no_variant attribute is still in a variant if + # the type of the attribute has been changed after creation. + no_variant_attributes_price_extra = [ + ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered( + lambda ptav: + ptav.price_extra and + ptav not in product.product_template_attribute_value_ids + ) + ] + if no_variant_attributes_price_extra: + product = product.with_context( + no_variant_attributes_price_extra=no_variant_attributes_price_extra + ) if self.order_id.pricelist_id.discount_policy == 'with_discount': return product.with_context(pricelist=self.order_id.pricelist_id.id).price @@ -1387,6 +1406,16 @@ class SaleOrderLine(models.Model): if not self.product_id: return {'domain': {'product_uom': []}} + # remove the is_custom values that don't belong to this template + for pacv in self.product_custom_attribute_value_ids: + if pacv.attribute_value_id not in self.product_id.product_tmpl_id._get_valid_product_attribute_values(): + self.product_custom_attribute_value_ids -= pacv + + # remove the no_variant attributes that don't belong to this template + for ptav in self.product_no_variant_attribute_value_ids: + if ptav.product_attribute_value_id not in self.product_id.product_tmpl_id._get_valid_product_attribute_values(): + self.product_no_variant_attribute_value_ids -= ptav + vals = {} domain = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]} if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id): @@ -1395,7 +1424,7 @@ class SaleOrderLine(models.Model): product = self.product_id.with_context( lang=self.order_id.partner_id.lang, - partner=self.order_id.partner_id.id, + partner=self.order_id.partner_id, quantity=vals.get('product_uom_qty') or self.product_uom_qty, date=self.order_id.date_order, pricelist=self.order_id.pricelist_id.id, @@ -1419,20 +1448,6 @@ class SaleOrderLine(models.Model): name = self.get_sale_order_line_multiline_description_sale(product) - if self.product_custom_attribute_value_ids or self.product_no_variant_attribute_value_ids: - name += '\n' - - if self.product_custom_attribute_value_ids: - for product_custom_attribute_value in self.product_custom_attribute_value_ids: - if product_custom_attribute_value.custom_value and product_custom_attribute_value.custom_value.strip(): - name += '\n' + product_custom_attribute_value.attribute_value_id.name + ': ' + product_custom_attribute_value.custom_value.strip() - - if self.product_no_variant_attribute_value_ids: - for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered( - lambda product_attribute_value: not product_attribute_value.is_custom - ): - name += '\n' + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name - vals.update(name=name) self._compute_tax_id() @@ -1451,7 +1466,7 @@ class SaleOrderLine(models.Model): if self.order_id.pricelist_id and self.order_id.partner_id: product = self.product_id.with_context( lang=self.order_id.partner_id.lang, - partner=self.order_id.partner_id.id, + partner=self.order_id.partner_id, quantity=self.product_uom_qty, date=self.order_id.date_order, pricelist=self.order_id.pricelist_id.id, @@ -1547,7 +1562,7 @@ class SaleOrderLine(models.Model): self.discount = 0.0 product = self.product_id.with_context( lang=self.order_id.partner_id.lang, - partner=self.order_id.partner_id.id, + partner=self.order_id.partner_id, quantity=self.product_uom_qty, date=self.order_id.date_order, pricelist=self.order_id.pricelist_id.id, @@ -1581,4 +1596,38 @@ class SaleOrderLine(models.Model): BUT in event_sale we need to know specifically the sales order line as well as the product to generate the name: the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line). """ - return product.get_product_multiline_description_sale() + return product.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants() + + def _get_sale_order_line_multiline_description_variants(self): + """When using no_variant attributes or is_custom values, the product + itself is not sufficient to create the description: we need to add + information about those special attributes and values. + + See note about `product_no_variant_attribute_value_ids` above the field + definition: this method is not reliable to recompute the description at + a later time, it should only be used initially. + + :return: the description related to special variant attributes/values + :rtype: string + """ + if not self.product_custom_attribute_value_ids and not self.product_no_variant_attribute_value_ids: + return "" + + name = "\n" + + product_attribute_with_is_custom = self.product_custom_attribute_value_ids.mapped('attribute_value_id.attribute_id') + + # display the no_variant attributes, except those that are also + # displayed by a custom (avoid duplicate) + for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered( + lambda ptav: ptav.attribute_id not in product_attribute_with_is_custom + ): + name += "\n" + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name + + # display the is_custom values + for pacv in self.product_custom_attribute_value_ids: + name += "\n" + pacv.attribute_value_id.attribute_id.name + \ + ': ' + pacv.attribute_value_id.name + \ + ': ' + (pacv.custom_value or '').strip() + + return name diff --git a/addons/sale/static/src/js/product_configurator_controller.js b/addons/sale/static/src/js/product_configurator_controller.js index f7019fa3e04..a2b59cf835d 100644 --- a/addons/sale/static/src/js/product_configurator_controller.js +++ b/addons/sale/static/src/js/product_configurator_controller.js @@ -49,21 +49,27 @@ var ProductConfiguratorFormController = FormController.extend({ * @override */ _onFieldChanged: function (event) { + this._super.apply(this, arguments); + var self = this; + var product_id = event.data.changes.product_template_id.id; + + // check to prevent traceback when emptying the field + if (!product_id) { + return; + } this.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled'); this._rpc({ route: '/product_configurator/configure', params: { - product_id: event.data.changes.product_template_id.id, + product_id: product_id, pricelist_id: this.renderer.pricelistId } }).then(function (configurator) { self.renderer.renderConfigurator(configurator); }); - - this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- @@ -95,7 +101,7 @@ var ProductConfiguratorFormController = FormController.extend({ false ); - productReady.done(function (productId){ + productReady.done(function (productId) { $modal.find(productSelector.join(', ')).val(productId); var variantValues = self diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js index aa1066a0c24..458dd5a580c 100644 --- a/addons/sale/static/src/js/product_configurator_mixin.js +++ b/addons/sale/static/src/js/product_configurator_mixin.js @@ -13,6 +13,7 @@ var ProductConfiguratorMixin = { 'click button.js_add_cart_json': 'onClickAddCartJSON', 'change [data-attribute_exclusions]': 'onChangeVariant' }, + isSelectedVariantAllowed: true, //-------------------------------------------------------------------------- // Public @@ -22,7 +23,6 @@ var ProductConfiguratorMixin = { * When a product is added or when the quantity is changed, * we need to refresh the total price row * TODO awa: add a container context to avoid global selectors ? - * */ computePriceTotal: function () { if ($('.js_price_total').length){ @@ -56,8 +56,10 @@ var ProductConfiguratorMixin = { $component = $(ev.currentTarget).closest('form'); } else if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){ $component = $(ev.currentTarget).closest('.oe_optional_products_modal'); - } else { + } else if ($(ev.currentTarget).closest('.o_product_configurator').length > 0) { $component = $(ev.currentTarget).closest('.o_product_configurator'); + } else { + $component = $(ev.currentTarget); } var qty = $component.find('input[name="add_qty"]').val(); @@ -68,10 +70,10 @@ var ProductConfiguratorMixin = { ajax.jsonRpc(this._getUri('/product_configurator/get_combination_info'), 'call', { product_template_id: parseInt($parent.find('.product_template_id').val()), - product_id: parseInt($parent.find('.product_id').val()), + product_id: this._getProductId($parent), combination: combination, add_qty: parseInt(qty), - pricelist_id: this.pricelistId + pricelist_id: this.pricelistId || false, }).then(function (combinationData) { self._onChangeCombination(ev, $parent, combinationData); }); @@ -291,23 +293,28 @@ var ProductConfiguratorMixin = { */ selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) { var self = this; + productId = parseInt(productId); + productTemplateId = parseInt(productTemplateId); var productReady = $.Deferred(); - if (productId && productId !== '0'){ + if (productId) { productReady.resolve(productId); } else { var params = { - model: 'product.template', - method: 'create_product_variant', - args: [ - productTemplateId, - JSON.stringify(self.getSelectedVariantValues($container)) - ] + product_template_id: productTemplateId, + product_template_attribute_value_ids: + JSON.stringify(self.getSelectedVariantValues($container)), }; + // Note about 12.0 compatibility: this route will not exist if + // updating the code but not restarting the server. (404) + // We don't handle that compatibility because the previous code was + // not working either: it was making an RPC that failed with any + // non-admin user anyway. To use this feature, restart the server. + var route = '/product_configurator/create_product_variant'; if (useAjax) { - productReady = ajax.jsonRpc('/web/dataset/call', 'call', params); + productReady = ajax.jsonRpc(route, 'call', params); } else { - productReady = this._rpc(params); + productReady = this._rpc({route: route, params: params}); } } @@ -335,6 +342,23 @@ var ProductConfiguratorMixin = { * @param {Array} combination the selected combination of product attribute values */ _checkExclusions: function ($parent, combination) { + + function areCombinationsEqual(c1, c2) { + return c1.length === c2.length && _.every(c1, function (ptav) { + return c2.indexOf(ptav) > -1; + }); + } + + function isCombinationInList(c1, list) { + return _.some(list, function (c2) { + return areCombinationsEqual(c1, c2); + }); + } + + function isPtavInCombination(ptav, combination) { + return combination.indexOf(ptav) > -1; + } + var self = this; var combinationData = $parent .find('ul[data-attribute_exclusions]') @@ -343,43 +367,62 @@ var ProductConfiguratorMixin = { $parent.find('option, input, label').removeClass('css_not_available'); var disable = false; + + // compatibility 12.0 + var filteredCombination = combination; + if (combinationData.no_variant_product_template_attribute_value_ids !== undefined) { + var no_variants = combinationData.no_variant_product_template_attribute_value_ids; + filteredCombination = _.filter(combination, function (ptav) { + return !isPtavInCombination(ptav, no_variants); + }); + } + + // exclusion rules: array of ptav + // for each of them, contains array with the other ptav they exclude if (combinationData.exclusions) { - _.each(combination, function (combinationValue){ - if (combinationData.exclusions && - combinationData.exclusions.hasOwnProperty(combinationValue)){ - // check that the selected combination is in the exclusions - _.each(combinationData.exclusions[combinationValue], function (exclusion) { - if (!disable && combination.indexOf(exclusion) > -1) { + // browse all the currently selected attributes + _.each(combination, function (current_ptav) { + if (combinationData.exclusions.hasOwnProperty(current_ptav)) { + // for each exclusion of the current attribute: + _.each(combinationData.exclusions[current_ptav], function (excluded_ptav) { + // disable if it excludes any other attribute already in the combination + if (isPtavInCombination(excluded_ptav, combination)) { disable = true; } - self._disableInput($parent, exclusion); + // disable the excluded input (even when not already selected) + // to give a visual feedback before click + self._disableInput($parent, excluded_ptav); }); } }); } - if (combinationData.parent_exclusions){ - _.each(combinationData.parent_exclusions, function (exclusion){ - if (!disable && combination.indexOf(exclusion) > -1) { - disable = true; - } - self._disableInput($parent, exclusion); - }); - } + // parent exclusions (tell which attributes are excluded from parent) + _.each(combinationData.parent_exclusions, function (ptav) { + if (isPtavInCombination(ptav, combination)) { + disable = true; + } + // disable the excluded input (even when not already selected) + // to give a visual feedback before click + self._disableInput($parent, ptav); + }); - if (combinationData.archived_combinations){ - _.each(combinationData.archived_combinations, function (archived_combination){ - if (disable) { - return; - } + // archived variants + if (isCombinationInList(filteredCombination, combinationData.archived_combinations)) { + disable = true; + } - disable = _.every(archived_combination, function (attribute_value){ - return combination.indexOf(attribute_value) > -1; - }); - }); + // if not using dynamic attributes, exclude variants that are deleted + if (filteredCombination.length && // compatibility 12.0 list view of variants + combinationData.has_dynamic_attributes === false && + combinationData.existing_combinations !== undefined && + !isCombinationInList(filteredCombination, combinationData.existing_combinations) + ) { + disable = true; } + this.isSelectedVariantAllowed = !disable; $parent.toggleClass('css_not_available', disable); $parent.find("#add_to_cart").toggleClass('disabled', disable); $parent @@ -389,6 +432,15 @@ var ProductConfiguratorMixin = { }, /** + * Extracted to a method to be extendable by other modules + * + * @param {$.Element} $parent + */ + _getProductId: function ($parent) { + return parseInt($parent.find('.product_id').val()); + }, + + /** * Will disable the input/option that refers to the passed attributeValueId. * This is used for showing the user that some combinations are not available. * @@ -418,7 +470,11 @@ var ProductConfiguratorMixin = { var $optional_price = $parent.find(".oe_optional:first .oe_currency_value"); $price.html(self._priceToStr(combination.price)); $default_price.html(self._priceToStr(combination.list_price)); - if (combination.list_price - combination.price >= 0.01) { + + // compatibility_check to remove in master + // needed for fix in 12.0 in the case of git pull and no server restart + var compatibility_check = combination.list_price - combination.price >= 0.01; + if (combination.has_discounted_price !== undefined ? combination.has_discounted_price : compatibility_check) { $default_price .closest('.oe_website_sale') .addClass("discount"); @@ -428,6 +484,9 @@ var ProductConfiguratorMixin = { .css('text-decoration', 'line-through'); $default_price.parent().removeClass('d-none'); } else { + $default_price + .closest('.oe_website_sale') + .removeClass("discount"); $optional_price.closest('.oe_optional').addClass('d-none'); $default_price.parent().addClass('d-none'); } @@ -438,11 +497,16 @@ var ProductConfiguratorMixin = { '.o_product_configurator' ]; - self._updateProductImage( - $parent.closest(rootComponentSelectors.join(', ')), - combination.product_id, - combination.product_template_id - ); + // update images only when changing product + if (combination.product_id !== this.last_product_id) { + this.last_product_id = combination.product_id; + self._updateProductImage( + $parent.closest(rootComponentSelectors.join(', ')), + combination.product_id, + combination.product_template_id, + combination.carousel + ); + } $parent .find('.product_id') @@ -492,47 +556,20 @@ var ProductConfiguratorMixin = { * @param {integer} productTemplateId */ _updateProductImage: function ($productContainer, productId, productTemplateId) { - var $img; var model = productId ? 'product.product' : 'product.template'; var modelId = productId || productTemplateId; - var imageSrc = '/web/image?model={0}&id={1}&field=image' + var imageSrc = '/web/image/{0}/{1}/image' .replace("{0}", model) .replace("{1}", modelId); - if ($productContainer.find('#o-carousel-product').length) { - $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); - } - } - else { - var imagesSelectors = [ - 'span[data-oe-model^="product."][data-oe-type="image"] img:first', - 'img.product_detail_img', - 'span.variant_image img' - ]; - - $img = $productContainer.find(imagesSelectors.join(', ')); - $img.attr('src', imageSrc); - $img.parent() - .attr('data-oe-model', model) - .attr('data-oe-id', modelId) - .data('oe-model', model) - .data('oe-id', modelId); - } - // 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; - } + var imagesSelectors = [ + 'span[data-oe-model^="product."][data-oe-type="image"] img:first', + 'img.product_detail_img', + 'span.variant_image img' + ]; + + var $img = $productContainer.find(imagesSelectors.join(', ')); + $img.attr('src', imageSrc); }, /** @@ -553,11 +590,13 @@ var ProductConfiguratorMixin = { * Website behavior is slightly different from backend so we append * "_website" to URLs to lead to a different route * + * TODO this should be overriden in website_sale instead. + * * @private * @param {string} uri The uri to adapt */ _getUri: function (uri) { - if (this.isWebsite){ + if (this.isWebsite) { return uri + '_website'; } else { return uri; diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js index 1cebc683162..630774b8da5 100644 --- a/addons/sale/static/src/js/product_configurator_modal.js +++ b/addons/sale/static/src/js/product_configurator_modal.js @@ -47,7 +47,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi init: function (parent, params) { var self = this; - this._super(parent, { + var options = _.extend({ size: 'large', buttons: [{ text: params.okButtonText, @@ -57,8 +57,10 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi text: params.cancelButtonText, click: this._onCancelButtonClick }], - title: params.title - }); + technical: !params.isWebsite, + }, params || {}); + + this._super(parent, options); this.rootProduct = params.rootProduct; this.container = parent; @@ -70,10 +72,6 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi delete this.events['change [data-attribute_exclusions]']; delete this.events['click button.js_add_cart_json']; } - - this._opened.then(function () { - self.triggerVariantChange(self.$el); - }); }, /** * @override @@ -85,7 +83,8 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi var getModalContent = ajax.jsonRpc(uri, 'call', { product_id: self.rootProduct.product_id, variant_values: self.rootProduct.variant_values, - pricelist_id: self.pricelistId, + pricelist_id: self.pricelistId || false, + add_qty: self.rootProduct.quantity, kwargs: { context: _.extend({ 'quantity': self.rootProduct.quantity @@ -124,6 +123,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi self.$modal.attr('open', true); self.$modal.removeAttr("aria-hidden"); self.$modal.modal().appendTo(self.container); + self.$modal.focus(); self._opened.resolve(); } }); @@ -138,10 +138,18 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi * * @override */ - start: function (){ - this._super.apply(this, arguments); + start: function () { + var def = this._super.apply(this, arguments); + var self = this; this.$el.find('input[name="add_qty"]').val(this.rootProduct.quantity); + + return def.then(function () { + // This has to be triggered to compute the "out of stock" feature + self._opened.then(function () { + self.triggerVariantChange(self.$el); + }); + }); }, //-------------------------------------------------------------------------- @@ -357,7 +365,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi ajax.jsonRpc(self._getUri("/product_configurator/optional_product_items"), 'call', { 'product_id': productId, - 'pricelist_id': self.pricelistId + 'pricelist_id': self.pricelistId || false, }).then(function (addedItem) { var $addedItem = $(addedItem); $modal.find('tr:last').after($addedItem); @@ -408,7 +416,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi this._removeOptionOption($modal, productTemplateId); - $('tr:last').after($parent); + $modal.find('tr:last').after($parent); }, /** diff --git a/addons/sale/static/src/js/tour.js b/addons/sale/static/src/js/tour.js index f14c7d9f88b..8b82c091cee 100644 --- a/addons/sale/static/src/js/tour.js +++ b/addons/sale/static/src/js/tour.js @@ -46,7 +46,7 @@ tour.register('sale_tour', { content: _t("Select a product, or create a new one on the fly."), position: "right", run: function (actions) { - actions.text("DESK0001", this.$anchor.find("input")); + actions.text("Chair", this.$anchor.find("input")); }, }, { trigger: ".ui-menu-item > a", @@ -58,12 +58,12 @@ tour.register('sale_tour', { $('.modal-dialog:has(div.o_dialog_warning) footer.modal-footer .btn-primary').trigger('click'); } }, - id: "quotation_product_selected", }, { trigger: ".o_form_button_save", extra_trigger: ".o_sale_order", content: _t("Once your quotation is ready, you can save, print or send it by email."), position: "right", + id: "form_button_save_clicked" }, { trigger: ".o_sale_print", extra_trigger: ".o_sale_order.o_form_readonly", diff --git a/addons/sale/static/src/scss/product_configurator.scss b/addons/sale/static/src/scss/product_configurator.scss index ca3b9f63af2..8cf022dad50 100644 --- a/addons/sale/static/src/scss/product_configurator.scss +++ b/addons/sale/static/src/scss/product_configurator.scss @@ -39,6 +39,13 @@ .css_not_available_msg { display: block; } + + .js_add, + .oe_price, + .oe_default_price, + .oe_optional { + display: none; + } } .css_quantity { @@ -113,12 +120,7 @@ label.css_attribute_color.css_not_available { } .o_total_row { - h5 { - padding-top: 0.4rem; - } - .js_price_total { - font-size: 1.5rem; - } + font-size: 1.2rem; } } @@ -140,10 +142,6 @@ label.css_attribute_color.css_not_available { background-image: none; } - .remove-optionnal-item, .add-optionnal-item { - padding-left: 5px; - } - .td-product_name { word-wrap: break-word; } diff --git a/addons/sale/tests/__init__.py b/addons/sale/tests/__init__.py index e6be9852a3b..48bee457a5a 100644 --- a/addons/sale/tests/__init__.py +++ b/addons/sale/tests/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_sale_product_attribute_value_config from . import test_sale_to_invoice from . import test_sale_order from . import test_sale_pricelist diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py new file mode 100644 index 00000000000..dd36a916778 --- /dev/null +++ b/addons/sale/tests/test_sale_product_attribute_value_config.py @@ -0,0 +1,389 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields +from odoo.addons.product.tests.test_product_attribute_value_config import TestProductAttributeValueSetup +from odoo.tests import tagged + + +class TestSaleProductAttributeValueSetup(TestProductAttributeValueSetup): + def _setup_currency(self, currency_ratio=2): + """Get or create a currency. This makes the test non-reliant on demo. + + With an easy currency rate, for a simple 2 ratio in the following tests. + """ + from_currency = self.computer.currency_id + self._set_or_create_rate_today(from_currency, rate=1) + + to_currency = self._get_or_create_currency("my currency", "C") + self._set_or_create_rate_today(to_currency, currency_ratio) + + return to_currency + + def _set_or_create_rate_today(self, currency, rate): + """Get or create a currency rate for today. This makes the test + non-reliant on demo data.""" + name = fields.Date.today() + currency_id = currency.id + company_id = self.env.user.company_id.id + + CurrencyRate = self.env['res.currency.rate'] + + currency_rate = CurrencyRate.search([ + ('company_id', '=', company_id), + ('currency_id', '=', currency_id), + ('name', '=', name), + ]) + + if currency_rate: + currency_rate.rate = rate + else: + CurrencyRate.create({ + 'company_id': company_id, + 'currency_id': currency_id, + 'name': name, + 'rate': rate, + }) + + def _get_or_create_currency(self, name, symbol): + """Get or create a currency based on name. This makes the test + non-reliant on demo data.""" + currency = self.env['res.currency'].search([('name', '=', name)]) + return currency or currency.create({ + 'name': name, + 'symbol': symbol, + }) + + +@tagged('post_install', '-at_install') +class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): + def _setup_pricelist(self, currency_ratio=2): + to_currency = self._setup_currency(currency_ratio) + + discount = 10 + + pricelist = self.env['product.pricelist'].create({ + 'name': 'test pl', + 'currency_id': to_currency.id, + 'company_id': self.computer.company_id.id, + }) + + pricelist_item = self.env['product.pricelist.item'].create({ + 'min_quantity': 2, + 'compute_price': 'percentage', + 'percent_price': discount + }) + + pricelist.item_ids += pricelist_item + + return (pricelist, pricelist_item, currency_ratio, 1 - discount / 100) + + def test_01_is_combination_possible_archived(self): + """The goal is to test the possibility of archived combinations. + This test could not be put into product module because there was no + field which had product_id as required and without cascade on delete. + """ + def do_test(self): + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2) + + variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1) + variant2 = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_2) + + # Create a dummy SO to prevent the variant from being deleted by + # create_variant_ids() because the variant is a related field that + # is required on the SO line + so = self.env['sale.order'].create({'partner_id': 1}) + self.env['sale.order.line'].create({ + 'order_id': so.id, + 'name': "test", + 'product_id': variant.id + }) + # additional variant to test correct ignoring when mismatch values + self.env['sale.order.line'].create({ + 'order_id': so.id, + 'name': "test", + 'product_id': variant2.id + }) + + variant2.active = False + # CASE: 1 not archived, 2 archived + self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) + self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2)) + # CASE: both archived combination (without no_variant) + variant.active = False + self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2)) + self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) + + # CASE: not archived (with no_variant) + self.computer_hdd_attribute_lines.unlink() + self.hdd_attribute.create_variant = 'no_variant' + self._add_hdd_attribute_line() + self.computer.create_variant_ids() + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2) + + self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) + + # CASE: archived combination found (with no_variant) + variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1) + variant.active = False + self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) + + # CASE: archived combination has different attributes (including no_variant) + self.computer_ssd_attribute_lines.unlink() + self.computer.create_variant_ids() + + variant4 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1) + self.env['sale.order.line'].create({ + 'order_id': so.id, + 'name': "test", + 'product_id': variant4.id + }) + self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1)) + + # CASE: archived combination has different attributes (without no_variant) + self.computer_hdd_attribute_lines.unlink() + self.hdd_attribute.create_variant = 'always' + self._add_hdd_attribute_line() + self.computer.create_variant_ids() + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2) + + variant5 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1) + self.env['sale.order.line'].create({ + 'order_id': so.id, + 'name': "test", + 'product_id': variant5.id + }) + + self.assertTrue(variant4 != variant5) + + self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1)) + + computer_ssd_256_before = self._get_product_template_attribute_value(self.ssd_256) + + do_test(self) + + # CASE: add back the removed attribute and try everything again + # It will be the same attribute but the ptal and ptav will be different! + self.computer_ssd_attribute_lines = self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': self.computer.id, + 'attribute_id': self.ssd_attribute.id, + 'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])], + }) + self.computer.create_variant_ids() + + computer_ssd_256_after = self._get_product_template_attribute_value(self.ssd_256) + self.assertTrue(computer_ssd_256_after != computer_ssd_256_before) + do_test(self) + + def test_02_get_combination_info(self): + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + + # CASE: no pricelist, no currency, with existing combination, with price_extra on attributes + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 + computer_variant = self.computer._get_variant_for_combination(combination) + + res = self.computer._get_combination_info(combination) + self.assertEqual(res['product_template_id'], self.computer.id) + self.assertEqual(res['product_id'], computer_variant.id) + self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)") + self.assertEqual(res['price'], 2222) + self.assertEqual(res['list_price'], 2222) + + # CASE: no combination, product given + res = self.computer._get_combination_info(self.env['product.template.attribute.value'], computer_variant.id) + self.assertEqual(res['product_template_id'], self.computer.id) + self.assertEqual(res['product_id'], computer_variant.id) + # the variant has the same name as the template + self.assertEqual(res['display_name'], "Super Computer") + self.assertEqual(res['price'], 2222) + self.assertEqual(res['list_price'], 2222) + + # CASE: using pricelist, quantity rule + pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist() + + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) + self.assertEqual(res['product_template_id'], self.computer.id) + self.assertEqual(res['product_id'], computer_variant.id) + self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)") + self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) + self.assertEqual(res['list_price'], 2222 * currency_ratio) + + # CASE: no_variant combination, it's another variant now + + self.computer_ssd_attribute_lines.unlink() + self.ssd_attribute.create_variant = 'no_variant' + self._add_ssd_attribute_line() + self.computer.create_variant_ids() + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 + + computer_variant_new = self.computer._get_variant_for_combination(combination) + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) + self.assertEqual(res['product_template_id'], self.computer.id) + self.assertEqual(res['product_id'], computer_variant_new.id) + self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)") + self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) + self.assertEqual(res['list_price'], 2222 * currency_ratio) + + # CASE: dynamic combination, but the variant already exists + self.computer_hdd_attribute_lines.unlink() + self.hdd_attribute.create_variant = 'dynamic' + self._add_hdd_attribute_line() + self.computer.create_variant_ids() + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 + + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) + self.assertEqual(res['product_template_id'], self.computer.id) + self.assertEqual(res['product_id'], computer_variant_new.id) + self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)") + self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) + self.assertEqual(res['list_price'], 2222 * currency_ratio) + + # CASE: dynamic combination, no variant existing + self._add_keyboard_attribute() + self.computer.create_variant_ids() + combination += self._get_product_template_attribute_value(self.keyboard_excluded) + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) + self.assertEqual(res['product_template_id'], self.computer.id) + self.assertEqual(res['product_id'], False) + self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)") + self.assertEqual(res['price'], (2222 - 5) * currency_ratio * discount_ratio) + self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio) + + # CASE: pricelist set value to 0, no variant + pricelist_item.percent_price = 100 + self.computer.invalidate_cache() # need o2m to be refetched + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) + self.assertEqual(res['product_template_id'], self.computer.id) + self.assertEqual(res['product_id'], False) + self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)") + self.assertEqual(res['price'], 0) + self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio) + + def test_03_get_combination_info_discount_policy(self): + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 + + pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist() + + pricelist.discount_policy = 'with_discount' + + # CASE: no discount, setting with_discount + res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist) + self.assertEqual(res['price'], 2222 * currency_ratio) + self.assertEqual(res['list_price'], 2222 * currency_ratio) + self.assertEqual(res['has_discounted_price'], False) + + # CASE: discount, setting with_discount + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) + self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) + self.assertEqual(res['list_price'], 2222 * currency_ratio) + self.assertEqual(res['has_discounted_price'], False) + + # CASE: no discount, setting without_discount + pricelist.discount_policy = 'without_discount' + res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist) + self.assertEqual(res['price'], 2222 * currency_ratio) + self.assertEqual(res['list_price'], 2222 * currency_ratio) + self.assertEqual(res['has_discounted_price'], False) + + # CASE: discount, setting without_discount + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) + self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio) + self.assertEqual(res['list_price'], 2222 * currency_ratio) + self.assertEqual(res['has_discounted_price'], True) + + def test_04_create_product_variant_non_dynamic(self): + """The goal of this test is to make sure the create_product_variant does + not create variant if the type is not dynamic. It can however return a + variant if it already exists.""" + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_ram_16 = self._get_product_template_attribute_value(self.ram_16) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + self._add_exclude(computer_ram_16, computer_hdd_1) + + # CASE: variant is already created, it should return it + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 + variant1 = self.computer._get_variant_for_combination(combination) + self.assertEqual(self.computer._create_product_variant(combination), variant1) + + # CASE: variant does not exist, but template is non-dynamic, so it + # should not create it + Product = self.env['product.product'] + variant1.unlink() + self.assertEqual(self.computer._create_product_variant(combination), Product) + + def test_05_create_product_variant_dynamic(self): + """The goal of this test is to make sure the create_product_variant does + work with dynamic. If the combination is possible, it should create it. + If it's not possible, it should not create it.""" + self.computer_hdd_attribute_lines.unlink() + self.computer.create_variant_ids() + self.hdd_attribute.create_variant = 'dynamic' + self._add_hdd_attribute_line() + self.computer.create_variant_ids() + self.computer.invalidate_cache() + + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_ram_16 = self._get_product_template_attribute_value(self.ram_16) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + self._add_exclude(computer_ram_16, computer_hdd_1) + + # CASE: variant does not exist, but combination is not possible + # so it should not create it + impossible_combination = computer_ssd_256 + computer_ram_16 + computer_hdd_1 + Product = self.env['product.product'] + self.assertEqual(self.computer._create_product_variant(impossible_combination), Product) + + # CASE: the variant does not exist, and the combination is possible, so + # it should create it + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 + variant = self.computer._create_product_variant(combination) + self.assertTrue(variant) + + # CASE: the variant already exists, so it should return it + self.assertEqual(variant, self.computer._create_product_variant(combination)) + + def _add_keyboard_attribute(self): + self.keyboard_attribute = self.env['product.attribute'].create({ + 'name': 'Keyboard', + 'sequence': 6, + 'create_variant': 'dynamic', + }) + self.keyboard_included = self.env['product.attribute.value'].create({ + 'name': 'Included', + 'attribute_id': self.keyboard_attribute.id, + 'sequence': 1, + }) + self.keyboard_excluded = self.env['product.attribute.value'].create({ + 'name': 'Excluded', + 'attribute_id': self.keyboard_attribute.id, + 'sequence': 2, + }) + self.computer_keyboard_attribute_lines = self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': self.computer.id, + 'attribute_id': self.keyboard_attribute.id, + 'value_ids': [(6, 0, [self.keyboard_included.id, self.keyboard_excluded.id])], + }) + self.computer_keyboard_attribute_lines.product_template_value_ids[0].price_extra = 5 + self.computer_keyboard_attribute_lines.product_template_value_ids[1].price_extra = -5 diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml index 0e8c7378139..7f0338d1b6d 100644 --- a/addons/sale/views/sale_portal_templates.xml +++ b/addons/sale/views/sale_portal_templates.xml @@ -455,6 +455,7 @@ </td> <td t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}"> <div + t-if="line.discount >= 0" t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}' t-att-style="line.discount and 'text-decoration: line-through' or None" @@ -465,7 +466,7 @@ </div> </td> <td t-if="display_discount" t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}"> - <strong t-if="line.discount" class="text-info"> + <strong t-if="line.discount > 0" class="text-info"> <t t-esc="((line.discount % 1) and '%s' or '%d') % line.discount"/>% discount </strong> </td> diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale/views/sale_product_configurator_templates.xml index bf9df14a54d..6711dd2db8b 100644 --- a/addons/sale/views/sale_product_configurator_templates.xml +++ b/addons/sale/views/sale_product_configurator_templates.xml @@ -6,43 +6,64 @@ </main> </template> + <!-- backend --> <template id="product_configurator_configure" name="Configure"> <div class="js_product main_product"> + + <t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/> + <t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/> + <t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/> + <input type="hidden" class="product_template_id" t-att-value="product.id"/> - <input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/> + <input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product_variant.id"/> <div class="col-lg-12 text-center mt-5"> <div class="col-lg-5 d-inline-block align-top text-left"> - <span t-field="product.image" t-options='{"widget": "image", "class": "d-block product_detail_img"}'/> + <img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image' % product_variant.id" class="d-block product_detail_img" alt="Product Image"/> + <img t-else="" t-att-src="'/web/image/product.template/%s/image' % product.id" class="d-block product_detail_img" alt="Product Image"/> </div> <div class="col-lg-1 d-inline-block"></div> - <div class="col-lg-5 d-inline-block text-left"> - <t t-call="sale.variants" /> - <h2 class="oe_price product_id mt-3" style="white-space: nowrap;" - t-att-data-product-id="product.id" - t-esc="product.price" - t-options='{ - "widget": "monetary", - "display_currency": to_currency - }'/> + <div t-if="product._is_add_to_cart_possible()" class="col-lg-5 d-inline-block text-left"> + <t t-if="first_possible_combination" t-call="sale.variants"> + <t t-set="parent_combination" t-value="None"/> + </t> + <h2> + <span t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}" + t-esc="combination_info['list_price']" + t-options='{ + "widget": "monetary", + "display_currency": (pricelist or product).currency_id + }'/> + <span class="oe_price product_id mt-3" style="white-space: nowrap;" + t-att-data-product-id="product.id" + t-esc="combination_info['price']" + t-options='{ + "widget": "monetary", + "display_currency": (pricelist or product).currency_id + }'/> + </h2> <div class="css_quantity input-group oe_website_spinner"> <div class="input-group-prepend"> <button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one"> <i class="fa fa-minus"></i> </button> </div> - <input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" value="1"/> + <input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/> <div class="input-group-append"> <button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one"> <i class="fa fa-plus"></i> </button> </div> </div> - <p class="css_not_available_msg bg-danger" style="padding: 15px;">This combination does not exist.</p> + <p class="css_not_available_msg alert alert-warning">This combination does not exist.</p> + </div> + <div t-else="" class="col-lg-5 d-inline-block text-left"> + <p class="alert alert-warning">This product has no valid combination.</p> </div> </div> </div> </template> + <!-- modal: full table, currenclty selected products at top --> <template id="product_configurator_configure_optional_products"> <table class="table table-striped table-sm"> <thead> @@ -55,215 +76,237 @@ </thead> <tbody> <tr class="js_product in_cart main_product"> + + <t t-set="combination_info" t-value="product.product_tmpl_id._get_combination_info(combination, product.id, add_qty or 1, pricelist)"/> + <t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/> + <input type="hidden" class="product_template_id" t-att-value="product.product_tmpl_id.id"/> - <input type="hidden" class="product_id" t-att-value="product.id"/> + <input type="hidden" class="product_id" t-att-value="product_variant.id"/> <td class='td-img'> - <span t-field="product.image_medium" t-options='{"widget": "image" }'/> + <img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_medium' % product_variant.id" alt="Product Image"/> + <img t-else="" t-att-src="'/web/image/product.template/%s/image_medium' % product.id" alt="Product Image"/> </td> <td class='td-product_name'> - <strong t-field="product.display_name"/> - <div class="text-muted small"> - <div t-field="product.description_sale"/> - <div class="js_attributes"/> - </div> + <strong t-esc="combination_info['display_name']"/> + <div class="text-muted small"> + <div t-field="product.description_sale"/> + <div class="js_attributes"/> + </div> </td> <td class="text-center td-qty"> - <div class="css_quantity input-group oe_website_spinner"> - <div class="input-group-prepend"> - <button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one"> - <i class="fa fa-minus"></i> - </button> - </div> - <input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" value="1"/> - <div class="input-group-append"> - <button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one"> - <i class="fa fa-plus"></i> - </button> + <div class="css_quantity input-group oe_website_spinner"> + <div class="input-group-prepend"> + <button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one"> + <i class="fa fa-minus"></i> + </button> + </div> + <input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/> + <div class="input-group-append"> + <button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one"> + <i class="fa fa-plus"></i> + </button> + </div> </div> - </div> </td> <td class="text-center td-price" name="price"> - <ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"></ul> - <div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="variant_values" ></div> - <div t-attf-class="text-danger oe_default_price oe_striked_price {{ (compute_currency(product.lst_price) - product.price) < 0.01 and 'd-none' }}" - t-field="product.lst_price" - t-options='{ - "widget": "monetary", - "from_currency": product.currency_id, - "display_currency": to_currency - }'/> - <span class="oe_price product_id" style="white-space: nowrap;" - t-att-data-product-id="product.id" - t-esc="product.price" - t-options='{ - "widget": "monetary", - "display_currency": to_currency - }'/> - <span class="js_raw_price d-none" t-esc="product.price" /> + <ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"></ul> + <div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="variant_values" ></div> + <div t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}" + t-esc="combination_info['list_price']" + t-options='{ + "widget": "monetary", + "display_currency": (pricelist or product).currency_id + }' + /> + <span class="oe_price product_id" style="white-space: nowrap;" + t-att-data-product-id="product.id" + t-esc="combination_info['price']" + t-options='{ + "widget": "monetary", + "display_currency": (pricelist or product).currency_id + }'/> + <span class="js_raw_price d-none" t-esc="product.price"/> </td> </tr> <tr class="o_total_row"> - <td colspan="2"></td> - <td class="text-right"><h5>Total:</h5></td> - <td class="text-center"> + <td colspan="4" class="text-right"> + <strong>Total:</strong> <span class="js_price_total font-weight-bold" style="white-space: nowrap;" t-att-data-product-id="product.id" - t-esc="product.price" + t-esc="combination_info['price'] * (add_qty or 1)" t-options='{ - "widget": "monetary", - "display_currency": to_currency - }'/> + "widget": "monetary", + "display_currency": (pricelist or product).currency_id + }'/> </td> </tr> <tr t-if="product.optional_product_ids" class="o_select_options"><td colspan="4"><h4>Available Options:</h4></td></tr> - <t t-call="sale.optional_product_items" /> + <t t-call="sale.optional_product_items"> + <t t-set="parent_combination" t-value="combination"/> + </t> </tbody> </table> </template> + <!-- modal: optional products --> <template id="optional_product_items"> - <t t-foreach="product.optional_product_ids" t-as="product"> - <t t-if="len(product.get_filtered_variants(reference_product)) > 0 or product.has_dynamic_attributes()"> - <tr class="js_product"> - <td class="td-img"> - <input type="hidden" class="product_template_id" t-att-value="product.id"/> - <input type="hidden" class="product_id" t-attf-name="optional-product-#{product.id}" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/> - <input type="hidden" class="product_display_name" t-attf-name="optional-productname-#{product.id}" t-att-value="product.product_variant_id.display_name if len(product.product_variant_ids) == 1 else ''"/> - <span t-field="product.image_small" t-options='{"widget": "image"}' class="variant_image"/> - </td> - <td class='td-product_name' colspan="2"> - <div class="float-left"> - <strong class="product-name" t-field="product.name"/> - <div class="text-muted small" t-field="product.description_sale"/> - </div> - <div class="float-right"> - <t t-call="sale.variants"/> - </div> - </td> - <td class="text-center td-price"> - <span class="text-danger oe_default_price oe_optional" - t-field="product.lst_price" - t-options='{ - "widget": "monetary", - "from_currency": product.currency_id, - "display_currency": to_currency - }'/> - <span class="oe_price" style="white-space: nowrap;" - t-field="product.price" - t-options='{ - "widget": "monetary", - "display_currency": to_currency - }'/> - <span class="js_raw_price d-none" t-esc="product.price" /> - <p class="css_not_available_msg bg-danger" style="padding: 5px;">Option not available</p> + <t t-foreach="product.optional_product_ids" t-as="product"> + <t t-set="first_possible_combination" t-value="product._get_first_possible_combination(parent_combination)"/> + <t t-if="product._is_add_to_cart_possible()"> + + <t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/> + <t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/> - <input type="hidden" class="js_optional_same_quantity" t-attf-name="optional-add-#{product.id}" value="0"/> - <a role="button" href="#" class="js_add"><i class="fa fa-shopping-cart add-optionnal-item"></i></a> - <span class="js_remove d-none"> - <span class="optional_product_quantity"><span class="js_item add_qty">1 </span><span class="js_items d-none">5 </span> <span t-field="product.uom_id.sudo().name"/><br/></span> - <a role="button" href="#" class="js_remove"><i class="fa fa-trash-o remove-optionnal-item"></i></a> - </span> - </td> - </tr> + <tr class="js_product"> + <td class="td-img"> + <input type="hidden" class="product_template_id" t-att-value="product.id"/> + <input type="hidden" class="product_id" t-attf-name="optional-product-#{product.id}" t-att-value="product_variant.id"/> + <input type="hidden" class="product_display_name" t-attf-name="optional-productname-#{product.id}" t-att-value="combination_info['display_name']"/> + <img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_medium' % product_variant.id" class="variant_image" alt="Product Image"/> + <img t-else="" t-att-src="'/web/image/product.template/%s/image_medium' % product.id" class="variant_image" alt="Product Image"/> + </td> + <td class='td-product_name' colspan="2"> + <div class="float-left"> + <strong class="product-name" t-esc="combination_info['display_name']"/> + <div class="text-muted small" t-field="product.description_sale"/> + </div> + <div class="float-right"> + <t t-call="sale.variants"/> + </div> + </td> + <td class="text-center td-price"> + <div t-attf-class="text-danger oe_default_price oe_optional oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}" + t-esc="combination_info['list_price']" + t-options='{ + "widget": "monetary", + "display_currency": (pricelist or product).currency_id + }'/> + <div class="oe_price" style="white-space: nowrap;" + t-esc="combination_info['price']" + t-options='{ + "widget": "monetary", + "display_currency": (pricelist or product).currency_id + }'/> + <span class="js_raw_price d-none" t-esc="combination_info['price']" /> + <p class="css_not_available_msg alert alert-warning">Option not available</p> + + <input type="hidden" class="js_optional_same_quantity" t-attf-name="optional-add-#{product.id}" value="0"/> + <a role="button" href="#" class="js_add btn btn-primary btn-sm"><i class="fa fa-shopping-cart add-optionnal-item"></i> Add to cart</a> + <span class="js_remove d-none"> + <span class="optional_product_quantity"><span class="js_item add_qty">1 </span><span class="js_items d-none">5 </span> <span t-field="product.uom_id.sudo().name"/><br/></span> + <a role="button" href="#" class="js_remove"><i class="fa fa-trash-o remove-optionnal-item"></i></a> + </span> + </td> + </tr> + </t> </t> - </t> </template> <template id="variants"> - <t t-set="attribute_exclusions" t-value="get_attribute_exclusions(product, reference_product)"/> - <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)"> - <t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id"> - <!-- Attributes selection is hidden if there is only one value available and it's not a custom value --> - <li t-att-data-attribute_id="variant_id.attribute_id.id" - t-att-data-attribute_name="variant_id.attribute_id.name" - t-attf-class="variant_attribute #{'d-none' if len(variant_id.product_template_value_ids) == 1 and not variant_id.product_template_value_ids[0].is_custom else ''}"> + <t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions(parent_combination)"/> + <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)"> + <t t-foreach="product._get_valid_product_template_attribute_lines()" t-as="variant_id"> + <!-- Attributes selection is hidden if there is only one value available and it's not a custom value --> + <li t-att-data-attribute_id="variant_id.attribute_id.id" + t-att-data-attribute_name="variant_id.attribute_id.name" + t-attf-class="variant_attribute #{'d-none' if len(variant_id.product_template_value_ids) == 1 and not variant_id.product_template_value_ids[0].is_custom else ''}"> - <strong t-field="variant_id.attribute_id.name" class="attribute_name" /> + <strong t-field="variant_id.attribute_id.name" class="attribute_name"/> - <t t-if="variant_id.attribute_id.type == 'select'"> - <select - t-att-data-attribute_id="variant_id.attribute_id.id" - t-attf-class="form-control js_variant_change #{variant_id.attribute_id.create_variant}" - t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"> - <t t-foreach="variant_id.product_template_value_ids" t-as="value_id"> - <option t-att-value="value_id.id" - t-att-data-value_id="value_id.product_attribute_value_id.id" - t-att-data-value_name="value_id.name" - t-att-data-attribute_name="value_id.attribute_id.name" - t-att-data-is_custom="value_id.is_custom"> - <span t-field="value_id.name"/> - <span t-if="value_id.price_extra"> - <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{ - "widget": "monetary", - "from_currency": product.currency_id, - "display_currency": to_currency or website.currency_id - }'/> - </span> - </option> - </t> - </select> - </t> - - <t t-if="variant_id.attribute_id.type == 'radio'"> - <ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-unstyled"> - <t t-set="inc" t-value="0"/> - <t t-foreach="variant_id.product_template_value_ids" t-as="value_id"> - <li class="form-group js_attribute_value" style="margin: 0;"> - <label class="col-form-label"> - <div> - <input type="radio" - t-attf-class="js_variant_change radio_input #{variant_id.attribute_id.create_variant}" - t-att-checked="'checked' if not inc else None" - t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" - t-att-value="value_id.id" + <t t-if="variant_id.attribute_id.type == 'select'"> + <select + t-att-data-attribute_id="variant_id.attribute_id.id" + t-attf-class="form-control js_variant_change #{variant_id.attribute_id.create_variant}" + t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"> + <t t-foreach="variant_id.product_template_value_ids" t-as="value_id"> + <option t-att-value="value_id.id" t-att-data-value_id="value_id.product_attribute_value_id.id" t-att-data-value_name="value_id.name" t-att-data-attribute_name="value_id.attribute_id.name" - t-att-data-is_custom="value_id.is_custom" /> - <div class="radio_input_value"> - <span t-field="value_id.name"/> - <span class="badge badge-pill" t-if="value_id.price_extra"> - <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{ + t-att-data-is_custom="value_id.is_custom" + t-att-selected="first_possible_combination[variant_id_index] == value_id"> + <span t-field="value_id.name"/> + <span t-if="value_id.price_extra" class="badge badge-pill badge-secondary"> + <!-- + price_extra is displayed as catalog price instead of + price after pricelist because it is impossible to + compute. Indeed, the pricelist rule might depend on the + selected variant, so the price_extra will be different + depending on the selected combination. The price of an + attribute is therefore variable and it's not very + accurate to display it. + --> + <t t-esc="value_id.price_extra > 0 and '+' or '-'"/> + <span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;" + t-options='{ "widget": "monetary", "from_currency": product.currency_id, - "display_currency": to_currency or website.currency_id + "display_currency": (pricelist or product).currency_id }'/> - </span> - </div> - </div> - </label> - </li> - <t t-set="inc" t-value="inc+1"/> - </t> - </ul> - </t> + </span> + </option> + </t> + </select> + </t> - <t t-if="variant_id.attribute_id.type == 'color'"> - <ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-inline"> - <t t-set="inc" t-value="0"/> - <li t-foreach="variant_id.product_template_value_ids" t-as="value_id" class="list-inline-item"> - <label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}" - t-attf-class="css_attribute_color #{'active' if not inc else ''} #{'custom_value' if value_id.is_custom else ''}"> - <input type="radio" - t-attf-class="js_variant_change #{variant_id.attribute_id.create_variant}" - t-att-checked="'checked' if not inc else None" - t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" - t-att-value="value_id.id" - t-att-title="value_id.name" - t-att-data-value_id="value_id.product_attribute_value_id.id" - t-att-data-value_name="value_id.name" - t-att-data-attribute_name="value_id.attribute_id.name" - t-att-data-is_custom="value_id.is_custom"/> - </label> - <t t-set="inc" t-value="inc+1"/> - </li> - </ul> - </t> + <t t-if="variant_id.attribute_id.type == 'radio'"> + <ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-unstyled"> + <t t-foreach="variant_id.product_template_value_ids" t-as="value_id"> + <li class="form-group js_attribute_value" style="margin: 0;"> + <label class="col-form-label"> + <div> + <input type="radio" + t-attf-class="js_variant_change radio_input #{variant_id.attribute_id.create_variant}" + t-att-checked="first_possible_combination[variant_id_index] == value_id" + t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" + t-att-value="value_id.id" + t-att-data-value_id="value_id.product_attribute_value_id.id" + t-att-data-value_name="value_id.name" + t-att-data-attribute_name="value_id.attribute_id.name" + t-att-data-is_custom="value_id.is_custom" /> + <div class="radio_input_value"> + <span t-field="value_id.name"/> + <span class="badge badge-pill badge-secondary" t-if="value_id.price_extra"> + <!-- see note above about price_extra --> + <t t-esc="value_id.price_extra > 0 and '+' or '-'"/> + <span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;" + t-options='{ + "widget": "monetary", + "from_currency": product.currency_id, + "display_currency": (pricelist or product).currency_id + }'/> + </span> + </div> + </div> + </label> + </li> + </t> + </ul> + </t> - </li> - </t> - </ul> + <t t-if="variant_id.attribute_id.type == 'color'"> + <ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-inline"> + <li t-foreach="variant_id.product_template_value_ids" t-as="value_id" class="list-inline-item"> + <label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}" + t-attf-class="css_attribute_color #{'active' if first_possible_combination[variant_id_index] == value_id else ''} #{'custom_value' if value_id.is_custom else ''}"> + <input type="radio" + t-attf-class="js_variant_change #{variant_id.attribute_id.create_variant}" + t-att-checked="first_possible_combination[variant_id_index] == value_id" + t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" + t-att-value="value_id.id" + t-att-title="value_id.name" + t-att-data-value_id="value_id.product_attribute_value_id.id" + t-att-data-value_name="value_id.name" + t-att-data-attribute_name="value_id.attribute_id.name" + t-att-data-is_custom="value_id.is_custom"/> + </label> + </li> + </ul> + </t> + </li> + </t> + </ul> </template> </odoo> diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 619006e3c28..582c23404bf 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -6,6 +6,7 @@ name="Sales" web_icon="sale,static/description/icon.png" active="False" + groups="sales_team.group_sale_salesman,sales_team.group_sale_salesman_all_leads,sales_team.group_sale_manager" sequence="7"/> <menuitem id="sale_order_menu" diff --git a/addons/sale/wizard/sale_product_configurator.py b/addons/sale/wizard/sale_product_configurator.py index 7d2ae77ec51..75563872928 100644 --- a/addons/sale/wizard/sale_product_configurator.py +++ b/addons/sale/wizard/sale_product_configurator.py @@ -10,5 +10,5 @@ class SaleProductConfigurator(models.TransientModel): product_template_id = fields.Many2one( 'product.template', string="Product", - required=True, domain=[('sale_ok', '=', True), '|', ('attribute_line_ids', '!=', False), ('optional_product_ids', '!=', False)]) + required=True, domain=[('sale_ok', '=', True), '|', ('attribute_line_ids.value_ids', '!=', False), ('optional_product_ids', '!=', False)]) pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', readonly=True) diff --git a/addons/sale_management/security/sale_management_security.xml b/addons/sale_management/security/sale_management_security.xml index b5ea06ed03f..0d838862462 100644 --- a/addons/sale_management/security/sale_management_security.xml +++ b/addons/sale_management/security/sale_management_security.xml @@ -3,6 +3,7 @@ <record id="group_sale_order_template" model="res.groups"> <field name="name">Quotation Templates</field> + <field name="category_id" ref="base.module_category_hidden"/> </record> </odoo> diff --git a/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js b/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js index b5b63fdc2e4..1cccd7fe0e1 100644 --- a/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js +++ b/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js @@ -68,7 +68,7 @@ tour.register('sale_product_configurator_advanced_tour', { trigger: ".o_sale_product_configurator_add", run: 'click' }, { - trigger: '.main_product strong:contains("White, Custom, PAV9, PAV5, PAV1")', + trigger: '.main_product strong:contains("Custom, White, PAV9, PAV5, PAV1")', extra_trigger: '.oe_optional_products_modal', run: function () {} //check }, { @@ -100,7 +100,7 @@ tour.register('sale_product_configurator_advanced_tour', { extra_trigger: '.oe_optional_products_modal', run: 'click' }, { - trigger: 'td.o_data_cell:contains("Customizable Desk (White, Custom, PAV9, PAV5, PAV1)")', + trigger: 'td.o_data_cell:contains("Customizable Desk (Custom, White, PAV9, PAV5, PAV1)")', extra_trigger: 'div[name="order_line"]', in_modal: false, run: function (){} //check diff --git a/addons/sale_management/static/tests/product_configurator_pricelist_test_ui.js b/addons/sale_management/static/tests/product_configurator_pricelist_test_ui.js new file mode 100644 index 00000000000..0cb9b46e530 --- /dev/null +++ b/addons/sale_management/static/tests/product_configurator_pricelist_test_ui.js @@ -0,0 +1,100 @@ +odoo.define('sale.product_configurator_pricelist_tour', function (require) { +"use strict"; + +var tour = require('web_tour.tour'); + +tour.register('sale_product_configurator_pricelist_tour', { + url: "/web", + test: true, +}, +[ +tour.STEPS.SHOW_APPS_MENU_ITEM, +{ + content: "navigate to the sale app", + trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]', + edition: 'community' +}, { + content: "navigate to the sale app", + trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]', + edition: 'enterprise' +}, { + content: "create a new order", + trigger: '.o_list_button_add', + extra_trigger: ".o_sale_order" +}, { + content: "search the partner", + trigger: 'div[name="partner_id"] input', + run: 'text Azure' +}, { + content: "select the partner", + trigger: 'ul.ui-autocomplete > li > a:contains(Azure)', +}, { + content: "search the pricelist", + trigger: 'div[name="pricelist_id"] input', + run: 'text Public Pricelist' +}, { + content: "select the pricelist", + trigger: 'ul.ui-autocomplete > li > a:contains(Public Pricelist)', +}, { + content: "wait for pricelist to be set", + trigger: 'div.o_with_button[name="pricelist_id"]', + run: function () {}, +}, { + content: "open product configurator", + trigger: 'a:contains("Configure a product")', +}, { + content: "open product selector", + trigger: '.o_product_configurator .o_input_dropdown input', + run: 'click' +}, { + content: "select desk", + trigger: 'li a:contains("Customizable Desk")', + in_modal: false, + extra_trigger: 'ul.ui-autocomplete', +}, { + content: "check price is correct (USD)", + trigger: 'span.oe_currency_value:contains("750.00")', + run: function () {} // check price +}, { + content: "add one more", + trigger: 'button.js_add_cart_json:has(i.fa-plus)', +}, { + content: "check price for 2", + trigger: 'span.oe_currency_value:contains("600.00")', + run: function () {} // check price (pricelist has discount for 2) +}, { + content: "click add", + trigger: '.o_sale_product_configurator_add:not(.disabled)' +}, { + content: "check we are on the add modal", + trigger: '.td-product_name:contains("Customizable Desk (Steel, White)")', + extra_trigger: '.oe_optional_products_modal', + run: 'click' +}, { + content: "add conference chair", + trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))', + run: 'click' +}, { + content: "add chair floor protection", + trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add', + extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))', + run: 'click' +}, { + content: "verify configurator final price", // tax excluded + trigger: '.o_total_row .oe_currency_value:contains("1,257.00")', +}, { + content: "add to SO", + trigger: 'button span:contains(Confirm)', + extra_trigger: '.oe_optional_products_modal', + run: 'click' +}, { + content: "verify SO final price excluded", + trigger: 'span[name="amount_untaxed"]:contains("1,257.00")', +}, { + content: "verify SO final price included", + trigger: 'span[name="amount_total"]:contains("1,437.00")', +} +]); + +}); diff --git a/addons/sale_management/static/tests/product_configurator_test_ui.js b/addons/sale_management/static/tests/product_configurator_test_ui.js index 0f50c1b4440..03f5a0a8bbc 100644 --- a/addons/sale_management/static/tests/product_configurator_test_ui.js +++ b/addons/sale_management/static/tests/product_configurator_test_ui.js @@ -3,6 +3,9 @@ odoo.define('sale.product_configurator_tour', function (require) { var tour = require('web_tour.tour'); +// Note: please keep this test without pricelist for maximum coverage. +// The pricelist is tested on the other tours. + tour.register('sale_product_configurator_tour', { url: "/web", test: true, @@ -16,14 +19,7 @@ tour.register('sale_product_configurator_tour', { trigger: ".o_list_button_add", extra_trigger: ".o_sale_order" }, { - trigger: ".o_required_modifier[name=partner_id] input", - run: "text Couscous Magique", -}, { - trigger: ".ui-menu-item > a:contains('Couscous Magique')", - auto: true, -}, { trigger: "a:contains('Configure a product')", - extra_trigger: ".o_field_widget[name=pricelist_id] > .o_external_button", // Wait for pricelist (onchange_partner_id) }, { trigger: '.o_product_configurator .o_input_dropdown input', run: 'click' @@ -48,7 +44,7 @@ tour.register('sale_product_configurator_tour', { trigger: '.configurator_container span:contains("Aluminium")', run: 'click' }, { - trigger: 'span.oe_currency_value:contains("800")', + trigger: 'span.oe_currency_value:contains("800.40")', run: function (){} // check updated price }, { trigger: 'input[data-value_name="Black"]' @@ -74,11 +70,12 @@ tour.register('sale_product_configurator_tour', { }, { trigger: 'button span:contains(Confirm)', extra_trigger: '.oe_optional_products_modal', + id: "quotation_product_selected", run: 'click' }, // check that 3 products were added to the SO { - trigger: 'td.o_data_cell:contains("Customizable Desk (White, Aluminium)")', + trigger: 'td.o_data_cell:contains("Customizable Desk (Aluminium, White)")', extra_trigger: 'div[name="order_line"]', in_modal: false, run: function (){} @@ -93,15 +90,10 @@ tour.register('sale_product_configurator_tour', { in_modal: false, run: function (){} }, { - trigger: '.o_readonly_modifier[name=amount_total]:contains("835")', - in_modal: false, - edition: 'community', - run: function (){} -}, { - trigger: '.o_readonly_modifier[name=amount_total]:contains("837")', + trigger: '.o_readonly_modifier[name=amount_total]:contains("0.00")', in_modal: false, - edition: 'enterprise', run: function (){} -}]); +} +]); }); diff --git a/addons/sale_management/tests/test_sale_ui.py b/addons/sale_management/tests/test_sale_ui.py index 7f147d0d574..6ec2f4167c1 100644 --- a/addons/sale_management/tests/test_sale_ui.py +++ b/addons/sale_management/tests/test_sale_ui.py @@ -1,6 +1,7 @@ import odoo.tests # Part of Odoo. See LICENSE file for full copyright and licensing details. + @odoo.tests.tagged('post_install', '-at_install') class TestUi(odoo.tests.HttpCase): @@ -82,3 +83,43 @@ class TestUi(odoo.tests.HttpCase): }) self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_advanced_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_advanced_tour.ready", login="admin") + + def test_04_product_configurator_pricelist(self): + """The goal of this test is to make sure pricelist rules are correctly + applied on the backend product configurator. + Also testing B2C setting: no impact on the backend configurator. + """ + + admin = self.env.ref('base.user_admin') + + # Activate B2C + self.env.ref('account.group_show_line_subtotals_tax_included').users |= admin + self.env.ref('account.group_show_line_subtotals_tax_excluded').users -= admin + + # Active pricelist on SO + self.env.ref('product.group_sale_pricelist').users |= admin + + # Add a 15% tax on desk + tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 15}) + self.env.ref('product.product_product_4_product_template').taxes_id = tax + + # Remove tax from Conference Chair and Chair floor protection + self.env.ref('sale.product_product_1_product_template').taxes_id = None + self.env.ref('product.product_product_11_product_template').taxes_id = None + + # Make sure pricelist rule exist + product_template = self.env.ref('product.product_product_4_product_template') + pricelist = self.env.ref('product.list0') + + 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', + }) + + self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_pricelist_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_pricelist_tour.ready", login="admin") diff --git a/addons/sale_management/views/assets.xml b/addons/sale_management/views/assets.xml index 80183df6075..a5a3134949d 100644 --- a/addons/sale_management/views/assets.xml +++ b/addons/sale_management/views/assets.xml @@ -4,6 +4,7 @@ <xpath expr="script[last()]" position="after"> <script type="text/javascript" src="/sale_management/static/tests/product_configurator_test_ui.js"/> <script type="text/javascript" src="/sale_management/static/tests/product_configurator_advanced_test_ui.js"/> + <script type="text/javascript" src="/sale_management/static/tests/product_configurator_pricelist_test_ui.js"/> </xpath> </template> -</odoo>
\ No newline at end of file +</odoo> diff --git a/addons/sale_management/views/res_config_settings_views.xml b/addons/sale_management/views/res_config_settings_views.xml index e485b88438e..07cbfbf1f07 100644 --- a/addons/sale_management/views/res_config_settings_views.xml +++ b/addons/sale_management/views/res_config_settings_views.xml @@ -36,7 +36,6 @@ <label for="module_sale_quotation_builder"/> <div class="text-muted"> Design your quotation templates using building blocks<br/> - <!-- TODO SEB don't show this after the module is installed --> <em attrs="{'invisible': [('module_sale_quotation_builder','=',False)]}">Warning: this option will install the Website app.</em> </div> </div> diff --git a/addons/sale_quotation_builder/__manifest__.py b/addons/sale_quotation_builder/__manifest__.py index ab2a9883bad..eab71371c05 100644 --- a/addons/sale_quotation_builder/__manifest__.py +++ b/addons/sale_quotation_builder/__manifest__.py @@ -13,6 +13,7 @@ 'data/sale_order_template_data.xml', 'views/sale_portal_templates.xml', 'views/sale_order_template_views.xml', + 'views/res_config_settings_views.xml', 'views/sale_order_views.xml', ], 'installable': True, diff --git a/addons/sale_quotation_builder/views/res_config_settings_views.xml b/addons/sale_quotation_builder/views/res_config_settings_views.xml new file mode 100644 index 00000000000..0894b145e34 --- /dev/null +++ b/addons/sale_quotation_builder/views/res_config_settings_views.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="sale_quotation_builder.res_config_settings_view_form_inherit" model="ir.ui.view"> + <field name="name">res.config.settings.view.form.inherit.sale.management.inherit.sale.quotation.builder</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="sale_management.res_config_settings_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//label[@for='module_sale_quotation_builder']/following::div/em" position="replace"/> + </field> + </record> +</odoo> diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index 430c3cc52d9..4c2ad3262d3 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -285,7 +285,14 @@ class SaleOrderLine(models.Model): @api.onchange('product_uom_qty') def _onchange_product_uom_qty(self): - if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < self._origin.product_uom_qty: + # When modifying a one2many, _origin doesn't guarantee that its values will be the ones + # in database. Hence, we need to explicitly read them from there. + if self._origin: + product_uom_qty_origin = self._origin.read(["product_uom_qty"])[0]["product_uom_qty"] + else: + product_uom_qty_origin = 0 + + if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < product_uom_qty_origin: # Do not display this warning if the new quantity is below the delivered # one; the `write` will raise an `UserError` anyway. if self.product_uom_qty < self.qty_delivered: diff --git a/addons/sale_stock/static/src/js/tour.js b/addons/sale_stock/static/src/js/tour.js index 245a25db2b2..d27080305d5 100644 --- a/addons/sale_stock/static/src/js/tour.js +++ b/addons/sale_stock/static/src/js/tour.js @@ -4,17 +4,26 @@ odoo.define("sale_stock.tour", function (require) { var tour = require('web_tour.tour'); require('sale.tour'); - var quotation_product_selected_step_index = _.findIndex(tour.tours.sale_tour.steps, function (step) { + var quotation_product_selected_step_index = _.findIndex(tour.tours.sale_product_configurator_tour.steps, function (step) { return (step.id === "quotation_product_selected"); }); - tour.tours.sale_tour.steps[quotation_product_selected_step_index].run = function (actions) { + tour.tours.sale_product_configurator_tour.steps[quotation_product_selected_step_index].run = function (actions) { actions.auto(); }; - tour.tours.sale_tour.steps.splice(quotation_product_selected_step_index+1, 0, { - trigger: ".modal-footer .btn-primary", + tour.tours.sale_product_configurator_tour.steps.splice(quotation_product_selected_step_index+1, 0, { + trigger: ".o_dialog_warning + .modal-footer .btn-primary", auto: true, + run: function (actions) { + actions.auto('.o_dialog_warning + .modal-footer .btn-primary'); + } + }, { + trigger: ".o_dialog_warning + .modal-footer .btn-primary", + auto: true, + run: function (actions) { + actions.auto('.o_dialog_warning + .modal-footer .btn-primary'); + } }, { trigger: "body:not(:has(.o_dialog_warning))", auto: true, @@ -25,4 +34,18 @@ odoo.define("sale_stock.tour", function (require) { } }, }); + + // Check if sale_management is installed since sale_stock is adding an extra + // step to add to SO (not enough inventory) + if ('sale.product_configurator_pricelist_tour' in odoo.__DEBUG__.services) { + var steps = tour.tours.sale_product_configurator_pricelist_tour.steps; + for (var k=0; k<steps.length; k++) { + if (steps[k].content === "add to SO") { + steps.splice(k+1, 0, { + content: "click in modal on ok button", + trigger: '.modal-footer button:contains("Ok")', + }); + } + } + } }); diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index aa2b573f8d0..adec4d21015 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -194,7 +194,7 @@ class ProjectTask(models.Model): self.partner_id = self.sale_line_id.order_partner_id # set domain on SO: on non billable project, all SOL of customer, otherwise the one from the SO result = result or {} - domain = [('is_service', '=', True), ('is_expense', '=', False), ('order_partner_id', '=', self.partner_id.id), ('state', 'in', ['sale', 'done'])] + domain = [('is_service', '=', True), ('is_expense', '=', False), ('order_partner_id', 'child_of', self.partner_id.commercial_partner_id.id), ('state', 'in', ['sale', 'done'])] if self.project_id.sale_order_id: domain += [('order_id', '=', self.project_id.sale_order_id.id)] result.setdefault('domain', {})['sale_line_id'] = domain @@ -203,8 +203,11 @@ class ProjectTask(models.Model): @api.onchange('partner_id') def _onchange_partner_id(self): result = super(ProjectTask, self)._onchange_partner_id() - if self.sale_line_id.order_partner_id != self.partner_id: + result = result or {} + if self.sale_line_id.order_partner_id.commercial_partner_id != self.partner_id.commercial_partner_id: self.sale_line_id = False + if self.partner_id: + result.setdefault('domain', {})['sale_line_id'] = [('is_service', '=', True), ('is_expense', '=', False), ('order_partner_id', 'child_of', self.partner_id.commercial_partner_id.id), ('state', 'in', ['sale', 'done'])] return result @api.multi diff --git a/addons/snailmail/models/snailmail_letter.py b/addons/snailmail/models/snailmail_letter.py index 1f8abe12ad2..c2e3a241b1f 100644 --- a/addons/snailmail/models/snailmail_letter.py +++ b/addons/snailmail/models/snailmail_letter.py @@ -184,12 +184,12 @@ class SnailmailLetter(models.Model): else: # adding the web logo from the company for future possible customization document.update({ - 'company_logo': letter.company_id.logo_web, + 'company_logo': letter.company_id.logo_web.decode('utf-8'), }) attachment = letter._fetch_attachment() if attachment: document.update({ - 'pdf_bin': route == 'print' and attachment.datas, + 'pdf_bin': route == 'print' and attachment.datas.decode('utf-8'), 'pages': route == 'estimate' and self._count_pages_pdf(base64.b64decode(attachment.datas)), }) else: @@ -345,7 +345,7 @@ class SnailmailLetter(models.Model): @api.model def _snailmail_cron(self): - letters_send = self.search([('state', 'in', ['pending', 'error'])]) + letters_send = self.search([('state', '=', 'pending')]) if letters_send: letters_send._snailmail_print() limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1) diff --git a/addons/snailmail/static/src/js/snailmail_external_layout.js b/addons/snailmail/static/src/js/snailmail_external_layout.js new file mode 100644 index 00000000000..be691d26ba0 --- /dev/null +++ b/addons/snailmail/static/src/js/snailmail_external_layout.js @@ -0,0 +1,7 @@ +// Change address font-size if needed +document.addEventListener('DOMContentLoaded', function (evt) { + var recipientAddress = document.getElementsByClassName('address row')[0].getElementsByTagName('address')[0]; + var height = parseFloat(window.getComputedStyle(recipientAddress, null).getPropertyValue('height')); + var fontSize = parseFloat(window.getComputedStyle(recipientAddress, null).getPropertyValue('font-size')); + recipientAddress.style.fontSize = (85/height) * fontSize + 'px'; +}); diff --git a/addons/snailmail/static/src/scss/snailmail_external_layout_asset.scss b/addons/snailmail/static/src/scss/snailmail_external_layout_asset.scss index be7331a9b61..340c17dda25 100644 --- a/addons/snailmail/static/src/scss/snailmail_external_layout_asset.scss +++ b/addons/snailmail/static/src/scss/snailmail_external_layout_asset.scss @@ -1,18 +1,33 @@ /* Generic report layouts*/ .header { - height: 200px; + max-height: 40mm; + overflow: hidden; +} +.container { + margin-top: 5mm; +} +.o_standard_footer, .o_clean_footer, .o_boxed_footer, .o_background_footer { + max-height: 90px !important; + overflow: hidden; + width: 95%; + div > div:first-child { + padding-left: 10mm; + } } .article>.page { > h1, > h2, > h3 { width: 50%; } } -.article { - > .address.row { - padding-top: 95px; - } - > .page { - margin-top:-20px; +.article > .address.row > div[name="address"] { + height: 65mm; + background-color: #ffffff; + padding-top: 23mm; + padding-left: 5mm; + padding-bottom: 5mm; + left: -20px !important; + address { + height: 28mm; } } diff --git a/addons/snailmail/views/report_assets.xml b/addons/snailmail/views/report_assets.xml index 9ede269e291..20c2d2c1369 100644 --- a/addons/snailmail/views/report_assets.xml +++ b/addons/snailmail/views/report_assets.xml @@ -3,6 +3,7 @@ <template id="report_assets_snailmail"> <t t-call="web._assets_helpers"/> <link rel="stylesheet" type="text/scss" href="/snailmail/static/src/scss/snailmail_external_layout_asset.scss"/> + <script type="text/javascript" src="/snailmail/static/src/js/snailmail_external_layout.js"/> </template> <template id="report_layout" inherit_id="web.report_layout"> <xpath expr="//head" position="inside"> diff --git a/addons/snailmail_account/__init__.py b/addons/snailmail_account/__init__.py index 35e7c9600c5..d22fb1f7f36 100644 --- a/addons/snailmail_account/__init__.py +++ b/addons/snailmail_account/__init__.py @@ -2,3 +2,4 @@ from . import models from . import wizard +from . import tests diff --git a/addons/snailmail_account/tests/__init__.py b/addons/snailmail_account/tests/__init__.py new file mode 100644 index 00000000000..b81c3a2d614 --- /dev/null +++ b/addons/snailmail_account/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pingen_send diff --git a/addons/snailmail_account/tests/test_pingen_send.py b/addons/snailmail_account/tests/test_pingen_send.py new file mode 100644 index 00000000000..ce73f388274 --- /dev/null +++ b/addons/snailmail_account/tests/test_pingen_send.py @@ -0,0 +1,96 @@ + +import requests +import json +import base64 + +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.tests import tagged + +@tagged('post_install', '-at_install', '-standard', 'external') +class TestPingenSend(AccountingTestCase): + + def setUp(self): + super(TestPingenSend, self).setUp() + self.pingen_url = "https://stage-api.pingen.com/document/upload/token/30fc3947dbea4792eb12548b41ec8117/" + self.sample_invoice = self.create_invoice() + self.sample_invoice.partner_id.vat = "BE000000000" + self.letter = self.env['snailmail.letter'].create({ + 'partner_id': self.sample_invoice.partner_id.id, + 'model': 'account.invoice', + 'res_id': self.sample_invoice.id, + 'user_id': self.env.user.id, + 'company_id': self.sample_invoice.company_id.id, + 'report_template': self.env.ref('account.account_invoices').id + }) + self.data = { + 'data': json.dumps({ + 'speed': 1, + 'color': 2, + 'duplex': 0, + 'send': True, + }) + } + + def create_invoice(self): + """ Create a sample invoice """ + currency = self.env.ref('base.EUR') + partner_agrolait = self.env.ref("base.res_partner_2") + product = self.env.ref("product.product_product_4") + + account_receivable = self.env['account.account'].create({ + 'code': 'TESTPINGEN1', + 'name': 'Test Receivable Account', + 'user_type_id': self.env.ref('account.data_account_type_receivable').id, + 'reconcile': True + }) + account_income = self.env['account.account'].create({ + 'code': 'TESTPINGEN2', + 'name': 'Test Account', + 'user_type_id': self.env.ref('account.data_account_type_direct_costs').id + }) + + invoice = self.env['account.invoice'].create({ + 'partner_id': partner_agrolait.id, + 'currency_id': currency.id, + 'name': 'invoice to client', + 'account_id': account_receivable.id, + 'type': 'out_invoice', + 'date_invoice': '2018-12-11', + }) + + self.env['account.invoice.line'].create({ + 'product_id': product.id, + 'quantity': 1, + 'price_unit': 42, + 'invoice_id': invoice.id, + 'name': 'something', + 'account_id': account_income.id, + }) + + invoice.action_invoice_open() + + return invoice + + def render_and_send(self, report_name): + self.sample_invoice.company_id.external_report_layout_id = self.env.ref('web.' + report_name) + self.letter.attachment_id = False + attachment_id = self.letter.with_context(force_report_rendering=True)._fetch_attachment() + + files = { + 'file': ('pingen_test_%s.pdf' % report_name, base64.b64decode(attachment_id.datas), 'application/pdf'), + } + + response = requests.post(self.pingen_url, data=self.data, files=files) + + try: + response.raise_for_status() + except: + return False + + return True + + def test_pingen_send_invoice(self): + self.assertTrue(self.render_and_send('external_layout_standard')) + self.assertTrue(self.render_and_send('external_layout_background')) + self.assertTrue(self.render_and_send('external_layout_boxed')) + self.assertTrue(self.render_and_send('external_layout_clean')) diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index b0416c1a2ae..263476c140e 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -425,7 +425,7 @@ class InventoryLine(models.Model): """ for line in self: if line.product_id.type != 'product': - raise UserError(_("You can only adjust storable products.")) + raise UserError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type)) def _get_move_values(self, qty, location_id, location_dest_id, out): self.ensure_one() diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 4c02adf6400..83e979d8de4 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -512,7 +512,7 @@ class StockMove(models.Model): rules = self.env['procurement.group']._search_rule(move.route_ids, move.product_id, warehouse_id, domain) # Make sure it is not returning the return - if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_dest_id.id): + if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_id.id): rules._run_push(move) def _merge_moves_fields(self): diff --git a/addons/stock/report/report_location_barcode.xml b/addons/stock/report/report_location_barcode.xml index 7cf706d0467..a1f81d50aac 100644 --- a/addons/stock/report/report_location_barcode.xml +++ b/addons/stock/report/report_location_barcode.xml @@ -10,7 +10,7 @@ <template id="report_location_barcode"> <t t-call="web.html_container"> - <div t-foreach="[docs[x:x+4] for x in xrange(0, len(docs), 4)]" t-as="page_docs" class="page article page_stock_location_barcodes"> + <div t-foreach="[docs[x:x+4] for x in range(0, len(docs), 4)]" t-as="page_docs" class="page article page_stock_location_barcodes"> <t t-foreach="page_docs" t-as="o"> <t t-if="o.barcode"><t t-set="content" t-value="o.barcode"/></t> <t t-if="not o.barcode"><t t-set="content" t-value="o.name"/></t> diff --git a/addons/stock/static/src/js/stock_traceability_report_widgets.js b/addons/stock/static/src/js/stock_traceability_report_widgets.js index 65e9d4d5b2f..7a52ea100c1 100644 --- a/addons/stock/static/src/js/stock_traceability_report_widgets.js +++ b/addons/stock/static/src/js/stock_traceability_report_widgets.js @@ -49,11 +49,10 @@ var ReportWidget = Widget.extend({ }, updownStream: function(e) { var $el = $(e.target).parents('tr'); - var string = "Traceability Report"; this.do_action({ type: "ir.actions.client", tag: 'stock_report_generic', - name: _t(string), + name: _t("Traceability Report"), context: { active_id : $el.data('model_id'), active_model : $el.data('model'), diff --git a/addons/stock/wizard/product_replenish.py b/addons/stock/wizard/product_replenish.py index ced229de126..9b743b5116e 100644 --- a/addons/stock/wizard/product_replenish.py +++ b/addons/stock/wizard/product_replenish.py @@ -72,7 +72,7 @@ class ProductReplenish(models.TransientModel): values = { 'warehouse_id': self.warehouse_id, 'route_ids': self.route_ids, - 'date_planned': self.date_planned, + 'date_planned': self.date_planned or fields.Datetime.now(), 'group_id': replenishment, } return values diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index c0d05ebbd0d..3f362f29989 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -18,7 +18,7 @@ class StockInventory(models.Model): 'Accounting Date', help="Date at which the accounting entries will be created" " in case of automated inventory valuation." - " If empty, the inventoy date will be used.") + " If empty, the inventory date will be used.") @api.multi def post_inventory(self): @@ -306,18 +306,20 @@ class StockMove(models.Model): vals = {} price_unit = self._get_price_unit() value = price_unit * (quantity or valued_quantity) + value_to_return = value if quantity is None or not self.value else self.value vals = { 'price_unit': price_unit, - 'value': value if quantity is None or not self.value else self.value, + 'value': value_to_return, 'remaining_value': value if quantity is None else self.remaining_value + value, } vals['remaining_qty'] = valued_quantity if quantity is None else self.remaining_qty + quantity if self.product_id.cost_method == 'standard': value = self.product_id.standard_price * (quantity or valued_quantity) + value_to_return = value if quantity is None or not self.value else self.value vals.update({ 'price_unit': self.product_id.standard_price, - 'value': value if quantity is None or not self.value else self.value, + 'value': value_to_return, }) self.write(vals) elif self._is_out(): @@ -329,8 +331,9 @@ class StockMove(models.Model): if self.product_id.cost_method in ['standard', 'average']: curr_rounding = self.company_id.currency_id.rounding value = -float_round(self.product_id.standard_price * (valued_quantity if quantity is None else quantity), precision_rounding=curr_rounding) + value_to_return = value if quantity is None else self.value + value self.write({ - 'value': value if quantity is None else self.value + value, + 'value': value_to_return, 'price_unit': value / valued_quantity, }) elif self._is_dropshipped() or self._is_dropshipped_returned(): @@ -342,12 +345,14 @@ class StockMove(models.Model): else: price_unit = self.product_id.standard_price value = float_round(self.product_qty * price_unit, precision_rounding=curr_rounding) + value_to_return = value if self._is_dropshipped() else -value # In move have a positive value, out move have a negative value, let's arbitrary say # dropship are positive. self.write({ - 'value': value if self._is_dropshipped() else -value, + 'value': value_to_return, 'price_unit': price_unit if self._is_dropshipped() else -price_unit, }) + return value_to_return def _action_done(self, cancel_backorder=False): self.product_price_update_before_done() diff --git a/addons/stock_picking_batch/wizard/stock_picking_to_batch.py b/addons/stock_picking_batch/wizard/stock_picking_to_batch.py index ddf02ffc172..4739e39670e 100644 --- a/addons/stock_picking_batch/wizard/stock_picking_to_batch.py +++ b/addons/stock_picking_batch/wizard/stock_picking_to_batch.py @@ -8,7 +8,7 @@ class StockPickingToBatch(models.TransientModel): _name = 'stock.picking.to.batch' _description = 'Batch Picking Lines' - batch_id = fields.Many2one('stock.picking.batch', string='Batch Picking', required=True, oldname="wave_id") + batch_id = fields.Many2one('stock.picking.batch', string='Batch Picking', oldname="wave_id") @api.multi def attach_pickings(self): diff --git a/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml b/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml index da393c44972..b3b7b9a0108 100644 --- a/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml +++ b/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml @@ -8,7 +8,7 @@ <field name="arch" type="xml"> <form string="Add pickings to batch"> <separator string="Select a batch"/> - <field name="batch_id" class="oe_inline" options="{'no_create_edit': True, 'no_open': True}"/> + <field name="batch_id" class="oe_inline" required="1" options="{'no_create_edit': True, 'no_open': True}"/> <footer> <button name="attach_pickings" type="object" string="Add to Batch" class="btn-primary"/> <button string="Cancel" class="btn-secondary" special="cancel"/> diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js index c798189c0a0..107c6e314eb 100644 --- a/addons/web/static/src/js/chrome/action_manager.js +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -332,9 +332,6 @@ var ActionManager = Widget.extend({ // update the internal state and the DOM self._pushController(controller); - // toggle the fullscreen mode for actions in target='fullscreen' - self._toggleFullscreen(); - // store the action into the sessionStorage so that it can be // fully restored on F5 self.call('session_storage', 'setItem', 'current_action', action._originalAction); @@ -720,6 +717,9 @@ var ActionManager = Widget.extend({ action: this.getCurrentAction(), controller: controller, }); + + // toggle the fullscreen mode for actions in target='fullscreen' + this._toggleFullscreen(); }, /** * Pushes the given state, with additional information about the given diff --git a/addons/web/static/src/js/core/dialog.js b/addons/web/static/src/js/core/dialog.js index 492ea06c7bd..83eded92c8a 100644 --- a/addons/web/static/src/js/core/dialog.js +++ b/addons/web/static/src/js/core/dialog.js @@ -143,7 +143,11 @@ var Dialog = Widget.extend({ $.when(def).always(self.close.bind(self)); } }); - self.$footer.append($button); + if (self.technical) { + self.$footer.append($button); + } else { + self.$footer.prepend($button); + } }); }, diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 848e406c4b2..b8a5b8be3a4 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -506,7 +506,7 @@ var dom = { var $dropdownMenu = $('<ul/>', {class: 'dropdown-menu'}); $extraItemsToggle = $('<li/>', {class: 'nav-item dropdown o_extra_menu_items'}) - .append($('<a/>', {href: '#', class: 'nav-link dropdown-toggle o-no-caret', 'data-toggle': 'dropdown'}) + .append($('<a/>', {role: 'button', href: '#', class: 'nav-link dropdown-toggle o-no-caret', 'data-toggle': 'dropdown', 'aria-expanded': false}) .append($('<i/>', {class: 'fa fa-plus'}))) .append($dropdownMenu); $extraItemsToggle.insertAfter($items.last()); diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js index a348f54acd9..f09d8745f04 100644 --- a/addons/web/static/src/js/tools/debug_manager.js +++ b/addons/web/static/src/js/tools/debug_manager.js @@ -365,6 +365,10 @@ DebugManager.include({ views: [[false, 'list'], [false, 'form']], type: 'ir.actions.act_window', domain: [['res_model', '=', this._action.res_model], ['res_id', '=', selectedIDs[0]]], + context: { + default_res_model: this._action.res_model, + default_res_id: selectedIDs[0], + }, }); }, get_metadata: function() { diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index fecbaa5b90c..9706251ed07 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1758,7 +1758,7 @@ var BasicModel = AbstractModel.extend({ model: list.model, method: 'read', args: [_.pluck(data, 'id'), fieldNames], - context: record.context, + context: _.extend({}, record.context, field.context), }).then(function (records) { _.each(records, function (record) { list_records[record.id].data = record; @@ -1833,7 +1833,7 @@ var BasicModel = AbstractModel.extend({ addDef = this._applyX2ManyChange(record, fieldName, { operation: 'ADD_M2M', ids: values - }); + }, viewType); } if (removedIds.length) { var listData = _.map(list.data, function (localId) { @@ -1847,7 +1847,7 @@ var BasicModel = AbstractModel.extend({ } return _.findWhere(listData, {res_id: resID}).id; }), - }); + }, viewType); } return $.when(addDef, removedDef); } @@ -2827,7 +2827,7 @@ var BasicModel = AbstractModel.extend({ var ids = record.data[fieldName] || []; var list = self._makeDataPoint({ count: ids.length, - context: record.context, + context: _.extend({}, record.context, field.context), fieldsInfo: fieldsInfo, fields: view ? view.fields : fieldInfo.relatedFields, limit: fieldInfo.limit, @@ -4325,11 +4325,24 @@ var BasicModel = AbstractModel.extend({ } return def.then(function () { var resIDs = []; - // generate the current count and res_ids list by applying the changes - var currentCount = list.count; var currentResIDs = list.res_ids; - var effectiveLimit = (list.limit || 0) - (list.tempLimitIncrement || 0); - var upperBound = effectiveLimit ? Math.min(list.offset + effectiveLimit, currentCount) : currentCount; + // if new records have been added to the list, their virtual ids have + // been pushed at the end of res_ids (or at the beginning, depending + // on the editable property), ignoring completely the current page + // where the records have actually been created ; for that reason, + // we use orderedResIDs which is a freezed order with the virtual ids + // at the correct position where they were actually inserted ; however, + // when we use orderedResIDs, we must filter out ids that are not in + // res_ids, which correspond to records that have been removed from + // the relation (this information being taken into account in res_ids + // but not in orderedResIDs) + if (list.orderedResIDs) { + currentResIDs = list.orderedResIDs.filter(function (resID) { + return list.res_ids.indexOf(resID) >= 0; + }); + } + var currentCount = currentResIDs.length; + var upperBound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount; var fieldNames = list.getFieldNames(); for (var i = list.offset; i < upperBound; i++) { var resId = currentResIDs[i]; diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index d5d6cfa18d5..39444fdfd75 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -145,6 +145,21 @@ var BasicView = AbstractView.extend({ if (_.difference(fieldViewTypes, recordViewTypes).length) { fieldNames.push(name); } + + if (record.data[name].viewType === 'default') { + // Use case: x2many (tags) in x2many list views + // When opening the x2many record form view, the + // x2many will be reloaded but it may not have + // the same fields (ex: tags in list and list in + // form) so we need to merge the fieldsInfo to + // avoid losing the initial fields (display_name) + var defaultFieldInfo = record.data[name].fieldsInfo.default; + _.each(fieldViews, function (fieldView) { + _.each(fieldView.fieldsInfo, function (x2mFieldInfo) { + _.defaults(x2mFieldInfo, defaultFieldInfo); + }); + }); + } } } // Many2one: context is not the same between the different views diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index 1f8c9423cac..7c06a7d818d 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -244,18 +244,18 @@ return AbstractModel.extend({ this.setDate(this.data.target_date.clone().add(-1, this.data.scale)); }, /** - * @todo: this should not work. it ignores the domain/context - * * @override - * @param {any} _handle ignored - * @param {Object} params + * @param {Object} [params.context] * @param {Array} [params.domain] * @returns {Deferred} */ - reload: function (_handle, params) { + reload: function (handle, params) { if (params.domain) { this.data.domain = params.domain; } + if (params.context) { + this.data.context = params.context; + } return this._loadCalendar(); }, /** @@ -385,6 +385,9 @@ return AbstractModel.extend({ * @returns {Object} */ _getFullCalendarOptions: function () { + var week_start = _t.database.parameters.week_start || 0; + // calendar uses index 0 for Sunday but Odoo stores it as 7 + week_start = week_start % 7; return { defaultView: (this.mode === "month")? "month" : ((this.mode === "week")? "agendaWeek" : ((this.mode === "day")? "agendaDay" : "agendaWeek")), header: false, @@ -404,7 +407,7 @@ return AbstractModel.extend({ monthNamesShort: moment.monthsShort(), dayNames: moment.weekdays(), dayNamesShort: moment.weekdaysShort(), - firstDay: _t.database.parameters.week_start, + firstDay: week_start, slotLabelFormat: _t.database.parameters.time_format.search("%H") != -1 ? 'H:mm': 'h(:mm)a', }; }, diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js index 249f4ec6019..5a00350c68f 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -122,9 +122,14 @@ var FormView = BasicView.extend({ while (matches = regex.exec(attrs.context)) { context[matches[1]] = matches[2]; } + + // Remove *_view_ref coming from parent view + var refinedContext = _.pick(self.loadParams.context, function (value, key) { + return key.indexOf('_view_ref') === -1; + }); defs.push(parent.loadViews( field.relation, - new Context(context, self.userContext, self.loadParams.context).eval(), + new Context(context, self.userContext, refinedContext).eval(), [[null, attrs.mode === 'tree' ? 'list' : attrs.mode]]) .then(function (views) { for (var viewName in views) { diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js index 57ecc6f4b4e..b526b80a971 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column.js +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -114,7 +114,6 @@ var KanbanColumn = Widget.extend({ revert: 0, delay: 0, items: '> .o_kanban_record:not(.o_updating)', - helper: 'clone', cursor: 'move', over: function () { self.$el.addClass('o_kanban_hover'); @@ -122,6 +121,15 @@ var KanbanColumn = Widget.extend({ out: function () { self.$el.removeClass('o_kanban_hover'); }, + start: function (event, ui) { + ui.item.addClass('o_currently_dragged'); + }, + stop: function (event, ui) { + var item = ui.item; + setTimeout(function () { + item.removeClass('o_currently_dragged'); + }); + }, update: function (event, ui) { var record = ui.item.data('record'); var index = self.records.indexOf(record); diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js index d9d7ac77fe2..46f6964dd1c 100644 --- a/addons/web/static/src/js/views/kanban/kanban_record.js +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -5,7 +5,6 @@ odoo.define('web.KanbanRecord', function (require) { * This file defines the KanbanRecord widget, which corresponds to a card in * a Kanban view. */ - var config = require('web.config'); var core = require('web.core'); var Domain = require('web.Domain'); @@ -210,6 +209,11 @@ var KanbanRecord = Widget.extend({ * @private */ _openRecord: function () { + if (this.$el.hasClass('o_currently_dragged')) { + // this record is currently being dragged and dropped, so we do not + // want to open it. + return; + } var editMode = this.$el.hasClass('oe_kanban_global_click_edit'); this.trigger_up('open_record', { id: this.db_id, diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index a4833b40d81..ac48ab769ff 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -345,7 +345,8 @@ var PivotController = AbstractController.extend({ var col_id = $target.data('id'); var measure = $target.data('measure'); var isAscending = $target.hasClass('o_pivot_measure_row_sorted_asc'); - this.model.sortRows(col_id, measure, isAscending); + var dataType = $target.data('data_type'); + this.model.sortRows(col_id, measure, isAscending, dataType); this.update({}, {reload: false}); }, /** diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 1a0d3625440..5a6423fbe34 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -457,16 +457,23 @@ var PivotModel = AbstractModel.extend({ * @param {any} col_id * @param {any} measure * @param {any} descending + * @param {'data'|'comparisonData'|'variation'} [dataType] */ - sortRows: function (col_id, measure, descending) { + sortRows: function (col_id, measure, descending, dataType) { var cells = this.data.cells; + var comparisonFunction = compare; + if (this.data.compare) { + dataType = dataType || 'data'; + comparisonFunction = specialCompare; + } this._traverseTree(this.data.main_row.root, function (header) { - header.children.sort(compare); + header.children.sort(comparisonFunction); }); this.data.sorted_column = { id: col_id, measure: measure, order: descending ? 'desc' : 'asc', + dataType: dataType, }; function _getValue (id1, id2) { if ((id1 in cells) && (id2 in cells[id1])) { @@ -476,12 +483,26 @@ var PivotModel = AbstractModel.extend({ } function compare (row1, row2) { - var values1 = _getValue(row1.id, col_id), - values2 = _getValue(row2.id, col_id), - value1 = values1 ? values1[measure] : 0, - value2 = values2 ? values2[measure] : 0; + var values1 = _getValue(row1.id, col_id); + var values2 = _getValue(row2.id, col_id); + var value1 = values1 ? values1[measure] : 0; + var value2 = values2 ? values2[measure] : 0; return descending ? value1 - value2 : value2 - value1; } + function specialCompare (row1, row2) { + var values1 = _getValue(row1.id, col_id); + var values2 = _getValue(row2.id, col_id); + var value1 = values1 ? values1[measure] : {data: 0, comparisonData: 0, variation: {magnitude: 0}}; + var value2 = values2 ? values2[measure] : {data: 0, comparisonData: 0, variation: {magnitude: 0}}; + if (dataType === 'variation') { + return descending ? + value1[dataType].magnitude - value2[dataType].magnitude: + value2[dataType].magnitude - value1[dataType].magnitude; + } + return descending ? + value1[dataType] - value2[dataType]: + value2[dataType] - value1[dataType]; + } }, /** * Toggle the active state for a given measure, then reload the data. diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js index a68d91330a9..787e28e38a9 100644 --- a/addons/web/static/src/js/views/pivot/pivot_renderer.js +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -116,6 +116,8 @@ var PivotRenderer = AbstractRenderer.extend({ _renderHeaders: function ($thead, headers, nbrCols) { var self = this; var i, j, cell, $row, $cell; + var measure, dataType; + var id, measureCellIds = []; var groupbyLabels = _.map(this.state.colGroupBys, function (gb) { return self.state.fields[gb.split(':')[0]].string; @@ -144,10 +146,13 @@ var PivotRenderer = AbstractRenderer.extend({ $cell.data('id', cell.id); } if (cell.measure) { + measure = cell.measure; + measureCellIds.push(cell.id); $cell.addClass('o_pivot_measure_row text-muted') - .text(this.state.fields[cell.measure].string); - $cell.data('id', cell.id).data('measure', cell.measure); - if (!this.compare && cell.id === this.state.sortedColumn.id && cell.measure === this.state.sortedColumn.measure) { + .text(this.state.fields[measure].string); + $cell.data('id', cell.id).data('measure', measure); + if (cell.id === this.state.sortedColumn.id && + measure === this.state.sortedColumn.measure) { $cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order); if (this.state.sortedColumn.order == 'asc') { $cell.attr('aria-sorted', 'ascending'); @@ -167,12 +172,30 @@ var PivotRenderer = AbstractRenderer.extend({ } if (this.compare) { var colLabels = [this.timeRangeDescription, this.comparisonTimeRangeDescription, 'Variation']; + var dataTypes = ['data', 'comparisonData', 'variation']; $row = $('<tr>'); for (i = 0; i < 3 * nbrCols; i++) { + id = measureCellIds[~~(i / 3)]; + measure = this.state.measures[(~~(i / 3)) % this.state.measures.length]; + dataType = dataTypes[i % 3]; $cell = $('<th>') + .addClass('o_pivot_measure_row text-muted') + .data('data_type', dataType) + .data('id', id) + .data('measure', measure) .text(colLabels[i % 3]) .attr('rowspan', 1) .attr('colspan', 1); + if (dataType === this.state.sortedColumn.dataType && + id === this.state.sortedColumn.id && + measure === this.state.sortedColumn.measure) { + $cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order); + if (this.state.sortedColumn.order === 'asc') { + $cell.attr('aria-sorted', 'ascending'); + } else { + $cell.attr('aria-sorted', 'descending'); + } + } $row.append($cell); } $thead.append($row); diff --git a/addons/web/static/src/js/widgets/attach_document.js b/addons/web/static/src/js/widgets/attach_document.js index 041b021d60b..13496f3aea7 100644 --- a/addons/web/static/src/js/widgets/attach_document.js +++ b/addons/web/static/src/js/widgets/attach_document.js @@ -11,7 +11,7 @@ var _t = core._t; var AttachDocument = Widget.extend({ template: 'AttachDocument', events: { - 'click span.o_attach_document': '_onClickAttachDocument', + 'click': '_onClickAttachDocument', 'change input.o_input_file': '_onFileChanged', }, /** @@ -76,6 +76,9 @@ var AttachDocument = Widget.extend({ * @param {Event} ev */ _onClickAttachDocument: function (ev) { + if ($(ev.target).is('input.o_input_file')) { + return; + } var fieldNames = this.getParent().canBeSaved(this.state.id); if (fieldNames.length) { return this._notifyInvalidFields(fieldNames); diff --git a/addons/web/static/src/js/widgets/switch_company_menu.js b/addons/web/static/src/js/widgets/switch_company_menu.js index c9498950a04..cc0f8825643 100644 --- a/addons/web/static/src/js/widgets/switch_company_menu.js +++ b/addons/web/static/src/js/widgets/switch_company_menu.js @@ -53,7 +53,7 @@ var SwitchCompanyMenu = Widget.extend({ } else { a = '<span style="margin-right: 24px;"/>'; } - companiesList += '<a href="#" class="dropdown-item" data-menu="company" data-company-id="' + + companiesList += '<a role="menuitem" href="#" class="dropdown-item" data-menu="company" data-company-id="' + company[0] + '">' + a + company[1] + '</a>'; }); this.$('.dropdown-menu').html(companiesList); diff --git a/addons/web/static/src/scss/bootstrap_overridden.scss b/addons/web/static/src/scss/bootstrap_overridden.scss index 09dd5ddbfd4..e8406c948e9 100644 --- a/addons/web/static/src/scss/bootstrap_overridden.scss +++ b/addons/web/static/src/scss/bootstrap_overridden.scss @@ -133,8 +133,8 @@ $modal-inner-padding: $o-horizontal-padding; // FIXME $modal-title-padding ? -$modal-lg: 980px; -$modal-md: 650px; +$modal-lg: $o-modal-lg; +$modal-md: $o-modal-md; // Breadcrumbs diff --git a/addons/web/static/src/scss/primary_variables.scss b/addons/web/static/src/scss/primary_variables.scss index 45d1b7ac807..2bdc03a132b 100644 --- a/addons/web/static/src/scss/primary_variables.scss +++ b/addons/web/static/src/scss/primary_variables.scss @@ -65,6 +65,9 @@ $o-sheet-vpadding: 24px; $o-notification-error-bg-color: #F16567; $o-notification-info-bg-color: #FCFBEA; +$o-modal-lg: 980px; +$o-modal-md: 650px; + // Needed for having no spacing between sheet and mail body in mass_mailing: // Different required cancel paddings between web and web_enterprise $o-sheet-cancel-tpadding: 0px; diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 102e70efa1e..55d744d3a60 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -235,7 +235,7 @@ <t t-name="WebClient.DebugManager"> <li class="o_debug_manager" role="menuitem"> - <a href="#" title="Open Developer Tools" aria-label="Open Developer Tools" data-toggle="dropdown" aria-expanded="false" tabindex="-1" data-display="static"> + <a role="button" href="#" title="Open Developer Tools" aria-label="Open Developer Tools" data-toggle="dropdown" aria-expanded="false" tabindex="-1" data-display="static"> <span class="fa fa-bug"/> </a> <div class="dropdown-menu dropdown-menu-right o_debug_dropdown" role="menu"/> @@ -853,7 +853,7 @@ <span class="o_badge_text" t-att-title="el.display_name"><span role="img" t-attf-aria-label="Tag color: #{colornames[color]}"/><t t-esc="el.display_name"/></span> </t> <t t-if="colorField"> - <a href="#" class="dropdown-toggle o-no-caret" data-toggle="dropdown"> + <a role="button" href="#" class="dropdown-toggle o-no-caret" data-toggle="dropdown" aria-expanded="false"> <t t-raw="_badge_text"/> </a> </t> @@ -1080,10 +1080,10 @@ <t t-if="widget.items.length !== 0"> <t t-set="group" t-value="0"/> <t t-foreach="widget.items" t-as="item"> - <div t-if="group !== item.groupNumber" class="dropdown-divider" data-removable="1"/> + <div t-if="group !== item.groupNumber" role="separator" class="dropdown-divider" data-removable="1"/> <div class="o_menu_item" t-att-data-id="item.id"> - <a href="#" t-attf-class="dropdown-item#{item.isActive ? ' selected ' : ''}"> + <a role="menuitem" href="#" t-attf-class="dropdown-item#{item.isActive ? ' selected ' : ''}"> <t t-esc="item.description"/> <span t-if="item.isRemovable" t-att-data-id="item.id" class="o_trash_button" > <span class="fa fa-trash-o"></span> </span> <span t-if="item.hasOptions" t-att-data-id="item.id" class="o_submenu_switcher"> @@ -1093,10 +1093,10 @@ <ul t-if="item.hasOptions && widget.openItems[item.id]" class="list-unstyled"> <t t-set="subgroup" t-value="0"/> <t t-foreach="item.options" t-as="option"> - <li t-if="subgroup !== option.groupNumber" class="dropdown-divider" data-removable="1"/> + <li t-if="subgroup !== option.groupNumber" role="separator" class="dropdown-divider" data-removable="1"/> <li class="o_item_option" t-att-data-item_id="item.id" t-att-data-option_id="option.optionId"> - <a href="#" t-attf-class="dropdown-item#{item.currentOptionId === option.optionId ? ' selected': ''} pl-5"><t t-esc="option.description"/></a> + <a role="menuitem" href="#" t-attf-class="dropdown-item#{item.currentOptionId === option.optionId ? ' selected': ''} pl-5"><t t-esc="option.description"/></a> </li> <t t-set="subgroup" t-value="option.groupNumber"/> </t> @@ -1117,8 +1117,8 @@ </div> <t t-name="GroupByMenuGenerator"> - <div t-if="widget.presentedFields.length !== 0 && widget.items.length !== 0" class="dropdown-divider o_generator_menu"/> - <button t-if="widget.presentedFields.length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_group">Add Custom Group</button> + <div t-if="widget.presentedFields.length !== 0 && widget.items.length !== 0" role="separator" class="dropdown-divider o_generator_menu"/> + <button t-if="widget.presentedFields.length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_group" aria-expanded="false">Add Custom Group</button> <div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu"> <select class="w-auto o_input o_group_selector o_add_group"> <t t-foreach="widget.presentedFields" t-as="field"> @@ -1132,8 +1132,8 @@ </t> <t t-name="FilterMenuGenerator"> - <div t-if="Object.keys(widget.fields).length !== 0 && widget.items.length !== 0" class="dropdown-divider o_generator_menu"/> - <button t-if="Object.keys(widget.fields).length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_filter">Add Custom Filter</button> + <div t-if="Object.keys(widget.fields).length !== 0 && widget.items.length !== 0" role="separator" class="dropdown-divider o_generator_menu"/> + <button t-if="Object.keys(widget.fields).length !== 0" type="button" class="dropdown-item o_generator_menu o_add_custom_filter" aria-expanded="false">Add Custom Filter</button> <div t-if="widget.generatorMenuIsOpen" class="dropdown-item-text o_generator_menu o_add_filter_menu"> <button class="btn btn-primary o_apply_filter" type="button">Apply</button> <button class="btn btn-secondary o_add_condition" type="button"> @@ -1335,7 +1335,7 @@ <label for="o_favorite_share_all_users" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label> </div> <div class="dropdown-item-text o_save_favorite"> - <button role="menuitem" type="button" class="btn btn-primary">Save</button> + <button type="button" class="btn btn-primary">Save</button> </div> </div> </div> @@ -1495,7 +1495,7 @@ <t t-name="UserMenu"> <li class="o_user_menu"> - <a class="dropdown-toggle" data-toggle="dropdown" data-display="static" aria-expanded="false" href="#"> + <a role="button" class="dropdown-toggle" data-toggle="dropdown" data-display="static" aria-expanded="false" href="#"> <img class="rounded-circle oe_topbar_avatar" t-att-src="_s + '/web/static/src/img/user_menu_avatar.png'" alt="Avatar"/> <span class="oe_topbar_name"/> </a> @@ -1516,7 +1516,7 @@ <t t-name="SwitchCompanyMenu"> <li class="o_switch_company_menu"> - <a class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span t-attf-class="#{widget.isMobile ? 'fa fa-building-o' : 'oe_topbar_name'}"/> </a> <div class="dropdown-menu dropdown-menu-right" role="menu"/> diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml index b91b8844752..220395fef02 100644 --- a/addons/web/static/src/xml/kanban.xml +++ b/addons/web/static/src/xml/kanban.xml @@ -24,8 +24,8 @@ <a t-if="widget.deletable and widget.id" role="menuitem" class="dropdown-item o_column_delete" href="#">Delete</a> </t> <t t-if="widget.has_active_field and widget.archivable"> - <a href="#" class="dropdown-item o_column_archive_records">Archive All</a> - <a href="#" class="dropdown-item o_column_unarchive_records">Unarchive All</a> + <a role="menuitem" href="#" class="dropdown-item o_column_archive_records">Archive All</a> + <a role="menuitem" href="#" class="dropdown-item o_column_unarchive_records">Unarchive All</a> </t> </div> </span> diff --git a/addons/web/static/src/xml/menu.xml b/addons/web/static/src/xml/menu.xml index 4cf905674d7..3838cc6c226 100644 --- a/addons/web/static/src/xml/menu.xml +++ b/addons/web/static/src/xml/menu.xml @@ -97,7 +97,7 @@ <t t-extend="UserMenu.Actions"> <t t-jquery=".dropdown-divider" t-operation="before"> - <a href="#" data-menu="shortcuts" class="dropdown-item d-none d-md-inline-block">Shortcuts</a> + <a role="menuitem" href="#" data-menu="shortcuts" class="dropdown-item d-none d-md-inline-block">Shortcuts</a> </t> </t> diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index cf7df5376c3..990c166a419 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -3434,6 +3434,79 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + QUnit.test('fullscreen on action change: back to a "current" action', function (assert) { + assert.expect(3); + + this.actions[0].target = 'fullscreen'; + this.archs['partner,false,form'] = '<form>' + + '<button name="1" type="action" class="oe_stat_button" />' + + '</form>'; + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + intercepts: { + toggle_fullscreen: function (ev) { + var fullscreen = ev.data.fullscreen; + + switch (toggleFullscreenCalls) { + case 0: + assert.strictEqual(fullscreen, false); + break; + case 1: + assert.strictEqual(fullscreen, true); + break; + case 2: + assert.strictEqual(fullscreen, false); + break; + } + }, + }, + + }); + + var toggleFullscreenCalls = 0; + actionManager.doAction(6); + + toggleFullscreenCalls = 1; + actionManager.$('button[name=1]').click(); + + toggleFullscreenCalls = 2; + $('.breadcrumb li a:first').click(); + + actionManager.destroy(); + }); + + QUnit.test('fullscreen on action change: all "fullscreen" actions', function (assert) { + assert.expect(3); + + this.actions[5].target = 'fullscreen'; + this.archs['partner,false,form'] = '<form>' + + '<button name="1" type="action" class="oe_stat_button" />' + + '</form>'; + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + intercepts: { + toggle_fullscreen: function (ev) { + var fullscreen = ev.data.fullscreen; + assert.strictEqual(fullscreen, true); + }, + }, + }); + + actionManager.doAction(6); + + actionManager.$('button[name=1]').click(); + + $('.breadcrumb li a:first').click(); + + actionManager.destroy(); + }); + QUnit.module('"ir.actions.act_window_close" actions'); QUnit.test('close the currently opened dialog', function (assert) { diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 6c3493828ad..15999ca952e 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -228,6 +228,90 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('widget many2many_checkboxes in a subview', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<notebook>' + + '<page string="Turtles">' + + '<field name="turtles" mode="tree">' + + '<tree>' + + '<field name="id"/>' + + '</tree>' + + '</field>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + archs: { + 'turtle,false,form': '<form>' + + '<field name="partner_ids" widget="many2many_checkboxes"/>' + + '</form>', + }, + res_id: 1, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_data_cell').click(); + // edit the partner_ids field by (un)checking boxes on the widget + var $firstCheckbox = $('.modal .custom-control-input').first(); + $firstCheckbox.click(); + assert.ok($firstCheckbox.prop('checked'), "the checkbox should be ticked"); + var $secondCheckbox = $('.modal .custom-control-input').eq(1); + $secondCheckbox.click(); + assert.notOk($secondCheckbox.prop('checked'), "the checkbox should be unticked"); + form.destroy(); + }); + + QUnit.test('delete a record while adding another one in a multipage', function (assert) { + // in a many2one with at least 2 pages, add a new line. Delete the line above it. + // (the onchange makes it so that the virtualID is inserted in the middle of the currentResIDs.) + // it should load the next line to display it on the page. + assert.expect(2); + + this.data.partner.records[0].turtles = [2, 3]; + this.data.partner.onchanges.turtles = function (obj) { + obj.turtles = [[5]].concat(obj.turtles); + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="turtles">' + + '<tree editable="bottom" limit="1" decoration-muted="turtle_bar == False">' + + '<field name="turtle_foo"/>' + + '<field name="turtle_bar"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + form.$buttons.find('.o_form_button_edit').click(); + // add a line (virtual record) + form.$('.o_field_x2many_list_row_add a').click(); + form.$('.o_input').val('pi').trigger('input'); + // delete the line above it + form.$('.o_list_record_remove').first().click(); + // the next line should be displayed below the newly added one + assert.strictEqual(form.$('.o_data_row').length, 2, "should have 2 records"); + assert.strictEqual(form.$('.o_data_row .o_data_cell:first-child').text(), 'pikawa', + "should display the correct records on page 1"); + + form.destroy(); + }); + QUnit.test('one2many, onchange, edition and multipage...', function (assert) { assert.expect(7); @@ -252,7 +336,7 @@ QUnit.module('relational_fields', { '</form>', res_id: 1, mockRPC: function (route, args) { - assert.step(args.method + ' ' + args.model) + assert.step(args.method + ' ' + args.model); return this._super(route, args); }, viewOptions: { @@ -362,6 +446,39 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('many2many read, field context is properly sent', function (assert) { + assert.expect(4); + + this.data.partner.fields.timmy.context = {hello: 'world'}; + this.data.partner.records[0].timmy = [12]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="timmy" widget="many2many_tags"/>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'partner_type') { + assert.step(args.kwargs.context.hello); + } + return this._super.apply(this, arguments); + }, + }); + + assert.verifySteps(['world']); + + form.$buttons.find('.o_form_button_edit').click(); + var $m2mInput = form.$('.o_field_many2manytags input'); + $m2mInput.click(); + $m2mInput.autocomplete('widget').find('li:first()').click(); + assert.verifySteps(['world', 'world']); + + form.destroy(); + }); + QUnit.module('FieldStatus'); QUnit.test('static statusbar widget on many2one field', function (assert) { @@ -1323,6 +1440,53 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('widget many2many_tags in one2many with display_name', function (assert) { + assert.expect(4); + this.data.turtle.records[0].partner_ids = [2]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="turtles">' + + '<tree>' + + '<field name="partner_ids" widget="many2many_tags"/>' + // will use display_name + '</tree>' + + '<form>' + + '<sheet>' + + '<field name="partner_ids"/>' + + '</sheet>' + + '</form>' + + '</field>' + + '</sheet>' + + '</form>', + archs: { + 'partner,false,list': '<tree><field name="foo"/></tree>', + }, + res_id: 1, + }); + + assert.strictEqual(form.$('.o_field_one2many[name="turtles"] .o_list_view .o_field_many2manytags[name="partner_ids"]').text().replace(/\s/g, ''), + "secondrecordaaa", "the tags should be correctly rendered"); + + // open the x2m form view + form.$('.o_field_one2many[name="turtles"] .o_list_view td.o_data_cell:first').click(); + assert.strictEqual($('.modal .o_form_view .o_field_many2many[name="partner_ids"] .o_list_view .o_data_cell').text(), + "blipMy little Foo Value", "the list view should be correctly rendered with foo"); + + $('.modal button.o_form_button_cancel').click(); + assert.strictEqual(form.$('.o_field_one2many[name="turtles"] .o_list_view .o_field_many2manytags[name="partner_ids"]').text().replace(/\s/g, ''), + "secondrecordaaa", "the tags should still be correctly rendered"); + + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_field_one2many[name="turtles"] .o_list_view .o_field_many2manytags[name="partner_ids"]').text().replace(/\s/g, ''), + "secondrecordaaa", "the tags should still be correctly rendered"); + + form.destroy(); + }); + QUnit.test('widget many2many_tags: tags title attribute', function (assert) { assert.expect(1); this.data.turtle.records[0].partner_ids = [2]; diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js index b89f5a8eb5f..dcf9eab6e15 100644 --- a/addons/web/static/tests/helpers/test_utils_dom.js +++ b/addons/web/static/tests/helpers/test_utils_dom.js @@ -17,6 +17,10 @@ odoo.define('web.test_utils_dom', function () { * events, but it is enough to help test drag and drop operations with jqueryUI * sortable. * + * @todo: remove the withTrailingClick option when the jquery update branch is + * merged. This is not the default as of now, because handlers are triggered + * synchronously, which is not the same as the 'reality'. + * * @param {jqueryElement} $el * @param {jqueryElement} $to * @param {Object} [options] @@ -27,9 +31,13 @@ odoo.define('web.test_utils_dom', function () { * @param {boolean} [options.continueMove=false] whether to trigger the * mousedown action (will only work after another call of this function with * without this option) + * @param {boolean} [options.withTrailingClick=false] if true, this utility + * function will also trigger a click on the target after the mouseup event + * (this is actually what happens when a drag and drop operation is done) */ function dragAndDrop($el, $to, options) { - var position = (options && options.position) || 'center'; + options = options || {}; + var position = options.position || 'center'; var elementCenter = $el.offset(); var toOffset = $to.offset(); @@ -58,7 +66,7 @@ function dragAndDrop($el, $to, options) { toOffset.top += bound.top; } $el.trigger($.Event("mouseenter")); - if (!(options && options.continueMove)) { + if (!(options.continueMove)) { elementCenter.left += $el.outerWidth() / 2; elementCenter.top += $el.outerHeight() / 2; @@ -75,12 +83,15 @@ function dragAndDrop($el, $to, options) { pageY: toOffset.top })); - if (!(options && options.disableDrop)) { + if (!options.disableDrop) { $el.trigger($.Event("mouseup", { which: 1, pageX: toOffset.left, pageY: toOffset.top })); + if (options.withTrailingClick) { + $el.click(); + } } else { // It's impossible to drag another element when one is already // being dragged. So it's necessary to finish the drop when the test is diff --git a/addons/web/static/tests/tools/debug_manager_tests.js b/addons/web/static/tests/tools/debug_manager_tests.js index fde90105bfb..fb454aae0d1 100644 --- a/addons/web/static/tests/tools/debug_manager_tests.js +++ b/addons/web/static/tests/tools/debug_manager_tests.js @@ -46,6 +46,10 @@ QUnit.module('DebugManager', {}, function () { intercepts: { do_action: function (event) { assert.deepEqual(event.data.action, { + context: { + default_res_model: "test.model", + default_res_id: 5, + }, domain: [["res_model", "=", "test.model"],["res_id", "=", 5]], name: "Manage Attachments", res_model: "ir.attachment", diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index 3f4c6376d5b..264a00cc52a 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -557,6 +557,98 @@ QUnit.module('Views', { $view.remove(); }); + QUnit.test('default week start (US)', function (assert) { + // if not given any option, default week start is on Sunday + assert.expect(1); + var done = assert.async(); + + createAsyncView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initialDate, + }, + }).then(function (calendar) { + assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Sun 12/11", + "The first day of the week should be Sunday"); + calendar.destroy(); + done(); + }); + }); + + QUnit.test('European week start', function (assert) { + // the week start depends on the locale + assert.expect(1); + var done = assert.async(); + + createAsyncView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initialDate, + }, + translateParameters: { + week_start: 1, + }, + }).then(function (calendar) { + assert.strictEqual(calendar.$('.fc-day-header').first().text(), "Mon 12/12", + "The first day of the week should be Monday"); + calendar.destroy(); + done(); + }); + }); + + QUnit.test('week numbering', function (assert) { + // week number depends on the week start, which depends on the locale + // the calendar library uses numbers [0 .. 6], while Odoo uses [1 .. 7] + // so if the modulo is not done, the week number is incorrect + assert.expect(1); + var done = assert.async(); + + createAsyncView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week">'+ + '</calendar>', + archs: archs, + + viewOptions: { + initialDate: initialDate, + }, + translateParameters: { + week_start: 7, + }, + }).then(function (calendar) { + assert.strictEqual(calendar.$('.fc-week-number').text(), "W51", + "We should be on the 51st week"); + calendar.destroy(); + done(); + }); + }); + QUnit.test('create event with timezone in week mode with formViewDialog European locale', function (assert) { assert.expect(8); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index a120a6a7547..e1cb3277dba 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -17,6 +17,7 @@ var Widget = require('web.Widget'); var _t = core._t; var createView = testUtils.createView; var createAsyncView = testUtils.createAsyncView; +var createActionManager = testUtils.createActionManager; QUnit.module('Views', { beforeEach: function () { @@ -299,7 +300,78 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('Form and subview with _view_ref contexts', function (assert) { + assert.expect(2); + + this.data.product.fields.partner_type_ids = {string: "one2many field", type: "one2many", relation: "partner_type"}, + this.data.product.records = [{id: 1, name: 'Tromblon', partner_type_ids: [12,14]}]; + this.data.partner.records[0].product_id = 1; + + var actionManager = createActionManager({ + data: this.data, + archs: { + 'product,false,form': '<form>'+ + '<field name="name"/>'+ + '<field name="partner_type_ids" context="{\'tree_view_ref\': \'some_other_tree_view\'}"/>' + + '</form>', + 'partner_type,false,list': '<tree>'+ + '<field name="color"/>'+ + '</tree>', + 'product,false,search': '<search></search>', + }, + mockRPC: function (route, args) { + if (args.method === 'load_views') { + var context = args.kwargs.context; + if (args.model === 'product') { + assert.deepEqual(context, {tree_view_ref: 'some_tree_view'}, + 'The correct _view_ref should have been sent to the server, first time'); + } + if (args.model === 'partner_type') { + assert.deepEqual(context, {tree_view_ref: 'some_other_tree_view'}, + 'The correct _view_ref should have been sent to the server for the subview'); + } + } + return this._super.apply(this, arguments); + }, + }); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="name"/>' + + '<field name="product_id" context="{\'tree_view_ref\': \'some_tree_view\'}"/>' + + '</form>', + res_id: 1, + + mockRPC: function(route, args) { + if (args.method === 'get_formview_action') { + return $.when({ + res_id: 1, + type: 'ir.actions.act_window', + target: 'current', + res_model: args.model, + context: args.kwargs.context, + 'view_type': 'form', + 'view_mode': 'form', + 'views': [[false, 'form']], + }); + } + return this._super(route, args); + }, + + interceptsPropagate: { + do_action: function (ev) { + actionManager.doAction(ev.data.action); + }, + }, + }); + form.$('.o_field_widget[name="product_id"]').click(); + form.destroy(); + actionManager.destroy(); + }); QUnit.test('invisible fields are properly hidden', function (assert) { assert.expect(4); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index c8ac8890da1..6702a9bee37 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -2426,6 +2426,12 @@ QUnit.module('Views', { QUnit.test('can drag and drop a record from one column to the next', function (assert) { assert.expect(9); + // @todo: remove this resequenceDef whenever the jquery upgrade branch + // is merged. This is currently necessary to simulate the reality: we + // need the click handlers to be executed after the end of the drag and + // drop operation, not before. + var resequenceDef = $.Deferred(); + var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test this.data.partner.fields.sequence = {type: 'number', string: "Sequence"}; var kanban = createView({ @@ -2435,7 +2441,7 @@ QUnit.module('Views', { arch: '<kanban class="o_kanban_test" on_create="quick_create">' + '<field name="product_id"/>' + '<templates><t t-name="kanban-box">' + - '<div><field name="foo"/>' + + '<div class="oe_kanban_global_click"><field name="foo"/>' + '<t t-if="widget.editable"><span class="thisiseditable">edit</span></t>' + '</div>' + '</t></templates>' + @@ -2444,7 +2450,7 @@ QUnit.module('Views', { mockRPC: function (route, args) { if (route === '/web/dataset/resequence') { assert.ok(true, "should call resequence"); - return $.when(true); + return resequenceDef.then(_.constant(true)); } return this._super(route, args); }, @@ -2457,13 +2463,15 @@ QUnit.module('Views', { var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); var $group = kanban.$('.o_kanban_group:nth-child(2)'); envIDs = [3, 2, 4, 1]; // first record of first column moved to the bottom of second column - testUtils.dom.dragAndDrop($record, $group); + testUtils.dom.dragAndDrop($record, $group, {withTrailingClick: true}); + resequenceDef.resolve(); assert.containsOnce(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record'); assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 3); assert.containsN(kanban, '.thisiseditable', 4); assert.deepEqual(kanban.exportState().resIds, envIDs); + resequenceDef.resolve(); kanban.destroy(); }); diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index b2d71523e74..930fadbbb7b 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -1432,7 +1432,7 @@ QUnit.module('Views', { actionManager.destroy(); }); - QUnit.test('export data in excel with comparison', function (assert) { + QUnit.test('export data in excel with comparison', function (assert) { assert.expect(10); this.data.partner.records[0].date = '2016-12-15'; @@ -1587,5 +1587,147 @@ QUnit.module('Views', { unpatchDate(); actionManager.destroy(); }); + + QUnit.module('Sort in comparison mode', { + beforeEach: function () { + this.data.partner.records[0].date = '2016-12-15'; + this.data.partner.records[1].date = '2016-12-17'; + this.data.partner.records[2].date = '2016-11-22'; + this.data.partner.records[3].date = '2016-11-03'; + + this.data.partner.fields.company_type = {string: "Company Type", type: "selection", selection: [["company", "Company"], ["individual", "individual"]], searchable: true, store: true, sortable: true}; + + this.data.partner.records[0].company_type = 'company'; + this.data.partner.records[1].company_type = 'individual'; + this.data.partner.records[2].company_type = 'company'; + this.data.partner.records[3].company_type = 'individual'; + + + this.unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + + this.actualResult = function () { + var actualResult = $('.o_pivot .o_pivot_cell_value div').map(function() { + return $( this ).text(); + }).get().join(); + return actualResult; + }; + + // create an action manager to test the interactions with the search view + this.actionManager = createActionManager({ + data: this.data, + archs: { + 'partner,false,pivot': '<pivot>' + + '<field name="date" interval="day" type="row"/>' + + '<field name="company_type" type="col"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + 'partner,false,search': '<search></search>', + }, + debug: 1, + }); + + this.actionManager.doAction({ + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'pivot']], + flags: { + pivot: { + additionalMeasures: ['product_id'], + } + } + }); + + // open time range menu + testUtils.dom.click($('.o_control_panel .o_time_range_menu_button')); + // select 'Today' as range + testUtils.fields.editInput($('.o_control_panel .o_time_range_selector'), 'this_month'); + // check checkbox 'Compare To' + testUtils.dom.click($('.o_control_panel .o_time_range_menu .o_comparison_checkbox')); + // Click on 'Apply' button + testUtils.dom.click($('.o_control_panel .o_time_range_menu .o_apply_range')); + // We are in comparison mode + }, + afterEach: function () { + this.unpatchDate(); + this.actionManager.destroy(); + }, + }, function () { + QUnit.test('when clicking on measure, sort by "This Month" (period of interest)', function (assert) { + assert.expect(1); + + // click on 'Foo' in column Total/Company + testUtils.dom.click($('.o_pivot_measure_row').eq(0)); + var results = [ + "12", "17", "-29.41%", "1", "2", "-50%", "13", "19", "-31.58%", + "12", "0", "100%", "12", "0" , "100%", + "1", "0", "100%", "1" , "0", "100%", + "0", "17", "-100%", "0", "17", "-100%", + "0", "2", "-100%", "0" , "2" , "-100%" + ]; + assert.strictEqual(this.actualResult(), results.join()); + }); + + QUnit.test( + 'click on a period of interest header sort according to the appropriate column ' + + 'first in ascending order then in descending order', + function (assert) { + assert.expect(2); + + // click on 'This Month' in column Total/Individual/Foo + testUtils.dom.click($('.o_pivot_measure_row').eq(6)); + var results = [ + "12", "17", "-29.41%", "1", "2", "-50%", "13", "19", "-31.58%", + "1", "0", "100%", "1" , "0", "100%", + "12", "0", "100%", "12", "0" , "100%", + "0", "17", "-100%", "0", "17", "-100%", + "0", "2", "-100%", "0" , "2" , "-100%" + ]; + var actualResult = $('.o_pivot .o_pivot_cell_value div').text(); + assert.strictEqual(actualResult, results.join('')); + + // click once again on 'This Month' in column Total/Individual/Foo + testUtils.dom.click($('.o_pivot_measure_row').eq(6)); + results = [ + "12", "17", "-29.41%", "1", "2", "-50%", "13", "19", "-31.58%", + "12", "0", "100%", "12", "0" , "100%", + "0", "17", "-100%", "0", "17", "-100%", + "0", "2", "-100%", "0" , "2" , "-100%", + "1", "0", "100%", "1" , "0", "100%" + ]; + assert.strictEqual(this.actualResult(), results.join()); + }); + + QUnit.test('click on a period of comparison header sort according to appropriate column', + function (assert) { + assert.expect(1); + + // click on 'Previous Period' in column Total/Individual/Foo + testUtils.dom.click($('.o_pivot_measure_row').eq(7)); + var results = [ + "12", "17", "-29.41%", "1", "2", "-50%", "13", "19", "-31.58%", + "0", "2", "-100%", "0" , "2" , "-100%", + "12", "0", "100%", "12", "0" , "100%", + "1", "0", "100%", "1" , "0", "100%", + "0", "17", "-100%", "0", "17", "-100%" + ]; + assert.strictEqual(this.actualResult(), results.join()); + }); + + QUnit.test('click on a variation header sort according to appropriate column', + function (assert) { + assert.expect(1); + + // click on 'Variation' in column Total/Individual/Foo + testUtils.dom.click($('.o_pivot_measure_row').eq(11)); + var results = [ + "12", "17", "-29.41%", "1", "2", "-50%", "13", "19", "-31.58%", + "12", "0", "100%", "12", "0" , "100%", + "1", "0", "100%", "1" , "0", "100%", + "0", "17", "-100%", "0", "17", "-100%", + "0", "2", "-100%", "0" , "2" , "-100%" + ]; + assert.strictEqual(this.actualResult(), results.join()); + }); + }); }); }); diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml index b33f89cd929..c9f78d20394 100644 --- a/addons/web/views/report_templates.xml +++ b/addons/web/views/report_templates.xml @@ -103,9 +103,9 @@ <t t-call-assets="web.report_assets_pdf" t-js="false"/> <t t-call-assets="web.report_assets_common" t-js="false"/> <t t-call-assets="web.report_assets_pdf" t-css="false"/> - <t t-call-assets="web.report_assets_common" t-css="false"/> <meta charset="utf-8"/> - <t t-if="subst is True"> + <t t-set="subst_needed" t-value="subst is True"/> + <t t-if="subst_needed"> <script> function subst() { var vars = {}; @@ -138,7 +138,7 @@ </script> </t> </head> - <body class="container" onload="subst()"> + <body class="container" t-att-onload="subst_needed and 'subst()'"> <t t-raw="body"/> </body> </html> diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py index f0c295787a2..e281d9f9ecc 100644 --- a/addons/web_editor/models/ir_ui_view.py +++ b/addons/web_editor/models/ir_ui_view.py @@ -199,12 +199,21 @@ class IrUiView(models.Model): self.write({'arch': self._pretty_arch(new_arch)}) @api.model + def _view_get_inherited_children(self, view, options): + extensions = view.inherit_children_ids + if not options: + # only active children + extensions = extensions.filtered(lambda view: view.active) + return extensions + + @api.model def _view_obj(self, view_id): if isinstance(view_id, str): return self.search([('key', '=', view_id)], limit=1) or self.env.ref(view_id) elif isinstance(view_id, int): return self.browse(view_id) - # assume it's already a view object (WTF?) + # It can already be a view object when called by '_views_get()' that is calling '_view_obj' + # for it's inherit_children_ids, passing them directly as object record. return view_id # Returns all views (called and inherited) related to a view @@ -223,7 +232,7 @@ class IrUiView(models.Model): view = self._view_obj(view_id) except ValueError: _logger.warning("Could not find view object with view_id '%s'", view_id) - return [] + return self.env['ir.ui.view'] while root and view.inherit_id: view = view.inherit_id @@ -239,13 +248,10 @@ class IrUiView(models.Model): called_view = self._view_obj(child.get('t-call', child.get('t-call-assets'))) except ValueError: continue - if called_view not in views_to_return: + if called_view and called_view not in views_to_return: views_to_return += self._views_get(called_view, options=options, bundles=bundles) - extensions = view.inherit_children_ids - if not options: - # only active children - extensions = view.inherit_children_ids.filtered(lambda view: view.active) + extensions = self._view_get_inherited_children(view, options) # Keep options in a deterministic order regardless of their applicability for extension in extensions.sorted(key=lambda v: v.id): diff --git a/addons/web_editor/static/src/js/editor/transcoder.js b/addons/web_editor/static/src/js/editor/transcoder.js index fb161a3887e..85903f6b4eb 100644 --- a/addons/web_editor/static/src/js/editor/transcoder.js +++ b/addons/web_editor/static/src/js/editor/transcoder.js @@ -251,6 +251,9 @@ function applyOverDescendants(node, func) { if (node.nodeName === 'A' && $node.hasClass('btn') && !$node.children().length && $(node).parents('.o_outlook_hack').length) { node = $(node).parents('.o_outlook_hack')[0]; } + else if (node.nodeName === 'IMG' && $node.parent('p').hasClass('o_outlook_hack')) { + node = $node.parent()[0]; + } node = node.nextSibling; } } @@ -307,6 +310,9 @@ function classToStyle($editable) { $(node).remove(); } } + else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) { + $target.wrap('<p class="o_outlook_hack" style="text-align:center;margin:0"/>'); + } }); } @@ -318,8 +324,8 @@ function classToStyle($editable) { */ function styleToClass($editable) { // Outlook revert - $editable.find('table.o_outlook_hack').each(function () { - $(this).after($('a', this)); + $editable.find('.o_outlook_hack').each(function () { + $(this).after($('a,img', this)); }).remove(); getMatchedCSSRules($editable[0]); diff --git a/addons/web_tour/static/src/js/running_tour_action_helper.js b/addons/web_tour/static/src/js/running_tour_action_helper.js index de4e49e40df..1c4df6c27db 100644 --- a/addons/web_tour/static/src/js/running_tour_action_helper.js +++ b/addons/web_tour/static/src/js/running_tour_action_helper.js @@ -1,3 +1,4 @@ + odoo.define('web_tour.RunningTourActionHelper', function (require) { "use strict"; @@ -21,6 +22,9 @@ var RunningTourActionHelper = core.Class.extend({ tripleclick: function (element) { this._click(this._get_action_values(element), 3); }, + clicknoleave: function (element) { + this._click(this._get_action_values(element), 1, false); + }, text: function (text, element) { this._text(this._get_action_values(element), text); }, @@ -50,7 +54,7 @@ var RunningTourActionHelper = core.Class.extend({ consume_event: consume_event, }; }, - _click: function (values, nb) { + _click: function (values, nb, leave) { trigger_mouse_event(values.$element, "mouseover"); values.$element.trigger("mouseenter"); for (var i = 1 ; i <= (nb || 1) ; i++) { @@ -61,8 +65,10 @@ var RunningTourActionHelper = core.Class.extend({ trigger_mouse_event(values.$element, "dblclick"); } } - trigger_mouse_event(values.$element, "mouseout"); - values.$element.trigger("mouseleave"); + if (leave !== false) { + trigger_mouse_event(values.$element, "mouseout"); + values.$element.trigger("mouseleave"); + } function trigger_mouse_event($element, type, count) { var e = document.createEvent("MouseEvents"); diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 50f1373665a..de83a7cb8cf 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -283,6 +283,7 @@ class Website(Home): @http.route("/website/get_switchable_related_views", type="json", auth="user", website=True) def get_switchable_related_views(self, key): views = request.env["ir.ui.view"].get_related_views(key, bundles=False).filtered(lambda v: v.customize_show) + views = views.sorted(key=lambda v: (v.inherit_id.id, v.name)) return views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']) @http.route('/website/reset_templates', type='http', auth='user', methods=['POST'], website=True) diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index 3a54d5ce29c..9701610dde3 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -549,6 +549,7 @@ <data> <record id="group_multi_website" model="res.groups"> <field name="name">Multi-website</field> + <field name="category_id" ref="base.module_category_hidden"/> </record> </data> </odoo> diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index 25becf9749c..a649a9acc81 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -74,7 +74,9 @@ class Http(models.AbstractModel): # Force website with query string paramater, typically set from website selector in frontend navbar force_website_id = request.httprequest.args.get('fw') - if force_website_id and request.session.get('force_website_id') != force_website_id: + if (force_website_id and request.session.get('force_website_id') != force_website_id and + request.env.user.has_group('website.group_multi_website') and + request.env.user.has_group('website.group_website_publisher')): request.env['website']._force_website(request.httprequest.args.get('fw')) context = {} diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index f45f602fa8b..1a98713326d 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -125,64 +125,50 @@ class View(models.Model): # get_related_views can be called through website=False routes # (e.g. /web_editor/get_assets_editor_resources), so website # dispatch_parameters may not be added. Manually set - # website_id. + # website_id. (It will then always fallback on a website, this + # method should never be called in a generic context, even for + # tests) self = self.with_context(website_id=self.env['website'].get_current_website().id) - views = super(View, self).get_related_views(key, bundles=bundles) + return super(View, self).get_related_views(key, bundles=bundles) + + def filter_duplicate(self): + """ Filter current recordset only keeping the most suitable view per distinct key. + Every non-accessible view will be removed from the set: + * In non website context, every view with a website will be removed + * In a website context, every view from another website + """ current_website_id = self._context.get('website_id') most_specific_views = self.env['ir.ui.view'] - if not current_website_id: - return views + return self.filtered(lambda view: not view.website_id) - for view in views: + for view in self: if view.website_id and view.website_id.id == current_website_id: most_specific_views |= view - elif not view.website_id and not any(view.key == view2.key and view2.website_id and view2.website_id.id == current_website_id for view2 in views): + elif not view.website_id and not any(view.key == view2.key and view2.website_id and view2.website_id.id == current_website_id for view2 in self): most_specific_views |= view return most_specific_views - @api.multi - def _sort_suitability_key(self): - """ Key function to sort views by descending suitability - Suitability of a view is defined as follow: - * if the view and request website_id are matched - * then if the view has no set website - """ - self.ensure_one() - context_website_id = self.env.context.get('website_id', 1) - website_id = self.website_id.id or 0 - different_website = context_website_id != website_id - return (different_website, website_id) - - def filter_duplicate(self): - """ Filter current recordset only keeping the most suitable view per distinct key """ - view_with_key = self.filtered('key') - view_without_key = self - view_with_key - filtered = self.env['ir.ui.view'] - for dummy, group in groupby(view_with_key.sorted('key'), key=lambda record: record.key): - filtered += sorted(group, key=lambda record: record._sort_suitability_key())[0] - return (filtered + view_without_key).sorted(key=lambda view: (view.priority, view.id)) + @api.model + def _view_get_inherited_children(self, view, options): + extensions = super(View, self)._view_get_inherited_children(view, options) + return extensions.filter_duplicate() @api.model def _view_obj(self, view_id): - if isinstance(view_id, str): - if 'website_id' in self._context: - domain = [('key', '=', view_id)] + self.env['website'].website_domain(self._context.get('website_id')) - order = 'website_id' - else: - domain = [('key', '=', view_id)] - order = self._order - views = self.search(domain, order=order) - if views: - return views.filter_duplicate() - else: - return self.env.ref(view_id) - elif isinstance(view_id, int): - return self.browse(view_id) - - # assume it's already a view object (WTF?) - return view_id + ''' Given an xml_id or a view_id, return the corresponding view record. + In case of website context, return the most specific one. + :param view_id: either a string xml_id or an integer view_id + :return: The view record or empty recordset + ''' + if isinstance(view_id, str) or isinstance(view_id, int): + return self.env['website'].viewref(view_id) + else: + # It can already be a view object when called by '_views_get()' that is calling '_view_obj' + # for it's inherit_children_ids, passing them directly as object record. (Note that it might + # be a view_id from another website but it will be filtered in 'get_related_views()') + return view_id if view_id._name == 'ir.ui.view' else self.env['ir.ui.view'] @api.model def _get_inheriting_views_arch_website(self, view_id): diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 0a99b8ef8eb..82709c4663d 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -173,7 +173,6 @@ class Website(models.Model): default_menu = self.env.ref('website.main_menu') self.copy_menu_hierarchy(default_menu) - @api.model def copy_menu_hierarchy(self, top_menu): def copy_menu(menu, t_menu): new_menu = menu.copy({ @@ -281,11 +280,6 @@ class Website(models.Model): key_copy = string + (inc and "-%s" % inc or "") return key_copy - def key_to_view_id(self, view_id): - return self.env['ir.ui.view'].search([ - ('id', '=', view_id), ('type', '=', 'qweb') - ] + self.env['website'].website_domain(self._context.get('website_id'))) - @api.model def page_search_dependencies(self, page_id=False): """ Search dependencies just for information. It will not catch 100% @@ -506,6 +500,42 @@ class Website(models.Model): return request.env.user.id == request.website.user_id.id @api.model + def viewref(self, view_id, raise_if_not_found=True): + ''' Given an xml_id or a view_id, return the corresponding view record. + In case of website context, return the most specific one. + :param view_id: either a string xml_id or an integer view_id + :param raise_if_not_found: should the method raise an error if no view found + :return: The view record or empty recordset + ''' + View = self.env['ir.ui.view'] + view = None + if isinstance(view_id, str): + if 'website_id' in self._context: + domain = [('key', '=', view_id)] + self.env['website'].website_domain(self._context.get('website_id')) + order = 'website_id' + else: + domain = [('key', '=', view_id)] + order = View._order + views = View.with_context(active_test=False).search(domain, order=order) + if views: + view = views.filter_duplicate() + else: + view = self.env.ref(view_id) + # self.env.ref might return something else than an ir.ui.view (eg: a theme.ir.ui.view) + if view._name != 'ir.ui.view': + view = None + elif isinstance(view_id, int): + view = View.browse(view_id) + else: + raise ValueError('Expecting a string or an integer, not a %s.' % (type(view_id))) + + if view: + return view + if raise_if_not_found: + raise ValueError('No record found for unique ID %s. It may have been deleted.' % (view_id)) + return None + + @api.model def get_template(self, template): View = self.env['ir.ui.view'] if isinstance(template, int): diff --git a/addons/website/static/src/js/menu/seo.js b/addons/website/static/src/js/menu/seo.js index 899d3b8746d..a0a2d0f6b60 100644 --- a/addons/website/static/src/js/menu/seo.js +++ b/addons/website/static/src/js/menu/seo.js @@ -764,7 +764,7 @@ var SeoConfigurator = Dialog.extend({ if (!obj) { return $.Deferred().reject(); } else { - return rpc.query({ + return this._rpc({ model: obj.model, method: 'write', args: [[obj.id], data], diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss index eb350d658ce..e1c7bff9750 100644 --- a/addons/website/static/src/scss/website.scss +++ b/addons/website/static/src/scss/website.scss @@ -1070,3 +1070,8 @@ table.table_desc tr td { @each $media, $color in $o-social-colors { @include text-emphasis-variant(".text-#{$media}", $color); } + +// TODO: Will be handled properly in master/saas-12.2, temp fix for website_event.registration_attendee_details +.modal-footer > .float-left { + margin-right: auto; +} diff --git a/addons/website/static/src/xml/website.seo.xml b/addons/website/static/src/xml/website.seo.xml index 7dc54f7cfc5..bf2de26d4ce 100644 --- a/addons/website/static/src/xml/website.seo.xml +++ b/addons/website/static/src/xml/website.seo.xml @@ -90,7 +90,7 @@ <div class="form-inline" role="form"> <div class="input-group"> <input type="text" name="website_meta_keywords" id="website_meta_keywords" class="form-control" placeholder="Keyword" maxlength="30"/> - <span class="input-group-append"> + <span title="The language of the keyword and related keywords." class="input-group-append"> <select name="seo_page_language" id="language-box" class="btn form-control"/> </span> <span class="input-group-append"> diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py index 49a76cf78ab..132472de2c0 100644 --- a/addons/website/tests/test_views.py +++ b/addons/website/tests/test_views.py @@ -10,7 +10,7 @@ import unittest from lxml import etree as ET, html from lxml.html import builder as h -from odoo.tests import common +from odoo.tests import common, HttpCase def attrs(**kwargs): @@ -561,5 +561,235 @@ class TestCowViewSaving(common.TransactionCase): specific_view = View.with_context(website_id=1).get_view_id('website.main_view') generic_view_arch = View.browse(generic_view).with_context(load_all_views=True).read_combined(['arch'])['arch'] specific_view_arch = View.browse(specific_view).with_context(load_all_views=True, website_id=1).read_combined(['arch'])['arch'] - self.assertEqual(generic_view_arch == '<body>GENERIC<div>VIEW<span>C</span></div></body>', True) - self.assertEqual(specific_view_arch == '<body>SPECIFIC<div>VIEW<span>D</span></div></body>', True, "Writing on top level view hierarchy with a website in context should write on the view and clone it's inherited views") + self.assertEqual(generic_view_arch, '<body>GENERIC<div>VIEW<span>C</span></div></body>') + self.assertEqual(specific_view_arch, '<body>SPECIFIC<div>VIEW<span>D</span></div></body>', "Writing on top level view hierarchy with a website in context should write on the view and clone it's inherited views") + + def test_multi_website_view_obj_active(self): + ''' With the following structure: + * A generic active parent view + * A generic active child view, that is inactive on website 1 + The methods to retrieve views should return the specific inactive + child over the generic active one. + ''' + View = self.env['ir.ui.view'] + self.inherit_view.with_context(website_id=1).write({'active': False}) + + # Test _view_obj() return the inactive specific over active generic + inherit_view = View._view_obj(self.inherit_view.key) + self.assertEqual(inherit_view.active, True, "_view_obj should return the generic one") + inherit_view = View.with_context(website_id=1)._view_obj(self.inherit_view.key) + self.assertEqual(inherit_view.active, False, "_view_obj should return the specific one") + + # Test get_related_views() return the inactive specific over active generic + # Note that we cannot test get_related_views without a website in context as it will fallback on a website with get_current_website() + views = View.with_context(website_id=1).get_related_views(self.base_view.key) + self.assertEqual(views.mapped('active'), [True, False], "get_related_views should return the specific child") + + # Test filter_duplicate() return the inactive specific over active generic + view = View.with_context(active_test=False).search([('key', '=', self.inherit_view.key)]).filter_duplicate() + self.assertEqual(view.active, True, "filter_duplicate should return the generic one") + view = View.with_context(active_test=False, website_id=1).search([('key', '=', self.inherit_view.key)]).filter_duplicate() + self.assertEqual(view.active, False, "filter_duplicate should return the specific one") + + def test_get_related_views_tree(self): + View = self.env['ir.ui.view'] + + self.base_view.write({'name': 'B', 'key': 'B'}) + self.inherit_view.write({'name': 'I', 'key': 'I'}) + View.create({ + 'name': 'II', + 'mode': 'extension', + 'inherit_id': self.inherit_view.id, + 'arch': '<div position="inside">, sub ext</div>', + 'key': 'II', + }) + self.inherit_view.with_context(website_id=1).write({'name': 'Extension'}) # Trigger cow on hierarchy + View.create({ + 'name': 'II2', + 'mode': 'extension', + 'inherit_id': self.inherit_view.id, + 'arch': '<div position="inside">, sub sibling specific</div>', + 'key': 'II2', + }) + + # B + # / \ + # / \ + # I I' + # / \ | + # II II2 II' + + views = View.with_context(website_id=1).get_related_views('B') + self.assertEqual(views.mapped('key'), ['B', 'I', 'II'], "Should only return the specific tree") + + +class Crawler(HttpCase): + def setUp(self): + super(Crawler, self).setUp() + View = self.env['ir.ui.view'] + + self.base_view = View.create({ + 'name': 'Base', + 'type': 'qweb', + 'arch': '<div>base content</div>', + 'key': 'website.base_view', + }).with_context(load_all_views=True) + + self.inherit_view = View.create({ + 'name': 'Extension', + 'mode': 'extension', + 'inherit_id': self.base_view.id, + 'arch': '<div position="inside">, extended content</div>', + 'key': 'website.extension_view', + }) + + def test_get_switchable_related_views(self): + View = self.env['ir.ui.view'] + Website = self.env['website'] + + # Set up + website_1 = Website.create({'name': 'Website 1'}) # will have specific views + website_2 = Website.create({'name': 'Website 2'}) # will use generic views + + self.base_view.write({'name': 'Main Frontend Layout', 'key': '_portal.frontend_layout'}) + event_main_view = self.base_view.copy({ + 'name': 'Events', + 'key': '_website_event.index', + 'arch': '<t t-call="_website.layout"><div>Arch is not important in this test</div></t>', + }) + self.inherit_view.write({'name': 'Main layout', 'key': '_website.layout'}) + + self.inherit_view.copy({'name': 'Show Sign In', 'customize_show': True, 'key': '_portal.portal_show_sign_in'}) + view_logo = self.inherit_view.copy({ + 'name': 'Show Logo', + 'inherit_id': self.inherit_view.id, + 'customize_show': True, + 'key': '_website.layout_logo_show', + }) + view_logo.copy({'name': 'Affix Top Menu', 'key': '_website.affix_top_menu'}) + + event_child_view = self.inherit_view.copy({ + 'name': 'Filters', + 'customize_show': True, + 'inherit_id': event_main_view.id, + 'key': '_website_event.event_left_column', + 'priority': 30, + }) + view_photos = event_child_view.copy({'name': 'Photos', 'key': '_website_event.event_right_photos'}) + event_child_view.copy({'name': 'Quotes', 'key': '_website_event.event_right_quotes', 'priority': 30}) + + event_child_view.copy({'name': 'Filter by Category', 'inherit_id': event_child_view.id, 'key': '_website_event.event_category'}) + event_child_view.copy({'name': 'Filter by Country', 'inherit_id': event_child_view.id, 'key': '_website_event.event_location'}) + + # Customize + # | Main Frontend Layout + # | Show Sign In + # | Main Layout + # | Affix Top Menu + # | Show Logo + # | Events + # | Filters + # | Photos + # | Quotes + # | Filters + # | Filter By Category + # | Filter By Country + + self.authenticate("admin", "admin") + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + + # Simulate website 2 (that use only generic views) + url = base_url + '/website/force_website' + json = {'params': {'website_id': website_2.id}} + self.opener.post(url=url, json=json) + + # Test controller + url = base_url + '/website/get_switchable_related_views' + json = {'params': {'key': '_website_event.index'}} + response = self.opener.post(url=url, json=json) + res = response.json()['result'] + + self.assertEqual( + [v['name'] for v in res], + ['Show Sign In', 'Affix Top Menu', 'Show Logo', 'Filters', 'Photos', 'Quotes', 'Filter by Category', 'Filter by Country'], + "Sequence should not be taken into account for customize menu", + ) + self.assertEqual( + [v['inherit_id'][1] for v in res], + ['Main Frontend Layout', 'Main layout', 'Main layout', 'Events', 'Events', 'Events', 'Filters', 'Filters'], + "Sequence should not be taken into account for customize menu (Checking Customize headers)", + ) + + # Trigger COW + view_logo.with_context(website_id=website_1.id).write({'arch': '<div position="inside">, trigger COW, arch is not relevant in this test</div>'}) + # This would wrongly become: + + # Customize + # | Main Frontend Layout + # | Show Sign In + # | Main Layout + # | Affix Top Menu + # | Show Logo <==== Was above "Affix Top Menu" + # | Events + # | Filters + # | Photos + # | Quotes + # | Filters + # | Filter By Category + # | Filter By Country + + # Simulate website 1 (that has specific views) + url = base_url + '/website/force_website' + json = {'params': {'website_id': website_1.id}} + self.opener.post(url=url, json=json) + + # Test controller + url = base_url + '/website/get_switchable_related_views' + json = {'params': {'key': '_website_event.index'}} + response = self.opener.post(url=url, json=json) + res = response.json()['result'] + self.assertEqual( + [v['name'] for v in res], + ['Show Sign In', 'Affix Top Menu', 'Show Logo', 'Filters', 'Photos', 'Quotes', 'Filter by Category', 'Filter by Country'], + "multi-website COW should not impact customize views order (COW view will have a bigger ID and should not be last)", + ) + self.assertEqual( + [v['inherit_id'][1] for v in res], + ['Main Frontend Layout', 'Main layout', 'Main layout', 'Events', 'Events', 'Events', 'Filters', 'Filters'], + "multi-website COW should not impact customize views menu header position or split (COW view will have a bigger ID and should not be last)", + ) + + # Trigger COW + view_photos.with_context(website_id=website_1.id).write({'arch': '<div position="inside">, trigger COW, arch is not relevant in this test</div>'}) + # This would wrongly become: + + # Customize + # | Main Frontend Layout + # | Show Sign In + # | Main Layout + # | Affix Top Menu + # | Show Logo + # | Events + # | Filters + # | Quotes + # | Filters + # | Filter By Category + # | Filter By Country + # | Events <==== JS code creates a new Events header as the Event's children views are not one after the other anymore.. + # | Photos <==== .. since Photos got duplicated and now have a bigger ID that others + + # Test controller + url = base_url + '/website/get_switchable_related_views' + json = {'params': {'key': '_website_event.index'}} + response = self.opener.post(url=url, json=json) + res = response.json()['result'] + self.assertEqual( + [v['name'] for v in res], + ['Show Sign In', 'Affix Top Menu', 'Show Logo', 'Filters', 'Photos', 'Quotes', 'Filter by Category', 'Filter by Country'], + "multi-website COW should not impact customize views order (COW view will have a bigger ID and should not be last) (2)", + ) + self.assertEqual( + [v['inherit_id'][1] for v in res], + ['Main Frontend Layout', 'Main layout', 'Main layout', 'Events', 'Events', 'Events', 'Filters', 'Filters'], + "multi-website COW should not impact customize views menu header position or split (COW view will have a bigger ID and should not be last) (2)", + ) diff --git a/addons/website/views/website_navbar_templates.xml b/addons/website/views/website_navbar_templates.xml index 4056bb0542f..80eb4a2dfdd 100644 --- a/addons/website/views/website_navbar_templates.xml +++ b/addons/website/views/website_navbar_templates.xml @@ -90,7 +90,7 @@ </t> </div> </li> - <li groups="website.group_multi_website"> + <li groups="website.group_multi_website" t-if="multi_website_websites"> <a class="dropdown-toggle waves" data-toggle="dropdown" href="#"><t t-esc="multi_website_websites_current['name']"/></a> <div class="dropdown-menu" role="menu"> <t t-foreach="multi_website_websites" t-as="multi_website_website"> diff --git a/addons/website_blog/static/src/js/contentshare.js b/addons/website_blog/static/src/js/contentshare.js index cfc02470f7f..944ff5c9afe 100644 --- a/addons/website_blog/static/src/js/contentshare.js +++ b/addons/website_blog/static/src/js/contentshare.js @@ -34,7 +34,7 @@ }, commentEdition : function(){ var positionComment = ($('#comments').position()).top-50; - $(".o_website_chatter_form textarea").val('"' + selected_text + '" ').focus(); + $(".o_portal_chatter_composer_form textarea").val('"' + selected_text + '" ').focus(); $('html, body').stop().animate({ 'scrollTop': positionComment }, 500, 'swing', function () { diff --git a/addons/website_gengo/static/src/xml/website.gengo.xml b/addons/website_gengo/static/src/xml/website.gengo.xml index 4d43d5398f6..3b78cbc7a51 100644 --- a/addons/website_gengo/static/src/xml/website.gengo.xml +++ b/addons/website_gengo/static/src/xml/website.gengo.xml @@ -6,51 +6,27 @@ <a role="button" class="btn btn-danger d-none gengo_inprogress disabled" href="#"> <i class="fa fa-clock-o"></i> Translation in Progress</a> <a role="button" class="btn btn-link gengo_info" data-action="translation_gengo_info">Count Words</a> </t> -<t t-name="web_editor.TranslatorInfoDialog"> - <div role="dialog" class="modal fade" tabindex="-1"> - <div class="modal-dialog"> - <div class="modal-content"> - <header class="modal-header"> - <h2 class="modal-title">Translate this page</h2> - <button title="Close" aria-label="Close" type="button" class="close" data-dismiss="modal">×</button> - </header> - <main class="modal-body"> - <section> - <p>You are about to enter the translation mode.</p> - <p> - Here are the visuals used to help you translate efficiently: - <ul class="oe_translate_examples"> - <li style="background:#ffffb6;"> - Content to translate or you can post them to <b><a href="http://gengo.com/" >Gengo</a></b> for translation. - </li> - <li data-oe-translation-state="inprogress"> - Translation in process (Gengo) - </li> - <li data-oe-translation-state="translated"> - Already translated content - </li> - </ul> - </p> - <p> +<t t-extend="web_editor.TranslatorInfoDialog"> + <t t-jquery=".oe_translate_examples" t-operation="replace"> + <ul class="oe_translate_examples"> + <li style="background:#ffffb6;"> + Content to translate or you can post them to <b><a href="http://gengo.com/" >Gengo</a></b> for translation. + </li> + <li data-oe-translation-state="inprogress"> + Translation in process (Gengo) + </li> + <li data-oe-translation-state="translated"> + Already translated content + </li> + </ul> + </t> + <t t-jquery="p:last" t-operation="replace"> + <p> <!-- Keep wrong indent for translation --> In this mode, you can translate texts or post texts to Gengo for translation. To change the structure of the page, you must edit the master page. - </p> - </section> - </main> - <footer class="modal-footer"> - <div> - <input type="checkbox" name="do_not_show"/> - Do not show this dialog later. - </div> - <div> - <button type="button" data-action="activate" class="btn btn-primary">Ok</button> - <button type="button" class="btn btn-secondary" data-action="discard" data-dismiss="modal">Cancel</button> - </div> - </footer> - </div> - </div> - </div> + </p> + </t> </t> <t t-name="website.GengoTranslatorPostDialog"> 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/<model("product.public.category", "[('website_id', 'in', (False, current_website_id))]"):category>/page/<int: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/<model("product.pricelist"):pl_id>'], type='http', auth="public", website=True) + @http.route(['/shop/change_pricelist/<model("product.pricelist"):pl_id>'], 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') - 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] + # 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, + }) - no_variant_attribute_values = kwargs.get('no_variant_attribute_values') + # 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]) ] - values['name'] = self._get_line_description(self.id, product_id, no_variant_attribute_values=no_variant_attribute_values, custom_values=custom_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] + + # 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,23 +106,17 @@ 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 }, { @@ -136,57 +124,17 @@ odoo.define('website_sale.tour_shop_customize', function (require) { trigger: ".js_product label:contains('Steel')", }, { - 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: "check price is 750", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", + run: function () {}, }, { - 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: "click on 'Add to Cart' button", + trigger: "a:contains(Add to Cart)", }, { 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 @@ <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tracking.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_customize.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js"></script> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_zoom.js"></script> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js"></script> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js"></script> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_options.js"></script> </xpath> </template> @@ -94,6 +98,11 @@ <!-- Product item used by /shop and /shop/cart --> <template id="products_item" name="Products item"> <t t-set="product_href" t-value="keep('/shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']>1 else None))" /> + + <t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/> + <t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/> + <t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/> + <form action="/shop/cart/update" method="post"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" /> <div itemscope="itemscope" itemtype="http://schema.org/Product"> @@ -102,7 +111,8 @@ </div> <div class="oe_product_image"> <a itemprop="url" t-att-href="product_href"> - <span itemprop="image" t-attf-content="{{request.httprequest.url_root}}web/image/product.template/{{product.id}}/image" t-field="product.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300', 'zoom': 'image'}" t-att-alt="product.name" /> + <span t-if="product_variant" itemprop="image" t-attf-content="{{request.httprequest.url_root}}web/image/product.product/{{product_variant.id}}/image" t-field="product_variant.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300', 'zoom': 'image'}" t-att-alt="product.name" /> + <span t-else="" itemprop="image" t-attf-content="{{request.httprequest.url_root}}web/image/product.template/{{product.id}}/image" t-field="product.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300', 'zoom': 'image'}" t-att-alt="product.name" /> </a> </div> <t t-if="show_publish"> @@ -119,14 +129,13 @@ <a itemprop="name" t-att-href="product_href" t-att-content="product.name" t-field="product.name" /> <a role="button" t-if="not show_publish and not product.website_published" t-att-href="product_href" class="btn btn-secondary btn-sm btn-danger" title="This product is unpublished.">Unpublished</a> </h6> - <div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price" t-if="product.product_variant_ids"> + <div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price"> <b> - <t t-if="product.product_variant_id and product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'"> - <del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" /> + <t t-if="True"><!-- compatibility 12.0 --> + <del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" /> </t> - - <span t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> - <span itemprop="price" style="display:none;" t-esc="product.website_price" /> + <span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> + <span itemprop="price" style="display:none;" t-esc="combination_info['price']" /> <span itemprop="priceCurrency" style="display:none;" t-esc="website.currency_id.name" /> </b> </div> @@ -145,24 +154,19 @@ <template id="products_add_to_cart" inherit_id="website_sale.products_item" active="False" customize_show="True" name="Add to Cart"> <xpath expr="//div[hasclass('product_price')]" position="inside"> - <input name="product_id" t-att-value="product.product_variant_id.id" type="hidden" /> - <t t-if="len(product.product_variant_ids) == 1"> + <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/> + <input name="product_id" t-att-value="product_variant.id" type="hidden" /> + <t t-if="product._is_quick_add_to_cart_possible()"> <a href="#" role="button" class="btn btn-secondary btn-sm a-submit" aria-label="Shopping cart" title="Shopping cart"> <span class="fa fa-shopping-cart" /> </a> </t> - <t t-if="len(product.product_variant_ids) > 1"> + <t t-elif="product._is_add_to_cart_possible()"> <a role="button" class="btn btn-secondary btn-sm" t-att-href="keep('/shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']>1 else None))" aria-label="Shopping cart" title="Shopping cart"> <span class="fa fa-shopping-cart" /> </a> </t> </xpath> - <xpath expr="//div[hasclass('product_price')]/t[1]" position="attributes"> - <attribute name="t-if">len(product.product_variant_ids) == 1 and not len(product.optional_product_ids) >= 1</attribute> - </xpath> - <xpath expr="//div[hasclass('product_price')]/t[2]" position="attributes"> - <attribute name="t-if">len(product.product_variant_ids) > 1 or len(product.optional_product_ids) >= 1</attribute> - </xpath> </template> <template id="pricelist_list" name="Pricelists Dropdown"> @@ -417,10 +421,15 @@ </xpath> </template> <template id="product" name="Product"> + + <t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/> + <t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/> + <t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/> + <t t-call="website.layout"> <t t-set="additional_title" t-value="product.name" /> <div itemscope="itemscope" itemtype="http://schema.org/Product" id="wrap" class="js_sale"> - <section t-attf-class="container py-2 oe_website_sale #{(compute_currency(product.lst_price) - product.website_price) > 0.01 and website.get_current_pricelist().discount_policy == 'without_discount' and 'discount'}" id="product_detail"> + <section t-attf-class="container py-2 oe_website_sale #{'discount' if combination_info['has_discounted_price'] else ''}" id="product_detail"> <div class="row"> <div class="col-md-4"> <ol class="breadcrumb"> @@ -446,12 +455,12 @@ </div> <div class="row"> <div class="col-md-6"> - <t t-set="variant_img" t-value="any(product.mapped('product_variant_ids.image_variant'))"/> + <t t-set="variant_img" t-value="product_variant and product_variant.image_variant"/> <t t-set="image_ids" t-value="product.product_image_ids"/> <div id="o-carousel-product" class="carousel slide" data-ride="carousel" data-interval="0"> <div class="carousel-outer"> <div class="carousel-inner"> - <div t-if="variant_img" class="carousel-item active" itemprop="image" t-field="product[:1].product_variant_id.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': str(product['__last_update']) + (str(product.product_variant_id['__last_update']) or '')}"/> + <div t-if="variant_img" class="carousel-item active" itemprop="image" t-field="product_variant.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': str(product['__last_update']) + (str(product_variant['__last_update']) or '')}"/> <div t-attf-class="carousel-item#{'' if variant_img else ' active'}" itemprop="image" t-field="product.image" t-options="{'widget': 'image', 'class': 'product_detail_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update']}"/> <t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg"> <div class="carousel-item" t-field="pimg.image" t-options='{"widget": "image", "class": "product_detail_img", "alt-field": "name", "zoom": "image" }'/> @@ -470,7 +479,7 @@ <ol class="carousel-indicators" t-if="len(image_ids) or variant_img"> <li t-if="variant_img" data-target="#o-carousel-product" data-slide-to="0" class="active"> - <img class="img img-fluid js_variant_img_small" t-attf-src="/website/image/product.product/{{product.product_variant_id.id}}/image/90x90" t-att-alt="product.name"/> + <img class="img img-fluid js_variant_img_small" t-attf-src="/website/image/product.product/{{product_variant.id}}/image/90x90" t-att-alt="product.name"/> </li> <li data-target="#o-carousel-product" t-att-data-slide-to="1 if variant_img else '0'" t-att-class="'' if variant_img else 'active'"> <img class="img img-fluid" t-attf-src="/website/image/product.template/{{product.id}}/image/90x90" t-att-alt="product.name"/> @@ -486,21 +495,27 @@ <div class="col-md-6 col-xl-4 offset-xl-2" id="product_details"> <h1 itemprop="name" t-field="product.name">Product Name</h1> <span itemprop="url" style="display:none;" t-esc="'%sshop/product/%s' % (request.httprequest.url_root, slug(product))"/> - <form action="/shop/cart/update" method="POST"> + <form t-if="product._is_add_to_cart_possible()" action="/shop/cart/update" method="POST"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" /> <div class="js_product js_main_product"> <t t-placeholder="select"> - <input type="hidden" class="product_id" name="product_id" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'" /> + <input type="hidden" class="product_id" name="product_id" t-att-value="product_variant.id" /> <input type="hidden" class="product_template_id" name="product_template_id" t-att-value="product.id" /> - <t t-call="sale.variants"> + <t t-if="first_possible_combination" t-call="sale.variants"> <t t-set="ul_class" t-value="'flex-column'" /> + <t t-set="parent_combination" t-value="None" /> + </t> + <t t-else=""> + <ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"/> </t> </t> <t t-call="website_sale.product_price" /> - <p t-if="product.has_dynamic_attributes() or len(product.product_variant_ids) > 1" class="css_not_available_msg bg-danger" style="padding: 15px;">This combination does not exist.</p> + <p t-if="True" class="css_not_available_msg alert alert-warning">This combination does not exist.</p> <a role="button" id="add_to_cart" class="btn btn-primary btn-lg mt8 js_check_product a-submit" href="#">Add to Cart</a> </div> </form> + <p t-elif="not product.active" class="alert alert-warning">This product is no longer available.</p> + <p t-else="" class="alert alert-warning">This product has no valid combination.</p> <hr t-if="product.description_sale" /> <div class="o_not_editable"> <p t-field="product.description_sale" class="text-muted" /> @@ -550,7 +565,10 @@ <t t-foreach="product.alternative_product_ids" t-as="alt_product"> <div class="col-lg-2" style="width: 170px; height:130px; float:left; display:inline; margin-right: 10px; overflow:hidden;"> <div class="mt16 text-center" style="height: 100%;"> - <div t-field="alt_product.image_small" t-options="{'widget': 'image', 'class': 'rounded shadow o_alternative_product' }" /> + <t t-set="combination_info" t-value="alt_product._get_combination_info()"/> + <t t-set="product_variant" t-value="alt_product.env['product.product'].browse(combination_info['product_id'])"/> + <div t-if="product_variant" t-field="product_variant.image_small" t-options="{'widget': 'image', 'class': 'rounded shadow o_alternative_product' }" /> + <div t-else="" t-field="alt_product.image_small" t-options="{'widget': 'image', 'class': 'rounded shadow o_alternative_product' }" /> <h6> <a t-attf-href="/shop/product/#{ slug(alt_product) }" style="display: block"> <span t-att-title="alt_product.name" t-field="alt_product.name" class="o_text_overflow" style="display: block;" /> @@ -591,7 +609,7 @@ <i class="fa fa-minus"></i> </a> </div> - <input type="text" class="form-control quantity" data-min="1" name="add_qty" value="1"/> + <input type="text" class="form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/> <div class="input-group-append"> <a t-attf-href="#" class="btn btn-secondary float_left js_add_cart_json" aria-label="Add one" title="Add one"> <i class="fa fa-plus"></i> @@ -604,15 +622,15 @@ <template id="product_price"> <div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price mt16"> <h4 class="oe_price_h4 css_editable_mode_hidden"> - <span class="text-danger oe_default_price" style="text-decoration: line-through; white-space: nowrap;" - t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" t-att-style="'text-decoration: line-through; white-space: nowrap; ' + '' if product.product_variant_id and product.website_price_difference and website.get_current_pricelist().discount_policy == 'without_discount' else 'display: none;'" + <span t-attf-class="text-danger oe_default_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="text-decoration: line-through; white-space: nowrap;" + t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" /> - <b class="oe_price" style="white-space: nowrap;" t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> - <span itemprop="price" style="display:none;" t-esc="product.website_price"/> + <b class="oe_price" style="white-space: nowrap;" t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> + <span itemprop="price" style="display:none;" t-esc="combination_info['price']"/> <span itemprop="priceCurrency" style="display:none;" t-esc="website.currency_id.name"/> </h4> <h4 class="css_non_editable_mode_hidden decimal_precision" t-att-data-precision="str(product.currency_id.decimal_places)"> - <span t-field="product.lst_price" + <span t-field="product.list_price" t-options='{ "widget": "monetary", "display_currency": product.currency_id, @@ -623,23 +641,39 @@ </template> <template id="product_variants" inherit_id="website_sale.product" active="False" customize_show="True" name="List View of Variants"> - <xpath expr="//t[@t-placeholder='select']" position="replace"> - <t t-set="attribute_exclusions" t-value="get_attribute_exclusions(product, reference_product)"/> - <ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)"/> - <input type="hidden" t-if="len(product.product_variant_ids) == 1" class="product_id" name="product_id" t-att-value="product.product_variant_id.id"/> - <t t-if="len(product.product_variant_ids) > 1"> - <div t-foreach="product.product_variant_ids" t-as="variant_id" class="custom-control custom-radio"> - <input type="radio" name="product_id" class="custom-control-input js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-attf-id="radio_variant_#{variant_id.id}" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price"/> - <label t-attf-for="radio_variant_#{variant_id.id}" label-default="label-default" class="custom-control-label"> - <span t-esc="variant_id.display_name"/> - <t t-set="diff_price" t-value="variant_id.price - variant_id.product_tmpl_id.price"/> - <span class="badge badge-pill" t-if="diff_price != 0"> - <t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(diff_price)" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> - </span> - </label> - </div> - </t> - </xpath> + <xpath expr="//t[@t-placeholder='select']" position="replace"> + <!-- + Using this setting with dynamic variants is not supported. + Indeed the variants that have yet to exist will not show on the + list and will never be selectable to be created... + + We also don't use the feature with no_variant because these + attributes have to be selected manually. + + Finally we don't use the feature with is_custom values because + they need to be set by the user. + --> + <t t-if="not product.has_dynamic_attributes() and not product._has_no_variant_attributes() and not product._has_is_custom_values()"> + <t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions()"/> + <t t-set="filtered_sorted_variants" t-value="product._get_possible_variants_sorted()"/> + <ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)"/> + <input type="hidden" class="product_template_id" t-att-value="product.id"/> + <input type="hidden" t-if="len(filtered_sorted_variants) == 1" class="product_id" name="product_id" t-att-value="filtered_sorted_variants[0].id"/> + <t t-if="len(filtered_sorted_variants) > 1"> + <div t-foreach="filtered_sorted_variants" t-as="variant_id" class="custom-control custom-radio"> + <input type="radio" name="product_id" class="custom-control-input product_id js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-attf-id="radio_variant_#{variant_id.id}" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price" t-att-data-combination="variant_id.product_template_attribute_value_ids.ids"/> + <label t-attf-for="radio_variant_#{variant_id.id}" label-default="label-default" class="custom-control-label"> + <span t-esc="variant_id.display_name"/> + <t t-set="diff_price" t-value="website.currency_id.compare_amounts(variant_id.price, variant_id.product_tmpl_id.price)"/> + <span class="badge badge-pill" t-if="diff_price != 0"> + <t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(variant_id.price - variant_id.product_tmpl_id.price)" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> + </span> + </label> + </div> + </t> + </t> + <t t-else="">$0</t> + </xpath> </template> <template id="wizard_checkout" name="Wizard Checkout"> @@ -821,11 +855,18 @@ </t> </td> <td class="text-center td-price" name="price"> - <t t-if="(compute_currency(line.product_id.lst_price) - line.price_reduce ) > 0.01 and website.get_current_pricelist().discount_policy=='without_discount'"> - <del t-attf-class="#{'text-danger mr8'}" style="white-space: nowrap;" t-esc="compute_currency(line.product_id.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" /> + <t t-set="combination" t-value="line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids"/> + <t t-set="combination_info" t-value="line.product_id.product_tmpl_id._get_combination_info(combination)"/> + + <t t-set="list_price_converted" t-value="website.currency_id._convert(combination_info['list_price'], website_sale_order.currency_id, website_sale_order.company_id, date)"/> + <t groups="account.group_show_line_subtotals_tax_excluded" t-if="(website_sale_order.pricelist_id.discount_policy == 'without_discount' and website_sale_order.currency_id.compare_amounts(list_price_converted, line.price_reduce_taxexcl) == 1) or website_sale_order.currency_id.compare_amounts(line.price_unit, line.price_reduce) == 1" name="order_line_discount"> + <del t-attf-class="#{'text-danger mr8'}" style="white-space: nowrap;" t-esc="list_price_converted" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" /> + </t> + <span t-field="line.price_reduce_taxexcl" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_excluded" /> + <t groups="account.group_show_line_subtotals_tax_included" t-if="(website_sale_order.pricelist_id.discount_policy == 'without_discount' and website_sale_order.currency_id.compare_amounts(list_price_converted, line.price_reduce_taxinc) == 1) or website_sale_order.currency_id.compare_amounts(line.price_unit, line.price_reduce) == 1" name="order_line_discount"> + <del t-attf-class="#{'text-danger mr8'}" style="white-space: nowrap;" t-esc="list_price_converted" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" /> </t> - <span t-field="line.price_reduce_taxexcl" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" groups="account.group_show_line_subtotals_tax_excluded" /> - <span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" groups="account.group_show_line_subtotals_tax_included" /> + <span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_included" /> </td> <td class="td-action"> <a href='#' aria-label="Remove from cart" title="Remove from cart" class='js_delete_product no-decoration'> <small><i class='fa fa-trash-o'></i></small></a> @@ -938,6 +979,7 @@ <table t-if="suggested_products" id="suggested_products" class="js_cart_lines table table-striped table-sm"> <tbody> <tr t-foreach="suggested_products" t-as="product"> + <t t-set="combination_info" t-value="product._get_combination_info_variant()"/> <td class='td-img'> <a t-attf-href="/shop/product/#{ slug(product.product_tmpl_id) }"> <span t-field="product.image_small" t-options="{'widget': 'image', 'class': 'rounded'}" /> @@ -946,16 +988,16 @@ <td class='td-product_name'> <div> <a t-attf-href="/shop/product/#{ slug(product.product_tmpl_id) }"> - <strong t-field="product.display_name" /> + <strong t-esc="combination_info['display_name']" /> </a> </div> <div class="text-muted d-none d-md-block" t-field="product.description_sale" /> </td> <td class='td-price'> - <t t-if="product.product_variant_id and product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'"> - <del class="text-danger mr8" style="white-space: nowrap;" t-field="product.lst_price" t-options="{'widget': 'monetary','from_currency': product.currency_id, 'display_currency': website.currency_id}" /> + <t t-if="True"><!-- compatibility 12.0 --> + <del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> </t> - <span t-field="product.website_price" style="white-space: nowrap;" t-options="{'widget': 'monetary','display_currency': website.currency_id}" /> + <span t-esc="combination_info['price']" style="white-space: nowrap;" t-options="{'widget': 'monetary','display_currency': website.currency_id}"/> </td> <td class="w-25 text-center"> <input class="js_quantity" name="product_id" t-att-data-product-id="product.id" type="hidden" /> @@ -1075,7 +1117,7 @@ </form> <div t-attf-class="card #{selected and 'border_primary' or 'js_change_shipping'}"> <div class='card-body' style='min-height: 130px;'> - <a t-if="not actual_partner or (ship.id in actual_partner.ids + actual_partner.child_ids.ids)" href="#" class="btn btn-link float-right p-0 js_edit_address no-decoration" role="button" title="Edit this address" aria-label="Edit this address"><i class='fa fa-edit'/></a> + <a t-if="not actual_partner or (ship.id in actual_partner.child_ids.ids)" href="#" class="btn btn-link float-right p-0 js_edit_address no-decoration" role="button" title="Edit this address" aria-label="Edit this address"><i class='fa fa-edit'/></a> <t t-esc="contact" t-options="dict(widget='contact', fields=['name', 'address'], no_marker=True)"/> </div> <div class='card-footer' t-if='not readonly'> @@ -1123,6 +1165,15 @@ <t t-if="mode[1] == 'shipping'"> <h2 class="o_page_header mt8">Shipping Address </h2> </t> + <t t-if="partner_id == website_sale_order.partner_shipping_id.id == website_sale_order.partner_invoice_id.id"> + <div class="alert alert-warning" role="alert"> + <h4 class="alert-heading">Be aware!</h4> + <p> + You are editing your <b>billing and shipping</b> addresses at the same time!<br/> + If you want to modify your shipping address, create a <a href='/shop/address'>new address</a>. + </p> + </div> + </t> <t t-if="error" t-foreach="error.get('error_message', [])" t-as="err"> <h4 class="text-danger" t-esc="err" /> </t> @@ -1387,8 +1438,8 @@ <div t-esc="line.product_uom_qty" /> </td> <td class="text-center td-price"> - <span t-field="line.price_reduce_taxexcl" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" groups="account.group_show_line_subtotals_tax_excluded" /> - <span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" groups="account.group_show_line_subtotals_tax_included" /> + <span t-field="line.price_reduce_taxexcl" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_excluded" /> + <span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_included" /> </td> </tr> </tbody> @@ -1490,13 +1541,13 @@ <tr id="order_total_untaxed"> <td class="text-right noborder">Subtotal:</td> <td class="text-xl-right noborder" > - <span t-field="website_sale_order.amount_untaxed" style="white-space: nowrap;" t-options="{'widget': 'monetary','from_currency': website_sale_order.pricelist_id.currency_id,'display_currency': website.currency_id}" /> + <span t-field="website_sale_order.amount_untaxed" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}"/> </td> </tr> <tr id="order_total_taxes"> <td class="text-right noborder">Taxes:</td> <td class="text-xl-right noborder"> - <span t-field="website_sale_order.amount_tax" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'from_currency': website_sale_order.pricelist_id.currency_id, 'display_currency': website.currency_id}" /> + <span t-field="website_sale_order.amount_tax" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" /> </td> </tr> <tr id="order_total"> @@ -1650,4 +1701,43 @@ </t> </template> + <template id="website_sale.shop_product_carousel" name="Shop Product Carousel"> + <t t-set="variant_img" t-value="product_variant and product_variant.image_variant"/> + <t t-set="image_ids" t-value="product.product_image_ids"/> + <div id="o-carousel-product" class="carousel slide" data-ride="carousel" data-interval="0"> + <div class="carousel-outer"> + <div class="carousel-inner"> + <div t-if="variant_img" class="carousel-item active" itemprop="image" t-field="product_variant.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': str(product['__last_update']) + (str(product_variant['__last_update']) or '')}"/> + <div t-attf-class="carousel-item#{'' if variant_img else ' active'}" itemprop="image" t-field="product.image" t-options="{'widget': 'image', 'class': 'product_detail_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update']}"/> + <t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg"> + <div class="carousel-item" t-field="pimg.image" t-options='{"widget": "image", "class": "product_detail_img", "alt-field": "name", "zoom": "image" }'/> + </t> + </div> + + <t t-if="len(image_ids) or variant_img"> + <a class="carousel-control-prev" href="#o-carousel-product" role="button" data-slide="prev" > + <span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/> + </a> + <a class="carousel-control-next" href="#o-carousel-product" role="button" data-slide="next"> + <span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/> + </a> + </t> + </div> + + <ol t-if="len(image_ids) or variant_img" class="carousel-indicators"> + <li t-if="variant_img" data-target="#o-carousel-product" data-slide-to="0" class="active"> + <img class="img img-fluid js_variant_img_small" t-attf-src="/website/image/product.product/{{product_variant.id}}/image/90x90" t-att-alt="product.name"/> + </li> + <li data-target="#o-carousel-product" t-att-data-slide-to="1 if variant_img else '0'" t-att-class="'' if variant_img else 'active'"> + <img class="img img-fluid" t-attf-src="/website/image/product.template/{{product.id}}/image/90x90" t-att-alt="product.name"/> + </li> + <t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg"> + <li data-target="#o-carousel-product" t-att-data-slide-to="pimg_index + (variant_img and 2 or 1)"> + <img class="img img-fluid" t-attf-src="/website/image/product.image/{{pimg.id}}/image/90x90" t-att-alt="pimg.name"/> + </li> + </t> + </ol> + </div> + </template> + </odoo> diff --git a/addons/website_sale_comparison/controllers/main.py b/addons/website_sale_comparison/controllers/main.py index 24edf750d49..d58c6c5a0dc 100644 --- a/addons/website_sale_comparison/controllers/main.py +++ b/addons/website_sale_comparison/controllers/main.py @@ -19,7 +19,7 @@ class WebsiteSaleProductComparison(WebsiteSale): res = {} for num, product in enumerate(products): - for var in product.attribute_line_ids: + for var in product.product_tmpl_id._get_valid_product_template_attribute_lines(): cat_name = var.attribute_id.category_id.name or _('Uncategorized') att_name = var.attribute_id.name if not product.attribute_value_ids: # create_variant = False @@ -29,14 +29,15 @@ class WebsiteSaleProductComparison(WebsiteSale): if val: res[cat_name][att_name][num] = val[0].name values['specs'] = res - values['compute_currency'] = self._get_compute_currency_and_context()[0] + values['compute_currency'] = self._get_compute_currency_and_context(products[:1])[0] return request.render("website_sale_comparison.product_compare", values) @http.route(['/shop/get_product_data'], type='json', auth="public", website=True) def get_product_data(self, product_ids, cookies=None): ret = {} - compute_currency, pricelist_context, _ = self._get_compute_currency_and_context() + pricelist_context, pricelist = self._get_pricelist_context() prods = request.env['product.product'].with_context(pricelist_context, display_default_code=False).search([('id', 'in', product_ids)]) + compute_currency = self._get_compute_currency(pricelist, prods[:1]) if cookies is not None: ret['cookies'] = json.dumps(request.env['product.product'].search([('id', 'in', list(set(product_ids + cookies)))]).ids) diff --git a/addons/website_sale_comparison/models/website_sale_comparison.py b/addons/website_sale_comparison/models/website_sale_comparison.py index 5a1ea69ca84..9c2fbca9082 100644 --- a/addons/website_sale_comparison/models/website_sale_comparison.py +++ b/addons/website_sale_comparison/models/website_sale_comparison.py @@ -26,6 +26,6 @@ class ProductTemplate(models.Model): def get_variant_groups(self): res = OrderedDict() - for var in self.attribute_line_ids: + for var in self._get_valid_product_template_attribute_lines(): res.setdefault(var.attribute_id.category_id.name or _('Uncategorized'), []).append(var) return res diff --git a/addons/website_sale_comparison/views/website_sale_comparison_template.xml b/addons/website_sale_comparison/views/website_sale_comparison_template.xml index 40bfaef8a53..d6c55944800 100644 --- a/addons/website_sale_comparison/views/website_sale_comparison_template.xml +++ b/addons/website_sale_comparison/views/website_sale_comparison_template.xml @@ -10,14 +10,16 @@ <template id="add_to_compare" inherit_id="website_sale.products_item" active="True" customize_show="True" name="Comparison List" priority="22"> <xpath expr="//div[hasclass('product_price')]" position="inside"> - <button type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-sm o_add_compare" title="Compare" aria-label="Compare" t-att-data-product-product-id="product.product_variant_ids[0].id if product.product_variant_ids else '0'" data-action="o_comparelist"><span class="fa fa-exchange"></span></button> + <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/> + <button t-if="product_variant" type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-sm o_add_compare" title="Compare" aria-label="Compare" t-att-data-product-product-id="product_variant.id" data-action="o_comparelist"><span class="fa fa-exchange"></span></button> </xpath> </template> <template id="product_add_to_compare" name='Add to comparison in product page' inherit_id="website_sale.product" priority="8"> <xpath expr="//a[@id='add_to_cart']" position="after"> - <t t-if="product.env.ref('website_sale_comparison.add_to_compare').active"> - <button type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-lg mt8 o_add_compare_dyn" title="Compare" aria-label="Compare" t-att-data-product-product-id="product.product_variant_ids[0].id if product.product_variant_ids else '0'" data-action="o_comparelist"><span class="fa fa-exchange"></span></button> + <t t-if="request.website.viewref('website_sale_comparison.add_to_compare').active"> + <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/> + <button t-if="product_variant" type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-lg mt8 o_add_compare_dyn" title="Compare" aria-label="Compare" t-att-data-product-product-id="product_variant.id" data-action="o_comparelist"><span class="fa fa-exchange"></span></button> </t> </xpath> </template> @@ -56,8 +58,19 @@ <template id='recommended_product' inherit_id="website_sale.recommended_products" name="Alternative Products"> <xpath expr="//h3" position="replace"> - <t t-set="ids" t-value="str(product.product_variant_id.id) + ','+ ','.join([str(a) for a in product.alternative_product_ids.mapped('product_variant_id.id')])"/> - <div><span class='h3'>Suggested alternatives: </span><a role="button" class="btn btn-primary" t-attf-href="/shop/compare/?products=#{ids}"><i class="fa fa-exchange"></i> Compare</a></div> + <!-- + Create the first variant if it doesn't exist for the current + product and every alternative product because the product + comparator need the variants and not the templates. + --> + <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/> + <t t-set="id_list" t-value="[product_variant.id] if product_variant else []"/> + <t t-foreach="product.alternative_product_ids" t-as="alt_product"> + <t t-set="alt_product_variant_id" t-value="alt_product._get_combination_info()['product_id'] or alt_product._create_first_product_variant().id"/> + <t t-if="alt_product_variant_id" t-set="id_list" t-value="id_list + [alt_product_variant_id]"/> + </t> + <t t-set="ids" t-value="','.join([str(id) for id in id_list])"/> + <div><span class='h3'>Suggested alternatives: </span><a t-if="len(id_list) > 1" role="button" class="btn btn-primary" t-attf-href="/shop/compare/?products=#{ids}"><i class="fa fa-exchange"></i> Compare</a></div> </xpath> </template> @@ -73,6 +86,7 @@ <thead> <td t-if="specs.keys()" class='td-top-left'/> <td t-foreach="products" t-as="product"> + <t t-set="combination_info" t-value="product._get_combination_info_variant()"/> <a href="#" t-att-data-product_product_id="product.id" class="o_comparelist_remove" t-if="len(products) > 2"> <strong class='float-right'>x</strong> </a> @@ -81,15 +95,15 @@ </a> <div class='product_summary'> <a class="o_product_comparison_table" t-attf-href="/shop/product/#{ slug(product.product_tmpl_id) }"> - <span t-field='product.display_name'></span><br/> + <span t-esc="combination_info['display_name']"></span><br/> </a> <span> <strong>Price:</strong> - <t t-if="(compute_currency(product.lst_price) - product.website_price ) > 0.01 and website.get_current_pricelist().discount_policy=='without_discount'"> - <del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" /> + <t t-if="True"><!-- compatibility 12.0--> + <del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" /> </t> - <span t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span> + <span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span> </span> <form action="/shop/cart/update" method="post" class="text-center"> @@ -120,17 +134,18 @@ </template> <template id="product_product" name="Comparator - Product row in comparator popover"> + <t t-set="combination_info" t-value="product._get_combination_info_variant()"/> <div class="row no-gutters align-items-center mt-2 o_product_row" t-att-data-category_ids="product.public_categ_ids.ids"> <div class="col-3"> <img class="img img-fluid" t-attf-src="/web/image/product.product/#{product.id}/image_small" style="max-height: 50px;" alt="Product image"/> </div> <div class="col-8 pl-2"> <h6> - <a t-attf-href="/shop/product/#{product.product_tmpl_id[0].id}"><t t-esc="product.display_name" /></a><br/> - <t t-if="(compute_currency(product.lst_price) - product.website_price ) > 0.01 and website.get_current_pricelist().discount_policy=='without_discount'"> - <del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" /> + <a t-attf-href="/shop/product/#{product.product_tmpl_id[0].id}"><t t-esc="combination_info['display_name']" /></a><br/> + <t t-if="True"><!--compatibility 12.0--> + <del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" /> </t> - <span t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span> + <span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span> </h6> </div> <div class="col-1 text-right"> diff --git a/addons/website_sale_stock/controllers/main.py b/addons/website_sale_stock/controllers/main.py index b94bf5b9b9b..386acc30ba9 100644 --- a/addons/website_sale_stock/controllers/main.py +++ b/addons/website_sale_stock/controllers/main.py @@ -4,32 +4,9 @@ from odoo.addons.website_sale.controllers.main import WebsiteSale from odoo.http import request + class WebsiteSale(WebsiteSale): def _get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist, **kw): - res = super(WebsiteSale, self)._get_combination_info(product_template_id, product_id, combination, add_qty, pricelist, **kw) - - if res['product_id']: - product = request.env['product.product'].sudo().browse(res['product_id']) - res.update({ - 'virtual_available': product.virtual_available, - 'product_type': product.type, - 'inventory_availability': product.inventory_availability, - 'available_threshold': product.available_threshold, - 'custom_message': product.custom_message, - 'product_template': product.product_tmpl_id.id, - 'cart_qty': product.cart_qty, - 'uom_name': product.uom_id.name, - }) - else: - product_template = request.env['product.template'].sudo().browse(product_template_id) - res.update({ - 'virtual_available': 0, - 'product_type': product_template.type, - 'inventory_availability': product_template.inventory_availability, - 'available_threshold': product_template.available_threshold, - 'custom_message': product_template.custom_message, - 'product_template': product_template.id, - 'cart_qty': 0 - }) - - return res + """deprecated, use product method""" + combination = request.env['product.template.attribute.value'].browse(combination) + return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, product_id, add_qty, pricelist) diff --git a/addons/website_sale_stock/models/product_template.py b/addons/website_sale_stock/models/product_template.py index 9f636c7935d..d2d5f9e5759 100644 --- a/addons/website_sale_stock/models/product_template.py +++ b/addons/website_sale_stock/models/product_template.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models +from odoo import fields, models, api class ProductTemplate(models.Model): @@ -15,3 +15,33 @@ class ProductTemplate(models.Model): ], string='Inventory Availability', help='Adds an inventory availability status on the web product page.', default='never') available_threshold = fields.Float(string='Availability Threshold', default=5.0) custom_message = fields.Text(string='Custom Message', default='') + + @api.multi + def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False): + combination_info = super(ProductTemplate, self)._get_combination_info(combination, product_id, add_qty, pricelist, reference_product) + + if combination_info['product_id']: + product = self.env['product.product'].sudo().browse(combination_info['product_id']) + combination_info.update({ + 'virtual_available': product.virtual_available, + 'product_type': product.type, + 'inventory_availability': product.inventory_availability, + 'available_threshold': product.available_threshold, + 'custom_message': product.custom_message, + 'product_template': product.product_tmpl_id.id, + 'cart_qty': product.cart_qty, + 'uom_name': product.uom_id.name, + }) + else: + product_template = self.sudo() + combination_info.update({ + 'virtual_available': 0, + 'product_type': product_template.type, + 'inventory_availability': product_template.inventory_availability, + 'available_threshold': product_template.available_threshold, + 'custom_message': product_template.custom_message, + 'product_template': product_template.id, + 'cart_qty': 0 + }) + + return combination_info diff --git a/addons/website_sale_stock/static/src/js/product_configurator_mixin.js b/addons/website_sale_stock/static/src/js/product_configurator_mixin.js index 27d40afc687..fe9a92f1d5e 100644 --- a/addons/website_sale_stock/static/src/js/product_configurator_mixin.js +++ b/addons/website_sale_stock/static/src/js/product_configurator_mixin.js @@ -27,9 +27,16 @@ var xml_load = ajax.loadXML( * @param {Array} combination */ ProductConfiguratorMixin._onChangeCombinationStock = function (ev, $parent, combination) { + var product_id = 0; + // needed for list view of variants + if ($parent.find('input.product_id:checked').length) { + product_id = $parent.find('input.product_id:checked').val(); + } else { + product_id = $parent.find('.product_id').val(); + } var isMainProduct = combination.product_id && ($parent.is('.js_main_product') || $parent.is('.main_product')) && - combination.product_id === parseInt($parent.find('.product_id').val()); + combination.product_id === parseInt(product_id); if (!this.isWebsite || !isMainProduct){ return; diff --git a/addons/website_sale_wishlist/controllers/main.py b/addons/website_sale_wishlist/controllers/main.py index 3df38028749..09bd5f90b17 100644 --- a/addons/website_sale_wishlist/controllers/main.py +++ b/addons/website_sale_wishlist/controllers/main.py @@ -10,9 +10,9 @@ class WebsiteSaleWishlist(WebsiteSale): @http.route(['/shop/wishlist/add'], type='json', auth="public", website=True) def add_to_wishlist(self, product_id, price=False, **kw): if not price: - compute_currency, pricelist_context, pl = self._get_compute_currency_and_context() + pricelist_context, pl = self._get_pricelist_context() p = request.env['product.product'].with_context(pricelist_context, display_default_code=False).browse(product_id) - price = p.website_price + price = p._get_combination_info_variant()['price'] Wishlist = request.env['product.wishlist'] if request.website.is_public_user(): diff --git a/addons/website_sale_wishlist/models/product_wishlist.py b/addons/website_sale_wishlist/models/product_wishlist.py index bea6c0931b6..7467991f3a7 100644 --- a/addons/website_sale_wishlist/models/product_wishlist.py +++ b/addons/website_sale_wishlist/models/product_wishlist.py @@ -74,3 +74,21 @@ class ResPartner(models.Model): _inherit = 'res.partner' wishlist_ids = fields.One2many('product.wishlist', 'partner_id', string='Wishlist', domain=[('active', '=', True)]) + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + @api.multi + def _is_in_wishlist(self): + self.ensure_one() + return self in self.env['product.wishlist'].current().mapped('product_id.product_tmpl_id') + + +class ProductProduct(models.Model): + _inherit = 'product.product' + + @api.multi + def _is_in_wishlist(self): + self.ensure_one() + return self in self.env['product.wishlist'].current().mapped('product_id') diff --git a/addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js b/addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js index 66a2451fe66..b03e0108fb2 100644 --- a/addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js +++ b/addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js @@ -61,13 +61,18 @@ tour.register('shop_wishlist', { trigger: '.oe_product_cart a:contains("Customizable Desk")', }, { - content: "select desk cutomizable", + content: "check the first variant is already in wishlist", + trigger: '#product_detail .o_add_wishlist_dyn:disabled', + run: function () {}, + }, + { + content: "change variant", extra_trigger: '#product_detail label:contains(Aluminium) input', trigger: 'label:contains(Aluminium) input', }, { - content: "Change variant, wait button enable and click on add to wishlist", - extra_trigger: '#product_detail .o_add_wishlist_dyn:not(".disabled")', + content: "wait button enable and click on add to wishlist", + extra_trigger: '#product_detail .o_add_wishlist_dyn:not(:disabled)', trigger: '#product_detail .o_add_wishlist_dyn', }, { diff --git a/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js b/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js index 60557854b62..947500a7106 100644 --- a/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js +++ b/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js @@ -20,29 +20,56 @@ sAnimations.registry.ProductWishlist = sAnimations.Class.extend(ProductConfigura }, events: sAnimations.Class.events, - start: function () { + /** + * @constructor + */ + init: function (parent) { + this._super.apply(this, arguments); + this.wishlistProductIDs = []; + }, + /** + * Gets the current wishlist items. + * In editable mode, do nothing instead. + * + * @override + */ + willStart: function () { var self = this; var def = this._super.apply(this, arguments); if (this.editableMode) { return def; } - this.wishlistProductIDs = []; - var wishDef = $.get('/shop/wishlist', { count: 1, }).then(function (res) { self.wishlistProductIDs = JSON.parse(res); - self._updateWishlistView(); - if ($('input.js_product_change').length) { // manage "List View of variants" - $('input.js_product_change:checked').first().trigger('change'); - } else { - $('input.js_variant_change').trigger('change'); - } }); return $.when(def, wishDef); }, + /** + * Updates the wishlist view (navbar) & the wishlist button (product page). + * In editable mode, do nothing instead. + * + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + if (this.editableMode) { + return def; + } + + this._updateWishlistView(); + // trigger change on only one input + if (this.$('input.js_product_change').length) { // manage "List View of variants" + this.$('input.js_product_change:checked').first().trigger('change'); + } else { + this.$('input.product_id').first().trigger('change'); + } + + return def; + }, //-------------------------------------------------------------------------- // Private @@ -61,12 +88,17 @@ sAnimations.registry.ProductWishlist = sAnimations.Class.extend(ProductConfigura } productID = parseInt(productID, 10); } - + var $form = $el.closest('form'); + var templateId = $form.find('.product_template_id').val(); + // when adding from /shop instead of the product page, need another selector + if (!templateId) { + templateId = $el.data('product-template-id'); + } $el.prop("disabled", true).addClass('disabled'); var productReady = this.selectOrCreateProduct( $el.closest('form'), productID, - $el.closest('form').find('.product_template_id').val(), + templateId, false ); diff --git a/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml b/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml index 28d493f7142..55ec4bd8f89 100644 --- a/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml +++ b/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml @@ -11,23 +11,25 @@ <template id="add_to_wishlist" inherit_id="website_sale.products_item" active="True" customize_show="True" name="Wishlist" priority="20"> <xpath expr="//div[hasclass('product_price')]" position="inside"> - <t t-set="in_wish" t-value="product.product_variant_ids & request.env['product.wishlist'].current().mapped('product_id')"/> - <button type="button" role="button" class="btn btn-secondary btn-sm o_add_wishlist" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-product-id="product.product_variant_ids[0].id if product.product_variant_ids else '0'" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button> + <t t-set="in_wish" t-value="product._is_in_wishlist()"/> + <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/> + <button t-if="product_variant" type="button" role="button" class="btn btn-secondary btn-sm o_add_wishlist" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant.id" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button> </xpath> </template> <template id="product_add_to_wishlist" name='Add to wishlist in product page' inherit_id="website_sale.product" priority="20"> <xpath expr="//a[@id='add_to_cart']" position="after"> - <t t-if="product.env.ref('website_sale_wishlist.add_to_wishlist').active"> - <t t-set="in_wish" t-value="product.product_variant_ids & request.env['product.wishlist'].current().mapped('product_id')"/> - <button type="button" role="button" class="btn btn-secondary btn-lg mt8 o_add_wishlist_dyn" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-product-id="product.product_variant_ids[0].id if product.product_variant_ids else '0'" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button> + <t t-if="request.website.viewref('website_sale_wishlist.add_to_wishlist').active"> + <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/> + <t t-set="in_wish" t-value="product_variant and product_variant._is_in_wishlist()"/> + <button t-if="product_variant" type="button" role="button" class="btn btn-secondary btn-lg mt8 o_add_wishlist_dyn" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant.id" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button> </t> </xpath> </template> <template id="header" inherit_id="website.layout" name="Header Shop Wishlist Link"> <xpath expr="//header//ul[@id='top_menu']/li[@id='my_cart']" position="after"> - <t t-if="request.env.ref('website_sale_wishlist.add_to_wishlist').active"> + <t t-if="request.website.viewref('website_sale_wishlist.add_to_wishlist').active"> <t t-set='wishcount' t-value="len(request.env['product.wishlist'].current())"/> <li id="my_wish" class="nav-item" t-att-style="not wishcount and 'display:none;'"> <a href="/shop/wishlist" class="nav-link"> @@ -65,7 +67,8 @@ <button type="button" class="btn btn-link o_wish_rm no-decoration"><small><i class='fa fa-trash-o'></i> Remove</small></button> </td> <td> - <t t-esc="wish.product_id.website_price" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}"/> + <t t-set="combination_info" t-value="wish.product_id._get_combination_info_variant()"/> + <t t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}"/> </td> <td class='text-center td-wish-btn'> <input name="product_id" t-att-value="wish.product_id.id" type="hidden"/> diff --git a/addons/website_theme_install/tests/__init__.py b/addons/website_theme_install/tests/__init__.py new file mode 100644 index 00000000000..28efb5bbfb5 --- /dev/null +++ b/addons/website_theme_install/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_views diff --git a/addons/website_theme_install/tests/test_views.py b/addons/website_theme_install/tests/test_views.py new file mode 100644 index 00000000000..7657dcb19b8 --- /dev/null +++ b/addons/website_theme_install/tests/test_views.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import HttpCase + + +class Crawler(HttpCase): + def test_multi_website_views_retrieving(self): + View = self.env['ir.ui.view'] + Website = self.env['website'] + + website_1 = Website.create({'name': 'Website 1'}) + website_2 = Website.create({'name': 'Website 2'}) + + main_view = View.create({ + 'name': 'Products', + 'type': 'qweb', + 'arch': '<body>Arch is not relevant for this test</body>', + 'key': '_website_sale.products', + }).with_context(load_all_views=True) + + View.with_context(load_all_views=True).create({ + 'name': 'Child View W1', + 'mode': 'extension', + 'inherit_id': main_view.id, + 'arch': '<xpath expr="//body" position="replace">It is really not relevant!</xpath>', + 'key': '_website_sale.child_view_w1', + 'website_id': website_1.id, + 'active': False, + 'customize_show': True, + }) + + # Simulate theme view instal + load on website + theme_view = self.env['theme.ir.ui.view'].with_context(install_filename='/testviews').create({ + 'name': 'Products Theme Kea', + 'mode': 'extension', + 'inherit_id': main_view.id, + 'arch': '<xpath expr="//p" position="replace"><span>C</span></xpath>', + 'key': '_theme_kea_sale.products', + }) + view_from_theme_view_on_w2 = View.with_context(load_all_views=True).create({ + 'name': 'Products Theme Kea', + 'mode': 'extension', + 'inherit_id': main_view.id, + 'arch': '<xpath expr="//body" position="replace">Really really not important for this test</xpath>', + 'key': '_theme_kea_sale.products', + 'website_id': website_2.id, + 'customize_show': True, + }) + self.env['ir.model.data'].create({ + 'module': '_theme_kea_sale', + 'name': 'products', + 'model': 'theme.ir.ui.view', + 'res_id': theme_view.id, + }) + + # ##################################################### ir.ui.view ############################################### + # id | name | website_id | inherit | key | xml_id | + # ---------------------------------------------------------------------------------------------------------------- + # 1 | Products | / | / | _website_sale.products | / | + # 2 | Child View W1 | 1 | 1 | _website_sale.child_view_w1 | / | + # 3 | Products Theme Kea | 2 | 1 | _theme_kea_sale.products | / | + + # ################################################# theme.ir.ui.view ############################################# + # id | name | inherit | key | xml_id | + # ---------------------------------------------------------------------------------------------------------------- + # 1 | Products Theme Kea | 1 | _theme_kea_sale.products | _theme_kea_sale.products | + + with self.assertRaises(ValueError): + # It should crash as it should not find a view on website 1 for '_theme_kea_sale.products', !!and certainly not a theme.ir.ui.view!!. + view = View.with_context(website_id=website_1.id)._view_obj('_theme_kea_sale.products') + view = View.with_context(website_id=website_2.id)._view_obj('_theme_kea_sale.products') + self.assertEquals(len(view), 1, "It should find the ir.ui.view with key '_theme_kea_sale.products' on website 2..") + self.assertEquals(view._name, 'ir.ui.view', "..and not a theme.ir.ui.view") + + views = View.with_context(website_id=website_1.id).get_related_views('_website_sale.products') + self.assertEquals(len(views), 2, "It should not mix apples and oranges, only ir.ui.view ['_website_sale.products', '_website_sale.child_view_w1'] should be returned") + views = View.with_context(website_id=website_2.id).get_related_views('_website_sale.products') + self.assertEquals(len(views), 2, "It should not mix apples and oranges, only ir.ui.view ['_website_sale.products', '_theme_kea_sale.products'] should be returned") + + # Part 2 of the test, it test the same stuff but from a higher level (get_related_views ends up calling _view_obj) + called_theme_view = self.env['theme.ir.ui.view'].with_context(install_filename='/testviews').create({ + 'name': 'Called View Kea', + 'arch': '<div></div>', + 'key': '_theme_kea_sale.t_called_view', + }) + View.create({ + 'name': 'Called View Kea', + 'type': 'qweb', + 'arch': '<div></div>', + 'key': '_theme_kea_sale.t_called_view', + 'website_id': website_2.id, + }).with_context(load_all_views=True) + self.env['ir.model.data'].create({ + 'module': '_theme_kea_sale', + 'name': 't_called_view', + 'model': 'theme.ir.ui.view', + 'res_id': called_theme_view.id, + }) + view_from_theme_view_on_w2.write({'arch': '<t t-call="_theme_kea_sale.t_called_view"/>'}) + + # ##################################################### ir.ui.view ############################################### + # id | name | website_id | inherit | key | xml_id | + # ---------------------------------------------------------------------------------------------------------------- + # 1 | Products | / | / | _website_sale.products | / | + # 2 | Child View W1 | 1 | 1 | _website_sale.child_view_w1 | / | + # 3 | Products Theme Kea | 2 | 1 | _theme_kea_sale.products | / | + # 4 | Called View Kea | 2 | / | _theme_kea_sale.t_called_view | / | + + # ################################################# theme.ir.ui.view ############################################# + # id | name | inherit | key | xml_id | + # ---------------------------------------------------------------------------------------------------------------- + # 1 | Products Theme Kea | 1 | _theme_kea_sale.products | _theme_kea_sale.products | + # 1 | Called View Kea | / | _theme_kea_sale.t_called_view | _theme_kea_sale.t_called_view | + + # Next line should not crash (was mixing apples and oranges - ir.ui.view and theme.ir.ui.view) + views = View.with_context(website_id=website_1.id).get_related_views('_website_sale.products') + self.assertEquals(len(views), 2, "It should not mix apples and oranges, only ir.ui.view ['_website_sale.products', '_website_sale.child_view_w1'] should be returned (2)") + views = View.with_context(website_id=website_2.id).get_related_views('_website_sale.products') + self.assertEquals(len(views), 3, "It should not mix apples and oranges, only ir.ui.view ['_website_sale.products', '_theme_kea_sale.products', '_theme_kea_sale.t_called_view'] should be returned") + + # ######################################################## + # Test the controller (which is calling get_related_views) + self.authenticate("admin", "admin") + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + + # Simulate website 2 + url = base_url + '/website/force_website' + json = {'params': {'website_id': website_2.id}} + self.opener.post(url=url, json=json) + + # Test controller + url = base_url + '/website/get_switchable_related_views' + json = {'params': {'key': '_website_sale.products'}} + response = self.opener.post(url=url, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()['result']), 1, "Only '_theme_kea_sale.products' should be returned as it is the only customize_show related view in website 2 context") + self.assertEqual(response.json()['result'][0]['key'], '_theme_kea_sale.products', "Only '_theme_kea_sale.products' should be returned") + + # Simulate website 1 + url = base_url + '/website/force_website' + json = {'params': {'website_id': website_1.id}} + self.opener.post(url=url, json=json) + + # Test controller + url = base_url + '/website/get_switchable_related_views' + json = {'params': {'key': '_website_sale.products'}} + response = self.opener.post(url=url, json=json) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()['result']), 1, "Only '_website_sale.child_view_w1' should be returned as it is the only customize_show related view in website 1 context") + self.assertEqual(response.json()['result'][0]['key'], '_website_sale.child_view_w1', "Only '_website_sale.child_view_w1' should be returned") |
