[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:
Christophe Simonis
2019-09-20 14:33:54 +00:00
61 changed files with 1146 additions and 166 deletions
+4 -4
View File
@@ -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):
+5 -5
View File
@@ -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')
+14 -5
View File
@@ -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' ====
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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()
+5 -8
View File
@@ -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)."))
+118
View File
@@ -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):
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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',
+6 -2
View File
@@ -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
+4 -2
View File
@@ -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;
+5 -3
View File
@@ -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)
+1 -1
View File
@@ -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.
+3 -4
View File
@@ -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;
+1
View File
@@ -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>
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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',
+4 -1
View File
@@ -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:
+66 -35
View File
@@ -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:
+2 -1
View File
@@ -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;
}
}
}
+5
View File
@@ -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',
+1 -1
View File
@@ -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"/>
+1 -1
View File
@@ -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))])
+3 -3
View File
@@ -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')
+35 -2
View File
@@ -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 ('&lt;br/&gt;%s' % ('' if options.get('no_marker') else '')))"/>
<div class="w-100" itemprop="streetAddress" t-raw="address.replace('\n', options.get('no_tag_br') and ', ' or ('&lt;br/&gt;%s' % ('' if options.get('no_marker') else '')))"/>
</div>
<div t-if="city and 'city' in fields" class="d-flex align-items-baseline">
<i t-if="not options.get('no_marker')" class="fa fa-map-marker fa-fw" role="img" aria-label="Address" title="Address"/>
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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