diff options
Diffstat (limited to 'addons')
72 files changed, 1934 insertions, 111 deletions
diff --git a/addons/account/i18n/account.pot b/addons/account/i18n/account.pot index 1c0c16cccab..43deedf6f9d 100644 --- a/addons/account/i18n/account.pot +++ b/addons/account/i18n/account.pot @@ -758,7 +758,6 @@ msgstr "" #, python-format msgid "" "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)." diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 85896ad39d1..7b4288e8b39 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -279,13 +279,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, @@ -713,7 +713,7 @@ class AccountJournal(models.Model): return { 'alias_defaults': {'type': type == 'purchase' and 'in_invoice' or 'out_invoice', 'company_id': self.company_id.id}, 'alias_parent_thread_id': self.id, - 'alias_name': re.sub(r'[^\w]+', '-', alias_name) + 'alias_name': alias_name, } @api.multi diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 280869974e9..828dac97a6a 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -330,6 +330,10 @@ class account_payment(models.Model): 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.multi @api.depends('move_line_ids.reconciled') def _get_move_reconciled(self): @@ -457,7 +461,6 @@ class account_payment(models.Model): move.unlink() rec.write({ 'state': 'cancelled', - 'move_name': False, }) @api.multi diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 25f08790c9b..7bc7ea17d86 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -224,7 +224,7 @@ class AccountChartTemplate(models.Model): acc_template_ref, taxes_ref = self._install_template(company, code_digits=self.code_digits) # 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) @@ -245,7 +245,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 213cc97abcc..83b5b7c9afc 100644 --- a/addons/account/models/partner.py +++ b/addons/account/models/partner.py @@ -204,7 +204,7 @@ class ResPartner(models.Model): @api.multi 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 @@ -238,9 +238,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() @@ -308,7 +310,14 @@ class ResPartner(models.Model): partner.contracts_count = AccountAnalyticAccount.search_count([('partner_id', '=', partner.id)]) def get_followup_lines_domain(self, date, overdue_only=False, only_unblocked=False): - domain = [('reconciled', '=', False), ('account_id.deprecated', '=', False), ('account_id.internal_type', '=', 'receivable'), '|', ('debit', '!=', 0), ('credit', '!=', 0), ('company_id', '=', self.env.company.id)] + domain = [ + ('reconciled', '=', False), + ('account_id.deprecated', '=', False), + ('account_id.internal_type', '=', 'receivable'), + '|', ('debit', '!=', 0), ('credit', '!=', 0), + ('company_id', '=', self.env.company.id), + ('move_id.state', '=', 'posted'), + ] if only_unblocked: domain += [('blocked', '=', False)] if self.ids: diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 8e503f07b80..85a755c2920 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -4,6 +4,7 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools.misc import formatLang +from odoo.tools import misc class AccountReconciliation(models.AbstractModel): @@ -585,6 +586,8 @@ class AccountReconciliation(models.AbstractModel): for line in move_lines: company_currency = line.company_id.currency_id line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency + date_maturity = misc.format_date(self.env, line.date_maturity, lang_code=self.env.user.lang) + ret_line = { 'id': line.id, 'name': line.name and line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name, @@ -596,7 +599,7 @@ class AccountReconciliation(models.AbstractModel): 'account_code': line.account_id.code, 'account_name': line.account_id.name, 'account_type': line.account_id.internal_type, - 'date_maturity': line.date_maturity, + 'date_maturity': date_maturity, 'date': line.date, 'journal_id': [line.journal_id.id, line.journal_id.display_name], 'partner_id': line.partner_id.id, @@ -686,13 +689,14 @@ class AccountReconciliation(models.AbstractModel): amount_currency = amount amount_currency_str = "" amount_str = formatLang(self.env, abs(amount), currency_obj=st_line.currency_id or statement_currency) + date = misc.format_date(self.env, st_line.date, lang_code=self.env.user.lang) data = { 'id': st_line.id, 'ref': st_line.ref, 'note': st_line.note or "", 'name': st_line.name, - 'date': st_line.date, + 'date': date, 'amount': amount, 'amount_str': amount_str, # Amount in the statement line currency 'currency_id': st_line.currency_id.id or statement_currency.id, diff --git a/addons/account/models/res_users.py b/addons/account/models/res_users.py index 8030423e74d..013acde54ff 100644 --- a/addons/account/models/res_users.py +++ b/addons/account/models/res_users.py @@ -13,14 +13,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 fb83cc45a54..d180e09b396 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/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 805aaf5bfa7..2b489259da6 100644 --- a/addons/delivery/models/stock_move.py +++ b/addons/delivery/models/stock_move.py @@ -9,7 +9,7 @@ from odoo.addons import decimal_precision as dp class StockMove(models.Model): _inherit = 'stock.move' - weight = fields.Float(compute='_cal_move_weight', digits=dp.get_precision('Stock Weight'), store=True) + weight = fields.Float(compute='_cal_move_weight', digits=dp.get_precision('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 05852f12542..cfcb45b874e 100644 --- a/addons/delivery/models/stock_picking.py +++ b/addons/delivery/models/stock_picking.py @@ -76,7 +76,7 @@ class StockPicking(models.Model): delivery_type = fields.Selection(related='carrier_id.delivery_type', readonly=True) carrier_id = fields.Many2one("delivery.carrier", string="Carrier", domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") volume = fields.Float(copy=False) - weight = fields.Float(compute='_cal_weight', digits=dp.get_precision('Stock Weight'), store=True, help="Total weight of the products in the picking.") + weight = fields.Float(compute='_cal_weight', digits=dp.get_precision('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/models/event.py b/addons/event/models/event.py index e2b54887bbd..bea451ea666 100644 --- a/addons/event/models/event.py +++ b/addons/event/models/event.py @@ -562,7 +562,7 @@ class EventRegistration(models.Model): elif event_date.month == (today + relativedelta(months=+1)).month: return _('next month') else: - return _('on ') + format_tz(self.with_context({'use_babel': True}).env, self.event_begin_date, tz=self.event_id.date_tz or 'UTC') + return _('on ') + format_tz(self.with_context(use_babel=True).env, self.event_begin_date, tz=self.event_id.date_tz or 'UTC') @api.multi def summary(self): diff --git a/addons/event_sale/models/event.py b/addons/event_sale/models/event.py index 3d3e9095a43..b2d027e052e 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/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index c6b59683316..a3c01252867 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -125,7 +125,7 @@ var LivechatButton = Widget.extend({ var message = new WebsiteLivechatMessage(this, data, options); var hasAlreadyMessage = _.some(this._messages, function (msg) { - return message.id === msg.id; + return message.getID() === msg.getID(); }); if (hasAlreadyMessage) { return; 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 51ddb70c94c..8905c62d8f1 100644 --- a/addons/l10n_multilang/models/l10n_multilang.py +++ b/addons/l10n_multilang/models/l10n_multilang.py @@ -125,6 +125,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 8f64e7b0481..607bca08e13 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 0045ea87989..0a46837d7b2 100644 --- a/addons/mail/models/mail_alias.py +++ b/addons/mail/models/mail_alias.py @@ -249,7 +249,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/models/mail_message.py b/addons/mail/models/mail_message.py index 41580a61a96..091fbcc0150 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -1014,7 +1014,7 @@ class Message(models.Model): def create(self, values): # coming from mail.js that does not have pid in its values if self.env.context.get('default_starred'): - self = self.with_context({'default_starred_partner_ids': [(4, self.env.user.partner_id.id)]}) + self = self.with_context(default_starred_partner_ids=[(4, self.env.user.partner_id.id)]) if 'email_from' not in values: # needed to compute reply_to values['email_from'] = self._get_default_from() 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 db9aec543a6..f2b21f20a3c 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -79,7 +79,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', 'error'] else None - result['transactions'].append({ + tx_info = { 'reference': tx.reference, 'state': tx.state, 'return_url': tx.return_url, @@ -89,7 +89,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: @@ -258,11 +260,7 @@ class WebsitePayment(http.Controller): try: res = tx.s2s_do_transaction() - if tx.state == 'done': - tx.return_url = return_url or '/website_payment/confirm?tx_id=%d' % tx.id - valid_state = 'authorized' if tx.acquirer_id.capture_manually else 'done' - if not res or tx.state != valid_state: - tx.return_url = '/website_payment/pay?error_msg=%s' % _('Payment transaction failed.') + tx.return_url = return_url or '/website_payment/confirm?tx_id=%d' % tx.id return request.redirect('/payment/process') except Exception as e: return request.redirect('/payment/process') diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 7b747de0ce6..62d35059978 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -661,6 +661,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() + @api.multi def _get_payment_transaction_sent_message(self): self.ensure_one() diff --git a/addons/payment/static/src/js/payment_processing.js b/addons/payment/static/src/js/payment_processing.js index ec1028188ec..0424614fe14 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 f9166ecf302..f3fdad1d223 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='none', methods=['POST'], csrf=False) diff --git a/addons/payment_payumoney/models/payment.py b/addons/payment_payumoney/models/payment.py index 91e6023e107..2a4be713e48 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/payment_stripe_sca/__init__.py b/addons/payment_stripe_sca/__init__.py new file mode 100644 index 00000000000..bb0aae7ee63 --- /dev/null +++ b/addons/payment_stripe_sca/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import controllers +from . import models diff --git a/addons/payment_stripe_sca/__manifest__.py b/addons/payment_stripe_sca/__manifest__.py new file mode 100644 index 00000000000..6a2a0cac556 --- /dev/null +++ b/addons/payment_stripe_sca/__manifest__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# ____________________ +# / \ +# | DO NOT FORWARD | +# | PORT FURTHER | +# | THAN 12.4 | +# \____________________/ +# ! ! +# ! ! +# L_ ! +# / _)! +# / /__L +# _____/ (____) +# (____) +# _____ (____) +# \_(____) +# ! ! +# ! ! +# \__/ +# +# Starting with 12.5, the payment_stripe module contains these changes OoB + +{ + 'name': 'Stripe Payment Acquirer - Strong Customer Authentication Update', + 'category': 'Hidden', + 'summary': 'Payment Acquirer: Stripe Implementation for the EU PSD2', + 'version': '1.0', + 'description': """Stripe Payment Acquirer - Strong Customer Authentication Update""", + 'depends': ['payment_stripe'], + 'auto_install': True, + 'data': [ + 'views/assets.xml', + 'views/payment_templates.xml', + ], + 'images': ['static/description/icon.png'], +} diff --git a/addons/payment_stripe_sca/controllers/__init__.py b/addons/payment_stripe_sca/controllers/__init__.py new file mode 100644 index 00000000000..7036966b1ce --- /dev/null +++ b/addons/payment_stripe_sca/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import main
\ No newline at end of file diff --git a/addons/payment_stripe_sca/controllers/main.py b/addons/payment_stripe_sca/controllers/main.py new file mode 100644 index 00000000000..fb1bda1de54 --- /dev/null +++ b/addons/payment_stripe_sca/controllers/main.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import pprint +import werkzeug + +from odoo.http import route, request +from odoo.addons.payment_stripe.controllers.main import StripeController + +_logger = logging.getLogger(__name__) + + +class StripeControllerSCA(StripeController): + _success_url = '/payment/stripe/success' + _cancel_url = '/payment/stripe/cancel' + + @route(['/payment/stripe/success', '/payment/stripe/cancel'], type='http', auth='public') + def stripe_success(self, **kwargs): + request.env['payment.transaction'].sudo().form_feedback(kwargs, 'stripe') + return werkzeug.utils.redirect('/payment/process') + + @route(['/payment/stripe/s2s/create_json_3ds'], type='json', auth='public', csrf=False) + def stripe_s2s_create_json_3ds(self, verify_validity=False, **kwargs): + if not kwargs.get('partner_id'): + kwargs = dict(kwargs, partner_id=request.env.user.partner_id.id) + token = request.env['payment.acquirer'].browse(int(kwargs.get('acquirer_id'))).s2s_process(kwargs) + + if not token: + res = { + 'result': False, + } + return res + + res = { + 'result': True, + 'id': token.id, + 'short_name': token.short_name, + '3d_secure': False, + 'verified': False, + } + + if verify_validity != False: + token.validate() + res['verified'] = token.verified + + return res + + @route('/payment/stripe/s2s/create_setup_intent', type='json', auth='public', csrf=False) + def stripe_s2s_create_setup_intent(self, acquirer_id, **kwargs): + acquirer = request.env['payment.acquirer'].browse(int(acquirer_id)) + res = acquirer._create_setup_intent(kwargs) + return res.get('client_secret') + + + @route('/payment/stripe/s2s/process_payment_intent', type='json') + def stripe_s2s_process_payment_intent(self, **post): + return request.env['payment.transaction'].sudo().form_feedback(post, 'stripe') + + + # These routes are deprecated, let's remove them for security's sake + def stripe_s2s_create_json(self, **post): + raise werkzeug.exceptions.NotFound() + + def stripe_s2s_create(self, **post): + raise werkzeug.exceptions.NotFound() + + def stripe_create_charge(self, **post): + raise werkzeug.exceptions.NotFound() diff --git a/addons/payment_stripe_sca/models/__init__.py b/addons/payment_stripe_sca/models/__init__.py new file mode 100644 index 00000000000..df1092a4fa3 --- /dev/null +++ b/addons/payment_stripe_sca/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import payment
\ No newline at end of file diff --git a/addons/payment_stripe_sca/models/payment.py b/addons/payment_stripe_sca/models/payment.py new file mode 100644 index 00000000000..01093ab6d7f --- /dev/null +++ b/addons/payment_stripe_sca/models/payment.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import requests +import pprint +from werkzeug import urls + +from odoo import api, models, fields, _ +from odoo.exceptions import ValidationError +from odoo.addons.payment_stripe_sca.controllers.main import StripeControllerSCA as StripeController +from odoo.addons.payment_stripe.models.payment import INT_CURRENCIES +from odoo.tools.float_utils import float_round + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirerStripeSCA(models.Model): + _inherit = "payment.acquirer" + + def stripe_form_generate_values(self, tx_values): + self.ensure_one() + + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + stripe_session_data = { + "payment_method_types[]": "card", + "line_items[][amount]": int( + tx_values["amount"] + if tx_values["currency"].name in INT_CURRENCIES + else float_round(tx_values["amount"] * 100, 2) + ), + "line_items[][currency]": tx_values["currency"].name, + "line_items[][quantity]": 1, + "line_items[][name]": tx_values["reference"], + "client_reference_id": tx_values["reference"], + "success_url": urls.url_join(base_url, StripeController._success_url) + + "?reference=%s" % tx_values["reference"], + "cancel_url": urls.url_join(base_url, StripeController._cancel_url) + + "?reference=%s" % tx_values["reference"], + "customer_email": tx_values["partner_email"] or tx_values["billing_partner_email"], + } + tx_values["session_id"] = self._create_stripe_session(stripe_session_data) + + return tx_values + + def _stripe_request(self, url, data=False, method="POST"): + self.ensure_one() + stripe_url = 'https://%s/' % (self._get_stripe_api_url()) + url = urls.url_join(stripe_url, url) + headers = { + "AUTHORIZATION": "Bearer %s" % self.sudo().stripe_secret_key, + "Stripe-Version": "2019-05-16", # SetupIntent need a specific version + } + resp = requests.request(method, url, data=data, headers=headers) + try: + resp.raise_for_status() + except: + _logger.error(resp.text) + raise + return resp.json() + + def _create_stripe_session(self, kwargs): + self.ensure_one() + resp = self._stripe_request("checkout/sessions", kwargs) + if resp.get("payment_intent") and kwargs.get("client_reference_id"): + tx = ( + self.env["payment.transaction"] + .sudo() + .search([("reference", "=", kwargs["client_reference_id"])]) + ) + tx.stripe_payment_intent = resp["payment_intent"] + return resp["id"] + + def _create_setup_intent(self, kwargs): + self.ensure_one() + params = {"usage": "off_session"} + _logger.info( + "_stripe_create_setup_intent: Sending values to stripe, values:\n%s", + pprint.pformat(params), + ) + + res = self._stripe_request("setup_intents", params) + + _logger.info( + "_stripe_create_setup_intent: Values received:\n%s", pprint.pformat(res) + ) + return res + + @api.model + def stripe_s2s_form_process(self, data): + last4 = data.get("card", {}).get("last4") + if not last4: + # PM was created with a setup intent, need to get last4 digits through + # yet another call -_- + acquirer_id = self.env["payment.acquirer"].browse(int(data["acquirer_id"])) + pm = data.get("payment_method") + res = acquirer_id._stripe_request( + "payment_methods/%s" % pm, data=False, method="GET" + ) + last4 = res.get("card", {}).get("last4", "****") + + payment_token = ( + self.env["payment.token"] + .sudo() + .create( + { + "acquirer_id": int(data["acquirer_id"]), + "partner_id": int(data["partner_id"]), + "stripe_payment_method": data.get("payment_method"), + "name": "XXXXXXXXXXXX%s" % last4, + "acquirer_ref": data.get("customer"), + } + ) + ) + return payment_token + + def stripe_s2s_form_validate(self, data): + return True + + +class PaymentTransactionStripeSCA(models.Model): + _inherit = "payment.transaction" + + stripe_payment_intent = fields.Char( + string="Stripe Payment Intent ID", readonly=True + ) + stripe_payment_intent_secret = fields.Char(string='Stripe Payment Intent Secret', readonly=True) + + def _get_json_fields(self): + res = super()._get_json_fields() + res.append('stripe_payment_intent_secret') + return res + + def _get_processing_info(self): + res = super()._get_processing_info() + if self.acquirer_id.provider == 'stripe': + stripe_info = { + 'stripe_payment_intent': self.stripe_payment_intent, + 'stripe_payment_intent_secret': self.stripe_payment_intent_secret, + 'stripe_publishable_key': self.acquirer_id.stripe_publishable_key, + } + res.update(stripe_info) + return res + + def _create_stripe_charge(self, acquirer_ref=None, tokenid=None, email=None): + raise NotImplementedError( + "This method can no longer be used with the payment_stripe_sca module." + ) + + def form_feedback(self, data, acquirer_name): + if data.get("reference") and acquirer_name == "stripe": + transaction = self.env["payment.transaction"].search( + [("reference", "=", data["reference"])] + ) + + url = "payment_intents/%s" % transaction.stripe_payment_intent + resp = transaction.acquirer_id._stripe_request(url) + if resp.get("charges") and resp.get("charges").get("total_count"): + resp = resp.get("charges").get("data")[0] + + data.update(resp) + _logger.info( + "Stripe: entering form_feedback with post data %s" + % pprint.pformat(data) + ) + # note: luckily, the base stripe module did not override this method, avoiding + # me from using a context key to avoid this call in the parent model + return super(PaymentTransactionStripeSCA, self).form_feedback(data, acquirer_name) + + def _stripe_create_payment_intent(self, acquirer_ref=None, email=None): + if self.stripe_payment_intent: + _logger.info( + "_stripe_create_payment_intent: trying to create an intent when one already exists (tx #%s), refetching values for intent %s", + self.id, self.stripe_payment_intent + ) + res = self.acquirer_id._stripe_request("payment_intents/%s" % self.stripe_payment_intent, method="GET") + _logger.info( + "_stripe_create_payment_intent: Values received:\n%s", pprint.pformat(res) + ) + return res + charge_params = { + "amount": int( + self.amount + if self.currency_id.name in INT_CURRENCIES + else float_round(self.amount * 100, 2) + ), + "currency": self.currency_id.name.lower(), + "confirm": True, + "off_session": True, + "payment_method": self.payment_token_id.stripe_payment_method, + "customer": self.payment_token_id.acquirer_ref, + "description": self.reference, + } + if not self.env.context.get('off_session'): + charge_params.update(setup_future_usage='off_session', off_session=False) + _logger.info( + "_stripe_create_payment_intent: Sending values to stripe, values:\n%s", + pprint.pformat(charge_params), + ) + + res = self.acquirer_id._stripe_request("payment_intents", charge_params) + if res.get("charges") and res.get("charges").get("total_count"): + res = res.get("charges").get("data")[0] + + _logger.info( + "_stripe_create_payment_intent: Values received:\n%s", pprint.pformat(res) + ) + return res + + def stripe_s2s_do_transaction(self, **kwargs): + self.ensure_one() + result = self._stripe_create_payment_intent( + acquirer_ref=self.payment_token_id.acquirer_ref, email=self.partner_email + ) + return self._stripe_s2s_validate_tree(result) + + def _create_stripe_refund(self): + refund_params = { + "charge": self.acquirer_reference, + "amount": int( + float_round(self.amount * 100, 2) + ), # by default, stripe refund the full amount (we don't really need to specify the value) + "metadata[reference]": self.reference, + } + + _logger.info( + "_create_stripe_refund: Sending values to stripe URL, values:\n%s", + pprint.pformat(refund_params), + ) + res = self.acquirer_id._stripe_request("refunds", refund_params) + _logger.info("_create_stripe_refund: Values received:\n%s", pprint.pformat(res)) + return res + + @api.model + def _stripe_form_get_tx_from_data(self, data): + """ Given a data dict coming from stripe, verify it and find the related + transaction record. """ + reference = data.get("reference") + if not reference: + stripe_error = data.get("error", {}).get("message", "") + _logger.error( + "Stripe: invalid reply received from stripe API, looks like " + "the transaction failed. (error: %s)", + stripe_error or "n/a", + ) + error_msg = _("We're sorry to report that the transaction has failed.") + if stripe_error: + error_msg += " " + ( + _("Stripe gave us the following info about the problem: '%s'") + % stripe_error + ) + error_msg += " " + _( + "Perhaps the problem can be solved by double-checking your " + "credit card details, or contacting your bank?" + ) + raise ValidationError(error_msg) + + tx = self.search([("reference", "=", reference)]) + if not tx: + error_msg = _("Stripe: no order found for reference %s") % reference + _logger.error(error_msg) + raise ValidationError(error_msg) + elif len(tx) > 1: + error_msg = _("Stripe: %s orders found for reference %s") % ( + len(tx), + reference, + ) + _logger.error(error_msg) + raise ValidationError(error_msg) + return tx[0] + + def _stripe_s2s_validate_tree(self, tree): + self.ensure_one() + if self.state not in ("draft", "pending"): + _logger.info( + "Stripe: trying to validate an already validated tx (ref %s)", + self.reference, + ) + return True + + status = tree.get("status") + tx_id = tree.get("id") + tx_secret = tree.get("client_secret") + vals = {"date": fields.datetime.now(), "acquirer_reference": tx_id, "stripe_payment_intent": tx_id, "stripe_payment_intent_secret": tx_secret} + if status == "succeeded": + self.write(vals) + self._set_transaction_done() + self.execute_callback() + if self.type == "form_save": + s2s_data = { + "customer": tree.get("customer"), + "payment_method": tree.get("payment_method"), + "card": tree.get("payment_method_details").get("card"), + "acquirer_id": self.acquirer_id.id, + "partner_id": self.partner_id.id, + } + token = self.acquirer_id.stripe_s2s_form_process(s2s_data) + self.payment_token_id = token.id + if self.payment_token_id: + self.payment_token_id.verified = True + return True + if status in ("processing", "requires_action"): + self.write(vals) + self._set_transaction_pending() + return True + else: + error = tree.get("failure_message") + _logger.warn(error) + vals.update({"state_message": error}) + self.write(vals) + self._set_transaction_cancel() + return False + + def _stripe_form_get_invalid_parameters(self, data): + invalid_parameters = [] + if data.get("amount") != int( + self.amount + if self.currency_id.name in INT_CURRENCIES + else float_round(self.amount * 100, 2) + ): + invalid_parameters.append(("Amount", data.get("amount"), self.amount * 100)) + if data.get("currency").upper() != self.currency_id.name: + invalid_parameters.append( + ("Currency", data.get("currency"), self.currency_id.name) + ) + if ( + data.get("payment_intent") + and data.get("payment_intent") != self.stripe_payment_intent + ): + invalid_parameters.append( + ( + "Payment Intent", + data.get("payment_intent"), + self.stripe_payment_intent, + ) + ) + return invalid_parameters + + +class PaymentTokenStripeSCA(models.Model): + _inherit = "payment.token" + + stripe_payment_method = fields.Char("Payment Method ID") + + @api.model + def stripe_create(self, values): + if values.get("stripe_payment_method") and not values.get("acquirer_ref"): + partner_id = self.env["res.partner"].browse(values.get("partner_id")) + payment_acquirer = self.env["payment.acquirer"].browse( + values.get("acquirer_id") + ) + + # create customer to stipe + customer_data = {"email": partner_id.email} + cust_resp = payment_acquirer._stripe_request("customers", customer_data) + + # link customer with payment method + api_url_payment_method = ( + "payment_methods/%s/attach" % values["stripe_payment_method"] + ) + method_data = {"customer": cust_resp.get("id")} + payment_acquirer._stripe_request(api_url_payment_method, method_data) + return {"acquirer_ref": cust_resp["id"]} + return values + + def _stripe_create_customer(self, token, description=None, acquirer_id=None): + raise NotImplementedError( + "This method can no longer be used with the payment_stripe_sca module." + ) diff --git a/addons/payment_stripe_sca/static/description/icon.png b/addons/payment_stripe_sca/static/description/icon.png Binary files differnew file mode 100644 index 00000000000..497b2d65bd9 --- /dev/null +++ b/addons/payment_stripe_sca/static/description/icon.png diff --git a/addons/payment_stripe_sca/static/description/icon.svg b/addons/payment_stripe_sca/static/description/icon.svg new file mode 100644 index 00000000000..f169bb0d45e --- /dev/null +++ b/addons/payment_stripe_sca/static/description/icon.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 70 70" width="70pt" height="70pt"><defs><clipPath id="_clipPath_9pLMMgitI8J1StLiEfUXLEx0UjEaF7gy"><rect width="70" height="70"/></clipPath></defs><g clip-path="url(#_clipPath_9pLMMgitI8J1StLiEfUXLEx0UjEaF7gy)"><clipPath id="_clipPath_oga5uuxx3E0gTH5lUvcUI2SkbTwzbdcf"><path d=" M 4 0 L 65 0 C 69 0 70 1 70 5 L 70 65 C 70 69 69 70 65 70 L 4 70 C 1 70 0 69 0 65 L 0 5 C 0 1 1 0 4 0 Z " fill="rgb(255,255,255)"/></clipPath><g clip-path="url(#_clipPath_oga5uuxx3E0gTH5lUvcUI2SkbTwzbdcf)"><g filter="url(#QtJFuMNyeHoJjoERoMTGiyMkEFlKPbey)"><linearGradient id="_lgradient_1" x1="0" y1="0.5" x2="0.7071067811865476" y2="-0.20710678118654746" gradientTransform="matrix(70,0,0,70,0,0)" gradientUnits="userSpaceOnUse"><stop offset="0%" stop-opacity="1" style="stop-color:rgb(106,158,186)"/><stop offset="100%" stop-opacity="1" style="stop-color:rgb(148,182,200)"/></linearGradient><path d=" M 0 0 L 70 0 L 70 70 L 0 70 L 0 0 Z " fill-rule="evenodd" fill="url(#_lgradient_1)"/><path d=" M 4 1 L 65 1 C 67.667 1 69.333 1.667 70 3 L 70 0 L 0 0 L 0 3 C 0.667 1.667 2 1 4 1 Z " fill-rule="evenodd" fill="rgb(255,255,255)" fill-opacity="0.383"/><g opacity="0.324"><path d=" M 4 69 C 2 69 0 68 0 65 L 0 33.916 L 14 17.5 L 27.818 22.703 L 37.25 22.703 L 48 23 L 51.576 24.968 L 51.34 46.978 L 39.224 69 L 4 69 L 4 69 Z " fill-rule="evenodd" fill="rgb(57,57,57)"/></g><path d=" M 4 69 L 65 69 C 67.667 69 69.333 68 70 66 L 70 70 L 0 70 L 0 66 C 0.667 68 2 69 4 69 Z " fill-rule="evenodd" fill="rgb(0,0,0)" fill-opacity="0.383"/><g opacity="0.3"><path d=" M 19.25 46 L 21.964 46 L 21.964 47.212 C 21.964 47.4 22.116 47.555 22.303 47.555 L 48.697 47.555 C 48.885 47.553 49.037 47.4 49.036 47.212 L 49.036 37.5 L 32.5 37.5 C 32.833 34.167 32.335 32 31.006 31 L 49.036 31 L 49.036 27.788 C 49.037 27.6 48.885 27.447 48.697 27.445 L 29.765 27.445 C 30.126 26.435 30.335 25.521 30.392 24.703 L 49.036 24.703 C 50.536 24.703 51.75 25.931 51.75 27.445 L 51.75 47.555 C 51.75 49.069 50.537 50.297 49.036 50.297 L 21.964 50.297 C 20.464 50.297 19.25 49.069 19.25 47.555 L 19.25 46 Z M 15.46 55.642 L 14.423 55.642 L 14.423 55 L 17.256 55 L 17.256 55.642 L 16.219 55.642 L 16.219 58.474 L 15.459 58.474 L 15.459 55.642 L 15.46 55.642 Z M 17.422 55 L 18.492 55 L 19.301 57.389 L 19.311 57.389 L 20.075 55 L 21.145 55 L 21.145 58.474 L 20.434 58.474 L 20.434 56.012 L 20.424 56.012 L 19.577 58.474 L 18.991 58.474 L 18.143 56.036 L 18.133 56.036 L 18.133 58.474 L 17.422 58.474 L 17.422 55 Z M 23.082 27.008 C 27.338 28.531 29.978 30.335 29.978 34.657 C 29.978 37.269 29.077 39.29 27.338 40.658 C 25.785 41.902 23.486 42.555 20.722 42.555 C 17.242 42.555 13.888 41.498 12.087 40.471 L 13.019 34.657 C 15.131 35.901 18.361 36.864 20.318 36.864 C 21.902 36.864 22.772 36.274 22.772 35.248 C 22.772 34.19 21.871 33.506 19.169 32.542 C 14.976 31.019 12.398 29.215 12.398 24.986 C 12.398 22.654 13.236 20.726 14.851 19.389 C 16.404 18.114 18.579 17.43 21.188 17.43 C 24.884 17.43 27.555 18.457 28.859 19.079 L 27.928 24.831 C 26.281 24.023 23.89 23.121 21.56 23.121 C 20.287 23.121 19.572 23.618 19.572 24.489 C 19.572 25.515 20.815 26.169 23.082 27.008 L 23.082 27.008 Z " fill="rgb(0,0,0)"/></g><path d=" M 19.25 44 L 21.964 44 L 21.964 45.212 C 21.964 45.4 22.116 45.555 22.303 45.555 L 48.697 45.555 C 48.885 45.553 49.037 45.4 49.036 45.212 L 49.036 35.5 L 32.5 35.5 C 32.833 32.167 32.335 30 31.006 29 L 49.036 29 L 49.036 25.788 C 49.037 25.6 48.885 25.447 48.697 25.445 L 29.765 25.445 C 30.126 24.435 30.335 23.521 30.392 22.703 L 49.036 22.703 C 50.536 22.703 51.75 23.931 51.75 25.445 L 51.75 45.555 C 51.75 47.069 50.537 48.297 49.036 48.297 L 21.964 48.297 C 20.464 48.297 19.25 47.069 19.25 45.555 L 19.25 44 Z M 15.46 53.642 L 14.423 53.642 L 14.423 53 L 17.256 53 L 17.256 53.642 L 16.219 53.642 L 16.219 56.474 L 15.459 56.474 L 15.459 53.642 L 15.46 53.642 Z M 17.422 53 L 18.492 53 L 19.301 55.389 L 19.311 55.389 L 20.075 53 L 21.145 53 L 21.145 56.474 L 20.434 56.474 L 20.434 54.012 L 20.424 54.012 L 19.577 56.474 L 18.991 56.474 L 18.143 54.036 L 18.133 54.036 L 18.133 56.474 L 17.422 56.474 L 17.422 53 Z M 23.082 25.008 C 27.338 26.531 29.978 28.335 29.978 32.657 C 29.978 35.269 29.077 37.29 27.338 38.658 C 25.785 39.902 23.486 40.555 20.722 40.555 C 17.242 40.555 13.888 39.498 12.087 38.471 L 13.019 32.657 C 15.131 33.901 18.361 34.864 20.318 34.864 C 21.902 34.864 22.772 34.274 22.772 33.248 C 22.772 32.19 21.871 31.506 19.169 30.542 C 14.976 29.019 12.398 27.215 12.398 22.986 C 12.398 20.654 13.236 18.726 14.851 17.389 C 16.404 16.114 18.579 15.43 21.188 15.43 C 24.884 15.43 27.555 16.457 28.859 17.079 L 27.928 22.831 C 26.281 22.023 23.89 21.121 21.56 21.121 C 20.287 21.121 19.572 21.618 19.572 22.489 C 19.572 23.515 20.815 24.169 23.082 25.008 L 23.082 25.008 Z " fill="rgb(255,255,255)"/></g><defs><filter id="QtJFuMNyeHoJjoERoMTGiyMkEFlKPbey" x="-200%" y="-200%" width="400%" height="400%" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB"><feGaussianBlur xmlns="http://www.w3.org/2000/svg" in="SourceGraphic" stdDeviation="2.146804531419514"/><feOffset xmlns="http://www.w3.org/2000/svg" dx="0" dy="0" result="pf_100_offsetBlur"/><feFlood xmlns="http://www.w3.org/2000/svg" flood-color="#000000" flood-opacity="0.65"/><feComposite xmlns="http://www.w3.org/2000/svg" in2="pf_100_offsetBlur" operator="in" result="pf_100_dropShadow"/><feBlend xmlns="http://www.w3.org/2000/svg" in="SourceGraphic" in2="pf_100_dropShadow" mode="normal"/></filter></defs></g><image xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACkAAAATCAYAAAANgSQ9AAACLElEQVRIS+2VwU0cQRBF/4/AJgJMBEAEhgAsQwSICLxEYIgAkAOwffLRJgI4++J1BF4iwBmU9VrVo6Y1w+wBWVqJPu32Vne/+r+q1tqA5Q1g1Avkc7n0ouS6SkbEG0nvJa1s3/TnZpWMiIM8vJp6dJ2YmbNfJP2QtCfpwfZxGz8JGREcPGmCgTyzzWWKiNeSPks6morJuCtJHzKG83wfVkTcSbpq7l1KWthmv6xRyIhYSLqUdC8JWDJE0QPbywT8JQmbiOFCoIk5ah+ICJLjt1eSlrb3O0juxua/mRR3na8DSeBbSae2gSzKNRedS/qYgHvN/hCTZ1D5u6SvmRB37theZR3iRL8KtKQb27wzqWS1GumxaJA+H2d/t01irOaakjlNNXHn2vYiIgAgCVwbW7e2i9NTdmMjYNt5mjoEtjRPRETuH/YJdFZiITZvJeSftHYnISkfSoQ7e9vjSchqb2ZJpjzEg/tpVX18EjIiqtWlERIehxCAuuT3ApmA1DgWl0ZEiFnIqkg2Capi7wV1kh35qGZ7v0amQxtynUlXSBqrjKGmB56GZO51HVrHSFtPNA72o27pzMYBPj7k1kVDBwzjiHhAB7tHkpyFrMphFavUjaRj5lmqC2AtgzbuMC2lc+9tY++wIqKWyidJ71LBnpHvTJZydqpx6Lxai8QxCxmwZZA3iqEwCdQG+521xj5/c0X5DrJOjm+SfmZDjUFifUl+9m9x7PT/3tsIyH8quhYjfI9eKwAAAABJRU5ErkJggg==" x="-1" y="52" width="41" height="19" style=""/></g></svg>
\ No newline at end of file diff --git a/addons/payment_stripe_sca/static/description/index.html b/addons/payment_stripe_sca/static/description/index.html new file mode 100644 index 00000000000..713cb6fc74e --- /dev/null +++ b/addons/payment_stripe_sca/static/description/index.html @@ -0,0 +1,61 @@ +<section class="oe_container"> + <div class="oe_row oe_spaced"> + <div class="oe_span12"> + <h2 class="oe_slogan">Stripe - Strong Customer Authentication</h2> + <h3 class="oe_slogan">Add support for strong customer authentication as part of the EU PSD2</h3> + </div> + <div class="oe_span12"> + <p class='oe_mt32'> + The PSD2 - the second edition of the Payment Services Directive - is coming into full effect in + the European Union (EU) and the European Economic Area (EEA) on September 14th, 2019. This + directive may indirectly impact your business by requiring stronger authentication mechanisms + for online payments. + </p> + <h2 class="oe_mt32">Am I impacted by the PSD2 directive?</h2> + <p>You are impacted by this change if all of these statements applies to your business: + <ul> + <li>You are legally based in the EU/EEA</li> + <li>You accept payments from customers based in the EU/EEA</li> + <li>You accept payments via cards (credit/debit)</li> + </ul> + </p> + <h2 class="oe_mt32">What should I do if I am impacted?</h2> + <p> + This directive does not bind you legally to do anything in particular regarding + your business processes - it applies to payment processors, acquirers and banks. + Legally, you don't have to do anything to comply with this directive - it does not + apply to you. + </p> + <p> + You might however be indirectly impacted because some online payment flows will + change for customers that pay online - they might need to authenticate themselves during + a transaction that previously didn't require it. This is why Odoo is adapting some of + its integration with payment acquirers to avoid disruptions in your business flows. + </p> + <p> + If you use Stripe and are impacted by the PSD2 directive, then simply installing this + module will avoid problems for your online customers during their payment flow. No extra + configuration is needed once the module is installed. + </p> + <h2 class="oe_mt32">What happens if I don't install this update?</h2> + <p>If you don't install this update, some of your customers may be unable to pay you. + There is no reason not to install it if you are impacted by the directive.</p> + <h2 class="oe_mt32">I'm not impacted by the PSD2, should I install this module anyway?</h2> + <p>While not required, installing this module remains a good idea nonetheless since + it will switch your instance to newer - and thus more future-proof - APIs of the Stripe + payment acquirer. + </p> + <h2 class="oe_mt32">What will changes for my online customers</h2> + <p>The interface displayed to your customers will change when they pay you online.</p> + <p>If your Stripe acquirer is configured with the payment flow 'Redirection to the acquirer website' + in Odoo, the small payment overlay will be replaced by a full-screen interface. + </p> + <img src="redirection_flow.png" class="img-fluid"> + <p>If your Stripe acquirer is configured with the payment flow 'Payment from Odoo' + in Odoo, the credit card input form will look slightly different. + </p> + <img src="s2s_flow.png" class="img-fluid"> + <p>The rest of the payment experience remains the same.</p> + </div> + </div> +</section> diff --git a/addons/payment_stripe_sca/static/description/redirection_flow.png b/addons/payment_stripe_sca/static/description/redirection_flow.png Binary files differnew file mode 100644 index 00000000000..593fb5e1aab --- /dev/null +++ b/addons/payment_stripe_sca/static/description/redirection_flow.png diff --git a/addons/payment_stripe_sca/static/description/s2s_flow.png b/addons/payment_stripe_sca/static/description/s2s_flow.png Binary files differnew file mode 100644 index 00000000000..1211a8deb23 --- /dev/null +++ b/addons/payment_stripe_sca/static/description/s2s_flow.png diff --git a/addons/payment_stripe_sca/static/src/js/payment_form.js b/addons/payment_stripe_sca/static/src/js/payment_form.js new file mode 100644 index 00000000000..6786fc95ea7 --- /dev/null +++ b/addons/payment_stripe_sca/static/src/js/payment_form.js @@ -0,0 +1,187 @@ +odoo.define('payment_stripe.payment_form', function (require) { + "use strict"; + + var ajax = require('web.ajax'); + var core = require('web.core'); + var rpc = require('web.rpc'); + var Dialog = require('web.Dialog'); + var PaymentForm = require('payment.payment_form'); + + var _t = core._t; + + PaymentForm.include({ + + willStart: function () { + return this._super.apply(this, arguments).then(function () { + return ajax.loadJS("https://js.stripe.com/v3/"); + }) + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * called when clicking on pay now or add payment event to create token for credit card/debit card. + * + * @private + * @param {Event} ev + * @param {DOMElement} checkedRadio + * @param {Boolean} addPmEvent + */ + _createStripeToken: function (ev, $checkedRadio, addPmEvent) { + var self = this; + if (ev.type === 'submit') { + var button = $(ev.target).find('*[type="submit"]')[0] + } else { + var button = ev.target; + } + this.disableButton(button); + var acquirerID = this.getAcquirerIdFromRadio($checkedRadio); + var acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID); + var inputsForm = $('input', acquirerForm); + if (this.options.partnerId === undefined) { + console.warn('payment_form: unset partner_id when adding new token; things could go wrong'); + } + + var formData = self.getFormData(inputsForm); + var stripe = this.stripe; + var card = this.stripe_card_element; + if (card._invalid) { + return; + } + return rpc.query({ + route: '/payment/stripe/s2s/create_setup_intent', + params: {'acquirer_id': formData.acquirer_id} + }).then(function(intent_secret){ + // need to convert between ES6 promises and jQuery 2 deferred \o/ + return $.Deferred(function(defer) { + stripe.handleCardSetup(intent_secret, card) + .then(function(result) {defer.resolve(result)}) + }); + }).then(function(result) { + if (result.error) { + return $.Deferred().reject({"message": {"data": { "message": result.error.message}}}); + } else { + _.extend(formData, {"payment_method": result.setupIntent.payment_method}); + return rpc.query({ + route: formData.data_set, + params: formData, + }) + } + }).then(function(result) { + if (addPmEvent) { + if (formData.return_url) { + window.location = formData.return_url; + } else { + window.location.reload(); + } + } else { + $checkedRadio.val(result.id); + self.el.submit(); + } + }).fail(function (error, event) { + // if the rpc fails, pretty obvious + self.enableButton(button); + self.displayError( + _t('Unable to save card'), + _t("We are not able to add your payment method at the moment. ") + + error.message.data.message + ); + }); + }, + /** + * called when clicking a Stripe radio if configured for s2s flow; instanciates the card and bind it to the widget. + * + * @private + * @param {DOMElement} checkedRadio + */ + _bindStripeCard: function ($checkedRadio) { + var acquirerID = this.getAcquirerIdFromRadio($checkedRadio); + var acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID); + var inputsForm = $('input', acquirerForm); + var formData = this.getFormData(inputsForm); + var stripe = Stripe(formData.stripe_publishable_key); + var element = stripe.elements(); + var card = element.create('card', {hidePostalCode: true}); + card.mount('#card-element'); + card.on('ready', function(ev) { + card.focus(); + }); + card.addEventListener('change', function (event) { + var displayError = document.getElementById('card-errors'); + displayError.textContent = ''; + if (event.error) { + displayError.textContent = event.error.message; + } + }); + this.stripe = stripe; + this.stripe_card_element = card; + }, + /** + * destroys the card element and any stripe instance linked to the widget. + * + * @private + */ + _unbindStripeCard: function () { + if (this.stripe_card_element) { + this.stripe_card_element.destroy(); + } + this.stripe = undefined; + this.stripe_card_element = undefined; + }, + /** + * @override + */ + updateNewPaymentDisplayStatus: function () { + var $checkedRadio = this.$('input[type="radio"]:checked'); + + if ($checkedRadio.length !== 1) { + return; + } + var provider = $checkedRadio.data('provider') + if (provider === 'stripe') { + // always re-init stripe (in case of multiple acquirers for stripe, make sure the stripe instance is using the right key) + this._unbindStripeCard(); + if (this.isNewPaymentRadio($checkedRadio)) { + this._bindStripeCard($checkedRadio); + } + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + payEvent: function (ev) { + ev.preventDefault(); + var $checkedRadio = this.$('input[type="radio"]:checked'); + + // first we check that the user has selected a stripe as s2s payment method + if ($checkedRadio.length === 1 && $checkedRadio.data('provider') === 'stripe' && this.isNewPaymentRadio($checkedRadio)) { + return this._createStripeToken(ev, $checkedRadio); + } else { + return this._super.apply(this, arguments); + } + }, + /** + * @override + */ + addPmEvent: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var $checkedRadio = this.$('input[type="radio"]:checked'); + + // first we check that the user has selected a stripe as add payment method + if ($checkedRadio.length === 1 && this.isNewPaymentRadio($checkedRadio) && $checkedRadio.data('provider') === 'stripe') { + return this._createStripeToken(ev, $checkedRadio, true); + } else { + return this._super.apply(this, arguments); + } + }, + }); +}); diff --git a/addons/payment_stripe_sca/static/src/js/payment_processing.js b/addons/payment_stripe_sca/static/src/js/payment_processing.js new file mode 100644 index 00000000000..adb57e6221a --- /dev/null +++ b/addons/payment_stripe_sca/static/src/js/payment_processing.js @@ -0,0 +1,48 @@ +odoo.define('payment_stripe_sca.processing', function (require) { + 'use strict'; + + var ajax = require('web.ajax'); + var rpc = require('web.rpc') + var PaymentProcessing = require('payment.processing'); + + return PaymentProcessing.include({ + init: function () { + this._super.apply(this, arguments); + this._authInProgress = false; + }, + willStart: function () { + return this._super.apply(this, arguments).then(function () { + return ajax.loadJS("https://js.stripe.com/v3/"); + }) + }, + _stripeAuthenticate: function (tx) { + var stripe = Stripe(tx.stripe_publishable_key); + return $.Deferred(function(defer) { + stripe.handleCardPayment(tx.stripe_payment_intent_secret) + .then(function(result) {defer.resolve(result)}) + }).then(function(result) { + if (result.error) { + return $.Deferred().reject({"message": {"data": { "message": result.error.message}}}); + } + return rpc.query({ + route: '/payment/stripe/s2s/process_payment_intent', + params: _.extend({}, result.paymentIntent, {reference: tx.reference}), + }); + }).then(function(result) { + window.location = '/payment/process'; + }).fail(function (error) { + this._authInProgress = false; + }); + }, + processPolledData: function(transactions) { + this._super.apply(this, arguments); + for (var itx=0; itx < transactions.length; itx++) { + var tx = transactions[itx]; + if (tx.acquirer_provider === 'stripe' && tx.state === 'pending' && tx.stripe_payment_intent_secret && !this._authInProgress) { + this._authInProgress = true; + this._stripeAuthenticate(tx); + } + } + }, + }); +});
\ No newline at end of file diff --git a/addons/payment_stripe_sca/static/src/js/stripe_sca.js b/addons/payment_stripe_sca/static/src/js/stripe_sca.js new file mode 100644 index 00000000000..63c39360bbc --- /dev/null +++ b/addons/payment_stripe_sca/static/src/js/stripe_sca.js @@ -0,0 +1,81 @@ +odoo.define('payment_stripe.stripe', function (require) { + "use strict"; + + var ajax = require('web.ajax'); + var core = require('web.core'); + + var qweb = core.qweb; + var _t = core._t; + + ajax.loadXML('/payment_stripe/static/src/xml/stripe_templates.xml', qweb); + + if ($.blockUI) { + // our message needs to appear above the modal dialog + $.blockUI.defaults.baseZ = 2147483647; //same z-index as StripeCheckout + $.blockUI.defaults.css.border = '0'; + $.blockUI.defaults.css["background-color"] = ''; + $.blockUI.defaults.overlayCSS["opacity"] = '0.9'; + } + + require('web.dom_ready'); + if (!$('.o_payment_form').length) { + return Promise.reject("DOM doesn't contain '.o_payment_form'"); + } + + var observer = new MutationObserver(function (mutations, observer) { + for (var i = 0; i < mutations.length; ++i) { + for (var j = 0; j < mutations[i].addedNodes.length; ++j) { + if (mutations[i].addedNodes[j].tagName.toLowerCase() === "form" && mutations[i].addedNodes[j].getAttribute('provider') === 'stripe') { + _redirectToStripeCheckout($(mutations[i].addedNodes[j])); + } + } + } + }); + + function displayError(message) { + var wizard = $(qweb.render('stripe.error', {'msg': message || _t('Payment error')})); + wizard.appendTo($('body')).modal({'keyboard': true}); + if ($.blockUI) { + $.unblockUI(); + } + $("#o_payment_form_pay").removeAttr('disabled'); + } + + + function _redirectToStripeCheckout(providerForm) { + // Open Checkout with further options + if ($.blockUI) { + var msg = _t("Just one more second, we are redirecting you to Stripe..."); + $.blockUI({ + 'message': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' + + ' <br />' + msg + + '</h2>' + }); + } + + var paymentForm = $('.o_payment_form'); + if (!paymentForm.find('i').length) { + paymentForm.append('<i class="fa fa-spinner fa-spin"/>'); + paymentForm.attr('disabled', 'disabled'); + } + + var _getStripeInputValue = function (name) { + return providerForm.find('input[name="' + name + '"]').val(); + }; + + var stripe = Stripe(_getStripeInputValue('stripe_key')); + + stripe.redirectToCheckout({ + sessionId: _getStripeInputValue('session_id') + }).then(function (result) { + if (result.error) { + displayError(result.error.message); + } + }); + } + + $.getScript("https://js.stripe.com/v3/", function (data, textStatus, jqxhr) { + observer.observe(document.body, {childList: true}); + _redirectToStripeCheckout($('form[provider="stripe"]')); + }); + }); diff --git a/addons/payment_stripe_sca/views/assets.xml b/addons/payment_stripe_sca/views/assets.xml new file mode 100644 index 00000000000..6dd7fbab536 --- /dev/null +++ b/addons/payment_stripe_sca/views/assets.xml @@ -0,0 +1,8 @@ +<odoo> + <template id="assets_frontend" inherit_id="web.assets_frontend" name="Payment Stripe SCA Assets"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/payment_stripe_sca/static/src/js/payment_form.js"></script> + <script type="text/javascript" src="/payment_stripe_sca/static/src/js/payment_processing.js"></script> + </xpath> + </template> +</odoo> diff --git a/addons/payment_stripe_sca/views/payment_templates.xml b/addons/payment_stripe_sca/views/payment_templates.xml new file mode 100644 index 00000000000..0590c2aae22 --- /dev/null +++ b/addons/payment_stripe_sca/views/payment_templates.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <template id="stripe_form" inherit_id="payment_stripe.stripe_form"> + <xpath expr="//script" position="before"> + <input type='hidden' name='session_id' t-att-value='session_id'/> + </xpath> + <xpath expr="//script" position="replace"> + <script type="text/javascript"> + odoo.define(function (require) { + var ajax = require('web.ajax'); + ajax.loadJS("/payment_stripe_sca/static/src/js/stripe_sca.js"); + }); + </script> + </xpath> + </template> + + <template id="stripe_s2s_form" inherit_id="payment_stripe.stripe_s2s_form"> + <xpath expr="//div" position="replace"> + <input type="hidden" name="data_set" value="/payment/stripe/s2s/create_json_3ds"/> + <input type="hidden" name="acquirer_id" t-att-value="id"/> + <input type="hidden" name="stripe_publishable_key" t-att-value="acq.sudo().stripe_publishable_key"/> + <input type="hidden" name="currency_id" t-att-value="currency_id"/> + <input t-if="return_url" type="hidden" name="return_url" t-att-value="return_url"/> + <input t-if="partner_id" type="hidden" name="partner_id" t-att-value="partner_id"/> + <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> + <div id="payment-form"> + <div id="card-element" class="m-3"/> + <div id="card-errors" class="m-3 text-danger"/> + </div> + </xpath> + <xpath expr="//input[@name='data_set']" position="replace"/> + </template> + + <template inherit_id="payment.payment_tokens_list" id="payment_tokens_list" name="Payment Tokens list"> + <!-- add the provider on saved tokens --> + <xpath expr="//t[@t-foreach='pms']//input[@name='pm_id'][@type='radio'][@t-att-value='pm.id']" position="attributes"> + <attribute name="t-att-data-provider">pm.acquirer_id.provider</attribute> + </xpath> + </template> + </data> +</odoo>
\ No newline at end of file diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index f5215942e3c..71b3a07fcb8 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 6b5561401ad..7ad8dd3cbd4 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -1737,7 +1737,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 a6bdc1bb865..edd4d9ce82c 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -761,6 +761,7 @@ class SupplierInfo(models.Model): 'res.partner', 'Vendor', domain=[('supplier', '=', True)], ondelete='cascade', required=True, help="Vendor of this product") + 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 f5f59aeff23..a0bfe865af3 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 6a7817dfc02..f9e999cde7f 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -454,7 +454,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 '', @@ -482,7 +482,7 @@ class SaleOrder(models.Model): self.filtered(lambda s: s.state == 'draft').write({'state': 'sent'}) return self.env.ref('sale.action_report_saleorder')\ - .with_context({'discard_logo_check': True}).report_action(self) + .with_context(discard_logo_check=True).report_action(self) @api.multi def action_view_invoice(self): 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 b9e2fb55477..7c6eae6472f 100644 --- a/addons/snailmail/models/snailmail_letter.py +++ b/addons/snailmail/models/snailmail_letter.py @@ -219,7 +219,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 a4f6141ac37..6b219fa71d7 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -48,7 +48,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 5cc064ace4f..db431ff2475 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -724,7 +724,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/i18n/es.po b/addons/web/i18n/es.po index b18b803478c..f8682c1860f 100644 --- a/addons/web/i18n/es.po +++ b/addons/web/i18n/es.po @@ -2761,13 +2761,16 @@ msgid "" " <span class=\"page\"/>\n" " of\n" " <span class=\"topage\"/>" -msgstr "<span class=\"page\"/><span class=\"topage\"/>" +msgstr "Pรกgina:\n" +" <span class=\"page\"/>\n" +" de\n" +" <span class=\"topage\"/>" #. module: web #: model_terms:ir.ui.view,arch_db:web.external_layout_boxed #: model_terms:ir.ui.view,arch_db:web.external_layout_standard msgid "Page: <span class=\"page\"/> / <span class=\"topage\"/>" -msgstr "<span class=\"page\"/><span class=\"topage\"/>" +msgstr "Pรกgina: <span class=\"page\"/> / <span class=\"topage\"/>" #. module: web #: model_terms:ir.ui.view,arch_db:web.login diff --git a/addons/web/i18n/web.pot b/addons/web/i18n/web.pot index 4224c70aef9..6a3b4b9cf6e 100644 --- a/addons/web/i18n/web.pot +++ b/addons/web/i18n/web.pot @@ -2584,6 +2584,12 @@ msgid "Only Integer Value should be valid." msgstr "" #. module: web +#: code:addons/web/static/src/js/fields/basic_fields.js:1982 +#, python-format +msgid "Only Integer or Float Value should be valid." +msgstr "" + +#. module: web #: code:addons/web/controllers/main.py:537 #, python-format msgid "" diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 160f445f1c4..eeda3d220b6 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -2329,11 +2329,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; }, @@ -2341,19 +2358,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(); @@ -2363,24 +2380,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) { @@ -2388,29 +2406,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 74e1a239790..68abdf9855a 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, { @@ -477,6 +479,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 6248489922c..9063b2aca50 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', }), /** @@ -34,6 +36,7 @@ var SearchBar = Widget.extend({ this.autoCompleteSources = []; this.searchFacets = []; + this._isInputComposing = false; }, /** * @override @@ -208,10 +211,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 ca9a7ad6225..3f18597cccf 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; }, //-------------------------------------------------------------------------- @@ -107,6 +110,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}); @@ -116,6 +133,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/kanban/search_panel.js b/addons/web/static/src/js/views/kanban/search_panel.js index 730c9b83ae2..788c8e5ed20 100644 --- a/addons/web/static/src/js/views/kanban/search_panel.js +++ b/addons/web/static/src/js/views/kanban/search_panel.js @@ -140,8 +140,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 52c3100df31..5b190240235 100644 --- a/addons/web/static/src/scss/modal.scss +++ b/addons/web/static/src/scss/modal.scss @@ -92,6 +92,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 a593505e9c2..c4869ce2e00 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -10,6 +10,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; @@ -5510,7 +5511,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; @@ -5535,6 +5536,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', @@ -5546,6 +5556,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 4b3517e4cc5..1490db60334 100644 --- a/addons/web/static/tests/views/search_panel_tests.js +++ b/addons/web/static/tests/views/search_panel_tests.js @@ -470,6 +470,61 @@ 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>' + + '<searchpanel>' + + '<field name="company_id"/>' + + '</searchpanel>' + + '</kanban>', + 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 shold 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/wysiwyg/plugin/media.js b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js index b8b4a7582fe..ba7793d1d95 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js @@ -76,7 +76,7 @@ var MediaPlugin = AbstractPlugin.extend({ mediaWidth: mediaWidth, onlyImages: $mediaParent.data('oeField') === 'image' || $mediaParent.data('oeType') === 'image', }, - $(media).clone()[0] + $(media).clone(true, true)[0] ); mediaDialog.on('save', this, function (element) { @@ -708,7 +708,7 @@ var ImagePlugin = AbstractMediaPlugin.extend({ var media = this.context.invoke('editor.restoreTarget'); var cropImageDialog = new weWidgets.CropImageDialog(this.options.parent, {}, - $(media).clone()[0] + $(media).clone(true, true)[0] ); cropImageDialog.on('save', this, function (img) { this.context.invoke('MediaPlugin.insertMedia', media, img); @@ -727,7 +727,7 @@ var ImagePlugin = AbstractMediaPlugin.extend({ var media = this.context.invoke('editor.restoreTarget'); var altDialog = new weWidgets.AltDialog(this.options.parent, {}, - $(media).clone()[0] + $(media).clone(true, true)[0] ); altDialog.on('save', this, this._wrapCommand(function (img) { $(media).attr('alt', img.alt) 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 b196552709d..95b55b7e877 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -1119,7 +1119,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/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js index 4193aef5332..6077dcfafae 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js @@ -88,7 +88,16 @@ var SnippetEditor = Widget.extend({ return $clone; }, start: _.bind(self._onDragAndDropStart, self), - stop: _.bind(self._onDragAndDropStop, self) + 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); + }, }); } diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js index 9515bf19ab6..f4b921bbeef 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js @@ -703,6 +703,17 @@ registry.colorpicker = SnippetOption.extend({ } var bgColor = ColorpickerDialog.formatColor(self.$target.css('background-color')); + + // 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/website/static/src/js/editor/wysiwyg_multizone.js b/addons/website/static/src/js/editor/wysiwyg_multizone.js index 64c3e84daed..6edeae97071 100644 --- a/addons/website/static/src/js/editor/wysiwyg_multizone.js +++ b/addons/website/static/src/js/editor/wysiwyg_multizone.js @@ -238,7 +238,7 @@ var WysiwygMultizone = Wysiwyg.extend({ this.savingMutex.exec(this._saveCroppedImages.bind(this)); } var _super = this._super.bind(this); - return this.savingMutex.lock.then(function () { + return this.savingMutex.getUnlockedDef().then(function () { return _super().then(function (res) { this._summernote.layoutInfo.editable.html(res.html); @@ -250,7 +250,7 @@ var WysiwygMultizone = Wysiwyg.extend({ $areaDirty.each(function (index, editable) { this.savingMutex.exec(this._saveEditable.bind(this, editable)); }.bind(this)); - return this.savingMutex.lock.then(function () { + return this.savingMutex.getUnlockedDef().then(function () { return { isDirty: true }; }); }.bind(this)); @@ -415,8 +415,8 @@ var WysiwygMultizone = Wysiwyg.extend({ */ _saveCroppedImages: function () { var self = this; - var $area = $(this.selectorEditableArea, this.$target); - var defs = $area.find('.o_cropped_img_to_save').map(function () { + var $editable = this._getEditableArea(); + var defs = $editable.find('.o_cropped_img_to_save').map(function () { var $croppedImg = $(this); $croppedImg.removeClass('o_cropped_img_to_save'); var resModel = $croppedImg.data('crop:resModel'); 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', |
