diff options
61 files changed, 1146 insertions, 166 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 967656fd90e..e2301af91d3 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -317,13 +317,13 @@ class AccountAccount(models.Model): if opening_move_line: if amount: # modify the line - setattr(opening_move_line.with_context({'check_move_validity': False}), field, amount) + opening_move_line.with_context(check_move_validity=False)[field] = amount elif counter_part_map[field]: # delete the line (no need to keep a line with value = 0) - opening_move_line.with_context({'check_move_validity': False}).unlink() + opening_move_line.with_context(check_move_validity=False).unlink() elif amount: # create a new line, as none existed before - self.env['account.move.line'].with_context({'check_move_validity': False}).create({ + self.env['account.move.line'].with_context(check_move_validity=False).create({ 'name': _('Opening balance'), field: amount, 'move_id': opening_move.id, @@ -794,7 +794,7 @@ class AccountJournal(models.Model): return { 'alias_defaults': {'type': type == 'purchase' and 'in_invoice' or 'out_invoice', 'company_id': self.company_id.id, 'journal_id': self.id}, 'alias_parent_thread_id': self.id, - 'alias_name': re.sub(r'[^\w]+', '-', alias_name) + 'alias_name': alias_name, } def unlink(self): diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 222727f614a..2f23d4c923c 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -133,13 +133,13 @@ class AccountMove(models.Model): amount_residual = fields.Monetary(string='Amount Due', store=True, compute='_compute_amount') amount_untaxed_signed = fields.Monetary(string='Untaxed Amount Signed', store=True, readonly=True, - compute='_compute_amount') + compute='_compute_amount', currency_field='company_currency_id') amount_tax_signed = fields.Monetary(string='Tax Signed', store=True, readonly=True, - compute='_compute_amount') + compute='_compute_amount', currency_field='company_currency_id') amount_total_signed = fields.Monetary(string='Total Signed', store=True, readonly=True, - compute='_compute_amount') + compute='_compute_amount', currency_field='company_currency_id') amount_residual_signed = fields.Monetary(string='Amount Due Signed', store=True, - compute='_compute_amount') + compute='_compute_amount', currency_field='company_currency_id') amount_by_group = fields.Binary(string="Tax amount by group", compute='_compute_invoice_taxes_by_group', help="technical field used in report and in invoice form view with a widget to display the detail of taxes (grouped by tax group) under the subtotal") @@ -2767,7 +2767,7 @@ class AccountMoveLine(models.Model): reconciled = True line.reconciled = reconciled - line.amount_residual = line.move_id.company_id.currency_id.round(amount * sign) + line.amount_residual = line.move_id.company_id.currency_id.round(amount * sign) if line.move_id.company_id else amount * sign line.amount_residual_currency = line.currency_id and line.currency_id.round(amount_residual_currency * sign) or 0.0 @api.depends('tax_repartition_line_id.invoice_tax_id', 'tax_repartition_line_id.refund_tax_id') diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 25ddf2a3544..6b4dd3a1078 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -319,15 +319,17 @@ class account_payment(models.Model): move_currency = self.env['res.currency'].browse(res['currency_id']) if move_currency == currency and move_currency != company.currency_id: total += res['residual_currency'] - elif move_currency == currency == company.currency_id: - total += res['amount_residual'] else: - total += move_currency._convert(res['amount_residual'], currency, company, date) + total += company.currency_id._convert(res['amount_residual'], currency, company, date) return total def name_get(self): return [(payment.id, payment.name or _('Draft Payment')) for payment in self] + @api.model + def _get_move_name_transfer_separator(self): + return 'ยงยง' + @api.depends('move_line_ids.reconciled') def _get_move_reconciled(self): for payment in self: @@ -448,7 +450,6 @@ class account_payment(models.Model): move.unlink() rec.write({ 'state': 'cancelled', - 'move_name': False, }) def unlink(self): @@ -484,6 +485,7 @@ class account_payment(models.Model): all_move_vals = [] for payment in self: company_currency = payment.company_id.currency_id + move_names = payment.move_name.split(payment._get_move_name_transfer_separator()) if payment.move_name else None # Compute amounts. write_off_amount = payment.payment_difference_handling == 'reconcile' and -payment.payment_difference or 0.0 @@ -591,6 +593,9 @@ class account_payment(models.Model): 'payment_id': payment.id, })) + if move_names: + move_vals['name'] = move_names[0] + all_move_vals.append(move_vals) # ==== 'transfer' ==== @@ -634,6 +639,9 @@ class account_payment(models.Model): ], } + if move_names and len(move_names) == 2: + transfer_move_vals['name'] = move_names[1] + all_move_vals.append(transfer_move_vals) return all_move_vals @@ -677,7 +685,8 @@ class account_payment(models.Model): moves.filtered(lambda move: move.journal_id.post_at != 'bank_rec').post() # Update the state / move before performing any reconciliation. - rec.write({'state': 'posted', 'move_name': moves[0].name}) + move_name = self._get_move_name_transfer_separator().join(moves.mapped('name')) + rec.write({'state': 'posted', 'move_name': move_name}) if rec.payment_type in ('inbound', 'outbound'): # ==== 'inbound' / 'outbound' ==== diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 3ad49898420..dfa524f5cf6 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -238,7 +238,7 @@ class AccountChartTemplate(models.Model): }) # Set the transfer account on the company - company.transfer_account_id = self.env['account.account'].search([('code', '=like', self.transfer_account_code_prefix + '%')])[0] + company.transfer_account_id = self.env['account.account'].search([('code', '=like', self.transfer_account_code_prefix + '%')])[:1] # Create Bank journals self._create_bank_journals(company, acc_template_ref) @@ -265,7 +265,7 @@ class AccountChartTemplate(models.Model): """ model_to_check = ['account.move', 'account.payment', 'account.bank.statement'] for model in model_to_check: - if len(self.env[model].search([('company_id', '=', company_id.id)])) > 0: + if self.env[model].sudo().search([('company_id', '=', company_id.id)], limit=1): return True return False diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py index ef0f030a5ee..e3884d5c975 100644 --- a/addons/account/models/partner.py +++ b/addons/account/models/partner.py @@ -225,7 +225,7 @@ class ResPartner(models.Model): @api.depends_context('force_company') def _credit_debit_get(self): - tables, where_clause, where_params = self.env['account.move.line'].with_context(company_id=self.env.company.id)._query_get() + tables, where_clause, where_params = self.env['account.move.line'].with_context(state='posted', company_id=self.env.company.id)._query_get() where_params = [tuple(self.ids)] + where_params if where_clause: where_clause = 'AND ' + where_clause @@ -266,9 +266,11 @@ class ResPartner(models.Model): SELECT partner.id FROM res_partner partner LEFT JOIN account_move_line aml ON aml.partner_id = partner.id + JOIN account_move move ON move.id = aml.move_id RIGHT JOIN account_account acc ON aml.account_id = acc.id WHERE acc.internal_type = %s AND NOT acc.deprecated AND acc.company_id = %s + AND move.state == 'posted' GROUP BY partner.id HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand)) res = self._cr.fetchall() diff --git a/addons/account/models/res_users.py b/addons/account/models/res_users.py index 1bc6ee1c813..6b379668fa3 100644 --- a/addons/account/models/res_users.py +++ b/addons/account/models/res_users.py @@ -12,14 +12,11 @@ class Users(models.Model): def _check_one_user_type(self): super(Users, self)._check_one_user_type() - users_with_both_groups = self.filtered(lambda user: - user.has_group('account.group_show_line_subtotals_tax_included') and - user.has_group('account.group_show_line_subtotals_tax_excluded') - ) - if users_with_both_groups: - names = ", ".join(users_with_both_groups.mapped('name')) + g1 = self.env.ref('account.group_show_line_subtotals_tax_included') + g2 = self.env.ref('account.group_show_line_subtotals_tax_excluded') + + if self._has_multiple_groups([g1.id, g2.id]): raise ValidationError(_("A user cannot have both Tax B2B and Tax B2C.\n" - "Problematic user(s): %s\n" "You should go in General Settings, and choose to display Product Prices\n" "either in 'Tax-Included' or in 'Tax-Excluded' mode\n" - "(or switch twice the mode if you are already in the desired one).") % names) + "(or switch twice the mode if you are already in the desired one).")) diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py index ade019768ea..557a482d34f 100644 --- a/addons/account/tests/test_payment.py +++ b/addons/account/tests/test_payment.py @@ -33,6 +33,8 @@ class TestPayment(AccountingTestCase): self.bank_journal_euro = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'BNK67'}) self.account_eur = self.bank_journal_euro.default_debit_account_id + self.cash_journal_euro = self.env['account.journal'].create({'name': 'Cash', 'type': 'cash', 'code': 'CASH'}) + self.bank_journal_usd = self.env['account.journal'].create({'name': 'Bank US', 'type': 'bank', 'code': 'BNK68', 'currency_id': self.currency_usd_id}) self.account_usd = self.bank_journal_usd.default_debit_account_id @@ -425,3 +427,119 @@ class TestPayment(AccountingTestCase): # The invoice should now be paid self.assertEqual(invoice.invoice_payment_state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement") + + def test_payment_cancel_keep_name(self): + self.bank_journal_euro.update_posted = True + + payment = self.payment_model.create({ + 'payment_type': 'inbound', + 'payment_method_id': self.payment_method_manual_in.id, + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait.id, + 'amount': 90, + 'payment_date': time.strftime('%Y') + '-07-15', + 'payment_difference_handling': 'reconcile', + 'journal_id': self.bank_journal_euro.id, + }) + + payment.post() + self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1) + name = payment.move_line_ids.mapped('move_id').name + self.assertTrue(name) + + payment.cancel() + self.assertFalse(payment.move_line_ids.mapped('move_id')) + payment.action_draft() + + payment.post() + self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1) + self.assertEqual(name, payment.move_line_ids.mapped('move_id').name) + + def test_payment_transfer_cancel_keep_names(self): + self.bank_journal_euro.update_posted = True + self.cash_journal_euro.update_posted = True + + payment = self.payment_model.create({ + 'payment_type': 'transfer', + 'payment_method_id': self.payment_method_manual_out.id, + 'amount': 90, + 'payment_date': time.strftime('%Y') + '-07-15', + 'journal_id': self.bank_journal_euro.id, + 'destination_journal_id': self.cash_journal_euro.id, + }) + + payment.post() + self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 2) + + all_moves = payment.move_line_ids.mapped('move_id') + move = all_moves.filtered(lambda m: m.journal_id == self.bank_journal_euro) + transfer_move = all_moves - move + self.assertEqual(transfer_move.journal_id, self.cash_journal_euro) + + name = move.name + transfer_name = transfer_move.name + self.assertTrue(name) + self.assertTrue(transfer_name) + self.assertNotEqual(name, transfer_name) + + reconciled_lines = payment.move_line_ids.filtered(lambda l: l.reconciled) + self.assertEqual(len(reconciled_lines), 2) + self.assertEqual(reconciled_lines.mapped('move_id'), all_moves) + + reconciled_lines.remove_move_reconcile() + payment.cancel() + self.assertFalse(payment.move_line_ids.mapped('move_id')) + payment.action_draft() + + payment.post() + self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 2) + + all_moves = payment.move_line_ids.mapped('move_id') + move = all_moves.filtered(lambda m: m.journal_id == self.bank_journal_euro) + transfer_move = all_moves - move + self.assertEqual(transfer_move.journal_id, self.cash_journal_euro) + + self.assertEqual(name, move.name) + self.assertEqual(transfer_name, transfer_move.name) + + def test_payment_cancel_to_transfer(self): + self.bank_journal_euro.update_posted = True + + payment = self.payment_model.create({ + 'payment_type': 'inbound', + 'payment_method_id': self.payment_method_manual_in.id, + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait.id, + 'amount': 90, + 'payment_date': time.strftime('%Y') + '-07-15', + 'payment_difference_handling': 'reconcile', + 'journal_id': self.bank_journal_euro.id, + }) + + payment.post() + self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1) + name = payment.move_line_ids.mapped('move_id').name + self.assertTrue(name) + + payment.cancel() + self.assertFalse(payment.move_line_ids.mapped('move_id')) + payment.action_draft() + + payment.write({ + 'payment_type': 'transfer', + 'payment_method_id': self.payment_method_manual_out.id, + 'partner_id': False, + 'destination_journal_id': self.cash_journal_euro.id, + }) + + payment.post() + self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 2) + + all_moves = payment.move_line_ids.mapped('move_id') + move = all_moves.filtered(lambda m: m.journal_id == self.bank_journal_euro) + transfer_move = all_moves - move + self.assertEqual(transfer_move.journal_id, self.cash_journal_euro) + + self.assertEqual(name, move.name) + self.assertTrue(transfer_move.name) + self.assertNotEqual(name, transfer_move.name) diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py index bcae241be67..11114cf893b 100644 --- a/addons/account/tests/test_reconciliation_matching_rules.py +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -334,9 +334,9 @@ class TestReconciliationMatchingRules(AccountingTestCase): # Check first line has been well reconciled. self.assertRecordValues(self.bank_line_1.journal_entry_ids, [ - {'partner_id': self.partner_1.id, 'debit': 100.0, 'credit': 0.0, 'tax_ids': [self.tax21.id]}, - {'partner_id': self.partner_1.id, 'debit': 21.0, 'credit': 0.0, 'tax_line_id': self.tax21.id}, - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0}, + {'partner_id': self.partner_1.id, 'debit': 100.0, 'credit': 0.0, 'tax_ids': [self.tax21.id], 'tax_line_id': False}, + {'partner_id': self.partner_1.id, 'debit': 21.0, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax21.id}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0, 'tax_ids': [], 'tax_line_id': False}, ]) def test_reverted_move_matching(self): diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index 4269adb3fa9..894976e827d 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -30,7 +30,7 @@ <strong>Invoice Date:</strong> <p class="m-0" t-field="o.invoice_date"/> </div> - <div class="col-auto mw-100 mb-2" t-if="o.invoice_date_due and o.type == 'out_invoice' and o.state in ('open', 'in_payment', 'paid')" name="due_date"> + <div class="col-auto mw-100 mb-2" t-if="o.invoice_date_due and o.type == 'out_invoice' and o.state == 'posted'" name="due_date"> <strong>Due Date:</strong> <p class="m-0" t-field="o.invoice_date_due"/> </div> diff --git a/addons/account/views/report_payment_receipt_templates.xml b/addons/account/views/report_payment_receipt_templates.xml index 3da12afe644..19f692b229f 100644 --- a/addons/account/views/report_payment_receipt_templates.xml +++ b/addons/account/views/report_payment_receipt_templates.xml @@ -2,7 +2,7 @@ <odoo> <template id="report_payment_receipt_document"> <t t-call="web.external_layout"> - <t t-set="o" t-value="o.with_context({'lang': lang})"/> + <t t-set="o" t-value="o.with_context(lang=lang)"/> <div class="page"> <h3><strong>Payment Receipt: <span t-field="o.name"/></strong></h3> <div class="row mt64"> diff --git a/addons/delivery/models/stock_move.py b/addons/delivery/models/stock_move.py index 39d4707301a..c5b81f1cd51 100644 --- a/addons/delivery/models/stock_move.py +++ b/addons/delivery/models/stock_move.py @@ -8,7 +8,7 @@ from odoo import api, fields, models class StockMove(models.Model): _inherit = 'stock.move' - weight = fields.Float(compute='_cal_move_weight', digits='Stock Weight', store=True) + weight = fields.Float(compute='_cal_move_weight', digits='Stock Weight', store=True, compute_sudo=True) @api.depends('product_id', 'product_uom_qty', 'product_uom') def _cal_move_weight(self): diff --git a/addons/delivery/models/stock_picking.py b/addons/delivery/models/stock_picking.py index 2b2da5777b0..9b46344a9b1 100644 --- a/addons/delivery/models/stock_picking.py +++ b/addons/delivery/models/stock_picking.py @@ -79,7 +79,7 @@ class StockPicking(models.Model): delivery_type = fields.Selection(related='carrier_id.delivery_type', readonly=True) carrier_id = fields.Many2one("delivery.carrier", string="Carrier", check_company=True) volume = fields.Float(copy=False) - weight = fields.Float(compute='_cal_weight', digits='Stock Weight', store=True, help="Total weight of the products in the picking.") + weight = fields.Float(compute='_cal_weight', digits='Stock Weight', store=True, help="Total weight of the products in the picking.", compute_sudo=True) carrier_tracking_ref = fields.Char(string='Tracking Reference', copy=False) carrier_tracking_url = fields.Char(string='Tracking URL', compute='_compute_carrier_tracking_url') weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name', readonly=True, default=_get_default_weight_uom) diff --git a/addons/event_sale/models/event.py b/addons/event_sale/models/event.py index 0b9d5aa3513..ae3f84a4b13 100644 --- a/addons/event_sale/models/event.py +++ b/addons/event_sale/models/event.py @@ -96,7 +96,7 @@ class EventTicket(models.Model): def _compute_is_expired(self): for record in self: if record.deadline: - current_date = fields.Date.context_today(record.with_context({'tz': record.event_id.date_tz})) + current_date = fields.Date.context_today(record.with_context(tz=record.event_id.date_tz)) record.is_expired = record.deadline < current_date else: record.is_expired = False diff --git a/addons/l10n_de/static/src/scss/report_din5008.scss b/addons/l10n_de/static/src/scss/report_din5008.scss index cdecd43eda7..801ef16637c 100644 --- a/addons/l10n_de/static/src/scss/report_din5008.scss +++ b/addons/l10n_de/static/src/scss/report_din5008.scss @@ -21,6 +21,7 @@ top: 17.7mm; width: 85mm; height: 27.3mm; + line-height: 1.15; overflow-y: hidden; word-break: break-word; float: left; diff --git a/addons/l10n_fr/data/account_tax_data.xml b/addons/l10n_fr/data/account_tax_data.xml index f8938c2f145..3f72c43b63e 100644 --- a/addons/l10n_fr/data/account_tax_data.xml +++ b/addons/l10n_fr/data/account_tax_data.xml @@ -1683,7 +1683,7 @@ <record model="account.tax.template" id="tva_export_0"> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> <field name="name">TVA 0% export (vente)</field> - <field name="description">TVA 0% Import</field> + <field name="description">TVA 0% Export</field> <field name="amount" eval="0.00"/> <field name="amount_type">percent</field> <field name="sequence" eval="10"/> diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py index 3fbdd494a5e..0e7b9d3a156 100644 --- a/addons/l10n_multilang/models/l10n_multilang.py +++ b/addons/l10n_multilang/models/l10n_multilang.py @@ -120,6 +120,9 @@ class AccountChartTemplate(models.Model): in_xml_ids = {xml_id.name: xml_id for xml_id in in_xml_ids} for name, xml_id in expected_in_xml_id_names.items(): + # ignore nonconforming customized data + if name not in in_xml_ids: + continue in_records += self.env[model + '.template'].browse(in_xml_ids[name].res_id) out_records += self.env[model].browse(xml_id.res_id) diff --git a/addons/lunch/static/tests/lunch_kanban_tests.js b/addons/lunch/static/tests/lunch_kanban_tests.js index 80e7747cbdd..b16dee08915 100644 --- a/addons/lunch/static/tests/lunch_kanban_tests.js +++ b/addons/lunch/static/tests/lunch_kanban_tests.js @@ -261,6 +261,55 @@ QUnit.module('LunchKanbanView', { kanban.destroy(); }); + QUnit.test('search panel domain location false: fetch products in all locations', async function (assert) { + assert.expect(10); + const regularInfos = _.extend({}, this.regularInfos); + + const kanban = await createLunchKanbanView({ + View: LunchKanbanView, + model: 'product', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div><field name="name"/></div> + </t> + </templates> + </kanban> + `, + mockRPC: function (route, args) { + assert.step(route); + + if (route.startsWith('/lunch')) { + return mockLunchRPC({ + infos: regularInfos, + userLocation: false, + }).apply(this, arguments); + } + if (args.method === 'search_panel_select_multi_range') { + assert.deepEqual(args.kwargs.search_domain, [], + 'The domain should not exist since the location is false.'); + } + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [], + 'The domain for fetching actual data should be correct'); + } + return this._super.apply(this, arguments); + } + }); + assert.verifySteps([ + '/lunch/user_location_get', + '/web/dataset/call_kw/product/search_panel_select_multi_range', + '/web/dataset/call_kw/product/search_panel_select_multi_range', + '/web/dataset/search_read', + '/lunch/infos', + '/web/dataset/call_kw/ir.model.data/xmlid_to_res_id', + ]) + + kanban.destroy(); + }); + QUnit.test('non-empty cart', async function (assert) { assert.expect(17); diff --git a/addons/mail/models/mail_alias.py b/addons/mail/models/mail_alias.py index 15447921645..4cc8234ba23 100644 --- a/addons/mail/models/mail_alias.py +++ b/addons/mail/models/mail_alias.py @@ -240,7 +240,7 @@ class AliasMixin(models.AbstractModel): for record in child_model.search([('alias_id', '=', False)]): # create the alias, and link it to the current record alias = alias_model.create(record.get_alias_values()) - record.with_context({'mail_notrack': True}).alias_id = alias + record.with_context(mail_notrack=True).alias_id = alias _logger.info('Mail alias created for %s %s (id %s)', record._name, record.display_name, record.id) diff --git a/addons/mail_bot/models/mail_bot.py b/addons/mail_bot/models/mail_bot.py index 2e0ab860255..10686b14ba3 100644 --- a/addons/mail_bot/models/mail_bot.py +++ b/addons/mail_bot/models/mail_bot.py @@ -30,7 +30,7 @@ class MailBot(models.AbstractModel): if answer: message_type = values.get('message_type', 'comment') subtype_id = values.get('subtype_id', self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')) - record.with_context({"mail_create_nosubscribe": True}).sudo().message_post(body=answer, author_id=odoobot_id, message_type=message_type, subtype_id=subtype_id) + record.with_context(mail_create_nosubscribe=True).sudo().message_post(body=answer, author_id=odoobot_id, message_type=message_type, subtype_id=subtype_id) def _get_answer(self, record, body, values, command=False): # onboarding diff --git a/addons/mail_bot/models/mail_channel.py b/addons/mail_bot/models/mail_channel.py index efada26b88a..0fc6c6f056b 100644 --- a/addons/mail_bot/models/mail_channel.py +++ b/addons/mail_bot/models/mail_channel.py @@ -16,7 +16,7 @@ class Channel(models.Model): if self.env.user.odoobot_state == 'not_initialized': partner = self.env.user.partner_id odoobot_id = self.env['ir.model.data'].xmlid_to_res_id("base.partner_root") - channel = self.with_context({"mail_create_nosubscribe": True}).create({ + channel = self.with_context(mail_create_nosubscribe=True).create({ 'channel_partner_ids': [(4, partner.id), (4, odoobot_id)], 'public': 'private', 'channel_type': 'chat', diff --git a/addons/mrp_account/models/product.py b/addons/mrp_account/models/product.py index 9af9b84d9d6..da15e757cb3 100644 --- a/addons/mrp_account/models/product.py +++ b/addons/mrp_account/models/product.py @@ -11,11 +11,13 @@ class ProductTemplate(models.Model): def action_bom_cost(self): templates = self.filtered(lambda t: t.product_variant_count == 1 and t.bom_count > 0) - return templates.mapped('product_variant_id').action_bom_cost() + if templates: + return templates.mapped('product_variant_id').action_bom_cost() def button_bom_cost(self): templates = self.filtered(lambda t: t.product_variant_count == 1 and t.bom_count > 0) - return templates.mapped('product_variant_id').button_bom_cost() + if templates: + return templates.mapped('product_variant_id').button_bom_cost() class ProductProduct(models.Model): @@ -50,6 +52,8 @@ class ProductProduct(models.Model): def _compute_bom_price(self, bom, boms_to_recompute=False): self.ensure_one() + if not bom: + return 0 if not boms_to_recompute: boms_to_recompute = [] total = 0 diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index 2d29278cd7d..be081a4e34f 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -83,7 +83,7 @@ class PaymentProcessing(http.Controller): # populate the returned dictionnary with the transactions data for tx in payment_transaction_ids: message_to_display = tx.acquirer_id[tx.state + '_msg'] if tx.state in ['done', 'pending', 'cancel'] else None - result['transactions'].append({ + tx_info = { 'reference': tx.reference, 'state': tx.state, 'return_url': tx.return_url, @@ -93,7 +93,9 @@ class PaymentProcessing(http.Controller): 'amount': tx.amount, 'currency': tx.currency_id.name, 'acquirer_provider': tx.acquirer_id.provider, - }) + } + tx_info.update(tx._get_processing_info()) + result['transactions'].append(tx_info) tx_to_process = payment_transaction_ids.filtered(lambda x: x.state == 'done' and x.is_processed is False) try: diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index f9d703972d8..d23658ffa5d 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -644,6 +644,10 @@ class PaymentTransaction(models.Model): transactions = self.filtered(lambda t: t.state != 'draft') return transactions and transactions[0] or transactions + def _get_processing_info(self): + """ Extensible method for providers if they need specific fields/info regarding a tx in the payment processing page. """ + return dict() + def _get_payment_transaction_sent_message(self): self.ensure_one() if self.payment_token_id: diff --git a/addons/payment/static/src/js/payment_processing.js b/addons/payment/static/src/js/payment_processing.js index 53b688be06a..f52d34f6a8c 100644 --- a/addons/payment/static/src/js/payment_processing.js +++ b/addons/payment/static/src/js/payment_processing.js @@ -94,7 +94,7 @@ odoo.define('payment.processing', function (require) { return nbTx; } // if there's only one tx to manage - if(countTxInState(['tx_done', 'tx_error']) === 1) { + if(countTxInState(['tx_done', 'tx_error', 'tx_pending']) === 1) { var tx = render_values['tx_done'][0] || render_values['tx_error'][0]; if (tx) { window.location = tx.return_url; diff --git a/addons/payment_paypal/controllers/main.py b/addons/payment_paypal/controllers/main.py index f347973ad02..dc2ea54e81c 100644 --- a/addons/payment_paypal/controllers/main.py +++ b/addons/payment_paypal/controllers/main.py @@ -76,14 +76,16 @@ class PaypalController(http.Controller): if resp in ['VERIFIED', 'SUCCESS']: _logger.info('Paypal: validated data') res = request.env['payment.transaction'].sudo().form_feedback(post, 'paypal') - if not res: + if not res and tx: tx.sudo()._set_transaction_error('Validation error occured. Please contact your administrator.') elif resp in ['INVALID', 'FAIL']: _logger.warning('Paypal: answered INVALID/FAIL on data verification') - tx.sudo()._set_transaction_error('Invalid response from Paypal. Please contact your administrator.') + if tx: + tx.sudo()._set_transaction_error('Invalid response from Paypal. Please contact your administrator.') else: _logger.warning('Paypal: unrecognized paypal answer, received %s instead of VERIFIED/SUCCESS or INVALID/FAIL (validation: %s)' % (resp, 'PDT' if pdt_request else 'IPN/DPN')) - tx.sudo()._set_transaction_error('Unrecognized error from Paypal. Please contact your administrator.') + if tx: + tx.sudo()._set_transaction_error('Unrecognized error from Paypal. Please contact your administrator.') return res @http.route('/payment/paypal/ipn/', type='http', auth='public', methods=['POST'], csrf=False) diff --git a/addons/payment_payumoney/models/payment.py b/addons/payment_payumoney/models/payment.py index 202e4bd1d72..b8f60673031 100644 --- a/addons/payment_payumoney/models/payment.py +++ b/addons/payment_payumoney/models/payment.py @@ -26,7 +26,7 @@ class PaymentAcquirerPayumoney(models.Model): if environment == 'prod': return {'payumoney_form_url': 'https://secure.payu.in/_payment'} else: - return {'payumoney_form_url': 'https://test.payu.in/_payment'} + return {'payumoney_form_url': 'https://sandboxsecure.payu.in/_payment'} def _payumoney_generate_sign(self, inout, values): """ Generate the shasign for incoming or outgoing communications. diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index 8a6eb09624c..54932140049 100644 --- a/addons/point_of_sale/static/src/css/pos.css +++ b/addons/point_of_sale/static/src/css/pos.css @@ -357,6 +357,9 @@ td { height:100%; display: -webkit-flex; display: flex; + overflow : hidden; + overflow-x: auto; + -webkit-overflow-scrolling: touch; } .pos .pos-rightheader > * { border-right: 1px solid #292929; @@ -437,8 +440,6 @@ td { display: flex; -webkit-flex: 1; flex: 1; - max-width: -moz-available; - max-width: -webkit-fill-available; } .pos .orders { display: -webkit-flex; @@ -446,8 +447,6 @@ td { vertical-align: top; margin-left: 0px; overflow: hidden; - overflow-x: auto; - -webkit-overflow-scrolling: touch; } /* c) The session buttons */ diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 011bcce36fc..0e5fb975786 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -1757,7 +1757,7 @@ var ReceiptScreenWidget = ScreenWidget.extend({ var invoiced = self.pos.push_and_invoice_order(order); self.invoicing = true; - invoiced.fail(self._handleFailedPushForInvoice.bind(self, order, true)); // refresh + invoiced.catch(self._handleFailedPushForInvoice.bind(self, order, true)); // refresh invoiced.then(function(){ self.invoicing = false; diff --git a/addons/product/models/product.py b/addons/product/models/product.py index fe3fac09f76..6e0fdaa206e 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -710,6 +710,7 @@ class SupplierInfo(models.Model): 'res.partner', 'Vendor', ondelete='cascade', required=True, help="Vendor of this product", check_company=True) + active = fields.Boolean(related='name.active', readonly=True) product_name = fields.Char( 'Vendor Product Name', help="This vendor's product name will be used when printing a request for quotation. Keep empty to use the internal one.") diff --git a/addons/product/report/product_pricelist_templates.xml b/addons/product/report/product_pricelist_templates.xml index 0f7d54db714..41ba6092f31 100644 --- a/addons/product/report/product_pricelist_templates.xml +++ b/addons/product/report/product_pricelist_templates.xml @@ -53,9 +53,9 @@ </td> <t t-foreach="data['quantities']" t-as="quantity"> <td><strong t-esc="categ_data['prices'][product.id][quantity]" - t-esc-options="{ - 'widget': 'monetary', - 'display_currency': data['pricelist'].currency_id}"/> + t-options="{ + 'widget': 'float', + 'decimal_precision': 'Product Price'}"/> </td> </t> </tr> diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index d6bb0f9ce30..f081041715e 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -457,7 +457,7 @@ class SaleOrder(models.Model): self.ensure_one() journal = self.env['account.move'].with_context(force_company=self.company_id.id, default_type='out_invoice')._get_default_journal() if not journal: - raise UserError(_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name, self.company_id.id)) + raise UserError(_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name, self.company_id.id)) invoice_vals = { 'ref': self.client_order_ref or '', diff --git a/addons/sale_mrp/tests/test_sale_mrp_procurement.py b/addons/sale_mrp/tests/test_sale_mrp_procurement.py index 25753211eca..e9c7a52ae62 100644 --- a/addons/sale_mrp/tests/test_sale_mrp_procurement.py +++ b/addons/sale_mrp/tests/test_sale_mrp_procurement.py @@ -69,3 +69,105 @@ class TestSaleMrpProcurement(TransactionCase): # I verify that a manufacturing order has been generated, and that its name and reference are correct mo = self.env['mrp.production'].search([('origin', 'like', sale_order_so0.name)], limit=1) self.assertTrue(mo, 'Manufacturing order has not been generated') + + def test_sale_mrp_pickings(self): + """ Test sale of multiple mrp products in MTO + to avoid generating multiple deliveries + to the customer location + """ + + # Create warehouse + self.customer_location = self.env['ir.model.data'].xmlid_to_res_id('stock.stock_location_customers') + warehouse_form = Form(self.env['stock.warehouse']) + warehouse_form.name = 'Test Warehouse' + warehouse_form.code = 'TWH' + self.warehouse = warehouse_form.save() + + self.uom_unit = self.env.ref('uom.product_uom_unit') + + # Create raw product for manufactured product + product_form = Form(self.env['product.product']) + product_form.name = 'Raw Stick' + product_form.type = 'product' + product_form.uom_id = self.uom_unit + product_form.uom_po_id = self.uom_unit + self.raw_product = product_form.save() + + # Create manufactured product + product_form = Form(self.env['product.product']) + product_form.name = 'Stick' + product_form.uom_id = self.uom_unit + product_form.uom_po_id = self.uom_unit + product_form.type = 'product' + product_form.route_ids.clear() + product_form.route_ids.add(self.warehouse.manufacture_pull_id.route_id) + product_form.route_ids.add(self.warehouse.mto_pull_id.route_id) + self.finished_product = product_form.save() + + # Create manifactured product which uses another manifactured + product_form = Form(self.env['product.product']) + product_form.name = 'Arrow' + product_form.type = 'product' + product_form.route_ids.clear() + product_form.route_ids.add(self.warehouse.manufacture_pull_id.route_id) + product_form.route_ids.add(self.warehouse.mto_pull_id.route_id) + self.complex_product = product_form.save() + + ## Create raw product for manufactured product + product_form = Form(self.env['product.product']) + product_form.name = 'Raw Iron' + product_form.type = 'product' + product_form.uom_id = self.uom_unit + product_form.uom_po_id = self.uom_unit + self.raw_product_2 = product_form.save() + + # Create bom for manufactured product + bom_product_form = Form(self.env['mrp.bom']) + bom_product_form.product_id = self.finished_product + bom_product_form.product_tmpl_id = self.finished_product.product_tmpl_id + bom_product_form.product_qty = 1.0 + bom_product_form.type = 'normal' + with bom_product_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.raw_product + bom_line.product_qty = 2.0 + + self.bom = bom_product_form.save() + + ## Create bom for manufactured product + bom_product_form = Form(self.env['mrp.bom']) + bom_product_form.product_id = self.complex_product + bom_product_form.product_tmpl_id = self.complex_product.product_tmpl_id + with bom_product_form.bom_line_ids.new() as line: + line.product_id = self.finished_product + line.product_qty = 1.0 + with bom_product_form.bom_line_ids.new() as line: + line.product_id = self.raw_product_2 + line.product_qty = 1.0 + + self.complex_bom = bom_product_form.save() + + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm_sam' + + so_form = Form(self.env['sale.order']) + so_form.partner_id = self.env.ref('base.res_partner_4') + with so_form.order_line.new() as line: + line.product_id = self.complex_product + line.price_unit = 1 + line.product_uom_qty = 1 + with so_form.order_line.new() as line: + line.product_id = self.finished_product + line.price_unit = 1 + line.product_uom_qty = 1 + sale_order_so0 = so_form.save() + + sale_order_so0.action_confirm() + + pickings = sale_order_so0.picking_ids + + # One delivery... + self.assertEqual(len(pickings), 1) + + # ...with two products + move_lines = pickings[0].move_lines + self.assertEqual(len(move_lines), 2) diff --git a/addons/snailmail/models/snailmail_letter.py b/addons/snailmail/models/snailmail_letter.py index 96b1a91e4c4..b74e2a42abc 100644 --- a/addons/snailmail/models/snailmail_letter.py +++ b/addons/snailmail/models/snailmail_letter.py @@ -217,7 +217,7 @@ class SnailmailLetter(models.Model): else: # adding the web logo from the company for future possible customization document.update({ - 'company_logo': letter.company_id.logo_web.decode('utf-8'), + 'company_logo': letter.company_id.logo_web and letter.company_id.logo_web.decode('utf-8') or False, }) attachment = letter._fetch_attachment() if attachment: diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index a22a29b40f8..ec78b56eca9 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -49,7 +49,7 @@ class StockMove(models.Model): description_picking = fields.Text('Description of Picking') product_qty = fields.Float( 'Real Quantity', compute='_compute_product_qty', inverse='_set_product_qty', - digits=0, store=True, + digits=0, store=True, compute_sudo=True, help='Quantity in the default UoM of the product') product_uom_qty = fields.Float( 'Initial Demand', diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 36acbf4d000..eaeca84b039 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -741,7 +741,10 @@ class Warehouse(models.Model): if not change_to_multiple: # If single delivery we should create the necessary MTO rules for the resupply routings = [self.Routing(self.lot_stock_id, location, self.out_type_id, 'pull') for location in rules.mapped('location_id')] - mto_rule_vals = self._get_rule_values(routings) + mto_vals = self._get_global_route_rules_values().get('mto_pull_id') + values = mto_vals['create_values'] + mto_rule_vals = self._get_rule_values(routings, values, name_suffix='MTO') + for mto_rule_val in mto_rule_vals: Rule.create(mto_rule_val) else: diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index a2f4917b699..c1d01022c68 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -2550,11 +2550,28 @@ var FieldProgressBar = AbstractField.extend({ if (this.recordData[this.nodeOptions.current_value]) { this.value = this.recordData[this.nodeOptions.current_value]; } - this.max_value = this.recordData[this.nodeOptions.max_value] || 100; + + // The few next lines determine if the widget can write on the record or not + this.editable_readonly = !!this.nodeOptions.editable_readonly; + // "hard" readonly this.readonly = this.nodeOptions.readonly || !this.nodeOptions.editable; - this.edit_max_value = this.nodeOptions.edit_max_value || false; + + this.canWrite = !this.readonly && ( + this.mode === 'edit' || + (this.editable_readonly && this.mode === 'readonly') || + (this.viewType === 'kanban') // Keep behavior before commit + ); + + // Boolean to toggle if we edit the numerator (value) or the denominator (max_value) + this.edit_max_value = !!this.nodeOptions.edit_max_value; + this.max_value = this.recordData[this.nodeOptions.max_value] || 100; + this.title = _t(this.attrs.title || this.nodeOptions.title) || ''; - this.edit_on_click = !this.nodeOptions.edit_max_value || false; + + // Ability to edit the field through the bar + // /!\ this feature is disabled + this.enableBarAsInput = false; + this.edit_on_click = this.enableBarAsInput && this.mode === 'readonly' && !this.edit_max_value; this.write_mode = false; }, @@ -2562,19 +2579,19 @@ var FieldProgressBar = AbstractField.extend({ var self = this; this._render_value(); - if (!this.readonly) { + if (this.canWrite) { if (this.edit_on_click) { this.$el.on('click', '.o_progress', function (e) { var $target = $(e.currentTarget); - self.value = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value); + var numValue = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value); + self.on_update(numValue); self._render_value(); - self.on_update(self.value); }); } else { this.$el.on('click', function () { if (!self.write_mode) { var $input = $('<input>', {type: 'text', class: 'o_progressbar_value o_input'}); - $input.on('blur', _.bind(self.on_change_input, self)); + $input.on('blur', self.on_change_input.bind(self)); self.$('.o_progressbar_value').replaceWith($input); self.write_mode = true; self._render_value(); @@ -2584,24 +2601,25 @@ var FieldProgressBar = AbstractField.extend({ } return this._super(); }, + /** + * Updates the widget with value + * + * @param {Number} value + */ on_update: function (value) { - if (!isNaN(value)) { - if (this.edit_max_value) { - try { - this.max_value = this._parseValue(value); - this._isValid = true; - } catch (e) { - this._isValid = false; - } - var changes = {}; - changes[this.nodeOptions.max_value] = this.max_value; - this.trigger_up('field_changed', { - dataPointID: this.dataPointID, - changes: changes, - }); - } else { - this._setValue(value); - } + if (this.edit_max_value) { + this.max_value = value; + this._isValid = true; + var changes = {}; + changes[this.nodeOptions.max_value] = this.max_value; + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: changes, + }); + } else { + // _setValues accepts string and will parse it + var formattedValue = this._formatValue(value); + this._setValue(formattedValue); } }, on_change_input: function (e) { @@ -2609,29 +2627,42 @@ var FieldProgressBar = AbstractField.extend({ if (e.type === 'change' && !$input.is(':focus')) { return; } - if (isNaN($input.val())) { - this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid.")); - } else { - if (e.type === 'input') { - this._render_value($input.val()); - if (parseFloat($input.val()) === 0) { + + var parsedValue; + try { + // Cover all numbers with parseFloat + parsedValue = field_utils.parse.float($input.val()); + } catch (error) { + this.do_warn(_t("Wrong value entered!"), _t("Only Integer or Float Value should be valid.")); + } + + if (parsedValue !== undefined) { + if (e.type === 'input') { // ensure what has just been typed in the input is a number + // returns NaN if not a number + this._render_value(parsedValue); + if (parsedValue === 0) { $input.select(); } - } else { + } else { // Implicit type === 'blur': we commit the value if (this.edit_max_value) { - this.max_value = $(e.target).val(); - } else { - this.value = $(e.target).val() || 0; + parsedValue = parsedValue || 100; } + var $div = $('<div>', {class: 'o_progressbar_value'}); this.$('.o_progressbar_value').replaceWith($div); this.write_mode = false; + this.on_update(parsedValue); this._render_value(); - this.on_update(this.edit_max_value ? this.max_value : this.value); } } }, + /** + * Renders the value + * + * @private + * @param {Number} v + */ _render_value: function (v) { var value = this.value; var max_value = this.max_value; diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index cb83301c14d..43a485fb009 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -19,6 +19,8 @@ var config = require('web.config'); var core = require('web.core'); var mvc = require('web.mvc'); +var session = require('web.session'); + var QWeb = core.qweb; var AbstractController = mvc.Controller.extend(ActionMixin, { @@ -519,6 +521,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { this.dp.add(this._rpc({ model: data.model, method: data.method, + context: session.user_context, })).then(function (action) { if (action !== undefined) { self.do_action(action, options); 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 3f58fcdce2a..42b156beed7 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 @@ -9,6 +9,8 @@ var Widget = require('web.Widget'); var SearchBar = Widget.extend({ template: 'SearchView.SearchBar', events: _.extend({}, Widget.prototype.events, { + 'compositionend .o_searchview_input': '_onCompositionendInput', + 'compositionstart .o_searchview_input': '_onCompositionstartInput', 'keydown': '_onKeydown', }), /** @@ -32,6 +34,7 @@ var SearchBar = Widget.extend({ this.autoCompleteSources = []; this.searchFacets = []; + this._isInputComposing = false; }, /** * @override @@ -200,10 +203,27 @@ var SearchBar = Widget.extend({ } }, /** + * @rivate + * @param {CompositionEvent} ev + */ + _onCompositionendInput: function () { + this._isInputComposing = false; + }, + /** + * @rivate + * @param {CompositionEvent} ev + */ + _onCompositionstartInput: function () { + this._isInputComposing = true; + }, + /** * @private * @param {KeyEvent} e */ _onKeydown: function (e) { + if (this._isInputComposing) { + return; + } switch(e.which) { case $.ui.keyCode.LEFT: this._focusPreceding(); diff --git a/addons/web/static/src/js/views/control_panel/search/search_facet.js b/addons/web/static/src/js/views/control_panel/search/search_facet.js index 917d42b7e41..22d957c030b 100644 --- a/addons/web/static/src/js/views/control_panel/search/search_facet.js +++ b/addons/web/static/src/js/views/control_panel/search/search_facet.js @@ -10,6 +10,8 @@ var SearchFacet = Widget.extend({ template: 'SearchView.SearchFacet', events: _.extend({}, Widget.prototype.events, { 'click .o_facet_remove': '_onFacetRemove', + 'compositionend': '_onCompositionend', + 'compositionstart': '_onCompositionstart', 'keydown': '_onKeydown', }), /** @@ -26,6 +28,7 @@ var SearchFacet = Widget.extend({ }); this.separator = this._getSeparator(); this.icon = this._getIcon(); + this._isComposing = false; }, //-------------------------------------------------------------------------- @@ -130,6 +133,20 @@ var SearchFacet = Widget.extend({ /** * @private + * @param {CompositionEvent} ev + */ + _onCompositionend: function (ev) { + this._isComposing = false; + }, + /** + * @private + * @param {CompositionEvent} ev + */ + _onCompositionstart: function (ev) { + this._isComposing = true; + }, + /** + * @private */ _onFacetRemove: function () { this.trigger_up('facet_removed', {group: this.facet}); @@ -139,6 +156,9 @@ var SearchFacet = Widget.extend({ * @param {KeyboardEvent} ev */ _onKeydown: function (ev) { + if (this._isComposing) { + return; + } switch (ev.which) { case $.ui.keyCode.BACKSPACE: this.trigger_up('facet_removed', {group: this.facet}); diff --git a/addons/web/static/src/js/views/search_panel.js b/addons/web/static/src/js/views/search_panel.js index d222a5642cf..4f8c42325db 100644 --- a/addons/web/static/src/js/views/search_panel.js +++ b/addons/web/static/src/js/views/search_panel.js @@ -288,8 +288,9 @@ var SearchPanel = Widget.extend({ }); _.map(values, function (value) { var value = category.values[value.id]; - if (value.parentId) { - category.values[value.parentId].childrenIds.push(value.id); + var parentCategoryId = value.parentId; + if (parentCategoryId && parentCategoryId in category.values) { + category.values[parentCategoryId].childrenIds.push(value.id); } }); category.rootIds = _.filter(_.map(values, function (value) { diff --git a/addons/web/static/src/js/widgets/auto_complete.js b/addons/web/static/src/js/widgets/auto_complete.js index 1bb00be8d38..30c38690d17 100644 --- a/addons/web/static/src/js/widgets/auto_complete.js +++ b/addons/web/static/src/js/widgets/auto_complete.js @@ -29,16 +29,23 @@ return Widget.extend({ this.searching = true; this.search_string = ''; this.current_search = null; + this._isInputComposing = false; }, start: function () { var self = this; + this.$input.on('compositionend', function (ev) { + self._isInputComposing = false; + }); + this.$input.on('compositionstart', function (ev) { + self._isInputComposing = true; + }); this.$input.on('keyup', function (ev) { - if (ev.which === $.ui.keyCode.RIGHT) { + if (ev.which === $.ui.keyCode.RIGHT && !self._isInputComposing) { self.searching = true; ev.preventDefault(); return; } - if (ev.which === $.ui.keyCode.ENTER) { + if (ev.which === $.ui.keyCode.ENTER && !self._isInputComposing) { if (self.search_string.length) { self.select_item(ev); } @@ -68,6 +75,9 @@ return Widget.extend({ } }); this.$input.on('keydown', function (ev) { + if (self._isInputComposing) { + return; + } switch (ev.which) { case $.ui.keyCode.ENTER: diff --git a/addons/web/static/src/scss/banner.scss b/addons/web/static/src/scss/banner.scss index d7237203897..be011d70698 100644 --- a/addons/web/static/src/scss/banner.scss +++ b/addons/web/static/src/scss/banner.scss @@ -1,8 +1,9 @@ .o_has_banner { .o_view_nocontent { top: 30%; - @media screen and (max-height: map-get($grid-breakpoints, 'md')) { + @include media-breakpoint-down(sm) { position: relative; + margin: auto; } } } diff --git a/addons/web/static/src/scss/modal.scss b/addons/web/static/src/scss/modal.scss index f33aec59431..e7243042d35 100644 --- a/addons/web/static/src/scss/modal.scss +++ b/addons/web/static/src/scss/modal.scss @@ -96,6 +96,11 @@ } } +body.modal-open { + // Allow vertical scrolling in modals on iOS + -webkit-overflow-scrolling: touch; +} + // Temporary fix for modals which are not instantiated thanks to the Dialog // JS classes (deprecated case) (see bootstrap_overridden.scss) + Frontend. // diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 8abe04107ff..aa86ff925e4 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -11,6 +11,7 @@ var KanbanView = require('web.KanbanView'); var ListView = require('web.ListView'); var session = require('web.session'); var testUtils = require('web.test_utils'); +var testUtilsDom = require('web.test_utils_dom'); var field_registry = require('web.field_registry'); var createView = testUtils.createView; @@ -5955,7 +5956,7 @@ QUnit.module('basic_fields', { QUnit.module('FieldProgressBar'); QUnit.test('Field ProgressBar: max_value should update', async function (assert) { - assert.expect(2); + assert.expect(3); this.data.partner.records = this.data.partner.records.slice(0,1); this.data.partner.records[0].qux = 2; @@ -5980,6 +5981,15 @@ QUnit.module('basic_fields', { viewOptions: { mode: 'edit', }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual( + args.args[1], + {int_field: 999, qux: 5, display_name: 'new name'}, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } }); assert.strictEqual(form.$('.o_progressbar_value').text(), '10 / 2', @@ -5991,6 +6001,435 @@ QUnit.module('basic_fields', { assert.strictEqual(form.$('.o_progressbar_value').text(), '999 / 5', 'The value of the progress bar should be correct after the update'); + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: value should not update in readonly mode when sliding the bar', async function (assert) { + assert.expect(4); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + assert.step(route); + return this._super.apply(this, arguments); + } + }); + var $view = $('#qunit-fixture').contents(); + $view.prependTo('body'); // => select with click position + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct') + + var $progressBarEl = form.$('.o_progress'); + var top = $progressBarEl.offset().top + 5; + var left = $progressBarEl.offset().left + 5; + try { + testUtils.triggerPositionalMouseEvent(left, top, "click"); + } catch (e) { + form.destroy(); + $view.remove(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'New value should be different than initial after click'); + + assert.verifySteps(["/web/dataset/call_kw/partner/read"]); + + form.destroy(); + $view.remove(); + }); + + QUnit.test('Field ProgressBar: value should not update in edit mode when sliding the bar', async function (assert) { + assert.expect(6); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + assert.step(route); + return this._super.apply(this, arguments); + } + }); + var $view = $('#qunit-fixture').contents(); + $view.prependTo('body'); // => select with click position + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct') + + var $progressBarEl = form.$('.o_progress'); + var top = $progressBarEl.offset().top + 5; + var left = $progressBarEl.offset().left + 5; + try { + testUtils.triggerPositionalMouseEvent(left, top, "click"); + } catch (e) { + form.destroy(); + $view.remove(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + assert.strictEqual(form.$('.o_progressbar_value.o_input').val(), "99", + 'Value of input is not changed'); + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'New value should be different than initial after click'); + + assert.verifySteps(["/web/dataset/call_kw/partner/read"]); + + form.destroy(); + $view.remove(); + }); + + QUnit.test('Field ProgressBar: value should update in edit mode when typing in input', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].int_field, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']); + + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '69%', + 'New value should be different than initial after click'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: value should update in edit mode when typing in input with field max value', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\'}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].int_field, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']); + + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '69 / 0', + 'New value should be different than initial after click'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: max value should update in edit mode when typing in input with field max value', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].qux, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), "0.44444", 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']); + + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 69', + 'New value should be different than initial after click'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: Standard readonly mode is readonly', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true}" />' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + assert.step(route); + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + assert.containsNone(form, '.o_progressbar_value.o_input', 'no input in readonly mode'); + + assert.verifySteps(["/web/dataset/call_kw/partner/read"]); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: max value should update in readonly mode with right parameter when typing in input with field max value', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true, \'editable_readonly\': true}" />' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].qux, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), "0.44444", 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 69', + 'New value should be different than initial after changing it'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: value should update in readonly mode with right parameter when typing in input with field value', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true, \'editable_readonly\': true}" />' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].int_field, 69, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), "99", 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '69.6', ['input', 'blur']); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '69%', + 'New value should be different than initial after changing it'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: write float instead of int works, in locale', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + translateParameters: { + thousands_sep: "#", + decimal_point: ":", + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].int_field, 1037, + 'New value of progress bar saved'); + } + return this._super.apply(this, arguments); + } + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, '1#037:9', ['input', 'blur']); + + await testUtilsDom.click(form.$buttons.find('.o_form_button_save')); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '1k%', + 'New value should be different than initial after click'); + + form.destroy(); + }); + + QUnit.test('Field ProgressBar: write gibbrish instead of int throws warning', async function (assert) { + assert.expect(5); + this.data.partner.records[0].int_field = 99; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + interceptsPropagate: { + call_service: function (ev) { + if (ev.data.service === 'notification') { + assert.strictEqual(ev.data.method, 'notify'); + assert.strictEqual(ev.data.args[0].title, 'Wrong value entered!'); + } + } + }, + }); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '99%', + 'Initial value should be correct'); + + await testUtilsDom.click(form.$('.o_progress')); + + var $valInput = form.$('.o_progressbar_value.o_input'); + assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct'); + + await testUtils.fields.editAndTrigger($valInput, 'trente sept virgule neuf', ['input']); + form.destroy(); }); diff --git a/addons/web/static/tests/views/search_panel_tests.js b/addons/web/static/tests/views/search_panel_tests.js index 233fa88d355..632158d9b1e 100644 --- a/addons/web/static/tests/views/search_panel_tests.js +++ b/addons/web/static/tests/views/search_panel_tests.js @@ -571,6 +571,64 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('category has been archived', async function (assert) { + assert.expect(2); + + this.data.company.fields.active = {type: 'boolean', string: 'Archived'}; + this.data.company.records = [ + { + name: 'Company 5', + id: 5, + active: true, + }, { + name: 'child of 5 archived', + parent_id: 5, + id: 666, + active: false, + }, { + name: 'child of 666', + parent_id: 666, + id: 777, + active: true, + } + ]; + + var kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="foo"/> + </div> + </t> + </templates> + </kanban>`, + archs: { + 'partner,false,search': `<search><searchpanel><field name="company_id"/></searchpanel></search>`, + }, + mockRPC: async function (route, args) { + if (route === '/web/dataset/call_kw/partner/search_panel_select_range') { + var results = await this._super.apply(this, arguments); + results.values = results.values.filter(rec => rec.active !== false); + return Promise.resolve(results); + } + return this._super.apply(this, arguments); + }, + }); + + assert.containsN(kanban, '.o_search_panel_category_value', 2, + 'The number of categories should be 2: All and Company 5'); + + assert.containsNone(kanban, '.o_toggle_fold', + 'None of the categories should have children'); + + kanban.destroy(); + }); + QUnit.test('use two categories to refine search', async function (assert) { assert.expect(14); diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index c9571f59c51..b94c7108122 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -77,7 +77,16 @@ var SnippetEditor = Widget.extend({ return $clone; }, start: this._onDragAndDropStart.bind(this), - stop: this._onDragAndDropStop.bind(this), + stop: function () { + // Delay our stop handler so that some summernote handlers + // which occur on mouseup (and are themself delayed) are + // executed first (this prevents the library to crash + // because our stop handler may change the DOM). + var args = arguments; + setTimeout(function () { + self._onDragAndDropStop.apply(self, args); + }, 0); + }, }); } else { this.$('.o_move_handle').addClass('d-none'); 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 5b493dfef0d..eec85058ebc 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -700,6 +700,17 @@ registry.colorpicker = SnippetOption.extend({ this.$el.find('we-collapse').append($pt); } + + // TODO refactor in master + // The primary and secondary are hardcoded here (but marked as hidden) + // so they can be removed from snippets when selecting another color. + // Normally, the chosable colors do not contain them, which prevents + // them to be removed. Indeed, normally, the 'alpha' and 'beta' colors + // (which are the same) are displayed instead... but not for all themes. + var $colorpicker = this.$el.find('.colorpicker'); + $colorpicker.append($('<button/>', {'class': 'd-none', 'data-color': 'primary'})); + $colorpicker.append($('<button/>', {'class': 'd-none', 'data-color': 'secondary'})); + var classes = []; this.$el.find('.colorpicker button').each(function () { var $color = $(this); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js index a6a9fb675fd..c291a00a0c7 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -1139,7 +1139,7 @@ var VideoWidget = MediaWidget.extend({ $video.attr('src', vinMatch[0] + '/embed/simple'); videoType = 'vin'; } else if (vimMatch && vimMatch[3].length) { - $video.attr('src', '//player.vimeo.com/video/' + vimMatch[3] + autoplay); + $video.attr('src', '//player.vimeo.com/video/' + vimMatch[3] + autoplay.replace('mute', 'muted')); videoType = 'vim'; } else if (dmMatch && dmMatch[2].length) { var justId = dmMatch[2].replace('video/', ''); diff --git a/addons/website_google_map/controllers/main.py b/addons/website_google_map/controllers/main.py index 092fab26a03..afa7dd784f4 100644 --- a/addons/website_google_map/controllers/main.py +++ b/addons/website_google_map/controllers/main.py @@ -38,7 +38,7 @@ class GoogleMap(http.Controller): "counter": len(partners), "partners": [] } - for partner in partners.with_context({'show_address': True}): + for partner in partners.with_context(show_address=True): # TODO in master, do not use `escape` but `t-esc` in the qweb template. partner_data["partners"].append({ 'id': partner.id, diff --git a/addons/website_twitter/static/src/js/website.twitter.animation.js b/addons/website_twitter/static/src/js/website.twitter.animation.js index e0c54bcb9d7..3bea076d7bd 100644 --- a/addons/website_twitter/static/src/js/website.twitter.animation.js +++ b/addons/website_twitter/static/src/js/website.twitter.animation.js @@ -9,6 +9,7 @@ var qweb = core.qweb; publicWidget.registry.twitter = publicWidget.Widget.extend({ selector: '.twitter', xmlDependencies: ['/website_twitter/static/src/xml/website.twitter.xml'], + disabledInEditableMode: false, events: { 'mouseenter .wrap-row': '_onEnterRow', 'mouseleave .wrap-row': '_onLeaveRow', diff --git a/doc/reference/reports.rst b/doc/reference/reports.rst index ee36f9bfd27..f12efdcab07 100644 --- a/doc/reference/reports.rst +++ b/doc/reference/reports.rst @@ -153,7 +153,7 @@ For example, let's look at the Sale Order report from the Sale module:: <!-- Translatable template --> <template id="report_saleorder_document"> <!-- Re-browse of the record with the partner lang --> - <t t-set="doc" t-value="doc.with_context({'lang':doc.partner_id.lang})" /> + <t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)" /> <t t-call="web.external_layout"> <div class="page"> <div class="oe_structure"/> diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py index ee8944083b6..195f899172d 100644 --- a/odoo/addons/base/models/ir_mail_server.py +++ b/odoo/addons/base/models/ir_mail_server.py @@ -126,7 +126,7 @@ def encode_rfc2822_address_header(header_text): return formataddr((name, email)) except UnicodeEncodeError: _logger.warning(_('Failed to encode the address %s\n' - 'from mail header:\n%s') % addr, header_text) + 'from mail header:\n%s') % (addr, header_text)) return "" addresses = getaddresses([pycompat.to_text(ustr(header_text))]) diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py index 8f64560203c..2cc77269e27 100644 --- a/odoo/addons/base/models/ir_qweb_fields.py +++ b/odoo/addons/base/models/ir_qweb_fields.py @@ -601,7 +601,7 @@ class BarcodeConverter(models.AbstractModel): def get_available_options(self): options = super(BarcodeConverter, self).get_available_options() options.update( - type=dict(type='string', string=_('Barcode type'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'), + symbology=dict(type='string', string=_('Barcode symbology'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'), width=dict(type='integer', string=_('Width'), default_value=600), height=dict(type='integer', string=_('Height'), default_value=100), humanreadable=dict(type='integer', string=_('Human Readable'), default_value=0), @@ -610,9 +610,9 @@ class BarcodeConverter(models.AbstractModel): @api.model def value_to_html(self, value, options=None): - barcode_type = options.get('type', 'Code128') + barcode_symbology = options.get('symbology', 'Code128') barcode = self.env['ir.actions.report'].barcode( - barcode_type, + barcode_symbology, value, **{key: value for key, value in options.items() if key in ['width', 'height', 'humanreadable']}) return u'<img src="data:png;base64,%s">' % base64.b64encode(barcode).decode('ascii') diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index ea8a05811f3..66b0b2f3e49 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -400,10 +400,42 @@ class Users(models.Model): @api.constrains('groups_id') def _check_one_user_type(self): - for user in self: - if len(user.groups_id.filtered(lambda x: x.category_id.xml_id == 'base.module_category_user_type')) > 1: + """We check that no users are both portal and users (same with public). + This could typically happen because of implied groups. + """ + user_types_category = self.env.ref('base.module_category_user_type', raise_if_not_found=False) + user_types_groups = self.env['res.groups'].search( + [('category_id', '=', user_types_category.id)]) if user_types_category else False + if user_types_groups: # needed at install + if self._has_multiple_groups(user_types_groups.ids): raise ValidationError(_('The user cannot have more than one user types.')) + def _has_multiple_groups(self, group_ids): + """The method is not fast if the list of ids is very long; + so we rather check all users than limit to the size of the group + :param group_ids: list of group ids + :return: boolean: is there at least a user in at least 2 of the provided groups + """ + if group_ids: + args = [tuple(group_ids)] + if len(self.ids) == 1: + where_clause = "AND r.uid = %s" + args.append(self.id) + else: + where_clause = "" # default; we check ALL users (actually pretty efficient) + query = """ + SELECT 1 FROM res_groups_users_rel WHERE EXISTS( + SELECT r.uid + FROM res_groups_users_rel r + WHERE r.gid IN %s""" + where_clause + """ + GROUP BY r.uid HAVING COUNT(r.gid) > 1 + ) + """ + self.env.cr.execute(query, args) + return bool(self.env.cr.fetchall()) + else: + return False + def toggle_active(self): for user in self: if not user.active and not user.partner_id.active: @@ -939,6 +971,7 @@ class GroupsImplied(models.Model): JOIN group_imply i ON (r.gid = i.hid) WHERE i.gid = %(gid)s """, dict(gid=group.id)) + self._check_one_user_type() return res class UsersImplied(models.Model): diff --git a/odoo/addons/base/tests/test_ir_sequence_date_range.py b/odoo/addons/base/tests/test_ir_sequence_date_range.py index 08729f3a072..43e218f1189 100644 --- a/odoo/addons/base/tests/test_ir_sequence_date_range.py +++ b/odoo/addons/base/tests/test_ir_sequence_date_range.py @@ -24,7 +24,7 @@ class TestIrSequenceDateRangeStandard(SingleTransactionCase): year = date.today().year - 1 january = lambda d: date(year, 1, d) - seq16 = self.env['ir.sequence'].with_context({'ir_sequence_date': january(16)}) + seq16 = self.env['ir.sequence'].with_context(ir_sequence_date=january(16)) n = seq16.next_by_code('test_sequence_date_range') self.assertEqual(n, '1') n = seq16.next_by_code('test_sequence_date_range') diff --git a/odoo/addons/base/tests/test_user_has_group.py b/odoo/addons/base/tests/test_user_has_group.py index 61f6efaecfb..4f5b2bc61dd 100644 --- a/odoo/addons/base/tests/test_user_has_group.py +++ b/odoo/addons/base/tests/test_user_has_group.py @@ -143,3 +143,21 @@ class TestHasGroup(TransactionCase): }) with self.assertRaises(ValidationError): self.grp_internal.users = [(4, test_user.id)] + + def test_two_user_types_implied_groups(self): + """Contrarily to test_two_user_types, we simply add an implied_id to a group. + This will trigger the addition of the relevant users to the relevant groups; + if, say, this was done in SQL and thus bypassing the ORM, it would bypass the constraints + and thus give us a case uncovered by the aforementioned test. + """ + grp_test = self.env["res.groups"].create( + {"name": "test", "implied_ids": [(6, 0, [self.grp_internal.id])]}) + + test_user = self.env['res.users'].create({ + 'login': 'test_user_portal', + 'name': "Test User with one user types", + 'groups_id': [(6, 0, [grp_test.id])] + }) + + with self.assertRaises(ValidationError): + grp_test.write({'implied_ids': [(4, self.grp_portal.id)]}) diff --git a/odoo/addons/base/views/ir_qweb_widget_templates.xml b/odoo/addons/base/views/ir_qweb_widget_templates.xml index 47bf04a62ff..4f141cadb71 100644 --- a/odoo/addons/base/views/ir_qweb_widget_templates.xml +++ b/odoo/addons/base/views/ir_qweb_widget_templates.xml @@ -13,7 +13,7 @@ </div> </template> <template id="contact"> - <address t-ignore="true" class="d-inline-block mb-0" itemscope="itemscope" itemtype="http://schema.org/Organization"> + <address t-ignore="true" class="mb-0" itemscope="itemscope" itemtype="http://schema.org/Organization"> <div t-if="not (('name' in fields) or (address and 'address' in fields) or (city and 'city' in fields) or (mobile and 'mobile' in fields) or (website and 'website' in fields) or (email and 'email' in fields))" class="css_non_editable_mode_hidden"> --<span class="text-muted" t-esc="name"/>-- </div> @@ -23,7 +23,7 @@ <div itemprop="address" itemscope="itemscope" itemtype="http://schema.org/PostalAddress"> <div t-if="address and 'address' in fields" class="d-flex align-items-baseline"> <i t-if="not options.get('no_marker')" class="fa fa-map-marker fa-fw" role="img" aria-label="Address" title="Address"/> - <span itemprop="streetAddress" t-raw="address.replace('\n', options.get('no_tag_br') and ', ' or ('<br/>%s' % ('' if options.get('no_marker') else '')))"/> + <div class="w-100" itemprop="streetAddress" t-raw="address.replace('\n', options.get('no_tag_br') and ', ' or ('<br/>%s' % ('' if options.get('no_marker') else '')))"/> </div> <div t-if="city and 'city' in fields" class="d-flex align-items-baseline"> <i t-if="not options.get('no_marker')" class="fa fa-map-marker fa-fw" role="img" aria-label="Address" title="Address"/> diff --git a/odoo/addons/base/views/res_partner_views.xml b/odoo/addons/base/views/res_partner_views.xml index 96b2f0dcb3c..aa8e65f5508 100644 --- a/odoo/addons/base/views/res_partner_views.xml +++ b/odoo/addons/base/views/res_partner_views.xml @@ -524,7 +524,7 @@ <img class="o_kanban_image_inner_pic" t-if="record.parent_id.raw_value" t-att-alt="record.parent_id.value" t-att-src="kanban_image('res.partner', 'image_128', record.parent_id.raw_value)"/> </div> </t> - <t t-else=""> + <t t-elif="record.image_128.raw_value"> <t t-set="placeholder" t-value="'/base/static/img/company_image.png'"/> <div class="o_kanban_image_fill_left o_kanban_image_full" t-attf-style="background-image: url(#{kanban_image('res.partner', 'image_128', record.id.raw_value, placeholder)})" role="img"/> </t> diff --git a/odoo/addons/test_testing_utilities/tests/test_methods.py b/odoo/addons/test_testing_utilities/tests/test_methods.py index 031d3a5e2ec..2a783aa2166 100644 --- a/odoo/addons/test_testing_utilities/tests/test_methods.py +++ b/odoo/addons/test_testing_utilities/tests/test_methods.py @@ -11,9 +11,11 @@ class TestBasic(common.TransactionCase): Y3 = {'f1': "Y", 'f2': 3} records = self.env['test_testing_utilities.a'].create([X1, Y2]) - # match in any order self.assertRecordValues(records, [X1, Y2]) - self.assertRecordValues(records, [Y2, X1]) + + with self.assertRaises(AssertionError): + # order should match + self.assertRecordValues(records, [Y2, X1]) # fail if wrong size with self.assertRaises(AssertionError): diff --git a/odoo/models.py b/odoo/models.py index 92860de3c41..5ba3b364a9e 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -5560,7 +5560,7 @@ Record ids: %(records)s real_records = self - new_records records = model.browse() if real_records: - records |= model.search([(key.name, 'in', real_records.ids)]) + records |= model.search([(key.name, 'in', real_records.ids)], order='id') if new_records: cache_records = self.env.cache.get_records(model, key) records |= cache_records.filtered(lambda r: set(r[key.name]._ids) & set(self._ids)) diff --git a/odoo/tests/common.py b/odoo/tests/common.py index e02b174652b..706626394db 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -23,6 +23,7 @@ import tempfile import threading import time import unittest +import difflib import werkzeug.urls from contextlib import contextmanager from datetime import datetime, date @@ -315,74 +316,95 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})): :param expected_values: List of dicts expected to be exactly matched in records ''' - def _compare_candidate(record, candidate): - ''' Return True if the candidate matches the given record ''' - for field_name in candidate.keys(): + def _compare_candidate(record, candidate, field_names): + ''' Compare all the values in `candidate` with a record. + :param record: record being compared + :param candidate: dict of values to compare + :return: A dictionary will encountered difference in values. + ''' + diff = {} + for field_name in field_names: record_value = record[field_name] - candidate_value = candidate[field_name] field = record._fields[field_name] field_type = field.type if field_type == 'monetary': # Compare monetary field. currency_field_name = record._fields[field_name].currency_field record_currency = record[currency_field_name] - if record_currency.compare_amounts(candidate_value, record_value)\ - if record_currency else candidate_value != record_value: - return False + if field_name not in candidate: + diff[field_name] = (record_value, None) + elif record_currency: + if record_currency.compare_amounts(candidate[field_name], record_value): + diff[field_name] = (record_value, record_currency.round(candidate[field_name])) + elif candidate[field_name] != record_value: + diff[field_name] = (record_value, candidate[field_name]) elif field_type == 'float' and field.get_digits(record.env): prec = field.get_digits(record.env)[1] - if float_compare(candidate_value, record_value, precision_digits=prec) != 0: - return False + if float_compare(candidate[field_name], record_value, precision_digits=prec) != 0: + diff[field_name] = (record_value, candidate[field_name]) elif field_type in ('one2many', 'many2many'): # Compare x2many relational fields. # Empty comparison must be an empty list to be True. - if set(record_value.ids) != set(candidate_value): - return False + if field_name not in candidate: + diff[field_name] = (sorted(record_value.ids), None) + elif set(record_value.ids) != set(candidate[field_name]): + diff[field_name] = (sorted(record_value.ids), sorted(candidate[field_name])) elif field_type == 'many2one': # Compare many2one relational fields. # Every falsy value is allowed to compare with an empty record. - if (record_value or candidate_value) and record_value.id != candidate_value: - return False - elif (candidate_value or record_value) and record_value != candidate_value: + if field_name not in candidate: + diff[field_name] = (record_value.id, None) + elif (record_value or candidate[field_name]) and record_value.id != candidate[field_name]: + diff[field_name] = (record_value.id, candidate[field_name]) + else: # Compare others fields if not both interpreted as falsy values. - return False - return True - - def _repr_field_value(record, field_name): - record_value = record[field_name] - field_type = record._fields[field_name].type - if field_type == 'monetary': - currency_field_name = record._fields[field_name].currency_field - record_currency = record[currency_field_name] - return record_currency and record_currency.round(record_value) or record_value - elif field_type in ('one2many', 'many2many'): - return set(record_value.ids) - elif field_type == 'many2one': - return record_value.id - else: - return record_value - - def _format_message(records, expected_values): - ''' Return a formatted representation of records/expected_values. ''' - all_records_values = [{key: _repr_field_value(record, key) for key in expected_values[0]} for record in records] - msg1 = '\n'.join(pprint.pformat(dic) for dic in all_records_values) - msg2 = '\n'.join(pprint.pformat(dic) for dic in expected_values) - return 'Current values:\n\n%s\n\nExpected values:\n\n%s' % (msg1, msg2) + if field_name not in candidate: + diff[field_name] = (record_value, None) + elif (candidate[field_name] or record_value) and record_value != candidate[field_name]: + diff[field_name] = (record_value, candidate[field_name]) + return diff + + # Compare records with candidates. + different_values = [] + field_names = list(expected_values[0].keys()) + for index, record in enumerate(records): + is_additional_record = index >= len(expected_values) + candidate = {} if is_additional_record else expected_values[index] + diff = _compare_candidate(record, candidate, field_names) + if diff: + different_values.append((index, 'additional_record' if is_additional_record else 'regular_diff', diff)) + for index in range(len(records), len(expected_values)): + diff = {} + for field_name in field_names: + diff[field_name] = (None, expected_values[index][field_name]) + different_values.append((index, 'missing_record', diff)) + + # Build error message. + if not different_values: + return - # if the length or both things to compare is different, we can already tell they're not equal + errors = ['The records and expected_values do not match.'] if len(records) != len(expected_values): - msg = 'Wrong number of records to compare: %d != %d.\n\n' % (len(records), len(expected_values)) - self.fail(msg + _format_message(records, expected_values)) - - candidates = list(expected_values) - for index, record in enumerate(records): - for candidate_index, candidate in enumerate(candidates): - if _compare_candidate(record, candidate): - candidates.pop(candidate_index) - break - else: - msg = 'Record doesn\'t match expected values at index %d.\n\n' % index - self.fail(msg + _format_message(records, expected_values)) + errors.append('Wrong number of records to compare: %d records versus %d expected values.' % (len(records), len(expected_values))) + + for index, diff_type, diff in different_values: + if diff_type == 'regular_diff': + errors.append('\n==== Differences at index %s ====' % index) + record_diff = ['%s:%s' % (k, v[0]) for k, v in diff.items()] + candidate_diff = ['%s:%s' % (k, v[1]) for k, v in diff.items()] + errors.append('\n'.join(difflib.unified_diff(record_diff, candidate_diff))) + elif diff_type == 'additional_record': + errors += [ + '\n==== Additional record ====', + pprint.pformat(dict((k, v[0]) for k, v in diff.items())), + ] + elif diff_type == 'missing_record': + errors += [ + '\n==== Missing record ====', + pprint.pformat(dict((k, v[1]) for k, v in diff.items())), + ] + + self.fail('\n'.join(errors)) def shortDescription(self): return None |
