summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-03-08 17:56:14 +0100
committerChristophe Simonis <chs@odoo.com>2019-03-08 17:56:14 +0100
commit2c5c9b8342df2748ad1044fa94c7c3ad821245ba (patch)
treeaa6228b4ee7c0038d8759f6d202c2e16e9ac0012
parent7d4ca676809564d592257450f6a7628d0722ba0a (diff)
parentc023d0784f27622f185a1075cba8e408067b5352 (diff)
[MERGE] forward port branch 12.0 up to c023d0784f2
-rw-r--r--addons/account/models/account_bank_statement.py5
-rw-r--r--addons/account/models/account_invoice.py12
-rw-r--r--addons/account/models/account_journal_dashboard.py2
-rw-r--r--addons/account/models/account_payment.py10
-rw-r--r--addons/account/models/reconciliation_widget.py22
-rw-r--r--addons/account/tests/test_reconciliation_widget.py27
-rw-r--r--addons/account/views/account_payment_view.xml2
-rw-r--r--addons/account/views/account_view.xml2
-rw-r--r--addons/account/views/report_invoice.xml2
-rw-r--r--addons/auth_signup/models/res_partner.py8
-rw-r--r--addons/base_automation/data/base_automation_demo.xml15
-rw-r--r--addons/base_automation/models/base_automation.py19
-rw-r--r--addons/base_automation/tests/test_base_automation.py59
-rw-r--r--addons/base_import/static/csv/o2m_customers_contacts.csv16
-rw-r--r--addons/bus/static/src/js/longpolling_bus.js27
-rw-r--r--addons/event_sale/models/sale_order.py5
-rw-r--r--addons/gamification/models/badge.py2
-rw-r--r--addons/hr_fleet/controllers/i18n/az.po115
-rw-r--r--addons/hr_fleet/controllers/i18n/fil.po115
-rw-r--r--addons/hr_gamification/wizard/gamification_badge_user_wizard.py2
-rw-r--r--addons/hr_holidays/models/hr_leave.py3
-rw-r--r--addons/hr_holidays/models/hr_leave_allocation.py2
-rw-r--r--addons/hr_holidays/report/hr_leave_reports.xml2
-rw-r--r--addons/hr_holidays/tests/test_leave_requests.py2
-rw-r--r--addons/l10n_fr_fec/wizard/account_fr_fec.py79
-rw-r--r--addons/l10n_multilang/models/l10n_multilang.py15
-rw-r--r--addons/lunch/views/lunch_orders_views.xml2
-rw-r--r--addons/mail/models/ir_actions.py25
-rw-r--r--addons/mail/models/mail_activity.py16
-rw-r--r--addons/mail/static/src/js/composers/mention_manager.js9
-rw-r--r--addons/mail/static/src/js/thread_widget.js2
-rw-r--r--addons/mail/static/tests/discuss_tests.js79
-rw-r--r--addons/maintenance/models/maintenance.py2
-rw-r--r--addons/mass_mailing/models/mass_mailing.py53
-rw-r--r--addons/mass_mailing/views/unsubscribe_templates.xml2
-rw-r--r--addons/mrp/models/mrp_bom.py4
-rw-r--r--addons/mrp/models/stock_warehouse.py16
-rw-r--r--addons/mrp/views/mrp_unbuild_views.xml2
-rw-r--r--addons/payment_authorize/models/payment.py15
-rw-r--r--addons/payment_authorize/tests/test_authorize.py15
-rw-r--r--addons/point_of_sale/static/src/css/pos.css2
-rw-r--r--addons/point_of_sale/static/src/js/screens.js2
-rw-r--r--addons/point_of_sale/views/pos_order_view.xml2
-rw-r--r--addons/point_of_sale/views/pos_session_view.xml2
-rw-r--r--addons/portal/wizard/portal_share.py2
-rw-r--r--addons/portal/wizard/portal_wizard.py2
-rw-r--r--addons/product/models/product.py21
-rw-r--r--addons/product/models/product_attribute.py26
-rw-r--r--addons/product/models/product_pricelist.py14
-rw-r--r--addons/product/models/product_template.py293
-rw-r--r--addons/product/tests/test_product_attribute_value_config.py91
-rw-r--r--addons/product/views/product_views.xml3
-rw-r--r--addons/purchase_requisition/models/purchase_requisition.py2
-rw-r--r--addons/purchase_requisition/views/purchase_requisition_views.xml4
-rw-r--r--addons/purchase_stock/models/purchase.py7
-rw-r--r--addons/repair/views/repair_views.xml2
-rw-r--r--addons/sale/controllers/product_configurator.py5
-rw-r--r--addons/sale/data/mail_data.xml2
-rw-r--r--addons/sale/models/payment.py2
-rw-r--r--addons/sale/models/product_template.py22
-rw-r--r--addons/sale/models/res_config_settings.py2
-rw-r--r--addons/sale/models/res_partner.py22
-rw-r--r--addons/sale/models/sale.py28
-rw-r--r--addons/sale/report/sale_report_templates.xml4
-rw-r--r--addons/sale/static/src/js/product_configurator_mixin.js8
-rw-r--r--addons/sale/static/src/js/product_configurator_modal.js12
-rw-r--r--addons/sale/tests/test_sale_product_attribute_value_config.py12
-rw-r--r--addons/sale_management/models/sale_order.py1
-rw-r--r--addons/sale_management/models/sale_order_template.py5
-rw-r--r--addons/sale_management/static/tests/product_configurator_advanced_test_ui.js21
-rw-r--r--addons/stock/models/stock_inventory.py2
-rw-r--r--addons/stock/models/stock_location.py6
-rw-r--r--addons/stock/models/stock_move.py3
-rw-r--r--addons/stock/models/stock_picking.py2
-rw-r--r--addons/stock/models/stock_scrap.py3
-rw-r--r--addons/stock/models/stock_warehouse.py37
-rw-r--r--addons/stock/tests/test_move.py9
-rw-r--r--addons/stock/tests/test_move2.py7
-rw-r--r--addons/stock/views/stock_inventory_views.xml2
-rw-r--r--addons/stock/views/stock_move_views.xml4
-rw-r--r--addons/stock_account/views/product_views.xml2
-rw-r--r--addons/stock_account/views/stock_account_views.xml8
-rw-r--r--addons/stock_picking_batch/views/stock_picking_batch_views.xml2
-rw-r--r--addons/survey/views/survey_user_views.xml4
-rw-r--r--addons/test_website/data/test_website_data.xml8
-rw-r--r--addons/test_website/static/src/js/reset_views.test.js2
-rw-r--r--addons/test_website/tests/__init__.py1
-rw-r--r--addons/test_website/tests/test_views_during_module_operation.py72
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js47
-rw-r--r--addons/web/static/src/js/fields/field_registry.js3
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js12
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record.js13
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js16
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js4
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js1
-rw-r--r--addons/web/static/src/scss/fields.scss5
-rw-r--r--addons/web/static/src/scss/form_view.scss10
-rw-r--r--addons/web/static/src/xml/base.xml4
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js86
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js24
-rw-r--r--addons/web/static/tests/views/kanban_tests.js110
-rw-r--r--addons/web/static/tests/views/list_tests.js46
-rw-r--r--addons/web/static/tests/views/pivot_tests.js42
-rw-r--r--addons/web_editor/models/ir_ui_view.py3
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.editor.js16
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.options.js8
-rw-r--r--addons/web_editor/static/src/scss/web_editor.ui.scss3
-rw-r--r--addons/web_unsplash/models/__init__.py1
-rw-r--r--addons/web_unsplash/models/ir_qweb.py26
-rw-r--r--addons/website/__init__.py16
-rw-r--r--addons/website/__manifest__.py1
-rw-r--r--addons/website/models/ir_http.py4
-rw-r--r--addons/website/models/ir_ui_view.py7
-rw-r--r--addons/website/static/src/js/tours/rte.js20
-rw-r--r--addons/website/static/src/scss/website.edit_mode.scss3
-rw-r--r--addons/website/tests/test_page.py3
-rw-r--r--addons/website/tests/test_views.py3
-rw-r--r--addons/website_blog/views/website_blog_templates.xml3
-rw-r--r--addons/website_event_sale/models/__init__.py1
-rw-r--r--addons/website_event_sale/models/product_pricelist.py23
-rw-r--r--addons/website_sale/controllers/main.py2
-rw-r--r--addons/website_sale/models/product.py31
-rw-r--r--addons/website_sale/models/website.py2
-rw-r--r--addons/website_sale/static/src/js/website_sale.js16
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js26
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_customize.js10
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js10
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js1
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js98
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js6
-rw-r--r--addons/website_sale/tests/test_customize.py20
-rw-r--r--addons/website_sale/tests/test_website_sale_pricelist.py9
-rw-r--r--addons/website_sale/tests/test_website_sale_product_attribute_value_config.py2
-rw-r--r--addons/website_sale/views/templates.xml33
-rw-r--r--addons/website_sale_comparison/views/website_sale_comparison_template.xml6
-rw-r--r--addons/website_sale_stock/controllers/main.py6
-rw-r--r--addons/website_sale_stock/models/product_template.py9
-rw-r--r--addons/website_sale_wishlist/views/website_sale_wishlist_template.xml4
-rw-r--r--addons/website_theme_install/tests/__init__.py1
-rw-r--r--addons/website_theme_install/tests/test_theme.py14
-rw-r--r--doc/_static/banners/email_gateway.jpgbin0 -> 83480 bytes
-rw-r--r--doc/_static/banners/email_gateway.small.jpgbin0 -> 28803 bytes
-rw-r--r--doc/cla/corporate/adhoc.md1
-rw-r--r--doc/cla/corporate/projet-grup.md18
-rw-r--r--doc/cla/corporate/savoirfairelinux.md2
-rw-r--r--doc/cla/individual/cantecim.md11
-rw-r--r--doc/cla/individual/mjabuz.md11
-rw-r--r--doc/howtos/backend/exercise-calendar9
-rw-r--r--doc/howtos/backend/exercise-gantt6
-rw-r--r--doc/howtos/backend/exercise-graph2
-rw-r--r--doc/howtos/backend/exercise-translations2
-rw-r--r--doc/setup.rst1
-rw-r--r--doc/setup/email_gateway.rst43
-rw-r--r--odoo/addons/base/models/ir_model.py3
-rw-r--r--odoo/addons/base/models/ir_rule.py2
-rw-r--r--odoo/addons/base/models/qweb.py101
-rw-r--r--odoo/addons/base/models/res_users.py34
-rw-r--r--odoo/addons/base/security/base_security.xml31
-rw-r--r--odoo/addons/base/static/xls/res_partner.xlsbin33792 -> 7168 bytes
-rw-r--r--odoo/addons/base/tests/test_expression.py30
-rw-r--r--odoo/addons/base/tests/test_qweb.py41
-rw-r--r--odoo/addons/base/tests/test_user_has_group.py100
-rw-r--r--odoo/addons/base/wizard/base_import_language.py2
-rw-r--r--odoo/addons/test_new_api/tests/test_ui.py7
-rw-r--r--odoo/addons/test_testing_utilities/ir.model.access.csv2
-rw-r--r--odoo/addons/test_testing_utilities/menu.xml17
-rw-r--r--odoo/addons/test_testing_utilities/models.py22
-rw-r--r--odoo/addons/test_testing_utilities/tests/test_form_impl.py34
-rw-r--r--odoo/fields.py1
-rw-r--r--odoo/osv/expression.py2
-rw-r--r--odoo/tests/common.py10
171 files changed, 2191 insertions, 831 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index d22962ebbd0..e03dea98b26 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -618,9 +618,12 @@ class AccountBankStatementLine(models.Model):
# Create The payment
payment = self.env['account.payment']
+ partner_id = self.partner_id or (aml_dict.get('move_line') and aml_dict['move_line'].partner_id) or self.env['res.partner']
if abs(total)>0.00001:
payment_vals = self._prepare_payment_vals(total)
- if self.partner_id and len(account_types) == 1:
+ if not payment_vals['partner_id']:
+ payment_vals['partner_id'] = partner_id.id
+ if payment_vals['partner_id'] and len(account_types) == 1:
payment_vals['partner_type'] = 'customer' if account_types == receivable_account_type else 'supplier'
payment = payment.create(payment_vals)
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index 6ded10a62cc..141658dc19f 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -1583,12 +1583,14 @@ class AccountInvoice(models.Model):
fmt = partial(formatLang, invoice.with_context(lang=invoice.partner_id.lang).env, currency_obj=currency)
res = {}
for line in invoice.tax_line_ids:
- res.setdefault(line.tax_id.tax_group_id, {'base': 0.0, 'amount': 0.0})
- res[line.tax_id.tax_group_id]['amount'] += line.amount_total
- res[line.tax_id.tax_group_id]['base'] += line.base
- res = sorted(res.items(), key=lambda l: l[0].sequence)
+ tax = line.tax_id
+ group_key = (tax.tax_group_id, tax.amount_type, tax.amount)
+ res.setdefault(group_key, {'base': 0.0, 'amount': 0.0})
+ res[group_key]['amount'] += line.amount_total
+ res[group_key]['base'] += line.base
+ res = sorted(res.items(), key=lambda l: l[0][0].sequence)
invoice.amount_by_group = [(
- r[0].name, r[1]['amount'], r[1]['base'],
+ r[0][0].name, r[1]['amount'], r[1]['base'],
fmt(r[1]['amount']), fmt(r[1]['base']),
len(res),
) for r in res]
diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py
index eeaca15fae8..7d19678ebcf 100644
--- a/addons/account/models/account_journal_dashboard.py
+++ b/addons/account/models/account_journal_dashboard.py
@@ -169,7 +169,7 @@ class account_journal(models.Model):
LEFT JOIN account_move move ON aml.move_id = move.id
WHERE aml.account_id in %%s
AND move.date <= %%s AND move.state = 'posted';""" % (amount_field,)
- self.env.cr.execute(query, (account_ids, fields.Date.today(),))
+ self.env.cr.execute(query, (account_ids, fields.Date.context_today(self),))
query_results = self.env.cr.dictfetchall()
if query_results and query_results[0].get('sum') != None:
account_sum = query_results[0].get('sum')
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index 7dffbaa2233..f4cb84a3531 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -236,10 +236,14 @@ class account_abstract_payment(models.AbstractModel):
currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id or invoices and invoices[0].currency_id
# Avoid currency rounding issues by summing the amounts according to the company_currency_id before
+ invoice_datas = invoices.read_group(
+ [('id', 'in', invoices.ids)],
+ ['currency_id', 'type', 'residual_signed'],
+ ['currency_id', 'type'], lazy=False)
total = 0.0
- groups = groupby(invoices, lambda i: i.currency_id)
- for payment_currency, payment_invoices in groups:
- amount_total = sum([MAP_INVOICE_TYPE_PAYMENT_SIGN[i.type] * i.residual_signed for i in payment_invoices])
+ for invoice_data in invoice_datas:
+ amount_total = MAP_INVOICE_TYPE_PAYMENT_SIGN[invoice_data['type']] * invoice_data['residual_signed']
+ payment_currency = self.env['res.currency'].browse(invoice_data['currency_id'][0])
if payment_currency == currency:
total += amount_total
else:
diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py
index df92a5b45f1..0db2460b478 100644
--- a/addons/account/models/reconciliation_widget.py
+++ b/addons/account/models/reconciliation_widget.py
@@ -134,11 +134,12 @@ class AccountReconciliation(models.AbstractModel):
else:
aml_ids = matching_amls[line.id]['aml_ids']
bank_statements_left += line.statement_id
+ target_currency = line.currency_id or line.journal_id.currency_id or line.journal_id.company_id.currency_id
amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
line_vals = {
'st_line': self._get_statement_line(line),
- 'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls) or [],
+ 'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls, target_currency=target_currency, target_date=line.date) or [],
'model_id': matching_amls[line.id].get('model') and matching_amls[line.id]['model'].id,
'write_off': matching_amls[line.id].get('status') == 'write_off',
}
@@ -453,21 +454,12 @@ class AccountReconciliation(models.AbstractModel):
('payment_id', '<>', False)
]
- # Black lines = unreconciled & (not linked to a payment or open balance created by statement
- domain_matching = [('reconciled', '=', False)]
- if partner_id:
- domain_matching = expression.AND([
- domain_matching,
- [('account_id.internal_type', 'in', ['payable', 'receivable'])]
- ])
- else:
- # TODO : find out what use case this permits (match a check payment, registered on a journal whose account type is other instead of liquidity)
- domain_matching = expression.AND([
- domain_matching,
- [('account_id.reconcile', '=', True)]
- ])
+ # default domain matching
+ domain_matching = expression.AND([
+ [('reconciled', '=', False)],
+ [('account_id.reconcile', '=', True)]
+ ])
- # Let's add what applies to both
domain = expression.OR([domain_reconciliation, domain_matching])
if partner_id:
domain = expression.AND([domain, [('partner_id', '=', partner_id)]])
diff --git a/addons/account/tests/test_reconciliation_widget.py b/addons/account/tests/test_reconciliation_widget.py
index 5d2dc637ed7..6edb1fa108b 100644
--- a/addons/account/tests/test_reconciliation_widget.py
+++ b/addons/account/tests/test_reconciliation_widget.py
@@ -1,6 +1,7 @@
import logging
import odoo.tests
import time
+from odoo.addons.account.tests.test_reconciliation import TestReconciliation
_logger = logging.getLogger(__name__)
@@ -22,3 +23,29 @@ class TestUi(odoo.tests.HttpCase):
self.phantom_js("/web#statement_ids=" + str(bank_stmt.id) + "&action=bank_statement_reconciliation_view",
"odoo.__DEBUG__.services['web_tour.tour'].run('bank_statement_reconciliation')",
"odoo.__DEBUG__.services['web_tour.tour'].tours.bank_statement_reconciliation.ready", login="admin")
+
+
+@odoo.tests.tagged('post_install', '-at_install')
+class TestReconciliationWidget(TestReconciliation):
+
+ def test_statement_suggestion_other_currency(self):
+ # company currency is EUR
+ # payment in USD
+ invoice = self.create_invoice(invoice_amount=50, currency_id=self.currency_usd_id)
+
+ # journal currency in USD
+ bank_stmt = self.acc_bank_stmt_model.create({
+ 'journal_id': self.bank_journal_usd.id,
+ 'date': time.strftime('%Y-07-15'),
+ 'name': 'payment %s' % invoice.number,
+ })
+
+ bank_stmt_line = self.acc_bank_stmt_line_model.create({'name': 'payment',
+ 'statement_id': bank_stmt.id,
+ 'partner_id': self.partner_agrolait_id,
+ 'amount': 50,
+ 'date': time.strftime('%Y-07-15'),
+ })
+
+ result = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids)
+ self.assertEqual(result['lines'][0]['reconciliation_proposition'][0]['amount_str'], '$ 50.00') \ No newline at end of file
diff --git a/addons/account/views/account_payment_view.xml b/addons/account/views/account_payment_view.xml
index 7837959615f..70d7c869d8f 100644
--- a/addons/account/views/account_payment_view.xml
+++ b/addons/account/views/account_payment_view.xml
@@ -71,7 +71,7 @@
</div>
<div class="col-6">
<span class="float-right badge badge-secondary">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'sent': 'success'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'sent': 'success'}}"/>
</span>
</div>
</div>
diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml
index 80d2d4e589e..4335e976826 100644
--- a/addons/account/views/account_view.xml
+++ b/addons/account/views/account_view.xml
@@ -1545,7 +1545,7 @@
</div>
<div class="col-6">
<span class="float-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'posted': 'success'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'posted': 'success'}}"/>
</span>
</div>
</div>
diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml
index 75140895390..bd55957c7c1 100644
--- a/addons/account/views/report_invoice.xml
+++ b/addons/account/views/report_invoice.xml
@@ -84,7 +84,7 @@
<span t-field="line.uom_id" groups="uom.group_uom"/>
</td>
<td t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
- <span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
+ <span t-field="line.price_unit"/>
</td>
<td t-if="display_discount" t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span t-field="line.discount"/>
diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py
index 5703e018548..7b8e176c9ac 100644
--- a/addons/auth_signup/models/res_partner.py
+++ b/addons/auth_signup/models/res_partner.py
@@ -27,16 +27,16 @@ class ResPartner(models.Model):
signup_token = fields.Char(copy=False, groups="base.group_erp_manager")
signup_type = fields.Char(string='Signup Token Type', copy=False, groups="base.group_erp_manager")
signup_expiration = fields.Datetime(copy=False, groups="base.group_erp_manager")
- signup_valid = fields.Boolean(compute='_compute_signup_valid', compute_sudo=True, string='Signup Token is Valid')
+ signup_valid = fields.Boolean(compute='_compute_signup_valid', string='Signup Token is Valid')
signup_url = fields.Char(compute='_compute_signup_url', string='Signup URL')
@api.multi
@api.depends('signup_token', 'signup_expiration')
def _compute_signup_valid(self):
dt = now()
- for partner in self:
- partner.signup_valid = bool(partner.signup_token) and \
- (not partner.signup_expiration or dt <= partner.signup_expiration)
+ for partner, partner_sudo in zip(self, self.sudo()):
+ partner.signup_valid = bool(partner_sudo.signup_token) and \
+ (not partner_sudo.signup_expiration or dt <= partner_sudo.signup_expiration)
@api.multi
def _compute_signup_url(self):
diff --git a/addons/base_automation/data/base_automation_demo.xml b/addons/base_automation/data/base_automation_demo.xml
index 246320ecf4a..5ffdc94d6a3 100644
--- a/addons/base_automation/data/base_automation_demo.xml
+++ b/addons/base_automation/data/base_automation_demo.xml
@@ -71,4 +71,19 @@ if 'user_id' in env.context['old_values'][record.id]:
</field>
</record>
+ <record id="test_mail_template_automation" model="mail.template">
+ <field name="name">Template Automation</field>
+ <field name="model_id" ref="base_automation.model_base_automation_lead_test"/>
+ <field name="body_html">&lt;div&gt;Email automation&lt;/div&gt;</field>
+ </record>
+ <record id="test_rule_on_write_recompute_send_email" model="base.automation">
+ <field name="name">Base Automation: test send an email</field>
+ <field name="model_id" ref="base_automation.model_base_automation_lead_test"/>
+ <field name="template_id" ref="base_automation.test_mail_template_automation"/>
+ <field name="state">email</field>
+ <field name="trigger">on_write</field>
+ <field name="active" eval="True"/>
+ <field name="filter_domain">[('deadline', '!=', False)]</field>
+ <field name="filter_pre_domain">[('deadline', '=', False)]</field>
+ </record>
</odoo>
diff --git a/addons/base_automation/models/base_automation.py b/addons/base_automation/models/base_automation.py
index ac3e6305c6b..a482d7d5911 100644
--- a/addons/base_automation/models/base_automation.py
+++ b/addons/base_automation/models/base_automation.py
@@ -161,14 +161,17 @@ class BaseAutomation(models.Model):
return records
def _filter_post(self, records):
+ return self._filter_post_export_domain(records)[0]
+
+ def _filter_post_export_domain(self, records):
""" Filter the records that satisfy the postcondition of action ``self``. """
if self.filter_domain and records:
domain = [('id', 'in', records.ids)] + safe_eval(self.filter_domain, self._get_eval_context())
- return records.search(domain)
+ return records.search(domain), domain
else:
- return records
+ return records, None
- def _process(self, records):
+ def _process(self, records, domain_post=None):
""" Process action ``self`` on the ``records`` that have not been done yet. """
# filter out the records on which self has already been done
action_done = self._context['__action_done']
@@ -193,7 +196,12 @@ class BaseAutomation(models.Model):
# execute server actions
if self.action_server_id:
for record in records:
- ctx = {'active_model': record._name, 'active_ids': record.ids, 'active_id': record.id}
+ ctx = {
+ 'active_model': record._name,
+ 'active_ids': record.ids,
+ 'active_id': record.id,
+ 'domain_post': domain_post,
+ }
self.action_server_id.with_context(**ctx).run()
@api.model_cr
@@ -247,7 +255,8 @@ class BaseAutomation(models.Model):
_write.origin(records, vals, **kw)
# check postconditions, and execute actions on the records that satisfy them
for action in actions.with_context(old_values=old_values):
- action._process(action._filter_post(pre[action]))
+ records, domain_post = action._filter_post_export_domain(pre[action])
+ action._process(records, domain_post=domain_post)
return True
return _write
diff --git a/addons/base_automation/tests/test_base_automation.py b/addons/base_automation/tests/test_base_automation.py
index d71423f4e69..c3fcf6e7ad5 100644
--- a/addons/base_automation/tests/test_base_automation.py
+++ b/addons/base_automation/tests/test_base_automation.py
@@ -1,5 +1,6 @@
# # -*- coding: utf-8 -*-
# # Part of Odoo. See LICENSE file for full copyright and licensing details.
+from unittest.mock import patch
from odoo.tests import common
@@ -9,13 +10,14 @@ class base_automation_test(common.TransactionCase):
def setUp(self):
super(base_automation_test, self).setUp()
- self.user_admin = self.env.ref('base.user_root')
+ self.user_root = self.env.ref('base.user_root')
+ self.user_admin = self.env.ref('base.user_admin')
self.user_demo = self.env.ref('base.user_demo')
def create_lead(self, **kwargs):
vals = {
'name': "Lead Test",
- 'user_id': self.user_admin.id,
+ 'user_id': self.user_root.id,
}
vals.update(kwargs)
return self.env['base.automation.lead.test'].create(vals)
@@ -27,7 +29,7 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open')
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
def test_01_check_to_state_draft_post(self):
"""
@@ -49,15 +51,15 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
# change state to pending and check that responsible has not changed
lead.write({'state': 'pending'})
self.assertEqual(lead.state, 'pending', "Lead state should be 'pending'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
# change state to done and check that responsible has not changed
lead.write({'state': 'done'})
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
def test_03_check_from_draft_to_done_without_steps(self):
"""
@@ -70,7 +72,7 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
# change state to done and check that responsible has changed
lead.write({'state': 'done'})
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
@@ -85,7 +87,7 @@ class base_automation_test(common.TransactionCase):
partner.write({'customer': False})
lead = self.create_lead(state='open', partner_id=partner.id)
self.assertFalse(lead.customer, "Customer field should updated to False")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
# change partner, recompute on lead should trigger the rule
partner.write({'customer': True})
self.assertTrue(lead.customer, "Customer field should updated to True")
@@ -101,15 +103,52 @@ class base_automation_test(common.TransactionCase):
self.assertFalse(lead.deadline, 'There should not be a deadline defined')
# change priority and user; this triggers deadline recomputation, and
# the server action should set the boolean field to True
- lead.write({'priority': True, 'user_id': self.user_admin.id})
+ lead.write({'priority': True, 'user_id': self.user_root.id})
self.assertTrue(lead.deadline, 'Deadline should be defined')
self.assertTrue(lead.is_assigned_to_admin, 'Lead should be assigned to admin')
+ def test_11b_recomputed_field(self):
+ mail_automation = self.env.ref('base_automation.test_rule_on_write_recompute_send_email')
+ send_mail_count = 0
+
+ def _patched_get_actions(*args, **kwargs):
+ obj = args[0]
+ if '__action_done' not in obj._context:
+ obj = obj.with_context(__action_done={})
+ return mail_automation.with_env(obj.env)
+
+ def _patched_send_mail(*args, **kwargs):
+ nonlocal send_mail_count
+ send_mail_count += 1
+
+ patchers = [
+ patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._get_actions', _patched_get_actions),
+ patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail),
+ ]
+
+ patchers[0].start()
+
+ lead = self.create_lead()
+ self.assertFalse(lead.priority)
+ self.assertFalse(lead.deadline)
+
+ patchers[1].start()
+
+ lead.write({'priority': True})
+
+ self.assertTrue(lead.priority)
+ self.assertTrue(lead.deadline)
+
+ for patcher in patchers:
+ patcher.stop()
+
+ self.assertEqual(send_mail_count, 1)
+
def test_12_recursive(self):
""" Check that a rule is executed recursively by a secondary change. """
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open')
- self.assertEqual(lead.user_id, self.user_admin)
+ self.assertEqual(lead.user_id, self.user_root)
# change partner; this should trigger the rule that modifies the state
partner = self.env.ref('base.res_partner_1')
lead.write({'partner_id': partner.id})
diff --git a/addons/base_import/static/csv/o2m_customers_contacts.csv b/addons/base_import/static/csv/o2m_customers_contacts.csv
index bf142142d3c..d29a600f18b 100644
--- a/addons/base_import/static/csv/o2m_customers_contacts.csv
+++ b/addons/base_import/static/csv/o2m_customers_contacts.csv
@@ -1,9 +1,9 @@
Name,Is a company,Related company,Address type,Customer,Supplier,Street,ZIP,City,State,Country
-Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,CA,United States
-Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,CA,United States
-House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,CA,United States
-Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,CA,United States
-Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,CA,United States
-Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,CA,United States
-International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,DC,United States
-Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,DC,United States
+Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,California,United States
+Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,California,United States
+House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,California,United States
+Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,California,United States
+Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,California,United States
+Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,California,United States
+International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,D.C.,United States
+Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,D.C.,United States
diff --git a/addons/bus/static/src/js/longpolling_bus.js b/addons/bus/static/src/js/longpolling_bus.js
index a24dcb593fd..750db65eba2 100644
--- a/addons/bus/static/src/js/longpolling_bus.js
+++ b/addons/bus/static/src/js/longpolling_bus.js
@@ -32,31 +32,34 @@ var LongpollingBus = Bus.extend(ServicesMixin, {
init: function (parent, params) {
this._super.apply(this, arguments);
this._id = _.uniqueId('bus');
+
+ // the _id is modified by crosstab_bus, so we can't use it to unbind the events in the destroy.
+ this._longPollingBusId = this._id;
this._options = {};
this._channels = [];
// bus presence
this._lastPresenceTime = new Date().getTime();
this._lastPartnersPresenceCheck = this._lastPresenceTime;
- $(window).on("focus." + this._id, this._onFocusChange.bind(this, {focus: true}));
- $(window).on("blur." + this._id, this._onFocusChange.bind(this, {focus: false}));
- $(window).on("unload." + this._id, this._onFocusChange.bind(this, {focus: false}));
+ $(window).on("focus." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: true}));
+ $(window).on("blur." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: false}));
+ $(window).on("unload." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: false}));
- $(window).on("click." + this._id, this._onPresence.bind(this));
- $(window).on("keydown." + this._id, this._onPresence.bind(this));
- $(window).on("keyup." + this._id, this._onPresence.bind(this));
+ $(window).on("click." + this._longPollingBusId, this._onPresence.bind(this));
+ $(window).on("keydown." + this._longPollingBusId, this._onPresence.bind(this));
+ $(window).on("keyup." + this._longPollingBusId, this._onPresence.bind(this));
},
/**
* @override
*/
destroy: function () {
this.stopPolling();
- $(window).off("focus." + this._id);
- $(window).off("blur." + this._id);
- $(window).off("unload." + this._id);
- $(window).off("click." + this._id);
- $(window).off("keydown." + this._id);
- $(window).off("keyup." + this._id);
+ $(window).off("focus." + this._longPollingBusId);
+ $(window).off("blur." + this._longPollingBusId);
+ $(window).off("unload." + this._longPollingBusId);
+ $(window).off("click." + this._longPollingBusId);
+ $(window).off("keydown." + this._longPollingBusId);
+ $(window).off("keyup." + this._longPollingBusId);
this._super();
},
//--------------------------------------------------------------------------
diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py
index 1b15093de03..fac1730bead 100644
--- a/addons/event_sale/models/sale_order.py
+++ b/addons/event_sale/models/sale_order.py
@@ -73,6 +73,11 @@ class SaleOrderLine(models.Model):
if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id):
self.event_ticket_id = None
+ @api.onchange('product_uom', 'product_uom_qty')
+ def product_uom_change(self):
+ if not self.event_ticket_id:
+ super(SaleOrderLine, self).product_uom_change()
+
@api.onchange('event_ticket_id')
def _onchange_event_ticket_id(self):
company = self.event_id.company_id or self.env.user.company_id
diff --git a/addons/gamification/models/badge.py b/addons/gamification/models/badge.py
index 6686b29fee5..9997b443d33 100644
--- a/addons/gamification/models/badge.py
+++ b/addons/gamification/models/badge.py
@@ -231,7 +231,7 @@ class GamificationBadge(models.Model):
elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids:
return self.USER_NOT_VIP
elif self.rule_auth == 'having':
- all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)])
+ all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)]).mapped('badge_id')
if self.rule_auth_badge_ids - all_user_badges:
return self.BADGE_REQUIRED
diff --git a/addons/hr_fleet/controllers/i18n/az.po b/addons/hr_fleet/controllers/i18n/az.po
deleted file mode 100644
index 23fa2458e4d..00000000000
--- a/addons/hr_fleet/controllers/i18n/az.po
+++ /dev/null
@@ -1,115 +0,0 @@
-# Translation of Odoo Server.
-# This file contains the translation of the following modules:
-# * crm_project
-#
-msgid ""
-msgstr ""
-"Project-Id-Version: Odoo Server saas~11.5\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2018-09-21 13:17+0000\n"
-"PO-Revision-Date: 2018-08-24 09:17+0000\n"
-"Language-Team: Azerbaijani (https://www.transifex.com/odoo/teams/41243/az/)\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: \n"
-"Language: az\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid ""
-"<span class=\"o_stat_text\">Convert To</span>\n"
-" <span class=\"o_stat_text\">Task</span>"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Cancel"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid "Convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Create Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Create a new customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
-msgid "Created by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
-msgid "Created on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
-msgid "Customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
-msgid "Display Name"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Do not link to a customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
-msgid "ID"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
-msgid "Last Modified on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
-msgid "Last Updated by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
-msgid "Last Updated on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
-msgid "Lead"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model,name:crm_project.model_crm_lead_convert2task
-msgid "Lead convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Link to an existing customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
-msgid "Project"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
-msgid "Related Customer"
-msgstr ""
diff --git a/addons/hr_fleet/controllers/i18n/fil.po b/addons/hr_fleet/controllers/i18n/fil.po
deleted file mode 100644
index f3c56bf488c..00000000000
--- a/addons/hr_fleet/controllers/i18n/fil.po
+++ /dev/null
@@ -1,115 +0,0 @@
-# Translation of Odoo Server.
-# This file contains the translation of the following modules:
-# * crm_project
-#
-msgid ""
-msgstr ""
-"Project-Id-Version: Odoo Server saas~11.5\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2018-09-21 13:17+0000\n"
-"PO-Revision-Date: 2018-08-24 09:17+0000\n"
-"Language-Team: Filipino (https://www.transifex.com/odoo/teams/41243/fil/)\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: \n"
-"Language: fil\n"
-"Plural-Forms: nplurals=2; plural=(n == 1 || n==2 || n==3) || (n % 10 != 4 || n % 10 != 6 || n % 10 != 9);\n"
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid ""
-"<span class=\"o_stat_text\">Convert To</span>\n"
-" <span class=\"o_stat_text\">Task</span>"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Cancel"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid "Convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Create Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Create a new customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
-msgid "Created by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
-msgid "Created on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
-msgid "Customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
-msgid "Display Name"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Do not link to a customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
-msgid "ID"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
-msgid "Last Modified on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
-msgid "Last Updated by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
-msgid "Last Updated on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
-msgid "Lead"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model,name:crm_project.model_crm_lead_convert2task
-msgid "Lead convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Link to an existing customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
-msgid "Project"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
-msgid "Related Customer"
-msgstr ""
diff --git a/addons/hr_gamification/wizard/gamification_badge_user_wizard.py b/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
index 4e486a0e048..7c4c174129f 100644
--- a/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
+++ b/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
@@ -10,7 +10,7 @@ class GamificationBadgeUserWizard(models.TransientModel):
employee_id = fields.Many2one('hr.employee', string='Employee', required=True)
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id',
- store=False, readonly=True)
+ store=False, readonly=True, compute_sudo=True)
@api.multi
def action_grant_badge(self):
diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 9d24ea47916..cded512698d 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -111,13 +111,12 @@ class HolidaysRequest(models.Model):
"\nThe status is 'Approved', when leave request is approved by manager.")
payslip_status = fields.Boolean('Reported in last payslips', help='Green this button when the leave has been taken into account in the payslip.')
report_note = fields.Text('HR Comments')
- user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
+ user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, compute_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
# leave type configuration
holiday_status_id = fields.Many2one(
"hr.leave.type", string="Leave Type", required=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
domain=[('valid', '=', True)])
- leave_type_request_unit = fields.Selection(related='holiday_status_id.request_unit', readonly=True)
validation_type = fields.Selection('Validation Type', related='holiday_status_id.validation_type', readonly=False)
# HR data
employee_id = fields.Many2one(
diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py
index 5c128df5af3..840537796e4 100644
--- a/addons/hr_holidays/models/hr_leave_allocation.py
+++ b/addons/hr_holidays/models/hr_leave_allocation.py
@@ -252,7 +252,7 @@ class HolidaysAllocation(models.Model):
if self.env.user.employee_ids:
self.department_id = self.department_id or self.env.user.employee_ids[0].department_id
self.employee_id = None
- elif self.holiday_type == 'category':
+ else:
self.employee_id = None
self.department_id = None
diff --git a/addons/hr_holidays/report/hr_leave_reports.xml b/addons/hr_holidays/report/hr_leave_reports.xml
index f818b8fbec6..94502e269ea 100644
--- a/addons/hr_holidays/report/hr_leave_reports.xml
+++ b/addons/hr_holidays/report/hr_leave_reports.xml
@@ -77,7 +77,7 @@
<div>
<strong class="o_kanban_record_title"><t t-esc="record.employee_id.value"/></strong>
<span class="float-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'validate': 'success','confirm': 'default', 'cancel': 'danger'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'validate': 'success','confirm': 'default', 'cancel': 'danger'}}"/>
</span>
</div>
<div class="text-muted o_kanban_record_subtitle">
diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py
index 8dad86eae39..e6351430615 100644
--- a/addons/hr_holidays/tests/test_leave_requests.py
+++ b/addons/hr_holidays/tests/test_leave_requests.py
@@ -138,7 +138,7 @@ class TestLeaveRequests(TestHrHolidaysBase):
'number_of_days': 1,
})
- @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
+ @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.models')
def test_accrual_validity_time_not_valid(self):
""" Employee ask leav during a not valid validity time """
with self.assertRaises(ValidationError):
diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py
index c606ce0972c..2215764fdf4 100644
--- a/addons/l10n_fr_fec/wizard/account_fr_fec.py
+++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py
@@ -76,6 +76,27 @@ class AccountFrFec(models.TransientModel):
listrow = list(row)
return listrow
+ def _get_company_legal_data(self, company):
+ """
+ Dom-Tom are excluded from the EU's fiscal territory
+ Those regions do not have SIREN
+ sources:
+ https://www.service-public.fr/professionnels-entreprises/vosdroits/F23570
+ http://www.douane.gouv.fr/articles/a11024-tva-dans-les-dom
+ """
+ dom_tom_group = self.env.ref('l10n_fr.dom-tom')
+ is_dom_tom = company.country_id.code in dom_tom_group.country_ids.mapped('code')
+ if not is_dom_tom and not company.vat:
+ raise Warning(
+ _("Missing VAT number for company %s") % company.name)
+ if not is_dom_tom and company.vat[0:2] != 'FR':
+ raise Warning(
+ _("FEC is for French companies only !"))
+
+ return {
+ 'siren': company.vat[4:13] if not is_dom_tom else '',
+ }
+
@api.multi
def generate_fec(self):
self.ensure_one()
@@ -87,6 +108,9 @@ class AccountFrFec(models.TransientModel):
# 2) CSV files are easier to read/use for a regular accountant.
# So it will be easier for the accountant to check the file before
# sending it to the fiscal administration
+ company = self.env.user.company_id
+ company_legal_data = self._get_company_legal_data(company)
+
header = [
u'JournalCode', # 0
u'JournalLib', # 1
@@ -108,18 +132,7 @@ class AccountFrFec(models.TransientModel):
u'Idevise', # 17
]
- company = self.env.user.company_id
- if not company.vat:
- raise Warning(
- _("Missing VAT number for company %s") % company.name)
- if company.vat[0:2] != 'FR':
- raise Warning(
- _("FEC is for French companies only !"))
-
- fecfile = io.BytesIO()
- w = pycompat.csv_writer(fecfile, delimiter='|')
- w.writerow(header)
-
+ rows_to_write = [header]
# INITIAL BALANCE
unaffected_earnings_xml_ref = self.env.ref('account.data_unaffected_earnings')
unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once
@@ -195,7 +208,8 @@ class AccountFrFec(models.TransientModel):
else:
listrow[11] = '0,00'
listrow[12] = str(-listrow_amount).replace('.', ',')
- w.writerow(listrow)
+ rows_to_write.append(listrow)
+
#if the unaffected earnings account wasn't in the selection yet: add it manually
if (not unaffected_earnings_line
and unaffected_earnings_results
@@ -206,7 +220,7 @@ class AccountFrFec(models.TransientModel):
if unaffected_earnings_account:
unaffected_earnings_results[4] = unaffected_earnings_account.code
unaffected_earnings_results[5] = unaffected_earnings_account.name
- w.writerow(unaffected_earnings_results)
+ rows_to_write.append(unaffected_earnings_results)
# INITIAL BALANCE - receivable/payable
sql_query = '''
@@ -264,7 +278,7 @@ class AccountFrFec(models.TransientModel):
for row in self._cr.fetchall():
listrow = list(row)
account_id = listrow.pop()
- w.writerow(listrow)
+ rows_to_write.append(listrow)
# LINES
sql_query = '''
@@ -329,20 +343,19 @@ class AccountFrFec(models.TransientModel):
sql_query, (self.date_from, self.date_to, company.id))
for row in self._cr.fetchall():
- w.writerow(list(row))
+ rows_to_write.append(list(row))
- siren = company.vat[4:13]
+ fecvalue = self._csv_write_rows(rows_to_write)
end_date = fields.Date.to_string(self.date_to).replace('-', '')
suffix = ''
if self.export_type == "nonofficial":
suffix = '-NONOFFICIAL'
- fecvalue = fecfile.getvalue()
+
self.write({
'fec_data': base64.encodestring(fecvalue),
# Filename = <siren>FECYYYYMMDD where YYYMMDD is the closing date
- 'filename': '%sFEC%s%s.csv' % (siren, end_date, suffix),
+ 'filename': '%sFEC%s%s.csv' % (company_legal_data['siren'], end_date, suffix),
})
- fecfile.close()
action = {
'name': 'FEC',
@@ -351,3 +364,29 @@ class AccountFrFec(models.TransientModel):
'target': 'self',
}
return action
+
+ def _csv_write_rows(self, rows, lineterminator=u'\r\n'):
+ """
+ Write FEC rows into a file
+ It seems that Bercy's bureaucracy is not too happy about the
+ empty new line at the End Of File.
+
+ @param {list(list)} rows: the list of rows. Each row is a list of strings
+ @param {unicode string} [optional] lineterminator: effective line terminator
+ Has nothing to do with the csv writer parameter
+ The last line written won't be terminated with it
+
+ @return the value of the file
+ """
+ fecfile = io.BytesIO()
+ writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='')
+
+ rows_length = len(rows)
+ for i, row in enumerate(rows):
+ if not i == rows_length - 1:
+ row.append(lineterminator)
+ writer.writerow(row)
+
+ fecvalue = fecfile.getvalue()
+ fecfile.close()
+ return fecvalue
diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py
index 4bde8505d5c..08ecb01a665 100644
--- a/addons/l10n_multilang/models/l10n_multilang.py
+++ b/addons/l10n_multilang/models/l10n_multilang.py
@@ -87,11 +87,20 @@ class AccountChartTemplate(models.Model):
return self.process_translations(langs, field, in_ids, out_ids)
def _get_template_from_model(self, company_id, model):
+ """ Find the records and their matching template """
+ # genereated records have an external id with the format <company id>_<template id>
out_data = self.env['ir.model.data'].search([('model', '=', model), ('name', '=like', str(company_id)+'\_%')])
- out_ids = self.env[model].search([('id', 'in', out_data.mapped('res_id'))], order='id')
+ out_ids = self.env[model].browse(out_data.mapped('res_id'))
+
+ # templates and records may have been created in a different order
+ # reorder them based on external id names
in_xml_id_names = [xml_id.partition(str(company_id) + '_')[-1] for xml_id in out_data.mapped('name')]
- in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', in_xml_id_names)])
- in_ids = self.env[model+'.template'].search([('id', 'in', in_xml_ids.mapped('res_id'))], order='id')
+ in_xml_id_names = {name: index for index, name in enumerate(in_xml_id_names)}
+
+ in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', list(in_xml_id_names))])
+ in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
+
+ in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id'))
return (in_ids, out_ids)
class BaseLanguageInstall(models.TransientModel):
diff --git a/addons/lunch/views/lunch_orders_views.xml b/addons/lunch/views/lunch_orders_views.xml
index 8cd4658a8cb..33201b5fea8 100644
--- a/addons/lunch/views/lunch_orders_views.xml
+++ b/addons/lunch/views/lunch_orders_views.xml
@@ -139,7 +139,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><field name="product_id"/></strong>
</div>
- <field name="state" widget="kanban_label_selection" options="{'classes': {'new': 'default', 'confirmed': 'success', 'cancelled':'danger'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'new': 'default', 'confirmed': 'success', 'cancelled':'danger'}}"/>
</div>
<div>
<field name="note"/>
diff --git a/addons/mail/models/ir_actions.py b/addons/mail/models/ir_actions.py
index bfecd028152..40582b1d803 100644
--- a/addons/mail/models/ir_actions.py
+++ b/addons/mail/models/ir_actions.py
@@ -80,18 +80,27 @@ class ServerActions(models.Model):
def _is_recompute(self, action):
"""When an activity is set on update of a record,
update might be triggered many times by recomputes.
- When need to know it to skip these steps."""
+ When need to know it to skip these steps.
+ Except if the computed field is supposed to trigger the action
+ """
records = self.env[action.model_id.model].browse(
self._context.get('active_ids', self._context.get('active_id')))
old_values = action._context.get('old_values')
if old_values:
- field_names = list(old_values[records[0].id])
- field = records._fields[field_names[0]]
- # Pick an arbitrary field; if it is marked to be recomputed,
- # it means we are in an extraneous write triggered by the recompute.
- # In this case, we should not create a new activity.
- if records._recompute_check(field):
- return True
+ domain_post = action._context.get('domain_post')
+ tracked_fields = []
+ if domain_post:
+ for leaf in domain_post:
+ if isinstance(leaf, (tuple, list)):
+ tracked_fields.append(leaf[0])
+ fields_to_check = [field for record, field_names in old_values.items() for field in field_names if field not in tracked_fields]
+ if fields_to_check:
+ field = records._fields[fields_to_check[0]]
+ # Pick an arbitrary field; if it is marked to be recomputed,
+ # it means we are in an extraneous write triggered by the recompute.
+ # In this case, we should not create a new activity.
+ if records._recompute_check(field):
+ return True
return False
@api.model
diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py
index 893b6ea78af..1efffbf061f 100644
--- a/addons/mail/models/mail_activity.py
+++ b/addons/mail/models/mail_activity.py
@@ -121,9 +121,9 @@ class MailActivity(models.Model):
activity_type_id = fields.Many2one(
'mail.activity.type', 'Activity',
domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]", ondelete='restrict')
- activity_category = fields.Selection(related='activity_type_id.category', readonly=False)
- activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=False)
- icon = fields.Char('Icon', related='activity_type_id.icon', readonly=False)
+ activity_category = fields.Selection(related='activity_type_id.category', readonly=True)
+ activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=True)
+ icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True)
summary = fields.Char('Summary')
note = fields.Html('Note')
feedback = fields.Html('Feedback')
@@ -145,8 +145,8 @@ class MailActivity(models.Model):
('today', 'Today'),
('planned', 'Planned')], 'State',
compute='_compute_state')
- recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type")
- previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type')
+ recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type", readonly=True)
+ previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type', readonly=True)
has_recommended_activities = fields.Boolean(
'Next activities available',
compute='_compute_has_recommended_activities',
@@ -258,6 +258,12 @@ class MailActivity(models.Model):
activity.user_id.display_name)
else:
try:
+ target_user = activity.user_id
+ target_record = self.env[activity.res_model].browse(activity.res_id)
+ if hasattr(target_record, 'company_id') and (
+ target_record.company_id != target_user.company_id and (
+ len(target_user.company_ids) > 1)):
+ return # in that case we skip the check, assuming it would fail because of the company
model.browse(activity.res_id).check_access_rule('read')
except exceptions.AccessError:
raise exceptions.UserError(
diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js
index c76c6279861..87261c14b6c 100644
--- a/addons/mail/static/src/js/composers/mention_manager.js
+++ b/addons/mail/static/src/js/composers/mention_manager.js
@@ -53,7 +53,7 @@ var MentionManager = Widget.extend({
var listener = _.findWhere(this._listeners, { delimiter: delimiter });
if (listener) {
var escapedVal = _.escape(this._composer.$input.val());
- var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;)', 'g'));
+ var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;|$)', 'g'));
return this._validateSelection(listener.selection, inputMentions);
}
return [];
@@ -166,9 +166,8 @@ var MentionManager = Widget.extend({
listener.model,
listener.delimiter,
matchName);
- var subtext = s.substring(startIndex, endIndex)
- .replace(match[0], processedText);
- substrings.push(subtext);
+ substrings.push(s.substring(startIndex, match.index));
+ substrings.push(processedText);
startIndex = endIndex;
}
substrings.push(s.substring(startIndex, s.length));
@@ -258,7 +257,7 @@ var MentionManager = Widget.extend({
// create the regex of all mention's names
var names = _.pluck(listener.selection, 'name');
var escapedNames = _.map(names, function (str) {
- return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
+ return "("+_.str.escapeRegExp(listener.delimiter+str)+")(?= |&nbsp;|$)";
});
var regexStr = escapedNames.join('|');
// extract matches
diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js
index cbac2c3b417..c80ac0110ba 100644
--- a/addons/mail/static/src/js/thread_widget.js
+++ b/addons/mail/static/src/js/thread_widget.js
@@ -466,7 +466,7 @@ var ThreadWidget = Widget.extend({
return message.getID() === messageID;
});
return QWeb.render('mail.widget.Thread.Message.MailTooltip', {
- data: message.getCustomerEmailData()
+ data: message.hasCustomerEmailData() ? message.getCustomerEmailData() : [],
});
},
});
diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js
index d9d563a1ce2..9b2db263689 100644
--- a/addons/mail/static/tests/discuss_tests.js
+++ b/addons/mail/static/tests/discuss_tests.js
@@ -520,6 +520,85 @@ QUnit.test('@ mention with special chars', function (assert) {
});
});
+QUnit.test('@ mention with removed space', function (assert) {
+ assert.expect(4);
+ var done = assert.async();
+ var fetchListenersDef = $.Deferred();
+ var receiveMessageDef = $.Deferred();
+ this.data.initMessaging = {
+ channel_slots: {
+ channel_channel: [{
+ id: 1,
+ channel_type: "channel",
+ name: "general",
+ }],
+ },
+ };
+ var objectDiscuss;
+ createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return $.when([
+ {id: 1, name: 'Admin'},
+ ]);
+ }
+ if (args.method === 'message_post') {
+ assert.deepEqual(args.kwargs.partner_ids, [],
+ "mentioned partners are sent to server"
+ )
+ var data = {
+ author_id: ["42", "Me"],
+ body: args.kwargs.body,
+ channel_ids: [1],
+ };
+ var notification = [[false, 'mail.channel', 1], data];
+ objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
+ receiveMessageDef.resolve();
+ return $.when(42);
+ }
+ return this._super.apply(this, arguments);
+ },
+ })
+ .then(function (discuss) {
+ objectDiscuss = discuss;
+ var $general = discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]');
+ // click on general
+ $general.click();
+ var $input = discuss.$('textarea.o_composer_text_field').first();
+ assert.ok($input.length, "should display a composer input");
+ // Note: focus is needed in order to trigger rpc 'channel_fetch_listeners'
+ $input.focus();
+ $input.val("@");
+ $input.trigger('keyup');
+ fetchListenersDef
+ .then(concurrency.delay.bind(concurrency, 0))
+ .then(function () {
+ // equivalent to $mentionPropositions.find('active').click();
+ $input.trigger($.Event('keyup', {which: $.ui.keyCode.ENTER}));
+ $input.val('@Admin: hi');
+ // send message
+ $input.trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
+ receiveMessageDef
+ .then(concurrency.delay.bind(concurrency, 0))
+ .then(function () {
+ assert.containsNone(discuss, '.o_thread_message_content a',
+ "should not contain a link in the message content");
+ assert.strictEqual(discuss.$('.o_thread_message_content').html().trim(),
+ "@Admin: hi", "should not have linked mention without space");
+ discuss.destroy();
+ done();
+ });
+ });
+ });
+});
+
QUnit.test('@ mention in mailing channel', function (assert) {
assert.expect(8);
var done = assert.async();
diff --git a/addons/maintenance/models/maintenance.py b/addons/maintenance/models/maintenance.py
index acb6455e83d..440be988f42 100644
--- a/addons/maintenance/models/maintenance.py
+++ b/addons/maintenance/models/maintenance.py
@@ -302,7 +302,7 @@ class MaintenanceRequest(models.Model):
maintenance_type = fields.Selection([('corrective', 'Corrective'), ('preventive', 'Preventive')], string='Maintenance Type', default="corrective")
schedule_date = fields.Datetime('Scheduled Date', help="Date the maintenance team plans the maintenance. It should not differ much from the Request Date. ")
maintenance_team_id = fields.Many2one('maintenance.team', string='Team', required=True, default=_get_default_team_id)
- duration = fields.Float(help="Duration in minutes and seconds.")
+ duration = fields.Float(help="Duration in hours and minutes.")
@api.multi
def archive_equipment_request(self):
diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py
index 601576c4bf0..e8a904f2d37 100644
--- a/addons/mass_mailing/models/mass_mailing.py
+++ b/addons/mass_mailing/models/mass_mailing.py
@@ -4,10 +4,12 @@
import hashlib
import hmac
import logging
+import lxml
import random
import re
import threading
from ast import literal_eval
+from base64 import b64encode
from odoo import api, fields, models, tools, _, SUPERUSER_ID
from odoo.exceptions import UserError
@@ -26,6 +28,10 @@ MASS_MAILING_BUSINESS_MODELS = [
'mail.mass_mailing.contact'
]
+# Syntax of the data URL Scheme: https://tools.ietf.org/html/rfc2397#section-3
+# Used to find inline images
+image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)")
+
class MassMailingTag(models.Model):
"""Model of categories of mass mailing, i.e. marketing, newsletter, ... """
@@ -662,6 +668,53 @@ class MassMailing(models.Model):
if len(record_lists) > 0:
record.sudo().message_post(body=_(message % ', '.join(str(list.name) for list in record_lists.mapped('list_id'))))
+ @api.model
+ def create(self, values):
+ if values.get('body_html'):
+ values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
+ return super(MassMailing, self).create(values)
+
+ def write(self, values):
+ if values.get('body_html'):
+ values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
+ return super(MassMailing, self).write(values)
+
+ def _convert_inline_images_to_urls(self, body_html):
+ """
+ Find inline base64 encoded images, make an attachement out of
+ them and replace the inline image with an url to the attachement.
+ """
+
+ def _image_to_url(b64image: bytes):
+ """Store an image in an attachement and returns an url"""
+ attachment = self.env['ir.attachment'].create({
+ 'name': "cropped_image",
+ 'datas': b64image,
+ 'datas_fname': "cropped_image_mailing_{}".format(self.id),
+ 'type': 'binary',})
+
+ attachment.generate_access_token()
+
+ return '/web/image/%s?access_token=%s' % (
+ attachment.id, attachment.access_token)
+
+
+ modified = False
+ root = lxml.html.fromstring(body_html)
+ for node in root.iter('img'):
+ match = image_re.match(node.attrib.get('src', ''))
+ if match:
+ mime = match.group(1) # unsed
+ image = match.group(2).encode() # base64 image as bytes
+
+ node.attrib['src'] = _image_to_url(image)
+ modified = True
+
+ if modified:
+ return lxml.html.tostring(root)
+
+ return body_html
+
#------------------------------------------------------
# Views & Actions
#------------------------------------------------------
diff --git a/addons/mass_mailing/views/unsubscribe_templates.xml b/addons/mass_mailing/views/unsubscribe_templates.xml
index dfad695abf6..f26436a98d1 100644
--- a/addons/mass_mailing/views/unsubscribe_templates.xml
+++ b/addons/mass_mailing/views/unsubscribe_templates.xml
@@ -46,7 +46,7 @@
<div class="btn btn-secondary pull-right" id="button_add_blacklist" style="display:none">Blacklist Me</div>
</div>
<div class="btn btn-secondary pull-right" id="button_remove_blacklist" style="display:none">Come Back</div>
- <input type="submit" id="send_form" class="btn btn-primary" value="Update my subscriptions"/>
+ <button type="submit" id="send_form" class="btn btn-primary">Update my subscriptions</button>
</div>
</div>
diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py
index 63d7a0c5156..39d117e1a32 100644
--- a/addons/mrp/models/mrp_bom.py
+++ b/addons/mrp/models/mrp_bom.py
@@ -94,6 +94,8 @@ class MrpBom(models.Model):
self.product_uom_id = self.product_tmpl_id.uom_id.id
if self.product_id.product_tmpl_id != self.product_tmpl_id:
self.product_id = False
+ for line in self.bom_line_ids:
+ line.attribute_value_ids = False
@api.onchange('routing_id')
def onchange_routing_id(self):
@@ -290,6 +292,8 @@ class MrpBomLine(models.Model):
@api.onchange('parent_product_tmpl_id')
def onchange_parent_product(self):
+ if not self.parent_product_tmpl_id:
+ return {}
return {'domain': {'attribute_value_ids': [
('id', 'in', self.parent_product_tmpl_id._get_valid_product_attribute_values().ids),
('attribute_id.create_variant', '!=', 'no_variant')
diff --git a/addons/mrp/models/stock_warehouse.py b/addons/mrp/models/stock_warehouse.py
index c66d15131c6..59fa4f39e36 100644
--- a/addons/mrp/models/stock_warehouse.py
+++ b/addons/mrp/models/stock_warehouse.py
@@ -168,9 +168,21 @@ class StockWarehouse(models.Model):
values = super(StockWarehouse, self)._get_locations_values(vals)
def_values = self.default_get(['manufacture_steps'])
manufacture_steps = vals.get('manufacture_steps', def_values['manufacture_steps'])
+ code = vals.get('code') or self.code
+ code = code.replace(' ', '').upper()
values.update({
- 'pbm_loc_id': {'name': _('Pre-Production'), 'active': manufacture_steps in ('pbm', 'pbm_sam'), 'usage': 'internal'},
- 'sam_loc_id': {'name': _('Post-Production'), 'active': manufacture_steps == 'pbm_sam', 'usage': 'internal'},
+ 'pbm_loc_id': {
+ 'name': _('Pre-Production'),
+ 'active': manufacture_steps in ('pbm', 'pbm_sam'),
+ 'usage': 'internal',
+ 'barcode': code + '-PREPRODUCTION'
+ },
+ 'sam_loc_id': {
+ 'name': _('Post-Production'),
+ 'active': manufacture_steps == 'pbm_sam',
+ 'usage': 'internal',
+ 'barcode': code + '-POSTPRODUCTION'
+ },
})
return values
diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml
index 1a05dbc0b4a..7d3476694c9 100644
--- a/addons/mrp/views/mrp_unbuild_views.xml
+++ b/addons/mrp/views/mrp_unbuild_views.xml
@@ -83,7 +83,7 @@
</div>
<div class="col-4">
<span class="float-right text-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'done': 'success'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'done': 'success'}}"/>
</span>
</div>
</div>
diff --git a/addons/payment_authorize/models/payment.py b/addons/payment_authorize/models/payment.py
index c1380db1fd6..5a6ed0bf8e1 100644
--- a/addons/payment_authorize/models/payment.py
+++ b/addons/payment_authorize/models/payment.py
@@ -6,6 +6,7 @@ from datetime import datetime
import hashlib
import hmac
import logging
+import string
import time
from odoo import _, api, fields, models
@@ -54,8 +55,16 @@ class PaymentAcquirerAuthorize(models.Model):
values['x_fp_sequence'],
values['x_fp_timestamp'],
values['x_amount'],
- values['x_currency_code']])
- return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
+ values['x_currency_code']]).encode('utf-8')
+
+ # [BACKWARD COMPATIBILITY] Check that the merchant did update his transaction
+ # key to signature key (end of MD5 support from Authorize.net)
+ # The signature key is now '128-character hexadecimal format', while the
+ # transaction key was only 16-character.
+ if len(values['x_trans_key']) == 128:
+ return hmac.new(values['x_trans_key'].decode("hex").encode('utf-8'), data, hashlib.sha512).hexdigest().upper()
+ else:
+ return hmac.new(values['x_trans_key'].encode('utf-8'), data, hashlib.md5).hexdigest()
@api.multi
def authorize_form_generate_values(self, values):
@@ -172,7 +181,7 @@ class TxAuthorize(models.Model):
def _authorize_form_get_tx_from_data(self, data):
""" Given a data dict coming from authorize, verify it and find the related
transaction record. """
- reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_MD5_Hash')
+ reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_SHA2_Hash') or data.get('x_MD5_Hash')
if not reference or not trans_id or not fingerprint:
error_msg = _('Authorize: received data with missing reference (%s) or trans_id (%s) or fingerprint (%s)') % (reference, trans_id, fingerprint)
_logger.info(error_msg)
diff --git a/addons/payment_authorize/tests/test_authorize.py b/addons/payment_authorize/tests/test_authorize.py
index a45275b07d8..7e12b572419 100644
--- a/addons/payment_authorize/tests/test_authorize.py
+++ b/addons/payment_authorize/tests/test_authorize.py
@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
-import hashlib
-import hmac
import time
from werkzeug import urls
from lxml import objectify
@@ -29,15 +27,6 @@ class AuthorizeCommon(PaymentAcquirerCommon):
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'external')
class AuthorizeForm(AuthorizeCommon):
- def _authorize_generate_hashing(self, values):
- data = '^'.join([
- values['x_login'],
- values['x_fp_sequence'],
- values['x_fp_timestamp'],
- values['x_amount'],
- ]) + '^'
- return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
-
def test_10_Authorize_form_render(self):
self.assertEqual(self.authorize.environment, 'test', 'test without test environment')
@@ -82,7 +71,7 @@ class AuthorizeForm(AuthorizeCommon):
'x_ship_to_state': None,
}
- form_values['x_fp_hash'] = self._authorize_generate_hashing(form_values)
+ form_values['x_fp_hash'] = self.env['payment.acquirer']._authorize_generate_hashing(form_values)
# render the button
res = self.authorize.render('SO004', 56.16, self.currency_usd.id, values=self.buyer_values)
# check form result
@@ -109,7 +98,9 @@ class AuthorizeForm(AuthorizeCommon):
# typical data posted by authorize after client has successfully paid
authorize_post_data = {
'return_url': u'/shop/payment/validate',
+ # x_MD5_Hash will be empty starting the 28th March 2019
'x_MD5_Hash': u'7934485E1C105940BE854208D10FAB4F',
+ 'x_SHA2_Hash': u'7D3AC844BE8CA3F649AB885A90D22CFE35B850338EC91D1A5ADD819A85FF948A3D777334A18CDE36821DC8F2B42A6E1950C1FF96B52B60F23201483A656195FB',
'x_account_number': u'XXXX0027',
'x_address': u'Huge Street 2/543',
'x_amount': u'320.00',
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css
index 5127fe75c71..a6c1ab99258 100644
--- a/addons/point_of_sale/static/src/css/pos.css
+++ b/addons/point_of_sale/static/src/css/pos.css
@@ -1479,6 +1479,8 @@ td {
body {
margin: 0;
color: white !important;
+ /* avoid black background if backgrounds are printed */
+ background: initial;
}
.oe_leftbar,
.oe_loading,
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index fb1b0550a46..7306eb629c8 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -1291,7 +1291,7 @@ var ClientListScreenWidget = ScreenWidget.extend({
// what happens when we've just pushed modifications for a partner of id partner_id
saved_client_details: function(partner_id){
var self = this;
- this.reload_partners().then(function(){
+ return this.reload_partners().then(function(){
var partner = self.pos.db.get_partner_by_id(partner_id);
if (partner) {
self.new_client = partner;
diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml
index 18e6a3ec53e..6bccce47ce5 100644
--- a/addons/point_of_sale/views/pos_order_view.xml
+++ b/addons/point_of_sale/views/pos_order_view.xml
@@ -146,7 +146,7 @@
</div>
<div class="col-4">
<span class="float-right text-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default',
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default',
'invoiced': 'success', 'cancel': 'danger'}}"/>
</span>
</div>
diff --git a/addons/point_of_sale/views/pos_session_view.xml b/addons/point_of_sale/views/pos_session_view.xml
index 04766df77ca..677cea3350c 100644
--- a/addons/point_of_sale/views/pos_session_view.xml
+++ b/addons/point_of_sale/views/pos_session_view.xml
@@ -161,7 +161,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><span><field name="config_id"/></span></strong>
</div>
- <field name="state" widget="kanban_label_selection" options="{'classes': {'opening_control': 'default',
+ <field name="state" widget="label_selection" options="{'classes': {'opening_control': 'default',
'opened': 'success', 'closing_control': 'warning', 'closed': 'warning'}}"/>
</div>
<div class="o_kanban_record_body">
diff --git a/addons/portal/wizard/portal_share.py b/addons/portal/wizard/portal_share.py
index 8474f2c810c..0da5ce44a7b 100644
--- a/addons/portal/wizard/portal_share.py
+++ b/addons/portal/wizard/portal_share.py
@@ -10,9 +10,9 @@ class PortalShare(models.TransientModel):
@api.model
def default_get(self, fields):
result = super(PortalShare, self).default_get(fields)
- record = self.env[result['res_model']].browse(result['res_id'])
result['res_model'] = self._context.get('active_model')
result['res_id'] = self._context.get('active_id')
+ record = self.env[result['res_model']].browse(result['res_id'])
result['share_link'] = record.get_base_url() + record._get_share_url(redirect=True)
return result
diff --git a/addons/portal/wizard/portal_wizard.py b/addons/portal/wizard/portal_wizard.py
index ee5b82eecda..2f17441f451 100644
--- a/addons/portal/wizard/portal_wizard.py
+++ b/addons/portal/wizard/portal_wizard.py
@@ -35,7 +35,7 @@ class PortalWizard(models.TransientModel):
contact_ids = set()
user_changes = []
for partner in self.env['res.partner'].sudo().browse(partner_ids):
- contact_partners = partner.child_ids or [partner]
+ contact_partners = partner.child_ids | partner
for contact in contact_partners:
# make sure that each contact appears at most once in the list
if contact.id not in contact_ids:
diff --git a/addons/product/models/product.py b/addons/product/models/product.py
index ef770c8ac38..7a711f801e6 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -277,7 +277,11 @@ class ProductProduct(models.Model):
if isinstance(value, str):
value = value.encode('ascii')
image = tools.image_resize_image_big(value)
- if self.product_tmpl_id.image:
+
+ # This is needed because when there is only one variant, the user
+ # doesn't know there is a difference between template and variant, he
+ # expects both images to be the same.
+ if self.product_tmpl_id.image and self.product_variant_count > 1:
self.image_variant = image
else:
self.product_tmpl_id.image = image
@@ -336,6 +340,8 @@ class ProductProduct(models.Model):
# When a unique variant is created from tmpl then the standard price is set by _set_standard_price
if not (self.env.context.get('create_from_tmpl') and len(product.product_tmpl_id.product_variant_ids) == 1):
product._set_standard_price(vals.get('standard_price') or 0.0)
+ # `_get_variant_id_for_combination` depends on existing variants
+ self.clear_caches()
return products
@api.multi
@@ -344,6 +350,13 @@ class ProductProduct(models.Model):
res = super(ProductProduct, self).write(values)
if 'standard_price' in values:
self._set_standard_price(values['standard_price'])
+ if 'attribute_value_ids' in values:
+ # `_get_variant_id_for_combination` depends on `attribute_value_ids`
+ self.clear_caches()
+ if 'active' in values:
+ # prefetched o2m have to be reloaded (because of active_test)
+ # (eg. product.template: product_variant_ids)
+ self.invalidate_cache()
return res
@api.multi
@@ -364,6 +377,8 @@ class ProductProduct(models.Model):
# delete templates after calling super, as deleting template could lead to deleting
# products due to ondelete='cascade'
unlink_templates.unlink()
+ # `_get_variant_id_for_combination` depends on existing variants
+ self.clear_caches()
return res
@api.multi
@@ -652,6 +667,10 @@ class ProductProduct(models.Model):
We must make sure that all attributes are used to take into account the case where
attributes would be added to the template.
+ This method does not check if the combination is possible, it just
+ checks if it has valid attributes and values. A possible combination
+ is always valid, but a valid combination is not always possible.
+
:param valid_attributes: a recordset of product.attribute
:param valid_values: a recordset of product.attribute.value
:return: True if the attibutes and values are correct, False instead
diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py
index ed1f70b4bc4..32f86b79eea 100644
--- a/addons/product/models/product_attribute.py
+++ b/addons/product/models/product_attribute.py
@@ -44,7 +44,13 @@ class ProductAttribute(models.Model):
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)
+ invalidate_cache = 'sequence' in vals and any(record.sequence != vals['sequence'] for record in self)
+ res = super(ProductAttribute, self).write(vals)
+ if invalidate_cache:
+ # prefetched o2m have to be resequenced
+ # (eg. product.template: attribute_line_ids)
+ self.invalidate_cache()
+ return res
@api.multi
def _get_related_product_templates(self):
@@ -79,6 +85,16 @@ class ProductAttributeValue(models.Model):
return ", ".join([v.name for v in self if v.attribute_id in variable_attributes])
@api.multi
+ def write(self, values):
+ invalidate_cache = 'sequence' in values and any(record.sequence != values['sequence'] for record in self)
+ res = super(ProductAttributeValue, self).write(values)
+ if invalidate_cache:
+ # prefetched o2m have to be resequenced
+ # (eg. product.template.attribute.line: value_ids)
+ self.invalidate_cache()
+ return res
+
+ @api.multi
def unlink(self):
linked_products = self._get_related_product_templates()
if linked_products:
@@ -136,8 +152,8 @@ class ProductTemplateAttributeLine(models.Model):
for product_template_attribute_line in self:
product_template_attribute_line.product_template_value_ids = self.env['product.template.attribute.value'].search([
('product_tmpl_id', 'in', product_template_attribute_line.product_tmpl_id.ids),
- ('product_attribute_value_id.attribute_id', 'in', product_template_attribute_line.value_ids.mapped('attribute_id').ids)]
- ).sorted(lambda product_product_attribute: product_product_attribute.sequence)
+ ('product_attribute_value_id', 'in', product_template_attribute_line.value_ids.ids)]
+ )
@api.multi
def unlink(self):
@@ -246,9 +262,9 @@ class ProductTemplateAttributeExclusion(models.Model):
_description = 'Product Template Attribute Exclusion'
product_template_attribute_value_id = fields.Many2one(
- 'product.template.attribute.value', string="Attribute Value", ondelete='cascade')
+ 'product.template.attribute.value', string="Attribute Value", ondelete='cascade', index=True)
product_tmpl_id = fields.Many2one(
- 'product.template', string='Product Template', ondelete='cascade', required=True)
+ 'product.template', string='Product Template', ondelete='cascade', required=True, index=True)
value_ids = fields.Many2many(
'product.template.attribute.value', relation="product_attr_exclusion_value_ids_rel",
string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id)]")
diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py
index 4cae61d9a0b..5f40c360d23 100644
--- a/addons/product/models/product_pricelist.py
+++ b/addons/product/models/product_pricelist.py
@@ -246,7 +246,11 @@ class Pricelist(models.Model):
break
# Final price conversion into pricelist currency
if suitable_rule and suitable_rule.compute_price != 'fixed' and suitable_rule.base != 'pricelist':
- price = product.currency_id._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
+ if suitable_rule.base == 'standard_price':
+ cur = product.cost_currency_id
+ else:
+ cur = product.currency_id
+ price = cur._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
results[product.id] = (price, suitable_rule and suitable_rule.id or False)
@@ -494,3 +498,11 @@ class PricelistItem(models.Model):
'price_min_margin': 0.0,
'price_max_margin': 0.0,
})
+
+ @api.multi
+ def write(self, values):
+ res = super(PricelistItem, self).write(values)
+ # When the pricelist changes we need the product.template price
+ # to be invalided and recomputed.
+ self.invalidate_cache()
+ return res
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index 94858d7a98d..da355aec664 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -62,6 +62,8 @@ class ProductTemplate(models.Model):
currency_id = fields.Many2one(
'res.currency', 'Currency', compute='_compute_currency_id')
+ cost_currency_id = fields.Many2one(
+ 'res.currency', 'Cost Currency', compute='_compute_cost_currency_id')
# price fields
# price: total template price, context dependent (partner, pricelist, quantity)
@@ -119,6 +121,25 @@ class ProductTemplate(models.Model):
is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant')
attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes')
+
+ valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Lines', help="Technical compute")
+ valid_product_attribute_value_ids = fields.Many2many('product.attribute.value',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Values', help="Technical compute")
+ valid_product_attribute_ids = fields.Many2many('product.attribute',
+ compute="_compute_valid_attributes", string='Valid Product Attributes', help="Technical compute")
+ # wnva = without no_variant attributes
+ valid_product_template_attribute_line_wnva_ids = fields.Many2many('product.template.attribute.line',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Lines Without No Variant Attributes', help="Technical compute")
+ valid_product_attribute_value_wnva_ids = fields.Many2many('product.attribute.value',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Values Without No Variant Attributes', help="Technical compute")
+ valid_product_attribute_wnva_ids = fields.Many2many('product.attribute',
+ compute="_compute_valid_attributes", string='Valid Product Attributes Without No Variant Attributes', help="Technical compute")
+ valid_archived_variant_ids = fields.Many2many('product.product',
+ compute="_compute_valid_attributes", string='Valid Archived Variants', help="Technical compute")
+ valid_existing_variant_ids = fields.Many2many('product.product',
+ compute="_compute_valid_existing_variant_ids", string='Valid Existing Variants', help="Technical compute")
+
product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True)
# performance: product_variant_id provides prefetching on the first product variant only
product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id')
@@ -160,6 +181,10 @@ class ProductTemplate(models.Model):
for template in self:
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
+ def _compute_cost_currency_id(self):
+ for template in self:
+ template.cost_currency_id = self.env.user.company_id.currency_id.id
+
@api.multi
def _compute_template_price(self):
prices = self._compute_template_price_no_inverse()
@@ -478,7 +503,7 @@ class ProductTemplate(models.Model):
# 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._get_valid_product_template_attribute_lines()._without_no_variant_attributes()
+ line.value_ids.ids for line in tmpl_id.valid_product_template_attribute_line_wnva_ids
))
# Set containing existing `product.attribute.value` combination
existing_variants = {
@@ -504,8 +529,8 @@ class ProductTemplate(models.Model):
# should be activated
# - if the product does not have valid attributes or attribute values, it should be
# deleted
- 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()
+ valid_value_ids = tmpl_id.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = tmpl_id.valid_product_attribute_wnva_ids
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:
@@ -537,6 +562,10 @@ class ProductTemplate(models.Model):
# This is the case from existing stock reordering rules.
variant.write({'active': False})
+ # prefetched o2m have to be reloaded (because of active_test)
+ # (eg. product.template: product_variant_ids)
+ # We can't rely on existing invalidate_cache because of the savepoint.
+ self.invalidate_cache()
return True
def has_dynamic_attributes(self):
@@ -550,7 +579,7 @@ class ProductTemplate(models.Model):
return any(a.create_variant == 'dynamic' for a in self._get_valid_product_attributes())
@api.multi
- def _get_valid_product_template_attribute_lines(self):
+ def _compute_valid_attributes(self):
"""A product template attribute line is considered valid if it has at
least one possible value.
@@ -559,42 +588,68 @@ class ProductTemplate(models.Model):
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.
+ A product attribute value is considered valid for a template if it is
+ defined on a product template attribute line.
+
+ A product attribute is considered valid for a template if it
+ has at least one possible value set on the template.
- :return: all the valid product template attribute lines of this template
- :rtype: recordset `product.template.attribute.line`
+ For what is considered an archived variant, see `_has_valid_attributes`.
"""
- self.ensure_one()
- return self.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
+ archived_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', False)])
+
+ # prefetch
+ self.mapped('attribute_line_ids.value_ids.id')
+ self.mapped('attribute_line_ids.attribute_id.create_variant')
+
+ for record in self:
+ record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
+ record.valid_product_template_attribute_line_wnva_ids = record.valid_product_template_attribute_line_ids._without_no_variant_attributes()
+
+ record.valid_product_attribute_value_ids = record.valid_product_template_attribute_line_ids.mapped('value_ids')
+ record.valid_product_attribute_value_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('value_ids')
+
+ record.valid_product_attribute_ids = record.valid_product_template_attribute_line_ids.mapped('attribute_id')
+ record.valid_product_attribute_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('attribute_id')
+
+ valid_value_ids = record.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = record.valid_product_attribute_wnva_ids
+
+ record.valid_archived_variant_ids = archived_variants.filtered(
+ lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_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.
+ def _compute_valid_existing_variant_ids(self):
+ """This compute is done outside of `_compute_valid_attributes` because
+ it is often not needed, and it can be very bad on performance."""
+ existing_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', True)])
+ for record in self:
- See `_get_valid_product_template_attribute_lines`.
+ valid_value_ids = record.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = record.valid_product_attribute_wnva_ids
- :return: all the valid product attributes of this template
- :rtype: recordset `product.attribute`
- """
+ record.valid_existing_variant_ids = existing_variants.filtered(
+ lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
+ )
+
+ @api.multi
+ def _get_valid_product_template_attribute_lines(self):
+ """deprecated, use `valid_product_template_attribute_line_ids`"""
self.ensure_one()
- product_attributes = self.env['product.attribute']
- for ptal in self._get_valid_product_template_attribute_lines():
- product_attributes |= ptal.attribute_id
- return product_attributes
+ return self.valid_product_template_attribute_line_ids
@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.
+ def _get_valid_product_attributes(self):
+ """deprecated, use `valid_product_attribute_ids`"""
+ self.ensure_one()
+ return self.valid_product_attribute_ids
- :return: all the valid product attribute values of this template
- :rtype: recordset `product.attribute.value`
- """
+ @api.multi
+ def _get_valid_product_attribute_values(self):
+ """deprecated, use `valid_product_attribute_value_ids`"""
self.ensure_one()
- return self._get_valid_product_template_attribute_lines().mapped('value_ids')
+ return self.valid_product_attribute_value_ids
@api.multi
def _get_possible_variants(self, parent_combination=None):
@@ -603,6 +658,13 @@ class ProductTemplate(models.Model):
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.
+ If there are a lot of variants, this method might be slow. Even if there
+ aren't too many variants, for performance reasons, do not call this
+ method in a loop over the product templates.
+
+ Therefore this method has a very restricted reasonable use case and you
+ should strongly consider doing things differently if you consider using
+ this method.
:param parent_combination: combination from which `self` is an
optional or accessory product.
@@ -702,19 +764,8 @@ class ProductTemplate(models.Model):
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]
+ for archived_variant in self.valid_archived_variant_ids]
@api.multi
def _get_existing_combinations(self):
@@ -726,19 +777,8 @@ class ProductTemplate(models.Model):
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]
+ for variant in self.valid_existing_variant_ids]
@api.multi
def _get_no_variant_product_template_attribute_values(self):
@@ -774,17 +814,20 @@ class ProductTemplate(models.Model):
"""
self.ensure_one()
- if len(combination) != len(self._get_valid_product_template_attribute_lines()):
+ if len(combination) != len(self.valid_product_template_attribute_line_ids):
# 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'):
+ if self.valid_product_attribute_ids != 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:
+ if self.valid_product_attribute_value_ids < combination.mapped('product_attribute_value_id'):
+ # combination has different values than the ones configured on the template
+ return False
+
+ if not self.has_dynamic_attributes() and not self._get_variant_for_combination(combination):
# the variant has been deleted
return False
@@ -826,40 +869,64 @@ class ProductTemplate(models.Model):
self.ensure_one()
filtered_combination = combination._without_no_variant_attributes()
+ attribute_values = filtered_combination.mapped('product_attribute_value_id')
+ return self.env['product.product'].browse(self._get_variant_id_for_combination(attribute_values))
+ @api.multi
+ @tools.ormcache('self', 'attribute_values')
+ def _get_variant_id_for_combination(self, attribute_values):
+ """See `_get_variant_for_combination`. This method returns an ID
+ so it can be cached."""
+ self.ensure_one()
# 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)
+ for pav in attribute_values:
+ domain = expression.AND([[('attribute_value_ids', 'in', pav.id)], domain])
+
+ res = self.env['product.product'].with_context(active_test=False).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]
+ lambda v: v.attribute_value_ids == attribute_values
+ )[:1].id
@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.
+ @tools.ormcache('self')
+ def _get_first_possible_variant_id(self):
+ """See `_create_first_product_variant`. This method returns an ID
+ so it can be cached."""
+ self.ensure_one()
+ return self._create_first_product_variant().id
- Ignore attributes that have no values.
+ @api.multi
+ def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
+ """See `_get_possible_combinations` (one iteration).
- Note this method return the same result (empty recordset) if no
+ 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.
+ """
+ return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value'])
+
+ @api.multi
+ def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
+ """Generator returning combinations that are possible, following the
+ sequence of attributes and values.
+
+ See `_is_combination_possible` for what is a possible combination.
+
+ When encountering an impossible combination, try to change the value
+ of attributes by starting with the further regarding their sequences.
+
+ Ignore attributes that have no values.
:param parent_combination: combination from which `self` is an
optional or accessory product.
@@ -868,63 +935,81 @@ class ProductTemplate(models.Model):
: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`
+ :return: the possible combinations
+ :rtype: generator of recordset of `product.template.attribute.value`
"""
self.ensure_one()
if not self.active:
- return self.env['product.template.attribute.value']
+ return _("The product template is archived so no combination is possible.")
- attribute_lines = self._get_valid_product_template_attribute_lines()
+ ptal_stack = [self._get_valid_product_template_attribute_lines()]
+ combination_stack = [self.env['product.template.attribute.value']]
- 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
+ # keep going while we have attribute lines to test
+ while len(ptal_stack):
+ attribute_lines = ptal_stack.pop()
+ combination = combination_stack.pop()
- :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']
+ # full combination, if it's possible return it, otherwise skip it
+ if self._is_combination_possible(combination, parent_combination) and all(v in combination for v in (necessary_values or [])):
+ yield(combination)
+ else:
+ # we have remaining attribute lines to consider
+ for ptav in reversed(attribute_lines[0].product_template_value_ids):
+ ptal_stack.append(attribute_lines[1:])
+ combination_stack.append(combination + ptav)
- return iterate_attribute_lines(attribute_lines, self.env['product.template.attribute.value'])
+ return _("There are no remaining possible combination.")
@api.multi
def _get_closest_possible_combination(self, combination):
- """Get the first possible combination that is the closest to the given
- combination.
+ """See `_get_closest_possible_combinations` (one iteration).
+
+ 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.
+ """
+ return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
+
+ @api.multi
+ def _get_closest_possible_combinations(self, combination):
+ """Generator returning the possible combinations that are 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
+ :return: the possible combinations that are including as much
elements as possible from the given combination.
- :rtype: recordset of product.template.attribute.value
+ :rtype: generator of 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]
+ res = self._get_possible_combinations(necessary_values=combination)
+ try:
+ # If there is at least one result for the given combination
+ # we consider that combination set, and we yield all the
+ # possible combinations for it.
+ yield(next(res))
+ for cur in res:
+ yield(cur)
+ return _("There are no remaining closest combination.")
+ except StopIteration:
+ # There are no results for the given combination, we try to
+ # progressively remove values from it.
+ if not combination:
+ return _("There are no possible combination.")
+ combination = combination[:-1]
@api.multi
def _get_current_company(self, **kwargs):
diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py
index 19070a1c887..aa1de67d16b 100644
--- a/addons/product/tests/test_product_attribute_value_config.py
+++ b/addons/product/tests/test_product_attribute_value_config.py
@@ -204,8 +204,8 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
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()
+ valid_value_ids = self.computer.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = self.computer.valid_product_attribute_wnva_ids
self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids))
# also test _has_valid_attributes (case not ok):
@@ -321,34 +321,52 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
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
+ # Basic case: test all iterations of generator
+ gen = self.computer._get_possible_combinations()
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
+ with self.assertRaises(StopIteration):
+ next(gen)
# Give priority to ram_16 but it is not allowed by hdd_1 so it should return hhd_2 instead
+ # Test invalidate_cache on product.attribute.value write
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)
+ # Move down the ram, so it will try to change the ram instead of the hdd
+ # Test invalidate_cache on product.attribute write
+ self.ram_attribute.sequence = 10
+ self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+
# Give priority to ram_32 and is allowed with the rest so it should return it
+ self.ram_attribute.sequence = 2
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)
@@ -357,26 +375,37 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
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
+ # No possible combination (test helper and iterator)
self._add_exclude(computer_ram_32, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), self.env['product.template.attribute.value'])
+ gen = self.computer._get_possible_combinations()
+ with self.assertRaises(StopIteration):
+ next(gen)
- def test_get_closest_possible_combination(self):
+ def test_get_closest_possible_combinations(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)
- # CASE nothing special
- self.assertEqual(self.computer._get_closest_possible_combination(None),
- computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ # CASE nothing special (test 2 iterations)
+ gen = self.computer._get_closest_possible_combinations(None)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
- # 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_1 (test all iterations)
+ gen = self.computer._get_closest_possible_combinations(computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
+ with self.assertRaises(StopIteration):
+ next(gen)
# CASE contains computer_hdd_2
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2),
@@ -393,3 +422,27 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
# 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)
+
+ def test_clear_caches(self):
+ """The goal of this test is to make sure the cache is invalidated when
+ it should be."""
+ attribute_values = self.ssd_256 + self.ram_8 + self.hdd_1
+
+ # CASE: initial result of _get_variant_id_for_combination
+ variant_id = self.computer._get_variant_id_for_combination(attribute_values)
+ self.assertTrue(variant_id)
+
+ # CASE: clear_caches in product.product unlink
+ self.env['product.product'].browse(variant_id).unlink()
+ self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
+
+ # CASE: clear_caches in product.product create
+ variant = self.env['product.product'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_value_ids': [(6, 0, attribute_values.ids)],
+ })
+ self.assertEqual(variant.id, self.computer._get_variant_id_for_combination(attribute_values))
+
+ # CASE: clear_caches in product.product write
+ variant.attribute_value_ids = False
+ self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index bd70a9f0047..dbf8a4ab4dc 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -52,13 +52,14 @@
<field name="list_price" string="Sales Price" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
<label for="standard_price" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '&gt;', 1), ('is_product_variant', '=', False)]}"/>
<div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '&gt;', 1), ('is_product_variant', '=', False)]}">
- <field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/>
+ <field name="standard_price" widget='monetary' options="{'currency_field': 'cost_currency_id'}"/>
</div>
<field name="company_id" groups="base.group_multi_company"
options="{'no_create': True}"/>
<field name="uom_id" groups="uom.group_uom" options="{'no_create': True}"/>
<field name="uom_po_id" groups="uom.group_uom" options="{'no_create': True}"/>
<field name="currency_id" invisible="1"/>
+ <field name="cost_currency_id" invisible="1"/>
<field name="product_variant_id" invisible="1"/>
</group>
</group>
diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py
index 49fd7c9da88..ba676bf2fad 100644
--- a/addons/purchase_requisition/models/purchase_requisition.py
+++ b/addons/purchase_requisition/models/purchase_requisition.py
@@ -258,7 +258,7 @@ class PurchaseRequisitionLine(models.Model):
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
- self.product_uom_id = self.product_id.uom_id
+ self.product_uom_id = self.product_id.uom_po_id
self.product_qty = 1.0
if not self.schedule_date:
self.schedule_date = self.requisition_id.schedule_date
diff --git a/addons/purchase_requisition/views/purchase_requisition_views.xml b/addons/purchase_requisition/views/purchase_requisition_views.xml
index cb4bb9ed82c..ccc537df2cf 100644
--- a/addons/purchase_requisition/views/purchase_requisition_views.xml
+++ b/addons/purchase_requisition/views/purchase_requisition_views.xml
@@ -30,7 +30,7 @@
<div class="o_kanban_record_headings mt4">
<strong class="o_kanban_record_title"><field name="name"/></strong>
</div>
- <field name="exclusive" widget="kanban_label_selection"/>
+ <field name="exclusive" widget="label_selection"/>
</div>
</div>
</t>
@@ -226,7 +226,7 @@
<div class="o_kanban_record_headings mt4">
<strong class="o_kanban_record_title"><span><field name="name"/></span></strong>
</div>
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}"/>
</div>
<div class="o_kanban_record_body">
<span class="text-muted"><field name="type_id"/></span>
diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py
index 3cc797255cd..4813bf2fe16 100644
--- a/addons/purchase_stock/models/purchase.py
+++ b/addons/purchase_stock/models/purchase.py
@@ -372,12 +372,11 @@ class PurchaseOrderLine(models.Model):
@api.multi
def _create_stock_moves(self, picking):
- moves = self.env['stock.move']
- done = self.env['stock.move'].browse()
+ values = []
for line in self:
for val in line._prepare_stock_moves(picking):
- done += moves.create(val)
- return done
+ values.append(val)
+ return self.env['stock.move'].create(values)
def _merge_in_existing_line(self, product_id, product_qty, product_uom, location_id, name, origin, values):
""" This function purpose is to be override with the purpose to forbide _run_buy method
diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml
index 937301a7176..f8d0d840697 100644
--- a/addons/repair/views/repair_views.xml
+++ b/addons/repair/views/repair_views.xml
@@ -206,7 +206,7 @@
<strong><span><t t-esc="record.name.value"/></span></strong>
</div>
<div class="col-6 text-right">
- <span class="badge badge-secondary"><field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'info', 'cancel': 'danger', 'done': 'success', 'under_repair': 'dafault'}}"/></span>
+ <span class="badge badge-secondary"><field name="state" widget="label_selection" options="{'classes': {'draft': 'info', 'cancel': 'danger', 'done': 'success', 'under_repair': 'dafault'}}"/></span>
</div>
</div>
<div class="row">
diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py
index 5a5ae1b26d9..eea3dfe67fd 100644
--- a/addons/sale/controllers/product_configurator.py
+++ b/addons/sale/controllers/product_configurator.py
@@ -41,7 +41,10 @@ class ProductConfiguratorController(http.Controller):
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
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)
+ ProductTemplate = request.env['product.template']
+ if 'context' in kw:
+ ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
+ return ProductTemplate.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):
diff --git a/addons/sale/data/mail_data.xml b/addons/sale/data/mail_data.xml
index 938a39e1576..b39a793f83b 100644
--- a/addons/sale/data/mail_data.xml
+++ b/addons/sale/data/mail_data.xml
@@ -56,7 +56,7 @@
</data>
<!-- Template and notification section -->
<data noupdate="1">
- <template id="" inherit_id="mail.mail_notification_paynow" name="oezirhgoi">
+ <template id="mail_notification_paynow_online" inherit_id="mail.mail_notification_paynow" name="oezirhgoi">
<xpath expr="//t[@t-set='access_name']" position="after">
<t t-if="record._name == 'sale.order'">
<t t-if="record.has_to_be_signed(include_draft=True)">
diff --git a/addons/sale/models/payment.py b/addons/sale/models/payment.py
index c97d93430cd..3d650c647fe 100644
--- a/addons/sale/models/payment.py
+++ b/addons/sale/models/payment.py
@@ -112,7 +112,7 @@ class PaymentTransaction(models.Model):
ctx_company = {'company_id': self.acquirer_id.company_id.id,
'force_company': self.acquirer_id.company_id.id}
for trans in self.filtered(lambda t: t.sale_order_ids):
- trans = trans.with_context(ctx_company)
+ trans = trans.with_context(**ctx_company)
trans.sale_order_ids._force_lines_to_invoice_policy_order()
invoices = trans.sale_order_ids.action_invoice_create()
trans.invoice_ids = [(6, 0, invoices)]
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
index 3691067d106..3e191b13cc1 100644
--- a/addons/sale/models/product_template.py
+++ b/addons/sale/models/product_template.py
@@ -169,7 +169,7 @@ class ProductTemplate(models.Model):
return res
@api.multi
- def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
""" Return info about a given combination.
Note: this method does not take into account whether the combination is
@@ -194,6 +194,9 @@ class ProductTemplate(models.Model):
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
+ :param only_template: boolean, if set to True, get the info for the
+ template only: ignore combination and don't try to find variant
+
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
@@ -215,6 +218,8 @@ class ProductTemplate(models.Model):
discount applied (price < list_price), else False
"""
self.ensure_one()
+ # get the name before the change of context to benefit from prefetch
+ display_name = self.name
quantity = self.env.context.get('quantity', add_qty)
context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
@@ -222,10 +227,12 @@ class ProductTemplate(models.Model):
combination = combination or product_template.env['product.template.attribute.value']
- if not product_id and not combination:
+ if not product_id and not combination and not only_template:
combination = product_template._get_first_possible_combination(parent_combination)
- if product_id and not combination:
+ if only_template:
+ product = product_template.env['product.product']
+ elif product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
@@ -253,8 +260,6 @@ class ProductTemplate(models.Model):
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')))
@@ -281,8 +286,7 @@ class ProductTemplate(models.Model):
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.
+ there is at least one possible combination.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
@@ -293,9 +297,9 @@ class ProductTemplate(models.Model):
"""
self.ensure_one()
if not self.active:
+ # for performance: avoid calling `_get_possible_combinations`
return False
- combination = self._get_first_possible_combination(parent_combination)
- return True if combination else self._is_combination_possible(combination, parent_combination)
+ return next(self._get_possible_combinations(parent_combination), False) is not False
@api.multi
def _get_current_company_fallback(self, **kwargs):
diff --git a/addons/sale/models/res_config_settings.py b/addons/sale/models/res_config_settings.py
index 0a5060483a4..9af48d1a011 100644
--- a/addons/sale/models/res_config_settings.py
+++ b/addons/sale/models/res_config_settings.py
@@ -79,6 +79,8 @@ class ResConfigSettings(models.TransientModel):
def set_values(self):
super(ResConfigSettings, self).set_values()
+ if self.default_invoice_policy != 'order':
+ self.env['ir.config_parameter'].set_param('sale.automatic_invoice', False)
if not self.group_discount_per_so_line:
pl = self.env['product.pricelist'].search([('discount_policy', '=', 'without_discount')])
pl.write({'discount_policy': 'with_discount'})
diff --git a/addons/sale/models/res_partner.py b/addons/sale/models/res_partner.py
index d03c877b938..a1f60defb2d 100644
--- a/addons/sale/models/res_partner.py
+++ b/addons/sale/models/res_partner.py
@@ -14,14 +14,20 @@ class ResPartner(models.Model):
sale_warn_msg = fields.Text('Message for Sales Order')
def _compute_sale_order_count(self):
- sale_data = self.env['sale.order'].read_group(domain=[('partner_id', 'child_of', self.ids)],
- fields=['partner_id'], groupby=['partner_id'])
- mapped_data = dict([(m['partner_id'][0], m['partner_id_count']) for m in sale_data])
- for partner in self:
- # let's obtain the partner id and all its child ids
- partner_ids = [partner.id] + partner.child_ids.ids
- # then we can sum for all the partner's child
- partner.sale_order_count = sum(mapped_data.get(child, 0) for child in partner_ids)
+ # retrieve all children partners and prefetch 'parent_id' on them
+ all_partners = self.search([('id', 'child_of', self.ids)])
+ all_partners.read(['parent_id'])
+
+ sale_order_groups = self.env['sale.order'].read_group(
+ domain=[('partner_id', 'in', all_partners.ids)],
+ fields=['partner_id'], groupby=['partner_id']
+ )
+ for group in sale_order_groups:
+ partner = self.browse(group['partner_id'][0])
+ while partner:
+ if partner in self:
+ partner.sale_order_count += group['partner_id_count']
+ partner = partner.parent_id
def can_edit_vat(self):
can_edit_vat = super(ResPartner, self).can_edit_vat()
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 48a37020c96..562da160396 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -303,7 +303,7 @@ class SaleOrder(models.Model):
'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False,
'partner_invoice_id': addr['invoice'],
'partner_shipping_id': addr['delivery'],
- 'user_id': self.partner_id.user_id.id or self.env.uid
+ 'user_id': self.partner_id.user_id.id or self.partner_id.commercial_partner_id.user_id.id or self.env.uid
}
if self.env['ir.config_parameter'].sudo().get_param('sale.use_sale_note') and self.env.user.company_id.sale_note:
values['note'] = self.with_context(lang=self.partner_id.lang).env.user.company_id.sale_note
@@ -634,11 +634,15 @@ class SaleOrder(models.Model):
if email_act and email_act.get('context'):
email_ctx = email_act['context']
email_ctx.update(default_email_from=order.company_id.email)
- order.with_context(email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
+ order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
return True
@api.multi
def action_done(self):
+ tx = self.sudo().transaction_ids.get_last_transaction()
+ if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer':
+ tx._set_transaction_done()
+ tx.write({'is_processed': True})
return self.write({'state': 'done'})
@api.multi
@@ -1429,6 +1433,16 @@ class SaleOrderLine(models.Model):
result = {'domain': domain}
+ name = self.get_sale_order_line_multiline_description_sale(product)
+
+ vals.update(name=name)
+
+ self._compute_tax_id()
+
+ if self.order_id.pricelist_id and self.order_id.partner_id:
+ vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
+ self.update(vals)
+
title = False
message = False
warning = {}
@@ -1441,16 +1455,6 @@ class SaleOrderLine(models.Model):
if product.sale_line_warn == 'block':
self.product_id = False
- name = self.get_sale_order_line_multiline_description_sale(product)
-
- vals.update(name=name)
-
- self._compute_tax_id()
-
- if self.order_id.pricelist_id and self.order_id.partner_id:
- vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
- self.update(vals)
-
return result
@api.onchange('product_uom', 'product_uom_qty')
diff --git a/addons/sale/report/sale_report_templates.xml b/addons/sale/report/sale_report_templates.xml
index 8e678b1fcd6..60757646486 100644
--- a/addons/sale/report/sale_report_templates.xml
+++ b/addons/sale/report/sale_report_templates.xml
@@ -192,9 +192,9 @@
<p t-if="doc.payment_term_id.note">
<span t-field="doc.payment_term_id.note"/>
</p>
- <p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.note">
+ <p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.sudo().note">
<strong>Fiscal Position Remark:</strong>
- <span t-field="doc.fiscal_position_id.note"/>
+ <span t-field="doc.fiscal_position_id.sudo().note"/>
</p>
<div t-if="doc.signature" class="mt32 ml16 mr16" name="signature">
diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js
index 1a74764aacc..0d49a4f245e 100644
--- a/addons/sale/static/src/js/product_configurator_mixin.js
+++ b/addons/sale/static/src/js/product_configurator_mixin.js
@@ -183,7 +183,7 @@ var ProductConfiguratorMixin = {
*/
triggerVariantChange: function ($container) {
var self = this;
- $container.find('ul[data-attribute_exclusions]').trigger('change');
+ $container.find('ul[data-attribute_exclusions]').trigger('change', {$container: $container});
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
self.handleCustomValues($(this));
});
@@ -559,14 +559,16 @@ var ProductConfiguratorMixin = {
_updateProductImage: function ($productContainer, productId, productTemplateId) {
var model = productId ? 'product.product' : 'product.template';
var modelId = productId || productTemplateId;
- var imageSrc = '/web/image/{0}/{1}/image'
+ var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image');
+ var imageSrc = imageUrl
.replace("{0}", model)
.replace("{1}", modelId);
var imagesSelectors = [
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
'img.product_detail_img',
- 'span.variant_image img'
+ 'span.variant_image img',
+ 'img.variant_image',
];
var $img = $productContainer.find(imagesSelectors.join(', '));
diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js
index 630774b8da5..60544ada7c4 100644
--- a/addons/sale/static/src/js/product_configurator_modal.js
+++ b/addons/sale/static/src/js/product_configurator_modal.js
@@ -69,9 +69,9 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : '');
if (this.isWebsite) {
- delete this.events['change [data-attribute_exclusions]'];
delete this.events['click button.js_add_cart_json'];
}
+ this._productImageField = 'image_medium';
},
/**
* @override
@@ -440,7 +440,17 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
delete optionalProductsMap[optionId];
}
},
+ /**
+ * @override
+ */
+ _onChangeCombination:function (ev, $parent, combination) {
+ $parent
+ .find('.td-product_name .product-name')
+ .first()
+ .text(combination.display_name);
+ ProductConfiguratorMixin._onChangeCombination.apply(this, arguments);
+ },
/**
* When the quantity of the root product is updated, we need to update
* the quantity of all the selected optional products.
diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py
index 95b1814f50e..087cbe78de5 100644
--- a/addons/sale/tests/test_sale_product_attribute_value_config.py
+++ b/addons/sale/tests/test_sale_product_attribute_value_config.py
@@ -80,8 +80,13 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
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.
+ model which had product_id as required and without cascade on delete.
+ Here we have the sales order line in this situation.
+
+ This is a necessary condition for `create_variant_ids` to archive
+ instead of delete the variants.
"""
def do_test(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
@@ -110,6 +115,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
variant2.active = False
# CASE: 1 not archived, 2 archived
+ # Test invalidate_cache on product.product write
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)
@@ -261,6 +267,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: dynamic combination, no variant existing
+ # Test invalidate_cache on product.template create_variant_ids
self._add_keyboard_attribute()
self.computer.create_variant_ids()
combination += self._get_product_template_attribute_value(self.keyboard_excluded)
@@ -272,8 +279,8 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
# CASE: pricelist set value to 0, no variant
+ # Test invalidate_cache on product.pricelist write
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)
@@ -346,7 +353,6 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
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)
diff --git a/addons/sale_management/models/sale_order.py b/addons/sale_management/models/sale_order.py
index afa13aed50d..6ebed05a2bc 100644
--- a/addons/sale_management/models/sale_order.py
+++ b/addons/sale_management/models/sale_order.py
@@ -209,4 +209,5 @@ class SaleOrderOption(models.Model):
'product_uom_qty': self.quantity,
'product_uom': self.uom_id.id,
'discount': self.discount,
+ 'company_id': self.order_id.company_id.id,
}
diff --git a/addons/sale_management/models/sale_order_template.py b/addons/sale_management/models/sale_order_template.py
index 45bcf2bb752..8eef6efd405 100644
--- a/addons/sale_management/models/sale_order_template.py
+++ b/addons/sale_management/models/sale_order_template.py
@@ -120,7 +120,10 @@ class SaleOrderTemplateOption(models.Model):
return
product = self.product_id
self.price_unit = product.list_price
- self.name = product.name
+ name = product.name
+ if self.product_id.description_sale:
+ name += '\n' + self.product_id.description_sale
+ self.name = name
self.uom_id = product.uom_id
domain = {'uom_id': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
return {'domain': domain}
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 1cccd7fe0e1..074da95f06e 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
@@ -3,6 +3,8 @@ odoo.define('sale.sale_product_configurator_advanced_tour', function (require) {
var tour = require('web_tour.tour');
+var optionVariantImage;
+
tour.register('sale_product_configurator_advanced_tour', {
url: "/web",
test: true,
@@ -96,6 +98,25 @@ tour.register('sale_product_configurator_advanced_tour', {
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")',
+ run: function () {
+ optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ }
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")',
+ run: function () {
+ var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ if (newVariantImage !== optionVariantImage) {
+ $('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
+ }
+
+ }
+}, {
+ extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
+}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
index 263476c140e..d3111d2564f 100644
--- a/addons/stock/models/stock_inventory.py
+++ b/addons/stock/models/stock_inventory.py
@@ -267,7 +267,7 @@ class Inventory(models.Model):
args += (self.package_id.id,)
#case 5: Filter on One product category + Exahausted Products
if self.category_id:
- categ_products = Product.search([('categ_id', '=', self.category_id.id)])
+ categ_products = Product.search([('categ_id', 'child_of', self.category_id.id)])
domain += ' AND product_id = ANY (%s)'
args += (categ_products.ids,)
products_to_filter |= categ_products
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
index 1f32abd5130..b0a36dd29f8 100644
--- a/addons/stock/models/stock_location.py
+++ b/addons/stock/models/stock_location.py
@@ -182,9 +182,9 @@ class Route(models.Model):
'res.company', 'Company',
default=lambda self: self.env['res.company']._company_default_get('stock.location.route'), index=True,
help='Leave this field empty if this route is shared between all companies')
- product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products')
- categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories')
- warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses')
+ product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products', copy=False)
+ categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories', copy=False)
+ warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses', copy=False)
@api.onchange('warehouse_selectable')
def _onchange_warehouse_selectable(self):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 4881c84d591..156ed59eb32 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -1136,7 +1136,8 @@ class StockMove(models.Model):
moves_todo.mapped('move_dest_ids')._action_assign()
# We don't want to create back order for scrap moves
- if all(move_todo.scrapped for move_todo in moves_todo):
+ # Replace by a kwarg in master
+ if self.env.context.get('is_scrap'):
return moves_todo
if picking and not cancel_backorder:
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index 4eb563013c6..69f5aaf5e70 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -718,7 +718,7 @@ class Picking(models.Model):
# If no lots when needed, raise error
picking_type = self.picking_type_id
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids)
+ no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel')))
no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
if no_reserved_quantities and no_quantities_done:
raise UserError(_('You cannot validate a transfer if no quantites are reserved nor done. To force the transfer, switch in edit more and encode the done quantities.'))
diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py
index 7c8eeefb57a..9a9ccd51943 100644
--- a/addons/stock/models/stock_scrap.py
+++ b/addons/stock/models/stock_scrap.py
@@ -106,7 +106,8 @@ class StockScrap(models.Model):
def do_scrap(self):
for scrap in self:
move = self.env['stock.move'].create(scrap._prepare_move_values())
- move._action_done()
+ # master: replace context by cancel_backorder
+ move.with_context(is_scrap=True)._action_done()
scrap.write({'move_id': move.id, 'state': 'done'})
return True
diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py
index b7c83300245..56f82f9d4ba 100644
--- a/addons/stock/models/stock_warehouse.py
+++ b/addons/stock/models/stock_warehouse.py
@@ -495,12 +495,39 @@ class Warehouse(models.Model):
def_values = self.default_get(['reception_steps', 'delivery_steps'])
reception_steps = vals.get('reception_steps', def_values['reception_steps'])
delivery_steps = vals.get('delivery_steps', def_values['delivery_steps'])
+ code = vals.get('code') or self.code
+ code = code.replace(' ', '').upper()
sub_locations = {
- 'lot_stock_id': {'name': _('Stock'), 'active': True, 'usage': 'internal'},
- 'wh_input_stock_loc_id': {'name': _('Input'), 'active': reception_steps != 'one_step', 'usage': 'internal'},
- 'wh_qc_stock_loc_id': {'name': _('Quality Control'), 'active': reception_steps == 'three_steps', 'usage': 'internal'},
- 'wh_output_stock_loc_id': {'name': _('Output'), 'active': delivery_steps != 'ship_only', 'usage': 'internal'},
- 'wh_pack_stock_loc_id': {'name': _('Packing Zone'), 'active': delivery_steps == 'pick_pack_ship', 'usage': 'internal'},
+ 'lot_stock_id': {
+ 'name': _('Stock'),
+ 'active': True,
+ 'usage': 'internal',
+ 'barcode': code + '-STOCK'
+ },
+ 'wh_input_stock_loc_id': {
+ 'name': _('Input'),
+ 'active': reception_steps != 'one_step',
+ 'usage': 'internal',
+ 'barcode': code + '-INPUT'
+ },
+ 'wh_qc_stock_loc_id': {
+ 'name': _('Quality Control'),
+ 'active': reception_steps == 'three_steps',
+ 'usage': 'internal',
+ 'barcode': code + '-QUALITY'
+ },
+ 'wh_output_stock_loc_id': {
+ 'name': _('Output'),
+ 'active': delivery_steps != 'ship_only',
+ 'usage': 'internal',
+ 'barcode': code + '-OUTPUT'
+ },
+ 'wh_pack_stock_loc_id': {
+ 'name': _('Packing Zone'),
+ 'active': delivery_steps == 'pick_pack_ship',
+ 'usage': 'internal',
+ 'barcode': code + '-PACKING'
+ },
}
return sub_locations
diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py
index 0939389dda9..8a6cb87da67 100644
--- a/addons/stock/tests/test_move.py
+++ b/addons/stock/tests/test_move.py
@@ -3379,6 +3379,15 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+
+ scrap = self.env['stock.scrap'].create({
+ 'picking_id': picking.id,
+ 'product_id': self.product1.id,
+ 'product_uom_id': self.uom_unit.id,
+ 'scrap_qty': 5.0,
+ })
+ scrap.do_scrap()
+
# No products are reserved on the move of 10, click on `button_validate`.
with self.assertRaises(UserError):
picking.button_validate()
diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py
index 13b092a9407..dd75243ffa3 100644
--- a/addons/stock/tests/test_move2.py
+++ b/addons/stock/tests/test_move2.py
@@ -1811,6 +1811,9 @@ class TestSinglePicking(TestStockCommon):
class TestStockUOM(TestStockCommon):
def setUp(self):
super(TestStockUOM, self).setUp()
+ self.setUpCache()
+
+ def setUpCache(self):
dp = self.env.ref('product.decimal_product_uom')
dp.digits = 7
@@ -1845,6 +1848,10 @@ class TestStockUOM(TestStockCommon):
'uom_po_id': T_LBS.id,
'tracking': 'lot',
})
+
+ # creating a variant invalidates the cache
+ self.setUpCache()
+
picking_in = self.env['stock.picking'].create({
'partner_id': self.partner_delta_id,
'picking_type_id': self.picking_type_in,
diff --git a/addons/stock/views/stock_inventory_views.xml b/addons/stock/views/stock_inventory_views.xml
index 0aef58e898c..eb1fd43c0d5 100644
--- a/addons/stock/views/stock_inventory_views.xml
+++ b/addons/stock/views/stock_inventory_views.xml
@@ -97,7 +97,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><span><t t-esc="record.name.value"/></span></strong>
</div>
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'info', 'cancel': 'default', 'confirm': 'success', 'done': 'success'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'info', 'cancel': 'default', 'confirm': 'success', 'done': 'success'}}"/>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml
index d9811745801..a89b280ddbc 100644
--- a/addons/stock/views/stock_move_views.xml
+++ b/addons/stock/views/stock_move_views.xml
@@ -447,7 +447,7 @@
</div>
<div class="col-4">
<span class="float-right text-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'cancel': 'danger',
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'cancel': 'danger',
'assigned': 'success', 'confirmed': 'default'}}"/>
</span>
</div>
@@ -511,7 +511,7 @@
</div>
<div class="col-6">
<span class="float-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'info', 'cancel': 'default',
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'info', 'cancel': 'default',
'assigned': 'success', 'done': 'success'}}"/>
</span>
</div>
diff --git a/addons/stock_account/views/product_views.xml b/addons/stock_account/views/product_views.xml
index 6e09f8416a3..d4920effef1 100644
--- a/addons/stock_account/views/product_views.xml
+++ b/addons/stock_account/views/product_views.xml
@@ -39,7 +39,7 @@
<field name="standard_price" position="replace">
<field name="standard_price"
attrs="{'readonly':[('valuation','=','real_time')]}"
- widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"
+ widget='monetary' options="{'currency_field': 'cost_currency_id', 'field_digits': True}"
class="float-left oe_inline"/>
<div name="update_cost_price">
<button string="Update Cost" type="action"
diff --git a/addons/stock_account/views/stock_account_views.xml b/addons/stock_account/views/stock_account_views.xml
index a9dbf024f2e..b25d6cf94f3 100644
--- a/addons/stock_account/views/stock_account_views.xml
+++ b/addons/stock_account/views/stock_account_views.xml
@@ -77,8 +77,8 @@
<field name="qty_at_date"/>
<field name="uom_id" groups="uom.group_uom"/>
<field name="currency_id" invisible="1"/>
- <field name="stock_value_currency_id" invisible="1"/>
- <field name="stock_value" sum="Stock Valuation" widget="monetary" options="{'currency_field': 'stock_value_currency_id'}"/>
+ <field name="cost_currency_id" invisible="1"/>
+ <field name="stock_value" sum="Stock Valuation" widget="monetary" options="{'currency_field': 'cost_currency_id'}"/>
<field name="cost_method" invisible="1"/>
<button name="action_valuation_at_date_details" type="object" icon="fa-info-circle" aria-label="Valuation" title="Valuation" attrs="{'invisible': [('cost_method', '!=', 'fifo')]}" />
</tree>
@@ -91,6 +91,10 @@
<field name="priority" eval="17"/>
<field name="arch" type="xml">
<search string="Inventory Valuation">
+ <field name="name" string="Product"/>
+ <field name="categ_id" />
+ <field name="product_tmpl_id" />
+ <field name="attribute_value_ids" />
<filter string="Category" name="categ_id" context="{'group_by':'categ_id'}"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
diff --git a/addons/stock_picking_batch/views/stock_picking_batch_views.xml b/addons/stock_picking_batch/views/stock_picking_batch_views.xml
index 8df5a5bccc6..aeec7d199ac 100644
--- a/addons/stock_picking_batch/views/stock_picking_batch_views.xml
+++ b/addons/stock_picking_batch/views/stock_picking_batch_views.xml
@@ -70,7 +70,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><field name="name"/></strong>
</div>
- <field name="state" widget="kanban_label_selection"/>
+ <field name="state" widget="label_selection"/>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left"/>
diff --git a/addons/survey/views/survey_user_views.xml b/addons/survey/views/survey_user_views.xml
index 1e35a182e06..7ec4d7c6a05 100644
--- a/addons/survey/views/survey_user_views.xml
+++ b/addons/survey/views/survey_user_views.xml
@@ -108,7 +108,7 @@
<field name="date_create"/>
</div>
<div class="oe_kanban_bottom_right mr4">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'new': 'default', 'done': 'success', 'skip':'warning'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'new': 'default', 'done': 'success', 'skip':'warning'}}"/>
</div>
</div>
</div>
@@ -219,4 +219,4 @@
<menuitem name="Answers" id="menu_survey_type_form1" action="action_survey_user_input" parent="menu_surveys" sequence="1"/>
<menuitem name="User Input Lines" id="menu_survey_response_line_form" action="action_survey_user_input_line" parent="menu_surveys_configuration" sequence="4"/>
</data>
-</odoo> \ No newline at end of file
+</odoo>
diff --git a/addons/test_website/data/test_website_data.xml b/addons/test_website/data/test_website_data.xml
index df827d542a3..0d829aaab84 100644
--- a/addons/test_website/data/test_website_data.xml
+++ b/addons/test_website/data/test_website_data.xml
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
+
+ <!-- RECORDS FOR RESET VIEWS TESTS -->
<record id="test_view" model="ir.ui.view">
<field name="name">Test View</field>
<field name="type">qweb</field>
@@ -50,5 +52,11 @@
<p>placeholder</p>
</xpath>
</template>
+
+ <!-- RECORDS FOR MODULE OPERATION TESTS -->
+ <template id="update_module_base_view">
+ <div>I am a base view</div>
+ </template>
+
</data>
</odoo>
diff --git a/addons/test_website/static/src/js/reset_views.test.js b/addons/test_website/static/src/js/reset_views.test.js
index 957cda6d594..2b0e2b71a6b 100644
--- a/addons/test_website/static/src/js/reset_views.test.js
+++ b/addons/test_website/static/src/js/reset_views.test.js
@@ -23,7 +23,7 @@ tour.register('test_reset_page_view_complete_flow_part1', {
},
{
content: "save the page",
- extra_trigger: '#oe_structure_test_website_page .s_cover',
+ extra_trigger: '#oe_structure_test_website_page.o_dirty',
trigger: "#web_editor-top-edit button[data-action=save]",
},
// 2. Edit that COW'd view in the HTML editor to break it.
diff --git a/addons/test_website/tests/__init__.py b/addons/test_website/tests/__init__.py
index 4c32d5f1959..57d8fcd0c25 100644
--- a/addons/test_website/tests/__init__.py
+++ b/addons/test_website/tests/__init__.py
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_reset_views
+from . import test_views_during_module_operation
diff --git a/addons/test_website/tests/test_views_during_module_operation.py b/addons/test_website/tests/test_views_during_module_operation.py
new file mode 100644
index 00000000000..58cf943daf4
--- /dev/null
+++ b/addons/test_website/tests/test_views_during_module_operation.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import TransactionCase, tagged
+
+
+@tagged('-at_install', 'post_install')
+class TestViewsDuringModuleOperation(TransactionCase):
+ def test_01_cow_views_unlink_on_module_update(self):
+ """ Ensure COW views are correctly removed during module update.
+ Not removing the view could lead to traceback:
+ - Having a view A
+ - Having a view B that inherits from a view C
+ - View B t-call view A
+ - COW view B
+ - Delete view A and B from module datas and update it
+ - Rendering view C will crash since it will render child view B that
+ t-call unexisting view A
+ """
+
+ View = self.env['ir.ui.view']
+ Imd = self.env['ir.model.data']
+
+ update_module_base_view = self.env.ref('test_website.update_module_base_view')
+ update_module_view_to_be_t_called = View.create({
+ 'name': 'View to be t-called',
+ 'type': 'qweb',
+ 'arch': '<div>I will be t-called</div>',
+ 'key': 'test_website.update_module_view_to_be_t_called',
+ })
+ update_module_child_view = View.create({
+ 'name': 'Child View',
+ 'mode': 'extension',
+ 'inherit_id': update_module_base_view.id,
+ 'arch': '''
+ <div position="inside">
+ <t t-call="test_website.update_module_view_to_be_t_called"/>
+ </div>
+ ''',
+ 'key': 'test_website.update_module_child_view',
+ })
+
+ # Create IMD so when updating the module the views will be removed (not found in file)
+ Imd.create({
+ 'module': 'test_website',
+ 'name': 'update_module_view_to_be_t_called',
+ 'model': 'ir.ui.view',
+ 'res_id': update_module_view_to_be_t_called.id,
+ })
+ Imd.create({
+ 'module': 'test_website',
+ 'name': 'update_module_child_view',
+ 'model': 'ir.ui.view',
+ 'res_id': update_module_child_view.id,
+ })
+
+ # Trigger COW on child view
+ update_module_child_view.with_context(website_id=1).write({'name': 'Child View (W1)'})
+
+ # Ensure views are correctly setup
+ self.assertEquals(View.search_count([('type', '=', 'qweb'), ('key', '=', update_module_child_view.key)]), 2)
+ self.assertTrue(self.env.ref(update_module_view_to_be_t_called.key))
+ self.assertTrue(self.env.ref(update_module_base_view.key))
+
+ # Update the module
+ test_website_module = self.env['ir.module.module'].search([('name', '=', 'test_website')])
+ test_website_module.button_immediate_upgrade()
+
+ # Ensure generic views got removed
+ self.assertFalse(self.env.ref('test_website.update_module_view_to_be_t_called', raise_if_not_found=False))
+ # Ensure specific COW views got removed
+ self.assertEquals(View.search_count([('type', '=', 'qweb'), ('key', '=', 'test_website.update_module_child_view')]), 0)
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index dc75ba8ea2a..ee911b141a0 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -1331,7 +1331,9 @@ var CopyClipboard = {
*/
destroy: function () {
this._super.apply(this, arguments);
- this.clipboard.destroy();
+ if (this.clipboard) {
+ this.clipboard.destroy();
+ }
},
//--------------------------------------------------------------------------
@@ -1346,8 +1348,8 @@ var CopyClipboard = {
var $clipboardBtn = this.$('.o_clipboard_button');
$clipboardBtn.tooltip({title: _t('Copied !'), trigger: 'manual', placement: 'right'});
this.clipboard = new ClipboardJS($clipboardBtn[0], {
- text: function (_) {
- return self.value.trim();
+ text: function () {
+ return self.value.trim();
},
// Container added because of Bootstrap modal that give the focus to another element.
// We need to give to correct focus to ClipboardJS (see in ClipboardJS doc)
@@ -1363,40 +1365,31 @@ var CopyClipboard = {
});
});
},
-};
-
-var TextCopyClipboard = FieldText.extend(CopyClipboard, {
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
/**
* @override
*/
- _render: function() {
+ _render: function () {
this._super.apply(this, arguments);
this.$el.addClass('o_field_copy');
- this.$el.append($(qweb.render('CopyClipboardText')));
- this._initClipboard();
- }
-});
-
-var CharCopyClipboard = FieldChar.extend(CopyClipboard, {
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
+ },
/**
* @override
*/
- _render: function() {
+ _renderReadonly: function () {
this._super.apply(this, arguments);
- this.$el.addClass('o_field_copy');
- this.$el.append($(qweb.render('CopyClipboardChar')));
- this._initClipboard();
+ if (this.value) {
+ this.$el.append($(qweb.render(this.clipboardTemplate)));
+ this._initClipboard();
+ }
}
+};
+
+var TextCopyClipboard = FieldText.extend(CopyClipboard, {
+ clipboardTemplate: 'CopyClipboardText',
+});
+
+var CharCopyClipboard = FieldChar.extend(CopyClipboard, {
+ clipboardTemplate: 'CopyClipboardChar',
});
var AbstractFieldBinary = AbstractField.extend({
diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js
index c94c315cfa6..4406231868f 100644
--- a/addons/web/static/src/js/fields/field_registry.js
+++ b/addons/web/static/src/js/fields/field_registry.js
@@ -44,8 +44,9 @@ registry
.add('priority', basic_fields.PriorityWidget)
.add('attachment_image', basic_fields.AttachmentImage)
.add('label_selection', basic_fields.LabelSelection)
+ .add('kanban_label_selection', basic_fields.LabelSelection) // deprecated, use label_selection
.add('state_selection', basic_fields.StateSelectionWidget)
- .add('kanban_state_selection', basic_fields.StateSelectionWidget)
+ .add('kanban_state_selection', basic_fields.StateSelectionWidget) // deprecated, use state_selection
.add('boolean_favorite', basic_fields.FavoriteWidget)
.add('boolean_button', basic_fields.FieldBooleanButton)
.add('boolean_toggle', basic_fields.BooleanToggle)
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 6005c9f1a66..b78485a144d 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -242,7 +242,7 @@ var FieldMany2One = AbstractField.extend({
_bindAutoComplete: function () {
var self = this;
// avoid ignoring autocomplete="off" by obfuscating placeholder, see #30439
- if (this.$input.attr('placeholder')) {
+ if ($.browser.chrome && this.$input.attr('placeholder')) {
this.$input.attr('placeholder', function (index, val) {
return val.split('').join('\ufeff');
});
@@ -1886,6 +1886,7 @@ var FieldMany2ManyTags = AbstractField.extend({
}
this.colorField = this.nodeOptions.color_field;
+ this.hasDropdown = false;
},
//--------------------------------------------------------------------------
@@ -1952,6 +1953,7 @@ var FieldMany2ManyTags = AbstractField.extend({
return {
colorField: this.colorField,
elements: elements,
+ hasDropdown: this.hasDropdown,
readonly: this.mode === "readonly",
};
},
@@ -2063,6 +2065,14 @@ var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({
'mousedown .o_colorpicker a': '_onUpdateColor',
'mousedown .o_colorpicker .o_hide_in_kanban': '_onUpdateColor',
}),
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.hasDropdown = !!this.colorField;
+ },
//--------------------------------------------------------------------------
// Handlers
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 46f6964dd1c..0f4fc1edd1c 100644
--- a/addons/web/static/src/js/views/kanban/kanban_record.js
+++ b/addons/web/static/src/js/views/kanban/kanban_record.js
@@ -300,6 +300,7 @@ var KanbanRecord = Widget.extend({
// field's widgets point of view
// that dict being shared between records, we don't modify it
// in place
+ var self = this;
var attrs = Object.create(null);
_.each(this.fieldsInfo[field_name], function (value, key) {
if (_.str.startsWith(key, 't-att-')) {
@@ -310,11 +311,12 @@ var KanbanRecord = Widget.extend({
});
var options = _.extend({}, this.options, {attrs: attrs});
var widget = new Widget(this, field_name, this.state, options);
- var def = widget.replace($field);
+ var def = widget.replace($field).then(function () {
+ self._setFieldDisplay(widget.$el, field_name);
+ });
if (def.state() === 'pending') {
this.defs.push(def);
}
- this._setFieldDisplay(widget.$el, field_name);
return widget;
},
_processWidgets: function () {
@@ -324,12 +326,13 @@ var KanbanRecord = Widget.extend({
var Widget = widgetRegistry.get($field.attr('name'));
var widget = new Widget(self, self.state);
- var def = widget._widgetRenderAndInsert(function () {});
+ var def = widget._widgetRenderAndInsert(function () {}).then(function () {
+ widget.$el.addClass('o_widget');
+ $field.replaceWith(widget.$el);
+ });
if (def.state() === 'pending') {
self.defs.push(def);
}
- widget.$el.addClass('o_widget');
- $field.replaceWith(widget.$el);
});
},
/**
diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js
index cfb05792d4c..61101d9194f 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -247,7 +247,6 @@ var KanbanRenderer = BasicRenderer.extend({
*/
updateState: function (state) {
this._setState(state);
- this._toggleNoContentHelper();
return this._super.apply(this, arguments);
},
@@ -386,11 +385,8 @@ var KanbanRenderer = BasicRenderer.extend({
var self = this;
var oldWidgets = this.widgets;
this.widgets = [];
- this.$el.empty();
var isGrouped = !!this.state.groupedBy.length;
- this.$el.toggleClass('o_kanban_grouped', isGrouped);
- this.$el.toggleClass('o_kanban_ungrouped', !isGrouped);
var fragment = document.createDocumentFragment();
// render the kanban view
this.defs = [];
@@ -399,15 +395,19 @@ var KanbanRenderer = BasicRenderer.extend({
} else {
this._renderUngrouped(fragment);
}
- this.$el.append(fragment);
- this._toggleNoContentHelper();
var defs = this.defs;
- return this._super.apply(this, arguments).then(function () {
+ delete this.defs;
+ defs.push(this._super.apply(this, arguments));
+ return $.when.apply($, defs).then(function () {
_.invoke(oldWidgets, 'destroy');
+ self.$el.empty();
+ self.$el.toggleClass('o_kanban_grouped', isGrouped);
+ self.$el.toggleClass('o_kanban_ungrouped', !isGrouped);
+ self.$el.append(fragment);
+ self._toggleNoContentHelper();
if (self._isInDom) {
_.invoke(self.widgets, 'on_attach_callback');
}
- return $.when.apply(null, defs);
});
},
/**
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index ab5e5036dfa..185a385a812 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -278,6 +278,8 @@ ListRenderer.include({
this.currentRow = null;
}
+ // destroy widgets first
+ this._destroyFieldWidgets(recordID);
// remove the row
var $row = this.$('.o_data_row:nth(' + rowIndex + ')');
if (this.state.count >= 4) {
@@ -285,8 +287,6 @@ ListRenderer.include({
} else {
$row.replaceWith(this._renderEmptyRow());
}
-
- this._destroyFieldWidgets(recordID);
},
/**
* Updates the already rendered row associated to the given recordID so that
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 3f195c1ea24..88d0aee83b8 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -993,7 +993,6 @@ var PivotModel = AbstractModel.extend({
var groupBys;
var m;
-
for (var i = 0; i < rowGroupBys.length + 1; i++) {
for (var j = 0; j < colGroupBys.length + 1; j++) {
for (var k = 0; k < data[index].length; k++) {
diff --git a/addons/web/static/src/scss/fields.scss b/addons/web/static/src/scss/fields.scss
index 81a59743788..2ba631575f7 100644
--- a/addons/web/static/src/scss/fields.scss
+++ b/addons/web/static/src/scss/fields.scss
@@ -178,7 +178,7 @@
}
}
}
- }
+ }
.o_tag_error {
box-shadow: inset 0 0 0 2px #FF0000;
@@ -443,7 +443,10 @@
}
}
}
+}
+span.o_field_copy:empty {
+ border: none;
}
button.o_field_float_toggle {
diff --git a/addons/web/static/src/scss/form_view.scss b/addons/web/static/src/scss/form_view.scss
index 0645e00544e..812e6cbd6cc 100644
--- a/addons/web/static/src/scss/form_view.scss
+++ b/addons/web/static/src/scss/form_view.scss
@@ -81,16 +81,10 @@
.o_form_uri {
display: inline-block;
+ color: $link-color;
- color: $o-main-text-color;
- &:first-line {
- color: $link-color;
- }
&:hover {
- color: darken($o-main-text-color, 1%); // hack required on chrome
- &:first-line {
- color: darken($link-color, 15%);
- }
+ color: darken($link-color, 15%);
}
}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index e11184ebb1e..131c897bd8c 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -848,11 +848,11 @@
<t t-foreach="elements" t-as="el">
<t t-set="color" t-value="el[colorField] || 0"/>
<t t-set="colornames" t-value="['No color', 'Red', 'Orange', 'Yellow', 'Light blue', 'Dark purple', 'Salmon pink', 'Medium blue', 'Dark blue', 'Fushia', 'Green', 'Purple']"/>
- <div t-attf-class="badge badge-pill dropdown o_tag_color_#{color}" t-att-data-color="color" t-att-data-index="el_index" t-att-data-id="el.id" t-attf-title="Tag color: #{colornames[color]}">
+ <div t-attf-class="badge badge-pill #{hasDropdown ? 'dropdown' : ''} o_tag_color_#{color}" t-att-data-color="color" t-att-data-index="el_index" t-att-data-id="el.id" t-attf-title="Tag color: #{colornames[color]}">
<t t-set="_badge_text">
<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">
+ <t t-if="hasDropdown">
<a role="button" href="#" class="dropdown-toggle o-no-caret" data-toggle="dropdown" aria-expanded="false">
<t t-raw="_badge_text"/>
</a>
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index b721861c4b6..3e69a66b308 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -1408,45 +1408,79 @@ QUnit.module('basic_fields', {
list.destroy();
});
- QUnit.module('copy_clipboard', {
- beforeEach: function () {
- this.data = {
- partner: {
- fields: {
- script_external: {string: "Script External", type: "text"},
- web_page: {string: "Web page link", type: "char"}
- },
- records: [{
- id: 1,
- script_external:'Random Text',
- web_page: 'web page links'
- },],
- },
- };
- }
- });
+ QUnit.module('CopyClipboard');
QUnit.test('Char & Text Fields: Copy to clipboard button', function (assert) {
assert.expect(2);
- var done = assert.async();
- testUtils.createAsyncView({
+
+ var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<div>' +
- '<field name="script_external" widget="CopyClipboardText"/>' +
- '<field name="web_page" widget="CopyClipboardChar"/>' +
+ '<field name="txt" widget="CopyClipboardText"/>' +
+ '<field name="foo" widget="CopyClipboardChar"/>' +
'</div>' +
'</sheet>' +
'</form>',
- }).then(function (form) {
- assert.containsOnce(form, '.o_clipboard_button.o_btn_text_copy',"Should have copy button on text type field");
- assert.containsOnce(form, '.o_clipboard_button.o_btn_char_copy',"Should have copy button on char type field");
- form.destroy();
- done();
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, '.o_clipboard_button.o_btn_text_copy',"Should have copy button on text type field");
+ assert.containsOnce(form, '.o_clipboard_button.o_btn_char_copy',"Should have copy button on char type field");
+
+ form.destroy();
+ });
+
+ QUnit.test('CopyClipboard widget on unset field', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].foo = false;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" widget="CopyClipboardChar" />' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsNone(form, '.o_field_copy[name="foo"] .o_clipboard_button',
+ "foo (unset) should not contain a button");
+
+ form.destroy();
+ });
+
+ QUnit.test('CopyClipboard widget on readonly unset fields in create mode', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.display_name.readonly = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="display_name" widget="CopyClipboardChar" />' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
});
+
+ assert.containsNone(form, '.o_field_copy[name="display_name"] .o_clipboard_button',
+ "the readonly unset field should not contain a button");
+
+ form.destroy();
});
QUnit.module('FieldText');
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index faf3df6f2e8..5b3ff08a1b6 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1257,6 +1257,30 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('fieldmany2many tags in tree view', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].timmy = [12, 14];
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '<tree string="Partners">' +
+ '<field name="timmy" widget="many2many_tags" options="{\'color_field\': \'color\'}"/>' +
+ '</tree>',
+ });
+ assert.containsN(list, '.o_field_many2manytags .badge', 2, "there should be 2 tags");
+ assert.containsNone(list, '.dropdown-toggle', "the tags should not be dropdowns");
+
+ testUtils.intercept(list, 'switch_view', function (event) {
+ assert.strictEqual(event.data.view_type, "form", "should switch to form view");
+ });
+ // click on the tag: should do nothing and open the form view
+ testUtils.dom.click(list.$('.o_field_many2manytags .badge:first'));
+
+ list.destroy();
+ });
+
QUnit.test('fieldmany2many tags view a domain', function (assert) {
assert.expect(7);
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 47af910083d..862c579c347 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -5209,6 +5209,116 @@ QUnit.module('Views', {
delete fieldRegistry.map.asyncWidget;
});
+ QUnit.test('asynchronous rendering of a field widget with display attr', function (assert) {
+ assert.expect(3);
+
+ var fooFieldDef = $.Deferred();
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return fooFieldDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanbanController;
+ testUtils.createAsyncView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><field name="foo" display="right" widget="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ }).then(function (kanban) {
+ kanbanController = kanban;
+ });
+
+ assert.containsNone(document.body, '.o_kanban_record');
+
+ fooFieldDef.resolve();
+ assert.strictEqual(kanbanController.$('.o_kanban_record').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+ assert.hasClass(kanbanController.$('.o_kanban_record:first .o_field_char'), 'float-right');
+
+ kanbanController.destroy();
+ delete fieldRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('asynchronous rendering of a widget', function (assert) {
+ assert.expect(2);
+
+ var widgetDef = $.Deferred();
+ widgetRegistry.add('asyncwidget', Widget.extend({
+ willStart: function () {
+ return widgetDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanbanController;
+ testUtils.createAsyncView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><widget name="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ }).then(function (kanban) {
+ kanbanController = kanban;
+ });
+
+ assert.containsNone(document.body, '.o_kanban_record');
+
+ widgetDef.resolve();
+ assert.strictEqual(kanbanController.$('.o_kanban_record .o_widget').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+
+ kanbanController.destroy();
+ delete widgetRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('update kanban with asynchronous field widget', function (assert) {
+ assert.expect(3);
+
+ var fooFieldDef = $.Deferred();
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return fooFieldDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanban = testUtils.createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><field name="foo" widget="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ domain: [['id', '=', '0']], // no record matches this domain
+ });
+
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ kanban.update({domain: []}); // this rendering will be async
+
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ fooFieldDef.resolve();
+
+ assert.strictEqual(kanban.$('.o_kanban_record').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+
+ kanban.destroy();
+ delete widgetRegistry.map.asyncWidget;
+ });
});
});
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index f5e0088173b..347d0c23a53 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -10,6 +10,7 @@ var ListView = require('web.ListView');
var mixins = require('web.mixins');
var NotificationService = require('web.NotificationService');
var testUtils = require('web.test_utils');
+var testUtilsDom = require('web.test_utils_dom');
var widgetRegistry = require('web.widget_registry');
var Widget = require('web.Widget');
@@ -277,6 +278,51 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('editable list datetimepicker destroy widget', function (assert) {
+ assert.expect(7);
+ var done = assert.async();
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="date"/>' +
+ '</tree>',
+ });
+ list.$el.on({
+ 'show.datetimepicker': function () {
+ assert.equal($('.bootstrap-datetimepicker-widget').length, 1,
+ 'The datetimepicker is open');
+
+ assert.equal(list.$('.o_data_row').length, 5,
+ 'There should be 5 rows');
+
+ assert.equal(list.$('.o_selected_row').length, 1,
+ 'One row in edit mode');
+
+ list.$('input.o_datepicker_input').trigger($.Event('keydown', {which: $.ui.keyCode.ESCAPE}));
+
+ assert.equal(list.$('.o_data_row').length, 4,
+ 'There should be 4 rows');
+
+ assert.equal(list.$('.o_selected_row').length, 0,
+ 'No row should be in edit mode');
+
+ done();
+ }
+ });
+ assert.equal(list.$('.o_data_row').length, 4,
+ 'There should be 4 rows');
+
+ assert.equal(list.$('.o_selected_row').length, 0,
+ 'No row should be in edit mode');
+
+ testUtilsDom.click(list.$buttons.find('.o_list_button_add'));
+
+ list.destroy();
+ });
+
QUnit.test('at least 4 rows are rendered, even if less data', function (assert) {
assert.expect(1);
diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js
index 0e7e7ff0a2d..ccbf24895dd 100644
--- a/addons/web/static/tests/views/pivot_tests.js
+++ b/addons/web/static/tests/views/pivot_tests.js
@@ -1096,6 +1096,48 @@ QUnit.module('Views', {
pivot.destroy();
});
+ QUnit.skip('Reload, group by columns, reload', function (assert) {
+ assert.expect(2);
+
+ var pivot = createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot/>',
+ });
+
+ // Set a column groupby
+ pivot.$('thead .o_pivot_header_cell_closed').click();
+ pivot.$('.o_field_selection .dropdown-item[data-field=customer]').click();
+
+ // Set a domain
+ pivot.update({domain: [['product_id', '=', 41]]});
+
+ var expectedContext = {pivot_column_groupby: ['customer'],
+ pivot_measures: ['__count'],
+ pivot_row_groupby: []};
+
+ // Check that column groupbys were not lost
+ assert.deepEqual(pivot.getContext(), expectedContext,
+ 'Column groupby not lost after first reload');
+
+ // Set a column groupby
+ pivot.$('thead .o_pivot_header_cell_closed').click();
+ pivot.$('.o_field_selection .dropdown-item[data-field=product_id]').click();
+
+ // Set a domain
+ pivot.update({domain: [['product_id', '=', 37]]});
+
+ var expectedContext = {pivot_column_groupby: ['customer', 'product_id'],
+ pivot_measures: ['__count'],
+ pivot_row_groupby: []};
+
+ assert.deepEqual(pivot.getContext(), expectedContext,
+ 'Column groupby not lost after second reload');
+
+ pivot.destroy();
+ });
+
QUnit.test('correctly uses pivot_ keys from the context', function (assert) {
assert.expect(7);
diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py
index 41de79b3c24..44f136dc471 100644
--- a/addons/web_editor/models/ir_ui_view.py
+++ b/addons/web_editor/models/ir_ui_view.py
@@ -271,5 +271,6 @@ class IrUiView(models.Model):
``bundles=True`` returns also the asset bundles
"""
user_groups = set(self.env.user.groups_id)
- views = self.with_context(active_test=False)._views_get(key, bundles=bundles)
+ View = self.with_context(active_test=False, lang=None)
+ views = View._views_get(key, bundles=bundles)
return views.filtered(lambda v: not v.groups_id or len(user_groups.intersection(v.groups_id)))
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
index 59b717091a4..6a7b9397206 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -84,6 +84,16 @@ var SnippetEditor = Widget.extend({
});
}
+ this.$target.on('transitionstart.snippet_editor, animationstart.snippet_editor', function () {
+ self._targetIsAnimated = true;
+ });
+ this.$target.on('transitionend.snippet_editor, animationend.snippet_editor', function () {
+ self._targetIsAnimated = false;
+ if (self.$el.is('.oe_active')) {
+ self.cover();
+ }
+ });
+
return $.when.apply($, defs);
},
/**
@@ -93,6 +103,7 @@ var SnippetEditor = Widget.extend({
this.cleanForSave();
this._super.apply(this, arguments);
this.$target.removeData('snippet-editor');
+ this.$target.off('.snippet_editor');
},
//--------------------------------------------------------------------------
@@ -124,6 +135,11 @@ var SnippetEditor = Widget.extend({
* Makes the editor overlay cover the associated snippet.
*/
cover: function () {
+ if (this._targetIsAnimated) {
+ // Do not cover a target being animated, it will be covered once the
+ // animation is completed.
+ return;
+ }
var offset = this.$target.offset();
var manipulatorOffset = this.$el.parent().offset();
offset.top -= manipulatorOffset.top;
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index 97d3777b95f..b254ea932d2 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -290,13 +290,11 @@ var SnippetOption = Widget.extend({
.addClass('active');
_processSelectClassElements(this.$el);
- _.each(this.$el.find('.dropdown-menu'), function (group) {
- _processSelectClassElements($(group).children());
- });
- function _processSelectClassElements($elements) {
+ function _processSelectClassElements($el) {
var maxNbClasses = -1;
- $elements.filter('[data-select-class]')
+ $el.find('[data-select-class]')
+ .addBack('[data-select-class]')
.removeClass('active')
.filter(function () {
var className = $(this).data('selectClass');
diff --git a/addons/web_editor/static/src/scss/web_editor.ui.scss b/addons/web_editor/static/src/scss/web_editor.ui.scss
index c98d0245f7f..072593247fa 100644
--- a/addons/web_editor/static/src/scss/web_editor.ui.scss
+++ b/addons/web_editor/static/src/scss/web_editor.ui.scss
@@ -988,9 +988,6 @@ body.editor_enable {
left: 0;
}
}
- .btn {
- -webkit-user-select: none;
- }
}
#web_editor_inside_iframe {
diff --git a/addons/web_unsplash/models/__init__.py b/addons/web_unsplash/models/__init__.py
index 63a963dae32..ffce82298b0 100644
--- a/addons/web_unsplash/models/__init__.py
+++ b/addons/web_unsplash/models/__init__.py
@@ -3,3 +3,4 @@
from . import res_config_settings
from . import res_users
+from . import ir_qweb
diff --git a/addons/web_unsplash/models/ir_qweb.py b/addons/web_unsplash/models/ir_qweb.py
new file mode 100644
index 00000000000..8d581c47299
--- /dev/null
+++ b/addons/web_unsplash/models/ir_qweb.py
@@ -0,0 +1,26 @@
+from werkzeug import urls
+
+from odoo import models, api
+
+
+class Image(models.AbstractModel):
+ _inherit = 'ir.qweb.field.image'
+
+ @api.model
+ def from_html(self, model, field, element):
+ url = element.find('img').get('src')
+ url_object = urls.url_parse(url)
+
+ if url_object.path.startswith('/unsplash/'):
+ res_id = element.get('data-oe-id')
+ if res_id:
+ res_id = int(res_id)
+ res_model = model._name
+ attachment = self.env['ir.attachment'].search([
+ ('res_model', '=', res_model),
+ ('res_id', '=', res_id),
+ ('url', '=', url_object.path),
+ ], limit=1)
+ return attachment.datas
+
+ return super(Image, self).from_html(model, field, element)
diff --git a/addons/website/__init__.py b/addons/website/__init__.py
index c158f87e14f..dcd5ec789d0 100644
--- a/addons/website/__init__.py
+++ b/addons/website/__init__.py
@@ -4,3 +4,19 @@
from . import controllers
from . import models
from . import wizard
+
+import odoo
+from odoo import api, SUPERUSER_ID
+from functools import partial
+
+
+def uninstall_hook(cr, registry):
+ def rem_website_id_null(dbname):
+ db_registry = odoo.modules.registry.Registry.new(dbname)
+ with api.Environment.manage(), db_registry.cursor() as cr:
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ env['ir.model.fields'].search([
+ ('name', '=', 'website_id'),
+ ('model', '=', 'res.config.settings'),
+ ]).unlink()
+ cr.after('commit', partial(rem_website_id_null, cr.dbname))
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py
index fe7c479d23e..80a52659470 100644
--- a/addons/website/__manifest__.py
+++ b/addons/website/__manifest__.py
@@ -36,4 +36,5 @@
],
'qweb': ['static/src/xml/website.backend.xml'],
'application': True,
+ 'uninstall_hook': 'uninstall_hook',
}
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index 42a3580a18f..794afc6b733 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -208,6 +208,10 @@ class Http(models.AbstractModel):
if isinstance(exception, QWebException):
values.update(qweb_exception=exception)
+
+ # retro compatibility to remove in 12.2
+ exception.qweb = dict(message=exception.message, expression=exception.html)
+
if type(exception.error) == odoo.exceptions.AccessError:
code = 403
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index 27fe7a66437..c4dc9182906 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -157,7 +157,12 @@ class View(models.Model):
# care of creating pages and menus.
view.with_context(website_id=website.id).write({'name': view.name})
- result = super(View, self).unlink()
+ specific_views = self.env['ir.ui.view']
+ if self and self.pool._init:
+ for view in self:
+ specific_views += view._get_specific_views()
+
+ result = super(View, self + specific_views).unlink()
self.clear_caches()
return result
diff --git a/addons/website/static/src/js/tours/rte.js b/addons/website/static/src/js/tours/rte.js
index 46fbbff3fce..67426a64303 100644
--- a/addons/website/static/src/js/tours/rte.js
+++ b/addons/website/static/src/js/tours/rte.js
@@ -167,5 +167,25 @@ tour.register('rte_translator', {
}, {
content: "check bis: placeholder translation",
trigger: 'input[placeholder="test french placeholder"]',
+}, {
+ content: "Open customize menu",
+ trigger: "#customize-menu > .dropdown-toggle",
+}, {
+ content: "Open HTML editor",
+ trigger: "[data-action='ace']",
+}, {
+ content: "Check that the editor is not showing translated content (1)",
+ trigger: '.ace_text-layer .ace_line:contains("an HTML")',
+ run: function (actions) {
+ var lineEscapedText = $(this.$anchor.text()).text();
+ if (lineEscapedText !== "&lt;b&gt;&lt;/b&gt; is an HTML&nbsp;tag &amp; is empty") {
+ console.error('The HTML editor should display the correct untranslated content');
+ $('body').addClass('rte_translator_error');
+ }
+ },
+}, {
+ content: "Check that the editor is not showing translated content (2)",
+ trigger: 'body:not(.rte_translator_error)',
+ run: function () {},
}]);
});
diff --git a/addons/website/static/src/scss/website.edit_mode.scss b/addons/website/static/src/scss/website.edit_mode.scss
index 85ee8dc8fd4..aa6d830a162 100644
--- a/addons/website/static/src/scss/website.edit_mode.scss
+++ b/addons/website/static/src/scss/website.edit_mode.scss
@@ -203,6 +203,9 @@
display: inline-block;
}
.btn, &.btn {
+ -webkit-user-select: auto;
+ -moz-user-select: auto;
+ -ms-user-select: auto;
user-select: auto;
cursor: text!important;
}
diff --git a/addons/website/tests/test_page.py b/addons/website/tests/test_page.py
index 1b82d92ad57..88f09bfc2ed 100644
--- a/addons/website/tests/test_page.py
+++ b/addons/website/tests/test_page.py
@@ -1,7 +1,8 @@
# coding: utf-8
-from odoo.tests import common, HttpCase
+from odoo.tests import common, HttpCase, tagged
+@tagged('-at_install', 'post_install')
class TestPage(common.TransactionCase):
def setUp(self):
super(TestPage, self).setUp()
diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py
index 0416bc72bf7..bb847ba9f6a 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, HttpCase
+from odoo.tests import common, HttpCase, tagged
def attrs(**kwargs):
@@ -264,6 +264,7 @@ class TestViewSaving(common.TransactionCase):
)
+@tagged('-at_install', 'post_install')
class TestCowViewSaving(common.TransactionCase):
def setUp(self):
super(TestCowViewSaving, self).setUp()
diff --git a/addons/website_blog/views/website_blog_templates.xml b/addons/website_blog/views/website_blog_templates.xml
index 0d92f3fefdf..8e92cab712e 100644
--- a/addons/website_blog/views/website_blog_templates.xml
+++ b/addons/website_blog/views/website_blog_templates.xml
@@ -206,6 +206,9 @@
<!-- Option: Blog Post List: grid layout -->
<template id="opt_blog_post_grid_layout" name="Grid view"
customize_show="True" active="False" inherit_id="website_blog.blog_post_short">
+ <xpath expr="//div[@id='main_column']" position="attributes">
+ <attribute name="class" separator=" " add="d-flex" />
+ </xpath>
<xpath expr="//div[@name='blog_post']" position="attributes">
<attribute name="class" add="col-md-5 o_grid" separator=" "/>
</xpath>
diff --git a/addons/website_event_sale/models/__init__.py b/addons/website_event_sale/models/__init__.py
index 1bd7829ab86..a9c94cbe9ae 100644
--- a/addons/website_event_sale/models/__init__.py
+++ b/addons/website_event_sale/models/__init__.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from . import product
+from . import product_pricelist
from . import sale_order
from . import website
diff --git a/addons/website_event_sale/models/product_pricelist.py b/addons/website_event_sale/models/product_pricelist.py
new file mode 100644
index 00000000000..5b0d4465759
--- /dev/null
+++ b/addons/website_event_sale/models/product_pricelist.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+
+from odoo import _, api, models
+
+class PricelistItem(models.Model):
+ _inherit = "product.pricelist.item"
+
+ @api.onchange('applied_on', 'product_id', 'product_tmpl_id', 'min_quantity')
+ def _onchange_event_sale_warning(self):
+ if self.min_quantity > 0:
+ msg = ''
+ if self.applied_on == '3_global' or self.applied_on == '2_product_category':
+ msg = _("A pricelist item with a positive min. quantity will not be applied to the event tickets products.")
+ elif ((self.applied_on == '1_product' and self.product_tmpl_id.event_ok) or
+ (self.applied_on == '0_product_variant' and self.product_id.event_ok)):
+ msg = _("A pricelist item with a positive min. quantity cannot be applied to this event tickets product.")
+ if msg:
+ return {'warning':
+ {
+ 'title': _("Warning"),
+ 'message': msg
+ }
+ }
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 4f0f84cebf6..603d0176086 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -230,7 +230,7 @@ class WebsiteSale(ProductConfiguratorController):
if attrib_list:
post['attrib'] = attrib_list
- Product = request.env['product.template']
+ Product = request.env['product.template'].with_context(bin_size=True)
Category = request.env['product.public.category']
search_categories = False
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
index e0ee166ad39..99b3bb7f806 100644
--- a/addons/website_sale/models/product.py
+++ b/addons/website_sale/models/product.py
@@ -135,7 +135,7 @@ class ProductTemplate(models.Model):
def _website_price(self):
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())
+ res = template._get_combination_info()
template.website_price = res.get('price')
template.website_public_price = res.get('list_price')
template.website_price_difference = res.get('has_discounted_price')
@@ -165,9 +165,12 @@ class ProductTemplate(models.Model):
@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.
+ It's possible to quickly add to cart if there's no optional product,
+ there's only one possible combination and no value is set to is_custom.
+
+ Attributes set to dynamic or no_variant don't have to be tested
+ specifically because they will be taken into account when checking for
+ the possible combinations.
:param parent_combination: combination from which `self` is an
optional or accessory product
@@ -180,15 +183,14 @@ class ProductTemplate(models.Model):
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():
+ gen = self._get_possible_combinations(parent_combination)
+ first_possible_combination = next(gen)
+ if next(gen, False) is not False:
+ # there are at least 2 possible combinations.
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())):
+ if self.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(first_possible_combination)):
return False
return True
@@ -199,7 +201,8 @@ class ProductTemplate(models.Model):
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.
+ dynamic or no_variant attributes, and also for a warning about
+ performances.
:param parent_combination: combination from which `self` is an
optional or accessory product
@@ -230,7 +233,7 @@ class ProductTemplate(models.Model):
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):
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=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
@@ -247,7 +250,9 @@ class ProductTemplate(models.Model):
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)
+ combination_info = super(ProductTemplate, self)._get_combination_info(
+ combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
+ parent_combination=parent_combination, only_template=only_template)
if self.env.context.get('website_id'):
partner = self.env.user.partner_id
diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py
index e848067f1b2..11bdba05079 100644
--- a/addons/website_sale/models/website.py
+++ b/addons/website_sale/models/website.py
@@ -246,7 +246,7 @@ class Website(models.Model):
country_code = request.session['geoip'].get('country_code')
if country_code:
country_id = request.env['res.country'].search([('code', '=', country_code)], limit=1).id
- fp_id = request.env['account.fiscal.position'].sudo()._get_fpos_by_region(country_id)
+ fp_id = request.env['account.fiscal.position'].sudo().with_context(force_company=request.website.company_id.id)._get_fpos_by_region(country_id)
sale_order.fiscal_position_id = fp_id
else:
# if no geolocation, use the public user fp
diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js
index ed03f81c5de..87f91a5b045 100644
--- a/addons/website_sale/static/src/js/website_sale.js
+++ b/addons/website_sale/static/src/js/website_sale.js
@@ -152,6 +152,8 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM
'change #shipping_use_same': '_onChangeShippingUseSame',
'click .toggle_summary': '_onToggleSummary',
'click input.js_product_change': 'onChangeVariant',
+ // dirty fix: prevent options modal events to be triggered and bubbled
+ 'change oe_optional_products_modal [data-attribute_exclusions]': 'onChangeVariant',
},
/**
@@ -590,6 +592,20 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend(ProductConfiguratorM
$('.ship_to_other').toggle(!$(ev.currentTarget).prop('checked'));
},
/**
+ * @override
+ *
+ * Dirty fix: prevent options modal events to be triggered and bubbled
+ */
+ onChangeVariant: function (ev, data) {
+ var $originPath = ev.originalEvent && Array.isArray(ev.originalEvent.path) ? $(ev.originalEvent.path) : $();
+ var $container = data && data.$container ? data.$container : $();
+ if ($originPath.add($container).hasClass('oe_optional_products_modal')) {
+ ev.stopPropagation();
+ return;
+ }
+ return ProductConfiguratorMixin.onChangeVariant.apply(this, arguments);
+ },
+ /**
* @private
*/
_onToggleSummary: function () {
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 f23d8927d6d..87883f81172 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
@@ -4,13 +4,15 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
var tour = require("web_tour.tour");
var base = require("web_editor.base");
+ var optionVariantImage;
+
tour.register("shop_custom_attribute_value", {
- url: "/shop",
+ url: "/shop?search=Customizable Desk",
test: true,
wait_for: base.ready()
}, [{
- trigger: 'img[src*="/product.product/10"]',
- run: 'click'
+ content: "click on Customizable Desk",
+ trigger: '.oe_product_cart a:contains("Customizable Desk")',
}, {
trigger: 'li.js_attribute_value span:contains(Custom)',
extra_trigger: 'li.js_attribute_value',
@@ -35,6 +37,24 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
trigger: 'span.oe_price span:contains(600)',
run: function (){}, // check
}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")',
+ run: function () {
+ optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ }
+ }, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
+ }, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")',
+ run: function () {
+ var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ if (newVariantImage !== optionVariantImage) {
+ $('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
+ }
+ }
+ }, {
+ extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
+ }, {
trigger: 'li.js_attribute_value span:contains(Aluminium)',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
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 009eb44b436..fc1d6b12e50 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
@@ -70,6 +70,11 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
run: function () {}, // it's a check
},
{
+ content: "check list view of variants is disabled initially",
+ trigger: 'body:not(:has(.js_product_change))',
+ run: function () {},
+ },
+ {
content: "click on 'List View of Variants'",
trigger: "#customize-menu a:contains(List View of Variants)",
},
@@ -79,6 +84,11 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
run: function () {}, // it's a check
},
{
+ context: "check variant price",
+ trigger: '.custom-radio:contains("Aluminium") .badge:contains("+") .oe_currency_value:contains("50.4")',
+ run: function () {},
+ },
+ {
content: "check price is 750",
trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
run: function () {},
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
index c83b45434c9..dd29714e396 100644
--- 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
@@ -7,13 +7,17 @@ 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',
+ url: '/shop?search=Test Product 2',
wait_for: base.ready(),
},
[
{
- content: "select Test Product",
- trigger: '.oe_product_cart a:containsExact("Test Product")',
+ content: "check price on /shop (template price)",
+ trigger: '.oe_product_cart .oe_currency_value:contains("1.00")',
+ },
+ {
+ content: "select Test Product 2",
+ trigger: '.oe_product_cart a:containsExact("Test Product 2")',
},
{
content: "check price (3rd variant)",
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
index 2007a5e82a1..fbe7f61cba9 100644
--- 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
@@ -26,6 +26,7 @@ tour.register('tour_shop_dynamic_variants', {
},
{
content: "click add to cart",
+ extra_trigger: 'body:has(input[type="hidden"][name="product_id"][value=0])',
trigger: '#add_to_cart',
},
{
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js b/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js
new file mode 100644
index 00000000000..33c6a7ff2cd
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js
@@ -0,0 +1,98 @@
+odoo.define('website_sale.tour_shop_list_view_b2c', function (require) {
+'use strict';
+
+var base = require('web_editor.base');
+var rpc = require('web.rpc');
+var tour = require('web_tour.tour');
+
+tour.register('shop_list_view_b2c', {
+ test: true,
+ url: '/shop?search=Test Product',
+ wait_for: base.ready()
+},
+ [
+ {
+ content: "activate b2c",
+ trigger: 'body',
+ run: function () {
+ return rpc.query({
+ model: 'res.config.settings',
+ method: 'create',
+ args: [{
+ 'auth_signup_uninvited': 'b2c',
+ 'show_line_subtotals_tax_selection': 'tax_included',
+ 'group_show_line_subtotals_tax_excluded': false,
+ 'group_show_line_subtotals_tax_included': true,
+ }],
+ }).then(function (resId) {
+ return rpc.query({
+ model: 'res.config.settings',
+ method: 'execute',
+ args: [[resId]],
+ }).then(function () {
+ window.location.reload();
+ });
+ });
+ },
+ },
+ {
+ content: "check price on /shop",
+ trigger: '.oe_product_cart .oe_currency_value:contains("825.00")',
+ run: function () {},
+ },
+ {
+ content: "select product",
+ trigger: '.oe_product_cart a:contains("Test Product")',
+ },
+ {
+ content: "check list view of variants is disabled initially (when on /product page)",
+ trigger: 'body:not(:has(.js_product_change))',
+ extra_trigger: '#product_details',
+ run: function () {},
+ },
+ {
+ content: "open customize menu",
+ trigger: '#customize-menu > a',
+ extra_trigger: 'body:not(.notReady)',
+ },
+ {
+ content: "click on 'List View of Variants'",
+ trigger: '#customize-menu a:contains(List View of Variants)',
+ },
+ {
+ content: "check page loaded after list of variant customization enabled",
+ trigger: '.js_product_change',
+ run: function () {},
+ },
+ {
+ context: "check variant price",
+ trigger: '.custom-radio:contains("Aluminium") .badge:contains("+") .oe_currency_value:contains("55.44")',
+ run: function () {},
+ },
+ {
+ content: "check price is 825",
+ trigger: '.product_price .oe_price .oe_currency_value:containsExact("825.00")',
+ run: function () {},
+ },
+ {
+ 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:containsExact("880.44")',
+ run: function () {},
+ },
+ {
+ content: "click on 'Add to Cart' button",
+ trigger: 'a:contains(Add to Cart)',
+ },
+ {
+ content: "check price on /cart",
+ trigger: '#cart_products .oe_currency_value:containsExact("880.44")',
+ run: function () {},
+ },
+ ]
+);
+
+});
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
index 765fc540ccd..76f179c4578 100644
--- 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
@@ -7,13 +7,13 @@ 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',
+ url: '/shop?search=Test Product 3',
wait_for: base.ready(),
},
[
{
- content: "select Test Product",
- trigger: '.oe_product_cart a:containsExact("Test Product")',
+ content: "select Test Product 3",
+ trigger: '.oe_product_cart a:containsExact("Test Product 3")',
},
{
content: "check price",
diff --git a/addons/website_sale/tests/test_customize.py b/addons/website_sale/tests/test_customize.py
index 11f10c4c5e0..944d6f33a93 100644
--- a/addons/website_sale/tests/test_customize.py
+++ b/addons/website_sale/tests/test_customize.py
@@ -6,13 +6,18 @@ import odoo.tests
@odoo.tests.common.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
- def test_01_admin_shop_customize_tour(self):
+ def setUp(self):
+ super(TestUi, self).setUp()
# create a template
product_template = self.env['product.template'].create({
'name': 'Test Product',
'website_published': True,
'list_price': 750,
})
+
+ tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 10})
+ product_template.taxes_id = tax
+
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')
@@ -36,6 +41,7 @@ class TestUi(odoo.tests.HttpCase):
product_template.create_variant_ids()
+ def test_01_admin_shop_customize_tour(self):
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):
@@ -140,7 +146,7 @@ class TestUi(odoo.tests.HttpCase):
# create the template
product_template = self.env['product.template'].create({
- 'name': 'Test Product',
+ 'name': 'Test Product 2',
'website_published': True,
})
@@ -161,9 +167,10 @@ class TestUi(odoo.tests.HttpCase):
product_template.create_variant_ids()
- # archive first and delete second
+ # archive first combination (first variant)
product_template.product_variant_ids[0].active = False
- product_template.product_variant_ids[1].unlink()
+ # delete second combination (which is now first variant since cache has been cleared)
+ product_template.product_variant_ids[0].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")
@@ -188,7 +195,7 @@ class TestUi(odoo.tests.HttpCase):
# create the template
product_template = self.env['product.template'].create({
- 'name': 'Test Product',
+ 'name': 'Test Product 3',
'website_published': True,
})
@@ -205,3 +212,6 @@ class TestUi(odoo.tests.HttpCase):
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")
+
+ def test_06_admin_list_view_b2c(self):
+ self.browser_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_list_view_b2c')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_list_view_b2c.ready", login="admin")
diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py
index 390b96e02d1..a9a5c4981b5 100644
--- a/addons/website_sale/tests/test_website_sale_pricelist.py
+++ b/addons/website_sale/tests/test_website_sale_pricelist.py
@@ -38,6 +38,10 @@ class TestWebsitePriceList(TransactionCase):
'country_group_ids': [(6, 0, [ca_group.id])],
'sequence': 10
})
+ self.args = {
+ 'show': False,
+ 'current_pl': False,
+ }
patcher = patch('odoo.addons.website_sale.models.website.Website.get_pricelist_available', wraps=self._get_pricelist_available)
patcher.start()
self.addCleanup(patcher.stop)
@@ -87,11 +91,6 @@ class TestWebsitePriceList(TransactionCase):
def test_get_pricelist_available_promocode(self):
christmas_pl = self.ref('website_sale.list_christmas')
- public_pl = self.ref('product.list0')
- self.args = {
- 'show': False,
- 'current_pl': public_pl,
- }
country_list = {
False: True,
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
index 1c08075178f..6bd1f72ddc7 100644
--- 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
@@ -32,7 +32,6 @@ class TestWebsiteSaleProductAttributeValueConfig(TestSaleProductAttributeValueSe
# 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
@@ -54,7 +53,6 @@ class TestWebsiteSaleProductAttributeValueConfig(TestSaleProductAttributeValueSe
# 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
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 6cdaf9fac87..443d1bf0850 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -32,6 +32,7 @@
<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_list_view_b2c.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>
@@ -101,9 +102,7 @@
<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'])"/>
+ <t t-set="combination_info" t-value="product._get_combination_info(only_template=True, add_qty=add_qty or 1, pricelist=pricelist)"/>
<form action="/shop/cart/update" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
@@ -113,8 +112,7 @@
</div>
<div class="oe_product_image">
<a itemprop="url" t-att-href="product_href">
- <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" />
+ <span t-field="product.image" t-options="{'widget': 'image', 'resize': None if product_image_big else '300x300'}"/>
</a>
</div>
<t t-if="show_publish">
@@ -136,7 +134,7 @@
<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="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
+ <span t-if="combination_info['price']" 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>
@@ -156,18 +154,13 @@
<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">
- <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()">
+ <t t-set="product_variant_id" t-value="product._get_first_possible_variant_id()"/>
+ <input name="product_id" t-att-value="product_variant_id" type="hidden"/>
+ <t t-if="product_variant_id">
<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-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>
</template>
@@ -664,12 +657,14 @@
<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"/>
+ <t t-set="template_combination_info" t-value="product._get_combination_info(only_template=True, add_qty=add_qty, pricelist=pricelist)"/>
+ <t t-set="combination_info" t-value="variant_id._get_combination_info_variant(add_qty=add_qty, pricelist=pricelist)"/>
+ <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="combination_info['list_price']" t-att-data-price="combination_info['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 t-esc="combination_info['display_name']"/>
+ <t t-set="diff_price" t-value="website.currency_id.compare_amounts(combination_info['price'], template_combination_info['price'])"/>
+ <span class="badge badge-pill badge-secondary" t-if="diff_price != 0">
+ <t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(combination_info['price'] - template_combination_info['price'])" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</span>
</label>
</div>
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 d6c55944800..e4e8c9a9745 100644
--- a/addons/website_sale_comparison/views/website_sale_comparison_template.xml
+++ b/addons/website_sale_comparison/views/website_sale_comparison_template.xml
@@ -10,8 +10,8 @@
<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">
- <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>
+ <t t-set="product_variant_id" t-value="product._get_first_possible_variant_id()"/>
+ <button t-if="product_variant_id" 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>
@@ -66,7 +66,7 @@
<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-set="alt_product_variant_id" t-value="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])"/>
diff --git a/addons/website_sale_stock/controllers/main.py b/addons/website_sale_stock/controllers/main.py
index 386acc30ba9..58442ef9471 100644
--- a/addons/website_sale_stock/controllers/main.py
+++ b/addons/website_sale_stock/controllers/main.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import http
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.http import request
@@ -10,3 +11,8 @@ class WebsiteSale(WebsiteSale):
"""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)
+
+ @http.route()
+ def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
+ kw['context'] = {'website_sale_stock_get_quantity': True}
+ return super(WebsiteSale, self).get_combination_info_website(product_template_id, product_id, combination, add_qty, **kw)
diff --git a/addons/website_sale_stock/models/product_template.py b/addons/website_sale_stock/models/product_template.py
index d2d5f9e5759..ee8ea1e3ac3 100644
--- a/addons/website_sale_stock/models/product_template.py
+++ b/addons/website_sale_stock/models/product_template.py
@@ -17,8 +17,13 @@ class ProductTemplate(models.Model):
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)
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False, only_template=False):
+ combination_info = super(ProductTemplate, self)._get_combination_info(
+ combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
+ parent_combination=reference_product, only_template=only_template)
+
+ if not self.env.context.get('website_sale_stock_get_quantity'):
+ return combination_info
if combination_info['product_id']:
product = self.env['product.product'].sudo().browse(combination_info['product_id'])
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 55ec4bd8f89..74a69604c70 100644
--- a/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml
+++ b/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml
@@ -12,8 +12,8 @@
<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._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>
+ <t t-set="product_variant_id" t-value="product._get_first_possible_variant_id()"/>
+ <button t-if="product_variant_id" 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>
diff --git a/addons/website_theme_install/tests/__init__.py b/addons/website_theme_install/tests/__init__.py
index 28efb5bbfb5..ae189cba198 100644
--- a/addons/website_theme_install/tests/__init__.py
+++ b/addons/website_theme_install/tests/__init__.py
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import test_theme
from . import test_views
diff --git a/addons/website_theme_install/tests/test_theme.py b/addons/website_theme_install/tests/test_theme.py
new file mode 100644
index 00000000000..845474d67d2
--- /dev/null
+++ b/addons/website_theme_install/tests/test_theme.py
@@ -0,0 +1,14 @@
+# coding: utf-8
+from odoo.tests import common
+
+
+class TestTheme(common.TransactionCase):
+
+ def test_theme_remove_working(self):
+ """ This test ensure theme can be removed.
+ Theme removal is also the first step during theme installation.
+ """
+ theme_common_module = self.env['ir.module.module'].search([('name', '=', 'theme_default')])
+ website = self.env['website'].get_current_website()
+ website.theme_id = theme_common_module.id
+ self.env['ir.module.module']._theme_remove(website)
diff --git a/doc/_static/banners/email_gateway.jpg b/doc/_static/banners/email_gateway.jpg
new file mode 100644
index 00000000000..b9301e27040
--- /dev/null
+++ b/doc/_static/banners/email_gateway.jpg
Binary files differ
diff --git a/doc/_static/banners/email_gateway.small.jpg b/doc/_static/banners/email_gateway.small.jpg
new file mode 100644
index 00000000000..435c312eb52
--- /dev/null
+++ b/doc/_static/banners/email_gateway.small.jpg
Binary files differ
diff --git a/doc/cla/corporate/adhoc.md b/doc/cla/corporate/adhoc.md
index 428f46b0056..ca781cae7a5 100644
--- a/doc/cla/corporate/adhoc.md
+++ b/doc/cla/corporate/adhoc.md
@@ -14,3 +14,4 @@ Damián Soriano ds@adhoc.com.ar https://github.com/damiansoriano
Juan José Scarafía jjs@adhoc.com.ar https://github.com/jjscarafia
Nicolás Mac Rouillon nmr@adhoc.com.ar https://github.com/nicomacr
Katherine Zaoral kz@adhoc.com.ar https://github.com/zaoral
+Valentino Defelice vd@adhoc.com.ar https://github.com/ValentinoDefelice
diff --git a/doc/cla/corporate/projet-grup.md b/doc/cla/corporate/projet-grup.md
deleted file mode 100644
index 635f8d5148c..00000000000
--- a/doc/cla/corporate/projet-grup.md
+++ /dev/null
@@ -1,18 +0,0 @@
-Turkey, 2017-12-05
-
-Projet Yazilim Ve Danismanlik AS agrees to the terms of the Odoo Corporate Contributor License
-Agreement v1.0.
-
-I declare that I am authorized and able to make this agreement and sign this
-declaration.
-
-Signed,
-
-Can Tecim can.tecim@gmail.com https://github.com/cantecim
-
-List of contributors:
-
-Can Tecim can.tecim@gmail.com https://github.com/cantecim
-Can Tecim dev@projetgrup.com https://github.com/devprojetgrup
-Selahattin Ünlü selahattin.unlu@yandex.com https://github.com/selahattinunlu
-Abdurrahman Işık isikabdurrahman@yahoo.com https://github.com/abdurrahman
diff --git a/doc/cla/corporate/savoirfairelinux.md b/doc/cla/corporate/savoirfairelinux.md
index 6dca085c4ce..913e317cb07 100644
--- a/doc/cla/corporate/savoirfairelinux.md
+++ b/doc/cla/corporate/savoirfairelinux.md
@@ -21,6 +21,8 @@ Julien Jezequel-Breard julien.jezequel-breard@savoirfairelinux.com https://githu
Quentin Lavallée-Bourdeau quentin.lavallee@savoirfairelinux.com https://github.com/qtiplb
Rim Ben Dhaou rim.bendhaou@savoirfairelinux.com https://github.com/rimbendhaou
Yasmine El Mrini yasmine.elmrini@savoirfairelinux.com https://github.com/yasmineelmrini
+Luis Garcia Ruiz luis.garcia@savoirfairelinux.com https://github.com/eilst
+Dimitar Apostolov dimitar.apostolov@savoirfairelinux.com https://github.com/dapostolov-sfl
Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard (up to 2017-10-17)
Julie Moussu julie.mousse@savoirfairelinux.com https://github.com/JulieSFL (up to 2017-10-17)
Pierre Lamarche pierre.lamarche@savoirfairelinux.com https://github.com/plamarche (up to 2017-10-17)
diff --git a/doc/cla/individual/cantecim.md b/doc/cla/individual/cantecim.md
new file mode 100644
index 00000000000..1aa35478772
--- /dev/null
+++ b/doc/cla/individual/cantecim.md
@@ -0,0 +1,11 @@
+Turkey, 2019-02-18
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Can Tecim can.tecim@gmail.com https://github.com/cantecim
diff --git a/doc/cla/individual/mjabuz.md b/doc/cla/individual/mjabuz.md
new file mode 100644
index 00000000000..55e9589f992
--- /dev/null
+++ b/doc/cla/individual/mjabuz.md
@@ -0,0 +1,11 @@
+Bangladesh, 16 February, 2019
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Abu Zafar azmikbal@gmail.com https://github.com/MjAbuz
diff --git a/doc/howtos/backend/exercise-calendar b/doc/howtos/backend/exercise-calendar
index 8f3b59658e8..1ef0cbe2ca7 100644
--- a/doc/howtos/backend/exercise-calendar
+++ b/doc/howtos/backend/exercise-calendar
@@ -21,7 +21,7 @@ diff --git a/openacademy/models.py b/openacademy/models.py
@api.depends('seats', 'attendee_ids')
def _taken_seats(self):
-@@ -83,6 +86,30 @@ class Session(models.Model):
+@@ -83,6 +86,27 @@ class Session(models.Model):
},
}
@@ -34,9 +34,8 @@ diff --git a/openacademy/models.py b/openacademy/models.py
+
+ # Add duration to start_date, but: Monday + 5 days = Saturday, so
+ # subtract one second to get on Friday instead
-+ start = fields.Datetime.from_string(r.start_date)
+ duration = timedelta(days=r.duration, seconds=-1)
-+ r.end_date = start + duration
++ r.end_date = r.start_date + duration
+
+ def _set_end_date(self):
+ for r in self:
@@ -45,9 +44,7 @@ diff --git a/openacademy/models.py b/openacademy/models.py
+
+ # Compute the difference between dates, but: Friday - Monday = 4 days,
+ # so add one day to get 5 days instead
-+ start_date = fields.Datetime.from_string(r.start_date)
-+ end_date = fields.Datetime.from_string(r.end_date)
-+ r.duration = (end_date - start_date).days + 1
++ r.duration = (r.end_date - r.start_date).days + 1
+
@api.constrains('instructor_id', 'attendee_ids')
def _check_instructor_not_in_attendees(self):
diff --git a/doc/howtos/backend/exercise-gantt b/doc/howtos/backend/exercise-gantt
index 1e4aedd9624..54d3cb5b37f 100644
--- a/doc/howtos/backend/exercise-gantt
+++ b/doc/howtos/backend/exercise-gantt
@@ -15,9 +15,9 @@ diff --git a/openacademy/models.py b/openacademy/models.py
@api.depends('seats', 'attendee_ids')
def _taken_seats(self):
for r in self:
-@@ -110,6 +113,15 @@ class Session(models.Model):
- end_date = fields.Datetime.from_string(r.end_date)
- r.duration = (end_date - start_date).days + 1
+@@ -107,6 +110,15 @@ class Session(models.Model):
+ # so add one day to get 5 days instead
+ r.duration = (r.end_date - r.start_date).days + 1
+ @api.depends('duration')
+ def _get_hours(self):
diff --git a/doc/howtos/backend/exercise-graph b/doc/howtos/backend/exercise-graph
index 39d06f74944..8fb2a46dc1f 100644
--- a/doc/howtos/backend/exercise-graph
+++ b/doc/howtos/backend/exercise-graph
@@ -15,7 +15,7 @@ diff --git a/openacademy/models.py b/openacademy/models.py
@api.depends('seats', 'attendee_ids')
def _taken_seats(self):
for r in self:
-@@ -122,6 +125,11 @@ class Session(models.Model):
+@@ -119,6 +122,11 @@ class Session(models.Model):
for r in self:
r.duration = r.hours / 24
diff --git a/doc/howtos/backend/exercise-translations b/doc/howtos/backend/exercise-translations
index 08c013783cf..3cd57d2fd33 100644
--- a/doc/howtos/backend/exercise-translations
+++ b/doc/howtos/backend/exercise-translations
@@ -48,7 +48,7 @@ diff --git a/openacademy/models.py b/openacademy/models.py
},
}
-@@ -135,4 +135,4 @@ class Session(models.Model):
+@@ -132,4 +132,4 @@ class Session(models.Model):
def _check_instructor_not_in_attendees(self):
for r in self:
if r.instructor_id and r.instructor_id in r.attendee_ids:
diff --git a/doc/setup.rst b/doc/setup.rst
index 99b4bd3ad51..7b03c885e77 100644
--- a/doc/setup.rst
+++ b/doc/setup.rst
@@ -12,3 +12,4 @@ Setting Up
setup/update
setup/cdn
setup/enterprise
+ setup/email_gateway
diff --git a/doc/setup/email_gateway.rst b/doc/setup/email_gateway.rst
new file mode 100644
index 00000000000..3567ad9c9bf
--- /dev/null
+++ b/doc/setup/email_gateway.rst
@@ -0,0 +1,43 @@
+:banner: banners/email_gateway.jpg
+
+==================
+Odoo email gateway
+==================
+
+The Odoo mail gateway allows you to inject directly all the received emails in Odoo.
+
+Its principle is straightforward: your SMTP server executes the "mailgate" script for every new incoming email.
+
+The script takes care of connecting to your Odoo database through XML-RPC, and send the emails via the MailThread.message_process() feature.
+
+Prerequisites
+-------------
+- Administrator access to the Odoo database.
+- Your own mail server such as Postfix or Exim.
+- Technical knowledge on how to configure an email server.
+
+For Postfix
+-----------
+In you alias config (/etc/aliases):
+
+.. code-block:: text
+
+ email@address: "|/odoo-directory/addons/mail/static/scripts/odoo-mailgate.py -d <database-name> -u <userid> -p <password>"
+
+.. note:: Resources
+ - `Postfix <http://www.postfix.org/documentation.html>`
+ - `Postfix aliases <http://www.postfix.org/aliases.5.html>`
+ - `Postfix virtual <http://www.postfix.org/virtual.8.html>`
+
+
+For Exim
+--------
+.. code-block:: text
+
+ *: |/odoo-directory/addons/mail/static/scripts/odoo-mailgate.py -d <database-name> -u <userid> -p <password>
+
+.. note:: Resources
+ - `Exim <https://www.exim.org/docs.html>`
+
+
+.. note:: If you don't have access/manage your email server, use `inbound messages<https://www.odoo.com/documentation/user/12.0/discuss/email_servers.html#how-to-manage-inbound-messages>`.
diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index 84f993bca79..6eb84dd78fd 100644
--- a/odoo/addons/base/models/ir_model.py
+++ b/odoo/addons/base/models/ir_model.py
@@ -199,6 +199,9 @@ class IrModel(models.Model):
# prevent screwing up fields that depend on these models' fields
model.field_id._prepare_update()
+ # delete fields whose comodel is being removed
+ self.env['ir.model.fields'].search([('relation', 'in', self.mapped('model'))]).unlink()
+
self._drop_table()
res = super(IrModel, self).unlink()
diff --git a/odoo/addons/base/models/ir_rule.py b/odoo/addons/base/models/ir_rule.py
index a011272d855..5937a22df11 100644
--- a/odoo/addons/base/models/ir_rule.py
+++ b/odoo/addons/base/models/ir_rule.py
@@ -106,6 +106,8 @@ class IrRule(models.Model):
group_domains.append(dom)
# combine global domains and group domains
+ if not group_domains:
+ return expression.AND(global_domains)
return expression.AND(global_domains + [expression.OR(group_domains)])
@api.model
diff --git a/odoo/addons/base/models/qweb.py b/odoo/addons/base/models/qweb.py
index d8b1a3cdde2..31719d1f498 100644
--- a/odoo/addons/base/models/qweb.py
+++ b/odoo/addons/base/models/qweb.py
@@ -31,6 +31,8 @@ try:
except ImportError:
astor = None
+from odoo.tools.parse_version import parse_version
+
unsafe_eval = eval
_logger = logging.getLogger(__name__)
@@ -161,7 +163,7 @@ class QWebException(Exception):
return str(self)
# Avoid DeprecationWarning while still remaining compatible with werkzeug pre-0.9
-escape = (lambda text: _escape(text, quote=True)) if getattr(werkzeug, '__version__', '0.0') < '0.9.0' else _escape
+escape = (lambda text: _escape(text, quote=True)) if parse_version(getattr(werkzeug, '__version__', '0.0')) < parse_version('0.9.0') else _escape
def foreach_iterator(base_ctx, enum, name):
ctx = base_ctx.copy()
@@ -1470,7 +1472,7 @@ class QWeb(object):
)
if call_options:
- # update this dict with the content of `t-call-options`
+ # update this dict with the content of `t-call-options`
content.extend([
# options_.update(template options)
ast.Expr(ast.Call(
@@ -1481,7 +1483,56 @@ class QWeb(object):
),
args=[self._compile_expr(call_options)],
keywords=[], starargs=None, kwargs=None
- ))
+ )),
+
+ # if options.get('lang') != options_.get('lang'):
+ # self = self.with_context(lang=options.get('lang'))
+ ast.If(
+ test=ast.Compare(
+ left=ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='options', ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
+ ),
+ args=[ast.Str("lang")], keywords=[],
+ starargs=None, kwargs=None
+ ),
+ ops=[ast.NotEq()],
+ comparators=[ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id=name_options, ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
+ ),
+ args=[ast.Str("lang")], keywords=[],
+ starargs=None, kwargs=None
+ )]
+ ),
+ body=[
+ ast.Assign(
+ targets=[ast.Name(id='self', ctx=ast.Store())],
+ value=ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='self', ctx=ast.Load()),
+ attr='with_context',
+ ctx=ast.Load()
+ ),
+ args=[],
+ keywords=[ast.keyword('lang', ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id=name_options, ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
+ ),
+ args=[ast.Str("lang")], keywords=[],
+ starargs=None, kwargs=None
+ ))],
+ starargs=None, kwargs=None
+ )
+ )],
+ orelse=[],
+ )
])
if nsmap:
@@ -1593,32 +1644,26 @@ class QWeb(object):
def _compile_expr0(self, expr):
if expr == "0":
- # values.get(0) and u''.join(values[0])
- return ast.BoolOp(
- op=ast.And(),
- values=[
- ast.Call(
- func=ast.Attribute(
- value=ast.Name(id='values', ctx=ast.Load()),
- attr='get',
- ctx=ast.Load()
- ),
- args=[ast.Num(0)], keywords=[],
- starargs=None, kwargs=None
+ # u''.join(values.get(0, []))
+ return ast.Call(
+ func=ast.Attribute(
+ value=ast.Str(u''),
+ attr='join',
+ ctx=ast.Load()
+ ),
+ args=[
+ ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='values', ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
),
- ast.Call(
- func=ast.Attribute(
- value=ast.Str(u''),
- attr='join',
- ctx=ast.Load()
- ),
- args=[
- self._values_var(ast.Num(0), ctx=ast.Load())
- ],
- keywords=[], starargs=None, kwargs=None
- )
- ]
- )
+ args=[ast.Num(0), ast.List(elts=[], ctx=ast.Load())], keywords=[],
+ starargs=None, kwargs=None
+ )
+ ],
+ keywords=[], starargs=None, kwargs=None
+ )
return self._compile_expr(expr)
def _compile_format(self, f):
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index cc3f95420b8..ca1c20d8cf2 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -107,6 +107,11 @@ class Groups(models.Model):
('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique within an application!')
]
+ @api.multi
+ @api.constrains('users')
+ def _check_one_user_type(self):
+ self.mapped('users')._check_one_user_type()
+
@api.depends('category_id.name', 'name')
def _compute_full_name(self):
# Important: value must be stored in environment of group, not group1!
@@ -402,6 +407,13 @@ class Users(models.Model):
raise ValidationError(_('The "App Switcher" action cannot be selected as home action.'))
@api.multi
+ @api.constrains('groups_id')
+ def _check_one_user_type(self):
+ for user in self:
+ if len(user.groups_id.filtered(lambda x: x.category_id.xml_id == 'base.module_category_user_type')) > 1:
+ raise ValidationError(_('The user cannot have more than one user types.'))
+
+ @api.multi
def toggle_active(self):
for user in self:
if not user.active and not user.partner_id.active:
@@ -955,7 +967,14 @@ class UsersImplied(models.Model):
if 'groups_id' in values:
# complete 'groups_id' with implied groups
user = self.new(values)
- gs = user.groups_id | user.groups_id.mapped('trans_implied_ids')
+ group_public = self.env.ref('base.group_public', raise_if_not_found=False)
+ group_portal = self.env.ref('base.group_portal', raise_if_not_found=False)
+ if group_public and group_public in user.groups_id:
+ gs = self.env.ref('base.group_public') | self.env.ref('base.group_public').trans_implied_ids
+ elif group_portal and group_portal in user.groups_id:
+ gs = self.env.ref('base.group_portal') | self.env.ref('base.group_portal').trans_implied_ids
+ else:
+ gs = user.groups_id | user.groups_id.mapped('trans_implied_ids')
values['groups_id'] = type(self).groups_id.convert_to_write(gs, user.groups_id)
return super(UsersImplied, self).create(vals_list)
@@ -967,9 +986,9 @@ class UsersImplied(models.Model):
for user in self.with_context({}):
if not user.has_group('base.group_user'):
vals = {'groups_id': [(5, 0, 0)] + values['groups_id']}
- else:
- gs = set(concat(g.trans_implied_ids for g in user.groups_id))
- vals = {'groups_id': [(4, g.id) for g in gs]}
+ super(UsersImplied, user).write(vals)
+ gs = set(concat(g.trans_implied_ids for g in user.groups_id))
+ vals = {'groups_id': [(4, g.id) for g in gs]}
super(UsersImplied, user).write(vals)
return res
@@ -1231,7 +1250,12 @@ class UsersView(models.Model):
values[f] = get_boolean_group(f) in gids
elif is_selection_groups(f):
selected = [gid for gid in get_selection_groups(f) if gid in gids]
- values[f] = selected and selected[-1] or False
+ # if 'Internal User' is in the group, this is the "User Type" group
+ # and we need to show 'Internal User' selected, not Public/Portal.
+ if self.env.ref('base.group_user').id in selected:
+ values[f] = self.env.ref('base.group_user').id
+ else:
+ values[f] = selected and selected[-1] or False
@api.model
def fields_get(self, allfields=None, attributes=None):
diff --git a/odoo/addons/base/security/base_security.xml b/odoo/addons/base/security/base_security.xml
index 5e46fe20937..21401d88b47 100644
--- a/odoo/addons/base/security/base_security.xml
+++ b/odoo/addons/base/security/base_security.xml
@@ -34,37 +34,6 @@
<field name="perm_write" eval="False"/>
</record>
- <!-- Security restriction for private addresses -->
- <record id="res_partner_rule_private_employee" model="ir.rule">
- <field name="name">res.partner.rule.private.employee</field>
- <field name="model_id" ref="base.model_res_partner"/>
- <field name="domain_force">
- [('type', '!=', 'private')]
- </field>
- <field name="groups" eval="[
- (4, ref('base.group_user')),
- ]"/>
- <field name="perm_read" eval="True"/>
- <field name="perm_write" eval="False"/>
- <field name="perm_create" eval="False"/>
- <field name="perm_unlink" eval="False"/>
- </record>
- <!-- Relex previous rule for group_private_addresses -->
- <record id="res_partner_rule_private_group" model="ir.rule">
- <field name="name">res.partner.rule.private.group</field>
- <field name="model_id" ref="base.model_res_partner"/>
- <field name="domain_force">
- [('type', '=', 'private')]
- </field>
- <field name="groups" eval="[
- (4, ref('base.group_private_addresses'))
- ]"/>
- <field name="perm_read" eval="True"/>
- <field name="perm_write" eval="False"/>
- <field name="perm_create" eval="False"/>
- <field name="perm_unlink" eval="False"/>
- </record>
-
<record model="ir.rule" id="ir_default_user_rule">
<field name="name">Defaults: alter personal defaults</field>
<field name="model_id" ref="model_ir_default"/>
diff --git a/odoo/addons/base/static/xls/res_partner.xls b/odoo/addons/base/static/xls/res_partner.xls
index 8f6640c94b6..dbdec5bf229 100644
--- a/odoo/addons/base/static/xls/res_partner.xls
+++ b/odoo/addons/base/static/xls/res_partner.xls
Binary files differ
diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py
index 9f628054f02..b40035dadd6 100644
--- a/odoo/addons/base/tests/test_expression.py
+++ b/odoo/addons/base/tests/test_expression.py
@@ -640,6 +640,36 @@ class TestExpression(TransactionCase):
'&', ('foo', '=', 2), ('bar', '=', 2)]
self.assertEqual(expression.OR([d1, d2]), expected)
+ def test_proper_combine_unit_leaves(self):
+ # test that unit leaves (TRUE_LEAF, FALSE_LEAF) are properly handled in specific cases
+ false = expression.FALSE_DOMAIN
+ true = expression.TRUE_DOMAIN
+ normal = [('foo', '=', 'bar')]
+ # OR with single FALSE_LEAF
+ expr = expression.OR([false])
+ self.assertEqual(expr, false)
+ # OR with multiple FALSE_LEAF
+ expr = expression.OR([false, false])
+ self.assertEqual(expr, false)
+ # OR with FALSE_LEAF and a normal leaf
+ expr = expression.OR([false, normal])
+ self.assertEqual(expr, normal)
+ # OR with AND of single TRUE_LEAF and normal leaf
+ expr = expression.OR([expression.AND([true]), normal])
+ self.assertEqual(expr, true)
+ # AND with single TRUE_LEAF
+ expr = expression.AND([true])
+ self.assertEqual(expr, true)
+ # AND with multiple TRUE_LEAF
+ expr = expression.AND([true, true])
+ self.assertEqual(expr, true)
+ # AND with TRUE_LEAF and normal leaves
+ expr = expression.AND([true, normal])
+ self.assertEqual(expr, normal)
+ # AND with OR with single FALSE_LEAF and normal leaf
+ expr = expression.AND([expression.OR([false]), normal])
+ self.assertEqual(expr, false)
+
class TestAutoJoin(TransactionCase):
diff --git a/odoo/addons/base/tests/test_qweb.py b/odoo/addons/base/tests/test_qweb.py
index 2a01fa1ca44..d8ce5e48f68 100644
--- a/odoo/addons/base/tests/test_qweb.py
+++ b/odoo/addons/base/tests/test_qweb.py
@@ -501,6 +501,47 @@ class TestQWebNS(TransactionCase):
with self.assertRaises(QWebException, msg=error_msg):
view1.render()
+ def test_render_t_call_propagates_t_lang(self):
+ current_lang = 'en_US'
+ other_lang = 'fr_FR'
+
+ self.env['res.lang'].load_lang(lang=other_lang)
+
+ self.env['res.lang'].search([('code', '=', other_lang)], limit=1).write({
+ 'active': True,
+ 'decimal_point': '*',
+ 'thousands_sep': '/'
+ })
+
+ view1 = self.env['ir.ui.view'].create({
+ 'name': "callee",
+ 'type': 'qweb',
+ 'arch': u"""
+ <t t-name="base.callee">
+ <t t-esc="9000000.00" t-options="{'widget': 'float', 'precision': 2}" />
+ </t>
+ """
+ })
+ self.env['ir.model.data'].create({
+ 'name': 'callee',
+ 'model': 'ir.ui.view',
+ 'module': 'base',
+ 'res_id': view1.id,
+ })
+
+ view2 = self.env['ir.ui.view'].create({
+ 'name': "calling",
+ 'type': 'qweb',
+ 'arch': u"""
+ <t t-name="base.calling">
+ <t t-call="base.callee" t-lang="'%s'" />
+ </t>
+ """ % other_lang
+ })
+
+ rendered = view2.with_context(lang=current_lang).render().strip()
+ self.assertEqual(rendered, b'9/000/000*00')
+
from copy import deepcopy
class FileSystemLoader(object):
diff --git a/odoo/addons/base/tests/test_user_has_group.py b/odoo/addons/base/tests/test_user_has_group.py
index d7e44410104..0151bd2354c 100644
--- a/odoo/addons/base/tests/test_user_has_group.py
+++ b/odoo/addons/base/tests/test_user_has_group.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase
+from odoo.exceptions import ValidationError
class TestHasGroup(TransactionCase):
@@ -23,6 +24,11 @@ class TestHasGroup(TransactionCase):
'groups_id': [(4, group0.id, 0)]
})
+ self.grp_internal_xml_id = 'base.group_user'
+ self.grp_internal = self.env.ref(self.grp_internal_xml_id)
+ self.grp_portal_xml_id = 'base.group_portal'
+ self.grp_portal = self.env.ref(self.grp_portal_xml_id)
+
def test_env_uid(self):
Users = self.env['res.users'].sudo(self.test_user)
self.assertTrue(
@@ -43,3 +49,97 @@ class TestHasGroup(TransactionCase):
self.test_user.has_group(self.group1),
"the test user shoudl not belong to group1"
)
+
+ def test_portal_creation(self):
+ grp_public = self.env.ref('base.group_public')
+ grp_test_portal_xml_id = 'test_user_has_group.portal_implied_group'
+ grp_test_portal = self.env['res.groups']._load_records([
+ dict(xml_id=grp_test_portal_xml_id, values={'name': 'Test Group Portal'})
+ ])
+ grp_test_internal1 = self.env['res.groups']._load_records([
+ dict(xml_id='test_user_has_group.internal_implied_group1', values={'name': 'Test Group Itnernal 1'})
+ ])
+ grp_test_internal2_xml_id = 'test_user_has_group.internal_implied_group2'
+ grp_test_internal2 = self.env['res.groups']._load_records([
+ dict(xml_id=grp_test_internal2_xml_id, values={'name': 'Test Group Internal 2'})
+ ])
+ self.grp_portal.implied_ids = grp_test_portal
+ grp_test_internal1.implied_ids = self.grp_internal
+ grp_test_internal2.implied_ids = self.grp_internal
+ portal_user = self.env['res.users'].create({
+ 'login': 'portalTest',
+ 'name': 'Portal test',
+ 'sel_groups_%s_%s_%s' % (self.grp_internal.id, self.grp_portal.id, grp_public.id): self.grp_portal.id,
+ 'sel_groups_%s_%s' % (grp_test_internal1.id, grp_test_internal2.id): grp_test_internal2.id,
+ })
+
+ self.assertTrue(
+ portal_user.has_group(self.grp_portal_xml_id),
+ "The portal user should belong to '%s'" % self.grp_portal_xml_id,
+ )
+ self.assertTrue(
+ portal_user.has_group(grp_test_portal_xml_id),
+ "The portal user should belong to '%s'" % grp_test_portal_xml_id,
+ )
+ self.assertFalse(
+ portal_user.has_group(grp_test_internal2_xml_id),
+ "The portal user should not belong to '%s'" % grp_test_internal2_xml_id
+ )
+ self.assertFalse(
+ portal_user.has_group(self.grp_internal_xml_id),
+ "The portal user should not belong to '%s'" % self.grp_internal_xml_id
+ )
+
+ def test_portal_write(self):
+ grp_remove_xml_id = 'test_portal_write.group_to_remove'
+ grp_remove = self.env['res.groups']._load_records([
+ dict(xml_id=grp_remove_xml_id, values={'name': 'Group to remove'})
+ ])
+ portal_user = self.env['res.users'].create({
+ 'login': 'portalTest2',
+ 'name': 'Portal test 2',
+ 'groups_id': [(6, 0, [grp_remove.id])],
+ })
+ grp_test_portal_xml_id = 'test_portal_write.portal_implied_group'
+ grp_test_portal = self.env['res.groups']._load_records([
+ dict(xml_id=grp_test_portal_xml_id, values={'name': 'Test Group Portal'})
+ ])
+ self.grp_portal.implied_ids = grp_test_portal
+ portal_user.write({'groups_id': [(4, self.grp_portal.id, 0)]})
+
+ self.assertFalse(
+ portal_user.has_group(grp_remove_xml_id),
+ "The portal user should not belong to '%s'" % grp_remove_xml_id
+ )
+ self.assertTrue(
+ portal_user.has_group(self.grp_portal_xml_id),
+ "The portal user should belong to '%s'" % self.grp_portal_xml_id,
+ )
+ self.assertTrue(
+ portal_user.has_group(grp_test_portal_xml_id),
+ "The portal user should belong to '%s'" % grp_test_portal_xml_id,
+ )
+
+ def test_two_user_types(self):
+ #Create a user with two groups of user types kind (Internal and Portal)
+ grp_test = self.env['res.groups']._load_records([
+ dict(xml_id='test_two_user_types.implied_groups', values={'name': 'Test Group'})
+ ])
+ grp_test.implied_ids += self.grp_internal
+ grp_test.implied_ids += self.grp_portal
+
+ with self.assertRaises(ValidationError):
+ self.env['res.users'].create({
+ 'login': 'test_two_user_types',
+ 'name': "Test User with two user types",
+ 'groups_id': [(6, 0, [grp_test.id])]
+ })
+
+ #Add a user with portal to the group Internal
+ test_user = self.env['res.users'].create({
+ 'login': 'test_user_portal',
+ 'name': "Test User with two user types",
+ 'groups_id': [(6, 0, [self.grp_portal.id])]
+ })
+ with self.assertRaises(ValidationError):
+ self.grp_internal.users = [(4, test_user.id)]
diff --git a/odoo/addons/base/wizard/base_import_language.py b/odoo/addons/base/wizard/base_import_language.py
index aea906b8dc9..03619226290 100644
--- a/odoo/addons/base/wizard/base_import_language.py
+++ b/odoo/addons/base/wizard/base_import_language.py
@@ -17,7 +17,7 @@ class BaseLanguageImport(models.TransientModel):
_description = "Language Import"
name = fields.Char('Language Name', required=True)
- code = fields.Char('ISO Code', size=5, required=True,
+ code = fields.Char('ISO Code', size=6, required=True,
help="ISO Language and Country code, e.g. en_US")
data = fields.Binary('File', required=True)
filename = fields.Char('File Name', required=True)
diff --git a/odoo/addons/test_new_api/tests/test_ui.py b/odoo/addons/test_new_api/tests/test_ui.py
index 7291e2b7c3e..5afc2876f4f 100644
--- a/odoo/addons/test_new_api/tests/test_ui.py
+++ b/odoo/addons/test_new_api/tests/test_ui.py
@@ -5,6 +5,13 @@ import odoo.tests
class TestUi(odoo.tests.HttpCase):
def test_01_admin_widget_x2many(self):
+ # FIXME: breaks if too many children of base.menu_tests
+
+ # This tour turns out to be quite sensible to the number of items in
+ # the base.menu_tests: it's specifically sequenced to be lower (after)
+ # the default, but doesn't account for the fact that it could
+ # "fall off" into the "o_extra_menu_items" section if the window is
+ # too small or there are too many items preceding it in the tests menu
self.phantom_js("/web#action=test_new_api.action_discussions",
"odoo.__DEBUG__.services['web_tour.tour'].run('widget_x2many', 100)",
"odoo.__DEBUG__.services['web_tour.tour'].tours.widget_x2many.ready",
diff --git a/odoo/addons/test_testing_utilities/ir.model.access.csv b/odoo/addons/test_testing_utilities/ir.model.access.csv
index 697a7334581..c069b568ad4 100644
--- a/odoo/addons/test_testing_utilities/ir.model.access.csv
+++ b/odoo/addons/test_testing_utilities/ir.model.access.csv
@@ -14,3 +14,5 @@ access_test_testing_utilities_sub2,access_test_testing_utilities_sub2,model_test
access_test_testing_utilities_sub3,access_test_testing_utilities_sub3,model_test_testing_utilities_sub3,,1,0,0,0
access_test_testing_utilities_onchange_parent,access_test_testing_utilities_onchange_parent,model_test_testing_utilities_onchange_parent,,1,0,0,0
access_test_testing_utilities_onchange_line,access_test_testing_utilities_onchange_line,model_test_testing_utilities_onchange_line,,1,0,0,0
+access_model_test_testing_utilities_onchange_count,access_model_test_testing_utilities_onchange_count,model_test_testing_utilities_onchange_count,,1,1,1,1
+access_test_testing_utilities_onchange_count_sub,access_test_testing_utilities_onchange_count_sub,model_test_testing_utilities_onchange_count_sub,,1,1,1,1
diff --git a/odoo/addons/test_testing_utilities/menu.xml b/odoo/addons/test_testing_utilities/menu.xml
index 3ca18ef2a50..fa5f4f37163 100644
--- a/odoo/addons/test_testing_utilities/menu.xml
+++ b/odoo/addons/test_testing_utilities/menu.xml
@@ -1,4 +1,6 @@
<odoo>
+ <menuitem id="menu_ssf" parent="base.menu_tests" name="Server-side form"/>
+
<record id="action_test_o2m_onchange" model="ir.actions.act_window">
<field name="name">Test o2m onchange thing</field>
<field name="type">ir.actions.act_window</field>
@@ -6,7 +8,7 @@
<field name="view_mode">tree,form</field>
</record>
- <menuitem id="menu_o2m" parent="base.menu_tests"
+ <menuitem id="menu_o2m" parent="menu_ssf"
name="O2M onchange test thing"
action="action_test_o2m_onchange"/>
@@ -17,10 +19,21 @@
<field name="view_mode">tree,form</field>
</record>
- <menuitem id="menu_m2o" parent="base.menu_tests"
+ <menuitem id="menu_m2o" parent="menu_ssf"
name="test_default_and_onchange"
action="action_test_m2o_onchange"/>
+ <record id="action_test_o2m_count" model="ir.actions.act_window">
+ <field name="name">test_change_number_of_lines</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">test_testing_utilities.onchange_count</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+
+ <menuitem id="menu_o2m_count" parent="menu_ssf"
+ name="test_change_number_of_lines"
+ action="action_test_o2m_count"/>
+
<record id="o2m_parent" model="ir.ui.view">
<field name="name">Parent External Form</field>
<field name="priority">1</field>
diff --git a/odoo/addons/test_testing_utilities/models.py b/odoo/addons/test_testing_utilities/models.py
index 324b8d62c9f..fbdec47a7cf 100644
--- a/odoo/addons/test_testing_utilities/models.py
+++ b/odoo/addons/test_testing_utilities/models.py
@@ -211,3 +211,25 @@ class M2OOnchangeLine(models.Model):
@api.onchange('dummy')
def _onchange_flag(self):
self.flag = True
+
+class O2MChangeCount(models.Model):
+ _name = 'test_testing_utilities.onchange_count'
+ _description = _name
+
+ count = fields.Integer()
+ line_ids = fields.One2many('test_testing_utilities.onchange_count_sub', 'parent')
+
+ @api.onchange('count')
+ def _onchange_count(self):
+ Sub = self.env['test_testing_utilities.onchange_count_sub']
+ recs = Sub
+ for i in range(self.count):
+ recs |= Sub.new({'name': str(i)})
+ self.line_ids = recs
+
+class O2MChangeSub(models.Model):
+ _name = 'test_testing_utilities.onchange_count_sub'
+ _description = _name
+
+ parent = fields.Many2one('test_testing_utilities.onchange_count')
+ name = fields.Char()
diff --git a/odoo/addons/test_testing_utilities/tests/test_form_impl.py b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
index 344ae62f4d2..c13dbd73a60 100644
--- a/odoo/addons/test_testing_utilities/tests/test_form_impl.py
+++ b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
@@ -432,6 +432,40 @@ class TestO2M(TransactionCase):
with f.line_ids.edit(index=0) as new_line:
self.assertTrue(new_line.flag)
+ def test_o2m_remove(self):
+ def commands():
+ return [c[0] for c in f._values['line_ids']]
+ f = Form(self.env['test_testing_utilities.onchange_count'])
+
+ self.assertEqual(f.count, 0)
+ self.assertEqual(len(f.line_ids), 0)
+
+ f.count = 5
+ self.assertEqual(f.count, 5)
+ self.assertEqual(len(f.line_ids), 5)
+
+ f.count = 2
+ self.assertEqual(f.count, 2)
+ self.assertEqual(len(f.line_ids), 2)
+
+ f.count = 4
+
+ r = f.save()
+
+ previous = r.line_ids
+ self.assertEqual(len(previous), 4)
+
+ with Form(r) as f:
+ f.count = 2
+ self.assertEqual(commands(), [0, 0, 2, 2, 2, 2], "Should contain 2 creations and 4 deletions")
+ self.assertEqual(len(r.line_ids), 2)
+
+ with Form(r) as f:
+ f.line_ids.remove(0)
+ self.assertEqual(commands(), [2, 1])
+ f.count = 1
+ self.assertEqual(commands(), [0, 2, 2], "should contain 1 '0' command and 2 deletions")
+ self.assertEqual(len(r.line_ids), 1)
class TestEdition(TransactionCase):
""" These use the context manager form as we don't need the record
diff --git a/odoo/fields.py b/odoo/fields.py
index 75f6097d1aa..0b81c71c148 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -161,6 +161,7 @@ class Field(MetaField('DummyField', (object,), {})):
:param compute_sudo: whether the field should be recomputed as superuser
to bypass access rights (boolean, by default ``False``)
+ Note that this has no effects on non-stored computed fields
The methods given for ``compute``, ``inverse`` and ``search`` are model
methods. Their signature is shown in the following example::
diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py
index eeed85ca1e8..b6638a5b3c8 100644
--- a/odoo/osv/expression.py
+++ b/odoo/osv/expression.py
@@ -262,7 +262,7 @@ def combine(operator, unit, zero, domains):
result += normalize_domain(domain)
count += 1
result = [operator] * (count - 1) + result
- return result
+ return result or unit
def AND(domains):
diff --git a/odoo/tests/common.py b/odoo/tests/common.py
index c41bdb99955..34373dc3249 100644
--- a/odoo/tests/common.py
+++ b/odoo/tests/common.py
@@ -1437,12 +1437,12 @@ class Form(object):
return []
v = []
- c = {t[1]: t[2] for t in current if t[0] == 1} if current else {}
+ c = {t[1]: t[2] for t in current if t[0] in (1, 2)} if current else {}
# which view should this be???
subfields = descr['views']['edition']['fields']
for command in value:
- # TODO: get existing sub-values so we can pass them along?
if command[0] in (0, 1):
+ c.pop(command[1], None) # remove record from currents
v.append((command[0], command[1], {
k: self._cleanup_onchange(
subfields[k], v, None
@@ -1450,10 +1450,14 @@ class Form(object):
for k, v in command[2].items()
if k in subfields
}))
+ elif command[0] == 2:
+ v.append((2, command[1], False))
elif command[0] == 4:
- v.append((1, command[1], c.get(command[1], {})))
+ v.append((1, command[1], c.pop(command[1], {})))
elif command[0] == 5:
v = []
+ # explicitly mark all non-relinked (or modified) records as deleted
+ for id_ in c: v.append((2, id_, False))
return v
elif descr['type'] == 'many2many':
# onchange result is a bunch of commands, normalize to single 6