summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-01-09 15:44:05 +0000
committerChristophe Simonis <chs@odoo.com>2019-01-09 15:56:53 +0000
commit8aa8548d8ab68feb13f3bde51c557db84877e809 (patch)
tree930790c1c84a4412db777986f6b2077808560d95 /addons
parent55a48e341580a6a400c36b6ba0d7b12ff9997f12 (diff)
parent3e4138deaa23b3631dc3fce33cd6d1b3be28ffbd (diff)
[MERGE] forward port branch 12.0 up to 3e4138deaa2
closes odoo/odoo#30045
Diffstat (limited to 'addons')
-rw-r--r--addons/account/models/account.py2
-rw-r--r--addons/account/models/account_invoice.py9
-rw-r--r--addons/account/models/account_journal_dashboard.py4
-rw-r--r--addons/account/models/account_move.py4
-rw-r--r--addons/account/models/chart_template.py16
-rw-r--r--addons/account/security/account_security.xml2
-rw-r--r--addons/account/tests/__init__.py1
-rw-r--r--addons/account/tests/test_account_fiscal_year.py9
-rw-r--r--addons/account/tests/test_invoice_onchange.py33
-rw-r--r--addons/account/views/account_view.xml2
-rw-r--r--addons/account_check_printing/models/account_payment.py2
-rw-r--r--addons/account_facturx/data/facturx_templates.xml8
-rw-r--r--addons/base_address_extended/data/base_address_extended_data.xml3
-rw-r--r--addons/base_address_extended/tests/test_street_fields.py2
-rw-r--r--addons/base_address_extended/views/base_address_extended.xml12
-rw-r--r--addons/base_automation/models/__init__.py1
-rw-r--r--addons/base_automation/models/ir_demo.py22
-rw-r--r--addons/base_setup/views/res_config_settings_views.xml2
-rw-r--r--addons/event_sale/__manifest__.py1
-rw-r--r--addons/event_sale/models/sale_order.py2
-rw-r--r--addons/event_sale/static/src/js/tour.js13
-rw-r--r--addons/event_sale/views/event_views_template.xml8
-rw-r--r--addons/fleet/models/fleet_vehicle.py8
-rw-r--r--addons/gamification/models/challenge.py6
-rw-r--r--addons/hr_attendance/__init__.py1
-rw-r--r--addons/hr_attendance/controllers/__init__.py3
-rw-r--r--addons/hr_attendance/controllers/main.py12
-rw-r--r--addons/hr_attendance/static/src/js/kiosk_mode.js2
-rw-r--r--addons/hr_holidays/models/hr_leave.py19
-rw-r--r--addons/hr_holidays/models/hr_leave_type.py4
-rw-r--r--addons/hr_holidays/views/hr_leave_views.xml2
-rw-r--r--addons/iap/models/iap.py2
-rw-r--r--addons/im_livechat/static/src/scss/im_livechat_history.scss2
-rw-r--r--addons/l10n_be/data/account_chart_template_data.xml2
-rw-r--r--addons/l10n_be/i18n_extra/nl.po (renamed from addons/l10n_be/i18n_extra/nl_BE.po)0
-rw-r--r--addons/l10n_ca/data/account_chart_template_data.xml2
-rw-r--r--addons/l10n_ca/i18n_extra/fr.po (renamed from addons/l10n_ca/i18n_extra/fr_CA.po)0
-rw-r--r--addons/l10n_nl/data/account_data.xml9
-rw-r--r--addons/l10n_nl/data/account_fiscal_position_tax_template.xml40
-rw-r--r--addons/l10n_nl/data/account_tax_template.xml255
-rw-r--r--addons/l10n_no/data/account_data.xml3
-rw-r--r--addons/l10n_no/data/account_tax_data.xml29
-rw-r--r--addons/mail/data/mail_data.xml2
-rw-r--r--addons/mail/models/mail_channel.py7
-rw-r--r--addons/mail/static/src/js/tools/debug_manager.js4
-rw-r--r--addons/mail/static/tests/tools/debug_manager_tests.js4
-rw-r--r--addons/mail/wizard/mail_resend_message.py2
-rw-r--r--addons/mail_bot/static/src/js/mailbot_service.js36
-rw-r--r--addons/mail_bot/static/src/js/systray_messaging_menu.js1
-rw-r--r--addons/mail_bot/static/tests/systray_messaging_menu_tests.js97
-rw-r--r--addons/mrp/models/mrp_bom.py2
-rw-r--r--addons/mrp/wizard/mrp_product_produce.py2
-rw-r--r--addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js6
-rw-r--r--addons/payment/wizards/payment_acquirer_onboarding_wizard.py5
-rw-r--r--addons/portal/static/src/scss/bootstrap_overridden.scss5
-rw-r--r--addons/portal/static/src/scss/portal.scss2
-rw-r--r--addons/portal/views/portal_templates.xml2
-rw-r--r--addons/product/data/product_demo.xml10
-rw-r--r--addons/product/models/product.py88
-rw-r--r--addons/product/models/product_attribute.py68
-rw-r--r--addons/product/models/product_template.py512
-rw-r--r--addons/product/tests/common.py1
-rw-r--r--addons/product/tests/test_product_attribute_value_config.py385
-rw-r--r--addons/product/views/product_attribute_views.xml4
-rw-r--r--addons/product/views/product_views.xml54
-rw-r--r--addons/purchase/report/purchase_report.py6
-rw-r--r--addons/purchase/views/portal_templates.xml7
-rw-r--r--addons/purchase_requisition/models/stock.py2
-rw-r--r--addons/sale/controllers/portal.py2
-rw-r--r--addons/sale/controllers/product_configurator.py220
-rw-r--r--addons/sale/data/sale_demo.xml1
-rw-r--r--addons/sale/models/analytic.py2
-rw-r--r--addons/sale/models/product_product.py8
-rw-r--r--addons/sale/models/product_template.py220
-rw-r--r--addons/sale/models/sale.py109
-rw-r--r--addons/sale/static/src/js/product_configurator_controller.js14
-rw-r--r--addons/sale/static/src/js/product_configurator_mixin.js199
-rw-r--r--addons/sale/static/src/js/product_configurator_modal.js32
-rw-r--r--addons/sale/static/src/js/tour.js4
-rw-r--r--addons/sale/static/src/scss/product_configurator.scss18
-rw-r--r--addons/sale/tests/__init__.py1
-rw-r--r--addons/sale/tests/test_sale_product_attribute_value_config.py389
-rw-r--r--addons/sale/views/sale_portal_templates.xml3
-rw-r--r--addons/sale/views/sale_product_configurator_templates.xml409
-rw-r--r--addons/sale/views/sale_views.xml1
-rw-r--r--addons/sale/wizard/sale_product_configurator.py2
-rw-r--r--addons/sale_management/security/sale_management_security.xml1
-rw-r--r--addons/sale_management/static/tests/product_configurator_advanced_test_ui.js4
-rw-r--r--addons/sale_management/static/tests/product_configurator_pricelist_test_ui.js100
-rw-r--r--addons/sale_management/static/tests/product_configurator_test_ui.js26
-rw-r--r--addons/sale_management/tests/test_sale_ui.py41
-rw-r--r--addons/sale_management/views/assets.xml3
-rw-r--r--addons/sale_management/views/res_config_settings_views.xml1
-rw-r--r--addons/sale_quotation_builder/__manifest__.py1
-rw-r--r--addons/sale_quotation_builder/views/res_config_settings_views.xml11
-rw-r--r--addons/sale_stock/models/sale_order.py9
-rw-r--r--addons/sale_stock/static/src/js/tour.js31
-rw-r--r--addons/sale_timesheet/models/project.py7
-rw-r--r--addons/snailmail/models/snailmail_letter.py6
-rw-r--r--addons/snailmail/static/src/js/snailmail_external_layout.js7
-rw-r--r--addons/snailmail/static/src/scss/snailmail_external_layout_asset.scss29
-rw-r--r--addons/snailmail/views/report_assets.xml1
-rw-r--r--addons/snailmail_account/__init__.py1
-rw-r--r--addons/snailmail_account/tests/__init__.py1
-rw-r--r--addons/snailmail_account/tests/test_pingen_send.py96
-rw-r--r--addons/stock/models/stock_inventory.py2
-rw-r--r--addons/stock/models/stock_move.py2
-rw-r--r--addons/stock/report/report_location_barcode.xml2
-rw-r--r--addons/stock/static/src/js/stock_traceability_report_widgets.js3
-rw-r--r--addons/stock/wizard/product_replenish.py2
-rw-r--r--addons/stock_account/models/stock.py15
-rw-r--r--addons/stock_picking_batch/wizard/stock_picking_to_batch.py2
-rw-r--r--addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml2
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js6
-rw-r--r--addons/web/static/src/js/core/dialog.js6
-rw-r--r--addons/web/static/src/js/core/dom.js2
-rw-r--r--addons/web/static/src/js/tools/debug_manager.js4
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js29
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js15
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js15
-rw-r--r--addons/web/static/src/js/views/form/form_view.js7
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js10
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record.js6
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js3
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js33
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js29
-rw-r--r--addons/web/static/src/js/widgets/attach_document.js5
-rw-r--r--addons/web/static/src/js/widgets/switch_company_menu.js2
-rw-r--r--addons/web/static/src/scss/bootstrap_overridden.scss4
-rw-r--r--addons/web/static/src/scss/primary_variables.scss3
-rw-r--r--addons/web/static/src/xml/base.xml26
-rw-r--r--addons/web/static/src/xml/kanban.xml4
-rw-r--r--addons/web/static/src/xml/menu.xml2
-rw-r--r--addons/web/static/tests/chrome/action_manager_tests.js73
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js166
-rw-r--r--addons/web/static/tests/helpers/test_utils_dom.js17
-rw-r--r--addons/web/static/tests/tools/debug_manager_tests.js4
-rw-r--r--addons/web/static/tests/views/calendar_tests.js92
-rw-r--r--addons/web/static/tests/views/form_tests.js72
-rw-r--r--addons/web/static/tests/views/kanban_tests.js14
-rw-r--r--addons/web/static/tests/views/pivot_tests.js144
-rw-r--r--addons/web/views/report_templates.xml6
-rw-r--r--addons/web_editor/models/ir_ui_view.py20
-rw-r--r--addons/web_editor/static/src/js/editor/transcoder.js10
-rw-r--r--addons/web_tour/static/src/js/running_tour_action_helper.js12
-rw-r--r--addons/website/controllers/main.py1
-rw-r--r--addons/website/data/website_data.xml1
-rw-r--r--addons/website/models/ir_http.py4
-rw-r--r--addons/website/models/ir_ui_view.py74
-rw-r--r--addons/website/models/website.py42
-rw-r--r--addons/website/static/src/js/menu/seo.js2
-rw-r--r--addons/website/static/src/scss/website.scss5
-rw-r--r--addons/website/static/src/xml/website.seo.xml2
-rw-r--r--addons/website/tests/test_views.py236
-rw-r--r--addons/website/views/website_navbar_templates.xml2
-rw-r--r--addons/website_blog/static/src/js/contentshare.js2
-rw-r--r--addons/website_gengo/static/src/xml/website.gengo.xml60
-rw-r--r--addons/website_membership/controllers/main.py2
-rw-r--r--addons/website_sale/controllers/main.py101
-rw-r--r--addons/website_sale/models/product.py198
-rw-r--r--addons/website_sale/models/sale_order.py105
-rw-r--r--addons/website_sale/static/src/js/website_sale.js156
-rw-r--r--addons/website_sale/static/src/js/website_sale_options.js3
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_buy.js6
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js4
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_customize.js97
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js52
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js36
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js39
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_zoom.js50
-rw-r--r--addons/website_sale/static/src/scss/website_sale.scss9
-rw-r--r--addons/website_sale/tests/__init__.py3
-rw-r--r--addons/website_sale/tests/test_customize.py198
-rw-r--r--addons/website_sale/tests/test_website_sale_pricelist.py6
-rw-r--r--addons/website_sale/tests/test_website_sale_product_attribute_value_config.py64
-rw-r--r--addons/website_sale/tests/test_website_sale_zoom.py105
-rw-r--r--addons/website_sale/views/templates.xml212
-rw-r--r--addons/website_sale_comparison/controllers/main.py7
-rw-r--r--addons/website_sale_comparison/models/website_sale_comparison.py2
-rw-r--r--addons/website_sale_comparison/views/website_sale_comparison_template.xml41
-rw-r--r--addons/website_sale_stock/controllers/main.py31
-rw-r--r--addons/website_sale_stock/models/product_template.py32
-rw-r--r--addons/website_sale_stock/static/src/js/product_configurator_mixin.js9
-rw-r--r--addons/website_sale_wishlist/controllers/main.py4
-rw-r--r--addons/website_sale_wishlist/models/product_wishlist.py18
-rw-r--r--addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js11
-rw-r--r--addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js54
-rw-r--r--addons/website_sale_wishlist/views/website_sale_wishlist_template.xml17
-rw-r--r--addons/website_theme_install/tests/__init__.py4
-rw-r--r--addons/website_theme_install/tests/test_views.py151
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 &gt;= 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 &gt; 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) &lt; 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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; 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']&gt;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) &gt; 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']&gt;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) &gt;= 1</attribute>
- </xpath>
- <xpath expr="//div[hasclass('product_price')]/t[2]" position="attributes">
- <attribute name="t-if">len(product.product_variant_ids) &gt; 1 or len(product.optional_product_ids) &gt;= 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) &gt; 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) &gt; 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) &gt; 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) &gt; 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 ) &gt; 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) &gt; 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 ) &gt; 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 ) &gt; 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 &amp; 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 &amp; 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")