summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-09-17 17:49:09 +0200
committerChristophe Simonis <chs@odoo.com>2019-09-17 17:49:09 +0200
commit080f8b1f96dec30fd9568a81342b5d20cc9d521a (patch)
tree14cecabb2c6502e73d93178bd322722e2e62f07d
parentc12bd7125a1c64339e02ae0563f667b8de931b3b (diff)
parentd8ce75466e4a6728cccfc7696a65a399e9cf23f1 (diff)
[MERGE] forward port branch saas-12.3 up to d8ce75466e4
-rw-r--r--addons/account/i18n/account.pot1
-rw-r--r--addons/account/models/account.py8
-rw-r--r--addons/account/models/account_payment.py5
-rw-r--r--addons/account/models/chart_template.py4
-rw-r--r--addons/account/models/partner.py13
-rw-r--r--addons/account/models/reconciliation_widget.py8
-rw-r--r--addons/account/models/res_users.py13
-rw-r--r--addons/account/tests/test_payment.py118
-rw-r--r--addons/account/views/report_payment_receipt_templates.xml2
-rw-r--r--addons/delivery/models/stock_move.py2
-rw-r--r--addons/delivery/models/stock_picking.py2
-rw-r--r--addons/event/models/event.py2
-rw-r--r--addons/event_sale/models/event.py2
-rw-r--r--addons/im_livechat/static/src/js/im_livechat.js2
-rw-r--r--addons/l10n_de/static/src/scss/report_din5008.scss1
-rw-r--r--addons/l10n_fr/data/account_tax_data.xml2
-rw-r--r--addons/l10n_multilang/models/l10n_multilang.py3
-rw-r--r--addons/lunch/static/tests/lunch_kanban_tests.js49
-rw-r--r--addons/mail/models/mail_alias.py2
-rw-r--r--addons/mail/models/mail_message.py2
-rw-r--r--addons/mail_bot/models/mail_bot.py2
-rw-r--r--addons/mail_bot/models/mail_channel.py2
-rw-r--r--addons/mrp_account/models/product.py8
-rw-r--r--addons/payment/controllers/portal.py12
-rw-r--r--addons/payment/models/payment_acquirer.py4
-rw-r--r--addons/payment/static/src/js/payment_processing.js2
-rw-r--r--addons/payment_paypal/controllers/main.py8
-rw-r--r--addons/payment_payumoney/models/payment.py2
-rw-r--r--addons/payment_stripe_sca/__init__.py4
-rw-r--r--addons/payment_stripe_sca/__manifest__.py38
-rw-r--r--addons/payment_stripe_sca/controllers/__init__.py3
-rw-r--r--addons/payment_stripe_sca/controllers/main.py68
-rw-r--r--addons/payment_stripe_sca/models/__init__.py3
-rw-r--r--addons/payment_stripe_sca/models/payment.py368
-rw-r--r--addons/payment_stripe_sca/static/description/icon.pngbin0 -> 25859 bytes
-rw-r--r--addons/payment_stripe_sca/static/description/icon.svg1
-rw-r--r--addons/payment_stripe_sca/static/description/index.html61
-rw-r--r--addons/payment_stripe_sca/static/description/redirection_flow.pngbin0 -> 39730 bytes
-rw-r--r--addons/payment_stripe_sca/static/description/s2s_flow.pngbin0 -> 14935 bytes
-rw-r--r--addons/payment_stripe_sca/static/src/js/payment_form.js187
-rw-r--r--addons/payment_stripe_sca/static/src/js/payment_processing.js48
-rw-r--r--addons/payment_stripe_sca/static/src/js/stripe_sca.js81
-rw-r--r--addons/payment_stripe_sca/views/assets.xml8
-rw-r--r--addons/payment_stripe_sca/views/payment_templates.xml42
-rw-r--r--addons/point_of_sale/static/src/css/pos.css7
-rw-r--r--addons/point_of_sale/static/src/js/screens.js2
-rw-r--r--addons/product/models/product.py1
-rw-r--r--addons/product/report/product_pricelist_templates.xml6
-rw-r--r--addons/sale/models/sale.py4
-rw-r--r--addons/sale_mrp/tests/test_sale_mrp_procurement.py102
-rw-r--r--addons/snailmail/models/snailmail_letter.py2
-rw-r--r--addons/stock/models/stock_move.py2
-rw-r--r--addons/stock/models/stock_warehouse.py5
-rw-r--r--addons/web/i18n/es.po7
-rw-r--r--addons/web/i18n/web.pot6
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js101
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js3
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar.js20
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_facet.js20
-rw-r--r--addons/web/static/src/js/views/kanban/search_panel.js5
-rw-r--r--addons/web/static/src/js/widgets/auto_complete.js14
-rw-r--r--addons/web/static/src/scss/banner.scss3
-rw-r--r--addons/web/static/src/scss/modal.scss5
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js441
-rw-r--r--addons/web/static/tests/views/search_panel_tests.js55
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/media.js6
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media.js2
-rw-r--r--addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js11
-rw-r--r--addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js11
-rw-r--r--addons/website/static/src/js/editor/wysiwyg_multizone.js8
-rw-r--r--addons/website_google_map/controllers/main.py2
-rw-r--r--addons/website_twitter/static/src/js/website.twitter.animation.js1
-rw-r--r--doc/reference/reports.rst2
-rw-r--r--odoo/addons/base/i18n/base.pot6
-rw-r--r--odoo/addons/base/models/ir_mail_server.py2
-rw-r--r--odoo/addons/base/models/ir_qweb_fields.py6
-rw-r--r--odoo/addons/base/models/res_users.py38
-rw-r--r--odoo/addons/base/tests/test_ir_sequence_date_range.py2
-rw-r--r--odoo/addons/base/tests/test_user_has_group.py18
-rw-r--r--odoo/addons/base/views/ir_qweb_widget_templates.xml4
-rw-r--r--odoo/addons/base/views/res_partner_views.xml2
-rw-r--r--odoo/addons/base/wizard/base_language_install.py2
-rw-r--r--odoo/models.py2
83 files changed, 2006 insertions, 123 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
new file mode 100644
index 00000000000..497b2d65bd9
--- /dev/null
+++ b/addons/payment_stripe_sca/static/description/icon.png
Binary files differ
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
new file mode 100644
index 00000000000..593fb5e1aab
--- /dev/null
+++ b/addons/payment_stripe_sca/static/description/redirection_flow.png
Binary files differ
diff --git a/addons/payment_stripe_sca/static/description/s2s_flow.png b/addons/payment_stripe_sca/static/description/s2s_flow.png
new file mode 100644
index 00000000000..1211a8deb23
--- /dev/null
+++ b/addons/payment_stripe_sca/static/description/s2s_flow.png
Binary files differ
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',
diff --git a/doc/reference/reports.rst b/doc/reference/reports.rst
index ee36f9bfd27..f12efdcab07 100644
--- a/doc/reference/reports.rst
+++ b/doc/reference/reports.rst
@@ -153,7 +153,7 @@ For example, let's look at the Sale Order report from the Sale module::
<!-- Translatable template -->
<template id="report_saleorder_document">
<!-- Re-browse of the record with the partner lang -->
- <t t-set="doc" t-value="doc.with_context({'lang':doc.partner_id.lang})" />
+ <t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)" />
<t t-call="web.external_layout">
<div class="page">
<div class="oe_structure"/>
diff --git a/odoo/addons/base/i18n/base.pot b/odoo/addons/base/i18n/base.pot
index 5602a3b204c..dc5c2c677e4 100644
--- a/odoo/addons/base/i18n/base.pot
+++ b/odoo/addons/base/i18n/base.pot
@@ -7363,6 +7363,12 @@ msgstr ""
#. module: base
#: code:addons/base/models/ir_qweb_fields.py:610
#, python-format
+msgid "Barcode symbology"
+msgstr ""
+
+#. module: base
+#: code:addons/base/models/ir_qweb_fields.py:603
+#, python-format
msgid "Barcode type"
msgstr ""
diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py
index 9ab49f9e5c5..84dea41c5e2 100644
--- a/odoo/addons/base/models/ir_mail_server.py
+++ b/odoo/addons/base/models/ir_mail_server.py
@@ -126,7 +126,7 @@ def encode_rfc2822_address_header(header_text):
return formataddr((name, email))
except UnicodeEncodeError:
_logger.warning(_('Failed to encode the address %s\n'
- 'from mail header:\n%s') % addr, header_text)
+ 'from mail header:\n%s') % (addr, header_text))
return ""
addresses = getaddresses([pycompat.to_text(ustr(header_text))])
diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py
index 57e7f3891f5..dc520809f6e 100644
--- a/odoo/addons/base/models/ir_qweb_fields.py
+++ b/odoo/addons/base/models/ir_qweb_fields.py
@@ -607,7 +607,7 @@ class BarcodeConverter(models.AbstractModel):
def get_available_options(self):
options = super(BarcodeConverter, self).get_available_options()
options.update(
- type=dict(type='string', string=_('Barcode type'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'),
+ symbology=dict(type='string', string=_('Barcode symbology'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'),
width=dict(type='integer', string=_('Width'), default_value=600),
height=dict(type='integer', string=_('Height'), default_value=100),
humanreadable=dict(type='integer', string=_('Human Readable'), default_value=0),
@@ -616,9 +616,9 @@ class BarcodeConverter(models.AbstractModel):
@api.model
def value_to_html(self, value, options=None):
- barcode_type = options.get('type', 'Code128')
+ barcode_symbology = options.get('symbology', 'Code128')
barcode = self.env['ir.actions.report'].barcode(
- barcode_type,
+ barcode_symbology,
value,
**{key: value for key, value in options.items() if key in ['width', 'height', 'humanreadable']})
return u'<img src="data:png;base64,%s">' % base64.b64encode(barcode).decode('ascii')
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index 204ce97f6fe..2fb44ff6382 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -405,11 +405,44 @@ class Users(models.Model):
@api.multi
@api.constrains('groups_id')
def _check_one_user_type(self):
- for user in self:
- if len(user.groups_id.filtered(lambda x: x.category_id.xml_id == 'base.module_category_user_type')) > 1:
+ """We check that no users are both portal and users (same with public).
+ This could typically happen because of implied groups.
+ """
+ user_types_category = self.env.ref('base.module_category_user_type', raise_if_not_found=False)
+ user_types_groups = self.env['res.groups'].search(
+ [('category_id', '=', user_types_category.id)]) if user_types_category else False
+ if user_types_groups: # needed at install
+ if self._has_multiple_groups(user_types_groups.ids):
raise ValidationError(_('The user cannot have more than one user types.'))
@api.multi
+ def _has_multiple_groups(self, group_ids):
+ """The method is not fast if the list of ids is very long;
+ so we rather check all users than limit to the size of the group
+ :param group_ids: list of group ids
+ :return: boolean: is there at least a user in at least 2 of the provided groups
+ """
+ if group_ids:
+ args = [tuple(group_ids)]
+ if len(self.ids) == 1:
+ where_clause = "AND r.uid = %s"
+ args.append(self.id)
+ else:
+ where_clause = "" # default; we check ALL users (actually pretty efficient)
+ query = """
+ SELECT 1 FROM res_groups_users_rel WHERE EXISTS(
+ SELECT r.uid
+ FROM res_groups_users_rel r
+ WHERE r.gid IN %s""" + where_clause + """
+ GROUP BY r.uid HAVING COUNT(r.gid) > 1
+ )
+ """
+ self.env.cr.execute(query, args)
+ return bool(self.env.cr.fetchall())
+ else:
+ return False
+
+ @api.multi
def toggle_active(self):
for user in self:
if not user.active and not user.partner_id.active:
@@ -951,6 +984,7 @@ class GroupsImplied(models.Model):
JOIN group_imply i ON (r.gid = i.hid)
WHERE i.gid = %(gid)s
""", dict(gid=group.id))
+ self._check_one_user_type()
return res
class UsersImplied(models.Model):
diff --git a/odoo/addons/base/tests/test_ir_sequence_date_range.py b/odoo/addons/base/tests/test_ir_sequence_date_range.py
index 08729f3a072..43e218f1189 100644
--- a/odoo/addons/base/tests/test_ir_sequence_date_range.py
+++ b/odoo/addons/base/tests/test_ir_sequence_date_range.py
@@ -24,7 +24,7 @@ class TestIrSequenceDateRangeStandard(SingleTransactionCase):
year = date.today().year - 1
january = lambda d: date(year, 1, d)
- seq16 = self.env['ir.sequence'].with_context({'ir_sequence_date': january(16)})
+ seq16 = self.env['ir.sequence'].with_context(ir_sequence_date=january(16))
n = seq16.next_by_code('test_sequence_date_range')
self.assertEqual(n, '1')
n = seq16.next_by_code('test_sequence_date_range')
diff --git a/odoo/addons/base/tests/test_user_has_group.py b/odoo/addons/base/tests/test_user_has_group.py
index 70169a6f99a..18326607f06 100644
--- a/odoo/addons/base/tests/test_user_has_group.py
+++ b/odoo/addons/base/tests/test_user_has_group.py
@@ -143,3 +143,21 @@ class TestHasGroup(TransactionCase):
})
with self.assertRaises(ValidationError):
self.grp_internal.users = [(4, test_user.id)]
+
+ def test_two_user_types_implied_groups(self):
+ """Contrarily to test_two_user_types, we simply add an implied_id to a group.
+ This will trigger the addition of the relevant users to the relevant groups;
+ if, say, this was done in SQL and thus bypassing the ORM, it would bypass the constraints
+ and thus give us a case uncovered by the aforementioned test.
+ """
+ grp_test = self.env["res.groups"].create(
+ {"name": "test", "implied_ids": [(6, 0, [self.grp_internal.id])]})
+
+ test_user = self.env['res.users'].create({
+ 'login': 'test_user_portal',
+ 'name': "Test User with one user types",
+ 'groups_id': [(6, 0, [grp_test.id])]
+ })
+
+ with self.assertRaises(ValidationError):
+ grp_test.write({'implied_ids': [(4, self.grp_portal.id)]})
diff --git a/odoo/addons/base/views/ir_qweb_widget_templates.xml b/odoo/addons/base/views/ir_qweb_widget_templates.xml
index 47bf04a62ff..4f141cadb71 100644
--- a/odoo/addons/base/views/ir_qweb_widget_templates.xml
+++ b/odoo/addons/base/views/ir_qweb_widget_templates.xml
@@ -13,7 +13,7 @@
</div>
</template>
<template id="contact">
- <address t-ignore="true" class="d-inline-block mb-0" itemscope="itemscope" itemtype="http://schema.org/Organization">
+ <address t-ignore="true" class="mb-0" itemscope="itemscope" itemtype="http://schema.org/Organization">
<div t-if="not (('name' in fields) or (address and 'address' in fields) or (city and 'city' in fields) or (mobile and 'mobile' in fields) or (website and 'website' in fields) or (email and 'email' in fields))" class="css_non_editable_mode_hidden">
--<span class="text-muted" t-esc="name"/>--
</div>
@@ -23,7 +23,7 @@
<div itemprop="address" itemscope="itemscope" itemtype="http://schema.org/PostalAddress">
<div t-if="address and 'address' in fields" class="d-flex align-items-baseline">
<i t-if="not options.get('no_marker')" class="fa fa-map-marker fa-fw" role="img" aria-label="Address" title="Address"/>
- <span itemprop="streetAddress" t-raw="address.replace('\n', options.get('no_tag_br') and ', ' or ('&lt;br/&gt;%s' % ('' if options.get('no_marker') else '')))"/>
+ <div class="w-100" itemprop="streetAddress" t-raw="address.replace('\n', options.get('no_tag_br') and ', ' or ('&lt;br/&gt;%s' % ('' if options.get('no_marker') else '')))"/>
</div>
<div t-if="city and 'city' in fields" class="d-flex align-items-baseline">
<i t-if="not options.get('no_marker')" class="fa fa-map-marker fa-fw" role="img" aria-label="Address" title="Address"/>
diff --git a/odoo/addons/base/views/res_partner_views.xml b/odoo/addons/base/views/res_partner_views.xml
index f8c9ba3d7af..b6d3d414be8 100644
--- a/odoo/addons/base/views/res_partner_views.xml
+++ b/odoo/addons/base/views/res_partner_views.xml
@@ -519,7 +519,7 @@
<img class="o_kanban_image_inner_pic" t-if="record.parent_id.raw_value" t-att-alt="record.parent_id.value" t-att-src="kanban_image('res.partner', 'image_small', record.parent_id.raw_value)"/>
</div>
</t>
- <t t-else="">
+ <t t-elif="record.image_medium.raw_value">
<t t-set="placeholder" t-value="'/base/static/img/company_image.png'"/>
<div class="o_kanban_image_fill_left o_kanban_image_full" t-attf-style="background-image: url(#{kanban_image('res.partner', 'image_medium', record.id.raw_value, placeholder)})" role="img"/>
</t>
diff --git a/odoo/addons/base/wizard/base_language_install.py b/odoo/addons/base/wizard/base_language_install.py
index 438803ee388..376d1fd5751 100644
--- a/odoo/addons/base/wizard/base_language_install.py
+++ b/odoo/addons/base/wizard/base_language_install.py
@@ -35,6 +35,8 @@ class BaseLanguageInstall(models.TransientModel):
mods = self.env['ir.module.module'].search([('state', '=', 'installed')])
mods.with_context(overwrite=self.overwrite)._update_translations(self.lang)
self.state = 'done'
+ self.env.cr.execute('ANALYZE ir_translation')
+
return {
'name': _('Language Pack'),
'view_type': 'form',
diff --git a/odoo/models.py b/odoo/models.py
index 7d6962f0e23..36ee8d1495a 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -5306,7 +5306,7 @@ Fields:
target0 = Model.browse(i for [i] in self.env.cr.fetchall())
else:
env = self.env(user=SUPERUSER_ID, context={'active_test': False})
- target0 = env[model_name].search([(path, 'in', self.ids)])
+ target0 = env[model_name].search([(path, 'in', self.ids)], order='id')
target0 = target0.with_env(self.env)
# prepare recomputation for each field on linked records
for field in stored: