diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 4ad340ce75..d82425dc0b 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -1069,7 +1069,7 @@ class AccountBankStatementLine(models.Model): reconciliation_overview = [] total_balance = liquidity_lines.balance - total_amount_currency = liquidity_lines.amount_currency + total_amount_currency = -self._prepare_move_line_default_vals()[1]['amount_currency'] # Step 1: Split 'lines_vals_list' into two batches: # - The existing account.move.lines that need to be reconciled with the statement line. diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 00bff93e51..048d36db20 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -4855,6 +4855,7 @@ class AccountMoveLine(models.Model): exchange_diff_move_vals['date'] = max(exchange_diff_move_vals['date'], company._get_user_fiscal_lock_date()) exchange_move = self.env['account.move'].create(exchange_diff_move_vals) + exchange_move.line_ids.write({'tax_exigible': True}) # Enforce exigibility in case some cash basis adjustments were made in this exchange difference else: return None diff --git a/addons/account/models/account_partial_reconcile.py b/addons/account/models/account_partial_reconcile.py index f13bbb5a60..7532687ce4 100644 --- a/addons/account/models/account_partial_reconcile.py +++ b/addons/account/models/account_partial_reconcile.py @@ -169,6 +169,7 @@ class AccountPartialReconcile(models.Model): partial_amount_currency = 0.0 rate_amount = 0.0 rate_amount_currency = 0.0 + if partial.debit_move_id.move_id == move: partial_amount += partial.amount partial_amount_currency += partial.debit_amount_currency @@ -176,6 +177,7 @@ class AccountPartialReconcile(models.Model): rate_amount_currency -= partial.credit_move_id.amount_currency source_line = partial.debit_move_id counterpart_line = partial.credit_move_id + if partial.credit_move_id.move_id == move: partial_amount += partial.amount partial_amount_currency += partial.credit_amount_currency @@ -184,6 +186,16 @@ class AccountPartialReconcile(models.Model): source_line = partial.credit_move_id counterpart_line = partial.debit_move_id + if partial.debit_move_id.move_id.is_invoice(include_receipts=True) and partial.credit_move_id.move_id.is_invoice(include_receipts=True): + # Will match when reconciling a refund with an invoice. + # In this case, we want to use the rate of each businness document to compute its cash basis entry, + # not the rate of what it's reconciled with. + rate_amount = source_line.balance + rate_amount_currency = source_line.amount_currency + payment_date = move.date + else: + payment_date = counterpart_line.date + if move_values['currency'] == move.company_id.currency_id: # Percentage made on company's currency. percentage = partial_amount / move_values['total_balance'] @@ -198,7 +210,7 @@ class AccountPartialReconcile(models.Model): counterpart_line.company_currency_id, source_line.currency_id, counterpart_line.company_id, - counterpart_line.date, + payment_date, ) elif rate_amount: payment_rate = rate_amount_currency / rate_amount diff --git a/addons/account/tests/test_account_bank_statement.py b/addons/account/tests/test_account_bank_statement.py index 980d22c288..275805fa5a 100644 --- a/addons/account/tests/test_account_bank_statement.py +++ b/addons/account/tests/test_account_bank_statement.py @@ -1422,6 +1422,51 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, ]) + def test_reconciliation_statement_line_foreign_currency(self): + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_1.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_2.id, + 'amount': -80.0, + 'amount_currency': -120.0, + }), + ], + }) + statement.button_post() + statement_line = statement.line_ids + + invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.currency_2.id, + 'invoice_line_ids': [ + (0, None, { + 'name': 'counterpart line, same amount', + 'account_id': self.company_data['default_account_revenue'].id, + 'quantity': 1, + 'price_unit': 120.0, + }), + ], + }) + invoice.action_post() + invoice_line = invoice.line_ids.filtered(lambda line: line.account_internal_type == 'payable') + + statement_line.reconcile([{'id': invoice_line.id}]) + + self.assertRecordValues(statement_line.line_ids, [ + # pylint: disable=bad-whitespace + {'amount_currency': -80.0, 'currency_id': self.currency_1.id, 'balance': -80.0, 'reconciled': False}, + {'amount_currency': 120.0, 'currency_id': self.currency_2.id, 'balance': 80.0, 'reconciled': True}, + ]) + def test_reconciliation_statement_line_with_generated_payments(self): self.statement.button_post() diff --git a/addons/account/tests/test_account_move_reconcile.py b/addons/account/tests/test_account_move_reconcile.py index de5405f3e6..3153212966 100644 --- a/addons/account/tests/test_account_move_reconcile.py +++ b/addons/account/tests/test_account_move_reconcile.py @@ -2153,6 +2153,165 @@ class TestAccountMoveReconcile(AccountTestInvoicingCommon): {'debit': 0.0, 'credit': 50.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, ]) + def test_reconcile_cash_basis_refund_multicurrency(self): + self.env.company.tax_exigibility = True + rates_data = self.setup_multi_currency_data(default_values={ + 'name': 'Playmock', + 'symbol': '🦌', + 'rounding': 0.01, + 'currency_unit_label': 'Playmock', + 'currency_subunit_label': 'Cent', + }, rate2016=0.5, rate2017=0.33333333333333333) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'currency_id': rates_data['currency'].id, + 'invoice_date': '2016-01-01', + 'invoice_line_ids': [(0, 0, { + 'name': 'dudu', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 100.0, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + })], + }) + + refund = self.env['account.move'].create({ + 'move_type': 'out_refund', + 'partner_id': self.partner_a.id, + 'currency_id': rates_data['currency'].id, + 'invoice_date': '2017-01-01', + 'invoice_line_ids': [(0, 0, { + 'name': 'dudu', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 100.0, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + })], + }) + + invoice.action_post() + refund.action_post() + + (refund + invoice).line_ids.filtered(lambda x: x.account_id.user_type_id.type == 'receivable').reconcile() + + # Check the cash basis moves + self.assertRecordValues( + self.env['account.move'].search([('tax_cash_basis_move_id', '=', invoice.id)]).line_ids, + [ + { + 'debit': 200, + 'credit': 0, + 'amount_currency': 100, + 'currency_id': rates_data['currency'].id, + 'tax_ids': [], + 'tax_repartition_line_id': None, + 'tax_tag_ids': [], + 'tax_exigible': True, + }, + { + 'debit': 0, + 'credit': 200, + 'amount_currency': -100, + 'currency_id': rates_data['currency'].id, + 'tax_ids': self.cash_basis_tax_a_third_amount.ids, + 'tax_repartition_line_id': None, + 'tax_tag_ids': self.tax_tags[0].ids, + 'tax_exigible': True, + }, + { + 'debit': 66.66, + 'credit': 0, + 'amount_currency': 33.33, + 'currency_id': rates_data['currency'].id, + 'tax_ids': [], + 'tax_repartition_line_id': None, + 'tax_tag_ids': [], + 'tax_exigible': True, + }, + { + 'debit': 0, + 'credit': 66.66, + 'amount_currency': -33.33, + 'currency_id': rates_data['currency'].id, + 'tax_ids': [], + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, + 'tax_tag_ids': self.tax_tags[1].ids, + 'tax_exigible': True, + }, + ] + ) + + self.assertRecordValues( + self.env['account.move'].search([('tax_cash_basis_move_id', '=', refund.id)]).line_ids, + [ + { + 'debit': 0, + 'credit': 300, + 'amount_currency': -100, + 'currency_id': rates_data['currency'].id, + 'tax_ids': [], + 'tax_repartition_line_id': None, + 'tax_tag_ids': [], + 'tax_exigible': True, + }, + { + 'debit': 300, + 'credit': 0, + 'amount_currency': 100, + 'currency_id': rates_data['currency'].id, + 'tax_ids': self.cash_basis_tax_a_third_amount.ids, + 'tax_repartition_line_id': None, + 'tax_tag_ids': self.tax_tags[2].ids, + 'tax_exigible': True, + }, + { + 'debit': 0, + 'credit': 99.99, + 'amount_currency': -33.33, + 'currency_id': rates_data['currency'].id, + 'tax_ids': [], + 'tax_repartition_line_id': None, + 'tax_tag_ids': [], + 'tax_exigible': True, + }, + { + 'debit': 99.99, + 'credit': 0, + 'amount_currency': 33.33, + 'currency_id': rates_data['currency'].id, + 'tax_ids': [], + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.refund_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, + 'tax_tag_ids': self.tax_tags[3].ids, + 'tax_exigible': True, + }, + ] + ) + + # Check the exchange difference move, to be sure no cash basis rounding data was added into it + self.assertRecordValues( + invoice.line_ids.full_reconcile_id.exchange_move_id.line_ids, + [ + { + 'debit': 133.33, + 'credit': 0, + 'amount_currency': 0, + 'currency_id': rates_data['currency'].id, + 'tax_ids': [], + 'tax_repartition_line_id': None, + 'tax_tag_ids': [], + }, + { + 'debit': 0, + 'credit': 133.33, + 'amount_currency': 0, + 'currency_id': rates_data['currency'].id, + 'tax_ids': [], + 'tax_repartition_line_id': None, + 'tax_tag_ids': [], + }, + ] + ) + def test_reconcile_cash_basis_revert(self): ''' Ensure the cash basis journal entry can be reverted. ''' self.env.company.tax_exigibility = True diff --git a/addons/delivery/models/sale_order.py b/addons/delivery/models/sale_order.py index 0c9fbbf312..0613e6c786 100644 --- a/addons/delivery/models/sale_order.py +++ b/addons/delivery/models/sale_order.py @@ -91,8 +91,11 @@ class SaleOrder(models.Model): def _create_delivery_line(self, carrier, price_unit): SaleOrderLine = self.env['sale.order.line'] + context = {} if self.partner_id: # set delivery detail in the customer language + # used in local scope translation process + context['lang'] = self.partner_id.lang carrier = carrier.with_context(lang=self.partner_id.lang) # Apply fiscal position @@ -102,12 +105,12 @@ class SaleOrder(models.Model): taxes_ids = self.fiscal_position_id.map_tax(taxes, carrier.product_id, self.partner_id).ids # Create the sales order line - carrier_with_partner_lang = carrier.with_context(lang=self.partner_id.lang) - if carrier_with_partner_lang.product_id.description_sale: - so_description = '%s: %s' % (carrier_with_partner_lang.name, - carrier_with_partner_lang.product_id.description_sale) + + if carrier.product_id.description_sale: + so_description = '%s: %s' % (carrier.name, + carrier.product_id.description_sale) else: - so_description = carrier_with_partner_lang.name + so_description = carrier.name values = { 'order_id': self.id, 'name': so_description, @@ -127,6 +130,7 @@ class SaleOrder(models.Model): if self.order_line: values['sequence'] = self.order_line[-1].sequence + 1 sol = SaleOrderLine.sudo().create(values) + del context return sol def _format_currency_amount(self, amount): diff --git a/addons/fetchmail_outlook/__init__.py b/addons/fetchmail_outlook/__init__.py index aaea41f08e..013872cc0d 100644 --- a/addons/fetchmail_outlook/__init__.py +++ b/addons/fetchmail_outlook/__init__.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from . import models diff --git a/addons/fetchmail_outlook/__manifest__.py b/addons/fetchmail_outlook/__manifest__.py index 47f2202d0c..e58d3acc35 100644 --- a/addons/fetchmail_outlook/__manifest__.py +++ b/addons/fetchmail_outlook/__manifest__.py @@ -1,8 +1,9 @@ +# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. { "name": "Fetchmail Outlook", - "version": "1.0.1.0.0", + "version": "1.0", "category": "Hidden", "description": "OAuth authentication for incoming Outlook mail server", "depends": [ diff --git a/addons/fetchmail_outlook/models/__init__.py b/addons/fetchmail_outlook/models/__init__.py index eda7de200c..c4b5beb2e6 100644 --- a/addons/fetchmail_outlook/models/__init__.py +++ b/addons/fetchmail_outlook/models/__init__.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from . import fetchmail_server diff --git a/addons/fetchmail_outlook/models/fetchmail_server.py b/addons/fetchmail_outlook/models/fetchmail_server.py index 38eaa60d8e..9edbbd307b 100644 --- a/addons/fetchmail_outlook/models/fetchmail_server.py +++ b/addons/fetchmail_outlook/models/fetchmail_server.py @@ -1,4 +1,5 @@ -# Part of Flectra. See LICENSE file for full copyright and licensing details. +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from flectra import _, api, models from flectra.exceptions import UserError diff --git a/addons/fetchmail_outlook/tests/__init__.py b/addons/fetchmail_outlook/tests/__init__.py index 979b062b39..0aa09ba637 100644 --- a/addons/fetchmail_outlook/tests/__init__.py +++ b/addons/fetchmail_outlook/tests/__init__.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from . import test_fetchmail_outlook diff --git a/addons/fetchmail_outlook/tests/test_fetchmail_outlook.py b/addons/fetchmail_outlook/tests/test_fetchmail_outlook.py index cd0b216158..abec8c3fdf 100644 --- a/addons/fetchmail_outlook/tests/test_fetchmail_outlook.py +++ b/addons/fetchmail_outlook/tests/test_fetchmail_outlook.py @@ -1,4 +1,5 @@ -# Part of Flectra. See LICENSE file for full copyright and licensing details. +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. import time diff --git a/addons/microsoft_outlook/__init__.py b/addons/microsoft_outlook/__init__.py index ffc447aa9c..b855462005 100644 --- a/addons/microsoft_outlook/__init__.py +++ b/addons/microsoft_outlook/__init__.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from . import controllers diff --git a/addons/microsoft_outlook/__manifest__.py b/addons/microsoft_outlook/__manifest__.py index f7a9fe0456..5968559df4 100644 --- a/addons/microsoft_outlook/__manifest__.py +++ b/addons/microsoft_outlook/__manifest__.py @@ -1,8 +1,9 @@ +# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. { "name": "Microsoft Outlook", - "version": "1.0.1.0.0", + "version": "1.0", "category": "Hidden", "description": "Outlook support for outgoing mail servers", "depends": [ diff --git a/addons/microsoft_outlook/controllers/main.py b/addons/microsoft_outlook/controllers/main.py index 7fc684439c..9c49d4cda9 100644 --- a/addons/microsoft_outlook/controllers/main.py +++ b/addons/microsoft_outlook/controllers/main.py @@ -1,4 +1,5 @@ -# Part of Flectra. See LICENSE file for full copyright and licensing details. +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. import json import logging diff --git a/addons/microsoft_outlook/models/__init__.py b/addons/microsoft_outlook/models/__init__.py index 8d68d809ad..e34f800f4a 100644 --- a/addons/microsoft_outlook/models/__init__.py +++ b/addons/microsoft_outlook/models/__init__.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from . import microsoft_outlook_mixin diff --git a/addons/microsoft_outlook/models/ir_mail_server.py b/addons/microsoft_outlook/models/ir_mail_server.py index 274cb8b614..468a05231c 100644 --- a/addons/microsoft_outlook/models/ir_mail_server.py +++ b/addons/microsoft_outlook/models/ir_mail_server.py @@ -1,4 +1,5 @@ -# Part of Flectra. See LICENSE file for full copyright and licensing details. +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. import base64 diff --git a/addons/microsoft_outlook/models/microsoft_outlook_mixin.py b/addons/microsoft_outlook/models/microsoft_outlook_mixin.py index 5924aff53d..ecaed3239d 100644 --- a/addons/microsoft_outlook/models/microsoft_outlook_mixin.py +++ b/addons/microsoft_outlook/models/microsoft_outlook_mixin.py @@ -1,4 +1,5 @@ -# Part of Flectra. See LICENSE file for full copyright and licensing details. +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. import json import logging diff --git a/addons/microsoft_outlook/models/res_config_settings.py b/addons/microsoft_outlook/models/res_config_settings.py index 04ec556f45..ca325b20ed 100644 --- a/addons/microsoft_outlook/models/res_config_settings.py +++ b/addons/microsoft_outlook/models/res_config_settings.py @@ -1,4 +1,5 @@ -# Part of Flectra. See LICENSE file for full copyright and licensing details. +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from flectra import fields, models diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 6d657c0268..d1d3870c1f 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -776,7 +776,7 @@ class MrpProduction(models.Model): # Remove from `move_finished_ids` the by-product moves and then move `move_byproduct_ids` # into `move_finished_ids` to avoid duplicate and inconsistency. if values.get('move_finished_ids', False): - values['move_finished_ids'] = list(filter(lambda move: move[2]['byproduct_id'] is False, values['move_finished_ids'])) + values['move_finished_ids'] = list(filter(lambda move: move[2].get('byproduct_id', False) is False, values['move_finished_ids'])) if values.get('move_byproduct_ids', False): values['move_finished_ids'] = values.get('move_finished_ids', []) + values['move_byproduct_ids'] del values['move_byproduct_ids'] diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index b9a7111293..8458d2dde6 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -282,6 +282,8 @@ class PosOrder(models.Model): @api.onchange('payment_ids', 'lines') def _onchange_amount_all(self): for order in self: + if not order.pricelist_id.currency_id: + raise UserError(_("You can't: create a pos order from the backend interface, or unset the pricelist, or create a pos.order in a python test with Form tool, or edit the form view in studio if no PoS order exist")) currency = order.pricelist_id.currency_id order.amount_paid = sum(payment.amount for payment in order.payment_ids) order.amount_return = sum(payment.amount < 0 and payment.amount or 0 for payment in order.payment_ids) diff --git a/addons/purchase/report/purchase_report.py b/addons/purchase/report/purchase_report.py index 97b8085d58..301f4e460a 100644 --- a/addons/purchase/report/purchase_report.py +++ b/addons/purchase/report/purchase_report.py @@ -66,7 +66,6 @@ class PurchaseReport(models.Model): def _select(self): select_str = """ - WITH currency_rate as (%s) SELECT po.id as order_id, min(l.id) as id, @@ -101,7 +100,7 @@ class PurchaseReport(models.Model): then sum(l.product_qty / line_uom.factor * product_uom.factor) - sum(l.qty_invoiced / line_uom.factor * product_uom.factor) else sum(l.qty_received / line_uom.factor * product_uom.factor) - sum(l.qty_invoiced / line_uom.factor * product_uom.factor) end as qty_to_be_billed - """ % self.env['res.currency']._select_companies_rates() + """ return select_str def _from(self): @@ -115,10 +114,6 @@ class PurchaseReport(models.Model): left join uom_uom line_uom on (line_uom.id=l.product_uom) left join uom_uom product_uom on (product_uom.id=t.uom_id) left join account_analytic_account analytic_account on (l.account_analytic_id = analytic_account.id) - left join currency_rate cr on (cr.currency_id = po.currency_id and - cr.company_id = po.company_id and - cr.date_start <= coalesce(po.date_order, now()) and - (cr.date_end is null or cr.date_end > coalesce(po.date_order, now()))) left join {currency_table} ON currency_table.company_id = po.company_id """.format( currency_table=self.env['res.currency']._get_query_currency_table({'multi_company': True, 'date': {'date_to': fields.Date.today()}}), diff --git a/addons/snailmail/models/snailmail_letter.py b/addons/snailmail/models/snailmail_letter.py index 3b6e3e4e20..d9de37c21d 100644 --- a/addons/snailmail/models/snailmail_letter.py +++ b/addons/snailmail/models/snailmail_letter.py @@ -403,6 +403,8 @@ class SnailmailLetter(models.Model): ]) for letter in letters_send: letter._snailmail_print() + if letter.error_code == 'CREDIT_ERROR': + break # avoid spam # Commit after every letter sent to avoid to send it again in case of a rollback if autocommit: self.env.cr.commit() diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index 911c93729a..38bb63855c 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -388,8 +388,12 @@ var AbstractField = Widget.extend({ * @returns {string} */ _formatValue: function (value, formatType) { + formatType = formatType || this.formatType; + if (!formatType) { + throw new Error(`Missing format type for '${this.name}' value from the '${this.model}' model`); + } var options = _.extend({}, this.nodeOptions, { data: this.recordData }, this.formatOptions); - return field_utils.format[formatType || this.formatType](value, this.field, options); + return field_utils.format[formatType](value, this.field, options); }, /** * Returns the className corresponding to a given decoration. A diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index 04691d8ef8..7c1b910f3a 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -351,8 +351,14 @@ var KanbanModel = BasicModel.extend({ var data = results[1]; _.each(list.data, function (groupID) { var group = self.localData[groupID]; + var value = group.value; + if (value === true) { + value = "True"; + } else if (value === false) { + value = "False"; + } group.progressBarValues = _.extend({ - counts: data[group.value] || {}, + counts: data[value] || {}, }, list.progressBar); }); return list; diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index 3fff813535..c9f56b6b28 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -1593,6 +1593,15 @@ var MockServer = Class.extend({ _.each(records, function (record) { var groupByValue = record[groupBy]; // always technical value here + // special case for bool values: rpc call response with capitalized strings + if (!(groupByValue in data)) { + if (groupByValue === true) { + groupByValue = "True"; + } else if (groupByValue === false) { + groupByValue = "False"; + } + } + if (!(groupByValue in data)) { data[groupByValue] = {}; _.each(progress_bar.colors, function (val, key) { diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 65cfa42205..ea1605bc25 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -1389,7 +1389,23 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ } else if (weUtils.isColorCombinationName(this._value)) { this.colorPreviewEl.classList.add('o_cc', `o_cc${this._value}`); } else { - this.colorPreviewEl.classList.add(`bg-${this._value}`); + // Checking if the className actually exists seems overkill but + // it is actually needed to prevent a crash. As an example, if a + // colorpicker widget is linked to a SnippetOption instance's + // `selectStyle` method designed to handle the "border-color" + // property of an element, the value received can be split if + // the item uses different colors for its top/right/bottom/left + // borders. For instance, you could receive "red blue" if the + // item as red top and bottom borders and blue left and right + // borders, in which case you would reach this `else` and try to + // add the class "bg-red blue" which would crash because of the + // space inside). In that case, we simply do not show any color. + // We could choose to handle this split-value case specifically + // but it was decided that this is enough for the moment. + const className = `bg-${this._value}`; + if (classes.includes(className)) { + this.colorPreviewEl.classList.add(className); + } } } }, @@ -2085,6 +2101,13 @@ const SnippetOptionWidget = Widget.extend({ * @param {boolean} previewMode - @see this.selectClass * @param {string} widgetValue * @param {Object} params + * @param {string} [params.forceStyle] if undefined, the method will not + * set the inline style (and thus even remove it) if the item would + * already have the given style without it (thanks to a CSS rule for + * example). If defined (as a string), it acts as the "priority" param + * of @see CSSStyleDeclaration.setProperty: it should be 'important' to + * set the style as important or '' otherwise. Note that if forceStyle + * is undefined, the style is always set as important when applied. * @returns {Promise|undefined} */ selectStyle: function (previewMode, widgetValue, params) { @@ -2179,8 +2202,11 @@ const SnippetOptionWidget = Widget.extend({ hasUserValue = applyCSS.call(this, cssProps[0], values.join(' '), styles) || hasUserValue; function applyCSS(cssProp, cssValue, styles) { - if (!weUtils.areCssValuesEqual(styles[cssProp], cssValue, cssProp, this.$target[0])) { - this.$target[0].style.setProperty(cssProp, cssValue, 'important'); + const forceStyle = (typeof params.forceStyle !== 'undefined'); + if (forceStyle + || !weUtils.areCssValuesEqual(styles[cssProp], cssValue, cssProp, this.$target[0])) { + const priority = forceStyle ? params.forceStyle : 'important'; + this.$target[0].style.setProperty(cssProp, cssValue, priority); return true; } return false; diff --git a/addons/web_tour/models/tour.py b/addons/web_tour/models/tour.py index 84baca8416..29cef3e0e8 100644 --- a/addons/web_tour/models/tour.py +++ b/addons/web_tour/models/tour.py @@ -17,8 +17,12 @@ class Tour(models.Model): def consume(self, tour_names): """ Sets given tours as consumed, meaning that these tours won't be active anymore for that user """ + if not self.env.user.has_group('base.group_user'): + # Only internal users can use this method. + # TODO master: update ir.model.access records instead of using sudo() + return for name in tour_names: - self.create({'name': name, 'user_id': self.env.uid}) + self.sudo().create({'name': name, 'user_id': self.env.uid}) @api.model def get_consumed_tours(self): diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index b318ddf0d5..7070f56e89 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -27,6 +27,7 @@ const BaseAnimatedHeader = animations.Animation.extend({ this.fixedHeader = false; this.scrolledPoint = 0; this.hasScrolled = false; + this.scrollHeightTooShort = false; }, /** * @override @@ -155,6 +156,33 @@ const BaseAnimatedHeader = animations.Animation.extend({ } this.$main.css('padding-top', this.fixedHeader ? this.headerHeight : ''); }, + /** + * Checks if the size of the header will decrease by adding the + * 'o_header_is_scrolled' class. If so, we do not add this class if the + * remaining scroll height is not enough to stay above 'this.scrolledPoint' + * after the transition, otherwise it causes the scroll position to move up + * again below 'this.scrolledPoint' and trigger an infinite loop. + * + * @todo header effects should be improved in the future to not ever change + * the page scroll-height during their animation. The code would probably be + * simpler but also prevent having weird scroll "jumps" during animations + * (= depending on the logo height after/before scroll, a scroll step (one + * mousewheel event for example) can be bigger than other ones). + * + * @private + * @returns {boolean} + */ + _scrollHeightTooShort() { + const scrollEl = $().getScrollingElement()[0]; + const remainingScroll = (scrollEl.scrollHeight - scrollEl.clientHeight) - this.scrolledPoint; + const clonedHeader = this.el.cloneNode(true); + scrollEl.append(clonedHeader); + clonedHeader.classList.add('o_header_is_scrolled', 'o_header_affixed', 'o_header_no_transition'); + const endHeaderHeight = clonedHeader.offsetHeight; + clonedHeader.remove(); + const heightDiff = this.headerHeight - endHeaderHeight; + return heightDiff > 0 ? remainingScroll <= heightDiff : false; + }, //-------------------------------------------------------------------------- // Handlers @@ -180,9 +208,12 @@ const BaseAnimatedHeader = animations.Animation.extend({ // Indicates the page is scrolled, the logo size is changed. const headerIsScrolled = (scroll > this.scrolledPoint); if (this.headerIsScrolled !== headerIsScrolled) { - this.el.classList.toggle('o_header_is_scrolled', headerIsScrolled); - this.$el.trigger('flectra-transitionstart'); - this.headerIsScrolled = headerIsScrolled; + this.scrollHeightTooShort = headerIsScrolled && this._scrollHeightTooShort(); + if (!this.scrollHeightTooShort) { + this.el.classList.toggle('o_header_is_scrolled', headerIsScrolled); + this.$el.trigger('flectra-transitionstart'); + this.headerIsScrolled = headerIsScrolled; + } } // Close opened menus @@ -222,6 +253,13 @@ publicWidget.registry.StandardAffixedHeader = BaseAnimatedHeader.extend({ this.headerHeight = this.$el.outerHeight(); return this._super.apply(this, arguments); }, + /** + * @override + */ + destroy() { + this.$el.css('transform', ''); + this._super(...arguments); + }, //-------------------------------------------------------------------------- // Handlers @@ -236,14 +274,14 @@ publicWidget.registry.StandardAffixedHeader = BaseAnimatedHeader.extend({ /** * Called when the window is scrolled * - * @private + * @override * @param {integer} scroll */ _updateHeaderOnScroll: function (scroll) { this._super(...arguments); const mainPosScrolled = (scroll > this.headerHeight + this.topGap); - const reachPosScrolled = (scroll > this.scrolledPoint + this.topGap); + const reachPosScrolled = (scroll > this.scrolledPoint + this.topGap) && !this.scrollHeightTooShort; const fixedUpdate = (this.fixedHeader !== mainPosScrolled); const showUpdate = (this.fixedHeaderShow !== reachPosScrolled); diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 93978116ce..e4e5e9ff21 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -2687,6 +2687,58 @@ options.registry.ScrollButton = options.Class.extend({ }, }); +// TODO there is no data-js associated to this but a data-option-name, somehow +// it acts as data-js... it will be reviewed in master. +options.registry.minHeight = options.Class.extend({ + /** + * @override + */ + _renderCustomXML(uiFragment) { + // TODO adapt in master. This sets up a different UI for the image + // gallery snippet: for this one, we allow to force a specific height + // in auto mode. It was done in stable as without it, the default height + // is difficult to understand for the user as it depends on screen + // height of the one who edited the website and not on the added images. + // It was also a regression as in <= 11.0, this was a possibility. + if (this.$target[0].dataset.snippet !== 's_image_gallery') { + return; + } + const minHeightEl = uiFragment.querySelector('we-button-group'); + if (!minHeightEl) { + return; + } + minHeightEl.setAttribute('string', _t("Min-Height")); + const heightEl = document.createElement('we-input'); + heightEl.setAttribute('string', _t("└ Height")); + heightEl.dataset.name = 'image_gallery_height_opt'; + heightEl.dataset.unit = 'px'; + heightEl.dataset.selectStyle = ''; + heightEl.dataset.cssProperty = 'height'; + // For this setting, we need to always force the style (= if the block + // is naturally 800px tall and the user enters 800px for this setting, + // we set 800px as inline style anyway). Indeed, this snippet's style + // is based on the height that is forced but once the related public + // widgets are started, the inner carousel items receive a min-height + // which makes it so the snippet "natural" height is equal to the + // initially forced height... so if the style is not forced, it would + // ultimately be removed by mistake thinking it is not necessary. + // Note: this is forced as not important as we still need the height to + // be reset to 'auto' in mobile (generic css rules). + heightEl.dataset.forceStyle = ''; + uiFragment.appendChild(heightEl); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'image_gallery_height_opt') { + return !this.$target[0].classList.contains('o_half_screen_height') + && !this.$target[0].classList.contains('o_full_screen_height'); + } + return this._super(...arguments); + }, +}); + return { UrlPickerUserValueWidget: UrlPickerUserValueWidget, FontFamilyPickerUserValueWidget: FontFamilyPickerUserValueWidget, diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml index b2262a5e97..9d439815a1 100644 --- a/addons/website/views/snippets/snippets.xml +++ b/addons/website/views/snippets/snippets.xml @@ -1001,6 +1001,7 @@ +
Auto