diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-03-11 14:42:34 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-03-11 14:42:34 +0100 |
| commit | 71faa19af06d5002be627adbfd92dc25941c69c6 (patch) | |
| tree | c6abaadf36cea49d1b2a2779cf487974ca1c650f /addons | |
| parent | 0c1eff0d95869b9b4eab8b34e65be770e49fdd2b (diff) | |
| parent | 2b3296bbf8b5d08fad20a4ca8d83ebb0aa7303b3 (diff) | |
[MERGE] forward port branch saas-12.1 up to 2b3296bbf8b
Diffstat (limited to 'addons')
153 files changed, 1875 insertions, 744 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 2695db40ee6..553b6080cc6 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -637,9 +637,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 fd08620f992..ea88cd0275e 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -1623,12 +1623,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 f815820da4a..afc9a6cec49 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -172,7 +172,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 7646343f7a4..dfc2b605790 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/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index b3e7dbf6cc3..83aa46b8e0d 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -574,7 +574,7 @@ class AccountReconcileModel(models.Model): if line_residual > total_residual: amount_percentage = (total_residual / line_residual) * 100 else: - amount_percentage = (line_residual / total_residual) * 100 + amount_percentage = (line_residual / total_residual) * 100 if total_residual else 0 return amount_percentage >= self.match_total_amount_param @api.multi diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index dd8ad313ca4..c37953f60d6 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -143,11 +143,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', } @@ -485,21 +486,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 7b5f21ee325..a24cec75ca5 100644 --- a/addons/account/tests/test_reconciliation_widget.py +++ b/addons/account/tests/test_reconciliation_widget.py @@ -2,6 +2,7 @@ import logging import odoo.tests import time import requests +from odoo.addons.account.tests.test_reconciliation import TestReconciliation _logger = logging.getLogger(__name__) @@ -27,3 +28,29 @@ class TestUi(odoo.tests.HttpCase): self.phantom_js(prep.url.replace('http://localhost','').replace('?','#'), "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 135c735eecc..7dbbd1aa023 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 500028bfcf5..c0abc40d236 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -1553,7 +1553,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/analytic/models/analytic_account.py b/addons/analytic/models/analytic_account.py index 187d53d1ad7..91815380304 100644 --- a/addons/analytic/models/analytic_account.py +++ b/addons/analytic/models/analytic_account.py @@ -160,7 +160,10 @@ class AccountAnalyticAccount(models.Model): if operator == 'ilike' and not (name or '').strip(): domain = [] else: - domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id.name', operator, name)] + # `partner_id` is in auto_join and the searches using ORs with auto_join fields doesn't work + # we have to cut the search in two searches ... https://github.com/odoo/odoo/issues/25175 + partner_ids = self.env['res.partner']._search([('name', operator, name)], limit=limit, access_rights_uid=name_get_uid) + domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id', 'in', partner_ids)] analytic_account_ids = self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid) return self.browse(analytic_account_ids).name_get() diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py index 85d6d732055..db66467d7dd 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 50058bc8ae4..83f2b0676ef 100644 --- a/addons/bus/static/src/js/longpolling_bus.js +++ b/addons/bus/static/src/js/longpolling_bus.js @@ -32,30 +32,33 @@ 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(); - $(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/crm/models/crm_team.py b/addons/crm/models/crm_team.py index 30d7cc945ea..a101fb26848 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -31,7 +31,10 @@ class Team(models.Model): compute='_compute_overdue_opportunities', string='Overdue Opportunities Revenues') - alias_user_id = fields.Many2one(domain=lambda self: [ + # Since we are in a _inherits case, this is not an override + # but a plain definition of a field + # So we need to reset the property related of that field + alias_user_id = fields.Many2one('res.users', related='alias_id.alias_user_id', inherited=True, domain=lambda self: [ ('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)]) def _compute_unassigned_leads_count(self): 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 849f1744f46..f092a6dd522 100644 --- a/addons/gamification/models/badge.py +++ b/addons/gamification/models/badge.py @@ -259,7 +259,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 be18aaa71d6..395bc0e72c6 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 time off request is approved by manager.") payslip_status = fields.Boolean('Reported in last payslips', help='Green this button when the time off 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="Time Off 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 8be3c4ac604..aecd6d7f5ed 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 90e1affc20c..f7da796a104 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/security/hr_holidays_security.xml b/addons/hr_holidays/security/hr_holidays_security.xml index df7317fa5d2..479c504f048 100644 --- a/addons/hr_holidays/security/hr_holidays_security.xml +++ b/addons/hr_holidays/security/hr_holidays_security.xml @@ -3,8 +3,9 @@ <record id="group_hr_holidays_team_leader" model="res.groups"> <field name="name">Team Leader</field> - <field name="category_id" ref="base.module_category_hr_holidays"/> - </record> + <field name="category_id" ref="base.module_category_hr_holidays"/> + <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> + </record> <record id="group_hr_holidays_user" model="res.groups"> <field name="name">Officer</field> @@ -44,7 +45,7 @@ <record id="hr_leave_rule_team_leader_update" model="ir.rule"> <field name="name">Time Off: Team Leader: write on employee's time off in his team</field> <field name="model_id" ref="model_hr_leave"/> - <field name="domain_force">[('employee_id.leave_manager_id', '=' , user.id)]</field> + <field name="domain_force">['|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]</field> <field name="perm_read" eval="False"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> @@ -129,6 +130,16 @@ <field name="groups" eval="[(4, ref('group_hr_holidays_manager'))]"/> </record> + <record id="hr_leave_allocation_rule_team_leader" model="ir.rule"> + <field name="name">Allocations: Team Leader: read employee's allocations in his team</field> + <field name="model_id" ref="model_hr_leave_allocation"/> + <field name="domain_force">['|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]</field> + <field name="perm_write" eval="False"/> + <field name="perm_create" eval="False"/> + <field name="perm_unlink" eval="False"/> + <field name="groups" eval="[(4, ref('hr_holidays.group_hr_holidays_team_leader'))]"/> + </record> + <record id="resource_leaves_team_leader" model="ir.rule"> <field name="name">Time Off Resources: Team Leader</field> <field name="model_id" ref="model_resource_calendar_leaves"/> diff --git a/addons/hr_holidays/tests/common.py b/addons/hr_holidays/tests/common.py index 0f9c6f1cc89..510c6883b41 100644 --- a/addons/hr_holidays/tests/common.py +++ b/addons/hr_holidays/tests/common.py @@ -23,6 +23,9 @@ class TestHrHolidaysBase(common.TransactionCase): self.user_hrmanager_2 = mail_new_test_user(self.env, login='florence', groups='base.group_user,hr_holidays.group_hr_holidays_manager') self.user_hrmanager_2_id = self.user_hrmanager_2.id + self.user_team_leader = mail_new_test_user(self.env, login='arthur', groups='base.group_user,hr_holidays.group_hr_holidays_team_leader') + self.user_team_leader_id = self.user_team_leader.id + # Hr Data Department = self.env['hr.department'].with_context(tracking_disable=True) diff --git a/addons/hr_holidays/tests/test_access_rights.py b/addons/hr_holidays/tests/test_access_rights.py index ca651a69010..00ac2856d20 100644 --- a/addons/hr_holidays/tests/test_access_rights.py +++ b/addons/hr_holidays/tests/test_access_rights.py @@ -362,6 +362,37 @@ class TestLeavesRights(TestHrHolidaysBase): self.employee_leave.sudo(self.user_hruser).action_approve() self.employee_leave.sudo(self.user_hrmanager_id).action_validate() + # ---------------------------------------- + # Validation: team leader + # ---------------------------------------- + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_team_leader_other(self): + """ Team Leader validates its employee's leave only""" + self.leave_type.write({'allocation_type': 'fixed'}) + self.env['hr.leave.allocation'].create({ + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.employee_emp.id, + 'number_of_days': 10, + }).action_approve() + with self.assertRaises(AccessError): + self.employee_leave.sudo(self.user_team_leader).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_team_leader(self): + """ Team Leader validates its employee's leave""" + self.leave_type.write({'allocation_type': 'fixed'}) + + # set the team leader + self.employee_emp.leave_manager_id = self.user_team_leader + + self.env['hr.leave.allocation'].create({ + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.employee_emp.id, + 'number_of_days': 10, + }).action_approve() + self.employee_leave.sudo(self.user_team_leader).action_approve() + class TestMultiCompany(TestHrHolidaysBase): diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py index 67d468b0a44..ea025d571cd 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_be_hr_payroll/views/res_config_settings_views.xml b/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml index f65c72e610e..947cfbb98f7 100644 --- a/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml +++ b/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml @@ -18,52 +18,52 @@ </div> <group class="mt16"> <label for="default_holidays"/> - <div class="o_row"> + <div> <field name="default_holidays" nolabel="1"/> <span>days / year</span> </div> <label for="default_commission_on_target"/> - <div class="o_row"> + <div> <field name="default_commission_on_target" nolabel="1"/> <span>€ / month</span> </div> <label for="default_fuel_card"/> - <div class="o_row"> + <div> <field name="default_fuel_card" nolabel="1"/> <span>€ / month</span> </div> <label for="default_representation_fees"/> - <div class="o_row"> + <div> <field name="default_representation_fees" nolabel="1"/> <span>€ / month</span> </div> <label for="default_internet"/> - <div class="o_row"> + <div> <field name="default_internet" nolabel="1"/> <span>€ / month</span> </div> <label for="default_mobile"/> - <div class="o_row"> + <div> <field name="default_mobile" nolabel="1"/> <span>€ / month</span> </div> <label for="default_internet"/> - <div class="o_row"> + <div> <field name="default_internet" nolabel="1"/> <span>€ / month</span> </div> <label for="default_mobile_plus"/> - <div class="o_row"> + <div> <field name="default_mobile_plus" nolabel="1"/> <span>€ / month</span> </div> <label for="default_meal_voucher_amount"/> - <div class="o_row"> + <div> <field name="default_meal_voucher_amount" nolabel="1"/> <span>€ / month</span> </div> <label for="default_eco_checks"/> - <div class="o_row"> + <div> <field name="default_eco_checks" nolabel="1"/> <span>€ / year</span> </div> diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py index 25dcb719b5c..d91f4858c88 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 db39dabbc29..0a637869776 100644 --- a/addons/lunch/views/lunch_orders_views.xml +++ b/addons/lunch/views/lunch_orders_views.xml @@ -61,7 +61,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 11398151a0f..70072a16785 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -149,8 +149,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', @@ -296,6 +296,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 6cbaab5002a..04148db3c4b 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 02cb977b246..9b63a9dc7d5 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -532,6 +532,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/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index c83f52f68c6..2d569425119 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, ... """ @@ -675,6 +681,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 3e89131f7e5..bf7b4b54e84 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 311b116c586..02418f2c588 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): @@ -292,6 +294,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 4e20348b328..2505a183016 100644 --- a/addons/mrp/views/mrp_unbuild_views.xml +++ b/addons/mrp/views/mrp_unbuild_views.xml @@ -85,7 +85,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 ed137077f64..8fefe02fa09 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 b0f5ac10dfa..29404cbef6b 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -1321,7 +1321,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 5912a897a6d..f9ff6c4cce0 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -301,6 +301,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 @@ -309,6 +311,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 @@ -329,6 +338,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 @@ -617,6 +628,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 237fccdb2a2..e22adff39a1 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') @@ -145,6 +166,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() @@ -459,7 +484,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 = { @@ -485,8 +510,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: @@ -518,6 +543,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): @@ -531,7 +560,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. @@ -540,42 +569,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): @@ -584,6 +639,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. @@ -683,19 +745,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): @@ -707,19 +758,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): @@ -755,17 +795,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 @@ -807,40 +850,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. @@ -849,63 +916,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 3b4b0f65433..a75e6d5ba10 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 6c9871cb6e9..2cf546eab41 100644 --- a/addons/purchase_requisition/models/purchase_requisition.py +++ b/addons/purchase_requisition/models/purchase_requisition.py @@ -244,7 +244,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 82496969d51..18b5a82dd3d 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> @@ -225,7 +225,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 a2d45c57c31..968bcffef67 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -370,12 +370,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 _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values): """ Return the record in self where the procument with values passed as diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml index 787b43e7146..30b6f7a369c 100644 --- a/addons/repair/views/repair_views.xml +++ b/addons/repair/views/repair_views.xml @@ -208,7 +208,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 50c42763b67..70db8f8c5d2 100644 --- a/addons/sale/data/mail_data.xml +++ b/addons/sale/data/mail_data.xml @@ -57,7 +57,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 167889bfbbd..04af968fa3e 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._create_invoices() 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 27315c8d5a2..43cdf1d8c15 100644 --- a/addons/sale/models/res_config_settings.py +++ b/addons/sale/models/res_config_settings.py @@ -74,6 +74,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 cbcf2c79f23..22b8d24e131 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -304,7 +304,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('account.use_invoice_terms') and self.env.user.company_id.invoice_terms: values['note'] = self.with_context(lang=self.partner_id.lang).env.user.company_id.invoice_terms @@ -636,11 +636,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 @@ -1428,6 +1432,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 = {} @@ -1440,16 +1454,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 c30c10b92b9..7cd61d6baec 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 1a6eff0447b..14bbf0ed496 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -1152,7 +1152,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 71bb46a3886..23b75118216 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -723,7 +723,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 20a252b8307..010e4495c7a 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 45abf2db497..2bc4f61b89c 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 205ba2ee709..6970442d8b9 100644 --- a/addons/survey/views/survey_user_views.xml +++ b/addons/survey/views/survey_user_views.xml @@ -113,7 +113,7 @@ <field name="create_date"/> </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> @@ -224,4 +224,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/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index 106f9f2aa47..d36ec9371de 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -535,6 +535,9 @@ ActionManager.include({ return newController(controller.jsID); } else { return $.when(controller.widget.willRestore()).then(function () { + viewOptions = _.extend({}, viewOptions, { + breadcrumbs: self._getBreadcrumbs(self.controllerStack.slice(0, index)), + }); return controller.widget.reload(viewOptions).then(function () { return controller; }); diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index c6df3885a86..fa2b904eeb9 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 35d10c5a5e4..1617bfae24c 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 bd46faf1e08..ec777c2e12c 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/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index b5d7c8fe8b3..53afb4a1fbd 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -246,7 +246,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { return; } self.renderer.setLocalState(localState); - return self._update(state); + return self._update(state, params); }); }); }, @@ -402,12 +402,15 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * * @private * @param {Object} state the state given by the model + * @param {Object} [params] + * @param {Object[]} [params.breadcrumbs] * @returns {Deferred} */ - _update: function () { + _update: function (state, params) { // AAB: update the control panel -> this will be moved elsewhere at some point var cpContent = _.extend({}, this.controlPanelElements); this.updateControlPanel({ + breadcrumbs: params && params.breadcrumbs, cp_content: cpContent, title: this.getTitle(), }); diff --git a/addons/web/static/src/js/views/control_panel/control_panel_controller.js b/addons/web/static/src/js/views/control_panel/control_panel_controller.js index 40b1c45b9c7..f6967a6fb5a 100644 --- a/addons/web/static/src/js/views/control_panel/control_panel_controller.js +++ b/addons/web/static/src/js/views/control_panel/control_panel_controller.js @@ -17,6 +17,7 @@ var ControlPanelController = mvc.Controller.extend({ activate_time_range: '_onActivateTimeRange', autocompletion_filter: '_onAutoCompletionFilter', reload: '_onReload', + reset: '_onReset', }, /** @@ -244,6 +245,15 @@ var ControlPanelController = mvc.Controller.extend({ ev.stopPropagation(); this.trigger_up('search', this.model.getQuery()); }, + /** + * @private + * @param {OdooEvent} ev + */ + _onReset: function (ev) { + ev.stopPropagation(); + var state = this.model.get(); + this.renderer.updateState(state); + }, }); return ControlPanelController; diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar.js b/addons/web/static/src/js/views/control_panel/search/search_bar.js index 77d6d7fa5e9..45004fd04ef 100644 --- a/addons/web/static/src/js/views/control_panel/search/search_bar.js +++ b/addons/web/static/src/js/views/control_panel/search/search_bar.js @@ -184,10 +184,16 @@ var SearchBar = Widget.extend({ */ _onAutoCompleteSelected: function (e, ui) { e.preventDefault(); - var filter = ui.item.facet.filter; + var facet = ui.item.facet; + if (!facet) { + // this happens when selecting "(no result)" item + this.trigger_up('reset'); + return; + } + var filter = facet.filter; if (filter.type === 'field') { var values = filter.autoCompleteValues; - values.push(ui.item.facet.values[0]); + values.push(facet.values[0]); this.trigger_up('autocompletion_filter', { filterId: filter.id, autoCompleteValues: values, 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 3dc1d6788bf..48f1aacf4ad 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 252b4587af6..347de352503 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -249,7 +249,6 @@ var KanbanRenderer = BasicRenderer.extend({ */ updateState: function (state) { this._setState(state); - this._toggleNoContentHelper(); return this._super.apply(this, arguments); }, @@ -398,11 +397,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 = []; @@ -411,15 +407,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/js/widgets/auto_complete.js b/addons/web/static/src/js/widgets/auto_complete.js index 6dd78f8f730..4da54cf8b5c 100644 --- a/addons/web/static/src/js/widgets/auto_complete.js +++ b/addons/web/static/src/js/widgets/auto_complete.js @@ -201,10 +201,8 @@ return Widget.extend({ this.current_result = $li.data('result'); }, select_item: function (ev) { - if (this.current_result.facet) { - this.select(ev, {item: {facet: this.current_result.facet}}); - this.close(); - } + this.select(ev, {item: {facet: this.current_result.facet}}); + this.close(); }, show: function () { this.$el.show(); diff --git a/addons/web/static/src/scss/fields.scss b/addons/web/static/src/scss/fields.scss index 27d0d13770e..55559546e74 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 8126fcf11c4..7a6bca6aa7a 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -846,11 +846,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/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index 4b8a3a79751..a3501f71722 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -2276,7 +2276,7 @@ QUnit.module('ActionManager', { }); QUnit.test('breadcrumbs are updated when switching between views', function (assert) { - assert.expect(10); + assert.expect(15); var actionManager = createActionManager({ actions: this.actions, @@ -2297,6 +2297,20 @@ QUnit.module('ActionManager', { assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Partners', "breadcrumbs should still display the display_name of the action"); + // open a record in form view + testUtils.dom.click(actionManager.$('.o_kanban_view .o_kanban_record:first')); + assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 2, + "there should be two controllers in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb-item:last').text(), 'First record', + "breadcrumbs should contain the display_name of the opened record"); + + // go back to kanban view using the breadcrumbs + testUtils.dom.click($('.o_control_panel .breadcrumb a')); + assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1, + "there should be one controller in the breadcrumbs"); + assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Partners', + "breadcrumbs should display the display_name of the action"); + // switch back to list view testUtils.dom.click($('.o_control_panel .o_cp_switch_list')); assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1, @@ -2313,6 +2327,8 @@ QUnit.module('ActionManager', { // go back to list view using the breadcrumbs testUtils.dom.click($('.o_control_panel .breadcrumb a')); + assert.containsOnce(actionManager, '.o_list_view', + "should be back on list view"); assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1, "there should be one controller in the breadcrumbs"); assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Partners', diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 33f18558917..4438cfa004c 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..ec8b06b6351 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, '.badge.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 bb2231a025d..a012eff1ca8 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -5263,6 +5263,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/static/tests/views/search_view_tests.js b/addons/web/static/tests/views/search_view_tests.js index 4916834e1bc..bb6fa17d8e6 100644 --- a/addons/web/static/tests/views/search_view_tests.js +++ b/addons/web/static/tests/views/search_view_tests.js @@ -1009,7 +1009,6 @@ QUnit.module('Search View', { actions: this.actions, archs: this.archs, data: this.data, - debug: 1, mockRPC: function (route, args) { if (route === '/web/dataset/search_read') { if (searchRead === 1) { @@ -1086,6 +1085,35 @@ QUnit.module('Search View', { actionManager.destroy(); }); + QUnit.test('selecting (no result) triggers a re-render', function (assert) { + assert.expect(3); + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + actionManager.doAction(10); + + // 'a' key to filter nothing on bar + actionManager.$('.o_searchview_input').val('a'); + actionManager.$('.o_searchview_input').trigger($.Event('keypress', { which: 65, keyCode: 65 })); + actionManager.$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.DOWN, keyCode: $.ui.keyCode.DOWN })); + actionManager.$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, keyCode: $.ui.keyCode.RIGHT })); + actionManager.$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.DOWN, keyCode: $.ui.keyCode.DOWN })); + + assert.strictEqual(actionManager.$('.o_searchview_autocomplete .o-selection-focus').text(), "(no result)", + "there should be no result for 'a' in bar"); + + actionManager.$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER, keyCode: $.ui.keyCode.ENTER })); + + assert.containsNone(actionManager, '.o_searchview_facet_label'); + assert.strictEqual(actionManager.$('.o_searchview_input').val(), "", + "the search input should be re-rendered"); + + actionManager.destroy(); + }); + QUnit.module('TimeRangeMenu'); QUnit.test('time range menu stays hidden', function (assert) { 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/wysiwyg_snippets/snippets.editor.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js index 0b37e9aa9be..b4e783a5f6d 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js @@ -92,6 +92,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); }, /** @@ -101,6 +111,7 @@ var SnippetEditor = Widget.extend({ this.cleanForSave(); this._super.apply(this, arguments); this.$target.removeData('snippet-editor'); + this.$target.off('.snippet_editor'); }, //-------------------------------------------------------------------------- @@ -132,6 +143,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/wysiwyg_snippets/snippets.options.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js index 802311f09d0..0d2bd5a6e79 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js @@ -296,13 +296,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_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 2be6c571a37..e449bea5e42 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 2fd7aab345c..b62999ef2a7 100644 --- a/addons/website/static/src/js/tours/rte.js +++ b/addons/website/static/src/js/tours/rte.js @@ -177,5 +177,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 664dcf037d0..00a6d41a261 100644 --- a/addons/website/static/src/scss/website.edit_mode.scss +++ b/addons/website/static/src/scss/website.edit_mode.scss @@ -218,6 +218,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_links/static/src/js/website_links.js b/addons/website_links/static/src/js/website_links.js index afe64642f47..f95e1033552 100644 --- a/addons/website_links/static/src/js/website_links.js +++ b/addons/website_links/static/src/js/website_links.js @@ -366,7 +366,7 @@ sAnimations.registry.websiteLinks = sAnimations.Class.extend({ defs.push(sourceSelect.attachTo($('#source-select'))); // Recent Links Widgets - this.recentLinks = new RecentLinks(); + this.recentLinks = new RecentLinks(this); defs.push(this.recentLinks.appendTo($('#o_website_links_recent_links'))); this.recentLinks.getRecentLinks('newest'); diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 5e4956a7319..d22b69b8636 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 5670bbcb561..c1086108f73 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -138,7 +138,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') @@ -168,9 +168,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 @@ -183,15 +186,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 @@ -202,7 +204,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 @@ -233,7 +236,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 @@ -250,7 +253,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 94e9c54a312..ec883d9025b 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', }, /** @@ -599,6 +601,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 0a3b3b19f0c..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,6 +4,8 @@ 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?search=Customizable Desk", test: true, @@ -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 da48b761560..438de22adb4 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 557b788e558..8c643c918e2 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -33,6 +33,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> @@ -103,8 +104,7 @@ <template id="products_item" name="Products item"> <t t-set="product_href" t-value="request.httprequest.url_root + 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="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()" /> @@ -136,7 +136,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 +156,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> @@ -629,12 +624,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 ecf16c94bcb..26468eb57fa 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 0bd94c653c4..c7618596750 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) |
