summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
Diffstat (limited to 'addons')
-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
72 files changed, 1934 insertions, 111 deletions
diff --git a/addons/account/i18n/account.pot b/addons/account/i18n/account.pot
index 1c0c16cccab..43deedf6f9d 100644
--- a/addons/account/i18n/account.pot
+++ b/addons/account/i18n/account.pot
@@ -758,7 +758,6 @@ msgstr ""
#, python-format
msgid ""
"A user cannot have both Tax B2B and Tax B2C.\n"
-"Problematic user(s): %s\n"
"You should go in General Settings, and choose to display Product Prices\n"
"either in 'Tax-Included' or in 'Tax-Excluded' mode\n"
"(or switch twice the mode if you are already in the desired one)."
diff --git a/addons/account/models/account.py b/addons/account/models/account.py
index 85896ad39d1..7b4288e8b39 100644
--- a/addons/account/models/account.py
+++ b/addons/account/models/account.py
@@ -279,13 +279,13 @@ class AccountAccount(models.Model):
if opening_move_line:
if amount:
# modify the line
- setattr(opening_move_line.with_context({'check_move_validity': False}), field, amount)
+ opening_move_line.with_context(check_move_validity=False)[field] = amount
elif counter_part_map[field]:
# delete the line (no need to keep a line with value = 0)
- opening_move_line.with_context({'check_move_validity': False}).unlink()
+ opening_move_line.with_context(check_move_validity=False).unlink()
elif amount:
# create a new line, as none existed before
- self.env['account.move.line'].with_context({'check_move_validity': False}).create({
+ self.env['account.move.line'].with_context(check_move_validity=False).create({
'name': _('Opening balance'),
field: amount,
'move_id': opening_move.id,
@@ -713,7 +713,7 @@ class AccountJournal(models.Model):
return {
'alias_defaults': {'type': type == 'purchase' and 'in_invoice' or 'out_invoice', 'company_id': self.company_id.id},
'alias_parent_thread_id': self.id,
- 'alias_name': re.sub(r'[^\w]+', '-', alias_name)
+ 'alias_name': alias_name,
}
@api.multi
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index 280869974e9..828dac97a6a 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -330,6 +330,10 @@ class account_payment(models.Model):
def name_get(self):
return [(payment.id, payment.name or _('Draft Payment')) for payment in self]
+ @api.model
+ def _get_move_name_transfer_separator(self):
+ return 'ยงยง'
+
@api.multi
@api.depends('move_line_ids.reconciled')
def _get_move_reconciled(self):
@@ -457,7 +461,6 @@ class account_payment(models.Model):
move.unlink()
rec.write({
'state': 'cancelled',
- 'move_name': False,
})
@api.multi
diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py
index 25f08790c9b..7bc7ea17d86 100644
--- a/addons/account/models/chart_template.py
+++ b/addons/account/models/chart_template.py
@@ -224,7 +224,7 @@ class AccountChartTemplate(models.Model):
acc_template_ref, taxes_ref = self._install_template(company, code_digits=self.code_digits)
# Set the transfer account on the company
- company.transfer_account_id = self.env['account.account'].search([('code', '=like', self.transfer_account_code_prefix + '%')])[0]
+ company.transfer_account_id = self.env['account.account'].search([('code', '=like', self.transfer_account_code_prefix + '%')])[:1]
# Create Bank journals
self._create_bank_journals(company, acc_template_ref)
@@ -245,7 +245,7 @@ class AccountChartTemplate(models.Model):
"""
model_to_check = ['account.move', 'account.payment', 'account.bank.statement']
for model in model_to_check:
- if len(self.env[model].search([('company_id', '=', company_id.id)])) > 0:
+ if self.env[model].sudo().search([('company_id', '=', company_id.id)], limit=1):
return True
return False
diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py
index 213cc97abcc..83b5b7c9afc 100644
--- a/addons/account/models/partner.py
+++ b/addons/account/models/partner.py
@@ -204,7 +204,7 @@ class ResPartner(models.Model):
@api.multi
def _credit_debit_get(self):
- tables, where_clause, where_params = self.env['account.move.line'].with_context(company_id=self.env.company.id)._query_get()
+ tables, where_clause, where_params = self.env['account.move.line'].with_context(state='posted', company_id=self.env.company.id)._query_get()
where_params = [tuple(self.ids)] + where_params
if where_clause:
where_clause = 'AND ' + where_clause
@@ -238,9 +238,11 @@ class ResPartner(models.Model):
SELECT partner.id
FROM res_partner partner
LEFT JOIN account_move_line aml ON aml.partner_id = partner.id
+ JOIN account_move move ON move.id = aml.move_id
RIGHT JOIN account_account acc ON aml.account_id = acc.id
WHERE acc.internal_type = %s
AND NOT acc.deprecated AND acc.company_id = %s
+ AND move.state == 'posted'
GROUP BY partner.id
HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand))
res = self._cr.fetchall()
@@ -308,7 +310,14 @@ class ResPartner(models.Model):
partner.contracts_count = AccountAnalyticAccount.search_count([('partner_id', '=', partner.id)])
def get_followup_lines_domain(self, date, overdue_only=False, only_unblocked=False):
- domain = [('reconciled', '=', False), ('account_id.deprecated', '=', False), ('account_id.internal_type', '=', 'receivable'), '|', ('debit', '!=', 0), ('credit', '!=', 0), ('company_id', '=', self.env.company.id)]
+ domain = [
+ ('reconciled', '=', False),
+ ('account_id.deprecated', '=', False),
+ ('account_id.internal_type', '=', 'receivable'),
+ '|', ('debit', '!=', 0), ('credit', '!=', 0),
+ ('company_id', '=', self.env.company.id),
+ ('move_id.state', '=', 'posted'),
+ ]
if only_unblocked:
domain += [('blocked', '=', False)]
if self.ids:
diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py
index 8e503f07b80..85a755c2920 100644
--- a/addons/account/models/reconciliation_widget.py
+++ b/addons/account/models/reconciliation_widget.py
@@ -4,6 +4,7 @@ from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools.misc import formatLang
+from odoo.tools import misc
class AccountReconciliation(models.AbstractModel):
@@ -585,6 +586,8 @@ class AccountReconciliation(models.AbstractModel):
for line in move_lines:
company_currency = line.company_id.currency_id
line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency
+ date_maturity = misc.format_date(self.env, line.date_maturity, lang_code=self.env.user.lang)
+
ret_line = {
'id': line.id,
'name': line.name and line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name,
@@ -596,7 +599,7 @@ class AccountReconciliation(models.AbstractModel):
'account_code': line.account_id.code,
'account_name': line.account_id.name,
'account_type': line.account_id.internal_type,
- 'date_maturity': line.date_maturity,
+ 'date_maturity': date_maturity,
'date': line.date,
'journal_id': [line.journal_id.id, line.journal_id.display_name],
'partner_id': line.partner_id.id,
@@ -686,13 +689,14 @@ class AccountReconciliation(models.AbstractModel):
amount_currency = amount
amount_currency_str = ""
amount_str = formatLang(self.env, abs(amount), currency_obj=st_line.currency_id or statement_currency)
+ date = misc.format_date(self.env, st_line.date, lang_code=self.env.user.lang)
data = {
'id': st_line.id,
'ref': st_line.ref,
'note': st_line.note or "",
'name': st_line.name,
- 'date': st_line.date,
+ 'date': date,
'amount': amount,
'amount_str': amount_str, # Amount in the statement line currency
'currency_id': st_line.currency_id.id or statement_currency.id,
diff --git a/addons/account/models/res_users.py b/addons/account/models/res_users.py
index 8030423e74d..013acde54ff 100644
--- a/addons/account/models/res_users.py
+++ b/addons/account/models/res_users.py
@@ -13,14 +13,11 @@ class Users(models.Model):
def _check_one_user_type(self):
super(Users, self)._check_one_user_type()
- users_with_both_groups = self.filtered(lambda user:
- user.has_group('account.group_show_line_subtotals_tax_included') and
- user.has_group('account.group_show_line_subtotals_tax_excluded')
- )
- if users_with_both_groups:
- names = ", ".join(users_with_both_groups.mapped('name'))
+ g1 = self.env.ref('account.group_show_line_subtotals_tax_included')
+ g2 = self.env.ref('account.group_show_line_subtotals_tax_excluded')
+
+ if self._has_multiple_groups([g1.id, g2.id]):
raise ValidationError(_("A user cannot have both Tax B2B and Tax B2C.\n"
- "Problematic user(s): %s\n"
"You should go in General Settings, and choose to display Product Prices\n"
"either in 'Tax-Included' or in 'Tax-Excluded' mode\n"
- "(or switch twice the mode if you are already in the desired one).") % names)
+ "(or switch twice the mode if you are already in the desired one)."))
diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py
index fb83cc45a54..d180e09b396 100644
--- a/addons/account/tests/test_payment.py
+++ b/addons/account/tests/test_payment.py
@@ -33,6 +33,8 @@ class TestPayment(AccountingTestCase):
self.bank_journal_euro = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'BNK67'})
self.account_eur = self.bank_journal_euro.default_debit_account_id
+ self.cash_journal_euro = self.env['account.journal'].create({'name': 'Cash', 'type': 'cash', 'code': 'CASH'})
+
self.bank_journal_usd = self.env['account.journal'].create({'name': 'Bank US', 'type': 'bank', 'code': 'BNK68', 'currency_id': self.currency_usd_id})
self.account_usd = self.bank_journal_usd.default_debit_account_id
@@ -425,3 +427,119 @@ class TestPayment(AccountingTestCase):
# The invoice should now be paid
self.assertEqual(invoice.invoice_payment_state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement")
+
+ def test_payment_cancel_keep_name(self):
+ self.bank_journal_euro.update_posted = True
+
+ payment = self.payment_model.create({
+ 'payment_type': 'inbound',
+ 'payment_method_id': self.payment_method_manual_in.id,
+ 'partner_type': 'customer',
+ 'partner_id': self.partner_agrolait.id,
+ 'amount': 90,
+ 'payment_date': time.strftime('%Y') + '-07-15',
+ 'payment_difference_handling': 'reconcile',
+ 'journal_id': self.bank_journal_euro.id,
+ })
+
+ payment.post()
+ self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1)
+ name = payment.move_line_ids.mapped('move_id').name
+ self.assertTrue(name)
+
+ payment.cancel()
+ self.assertFalse(payment.move_line_ids.mapped('move_id'))
+ payment.action_draft()
+
+ payment.post()
+ self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1)
+ self.assertEqual(name, payment.move_line_ids.mapped('move_id').name)
+
+ def test_payment_transfer_cancel_keep_names(self):
+ self.bank_journal_euro.update_posted = True
+ self.cash_journal_euro.update_posted = True
+
+ payment = self.payment_model.create({
+ 'payment_type': 'transfer',
+ 'payment_method_id': self.payment_method_manual_out.id,
+ 'amount': 90,
+ 'payment_date': time.strftime('%Y') + '-07-15',
+ 'journal_id': self.bank_journal_euro.id,
+ 'destination_journal_id': self.cash_journal_euro.id,
+ })
+
+ payment.post()
+ self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 2)
+
+ all_moves = payment.move_line_ids.mapped('move_id')
+ move = all_moves.filtered(lambda m: m.journal_id == self.bank_journal_euro)
+ transfer_move = all_moves - move
+ self.assertEqual(transfer_move.journal_id, self.cash_journal_euro)
+
+ name = move.name
+ transfer_name = transfer_move.name
+ self.assertTrue(name)
+ self.assertTrue(transfer_name)
+ self.assertNotEqual(name, transfer_name)
+
+ reconciled_lines = payment.move_line_ids.filtered(lambda l: l.reconciled)
+ self.assertEqual(len(reconciled_lines), 2)
+ self.assertEqual(reconciled_lines.mapped('move_id'), all_moves)
+
+ reconciled_lines.remove_move_reconcile()
+ payment.cancel()
+ self.assertFalse(payment.move_line_ids.mapped('move_id'))
+ payment.action_draft()
+
+ payment.post()
+ self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 2)
+
+ all_moves = payment.move_line_ids.mapped('move_id')
+ move = all_moves.filtered(lambda m: m.journal_id == self.bank_journal_euro)
+ transfer_move = all_moves - move
+ self.assertEqual(transfer_move.journal_id, self.cash_journal_euro)
+
+ self.assertEqual(name, move.name)
+ self.assertEqual(transfer_name, transfer_move.name)
+
+ def test_payment_cancel_to_transfer(self):
+ self.bank_journal_euro.update_posted = True
+
+ payment = self.payment_model.create({
+ 'payment_type': 'inbound',
+ 'payment_method_id': self.payment_method_manual_in.id,
+ 'partner_type': 'customer',
+ 'partner_id': self.partner_agrolait.id,
+ 'amount': 90,
+ 'payment_date': time.strftime('%Y') + '-07-15',
+ 'payment_difference_handling': 'reconcile',
+ 'journal_id': self.bank_journal_euro.id,
+ })
+
+ payment.post()
+ self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1)
+ name = payment.move_line_ids.mapped('move_id').name
+ self.assertTrue(name)
+
+ payment.cancel()
+ self.assertFalse(payment.move_line_ids.mapped('move_id'))
+ payment.action_draft()
+
+ payment.write({
+ 'payment_type': 'transfer',
+ 'payment_method_id': self.payment_method_manual_out.id,
+ 'partner_id': False,
+ 'destination_journal_id': self.cash_journal_euro.id,
+ })
+
+ payment.post()
+ self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 2)
+
+ all_moves = payment.move_line_ids.mapped('move_id')
+ move = all_moves.filtered(lambda m: m.journal_id == self.bank_journal_euro)
+ transfer_move = all_moves - move
+ self.assertEqual(transfer_move.journal_id, self.cash_journal_euro)
+
+ self.assertEqual(name, move.name)
+ self.assertTrue(transfer_move.name)
+ self.assertNotEqual(name, transfer_move.name)
diff --git a/addons/account/views/report_payment_receipt_templates.xml b/addons/account/views/report_payment_receipt_templates.xml
index 3da12afe644..19f692b229f 100644
--- a/addons/account/views/report_payment_receipt_templates.xml
+++ b/addons/account/views/report_payment_receipt_templates.xml
@@ -2,7 +2,7 @@
<odoo>
<template id="report_payment_receipt_document">
<t t-call="web.external_layout">
- <t t-set="o" t-value="o.with_context({'lang': lang})"/>
+ <t t-set="o" t-value="o.with_context(lang=lang)"/>
<div class="page">
<h3><strong>Payment Receipt: <span t-field="o.name"/></strong></h3>
<div class="row mt64">
diff --git a/addons/delivery/models/stock_move.py b/addons/delivery/models/stock_move.py
index 805aaf5bfa7..2b489259da6 100644
--- a/addons/delivery/models/stock_move.py
+++ b/addons/delivery/models/stock_move.py
@@ -9,7 +9,7 @@ from odoo.addons import decimal_precision as dp
class StockMove(models.Model):
_inherit = 'stock.move'
- weight = fields.Float(compute='_cal_move_weight', digits=dp.get_precision('Stock Weight'), store=True)
+ weight = fields.Float(compute='_cal_move_weight', digits=dp.get_precision('Stock Weight'), store=True, compute_sudo=True)
@api.depends('product_id', 'product_uom_qty', 'product_uom')
def _cal_move_weight(self):
diff --git a/addons/delivery/models/stock_picking.py b/addons/delivery/models/stock_picking.py
index 05852f12542..cfcb45b874e 100644
--- a/addons/delivery/models/stock_picking.py
+++ b/addons/delivery/models/stock_picking.py
@@ -76,7 +76,7 @@ class StockPicking(models.Model):
delivery_type = fields.Selection(related='carrier_id.delivery_type', readonly=True)
carrier_id = fields.Many2one("delivery.carrier", string="Carrier", domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
volume = fields.Float(copy=False)
- weight = fields.Float(compute='_cal_weight', digits=dp.get_precision('Stock Weight'), store=True, help="Total weight of the products in the picking.")
+ weight = fields.Float(compute='_cal_weight', digits=dp.get_precision('Stock Weight'), store=True, help="Total weight of the products in the picking.", compute_sudo=True)
carrier_tracking_ref = fields.Char(string='Tracking Reference', copy=False)
carrier_tracking_url = fields.Char(string='Tracking URL', compute='_compute_carrier_tracking_url')
weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name', readonly=True, default=_get_default_weight_uom)
diff --git a/addons/event/models/event.py b/addons/event/models/event.py
index e2b54887bbd..bea451ea666 100644
--- a/addons/event/models/event.py
+++ b/addons/event/models/event.py
@@ -562,7 +562,7 @@ class EventRegistration(models.Model):
elif event_date.month == (today + relativedelta(months=+1)).month:
return _('next month')
else:
- return _('on ') + format_tz(self.with_context({'use_babel': True}).env, self.event_begin_date, tz=self.event_id.date_tz or 'UTC')
+ return _('on ') + format_tz(self.with_context(use_babel=True).env, self.event_begin_date, tz=self.event_id.date_tz or 'UTC')
@api.multi
def summary(self):
diff --git a/addons/event_sale/models/event.py b/addons/event_sale/models/event.py
index 3d3e9095a43..b2d027e052e 100644
--- a/addons/event_sale/models/event.py
+++ b/addons/event_sale/models/event.py
@@ -96,7 +96,7 @@ class EventTicket(models.Model):
def _compute_is_expired(self):
for record in self:
if record.deadline:
- current_date = fields.Date.context_today(record.with_context({'tz': record.event_id.date_tz}))
+ current_date = fields.Date.context_today(record.with_context(tz=record.event_id.date_tz))
record.is_expired = record.deadline < current_date
else:
record.is_expired = False
diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js
index c6b59683316..a3c01252867 100644
--- a/addons/im_livechat/static/src/js/im_livechat.js
+++ b/addons/im_livechat/static/src/js/im_livechat.js
@@ -125,7 +125,7 @@ var LivechatButton = Widget.extend({
var message = new WebsiteLivechatMessage(this, data, options);
var hasAlreadyMessage = _.some(this._messages, function (msg) {
- return message.id === msg.id;
+ return message.getID() === msg.getID();
});
if (hasAlreadyMessage) {
return;
diff --git a/addons/l10n_de/static/src/scss/report_din5008.scss b/addons/l10n_de/static/src/scss/report_din5008.scss
index cdecd43eda7..801ef16637c 100644
--- a/addons/l10n_de/static/src/scss/report_din5008.scss
+++ b/addons/l10n_de/static/src/scss/report_din5008.scss
@@ -21,6 +21,7 @@
top: 17.7mm;
width: 85mm;
height: 27.3mm;
+ line-height: 1.15;
overflow-y: hidden;
word-break: break-word;
float: left;
diff --git a/addons/l10n_fr/data/account_tax_data.xml b/addons/l10n_fr/data/account_tax_data.xml
index f8938c2f145..3f72c43b63e 100644
--- a/addons/l10n_fr/data/account_tax_data.xml
+++ b/addons/l10n_fr/data/account_tax_data.xml
@@ -1683,7 +1683,7 @@
<record model="account.tax.template" id="tva_export_0">
<field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/>
<field name="name">TVA 0% export (vente)</field>
- <field name="description">TVA 0% Import</field>
+ <field name="description">TVA 0% Export</field>
<field name="amount" eval="0.00"/>
<field name="amount_type">percent</field>
<field name="sequence" eval="10"/>
diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py
index 51ddb70c94c..8905c62d8f1 100644
--- a/addons/l10n_multilang/models/l10n_multilang.py
+++ b/addons/l10n_multilang/models/l10n_multilang.py
@@ -125,6 +125,9 @@ class AccountChartTemplate(models.Model):
in_xml_ids = {xml_id.name: xml_id for xml_id in in_xml_ids}
for name, xml_id in expected_in_xml_id_names.items():
+ # ignore nonconforming customized data
+ if name not in in_xml_ids:
+ continue
in_records += self.env[model + '.template'].browse(in_xml_ids[name].res_id)
out_records += self.env[model].browse(xml_id.res_id)
diff --git a/addons/lunch/static/tests/lunch_kanban_tests.js b/addons/lunch/static/tests/lunch_kanban_tests.js
index 8f64e7b0481..607bca08e13 100644
--- a/addons/lunch/static/tests/lunch_kanban_tests.js
+++ b/addons/lunch/static/tests/lunch_kanban_tests.js
@@ -261,6 +261,55 @@ QUnit.module('LunchKanbanView', {
kanban.destroy();
});
+ QUnit.test('search panel domain location false: fetch products in all locations', async function (assert) {
+ assert.expect(10);
+ const regularInfos = _.extend({}, this.regularInfos);
+
+ const kanban = await createLunchKanbanView({
+ View: LunchKanbanView,
+ model: 'product',
+ data: this.data,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="name"/></div>
+ </t>
+ </templates>
+ </kanban>
+ `,
+ mockRPC: function (route, args) {
+ assert.step(route);
+
+ if (route.startsWith('/lunch')) {
+ return mockLunchRPC({
+ infos: regularInfos,
+ userLocation: false,
+ }).apply(this, arguments);
+ }
+ if (args.method === 'search_panel_select_multi_range') {
+ assert.deepEqual(args.kwargs.search_domain, [],
+ 'The domain should not exist since the location is false.');
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, [],
+ 'The domain for fetching actual data should be correct');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+ assert.verifySteps([
+ '/lunch/user_location_get',
+ '/web/dataset/call_kw/product/search_panel_select_multi_range',
+ '/web/dataset/call_kw/product/search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ '/lunch/infos',
+ '/web/dataset/call_kw/ir.model.data/xmlid_to_res_id',
+ ])
+
+ kanban.destroy();
+ });
+
QUnit.test('non-empty cart', async function (assert) {
assert.expect(17);
diff --git a/addons/mail/models/mail_alias.py b/addons/mail/models/mail_alias.py
index 0045ea87989..0a46837d7b2 100644
--- a/addons/mail/models/mail_alias.py
+++ b/addons/mail/models/mail_alias.py
@@ -249,7 +249,7 @@ class AliasMixin(models.AbstractModel):
for record in child_model.search([('alias_id', '=', False)]):
# create the alias, and link it to the current record
alias = alias_model.create(record.get_alias_values())
- record.with_context({'mail_notrack': True}).alias_id = alias
+ record.with_context(mail_notrack=True).alias_id = alias
_logger.info('Mail alias created for %s %s (id %s)',
record._name, record.display_name, record.id)
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index 41580a61a96..091fbcc0150 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -1014,7 +1014,7 @@ class Message(models.Model):
def create(self, values):
# coming from mail.js that does not have pid in its values
if self.env.context.get('default_starred'):
- self = self.with_context({'default_starred_partner_ids': [(4, self.env.user.partner_id.id)]})
+ self = self.with_context(default_starred_partner_ids=[(4, self.env.user.partner_id.id)])
if 'email_from' not in values: # needed to compute reply_to
values['email_from'] = self._get_default_from()
diff --git a/addons/mail_bot/models/mail_bot.py b/addons/mail_bot/models/mail_bot.py
index 2e0ab860255..10686b14ba3 100644
--- a/addons/mail_bot/models/mail_bot.py
+++ b/addons/mail_bot/models/mail_bot.py
@@ -30,7 +30,7 @@ class MailBot(models.AbstractModel):
if answer:
message_type = values.get('message_type', 'comment')
subtype_id = values.get('subtype_id', self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment'))
- record.with_context({"mail_create_nosubscribe": True}).sudo().message_post(body=answer, author_id=odoobot_id, message_type=message_type, subtype_id=subtype_id)
+ record.with_context(mail_create_nosubscribe=True).sudo().message_post(body=answer, author_id=odoobot_id, message_type=message_type, subtype_id=subtype_id)
def _get_answer(self, record, body, values, command=False):
# onboarding
diff --git a/addons/mail_bot/models/mail_channel.py b/addons/mail_bot/models/mail_channel.py
index efada26b88a..0fc6c6f056b 100644
--- a/addons/mail_bot/models/mail_channel.py
+++ b/addons/mail_bot/models/mail_channel.py
@@ -16,7 +16,7 @@ class Channel(models.Model):
if self.env.user.odoobot_state == 'not_initialized':
partner = self.env.user.partner_id
odoobot_id = self.env['ir.model.data'].xmlid_to_res_id("base.partner_root")
- channel = self.with_context({"mail_create_nosubscribe": True}).create({
+ channel = self.with_context(mail_create_nosubscribe=True).create({
'channel_partner_ids': [(4, partner.id), (4, odoobot_id)],
'public': 'private',
'channel_type': 'chat',
diff --git a/addons/mrp_account/models/product.py b/addons/mrp_account/models/product.py
index 9af9b84d9d6..da15e757cb3 100644
--- a/addons/mrp_account/models/product.py
+++ b/addons/mrp_account/models/product.py
@@ -11,11 +11,13 @@ class ProductTemplate(models.Model):
def action_bom_cost(self):
templates = self.filtered(lambda t: t.product_variant_count == 1 and t.bom_count > 0)
- return templates.mapped('product_variant_id').action_bom_cost()
+ if templates:
+ return templates.mapped('product_variant_id').action_bom_cost()
def button_bom_cost(self):
templates = self.filtered(lambda t: t.product_variant_count == 1 and t.bom_count > 0)
- return templates.mapped('product_variant_id').button_bom_cost()
+ if templates:
+ return templates.mapped('product_variant_id').button_bom_cost()
class ProductProduct(models.Model):
@@ -50,6 +52,8 @@ class ProductProduct(models.Model):
def _compute_bom_price(self, bom, boms_to_recompute=False):
self.ensure_one()
+ if not bom:
+ return 0
if not boms_to_recompute:
boms_to_recompute = []
total = 0
diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py
index db9aec543a6..f2b21f20a3c 100644
--- a/addons/payment/controllers/portal.py
+++ b/addons/payment/controllers/portal.py
@@ -79,7 +79,7 @@ class PaymentProcessing(http.Controller):
# populate the returned dictionnary with the transactions data
for tx in payment_transaction_ids:
message_to_display = tx.acquirer_id[tx.state + '_msg'] if tx.state in ['done', 'pending', 'cancel', 'error'] else None
- result['transactions'].append({
+ tx_info = {
'reference': tx.reference,
'state': tx.state,
'return_url': tx.return_url,
@@ -89,7 +89,9 @@ class PaymentProcessing(http.Controller):
'amount': tx.amount,
'currency': tx.currency_id.name,
'acquirer_provider': tx.acquirer_id.provider,
- })
+ }
+ tx_info.update(tx._get_processing_info())
+ result['transactions'].append(tx_info)
tx_to_process = payment_transaction_ids.filtered(lambda x: x.state == 'done' and x.is_processed is False)
try:
@@ -258,11 +260,7 @@ class WebsitePayment(http.Controller):
try:
res = tx.s2s_do_transaction()
- if tx.state == 'done':
- tx.return_url = return_url or '/website_payment/confirm?tx_id=%d' % tx.id
- valid_state = 'authorized' if tx.acquirer_id.capture_manually else 'done'
- if not res or tx.state != valid_state:
- tx.return_url = '/website_payment/pay?error_msg=%s' % _('Payment transaction failed.')
+ tx.return_url = return_url or '/website_payment/confirm?tx_id=%d' % tx.id
return request.redirect('/payment/process')
except Exception as e:
return request.redirect('/payment/process')
diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py
index 7b747de0ce6..62d35059978 100644
--- a/addons/payment/models/payment_acquirer.py
+++ b/addons/payment/models/payment_acquirer.py
@@ -661,6 +661,10 @@ class PaymentTransaction(models.Model):
transactions = self.filtered(lambda t: t.state != 'draft')
return transactions and transactions[0] or transactions
+ def _get_processing_info(self):
+ """ Extensible method for providers if they need specific fields/info regarding a tx in the payment processing page. """
+ return dict()
+
@api.multi
def _get_payment_transaction_sent_message(self):
self.ensure_one()
diff --git a/addons/payment/static/src/js/payment_processing.js b/addons/payment/static/src/js/payment_processing.js
index ec1028188ec..0424614fe14 100644
--- a/addons/payment/static/src/js/payment_processing.js
+++ b/addons/payment/static/src/js/payment_processing.js
@@ -94,7 +94,7 @@ odoo.define('payment.processing', function (require) {
return nbTx;
}
// if there's only one tx to manage
- if(countTxInState(['tx_done', 'tx_error']) === 1) {
+ if(countTxInState(['tx_done', 'tx_error', 'tx_pending']) === 1) {
var tx = render_values['tx_done'][0] || render_values['tx_error'][0];
if (tx) {
window.location = tx.return_url;
diff --git a/addons/payment_paypal/controllers/main.py b/addons/payment_paypal/controllers/main.py
index f9166ecf302..f3fdad1d223 100644
--- a/addons/payment_paypal/controllers/main.py
+++ b/addons/payment_paypal/controllers/main.py
@@ -76,14 +76,16 @@ class PaypalController(http.Controller):
if resp in ['VERIFIED', 'SUCCESS']:
_logger.info('Paypal: validated data')
res = request.env['payment.transaction'].sudo().form_feedback(post, 'paypal')
- if not res:
+ if not res and tx:
tx.sudo()._set_transaction_error('Validation error occured. Please contact your administrator.')
elif resp in ['INVALID', 'FAIL']:
_logger.warning('Paypal: answered INVALID/FAIL on data verification')
- tx.sudo()._set_transaction_error('Invalid response from Paypal. Please contact your administrator.')
+ if tx:
+ tx.sudo()._set_transaction_error('Invalid response from Paypal. Please contact your administrator.')
else:
_logger.warning('Paypal: unrecognized paypal answer, received %s instead of VERIFIED/SUCCESS or INVALID/FAIL (validation: %s)' % (resp, 'PDT' if pdt_request else 'IPN/DPN'))
- tx.sudo()._set_transaction_error('Unrecognized error from Paypal. Please contact your administrator.')
+ if tx:
+ tx.sudo()._set_transaction_error('Unrecognized error from Paypal. Please contact your administrator.')
return res
@http.route('/payment/paypal/ipn/', type='http', auth='none', methods=['POST'], csrf=False)
diff --git a/addons/payment_payumoney/models/payment.py b/addons/payment_payumoney/models/payment.py
index 91e6023e107..2a4be713e48 100644
--- a/addons/payment_payumoney/models/payment.py
+++ b/addons/payment_payumoney/models/payment.py
@@ -26,7 +26,7 @@ class PaymentAcquirerPayumoney(models.Model):
if environment == 'prod':
return {'payumoney_form_url': 'https://secure.payu.in/_payment'}
else:
- return {'payumoney_form_url': 'https://test.payu.in/_payment'}
+ return {'payumoney_form_url': 'https://sandboxsecure.payu.in/_payment'}
def _payumoney_generate_sign(self, inout, values):
""" Generate the shasign for incoming or outgoing communications.
diff --git a/addons/payment_stripe_sca/__init__.py b/addons/payment_stripe_sca/__init__.py
new file mode 100644
index 00000000000..bb0aae7ee63
--- /dev/null
+++ b/addons/payment_stripe_sca/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import controllers
+from . import models
diff --git a/addons/payment_stripe_sca/__manifest__.py b/addons/payment_stripe_sca/__manifest__.py
new file mode 100644
index 00000000000..6a2a0cac556
--- /dev/null
+++ b/addons/payment_stripe_sca/__manifest__.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+# ____________________
+# / \
+# | DO NOT FORWARD |
+# | PORT FURTHER |
+# | THAN 12.4 |
+# \____________________/
+# ! !
+# ! !
+# L_ !
+# / _)!
+# / /__L
+# _____/ (____)
+# (____)
+# _____ (____)
+# \_(____)
+# ! !
+# ! !
+# \__/
+#
+# Starting with 12.5, the payment_stripe module contains these changes OoB
+
+{
+ 'name': 'Stripe Payment Acquirer - Strong Customer Authentication Update',
+ 'category': 'Hidden',
+ 'summary': 'Payment Acquirer: Stripe Implementation for the EU PSD2',
+ 'version': '1.0',
+ 'description': """Stripe Payment Acquirer - Strong Customer Authentication Update""",
+ 'depends': ['payment_stripe'],
+ 'auto_install': True,
+ 'data': [
+ 'views/assets.xml',
+ 'views/payment_templates.xml',
+ ],
+ 'images': ['static/description/icon.png'],
+}
diff --git a/addons/payment_stripe_sca/controllers/__init__.py b/addons/payment_stripe_sca/controllers/__init__.py
new file mode 100644
index 00000000000..7036966b1ce
--- /dev/null
+++ b/addons/payment_stripe_sca/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import main \ No newline at end of file
diff --git a/addons/payment_stripe_sca/controllers/main.py b/addons/payment_stripe_sca/controllers/main.py
new file mode 100644
index 00000000000..fb1bda1de54
--- /dev/null
+++ b/addons/payment_stripe_sca/controllers/main.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
+import pprint
+import werkzeug
+
+from odoo.http import route, request
+from odoo.addons.payment_stripe.controllers.main import StripeController
+
+_logger = logging.getLogger(__name__)
+
+
+class StripeControllerSCA(StripeController):
+ _success_url = '/payment/stripe/success'
+ _cancel_url = '/payment/stripe/cancel'
+
+ @route(['/payment/stripe/success', '/payment/stripe/cancel'], type='http', auth='public')
+ def stripe_success(self, **kwargs):
+ request.env['payment.transaction'].sudo().form_feedback(kwargs, 'stripe')
+ return werkzeug.utils.redirect('/payment/process')
+
+ @route(['/payment/stripe/s2s/create_json_3ds'], type='json', auth='public', csrf=False)
+ def stripe_s2s_create_json_3ds(self, verify_validity=False, **kwargs):
+ if not kwargs.get('partner_id'):
+ kwargs = dict(kwargs, partner_id=request.env.user.partner_id.id)
+ token = request.env['payment.acquirer'].browse(int(kwargs.get('acquirer_id'))).s2s_process(kwargs)
+
+ if not token:
+ res = {
+ 'result': False,
+ }
+ return res
+
+ res = {
+ 'result': True,
+ 'id': token.id,
+ 'short_name': token.short_name,
+ '3d_secure': False,
+ 'verified': False,
+ }
+
+ if verify_validity != False:
+ token.validate()
+ res['verified'] = token.verified
+
+ return res
+
+ @route('/payment/stripe/s2s/create_setup_intent', type='json', auth='public', csrf=False)
+ def stripe_s2s_create_setup_intent(self, acquirer_id, **kwargs):
+ acquirer = request.env['payment.acquirer'].browse(int(acquirer_id))
+ res = acquirer._create_setup_intent(kwargs)
+ return res.get('client_secret')
+
+
+ @route('/payment/stripe/s2s/process_payment_intent', type='json')
+ def stripe_s2s_process_payment_intent(self, **post):
+ return request.env['payment.transaction'].sudo().form_feedback(post, 'stripe')
+
+
+ # These routes are deprecated, let's remove them for security's sake
+ def stripe_s2s_create_json(self, **post):
+ raise werkzeug.exceptions.NotFound()
+
+ def stripe_s2s_create(self, **post):
+ raise werkzeug.exceptions.NotFound()
+
+ def stripe_create_charge(self, **post):
+ raise werkzeug.exceptions.NotFound()
diff --git a/addons/payment_stripe_sca/models/__init__.py b/addons/payment_stripe_sca/models/__init__.py
new file mode 100644
index 00000000000..df1092a4fa3
--- /dev/null
+++ b/addons/payment_stripe_sca/models/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import payment \ No newline at end of file
diff --git a/addons/payment_stripe_sca/models/payment.py b/addons/payment_stripe_sca/models/payment.py
new file mode 100644
index 00000000000..01093ab6d7f
--- /dev/null
+++ b/addons/payment_stripe_sca/models/payment.py
@@ -0,0 +1,368 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
+import requests
+import pprint
+from werkzeug import urls
+
+from odoo import api, models, fields, _
+from odoo.exceptions import ValidationError
+from odoo.addons.payment_stripe_sca.controllers.main import StripeControllerSCA as StripeController
+from odoo.addons.payment_stripe.models.payment import INT_CURRENCIES
+from odoo.tools.float_utils import float_round
+
+_logger = logging.getLogger(__name__)
+
+
+class PaymentAcquirerStripeSCA(models.Model):
+ _inherit = "payment.acquirer"
+
+ def stripe_form_generate_values(self, tx_values):
+ self.ensure_one()
+
+ base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
+ stripe_session_data = {
+ "payment_method_types[]": "card",
+ "line_items[][amount]": int(
+ tx_values["amount"]
+ if tx_values["currency"].name in INT_CURRENCIES
+ else float_round(tx_values["amount"] * 100, 2)
+ ),
+ "line_items[][currency]": tx_values["currency"].name,
+ "line_items[][quantity]": 1,
+ "line_items[][name]": tx_values["reference"],
+ "client_reference_id": tx_values["reference"],
+ "success_url": urls.url_join(base_url, StripeController._success_url)
+ + "?reference=%s" % tx_values["reference"],
+ "cancel_url": urls.url_join(base_url, StripeController._cancel_url)
+ + "?reference=%s" % tx_values["reference"],
+ "customer_email": tx_values["partner_email"] or tx_values["billing_partner_email"],
+ }
+ tx_values["session_id"] = self._create_stripe_session(stripe_session_data)
+
+ return tx_values
+
+ def _stripe_request(self, url, data=False, method="POST"):
+ self.ensure_one()
+ stripe_url = 'https://%s/' % (self._get_stripe_api_url())
+ url = urls.url_join(stripe_url, url)
+ headers = {
+ "AUTHORIZATION": "Bearer %s" % self.sudo().stripe_secret_key,
+ "Stripe-Version": "2019-05-16", # SetupIntent need a specific version
+ }
+ resp = requests.request(method, url, data=data, headers=headers)
+ try:
+ resp.raise_for_status()
+ except:
+ _logger.error(resp.text)
+ raise
+ return resp.json()
+
+ def _create_stripe_session(self, kwargs):
+ self.ensure_one()
+ resp = self._stripe_request("checkout/sessions", kwargs)
+ if resp.get("payment_intent") and kwargs.get("client_reference_id"):
+ tx = (
+ self.env["payment.transaction"]
+ .sudo()
+ .search([("reference", "=", kwargs["client_reference_id"])])
+ )
+ tx.stripe_payment_intent = resp["payment_intent"]
+ return resp["id"]
+
+ def _create_setup_intent(self, kwargs):
+ self.ensure_one()
+ params = {"usage": "off_session"}
+ _logger.info(
+ "_stripe_create_setup_intent: Sending values to stripe, values:\n%s",
+ pprint.pformat(params),
+ )
+
+ res = self._stripe_request("setup_intents", params)
+
+ _logger.info(
+ "_stripe_create_setup_intent: Values received:\n%s", pprint.pformat(res)
+ )
+ return res
+
+ @api.model
+ def stripe_s2s_form_process(self, data):
+ last4 = data.get("card", {}).get("last4")
+ if not last4:
+ # PM was created with a setup intent, need to get last4 digits through
+ # yet another call -_-
+ acquirer_id = self.env["payment.acquirer"].browse(int(data["acquirer_id"]))
+ pm = data.get("payment_method")
+ res = acquirer_id._stripe_request(
+ "payment_methods/%s" % pm, data=False, method="GET"
+ )
+ last4 = res.get("card", {}).get("last4", "****")
+
+ payment_token = (
+ self.env["payment.token"]
+ .sudo()
+ .create(
+ {
+ "acquirer_id": int(data["acquirer_id"]),
+ "partner_id": int(data["partner_id"]),
+ "stripe_payment_method": data.get("payment_method"),
+ "name": "XXXXXXXXXXXX%s" % last4,
+ "acquirer_ref": data.get("customer"),
+ }
+ )
+ )
+ return payment_token
+
+ def stripe_s2s_form_validate(self, data):
+ return True
+
+
+class PaymentTransactionStripeSCA(models.Model):
+ _inherit = "payment.transaction"
+
+ stripe_payment_intent = fields.Char(
+ string="Stripe Payment Intent ID", readonly=True
+ )
+ stripe_payment_intent_secret = fields.Char(string='Stripe Payment Intent Secret', readonly=True)
+
+ def _get_json_fields(self):
+ res = super()._get_json_fields()
+ res.append('stripe_payment_intent_secret')
+ return res
+
+ def _get_processing_info(self):
+ res = super()._get_processing_info()
+ if self.acquirer_id.provider == 'stripe':
+ stripe_info = {
+ 'stripe_payment_intent': self.stripe_payment_intent,
+ 'stripe_payment_intent_secret': self.stripe_payment_intent_secret,
+ 'stripe_publishable_key': self.acquirer_id.stripe_publishable_key,
+ }
+ res.update(stripe_info)
+ return res
+
+ def _create_stripe_charge(self, acquirer_ref=None, tokenid=None, email=None):
+ raise NotImplementedError(
+ "This method can no longer be used with the payment_stripe_sca module."
+ )
+
+ def form_feedback(self, data, acquirer_name):
+ if data.get("reference") and acquirer_name == "stripe":
+ transaction = self.env["payment.transaction"].search(
+ [("reference", "=", data["reference"])]
+ )
+
+ url = "payment_intents/%s" % transaction.stripe_payment_intent
+ resp = transaction.acquirer_id._stripe_request(url)
+ if resp.get("charges") and resp.get("charges").get("total_count"):
+ resp = resp.get("charges").get("data")[0]
+
+ data.update(resp)
+ _logger.info(
+ "Stripe: entering form_feedback with post data %s"
+ % pprint.pformat(data)
+ )
+ # note: luckily, the base stripe module did not override this method, avoiding
+ # me from using a context key to avoid this call in the parent model
+ return super(PaymentTransactionStripeSCA, self).form_feedback(data, acquirer_name)
+
+ def _stripe_create_payment_intent(self, acquirer_ref=None, email=None):
+ if self.stripe_payment_intent:
+ _logger.info(
+ "_stripe_create_payment_intent: trying to create an intent when one already exists (tx #%s), refetching values for intent %s",
+ self.id, self.stripe_payment_intent
+ )
+ res = self.acquirer_id._stripe_request("payment_intents/%s" % self.stripe_payment_intent, method="GET")
+ _logger.info(
+ "_stripe_create_payment_intent: Values received:\n%s", pprint.pformat(res)
+ )
+ return res
+ charge_params = {
+ "amount": int(
+ self.amount
+ if self.currency_id.name in INT_CURRENCIES
+ else float_round(self.amount * 100, 2)
+ ),
+ "currency": self.currency_id.name.lower(),
+ "confirm": True,
+ "off_session": True,
+ "payment_method": self.payment_token_id.stripe_payment_method,
+ "customer": self.payment_token_id.acquirer_ref,
+ "description": self.reference,
+ }
+ if not self.env.context.get('off_session'):
+ charge_params.update(setup_future_usage='off_session', off_session=False)
+ _logger.info(
+ "_stripe_create_payment_intent: Sending values to stripe, values:\n%s",
+ pprint.pformat(charge_params),
+ )
+
+ res = self.acquirer_id._stripe_request("payment_intents", charge_params)
+ if res.get("charges") and res.get("charges").get("total_count"):
+ res = res.get("charges").get("data")[0]
+
+ _logger.info(
+ "_stripe_create_payment_intent: Values received:\n%s", pprint.pformat(res)
+ )
+ return res
+
+ def stripe_s2s_do_transaction(self, **kwargs):
+ self.ensure_one()
+ result = self._stripe_create_payment_intent(
+ acquirer_ref=self.payment_token_id.acquirer_ref, email=self.partner_email
+ )
+ return self._stripe_s2s_validate_tree(result)
+
+ def _create_stripe_refund(self):
+ refund_params = {
+ "charge": self.acquirer_reference,
+ "amount": int(
+ float_round(self.amount * 100, 2)
+ ), # by default, stripe refund the full amount (we don't really need to specify the value)
+ "metadata[reference]": self.reference,
+ }
+
+ _logger.info(
+ "_create_stripe_refund: Sending values to stripe URL, values:\n%s",
+ pprint.pformat(refund_params),
+ )
+ res = self.acquirer_id._stripe_request("refunds", refund_params)
+ _logger.info("_create_stripe_refund: Values received:\n%s", pprint.pformat(res))
+ return res
+
+ @api.model
+ def _stripe_form_get_tx_from_data(self, data):
+ """ Given a data dict coming from stripe, verify it and find the related
+ transaction record. """
+ reference = data.get("reference")
+ if not reference:
+ stripe_error = data.get("error", {}).get("message", "")
+ _logger.error(
+ "Stripe: invalid reply received from stripe API, looks like "
+ "the transaction failed. (error: %s)",
+ stripe_error or "n/a",
+ )
+ error_msg = _("We're sorry to report that the transaction has failed.")
+ if stripe_error:
+ error_msg += " " + (
+ _("Stripe gave us the following info about the problem: '%s'")
+ % stripe_error
+ )
+ error_msg += " " + _(
+ "Perhaps the problem can be solved by double-checking your "
+ "credit card details, or contacting your bank?"
+ )
+ raise ValidationError(error_msg)
+
+ tx = self.search([("reference", "=", reference)])
+ if not tx:
+ error_msg = _("Stripe: no order found for reference %s") % reference
+ _logger.error(error_msg)
+ raise ValidationError(error_msg)
+ elif len(tx) > 1:
+ error_msg = _("Stripe: %s orders found for reference %s") % (
+ len(tx),
+ reference,
+ )
+ _logger.error(error_msg)
+ raise ValidationError(error_msg)
+ return tx[0]
+
+ def _stripe_s2s_validate_tree(self, tree):
+ self.ensure_one()
+ if self.state not in ("draft", "pending"):
+ _logger.info(
+ "Stripe: trying to validate an already validated tx (ref %s)",
+ self.reference,
+ )
+ return True
+
+ status = tree.get("status")
+ tx_id = tree.get("id")
+ tx_secret = tree.get("client_secret")
+ vals = {"date": fields.datetime.now(), "acquirer_reference": tx_id, "stripe_payment_intent": tx_id, "stripe_payment_intent_secret": tx_secret}
+ if status == "succeeded":
+ self.write(vals)
+ self._set_transaction_done()
+ self.execute_callback()
+ if self.type == "form_save":
+ s2s_data = {
+ "customer": tree.get("customer"),
+ "payment_method": tree.get("payment_method"),
+ "card": tree.get("payment_method_details").get("card"),
+ "acquirer_id": self.acquirer_id.id,
+ "partner_id": self.partner_id.id,
+ }
+ token = self.acquirer_id.stripe_s2s_form_process(s2s_data)
+ self.payment_token_id = token.id
+ if self.payment_token_id:
+ self.payment_token_id.verified = True
+ return True
+ if status in ("processing", "requires_action"):
+ self.write(vals)
+ self._set_transaction_pending()
+ return True
+ else:
+ error = tree.get("failure_message")
+ _logger.warn(error)
+ vals.update({"state_message": error})
+ self.write(vals)
+ self._set_transaction_cancel()
+ return False
+
+ def _stripe_form_get_invalid_parameters(self, data):
+ invalid_parameters = []
+ if data.get("amount") != int(
+ self.amount
+ if self.currency_id.name in INT_CURRENCIES
+ else float_round(self.amount * 100, 2)
+ ):
+ invalid_parameters.append(("Amount", data.get("amount"), self.amount * 100))
+ if data.get("currency").upper() != self.currency_id.name:
+ invalid_parameters.append(
+ ("Currency", data.get("currency"), self.currency_id.name)
+ )
+ if (
+ data.get("payment_intent")
+ and data.get("payment_intent") != self.stripe_payment_intent
+ ):
+ invalid_parameters.append(
+ (
+ "Payment Intent",
+ data.get("payment_intent"),
+ self.stripe_payment_intent,
+ )
+ )
+ return invalid_parameters
+
+
+class PaymentTokenStripeSCA(models.Model):
+ _inherit = "payment.token"
+
+ stripe_payment_method = fields.Char("Payment Method ID")
+
+ @api.model
+ def stripe_create(self, values):
+ if values.get("stripe_payment_method") and not values.get("acquirer_ref"):
+ partner_id = self.env["res.partner"].browse(values.get("partner_id"))
+ payment_acquirer = self.env["payment.acquirer"].browse(
+ values.get("acquirer_id")
+ )
+
+ # create customer to stipe
+ customer_data = {"email": partner_id.email}
+ cust_resp = payment_acquirer._stripe_request("customers", customer_data)
+
+ # link customer with payment method
+ api_url_payment_method = (
+ "payment_methods/%s/attach" % values["stripe_payment_method"]
+ )
+ method_data = {"customer": cust_resp.get("id")}
+ payment_acquirer._stripe_request(api_url_payment_method, method_data)
+ return {"acquirer_ref": cust_resp["id"]}
+ return values
+
+ def _stripe_create_customer(self, token, description=None, acquirer_id=None):
+ raise NotImplementedError(
+ "This method can no longer be used with the payment_stripe_sca module."
+ )
diff --git a/addons/payment_stripe_sca/static/description/icon.png b/addons/payment_stripe_sca/static/description/icon.png
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',