diff options
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"><div>Email automation</div></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+'[^ ]+(?= | )', 'g')); + var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= | |$)', '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)+")(?= | |$)"; }); 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', '>', 1), ('is_product_variant', '=', False)]}"/> <div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '>', 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 !== "<b></b> is an HTML tag & 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']>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']>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) > 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 Binary files differnew file mode 100644 index 00000000000..b9301e27040 --- /dev/null +++ b/doc/_static/banners/email_gateway.jpg diff --git a/doc/_static/banners/email_gateway.small.jpg b/doc/_static/banners/email_gateway.small.jpg Binary files differnew file mode 100644 index 00000000000..435c312eb52 --- /dev/null +++ b/doc/_static/banners/email_gateway.small.jpg 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 Binary files differindex 8f6640c94b6..dbdec5bf229 100644 --- a/odoo/addons/base/static/xls/res_partner.xls +++ b/odoo/addons/base/static/xls/res_partner.xls 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 |
