[MERGE] forward port branch saas-12.4 up to 4a1321bc99
closes odoo/odoo#37127 Signed-off-by: Christophe Simonis <chs@odoo.com>
This commit is contained in:
@@ -317,13 +317,13 @@ class AccountAccount(models.Model):
|
||||
if opening_move_line:
|
||||
if amount:
|
||||
# modify the line
|
||||
setattr(opening_move_line.with_context({'check_move_validity': False}), field, amount)
|
||||
opening_move_line.with_context(check_move_validity=False)[field] = amount
|
||||
elif counter_part_map[field]:
|
||||
# delete the line (no need to keep a line with value = 0)
|
||||
opening_move_line.with_context({'check_move_validity': False}).unlink()
|
||||
opening_move_line.with_context(check_move_validity=False).unlink()
|
||||
elif amount:
|
||||
# create a new line, as none existed before
|
||||
self.env['account.move.line'].with_context({'check_move_validity': False}).create({
|
||||
self.env['account.move.line'].with_context(check_move_validity=False).create({
|
||||
'name': _('Opening balance'),
|
||||
field: amount,
|
||||
'move_id': opening_move.id,
|
||||
@@ -794,7 +794,7 @@ class AccountJournal(models.Model):
|
||||
return {
|
||||
'alias_defaults': {'type': type == 'purchase' and 'in_invoice' or 'out_invoice', 'company_id': self.company_id.id, 'journal_id': self.id},
|
||||
'alias_parent_thread_id': self.id,
|
||||
'alias_name': re.sub(r'[^\w]+', '-', alias_name)
|
||||
'alias_name': alias_name,
|
||||
}
|
||||
|
||||
def unlink(self):
|
||||
|
||||
@@ -133,13 +133,13 @@ class AccountMove(models.Model):
|
||||
amount_residual = fields.Monetary(string='Amount Due', store=True,
|
||||
compute='_compute_amount')
|
||||
amount_untaxed_signed = fields.Monetary(string='Untaxed Amount Signed', store=True, readonly=True,
|
||||
compute='_compute_amount')
|
||||
compute='_compute_amount', currency_field='company_currency_id')
|
||||
amount_tax_signed = fields.Monetary(string='Tax Signed', store=True, readonly=True,
|
||||
compute='_compute_amount')
|
||||
compute='_compute_amount', currency_field='company_currency_id')
|
||||
amount_total_signed = fields.Monetary(string='Total Signed', store=True, readonly=True,
|
||||
compute='_compute_amount')
|
||||
compute='_compute_amount', currency_field='company_currency_id')
|
||||
amount_residual_signed = fields.Monetary(string='Amount Due Signed', store=True,
|
||||
compute='_compute_amount')
|
||||
compute='_compute_amount', currency_field='company_currency_id')
|
||||
amount_by_group = fields.Binary(string="Tax amount by group",
|
||||
compute='_compute_invoice_taxes_by_group',
|
||||
help="technical field used in report and in invoice form view with a widget to display the detail of taxes (grouped by tax group) under the subtotal")
|
||||
@@ -2767,7 +2767,7 @@ class AccountMoveLine(models.Model):
|
||||
reconciled = True
|
||||
line.reconciled = reconciled
|
||||
|
||||
line.amount_residual = line.move_id.company_id.currency_id.round(amount * sign)
|
||||
line.amount_residual = line.move_id.company_id.currency_id.round(amount * sign) if line.move_id.company_id else amount * sign
|
||||
line.amount_residual_currency = line.currency_id and line.currency_id.round(amount_residual_currency * sign) or 0.0
|
||||
|
||||
@api.depends('tax_repartition_line_id.invoice_tax_id', 'tax_repartition_line_id.refund_tax_id')
|
||||
|
||||
@@ -319,15 +319,17 @@ class account_payment(models.Model):
|
||||
move_currency = self.env['res.currency'].browse(res['currency_id'])
|
||||
if move_currency == currency and move_currency != company.currency_id:
|
||||
total += res['residual_currency']
|
||||
elif move_currency == currency == company.currency_id:
|
||||
total += res['amount_residual']
|
||||
else:
|
||||
total += move_currency._convert(res['amount_residual'], currency, company, date)
|
||||
total += company.currency_id._convert(res['amount_residual'], currency, company, date)
|
||||
return total
|
||||
|
||||
def name_get(self):
|
||||
return [(payment.id, payment.name or _('Draft Payment')) for payment in self]
|
||||
|
||||
@api.model
|
||||
def _get_move_name_transfer_separator(self):
|
||||
return '§§'
|
||||
|
||||
@api.depends('move_line_ids.reconciled')
|
||||
def _get_move_reconciled(self):
|
||||
for payment in self:
|
||||
@@ -448,7 +450,6 @@ class account_payment(models.Model):
|
||||
move.unlink()
|
||||
rec.write({
|
||||
'state': 'cancelled',
|
||||
'move_name': False,
|
||||
})
|
||||
|
||||
def unlink(self):
|
||||
@@ -484,6 +485,7 @@ class account_payment(models.Model):
|
||||
all_move_vals = []
|
||||
for payment in self:
|
||||
company_currency = payment.company_id.currency_id
|
||||
move_names = payment.move_name.split(payment._get_move_name_transfer_separator()) if payment.move_name else None
|
||||
|
||||
# Compute amounts.
|
||||
write_off_amount = payment.payment_difference_handling == 'reconcile' and -payment.payment_difference or 0.0
|
||||
@@ -591,6 +593,9 @@ class account_payment(models.Model):
|
||||
'payment_id': payment.id,
|
||||
}))
|
||||
|
||||
if move_names:
|
||||
move_vals['name'] = move_names[0]
|
||||
|
||||
all_move_vals.append(move_vals)
|
||||
|
||||
# ==== 'transfer' ====
|
||||
@@ -634,6 +639,9 @@ class account_payment(models.Model):
|
||||
],
|
||||
}
|
||||
|
||||
if move_names and len(move_names) == 2:
|
||||
transfer_move_vals['name'] = move_names[1]
|
||||
|
||||
all_move_vals.append(transfer_move_vals)
|
||||
return all_move_vals
|
||||
|
||||
@@ -677,7 +685,8 @@ class account_payment(models.Model):
|
||||
moves.filtered(lambda move: move.journal_id.post_at != 'bank_rec').post()
|
||||
|
||||
# Update the state / move before performing any reconciliation.
|
||||
rec.write({'state': 'posted', 'move_name': moves[0].name})
|
||||
move_name = self._get_move_name_transfer_separator().join(moves.mapped('name'))
|
||||
rec.write({'state': 'posted', 'move_name': move_name})
|
||||
|
||||
if rec.payment_type in ('inbound', 'outbound'):
|
||||
# ==== 'inbound' / 'outbound' ====
|
||||
|
||||
@@ -238,7 +238,7 @@ class AccountChartTemplate(models.Model):
|
||||
})
|
||||
|
||||
# Set the transfer account on the company
|
||||
company.transfer_account_id = self.env['account.account'].search([('code', '=like', self.transfer_account_code_prefix + '%')])[0]
|
||||
company.transfer_account_id = self.env['account.account'].search([('code', '=like', self.transfer_account_code_prefix + '%')])[:1]
|
||||
|
||||
# Create Bank journals
|
||||
self._create_bank_journals(company, acc_template_ref)
|
||||
@@ -265,7 +265,7 @@ class AccountChartTemplate(models.Model):
|
||||
"""
|
||||
model_to_check = ['account.move', 'account.payment', 'account.bank.statement']
|
||||
for model in model_to_check:
|
||||
if len(self.env[model].search([('company_id', '=', company_id.id)])) > 0:
|
||||
if self.env[model].sudo().search([('company_id', '=', company_id.id)], limit=1):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ class ResPartner(models.Model):
|
||||
|
||||
@api.depends_context('force_company')
|
||||
def _credit_debit_get(self):
|
||||
tables, where_clause, where_params = self.env['account.move.line'].with_context(company_id=self.env.company.id)._query_get()
|
||||
tables, where_clause, where_params = self.env['account.move.line'].with_context(state='posted', company_id=self.env.company.id)._query_get()
|
||||
where_params = [tuple(self.ids)] + where_params
|
||||
if where_clause:
|
||||
where_clause = 'AND ' + where_clause
|
||||
@@ -266,9 +266,11 @@ class ResPartner(models.Model):
|
||||
SELECT partner.id
|
||||
FROM res_partner partner
|
||||
LEFT JOIN account_move_line aml ON aml.partner_id = partner.id
|
||||
JOIN account_move move ON move.id = aml.move_id
|
||||
RIGHT JOIN account_account acc ON aml.account_id = acc.id
|
||||
WHERE acc.internal_type = %s
|
||||
AND NOT acc.deprecated AND acc.company_id = %s
|
||||
AND move.state == 'posted'
|
||||
GROUP BY partner.id
|
||||
HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand))
|
||||
res = self._cr.fetchall()
|
||||
|
||||
@@ -12,14 +12,11 @@ class Users(models.Model):
|
||||
def _check_one_user_type(self):
|
||||
super(Users, self)._check_one_user_type()
|
||||
|
||||
users_with_both_groups = self.filtered(lambda user:
|
||||
user.has_group('account.group_show_line_subtotals_tax_included') and
|
||||
user.has_group('account.group_show_line_subtotals_tax_excluded')
|
||||
)
|
||||
if users_with_both_groups:
|
||||
names = ", ".join(users_with_both_groups.mapped('name'))
|
||||
g1 = self.env.ref('account.group_show_line_subtotals_tax_included')
|
||||
g2 = self.env.ref('account.group_show_line_subtotals_tax_excluded')
|
||||
|
||||
if self._has_multiple_groups([g1.id, g2.id]):
|
||||
raise ValidationError(_("A user cannot have both Tax B2B and Tax B2C.\n"
|
||||
"Problematic user(s): %s\n"
|
||||
"You should go in General Settings, and choose to display Product Prices\n"
|
||||
"either in 'Tax-Included' or in 'Tax-Excluded' mode\n"
|
||||
"(or switch twice the mode if you are already in the desired one).") % names)
|
||||
"(or switch twice the mode if you are already in the desired one)."))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -334,9 +334,9 @@ class TestReconciliationMatchingRules(AccountingTestCase):
|
||||
|
||||
# Check first line has been well reconciled.
|
||||
self.assertRecordValues(self.bank_line_1.journal_entry_ids, [
|
||||
{'partner_id': self.partner_1.id, 'debit': 100.0, 'credit': 0.0, 'tax_ids': [self.tax21.id]},
|
||||
{'partner_id': self.partner_1.id, 'debit': 21.0, 'credit': 0.0, 'tax_line_id': self.tax21.id},
|
||||
{'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0},
|
||||
{'partner_id': self.partner_1.id, 'debit': 100.0, 'credit': 0.0, 'tax_ids': [self.tax21.id], 'tax_line_id': False},
|
||||
{'partner_id': self.partner_1.id, 'debit': 21.0, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax21.id},
|
||||
{'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0, 'tax_ids': [], 'tax_line_id': False},
|
||||
])
|
||||
|
||||
def test_reverted_move_matching(self):
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<strong>Invoice Date:</strong>
|
||||
<p class="m-0" t-field="o.invoice_date"/>
|
||||
</div>
|
||||
<div class="col-auto mw-100 mb-2" t-if="o.invoice_date_due and o.type == 'out_invoice' and o.state in ('open', 'in_payment', 'paid')" name="due_date">
|
||||
<div class="col-auto mw-100 mb-2" t-if="o.invoice_date_due and o.type == 'out_invoice' and o.state == 'posted'" name="due_date">
|
||||
<strong>Due Date:</strong>
|
||||
<p class="m-0" t-field="o.invoice_date_due"/>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -8,7 +8,7 @@ from odoo import api, fields, models
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
weight = fields.Float(compute='_cal_move_weight', digits='Stock Weight', store=True)
|
||||
weight = fields.Float(compute='_cal_move_weight', digits='Stock Weight', store=True, compute_sudo=True)
|
||||
|
||||
@api.depends('product_id', 'product_uom_qty', 'product_uom')
|
||||
def _cal_move_weight(self):
|
||||
|
||||
@@ -79,7 +79,7 @@ class StockPicking(models.Model):
|
||||
delivery_type = fields.Selection(related='carrier_id.delivery_type', readonly=True)
|
||||
carrier_id = fields.Many2one("delivery.carrier", string="Carrier", check_company=True)
|
||||
volume = fields.Float(copy=False)
|
||||
weight = fields.Float(compute='_cal_weight', digits='Stock Weight', store=True, help="Total weight of the products in the picking.")
|
||||
weight = fields.Float(compute='_cal_weight', digits='Stock Weight', store=True, help="Total weight of the products in the picking.", compute_sudo=True)
|
||||
carrier_tracking_ref = fields.Char(string='Tracking Reference', copy=False)
|
||||
carrier_tracking_url = fields.Char(string='Tracking URL', compute='_compute_carrier_tracking_url')
|
||||
weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name', readonly=True, default=_get_default_weight_uom)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -120,6 +120,9 @@ class AccountChartTemplate(models.Model):
|
||||
in_xml_ids = {xml_id.name: xml_id for xml_id in in_xml_ids}
|
||||
|
||||
for name, xml_id in expected_in_xml_id_names.items():
|
||||
# ignore nonconforming customized data
|
||||
if name not in in_xml_ids:
|
||||
continue
|
||||
in_records += self.env[model + '.template'].browse(in_xml_ids[name].res_id)
|
||||
out_records += self.env[model].browse(xml_id.res_id)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ class AliasMixin(models.AbstractModel):
|
||||
for record in child_model.search([('alias_id', '=', False)]):
|
||||
# create the alias, and link it to the current record
|
||||
alias = alias_model.create(record.get_alias_values())
|
||||
record.with_context({'mail_notrack': True}).alias_id = alias
|
||||
record.with_context(mail_notrack=True).alias_id = alias
|
||||
_logger.info('Mail alias created for %s %s (id %s)',
|
||||
record._name, record.display_name, record.id)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,7 +83,7 @@ class PaymentProcessing(http.Controller):
|
||||
# populate the returned dictionnary with the transactions data
|
||||
for tx in payment_transaction_ids:
|
||||
message_to_display = tx.acquirer_id[tx.state + '_msg'] if tx.state in ['done', 'pending', 'cancel'] else None
|
||||
result['transactions'].append({
|
||||
tx_info = {
|
||||
'reference': tx.reference,
|
||||
'state': tx.state,
|
||||
'return_url': tx.return_url,
|
||||
@@ -93,7 +93,9 @@ class PaymentProcessing(http.Controller):
|
||||
'amount': tx.amount,
|
||||
'currency': tx.currency_id.name,
|
||||
'acquirer_provider': tx.acquirer_id.provider,
|
||||
})
|
||||
}
|
||||
tx_info.update(tx._get_processing_info())
|
||||
result['transactions'].append(tx_info)
|
||||
|
||||
tx_to_process = payment_transaction_ids.filtered(lambda x: x.state == 'done' and x.is_processed is False)
|
||||
try:
|
||||
|
||||
@@ -644,6 +644,10 @@ class PaymentTransaction(models.Model):
|
||||
transactions = self.filtered(lambda t: t.state != 'draft')
|
||||
return transactions and transactions[0] or transactions
|
||||
|
||||
def _get_processing_info(self):
|
||||
""" Extensible method for providers if they need specific fields/info regarding a tx in the payment processing page. """
|
||||
return dict()
|
||||
|
||||
def _get_payment_transaction_sent_message(self):
|
||||
self.ensure_one()
|
||||
if self.payment_token_id:
|
||||
|
||||
@@ -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='public', 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.
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -1757,7 +1757,7 @@ var ReceiptScreenWidget = ScreenWidget.extend({
|
||||
var invoiced = self.pos.push_and_invoice_order(order);
|
||||
self.invoicing = true;
|
||||
|
||||
invoiced.fail(self._handleFailedPushForInvoice.bind(self, order, true)); // refresh
|
||||
invoiced.catch(self._handleFailedPushForInvoice.bind(self, order, true)); // refresh
|
||||
|
||||
invoiced.then(function(){
|
||||
self.invoicing = false;
|
||||
|
||||
@@ -710,6 +710,7 @@ class SupplierInfo(models.Model):
|
||||
'res.partner', 'Vendor',
|
||||
ondelete='cascade', required=True,
|
||||
help="Vendor of this product", check_company=True)
|
||||
active = fields.Boolean(related='name.active', readonly=True)
|
||||
product_name = fields.Char(
|
||||
'Vendor Product Name',
|
||||
help="This vendor's product name will be used when printing a request for quotation. Keep empty to use the internal one.")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -457,7 +457,7 @@ class SaleOrder(models.Model):
|
||||
self.ensure_one()
|
||||
journal = self.env['account.move'].with_context(force_company=self.company_id.id, default_type='out_invoice')._get_default_journal()
|
||||
if not journal:
|
||||
raise UserError(_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name, self.company_id.id))
|
||||
raise UserError(_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name, self.company_id.id))
|
||||
|
||||
invoice_vals = {
|
||||
'ref': self.client_order_ref or '',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -217,7 +217,7 @@ class SnailmailLetter(models.Model):
|
||||
else:
|
||||
# adding the web logo from the company for future possible customization
|
||||
document.update({
|
||||
'company_logo': letter.company_id.logo_web.decode('utf-8'),
|
||||
'company_logo': letter.company_id.logo_web and letter.company_id.logo_web.decode('utf-8') or False,
|
||||
})
|
||||
attachment = letter._fetch_attachment()
|
||||
if attachment:
|
||||
|
||||
@@ -49,7 +49,7 @@ class StockMove(models.Model):
|
||||
description_picking = fields.Text('Description of Picking')
|
||||
product_qty = fields.Float(
|
||||
'Real Quantity', compute='_compute_product_qty', inverse='_set_product_qty',
|
||||
digits=0, store=True,
|
||||
digits=0, store=True, compute_sudo=True,
|
||||
help='Quantity in the default UoM of the product')
|
||||
product_uom_qty = fields.Float(
|
||||
'Initial Demand',
|
||||
|
||||
@@ -741,7 +741,10 @@ class Warehouse(models.Model):
|
||||
if not change_to_multiple:
|
||||
# If single delivery we should create the necessary MTO rules for the resupply
|
||||
routings = [self.Routing(self.lot_stock_id, location, self.out_type_id, 'pull') for location in rules.mapped('location_id')]
|
||||
mto_rule_vals = self._get_rule_values(routings)
|
||||
mto_vals = self._get_global_route_rules_values().get('mto_pull_id')
|
||||
values = mto_vals['create_values']
|
||||
mto_rule_vals = self._get_rule_values(routings, values, name_suffix='MTO')
|
||||
|
||||
for mto_rule_val in mto_rule_vals:
|
||||
Rule.create(mto_rule_val)
|
||||
else:
|
||||
|
||||
@@ -2550,11 +2550,28 @@ var FieldProgressBar = AbstractField.extend({
|
||||
if (this.recordData[this.nodeOptions.current_value]) {
|
||||
this.value = this.recordData[this.nodeOptions.current_value];
|
||||
}
|
||||
this.max_value = this.recordData[this.nodeOptions.max_value] || 100;
|
||||
|
||||
// The few next lines determine if the widget can write on the record or not
|
||||
this.editable_readonly = !!this.nodeOptions.editable_readonly;
|
||||
// "hard" readonly
|
||||
this.readonly = this.nodeOptions.readonly || !this.nodeOptions.editable;
|
||||
this.edit_max_value = this.nodeOptions.edit_max_value || false;
|
||||
|
||||
this.canWrite = !this.readonly && (
|
||||
this.mode === 'edit' ||
|
||||
(this.editable_readonly && this.mode === 'readonly') ||
|
||||
(this.viewType === 'kanban') // Keep behavior before commit
|
||||
);
|
||||
|
||||
// Boolean to toggle if we edit the numerator (value) or the denominator (max_value)
|
||||
this.edit_max_value = !!this.nodeOptions.edit_max_value;
|
||||
this.max_value = this.recordData[this.nodeOptions.max_value] || 100;
|
||||
|
||||
this.title = _t(this.attrs.title || this.nodeOptions.title) || '';
|
||||
this.edit_on_click = !this.nodeOptions.edit_max_value || false;
|
||||
|
||||
// Ability to edit the field through the bar
|
||||
// /!\ this feature is disabled
|
||||
this.enableBarAsInput = false;
|
||||
this.edit_on_click = this.enableBarAsInput && this.mode === 'readonly' && !this.edit_max_value;
|
||||
|
||||
this.write_mode = false;
|
||||
},
|
||||
@@ -2562,19 +2579,19 @@ var FieldProgressBar = AbstractField.extend({
|
||||
var self = this;
|
||||
this._render_value();
|
||||
|
||||
if (!this.readonly) {
|
||||
if (this.canWrite) {
|
||||
if (this.edit_on_click) {
|
||||
this.$el.on('click', '.o_progress', function (e) {
|
||||
var $target = $(e.currentTarget);
|
||||
self.value = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value);
|
||||
var numValue = Math.floor((e.pageX - $target.offset().left) / $target.outerWidth() * self.max_value);
|
||||
self.on_update(numValue);
|
||||
self._render_value();
|
||||
self.on_update(self.value);
|
||||
});
|
||||
} else {
|
||||
this.$el.on('click', function () {
|
||||
if (!self.write_mode) {
|
||||
var $input = $('<input>', {type: 'text', class: 'o_progressbar_value o_input'});
|
||||
$input.on('blur', _.bind(self.on_change_input, self));
|
||||
$input.on('blur', self.on_change_input.bind(self));
|
||||
self.$('.o_progressbar_value').replaceWith($input);
|
||||
self.write_mode = true;
|
||||
self._render_value();
|
||||
@@ -2584,24 +2601,25 @@ var FieldProgressBar = AbstractField.extend({
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
/**
|
||||
* Updates the widget with value
|
||||
*
|
||||
* @param {Number} value
|
||||
*/
|
||||
on_update: function (value) {
|
||||
if (!isNaN(value)) {
|
||||
if (this.edit_max_value) {
|
||||
try {
|
||||
this.max_value = this._parseValue(value);
|
||||
this._isValid = true;
|
||||
} catch (e) {
|
||||
this._isValid = false;
|
||||
}
|
||||
var changes = {};
|
||||
changes[this.nodeOptions.max_value] = this.max_value;
|
||||
this.trigger_up('field_changed', {
|
||||
dataPointID: this.dataPointID,
|
||||
changes: changes,
|
||||
});
|
||||
} else {
|
||||
this._setValue(value);
|
||||
}
|
||||
if (this.edit_max_value) {
|
||||
this.max_value = value;
|
||||
this._isValid = true;
|
||||
var changes = {};
|
||||
changes[this.nodeOptions.max_value] = this.max_value;
|
||||
this.trigger_up('field_changed', {
|
||||
dataPointID: this.dataPointID,
|
||||
changes: changes,
|
||||
});
|
||||
} else {
|
||||
// _setValues accepts string and will parse it
|
||||
var formattedValue = this._formatValue(value);
|
||||
this._setValue(formattedValue);
|
||||
}
|
||||
},
|
||||
on_change_input: function (e) {
|
||||
@@ -2609,29 +2627,42 @@ var FieldProgressBar = AbstractField.extend({
|
||||
if (e.type === 'change' && !$input.is(':focus')) {
|
||||
return;
|
||||
}
|
||||
if (isNaN($input.val())) {
|
||||
this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid."));
|
||||
} else {
|
||||
if (e.type === 'input') {
|
||||
this._render_value($input.val());
|
||||
if (parseFloat($input.val()) === 0) {
|
||||
|
||||
var parsedValue;
|
||||
try {
|
||||
// Cover all numbers with parseFloat
|
||||
parsedValue = field_utils.parse.float($input.val());
|
||||
} catch (error) {
|
||||
this.do_warn(_t("Wrong value entered!"), _t("Only Integer or Float Value should be valid."));
|
||||
}
|
||||
|
||||
if (parsedValue !== undefined) {
|
||||
if (e.type === 'input') { // ensure what has just been typed in the input is a number
|
||||
// returns NaN if not a number
|
||||
this._render_value(parsedValue);
|
||||
if (parsedValue === 0) {
|
||||
$input.select();
|
||||
}
|
||||
} else {
|
||||
} else { // Implicit type === 'blur': we commit the value
|
||||
if (this.edit_max_value) {
|
||||
this.max_value = $(e.target).val();
|
||||
} else {
|
||||
this.value = $(e.target).val() || 0;
|
||||
parsedValue = parsedValue || 100;
|
||||
}
|
||||
|
||||
var $div = $('<div>', {class: 'o_progressbar_value'});
|
||||
this.$('.o_progressbar_value').replaceWith($div);
|
||||
this.write_mode = false;
|
||||
|
||||
this.on_update(parsedValue);
|
||||
this._render_value();
|
||||
this.on_update(this.edit_max_value ? this.max_value : this.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Renders the value
|
||||
*
|
||||
* @private
|
||||
* @param {Number} v
|
||||
*/
|
||||
_render_value: function (v) {
|
||||
var value = this.value;
|
||||
var max_value = this.max_value;
|
||||
|
||||
@@ -19,6 +19,8 @@ var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var mvc = require('web.mvc');
|
||||
|
||||
var session = require('web.session');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
@@ -519,6 +521,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
this.dp.add(this._rpc({
|
||||
model: data.model,
|
||||
method: data.method,
|
||||
context: session.user_context,
|
||||
})).then(function (action) {
|
||||
if (action !== undefined) {
|
||||
self.do_action(action, options);
|
||||
|
||||
@@ -9,6 +9,8 @@ var Widget = require('web.Widget');
|
||||
var SearchBar = Widget.extend({
|
||||
template: 'SearchView.SearchBar',
|
||||
events: _.extend({}, Widget.prototype.events, {
|
||||
'compositionend .o_searchview_input': '_onCompositionendInput',
|
||||
'compositionstart .o_searchview_input': '_onCompositionstartInput',
|
||||
'keydown': '_onKeydown',
|
||||
}),
|
||||
/**
|
||||
@@ -32,6 +34,7 @@ var SearchBar = Widget.extend({
|
||||
|
||||
this.autoCompleteSources = [];
|
||||
this.searchFacets = [];
|
||||
this._isInputComposing = false;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -199,11 +202,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;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -128,6 +131,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
|
||||
*/
|
||||
@@ -139,6 +156,9 @@ var SearchFacet = Widget.extend({
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeydown: function (ev) {
|
||||
if (this._isComposing) {
|
||||
return;
|
||||
}
|
||||
switch (ev.which) {
|
||||
case $.ui.keyCode.BACKSPACE:
|
||||
this.trigger_up('facet_removed', {group: this.facet});
|
||||
|
||||
@@ -288,8 +288,9 @@ var SearchPanel = Widget.extend({
|
||||
});
|
||||
_.map(values, function (value) {
|
||||
var value = category.values[value.id];
|
||||
if (value.parentId) {
|
||||
category.values[value.parentId].childrenIds.push(value.id);
|
||||
var parentCategoryId = value.parentId;
|
||||
if (parentCategoryId && parentCategoryId in category.values) {
|
||||
category.values[parentCategoryId].childrenIds.push(value.id);
|
||||
}
|
||||
});
|
||||
category.rootIds = _.filter(_.map(values, function (value) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
// Allow vertical scrolling in modals on iOS
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// Temporary fix for modals which are not instantiated thanks to the Dialog
|
||||
// JS classes (deprecated case) (see bootstrap_overridden.scss) + Frontend.
|
||||
//
|
||||
|
||||
@@ -11,6 +11,7 @@ var KanbanView = require('web.KanbanView');
|
||||
var ListView = require('web.ListView');
|
||||
var session = require('web.session');
|
||||
var testUtils = require('web.test_utils');
|
||||
var testUtilsDom = require('web.test_utils_dom');
|
||||
var field_registry = require('web.field_registry');
|
||||
|
||||
var createView = testUtils.createView;
|
||||
@@ -5955,7 +5956,7 @@ QUnit.module('basic_fields', {
|
||||
QUnit.module('FieldProgressBar');
|
||||
|
||||
QUnit.test('Field ProgressBar: max_value should update', async function (assert) {
|
||||
assert.expect(2);
|
||||
assert.expect(3);
|
||||
|
||||
this.data.partner.records = this.data.partner.records.slice(0,1);
|
||||
this.data.partner.records[0].qux = 2;
|
||||
@@ -5980,6 +5981,15 @@ QUnit.module('basic_fields', {
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'write') {
|
||||
assert.deepEqual(
|
||||
args.args[1],
|
||||
{int_field: 999, qux: 5, display_name: 'new name'},
|
||||
'New value of progress bar saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '10 / 2',
|
||||
@@ -5991,6 +6001,435 @@ QUnit.module('basic_fields', {
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '999 / 5',
|
||||
'The value of the progress bar should be correct after the update');
|
||||
|
||||
await testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: value should not update in readonly mode when sliding the bar', async function (assert) {
|
||||
assert.expect(4);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
mockRPC: function (route, args) {
|
||||
assert.step(route);
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
var $view = $('#qunit-fixture').contents();
|
||||
$view.prependTo('body'); // => select with click position
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99%',
|
||||
'Initial value should be correct')
|
||||
|
||||
var $progressBarEl = form.$('.o_progress');
|
||||
var top = $progressBarEl.offset().top + 5;
|
||||
var left = $progressBarEl.offset().left + 5;
|
||||
try {
|
||||
testUtils.triggerPositionalMouseEvent(left, top, "click");
|
||||
} catch (e) {
|
||||
form.destroy();
|
||||
$view.remove();
|
||||
throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
|
||||
}
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99%',
|
||||
'New value should be different than initial after click');
|
||||
|
||||
assert.verifySteps(["/web/dataset/call_kw/partner/read"]);
|
||||
|
||||
form.destroy();
|
||||
$view.remove();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: value should not update in edit mode when sliding the bar', async function (assert) {
|
||||
assert.expect(6);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
assert.step(route);
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
var $view = $('#qunit-fixture').contents();
|
||||
$view.prependTo('body'); // => select with click position
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99%',
|
||||
'Initial value should be correct')
|
||||
|
||||
var $progressBarEl = form.$('.o_progress');
|
||||
var top = $progressBarEl.offset().top + 5;
|
||||
var left = $progressBarEl.offset().left + 5;
|
||||
try {
|
||||
testUtils.triggerPositionalMouseEvent(left, top, "click");
|
||||
} catch (e) {
|
||||
form.destroy();
|
||||
$view.remove();
|
||||
throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
|
||||
}
|
||||
assert.strictEqual(form.$('.o_progressbar_value.o_input').val(), "99",
|
||||
'Value of input is not changed');
|
||||
await testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99%',
|
||||
'New value should be different than initial after click');
|
||||
|
||||
assert.verifySteps(["/web/dataset/call_kw/partner/read"]);
|
||||
|
||||
form.destroy();
|
||||
$view.remove();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: value should update in edit mode when typing in input', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'write') {
|
||||
assert.strictEqual(args.args[1].int_field, 69,
|
||||
'New value of progress bar saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99%',
|
||||
'Initial value should be correct');
|
||||
|
||||
await testUtilsDom.click(form.$('.o_progress'));
|
||||
|
||||
var $valInput = form.$('.o_progressbar_value.o_input');
|
||||
assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct');
|
||||
|
||||
await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']);
|
||||
|
||||
await testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '69%',
|
||||
'New value should be different than initial after click');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: value should update in edit mode when typing in input with field max value', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="qux" invisible="1" />' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\'}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'write') {
|
||||
assert.strictEqual(args.args[1].int_field, 69,
|
||||
'New value of progress bar saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0',
|
||||
'Initial value should be correct');
|
||||
|
||||
await testUtilsDom.click(form.$('.o_progress'));
|
||||
|
||||
var $valInput = form.$('.o_progressbar_value.o_input');
|
||||
assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct');
|
||||
|
||||
await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']);
|
||||
|
||||
await testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '69 / 0',
|
||||
'New value should be different than initial after click');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: max value should update in edit mode when typing in input with field max value', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="qux" invisible="1" />' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'write') {
|
||||
assert.strictEqual(args.args[1].qux, 69,
|
||||
'New value of progress bar saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0',
|
||||
'Initial value should be correct');
|
||||
|
||||
await testUtilsDom.click(form.$('.o_progress'));
|
||||
|
||||
var $valInput = form.$('.o_progressbar_value.o_input');
|
||||
assert.strictEqual($valInput.val(), "0.44444", 'Initial value in input is correct');
|
||||
|
||||
await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']);
|
||||
|
||||
await testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 69',
|
||||
'New value should be different than initial after click');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: Standard readonly mode is readonly', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="qux" invisible="1" />' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
mockRPC: function (route, args) {
|
||||
assert.step(route);
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0',
|
||||
'Initial value should be correct');
|
||||
|
||||
await testUtilsDom.click(form.$('.o_progress'));
|
||||
|
||||
assert.containsNone(form, '.o_progressbar_value.o_input', 'no input in readonly mode');
|
||||
|
||||
assert.verifySteps(["/web/dataset/call_kw/partner/read"]);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: max value should update in readonly mode with right parameter when typing in input with field max value', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="qux" invisible="1" />' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true, \'max_value\': \'qux\', \'edit_max_value\': true, \'editable_readonly\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'write') {
|
||||
assert.strictEqual(args.args[1].qux, 69,
|
||||
'New value of progress bar saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 0',
|
||||
'Initial value should be correct');
|
||||
|
||||
await testUtilsDom.click(form.$('.o_progress'));
|
||||
|
||||
var $valInput = form.$('.o_progressbar_value.o_input');
|
||||
assert.strictEqual($valInput.val(), "0.44444", 'Initial value in input is correct');
|
||||
|
||||
await testUtils.fields.editAndTrigger($valInput, '69', ['input', 'blur']);
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99 / 69',
|
||||
'New value should be different than initial after changing it');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: value should update in readonly mode with right parameter when typing in input with field value', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true, \'editable_readonly\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'write') {
|
||||
assert.strictEqual(args.args[1].int_field, 69,
|
||||
'New value of progress bar saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_readonly'), 'Form in readonly mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99%',
|
||||
'Initial value should be correct');
|
||||
|
||||
await testUtilsDom.click(form.$('.o_progress'));
|
||||
|
||||
var $valInput = form.$('.o_progressbar_value.o_input');
|
||||
assert.strictEqual($valInput.val(), "99", 'Initial value in input is correct');
|
||||
|
||||
await testUtils.fields.editAndTrigger($valInput, '69.6', ['input', 'blur']);
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '69%',
|
||||
'New value should be different than initial after changing it');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: write float instead of int works, in locale', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
translateParameters: {
|
||||
thousands_sep: "#",
|
||||
decimal_point: ":",
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'write') {
|
||||
assert.strictEqual(args.args[1].int_field, 1037,
|
||||
'New value of progress bar saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99%',
|
||||
'Initial value should be correct');
|
||||
|
||||
await testUtilsDom.click(form.$('.o_progress'));
|
||||
|
||||
var $valInput = form.$('.o_progressbar_value.o_input');
|
||||
assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct');
|
||||
|
||||
await testUtils.fields.editAndTrigger($valInput, '1#037:9', ['input', 'blur']);
|
||||
|
||||
await testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '1k%',
|
||||
'New value should be different than initial after click');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('Field ProgressBar: write gibbrish instead of int throws warning', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.data.partner.records[0].int_field = 99;
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="int_field" widget="progressbar" options="{\'editable\': true}" />' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
interceptsPropagate: {
|
||||
call_service: function (ev) {
|
||||
if (ev.data.service === 'notification') {
|
||||
assert.strictEqual(ev.data.method, 'notify');
|
||||
assert.strictEqual(ev.data.args[0].title, 'Wrong value entered!');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), 'Form in edit mode');
|
||||
|
||||
assert.strictEqual(form.$('.o_progressbar_value').text(), '99%',
|
||||
'Initial value should be correct');
|
||||
|
||||
await testUtilsDom.click(form.$('.o_progress'));
|
||||
|
||||
var $valInput = form.$('.o_progressbar_value.o_input');
|
||||
assert.strictEqual($valInput.val(), '99', 'Initial value in input is correct');
|
||||
|
||||
await testUtils.fields.editAndTrigger($valInput, 'trente sept virgule neuf', ['input']);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
|
||||
@@ -571,6 +571,64 @@ QUnit.module('Views', {
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('category has been archived', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
this.data.company.fields.active = {type: 'boolean', string: 'Archived'};
|
||||
this.data.company.records = [
|
||||
{
|
||||
name: 'Company 5',
|
||||
id: 5,
|
||||
active: true,
|
||||
}, {
|
||||
name: 'child of 5 archived',
|
||||
parent_id: 5,
|
||||
id: 666,
|
||||
active: false,
|
||||
}, {
|
||||
name: 'child of 666',
|
||||
parent_id: 666,
|
||||
id: 777,
|
||||
active: true,
|
||||
}
|
||||
];
|
||||
|
||||
var kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div>
|
||||
<field name="foo"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
archs: {
|
||||
'partner,false,search': `<search><searchpanel><field name="company_id"/></searchpanel></search>`,
|
||||
},
|
||||
mockRPC: async function (route, args) {
|
||||
if (route === '/web/dataset/call_kw/partner/search_panel_select_range') {
|
||||
var results = await this._super.apply(this, arguments);
|
||||
results.values = results.values.filter(rec => rec.active !== false);
|
||||
return Promise.resolve(results);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(kanban, '.o_search_panel_category_value', 2,
|
||||
'The number of categories should be 2: All and Company 5');
|
||||
|
||||
assert.containsNone(kanban, '.o_toggle_fold',
|
||||
'None of the categories should have children');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('use two categories to refine search', async function (assert) {
|
||||
assert.expect(14);
|
||||
|
||||
|
||||
@@ -77,7 +77,16 @@ var SnippetEditor = Widget.extend({
|
||||
return $clone;
|
||||
},
|
||||
start: this._onDragAndDropStart.bind(this),
|
||||
stop: this._onDragAndDropStop.bind(this),
|
||||
stop: function () {
|
||||
// Delay our stop handler so that some summernote handlers
|
||||
// which occur on mouseup (and are themself delayed) are
|
||||
// executed first (this prevents the library to crash
|
||||
// because our stop handler may change the DOM).
|
||||
var args = arguments;
|
||||
setTimeout(function () {
|
||||
self._onDragAndDropStop.apply(self, args);
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.$('.o_move_handle').addClass('d-none');
|
||||
|
||||
@@ -700,6 +700,17 @@ registry.colorpicker = SnippetOption.extend({
|
||||
this.$el.find('we-collapse').append($pt);
|
||||
}
|
||||
|
||||
|
||||
// TODO refactor in master
|
||||
// The primary and secondary are hardcoded here (but marked as hidden)
|
||||
// so they can be removed from snippets when selecting another color.
|
||||
// Normally, the chosable colors do not contain them, which prevents
|
||||
// them to be removed. Indeed, normally, the 'alpha' and 'beta' colors
|
||||
// (which are the same) are displayed instead... but not for all themes.
|
||||
var $colorpicker = this.$el.find('.colorpicker');
|
||||
$colorpicker.append($('<button/>', {'class': 'd-none', 'data-color': 'primary'}));
|
||||
$colorpicker.append($('<button/>', {'class': 'd-none', 'data-color': 'secondary'}));
|
||||
|
||||
var classes = [];
|
||||
this.$el.find('.colorpicker button').each(function () {
|
||||
var $color = $(this);
|
||||
|
||||
@@ -1139,7 +1139,7 @@ var VideoWidget = MediaWidget.extend({
|
||||
$video.attr('src', vinMatch[0] + '/embed/simple');
|
||||
videoType = 'vin';
|
||||
} else if (vimMatch && vimMatch[3].length) {
|
||||
$video.attr('src', '//player.vimeo.com/video/' + vimMatch[3] + autoplay);
|
||||
$video.attr('src', '//player.vimeo.com/video/' + vimMatch[3] + autoplay.replace('mute', 'muted'));
|
||||
videoType = 'vim';
|
||||
} else if (dmMatch && dmMatch[2].length) {
|
||||
var justId = dmMatch[2].replace('video/', '');
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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))])
|
||||
|
||||
@@ -601,7 +601,7 @@ class BarcodeConverter(models.AbstractModel):
|
||||
def get_available_options(self):
|
||||
options = super(BarcodeConverter, self).get_available_options()
|
||||
options.update(
|
||||
type=dict(type='string', string=_('Barcode type'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'),
|
||||
symbology=dict(type='string', string=_('Barcode symbology'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'),
|
||||
width=dict(type='integer', string=_('Width'), default_value=600),
|
||||
height=dict(type='integer', string=_('Height'), default_value=100),
|
||||
humanreadable=dict(type='integer', string=_('Human Readable'), default_value=0),
|
||||
@@ -610,9 +610,9 @@ class BarcodeConverter(models.AbstractModel):
|
||||
|
||||
@api.model
|
||||
def value_to_html(self, value, options=None):
|
||||
barcode_type = options.get('type', 'Code128')
|
||||
barcode_symbology = options.get('symbology', 'Code128')
|
||||
barcode = self.env['ir.actions.report'].barcode(
|
||||
barcode_type,
|
||||
barcode_symbology,
|
||||
value,
|
||||
**{key: value for key, value in options.items() if key in ['width', 'height', 'humanreadable']})
|
||||
return u'<img src="data:png;base64,%s">' % base64.b64encode(barcode).decode('ascii')
|
||||
|
||||
@@ -400,10 +400,42 @@ class Users(models.Model):
|
||||
|
||||
@api.constrains('groups_id')
|
||||
def _check_one_user_type(self):
|
||||
for user in self:
|
||||
if len(user.groups_id.filtered(lambda x: x.category_id.xml_id == 'base.module_category_user_type')) > 1:
|
||||
"""We check that no users are both portal and users (same with public).
|
||||
This could typically happen because of implied groups.
|
||||
"""
|
||||
user_types_category = self.env.ref('base.module_category_user_type', raise_if_not_found=False)
|
||||
user_types_groups = self.env['res.groups'].search(
|
||||
[('category_id', '=', user_types_category.id)]) if user_types_category else False
|
||||
if user_types_groups: # needed at install
|
||||
if self._has_multiple_groups(user_types_groups.ids):
|
||||
raise ValidationError(_('The user cannot have more than one user types.'))
|
||||
|
||||
def _has_multiple_groups(self, group_ids):
|
||||
"""The method is not fast if the list of ids is very long;
|
||||
so we rather check all users than limit to the size of the group
|
||||
:param group_ids: list of group ids
|
||||
:return: boolean: is there at least a user in at least 2 of the provided groups
|
||||
"""
|
||||
if group_ids:
|
||||
args = [tuple(group_ids)]
|
||||
if len(self.ids) == 1:
|
||||
where_clause = "AND r.uid = %s"
|
||||
args.append(self.id)
|
||||
else:
|
||||
where_clause = "" # default; we check ALL users (actually pretty efficient)
|
||||
query = """
|
||||
SELECT 1 FROM res_groups_users_rel WHERE EXISTS(
|
||||
SELECT r.uid
|
||||
FROM res_groups_users_rel r
|
||||
WHERE r.gid IN %s""" + where_clause + """
|
||||
GROUP BY r.uid HAVING COUNT(r.gid) > 1
|
||||
)
|
||||
"""
|
||||
self.env.cr.execute(query, args)
|
||||
return bool(self.env.cr.fetchall())
|
||||
else:
|
||||
return False
|
||||
|
||||
def toggle_active(self):
|
||||
for user in self:
|
||||
if not user.active and not user.partner_id.active:
|
||||
@@ -939,6 +971,7 @@ class GroupsImplied(models.Model):
|
||||
JOIN group_imply i ON (r.gid = i.hid)
|
||||
WHERE i.gid = %(gid)s
|
||||
""", dict(gid=group.id))
|
||||
self._check_one_user_type()
|
||||
return res
|
||||
|
||||
class UsersImplied(models.Model):
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -524,7 +524,7 @@
|
||||
<img class="o_kanban_image_inner_pic" t-if="record.parent_id.raw_value" t-att-alt="record.parent_id.value" t-att-src="kanban_image('res.partner', 'image_128', record.parent_id.raw_value)"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-elif="record.image_128.raw_value">
|
||||
<t t-set="placeholder" t-value="'/base/static/img/company_image.png'"/>
|
||||
<div class="o_kanban_image_fill_left o_kanban_image_full" t-attf-style="background-image: url(#{kanban_image('res.partner', 'image_128', record.id.raw_value, placeholder)})" role="img"/>
|
||||
</t>
|
||||
|
||||
@@ -11,9 +11,11 @@ class TestBasic(common.TransactionCase):
|
||||
Y3 = {'f1': "Y", 'f2': 3}
|
||||
records = self.env['test_testing_utilities.a'].create([X1, Y2])
|
||||
|
||||
# match in any order
|
||||
self.assertRecordValues(records, [X1, Y2])
|
||||
self.assertRecordValues(records, [Y2, X1])
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
# order should match
|
||||
self.assertRecordValues(records, [Y2, X1])
|
||||
|
||||
# fail if wrong size
|
||||
with self.assertRaises(AssertionError):
|
||||
|
||||
+1
-1
@@ -5560,7 +5560,7 @@ Record ids: %(records)s
|
||||
real_records = self - new_records
|
||||
records = model.browse()
|
||||
if real_records:
|
||||
records |= model.search([(key.name, 'in', real_records.ids)])
|
||||
records |= model.search([(key.name, 'in', real_records.ids)], order='id')
|
||||
if new_records:
|
||||
cache_records = self.env.cache.get_records(model, key)
|
||||
records |= cache_records.filtered(lambda r: set(r[key.name]._ids) & set(self._ids))
|
||||
|
||||
+72
-50
@@ -23,6 +23,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import difflib
|
||||
import werkzeug.urls
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, date
|
||||
@@ -315,74 +316,95 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})):
|
||||
:param expected_values: List of dicts expected to be exactly matched in records
|
||||
'''
|
||||
|
||||
def _compare_candidate(record, candidate):
|
||||
''' Return True if the candidate matches the given record '''
|
||||
for field_name in candidate.keys():
|
||||
def _compare_candidate(record, candidate, field_names):
|
||||
''' Compare all the values in `candidate` with a record.
|
||||
:param record: record being compared
|
||||
:param candidate: dict of values to compare
|
||||
:return: A dictionary will encountered difference in values.
|
||||
'''
|
||||
diff = {}
|
||||
for field_name in field_names:
|
||||
record_value = record[field_name]
|
||||
candidate_value = candidate[field_name]
|
||||
field = record._fields[field_name]
|
||||
field_type = field.type
|
||||
if field_type == 'monetary':
|
||||
# Compare monetary field.
|
||||
currency_field_name = record._fields[field_name].currency_field
|
||||
record_currency = record[currency_field_name]
|
||||
if record_currency.compare_amounts(candidate_value, record_value)\
|
||||
if record_currency else candidate_value != record_value:
|
||||
return False
|
||||
if field_name not in candidate:
|
||||
diff[field_name] = (record_value, None)
|
||||
elif record_currency:
|
||||
if record_currency.compare_amounts(candidate[field_name], record_value):
|
||||
diff[field_name] = (record_value, record_currency.round(candidate[field_name]))
|
||||
elif candidate[field_name] != record_value:
|
||||
diff[field_name] = (record_value, candidate[field_name])
|
||||
elif field_type == 'float' and field.get_digits(record.env):
|
||||
prec = field.get_digits(record.env)[1]
|
||||
if float_compare(candidate_value, record_value, precision_digits=prec) != 0:
|
||||
return False
|
||||
if float_compare(candidate[field_name], record_value, precision_digits=prec) != 0:
|
||||
diff[field_name] = (record_value, candidate[field_name])
|
||||
elif field_type in ('one2many', 'many2many'):
|
||||
# Compare x2many relational fields.
|
||||
# Empty comparison must be an empty list to be True.
|
||||
if set(record_value.ids) != set(candidate_value):
|
||||
return False
|
||||
if field_name not in candidate:
|
||||
diff[field_name] = (sorted(record_value.ids), None)
|
||||
elif set(record_value.ids) != set(candidate[field_name]):
|
||||
diff[field_name] = (sorted(record_value.ids), sorted(candidate[field_name]))
|
||||
elif field_type == 'many2one':
|
||||
# Compare many2one relational fields.
|
||||
# Every falsy value is allowed to compare with an empty record.
|
||||
if (record_value or candidate_value) and record_value.id != candidate_value:
|
||||
return False
|
||||
elif (candidate_value or record_value) and record_value != candidate_value:
|
||||
if field_name not in candidate:
|
||||
diff[field_name] = (record_value.id, None)
|
||||
elif (record_value or candidate[field_name]) and record_value.id != candidate[field_name]:
|
||||
diff[field_name] = (record_value.id, candidate[field_name])
|
||||
else:
|
||||
# Compare others fields if not both interpreted as falsy values.
|
||||
return False
|
||||
return True
|
||||
if field_name not in candidate:
|
||||
diff[field_name] = (record_value, None)
|
||||
elif (candidate[field_name] or record_value) and record_value != candidate[field_name]:
|
||||
diff[field_name] = (record_value, candidate[field_name])
|
||||
return diff
|
||||
|
||||
def _repr_field_value(record, field_name):
|
||||
record_value = record[field_name]
|
||||
field_type = record._fields[field_name].type
|
||||
if field_type == 'monetary':
|
||||
currency_field_name = record._fields[field_name].currency_field
|
||||
record_currency = record[currency_field_name]
|
||||
return record_currency and record_currency.round(record_value) or record_value
|
||||
elif field_type in ('one2many', 'many2many'):
|
||||
return set(record_value.ids)
|
||||
elif field_type == 'many2one':
|
||||
return record_value.id
|
||||
else:
|
||||
return record_value
|
||||
|
||||
def _format_message(records, expected_values):
|
||||
''' Return a formatted representation of records/expected_values. '''
|
||||
all_records_values = [{key: _repr_field_value(record, key) for key in expected_values[0]} for record in records]
|
||||
msg1 = '\n'.join(pprint.pformat(dic) for dic in all_records_values)
|
||||
msg2 = '\n'.join(pprint.pformat(dic) for dic in expected_values)
|
||||
return 'Current values:\n\n%s\n\nExpected values:\n\n%s' % (msg1, msg2)
|
||||
|
||||
# if the length or both things to compare is different, we can already tell they're not equal
|
||||
if len(records) != len(expected_values):
|
||||
msg = 'Wrong number of records to compare: %d != %d.\n\n' % (len(records), len(expected_values))
|
||||
self.fail(msg + _format_message(records, expected_values))
|
||||
|
||||
candidates = list(expected_values)
|
||||
# Compare records with candidates.
|
||||
different_values = []
|
||||
field_names = list(expected_values[0].keys())
|
||||
for index, record in enumerate(records):
|
||||
for candidate_index, candidate in enumerate(candidates):
|
||||
if _compare_candidate(record, candidate):
|
||||
candidates.pop(candidate_index)
|
||||
break
|
||||
else:
|
||||
msg = 'Record doesn\'t match expected values at index %d.\n\n' % index
|
||||
self.fail(msg + _format_message(records, expected_values))
|
||||
is_additional_record = index >= len(expected_values)
|
||||
candidate = {} if is_additional_record else expected_values[index]
|
||||
diff = _compare_candidate(record, candidate, field_names)
|
||||
if diff:
|
||||
different_values.append((index, 'additional_record' if is_additional_record else 'regular_diff', diff))
|
||||
for index in range(len(records), len(expected_values)):
|
||||
diff = {}
|
||||
for field_name in field_names:
|
||||
diff[field_name] = (None, expected_values[index][field_name])
|
||||
different_values.append((index, 'missing_record', diff))
|
||||
|
||||
# Build error message.
|
||||
if not different_values:
|
||||
return
|
||||
|
||||
errors = ['The records and expected_values do not match.']
|
||||
if len(records) != len(expected_values):
|
||||
errors.append('Wrong number of records to compare: %d records versus %d expected values.' % (len(records), len(expected_values)))
|
||||
|
||||
for index, diff_type, diff in different_values:
|
||||
if diff_type == 'regular_diff':
|
||||
errors.append('\n==== Differences at index %s ====' % index)
|
||||
record_diff = ['%s:%s' % (k, v[0]) for k, v in diff.items()]
|
||||
candidate_diff = ['%s:%s' % (k, v[1]) for k, v in diff.items()]
|
||||
errors.append('\n'.join(difflib.unified_diff(record_diff, candidate_diff)))
|
||||
elif diff_type == 'additional_record':
|
||||
errors += [
|
||||
'\n==== Additional record ====',
|
||||
pprint.pformat(dict((k, v[0]) for k, v in diff.items())),
|
||||
]
|
||||
elif diff_type == 'missing_record':
|
||||
errors += [
|
||||
'\n==== Missing record ====',
|
||||
pprint.pformat(dict((k, v[1]) for k, v in diff.items())),
|
||||
]
|
||||
|
||||
self.fail('\n'.join(errors))
|
||||
|
||||
def shortDescription(self):
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user