[MERGE] forward port branch saas-12.3 up to d8ce75466e
This commit is contained in:
@@ -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)."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)."))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import main
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import payment
|
||||
@@ -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."
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
@@ -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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"]'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2583,6 +2583,12 @@ msgstr ""
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -207,11 +210,28 @@ 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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -105,6 +108,20 @@ var SearchFacet = Widget.extend({
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionend: function (ev) {
|
||||
this._isComposing = false;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionstart: function (ev) {
|
||||
this._isComposing = true;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -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});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/', '');
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -153,7 +153,7 @@ For example, let's look at the Sale Order report from the Sale module::
|
||||
<!-- Translatable template -->
|
||||
<template id="report_saleorder_document">
|
||||
<!-- Re-browse of the record with the partner lang -->
|
||||
<t t-set="doc" t-value="doc.with_context({'lang':doc.partner_id.lang})" />
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)" />
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<div class="oe_structure"/>
|
||||
|
||||
@@ -7363,6 +7363,12 @@ msgstr ""
|
||||
#. module: base
|
||||
#: code:addons/base/models/ir_qweb_fields.py:610
|
||||
#, python-format
|
||||
msgid "Barcode symbology"
|
||||
msgstr ""
|
||||
|
||||
#. module: base
|
||||
#: code:addons/base/models/ir_qweb_fields.py:603
|
||||
#, python-format
|
||||
msgid "Barcode type"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ def encode_rfc2822_address_header(header_text):
|
||||
return formataddr((name, email))
|
||||
except UnicodeEncodeError:
|
||||
_logger.warning(_('Failed to encode the address %s\n'
|
||||
'from mail header:\n%s') % addr, header_text)
|
||||
'from mail header:\n%s') % (addr, header_text))
|
||||
return ""
|
||||
|
||||
addresses = getaddresses([pycompat.to_text(ustr(header_text))])
|
||||
|
||||
@@ -607,7 +607,7 @@ class BarcodeConverter(models.AbstractModel):
|
||||
def get_available_options(self):
|
||||
options = super(BarcodeConverter, self).get_available_options()
|
||||
options.update(
|
||||
type=dict(type='string', string=_('Barcode type'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'),
|
||||
symbology=dict(type='string', string=_('Barcode symbology'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'),
|
||||
width=dict(type='integer', string=_('Width'), default_value=600),
|
||||
height=dict(type='integer', string=_('Height'), default_value=100),
|
||||
humanreadable=dict(type='integer', string=_('Human Readable'), default_value=0),
|
||||
@@ -616,9 +616,9 @@ class BarcodeConverter(models.AbstractModel):
|
||||
|
||||
@api.model
|
||||
def value_to_html(self, value, options=None):
|
||||
barcode_type = options.get('type', 'Code128')
|
||||
barcode_symbology = options.get('symbology', 'Code128')
|
||||
barcode = self.env['ir.actions.report'].barcode(
|
||||
barcode_type,
|
||||
barcode_symbology,
|
||||
value,
|
||||
**{key: value for key, value in options.items() if key in ['width', 'height', 'humanreadable']})
|
||||
return u'<img src="data:png;base64,%s">' % base64.b64encode(barcode).decode('ascii')
|
||||
|
||||
@@ -405,10 +405,43 @@ class Users(models.Model):
|
||||
@api.multi
|
||||
@api.constrains('groups_id')
|
||||
def _check_one_user_type(self):
|
||||
for user in self:
|
||||
if len(user.groups_id.filtered(lambda x: x.category_id.xml_id == 'base.module_category_user_type')) > 1:
|
||||
"""We check that no users are both portal and users (same with public).
|
||||
This could typically happen because of implied groups.
|
||||
"""
|
||||
user_types_category = self.env.ref('base.module_category_user_type', raise_if_not_found=False)
|
||||
user_types_groups = self.env['res.groups'].search(
|
||||
[('category_id', '=', user_types_category.id)]) if user_types_category else False
|
||||
if user_types_groups: # needed at install
|
||||
if self._has_multiple_groups(user_types_groups.ids):
|
||||
raise ValidationError(_('The user cannot have more than one user types.'))
|
||||
|
||||
@api.multi
|
||||
def _has_multiple_groups(self, group_ids):
|
||||
"""The method is not fast if the list of ids is very long;
|
||||
so we rather check all users than limit to the size of the group
|
||||
:param group_ids: list of group ids
|
||||
:return: boolean: is there at least a user in at least 2 of the provided groups
|
||||
"""
|
||||
if group_ids:
|
||||
args = [tuple(group_ids)]
|
||||
if len(self.ids) == 1:
|
||||
where_clause = "AND r.uid = %s"
|
||||
args.append(self.id)
|
||||
else:
|
||||
where_clause = "" # default; we check ALL users (actually pretty efficient)
|
||||
query = """
|
||||
SELECT 1 FROM res_groups_users_rel WHERE EXISTS(
|
||||
SELECT r.uid
|
||||
FROM res_groups_users_rel r
|
||||
WHERE r.gid IN %s""" + where_clause + """
|
||||
GROUP BY r.uid HAVING COUNT(r.gid) > 1
|
||||
)
|
||||
"""
|
||||
self.env.cr.execute(query, args)
|
||||
return bool(self.env.cr.fetchall())
|
||||
else:
|
||||
return False
|
||||
|
||||
@api.multi
|
||||
def toggle_active(self):
|
||||
for user in self:
|
||||
@@ -951,6 +984,7 @@ class GroupsImplied(models.Model):
|
||||
JOIN group_imply i ON (r.gid = i.hid)
|
||||
WHERE i.gid = %(gid)s
|
||||
""", dict(gid=group.id))
|
||||
self._check_one_user_type()
|
||||
return res
|
||||
|
||||
class UsersImplied(models.Model):
|
||||
|
||||
@@ -24,7 +24,7 @@ class TestIrSequenceDateRangeStandard(SingleTransactionCase):
|
||||
year = date.today().year - 1
|
||||
january = lambda d: date(year, 1, d)
|
||||
|
||||
seq16 = self.env['ir.sequence'].with_context({'ir_sequence_date': january(16)})
|
||||
seq16 = self.env['ir.sequence'].with_context(ir_sequence_date=january(16))
|
||||
n = seq16.next_by_code('test_sequence_date_range')
|
||||
self.assertEqual(n, '1')
|
||||
n = seq16.next_by_code('test_sequence_date_range')
|
||||
|
||||
@@ -143,3 +143,21 @@ class TestHasGroup(TransactionCase):
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
self.grp_internal.users = [(4, test_user.id)]
|
||||
|
||||
def test_two_user_types_implied_groups(self):
|
||||
"""Contrarily to test_two_user_types, we simply add an implied_id to a group.
|
||||
This will trigger the addition of the relevant users to the relevant groups;
|
||||
if, say, this was done in SQL and thus bypassing the ORM, it would bypass the constraints
|
||||
and thus give us a case uncovered by the aforementioned test.
|
||||
"""
|
||||
grp_test = self.env["res.groups"].create(
|
||||
{"name": "test", "implied_ids": [(6, 0, [self.grp_internal.id])]})
|
||||
|
||||
test_user = self.env['res.users'].create({
|
||||
'login': 'test_user_portal',
|
||||
'name': "Test User with one user types",
|
||||
'groups_id': [(6, 0, [grp_test.id])]
|
||||
})
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
grp_test.write({'implied_ids': [(4, self.grp_portal.id)]})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template id="contact">
|
||||
<address t-ignore="true" class="d-inline-block mb-0" itemscope="itemscope" itemtype="http://schema.org/Organization">
|
||||
<address t-ignore="true" class="mb-0" itemscope="itemscope" itemtype="http://schema.org/Organization">
|
||||
<div t-if="not (('name' in fields) or (address and 'address' in fields) or (city and 'city' in fields) or (mobile and 'mobile' in fields) or (website and 'website' in fields) or (email and 'email' in fields))" class="css_non_editable_mode_hidden">
|
||||
--<span class="text-muted" t-esc="name"/>--
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@
|
||||
<div itemprop="address" itemscope="itemscope" itemtype="http://schema.org/PostalAddress">
|
||||
<div t-if="address and 'address' in fields" class="d-flex align-items-baseline">
|
||||
<i t-if="not options.get('no_marker')" class="fa fa-map-marker fa-fw" role="img" aria-label="Address" title="Address"/>
|
||||
<span itemprop="streetAddress" t-raw="address.replace('\n', options.get('no_tag_br') and ', ' or ('<br/>%s' % ('' if options.get('no_marker') else '')))"/>
|
||||
<div class="w-100" itemprop="streetAddress" t-raw="address.replace('\n', options.get('no_tag_br') and ', ' or ('<br/>%s' % ('' if options.get('no_marker') else '')))"/>
|
||||
</div>
|
||||
<div t-if="city and 'city' in fields" class="d-flex align-items-baseline">
|
||||
<i t-if="not options.get('no_marker')" class="fa fa-map-marker fa-fw" role="img" aria-label="Address" title="Address"/>
|
||||
|
||||
@@ -519,7 +519,7 @@
|
||||
<img class="o_kanban_image_inner_pic" t-if="record.parent_id.raw_value" t-att-alt="record.parent_id.value" t-att-src="kanban_image('res.partner', 'image_small', record.parent_id.raw_value)"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-elif="record.image_medium.raw_value">
|
||||
<t t-set="placeholder" t-value="'/base/static/img/company_image.png'"/>
|
||||
<div class="o_kanban_image_fill_left o_kanban_image_full" t-attf-style="background-image: url(#{kanban_image('res.partner', 'image_medium', record.id.raw_value, placeholder)})" role="img"/>
|
||||
</t>
|
||||
|
||||
@@ -35,6 +35,8 @@ class BaseLanguageInstall(models.TransientModel):
|
||||
mods = self.env['ir.module.module'].search([('state', '=', 'installed')])
|
||||
mods.with_context(overwrite=self.overwrite)._update_translations(self.lang)
|
||||
self.state = 'done'
|
||||
self.env.cr.execute('ANALYZE ir_translation')
|
||||
|
||||
return {
|
||||
'name': _('Language Pack'),
|
||||
'view_type': 'form',
|
||||
|
||||
+1
-1
@@ -5306,7 +5306,7 @@ Fields:
|
||||
target0 = Model.browse(i for [i] in self.env.cr.fetchall())
|
||||
else:
|
||||
env = self.env(user=SUPERUSER_ID, context={'active_test': False})
|
||||
target0 = env[model_name].search([(path, 'in', self.ids)])
|
||||
target0 = env[model_name].search([(path, 'in', self.ids)], order='id')
|
||||
target0 = target0.with_env(self.env)
|
||||
# prepare recomputation for each field on linked records
|
||||
for field in stored:
|
||||
|
||||
Reference in New Issue
Block a user