diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index d57fb164ee4..3981ed91640 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -667,8 +667,9 @@ class AccountBankStatementLine(models.Model):
#create the res.partner.bank if needed
if self.account_number and self.partner_id and not self.bank_account_id:
- bank_account = self.env['res.partner.bank'].search(
- [('acc_number', '=', self.account_number), ('partner_id', '=', self.partner_id.id)])
+ # Search bank account without partner to handle the case the res.partner.bank already exists but is set
+ # on a different partner.
+ bank_account = self.env['res.partner.bank'].search([('acc_number', '=', self.account_number)])
if not bank_account:
bank_account = self.env['res.partner.bank'].create({
'acc_number': self.account_number, 'partner_id': self.partner_id.id
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index ef7c145e072..50760c25ff9 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -992,6 +992,7 @@ class AccountInvoice(models.Model):
@api.multi
def get_taxes_values(self):
tax_grouped = {}
+ round_curr = self.currency_id.round
for line in self.invoice_line_ids:
if not line.account_id:
continue
@@ -1003,9 +1004,10 @@ class AccountInvoice(models.Model):
if key not in tax_grouped:
tax_grouped[key] = val
+ tax_grouped[key]['base'] = round_curr(val['base'])
else:
tax_grouped[key]['amount'] += val['amount']
- tax_grouped[key]['base'] += val['base']
+ tax_grouped[key]['base'] += round_curr(val['base'])
return tax_grouped
@api.multi
@@ -1734,17 +1736,17 @@ class AccountInvoiceLine(models.Model):
self.price_unit = 0.0
domain['uom_id'] = []
else:
+ self_lang = self
if part.lang:
- product = self.product_id.with_context(lang=part.lang)
- else:
- product = self.product_id
-
+ self_lang = self.with_context(lang=part.lang)
+
+ product = self_lang.product_id
account = self.get_invoice_line_account(type, product, fpos, company)
if account:
self.account_id = account.id
self._set_taxes()
- product_name = self._get_invoice_line_name_from_product()
+ product_name = self_lang._get_invoice_line_name_from_product()
if product_name != None:
self.name = product_name
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index 7b038433905..b19485674a4 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -775,7 +775,7 @@ class AccountMoveLine(models.Model):
(multiple_currency and float_compare(total_debit, total_credit, precision_rounding=digits_rounding_precision) == 0):
exchange_move_id = False
# Eventually create a journal entry to book the difference due to foreign currency's exchange rate that fluctuates
- if to_balance and any([residual for aml, residual in to_balance.values()]):
+ if to_balance and any([not float_is_zero(residual, precision_rounding=digits_rounding_precision) for aml, residual in to_balance.values()]):
exchange_move = self.env['account.move'].create(
self.env['account.full.reconcile']._prepare_exchange_diff_move(move_date=maxdate, company=amls[0].company_id))
part_reconcile = self.env['account.partial.reconcile']
@@ -804,7 +804,7 @@ class AccountMoveLine(models.Model):
(debit_moves + credit_moves).read([field])
to_create = []
cash_basis = debit_moves and debit_moves[0].account_id.internal_type in ('receivable', 'payable') or False
- cash_basis_percentage_before_rec = []
+ cash_basis_percentage_before_rec = {}
while (debit_moves and credit_moves):
debit_move = debit_moves[0]
credit_move = credit_moves[0]
@@ -839,7 +839,7 @@ class AccountMoveLine(models.Model):
if cash_basis:
tmp_set = debit_move | credit_move
- cash_basis_percentage_before_rec.append(tmp_set._get_matched_percentage())
+ cash_basis_percentage_before_rec.update(tmp_set._get_matched_percentage())
to_create.append({
'debit_move_id': debit_move.id,
@@ -850,14 +850,12 @@ class AccountMoveLine(models.Model):
})
part_rec = self.env['account.partial.reconcile']
- index = 0
with self.env.norecompute():
for partial_rec_dict in to_create:
new_rec = self.env['account.partial.reconcile'].create(partial_rec_dict)
part_rec += new_rec
if cash_basis:
- new_rec.create_tax_cash_basis_entry(cash_basis_percentage_before_rec[index])
- index += 1
+ new_rec.create_tax_cash_basis_entry(cash_basis_percentage_before_rec)
self.recompute()
return debit_moves+credit_moves
@@ -1468,10 +1466,14 @@ class AccountPartialReconcile(models.Model):
#move_date is the max of the 2 reconciled items
if move_date < move.date:
move_date = move.date
+ percentage_before = percentage_before_rec[move.id]
+ percentage_after = move.line_ids[0]._get_matched_percentage()[move.id]
+ # update the percentage before as the move can be part of
+ # multiple partial reconciliations
+ percentage_before_rec[move.id] = percentage_after
+
for line in move.line_ids:
if not line.tax_exigible:
- percentage_before = percentage_before_rec[move.id]
- percentage_after = line._get_matched_percentage()[move.id]
#amount is the current cash_basis amount minus the one before the reconciliation
amount = line.balance * percentage_after - line.balance * percentage_before
rounded_amt = self._get_amount_tax_cash_basis(amount, line)
@@ -1543,6 +1545,7 @@ class AccountPartialReconcile(models.Model):
'amount_currency': self.amount_currency and line.currency_id.round(-line.amount_currency * amount / line.balance) or 0.0,
'partner_id': line.partner_id.id,
})
+
if newly_created_move:
if move_date > (self.company_id.period_lock_date or date.min) and newly_created_move.date != move_date:
# The move date should be the maximum date between payment and invoice (in case
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index e69aa30e431..c83d2b31ed3 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -736,12 +736,15 @@ class account_payment(models.Model):
""" Return dict to create the payment move
"""
journal = journal or self.journal_id
- return {
+ move_vals = {
'date': self.payment_date,
'ref': self.communication or '',
'company_id': self.company_id.id,
'journal_id': journal.id,
}
+ if self.move_name:
+ move_vals['name'] = self.move_name
+ return move_vals
def _get_shared_move_line_vals(self, debit, credit, amount_currency, move_id, invoice_id=False):
""" Returns values common to both move lines (except for debit, credit and amount_currency which are reversed)
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js
index a192b2210af..5e756f28247 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_model.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js
@@ -903,11 +903,11 @@ var StatementModel = BasicModel.extend({
});
var company_currency = session.get_currency(line.st_line.currency_id);
var company_precision = company_currency && company_currency.digits[1] || 2;
- total = utils.round_precision(total*1000, company_precision)/1000 || 0;
+ total = utils.round_decimals(total*1000, company_precision)/1000 || 0;
if(isOtherCurrencyId){
var other_currency = session.get_currency(isOtherCurrencyId);
var other_precision = other_currency && other_currency.digits[1] || 2;
- amount_currency = utils.round_precision(amount_currency, other_precision)
+ amount_currency = utils.round_decimals(amount_currency, other_precision)
}
line.balance = {
amount: total,
diff --git a/addons/account/tests/test_account_customer_invoice.py b/addons/account/tests/test_account_customer_invoice.py
index a6b3459c9b9..83822ef8ffb 100644
--- a/addons/account/tests/test_account_customer_invoice.py
+++ b/addons/account/tests/test_account_customer_invoice.py
@@ -103,3 +103,66 @@ class TestAccountCustomerInvoice(AccountTestUsers):
# I clicked on Add Credit Note button.
self.account_invoice_refund_0.invoice_refund()
+
+ def test_customer_invoice_tax(self):
+
+ self.env.user.company_id.tax_calculation_rounding_method = 'round_globally'
+
+ payment_term = self.env.ref('account.account_payment_term_advance')
+ journalrec = self.env['account.journal'].search([('type', '=', 'sale')])[0]
+ partner3 = self.env.ref('base.res_partner_3')
+ account_id = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id
+
+ tax = self.env['account.tax'].create({
+ 'name': 'Tax 15.0',
+ 'amount': 15.0,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ })
+
+ invoice_line_data = [
+ (0, 0,
+ {
+ 'product_id': self.env.ref('product.product_product_1').id,
+ 'quantity': 40.0,
+ 'account_id': account_id,
+ 'name': 'product test 1',
+ 'discount' : 10.00,
+ 'price_unit': 2.27,
+ 'invoice_line_tax_ids': [(6, 0, [tax.id])],
+ }
+ ),
+ (0, 0,
+ {
+ 'product_id': self.env.ref('product.product_product_2').id,
+ 'quantity': 21.0,
+ 'account_id': self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id,
+ 'name': 'product test 2',
+ 'discount' : 10.00,
+ 'price_unit': 2.77,
+ 'invoice_line_tax_ids': [(6, 0, [tax.id])],
+ }
+ ),
+ (0, 0,
+ {
+ 'product_id': self.env.ref('product.product_product_3').id,
+ 'quantity': 21.0,
+ 'account_id': self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id,
+ 'name': 'product test 3',
+ 'discount' : 10.00,
+ 'price_unit': 2.77,
+ 'invoice_line_tax_ids': [(6, 0, [tax.id])],
+ }
+ )
+ ]
+
+ invoice = self.env['account.invoice'].create(dict(
+ name="Test Customer Invoice",
+ reference_type="none",
+ payment_term_id=payment_term.id,
+ journal_id=journalrec.id,
+ partner_id=partner3.id,
+ invoice_line_ids=invoice_line_data
+ ))
+
+ self.assertEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids]))
diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py
index be446c818a5..970989a970f 100644
--- a/addons/account/tests/test_reconciliation.py
+++ b/addons/account/tests/test_reconciliation.py
@@ -49,6 +49,58 @@ class TestReconciliation(AccountingTestCase):
'payment_type': 'inbound',
})
+ self.expense_account = self.env['account.account'].create({
+ 'name': 'EXP',
+ 'code': 'EXP',
+ 'user_type_id': self.env.ref('account.data_account_type_expenses').id,
+ 'company_id': company.id,
+ })
+ # cash basis intermediary account
+ self.tax_waiting_account = self.env['account.account'].create({
+ 'name': 'TAX_WAIT',
+ 'code': 'TWAIT',
+ 'user_type_id': self.env.ref('account.data_account_type_current_liabilities').id,
+ 'reconcile': True,
+ 'company_id': company.id,
+ })
+ # cash basis final account
+ self.tax_final_account = self.env['account.account'].create({
+ 'name': 'TAX_TO_DEDUCT',
+ 'code': 'TDEDUCT',
+ 'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
+ 'company_id': company.id,
+ })
+ self.tax_base_amount_account = self.env['account.account'].create({
+ 'name': 'TAX_BASE',
+ 'code': 'TBASE',
+ 'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
+ 'company_id': company.id,
+ })
+
+ # Journals
+ self.purchase_journal = self.env['account.journal'].create({
+ 'name': 'purchase',
+ 'code': 'PURCH',
+ 'type': 'purchase',
+ })
+ self.cash_basis_journal = self.env['account.journal'].create({
+ 'name': 'CABA',
+ 'code': 'CABA',
+ 'type': 'general',
+ })
+
+ # Tax Cash Basis
+ self.tax_cash_basis = self.env['account.tax'].create({
+ 'name': 'cash basis 20%',
+ 'type_tax_use': 'purchase',
+ 'company_id': company.id,
+ 'amount': 20,
+ 'account_id': self.tax_waiting_account.id,
+ 'tax_exigibility': 'on_payment',
+ 'cash_basis_account_id': self.tax_final_account.id,
+ 'cash_basis_base_account_id': self.tax_base_amount_account.id,
+ })
+
def create_invoice(self, type='out_invoice', invoice_amount=50, currency_id=None):
#we create an invoice in given currency
invoice = self.account_invoice_model.create({'partner_id': self.partner_agrolait_id,
@@ -1107,3 +1159,288 @@ class TestReconciliation(AccountingTestCase):
receivable_lines = invoice.move_id.line_ids.filtered(lambda line: line.account_id == self.account_rcv).sorted('date_maturity')[0]
self.assertTrue(receivable_lines.matched_credit_ids)
+
+ def test_reconciliation_cash_basis01(self):
+ # Simulates an expense made up by 2 lines
+ # one is subject to a cash basis tax
+ # the other is not subject to tax
+
+ company = self.env.ref('base.main_company')
+ company.tax_cash_basis_journal_id = self.cash_basis_journal
+
+ AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False)
+
+ # Purchase
+ purchase_move = self.env['account.move'].create({
+ 'name': 'purchase',
+ 'journal_id': self.purchase_journal.id,
+ })
+
+ purchase_payable_line0 = AccountMoveLine.create({
+ 'account_id': self.account_rsa.id,
+ 'credit': 100,
+ 'move_id': purchase_move.id,
+ })
+ purchase_payable_line1 = AccountMoveLine.create({
+ 'account_id': self.account_rsa.id,
+ 'credit': 50,
+ 'move_id': purchase_move.id,
+ })
+ AccountMoveLine.create({
+ 'name': 'expensNoTax',
+ 'account_id': self.expense_account.id,
+ 'debit': 50,
+ 'move_id': purchase_move.id,
+ })
+ AccountMoveLine.create({
+ 'name': 'expenseTaxed',
+ 'account_id': self.expense_account.id,
+ 'debit': 83.33,
+ 'move_id': purchase_move.id,
+ 'tax_ids': [(4, self.tax_cash_basis.id, False)],
+ })
+ tax_line = AccountMoveLine.create({
+ 'name': 'TaxLine',
+ 'account_id': self.tax_waiting_account.id,
+ 'debit': 16.67,
+ 'move_id': purchase_move.id,
+ 'tax_line_id': self.tax_cash_basis.id,
+ })
+ purchase_move.post()
+
+ # Payment Move
+ payment_move = self.env['account.move'].create({
+ 'name': 'payment',
+ 'journal_id': self.bank_journal_euro.id,
+ })
+ payment_payable_line = AccountMoveLine.create({
+ 'account_id': self.account_rsa.id,
+ 'debit': 150,
+ 'move_id': payment_move.id,
+ })
+ AccountMoveLine.create({
+ 'account_id': self.account_euro.id,
+ 'credit': 150,
+ 'move_id': payment_move.id,
+ })
+ payment_move.post()
+
+ to_reconcile = (purchase_move + payment_move).mapped('line_ids').filtered(lambda l: l.account_id.internal_type == 'payable')
+ to_reconcile.reconcile()
+
+ cash_basis_moves = self.env['account.move'].search([('journal_id', '=', self.cash_basis_journal.id)])
+
+ self.assertEqual(len(cash_basis_moves), 2)
+ self.assertTrue(cash_basis_moves.exists())
+
+ # check reconciliation in Payable account
+ self.assertTrue(purchase_payable_line0.full_reconcile_id.exists())
+ self.assertEqual(purchase_payable_line0.full_reconcile_id.reconciled_line_ids,
+ purchase_payable_line0 + purchase_payable_line1 + payment_payable_line)
+
+ cash_basis_aml_ids = cash_basis_moves.mapped('line_ids')
+ # check reconciliation in the tax waiting account
+ self.assertTrue(tax_line.full_reconcile_id.exists())
+ self.assertEqual(tax_line.full_reconcile_id.reconciled_line_ids,
+ cash_basis_aml_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + tax_line)
+
+ self.assertEqual(len(cash_basis_aml_ids), 8)
+
+ # check amounts
+ cash_basis_move1 = cash_basis_moves.filtered(lambda m: m.amount == 33.34)
+ cash_basis_move2 = cash_basis_moves.filtered(lambda m: m.amount == 66.66)
+
+ self.assertTrue(cash_basis_move1.exists())
+ self.assertTrue(cash_basis_move2.exists())
+
+ # For first move
+ move_lines = cash_basis_move1.line_ids
+ base_amount_tax_lines = move_lines.filtered(lambda l: l.account_id == self.tax_base_amount_account)
+ self.assertEqual(len(base_amount_tax_lines), 2)
+ self.assertAlmostEqual(sum(base_amount_tax_lines.mapped('credit')), 27.78)
+ self.assertAlmostEqual(sum(base_amount_tax_lines.mapped('debit')), 27.78)
+
+ self.assertAlmostEqual((move_lines - base_amount_tax_lines).filtered(lambda l: l.account_id == self.tax_waiting_account).credit,
+ 5.56)
+ self.assertAlmostEqual((move_lines - base_amount_tax_lines).filtered(lambda l: l.account_id == self.tax_final_account).debit,
+ 5.56)
+
+ # For second move
+ move_lines = cash_basis_move2.line_ids
+ base_amount_tax_lines = move_lines.filtered(lambda l: l.account_id == self.tax_base_amount_account)
+ self.assertEqual(len(base_amount_tax_lines), 2)
+ self.assertAlmostEqual(sum(base_amount_tax_lines.mapped('credit')), 55.55)
+ self.assertAlmostEqual(sum(base_amount_tax_lines.mapped('debit')), 55.55)
+
+ self.assertAlmostEqual((move_lines - base_amount_tax_lines).filtered(lambda l: l.account_id == self.tax_waiting_account).credit,
+ 11.11)
+ self.assertAlmostEqual((move_lines - base_amount_tax_lines).filtered(lambda l: l.account_id == self.tax_final_account).debit,
+ 11.11)
+
+ def test_reconciliation_cash_basis02(self):
+ # Simulates an invoice made up by 2 lines
+ # both subjected to cash basis taxes
+ # with 2 payment terms
+ # And partial payment not martching any payment term
+
+ company = self.env.ref('base.main_company')
+ company.tax_cash_basis_journal_id = self.cash_basis_journal
+ tax_cash_basis10percent = self.tax_cash_basis.copy({'amount': 10})
+ tax_waiting_account10 = self.tax_waiting_account.copy({
+ 'name': 'TAX WAIT 10',
+ 'code': 'TWAIT1',
+ })
+
+
+ AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False)
+
+ # Purchase
+ purchase_move = self.env['account.move'].create({
+ 'name': 'invoice',
+ 'journal_id': self.purchase_journal.id,
+ })
+
+ purchase_payable_line0 = AccountMoveLine.create({
+ 'account_id': self.account_rsa.id,
+ 'credit': 105,
+ 'move_id': purchase_move.id,
+ })
+ purchase_payable_line1 = AccountMoveLine.create({
+ 'account_id': self.account_rsa.id,
+ 'credit': 50,
+ 'move_id': purchase_move.id,
+ })
+ AccountMoveLine.create({
+ 'name': 'expenseTaxed 10%',
+ 'account_id': self.expense_account.id,
+ 'debit': 50,
+ 'move_id': purchase_move.id,
+ 'tax_ids': [(4, tax_cash_basis10percent.id, False)],
+ })
+ tax_line0 = AccountMoveLine.create({
+ 'name': 'TaxLine0',
+ 'account_id': tax_waiting_account10.id,
+ 'debit': 5,
+ 'move_id': purchase_move.id,
+ 'tax_line_id': tax_cash_basis10percent.id,
+ })
+ AccountMoveLine.create({
+ 'name': 'expenseTaxed 20%',
+ 'account_id': self.expense_account.id,
+ 'debit': 83.33,
+ 'move_id': purchase_move.id,
+ 'tax_ids': [(4, self.tax_cash_basis.id, False)],
+ })
+ tax_line1 = AccountMoveLine.create({
+ 'name': 'TaxLine1',
+ 'account_id': self.tax_waiting_account.id,
+ 'debit': 16.67,
+ 'move_id': purchase_move.id,
+ 'tax_line_id': self.tax_cash_basis.id,
+ })
+ purchase_move.post()
+
+ # Payment Move
+ payment_move0 = self.env['account.move'].create({
+ 'name': 'payment',
+ 'journal_id': self.bank_journal_euro.id,
+ })
+ payment_payable_line0 = AccountMoveLine.create({
+ 'account_id': self.account_rsa.id,
+ 'debit': 40,
+ 'move_id': payment_move0.id,
+ })
+ AccountMoveLine.create({
+ 'account_id': self.account_euro.id,
+ 'credit': 40,
+ 'move_id': payment_move0.id,
+ })
+ payment_move0.post()
+
+ # Payment Move
+ payment_move1 = self.env['account.move'].create({
+ 'name': 'payment',
+ 'journal_id': self.bank_journal_euro.id,
+ })
+ payment_payable_line1 = AccountMoveLine.create({
+ 'account_id': self.account_rsa.id,
+ 'debit': 115,
+ 'move_id': payment_move1.id,
+ })
+ AccountMoveLine.create({
+ 'account_id': self.account_euro.id,
+ 'credit': 115,
+ 'move_id': payment_move1.id,
+ })
+ payment_move1.post()
+
+ (purchase_move + payment_move0).mapped('line_ids').filtered(lambda l: l.account_id.internal_type == 'payable').reconcile()
+ (purchase_move + payment_move1).mapped('line_ids').filtered(lambda l: l.account_id.internal_type == 'payable').reconcile()
+
+ cash_basis_moves = self.env['account.move'].search([('journal_id', '=', self.cash_basis_journal.id)])
+
+ self.assertEqual(len(cash_basis_moves), 3)
+ self.assertTrue(cash_basis_moves.exists())
+
+ # check reconciliation in Payable account
+ self.assertTrue(purchase_payable_line0.full_reconcile_id.exists())
+ self.assertEqual(purchase_payable_line0.full_reconcile_id.reconciled_line_ids,
+ purchase_payable_line0 + purchase_payable_line1 + payment_payable_line0 + payment_payable_line1)
+
+ cash_basis_aml_ids = cash_basis_moves.mapped('line_ids')
+
+ # check reconciliation in the tax waiting account
+ self.assertTrue(tax_line0.full_reconcile_id.exists())
+ self.assertEqual(tax_line0.full_reconcile_id.reconciled_line_ids,
+ cash_basis_aml_ids.filtered(lambda l: l.account_id == tax_waiting_account10) + tax_line0)
+
+ self.assertTrue(tax_line1.full_reconcile_id.exists())
+ self.assertEqual(tax_line1.full_reconcile_id.reconciled_line_ids,
+ cash_basis_aml_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + tax_line1)
+
+ self.assertEqual(len(cash_basis_aml_ids), 24)
+
+ # check amounts
+ expected_move_amounts = [
+ {'base_20': 56.45, 'tax_20': 11.29, 'base_10': 33.87, 'tax_10': 3.39},
+ {'base_20': 21.50, 'tax_20': 4.30, 'base_10': 12.90, 'tax_10': 1.29},
+ {'base_20': 5.38, 'tax_20': 1.08, 'base_10': 3.23, 'tax_10': 0.32},
+ ]
+
+ index = 0
+ for cb_move in cash_basis_moves.sorted('amount', reverse=True):
+ expected = expected_move_amounts[index]
+ move_lines = cb_move.line_ids
+ base_amount_tax_lines20per = move_lines.filtered(lambda l: l.account_id == self.tax_base_amount_account and '20%' in l.name)
+ base_amount_tax_lines10per = move_lines.filtered(lambda l: l.account_id == self.tax_base_amount_account and '10%' in l.name)
+ self.assertEqual(len(base_amount_tax_lines20per), 2)
+
+ self.assertAlmostEqual(sum(base_amount_tax_lines20per.mapped('credit')), expected['base_20'])
+ self.assertAlmostEqual(sum(base_amount_tax_lines20per.mapped('debit')), expected['base_20'])
+
+ self.assertEqual(len(base_amount_tax_lines10per), 2)
+ self.assertAlmostEqual(sum(base_amount_tax_lines10per.mapped('credit')), expected['base_10'])
+ self.assertAlmostEqual(sum(base_amount_tax_lines10per.mapped('debit')), expected['base_10'])
+
+ self.assertAlmostEqual(
+ (move_lines - base_amount_tax_lines20per - base_amount_tax_lines10per)
+ .filtered(lambda l: l.account_id == self.tax_waiting_account).credit,
+ expected['tax_20']
+ )
+ self.assertAlmostEqual(
+ (move_lines - base_amount_tax_lines20per - base_amount_tax_lines10per)
+ .filtered(lambda l: 'TaxLine1' in l.name).debit,
+ expected['tax_20']
+ )
+
+ self.assertAlmostEqual(
+ (move_lines - base_amount_tax_lines20per - base_amount_tax_lines10per)
+ .filtered(lambda l: l.account_id == tax_waiting_account10).credit,
+ expected['tax_10']
+ )
+ self.assertAlmostEqual(
+ (move_lines - base_amount_tax_lines20per - base_amount_tax_lines10per)
+ .filtered(lambda l: 'TaxLine0' in l.name).debit,
+ expected['tax_10']
+ )
+ index += 1
diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml
index 45696253ffa..15219345394 100644
--- a/addons/account/views/account_view.xml
+++ b/addons/account/views/account_view.xml
@@ -291,7 +291,7 @@
-
+
diff --git a/addons/barcodes/static/src/js/barcode_field.js b/addons/barcodes/static/src/js/barcode_field.js
index ca5baa90b49..d8a7a1c853e 100644
--- a/addons/barcodes/static/src/js/barcode_field.js
+++ b/addons/barcodes/static/src/js/barcode_field.js
@@ -4,7 +4,7 @@ odoo.define('barcodes.field', function(require) {
var AbstractField = require('web.AbstractField');
var basicFields = require('web.basic_fields');
var fieldRegistry = require('web.field_registry');
-var is_special_key = require('barcodes.BarcodeEvents').BarcodeEvents.is_special_key;
+var BarcodeEvents = require('barcodes.BarcodeEvents').BarcodeEvents;
// Field in which the user can both type normally and scan barcodes
@@ -42,7 +42,7 @@ var FieldFloatScannable = basicFields.FieldFloat.extend({
_onKeypress: function (e) {
/* only simulate a keypress if it has been previously prevented */
if (e.dispatched_by_barcode_reader !== true) {
- if (!is_special_key(e)) {
+ if (!BarcodeEvents.is_special_key(e)) {
e.preventDefault();
}
return;
diff --git a/addons/delivery/models/stock_picking.py b/addons/delivery/models/stock_picking.py
index eb969156b09..accc102c2b7 100644
--- a/addons/delivery/models/stock_picking.py
+++ b/addons/delivery/models/stock_picking.py
@@ -16,8 +16,13 @@ class StockQuantPackage(models.Model):
@api.depends('quant_ids')
def _compute_weight(self):
weight = 0.0
- for quant in self.quant_ids:
- weight += quant.quantity * quant.product_id.weight
+ if self.env.context.get('picking_id'):
+ current_picking_move_line_ids = self.env['stock.move.line'].search([('result_package_id', '=', self.id), ('picking_id', '=', self.env.context['picking_id'])])
+ for ml in current_picking_move_line_ids:
+ weight += ml.product_uom_id._compute_quantity(ml.qty_done,ml.product_id.uom_id) * ml.product_id.weight
+ else:
+ for quant in self.quant_ids:
+ weight += quant.quantity * quant.product_id.weight
self.weight = weight
def _get_default_weight_uom(self):
diff --git a/addons/hr_holidays/i18n/hr_holidays.pot b/addons/hr_holidays/i18n/hr_holidays.pot
index 15161b395a5..68e174d4053 100644
--- a/addons/hr_holidays/i18n/hr_holidays.pot
+++ b/addons/hr_holidays/i18n/hr_holidays.pot
@@ -658,6 +658,11 @@ msgstr ""
msgid "Analyze from"
msgstr ""
+#. module: hr_holidays
+#: model:ir.model.fields,field_description:hr_holidays.field_hr_leave_type__double_validation
+msgid "Apply Double Validation"
+msgstr ""
+
#. module: hr_holidays
#: model_terms:ir.ui.view,arch_db:hr_holidays.view_evaluation_report_graph
msgid "Appraisal Analysis"
diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 31aab06c21a..24062454dca 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -427,8 +427,7 @@ class HolidaysRequest(models.Model):
employee = self.env['hr.employee'].browse(employee_id)
return employee.get_work_days_data(date_from, date_to)['days']
- time_delta = date_to - date_from
- return math.ceil(time_delta.days + float(time_delta.seconds) / 86400)
+ return self.env.user.company_id.resource_calendar_id.get_work_hours_count() / HOURS_PER_DAY
####################################################
# ORM Overrides methods
@@ -612,7 +611,7 @@ class HolidaysRequest(models.Model):
'date_from': self.date_from,
'date_to': self.date_to,
'notes': self.notes,
- 'number_of_days': self.number_of_days,
+ 'number_of_days': employee.get_work_days_data(self.date_from, self.date_to)['days'],
'parent_id': self.id,
'employee_id': employee.id
}
diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py
index af3baf9830b..c542ea14909 100644
--- a/addons/hr_holidays/models/hr_leave_type.py
+++ b/addons/hr_holidays/models/hr_leave_type.py
@@ -67,6 +67,14 @@ class HolidaysType(models.Model):
('hr', 'Payroll Officer'),
('manager', 'Team Leader'),
('both', 'Team Leader and Payroll Officer')], default='hr', string='Validation By')
+ # TODO: remove me in master, the behavior is exactly the same if you choose 'hr' or 'manager'
+ # in the validation_type field. This field is used only to hide this possibility to the user
+ # to avoid misunderstandings. This field and its corresponding's functions must be removed once
+ # the functional part is implemented.
+ double_validation = fields.Boolean(string='Apply Double Validation',
+ compute='_compute_validation_type', inverse='_inverse_validation_type',
+ help="When selected, the Allocation/Leave Requests for this type require a second validation to be approved.")
+
allocation_type = fields.Selection([
('fixed', 'Fixed by HR'),
('fixed_allocation', 'Fixed by HR + allocation request'),
@@ -88,6 +96,25 @@ class HolidaysType(models.Model):
leave_notif_subtype_id = fields.Many2one('mail.message.subtype', string='Leave Notification Subtype')
allocation_notif_subtype_id = fields.Many2one('mail.message.subtype', string='Allocation Notification Subtype')
+ # TODO: remove me in master
+ @api.depends('validation_type')
+ def _compute_validation_type(self):
+ for holiday_type in self:
+ if holiday_type.validation_type == 'both':
+ holiday_type.double_validation = True
+ else:
+ holiday_type.double_validation = False
+
+ # TODO: remove me in master
+ def _inverse_validation_type(self):
+ for holiday_type in self:
+ if holiday_type.double_validation == True:
+ holiday_type.validation_type = 'both'
+ else:
+ #IF to preserve the information (hr or manager)
+ if holiday_type.validation_type == 'both':
+ holiday_type.validation_type = 'hr'
+
@api.multi
@api.constrains('validity_start', 'validity_stop')
def _check_validity_dates(self):
diff --git a/addons/hr_holidays/views/hr_leave_type_views.xml b/addons/hr_holidays/views/hr_leave_type_views.xml
index 64562ab1f05..c7d48c1d319 100644
--- a/addons/hr_holidays/views/hr_leave_type_views.xml
+++ b/addons/hr_holidays/views/hr_leave_type_views.xml
@@ -49,7 +49,8 @@
-
+
+
@@ -110,7 +111,8 @@
-
+
+
diff --git a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py
index e5e65390304..a224be4ce9a 100644
--- a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py
+++ b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py
@@ -144,13 +144,13 @@ class HrContract(models.Model):
)
@api.depends('yearly_cost_before_charges', 'social_security_contributions', 'wage',
- 'social_security_contributions', 'double_holidays', 'warrants_cost', 'meal_voucher_paid_by_employer')
+ 'social_security_contributions', 'warrants_cost', 'meal_voucher_paid_by_employer')
def _compute_final_yearly_costs(self):
for contract in self:
contract.final_yearly_costs = (
contract.yearly_cost_before_charges +
contract.social_security_contributions +
- contract.double_holidays +
+ contract.wage * 0.92 +
contract.warrants_cost +
(220.0 * contract.meal_voucher_paid_by_employer)
)
@@ -197,11 +197,11 @@ class HrContract(models.Model):
for contract in self:
contract.monthly_yearly_costs = contract.final_yearly_costs / 12.0
- @api.depends('wage')
+ @api.depends('wage_with_holidays')
def _compute_holidays_advantages(self):
for contract in self:
- contract.double_holidays = contract.wage * 0.92
- contract.thirteen_month = contract.wage
+ contract.double_holidays = contract.wage_with_holidays * 0.92
+ contract.thirteen_month = contract.wage_with_holidays
@api.onchange('transport_mode_car', 'transport_mode_public', 'transport_mode_others')
def _onchange_transport_mode(self):
diff --git a/addons/l10n_nl/data/account_fiscal_position_tax_template.xml b/addons/l10n_nl/data/account_fiscal_position_tax_template.xml
index c026f355bbb..e59c852ef24 100644
--- a/addons/l10n_nl/data/account_fiscal_position_tax_template.xml
+++ b/addons/l10n_nl/data/account_fiscal_position_tax_template.xml
@@ -112,7 +112,7 @@
-
+
diff --git a/addons/mail/static/src/js/models/mail_failure.js b/addons/mail/static/src/js/models/mail_failure.js
index 390b195f8d9..438430c752f 100644
--- a/addons/mail/static/src/js/models/mail_failure.js
+++ b/addons/mail/static/src/js/models/mail_failure.js
@@ -83,7 +83,7 @@ var MailFailure = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
documentModel: this._documentModel,
id: 'mail_failure',
imageSRC: this._moduleIcon,
- title: this._recordName,
+ title: this._modelName,
};
return preview;
},
diff --git a/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js b/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js
index 9a5b5abb4cc..f995b4e09b3 100644
--- a/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js
+++ b/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js
@@ -94,17 +94,17 @@ var ThreadTypingMixin = {
* Get the text to display when some partners are typing something on the
* thread:
*
- * - single typing partner:
+ * - single typing partner:
*
- * A is typing...
+ * A is typing...
*
- * - two typing partners:
+ * - two typing partners:
*
- * A and B are typing...
+ * A and B are typing...
*
- * - three or more typing partners:
+ * - three or more typing partners:
*
- * A, B and more are typing...
+ * A, B and more are typing...
*
* The choice of the members name for display is not random: it displays
* the user that have been typing for the longest time. Also, this function
diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js
index 140266285c7..b59d7b91434 100644
--- a/addons/mail/static/src/js/services/mail_notification_manager.js
+++ b/addons/mail/static/src/js/services/mail_notification_manager.js
@@ -490,7 +490,7 @@ MailManager.include({
var self = this;
var partnerID = data.partner_id;
this.call('bus_service', 'sendNotification', data.title, data.message, function ( ){
- self.call('mail_service', 'openDmWindow', partnerID);
+ self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID);
});
},
/**
diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js
index 64cf6bb88c9..c8e05957afb 100644
--- a/addons/mail/static/src/js/services/mail_window_manager.js
+++ b/addons/mail/static/src/js/services/mail_window_manager.js
@@ -531,9 +531,6 @@ MailManager.include({
* @private
*/
_repositionThreadWindows: function () {
- if (this._areAllThreadWindowsHidden()) {
- return;
- }
this._computeAvailableSlotsForThreadWindows();
this._repositionVisibleThreadWindows();
@@ -575,9 +572,6 @@ MailManager.include({
* the focus is on the thread window.
*/
_updateThreadWindowsFromMessage: function (message, options) {
- if (this._areAllThreadWindowsHidden()) {
- return;
- }
_.each(this._threadWindows, function (threadWindow) {
if (_.contains(message.getThreadIDs(), threadWindow.getID())) {
threadWindow.update(options);
diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js
index cd5ffa7db94..57907a55943 100644
--- a/addons/mail/static/src/js/thread_windows/thread_window.js
+++ b/addons/mail/static/src/js/thread_windows/thread_window.js
@@ -132,9 +132,9 @@ var ThreadWindow = AbstractThreadWindow.extend({
*
* Do not display the input in the following cases:
*
- * - no thread related to this window
- * - window of a mailbox (temp: let us have mailboxes in window mode)
- * - window of a thread with mass mailing
+ * - no thread related to this window
+ * - window of a mailbox (temp: let us have mailboxes in window mode)
+ * - window of a thread with mass mailing
*
* Any other threads show the input in the window.
*
diff --git a/addons/mrp/static/src/js/mrp_bom_report.js b/addons/mrp/static/src/js/mrp_bom_report.js
index 76cd0b53fd8..8a96c5d6d67 100644
--- a/addons/mrp/static/src/js/mrp_bom_report.js
+++ b/addons/mrp/static/src/js/mrp_bom_report.js
@@ -25,7 +25,8 @@ var MrpBomReport = stock_report_generic.extend({
return this._rpc({
model: 'report.mrp.report_bom_structure',
method: 'get_html',
- args: args
+ args: args,
+ context: this.given_context,
})
.then(function (result) {
self.data = result;
diff --git a/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js b/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js
index 50872153d8c..74d32c74acc 100644
--- a/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js
+++ b/addons/partner_autocomplete/static/src/js/partner_autocomplete_core.js
@@ -112,7 +112,7 @@ var PartnerAutocompleteMixin = {
var self = this;
var removeUselessFields = function (company) {
- var fields = 'label,description,domain,logo,legal_name'.split(',');
+ var fields = 'label,description,domain,logo,legal_name,ignored'.split(',');
fields.forEach(function (field) {
delete company[field];
});
diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py
index 9fa51724678..afed11da38e 100644
--- a/addons/payment/models/payment_acquirer.py
+++ b/addons/payment/models/payment_acquirer.py
@@ -871,24 +871,25 @@ class PaymentTransaction(models.Model):
def create(self, values):
# call custom create method if defined (i.e. ogone_create for ogone)
acquirer = self.env['payment.acquirer'].browse(values['acquirer_id'])
- partner = self.env['res.partner'].browse(values['partner_id'])
+ if values.get('partner_id'):
+ partner = self.env['res.partner'].browse(values['partner_id'])
- values.update({
- 'partner_name': partner.name,
- 'partner_lang': partner.lang or 'en_US',
- 'partner_email': partner.email,
- 'partner_zip': partner.zip,
- 'partner_address': _partner_format_address(partner.street or '', partner.street2 or ''),
- 'partner_city': partner.city,
- 'partner_country_id': partner.country_id.id or self._get_default_partner_country_id(),
- 'partner_phone': partner.phone,
- })
+ values.update({
+ 'partner_name': partner.name,
+ 'partner_lang': partner.lang or 'en_US',
+ 'partner_email': partner.email,
+ 'partner_zip': partner.zip,
+ 'partner_address': _partner_format_address(partner.street or '', partner.street2 or ''),
+ 'partner_city': partner.city,
+ 'partner_country_id': partner.country_id.id or self._get_default_partner_country_id(),
+ 'partner_phone': partner.phone,
+ })
# compute fees
custom_method_name = '%s_compute_fees' % acquirer.provider
if hasattr(acquirer, custom_method_name):
fees = getattr(acquirer, custom_method_name)(
- values.get('amount', 0.0), values.get('currency_id'), partner.country_id.id)
+ values.get('amount', 0.0), values.get('currency_id'), values['partner_country_id'])
values['fees'] = fees
# custom create
diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py
index e4bd3382caa..bca2164dac1 100644
--- a/addons/payment_authorize/models/authorize_request.py
+++ b/addons/payment_authorize/models/authorize_request.py
@@ -149,7 +149,7 @@ class AuthorizeAPI():
root = self._base_tree('createCustomerProfileRequest')
profile = etree.SubElement(root, "profile")
etree.SubElement(profile, "merchantCustomerId").text = 'ODOO-%s-%s' % (partner.id, uuid4().hex[:8])
- etree.SubElement(profile, "email").text = partner.email
+ etree.SubElement(profile, "email").text = partner.email or ''
payment_profile = etree.SubElement(profile, "paymentProfiles")
etree.SubElement(payment_profile, "customerType").text = 'business' if partner.is_company else 'individual'
billTo = etree.SubElement(payment_profile, "billTo")
diff --git a/addons/payment_authorize/tests/test_authorize.py b/addons/payment_authorize/tests/test_authorize.py
index 314d4bdd26a..a45275b07d8 100644
--- a/addons/payment_authorize/tests/test_authorize.py
+++ b/addons/payment_authorize/tests/test_authorize.py
@@ -188,7 +188,7 @@ class AuthorizeForm(AuthorizeCommon):
self.assertEqual(tx.state, 'cancel', 'Authorize: erroneous validation did not put tx into error state')
-@odoo.tests.tagged('post_install', '-at_install', '-standard', 'external')
+@odoo.tests.tagged('post_install', '-at_install', '-standard')
class AuthorizeForm(AuthorizeCommon):
def test_30_authorize_s2s(self):
# be sure not to do stupid thing
diff --git a/addons/payment_buckaroo/tests/test_buckaroo.py b/addons/payment_buckaroo/tests/test_buckaroo.py
index 925385f402f..e23b1f0c002 100644
--- a/addons/payment_buckaroo/tests/test_buckaroo.py
+++ b/addons/payment_buckaroo/tests/test_buckaroo.py
@@ -32,12 +32,12 @@ class BuckarooForm(BuckarooCommon):
# ----------------------------------------
form_values = {
- 'add_returndata': None,
+ 'add_returndata': '',
'Brq_websitekey': self.buckaroo.brq_websitekey,
'Brq_amount': '2240.0',
'Brq_currency': 'EUR',
'Brq_invoicenumber': 'SO004',
- 'Brq_signature': '1b8c10074c622d965272a91a9e88b5b3777d2474', # update me
+ 'Brq_signature': 'aa6fe072afdd9d1b463d55f43e6df4b272d3529a', # update me
'brq_test': 'True',
'Brq_return': urls.url_join(base_url, BuckarooController._return_url),
'Brq_returncancel': urls.url_join(base_url, BuckarooController._cancel_url),
@@ -50,13 +50,14 @@ class BuckarooForm(BuckarooCommon):
res = self.buckaroo.render(
'SO004', 2240.0, self.currency_euro.id,
partner_id=None,
- partner_values=self.buyer_values)
-
+ values=self.buyer_values)
# check form result
tree = objectify.fromstring(res)
- self.assertEqual(tree.get('action'), 'https://testcheckout.buckaroo.nl/html/', 'Buckaroo: wrong form POST url')
+ data_set = tree.xpath("//input[@name='data_set']")
+ self.assertEqual(len(data_set), 1, 'paypal: Found %d "data_set" input instead of 1' % len(data_set))
+ self.assertEqual(data_set[0].get('data-action-url'), 'https://testcheckout.buckaroo.nl/html/', 'Buckaroo: wrong form POST url')
for form_input in tree.input:
- if form_input.get('name') in ['submit']:
+ if form_input.get('name') in ['submit', 'data_set']:
continue
self.assertEqual(
form_input.get('value'),
@@ -78,17 +79,18 @@ class BuckarooForm(BuckarooCommon):
})
# render the button
- res = self.buckaroo_id.render(
+ res = self.buckaroo.render(
'should_be_erased', 2240.0, self.currency_euro,
- tx_id=tx.id,
partner_id=None,
- partner_values=self.buyer_values)
+ values=self.buyer_values)
# check form result
tree = objectify.fromstring(res)
- self.assertEqual(tree.get('action'), 'https://testcheckout.buckaroo.nl/html/', 'Buckaroo: wrong form POST url')
+ data_set = tree.xpath("//input[@name='data_set']")
+ self.assertEqual(len(data_set), 1, 'paypal: Found %d "data_set" input instead of 1' % len(data_set))
+ self.assertEqual(data_set[0].get('data-action-url'), 'https://testcheckout.buckaroo.nl/html/', 'Buckaroo: wrong form POST url')
for form_input in tree.input:
- if form_input.get('name') in ['submit']:
+ if form_input.get('name') in ['submit', 'data_set']:
continue
self.assertEqual(
form_input.get('value'),
@@ -116,7 +118,7 @@ class BuckarooForm(BuckarooCommon):
'BRQ_SERVICE_PAYPAL_PAYERLASTNAME': u'Tester',
'BRQ_SERVICE_PAYPAL_PAYERMIDDLENAME': u'de',
'BRQ_SERVICE_PAYPAL_PAYERSTATUS': u'verified',
- 'Brq_signature': u'175d82dd53a02bad393fee32cb1eafa3b6fbbd91',
+ 'Brq_signature': u'9e75e51cbcb6fbc798b40d1787cc5b7c36557d56',
'BRQ_STATUSCODE': u'190',
'BRQ_STATUSCODE_DETAIL': u'S001',
'BRQ_STATUSMESSAGE': u'Transaction successfully processed',
@@ -134,7 +136,7 @@ class BuckarooForm(BuckarooCommon):
'amount': 2240.0,
'acquirer_id': self.buckaroo.id,
'currency_id': self.currency_euro.id,
- 'reference': 'SO004-1',
+ 'reference': 'SO004',
'partner_name': 'Norbert Buyer',
'partner_country_id': self.country_france.id})
@@ -153,14 +155,14 @@ class BuckarooForm(BuckarooCommon):
'partner_name': 'Norbert Buyer',
'partner_country_id': self.country_france.id})
+ buckaroo_post_data['BRQ_INVOICENUMBER'] = 'SO004-2'
# now buckaroo post is ok: try to modify the SHASIGN
- buckaroo_post_data['BRQ_SIGNATURE'] = '54d928810e343acf5fb0c3ee75fd747ff159ef7a'
+ buckaroo_post_data['Brq_signature'] = '54d928810e343acf5fb0c3ee75fd747ff159ef7a'
with self.assertRaises(ValidationError):
tx.form_feedback(buckaroo_post_data, 'buckaroo')
-
# simulate an error
- buckaroo_post_data['BRQ_STATUSCODE'] = 2
- buckaroo_post_data['BRQ_SIGNATURE'] = '4164b52adb1e6a2221d3d8a39d8c3e18a9ecb90b'
+ buckaroo_post_data['BRQ_STATUSCODE'] = '2'
+ buckaroo_post_data['Brq_signature'] = '9138e2bf09a708a4eb485ce7777e5406898b689d'
tx.form_feedback(buckaroo_post_data, 'buckaroo')
# check state
diff --git a/addons/payment_buckaroo/views/payment_buckaroo_templates.xml b/addons/payment_buckaroo/views/payment_buckaroo_templates.xml
index 95f1aa0ce9e..7b0749923c9 100644
--- a/addons/payment_buckaroo/views/payment_buckaroo_templates.xml
+++ b/addons/payment_buckaroo/views/payment_buckaroo_templates.xml
@@ -2,24 +2,26 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/payment_paypal/tests/test_paypal.py b/addons/payment_paypal/tests/test_paypal.py
index b43aed25fcf..e70129f9333 100644
--- a/addons/payment_paypal/tests/test_paypal.py
+++ b/addons/payment_paypal/tests/test_paypal.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
+from odoo import fields
from odoo.addons.payment.models.payment_acquirer import ValidationError
from odoo.addons.payment.tests.common import PaymentAcquirerCommon
from odoo.addons.payment_paypal.controllers.main import PaypalController
@@ -182,7 +183,6 @@ class PaypalForm(PaypalCommon):
self.assertEqual(tx.state, 'pending', 'paypal: wrong state after receiving a valid pending notification')
self.assertEqual(tx.state_message, 'multi_currency', 'paypal: wrong state message after receiving a valid pending notification')
self.assertEqual(tx.acquirer_reference, '08D73520KX778924N', 'paypal: wrong txn_id after receiving a valid pending notification')
- self.assertFalse(tx.date, 'paypal: validation date should not be updated whenr receiving pending notification')
# update tx
tx.write({
@@ -196,4 +196,4 @@ class PaypalForm(PaypalCommon):
# check
self.assertEqual(tx.state, 'done', 'paypal: wrong state after receiving a valid pending notification')
self.assertEqual(tx.acquirer_reference, '08D73520KX778924N', 'paypal: wrong txn_id after receiving a valid pending notification')
- self.assertEqual(tx.date, '2013-11-18 11:21:19', 'paypal: wrong validation date')
+ self.assertEqual(fields.Datetime.to_string(tx.date), '2013-11-18 11:21:19', 'paypal: wrong validation date')
diff --git a/addons/payment_stripe/tests/test_stripe.py b/addons/payment_stripe/tests/test_stripe.py
index 5308f54dbe0..1ef883de93f 100644
--- a/addons/payment_stripe/tests/test_stripe.py
+++ b/addons/payment_stripe/tests/test_stripe.py
@@ -59,9 +59,6 @@ class StripeTest(StripeCommon):
# render the button
res = self.stripe.render('SO404', 320.0, self.currency_euro.id, values=self.buyer_values).decode('utf-8')
- popup_script_src = 'script src="https://checkout.stripe.com/checkout.js"'
- # check form result
- self.assertIn(popup_script_src, res, "Stripe: popup script not found in template render")
# Generated and received
self.assertIn(self.buyer_values.get('partner_email'), res, 'Stripe: email input not found in rendered template')
@@ -87,7 +84,7 @@ class StripeTest(StripeCommon):
u'id': u'ch_172xfnGMfVJxozLwEjSfpfxD',
u'invoice': None,
u'livemode': False,
- u'metadata': {u'reference': u'SO100'},
+ u'metadata': {u'reference': u'SO100-1'},
u'object': u'charge',
u'paid': True,
u'receipt_email': None,
@@ -137,6 +134,7 @@ class StripeTest(StripeCommon):
tx.form_feedback(stripe_post_data, 'stripe')
self.assertEqual(tx.state, 'done', 'Stripe: validation did not put tx into done state')
self.assertEqual(tx.acquirer_reference, stripe_post_data.get('id'), 'Stripe: validation did not update tx id')
+ stripe_post_data['metadata']['reference'] = u'SO100-2'
# reset tx
tx = self.env['payment.transaction'].create({
'amount': 4700,
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index 2d17216eadc..a4919e7c17f 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -839,21 +839,18 @@ class PosOrder(models.Model):
lots_necessary = True
if picking_type:
lots_necessary = picking_type and picking_type.use_existing_lots
- qty = 0
qty_done = 0
pack_lots = []
pos_pack_lots = PosPackOperationLot.search([('order_id', '=', order.id), ('product_id', '=', move.product_id.id)])
- pack_lot_names = [pos_pack.lot_name for pos_pack in pos_pack_lots]
- if pack_lot_names and lots_necessary:
- for lot_name in list(set(pack_lot_names)):
- stock_production_lot = StockProductionLot.search([('name', '=', lot_name), ('product_id', '=', move.product_id.id)])
+ if pos_pack_lots and lots_necessary:
+ for pos_pack_lot in pos_pack_lots:
+ stock_production_lot = StockProductionLot.search([('name', '=', pos_pack_lot.lot_name), ('product_id', '=', move.product_id.id)])
if stock_production_lot:
+ # a serialnumber always has a quantity of 1 product, a lot number takes the full quantity of the order line
+ qty = 1.0
if stock_production_lot.product_id.tracking == 'lot':
- # if a lot nr is set through the frontend it will refer to the full quantity
- qty = move.product_uom_qty
- else: # serial numbers
- qty = 1.0
+ qty = pos_pack_lot.pos_order_line_id.qty
qty_done += qty
pack_lots.append({'lot_id': stock_production_lot.id, 'qty': qty})
else:
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index 4bf06abdc6b..b82bcb38eb5 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -1943,16 +1943,11 @@ var PaymentScreenWidget = ScreenWidget.extend({
$('body').keypress(this.keyboard_handler);
// that one comes from the pos, but we prefer to cover all the basis
$('body').keydown(this.keyboard_keydown_handler);
- // legacy vanilla JS listeners
- window.document.body.addEventListener('keypress',this.keyboard_handler);
- window.document.body.addEventListener('keydown',this.keyboard_keydown_handler);
this._super();
},
hide: function(){
$('body').off('keypress', this.keyboard_handler);
$('body').off('keydown', this.keyboard_keydown_handler);
- window.document.body.removeEventListener('keypress',this.keyboard_handler);
- window.document.body.removeEventListener('keydown',this.keyboard_keydown_handler);
this._super();
},
// sets up listeners to watch for order changes
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index 69568ff9bea..94858d7a98d 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -156,10 +156,7 @@ class ProductTemplate(models.Model):
@api.multi
def _compute_currency_id(self):
- try:
- main_company = self.sudo().env.ref('base.main_company')
- except ValueError:
- main_company = self.env['res.company'].sudo().search([], limit=1, order="id")
+ main_company = self.env['res.company']._get_main_company()
for template in self:
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
@@ -929,6 +926,36 @@ class ProductTemplate(models.Model):
return result
combination = combination[:-1]
+ @api.multi
+ def _get_current_company(self, **kwargs):
+ """Get the most appropriate company for this product.
+
+ If the company is set on the product, directly return it. Otherwise,
+ fallback to a contextual company.
+
+ :param kwargs: kwargs forwarded to the fallback method.
+
+ :return: the most appropriate company for this product
+ :rtype: recordset of one `res.company`
+ """
+ self.ensure_one()
+ return self.company_id or self._get_current_company_fallback(**kwargs)
+
+ @api.multi
+ def _get_current_company_fallback(self, **kwargs):
+ """Fallback to get the most appropriate company for this product.
+
+ This should only be called from `_get_current_company` but is defined
+ separately to allow override.
+
+ The final fallback will be the current user's company.
+
+ :return: the fallback company for this product
+ :rtype: recordset of one `res.company`
+ """
+ self.ensure_one()
+ return self.env.user.company_id
+
@api.model
def get_empty_list_help(self, help):
self = self.with_context(
diff --git a/addons/product/report/product_product_templates.xml b/addons/product/report/product_product_templates.xml
index bf8d0acfc10..2a612f556bc 100644
--- a/addons/product/report/product_product_templates.xml
+++ b/addons/product/report/product_product_templates.xml
@@ -12,7 +12,7 @@
Price:
-
+
diff --git a/addons/sale/i18n/sale.pot b/addons/sale/i18n/sale.pot
index a808ecba1e5..e9f665a9b3b 100644
--- a/addons/sale/i18n/sale.pot
+++ b/addons/sale/i18n/sale.pot
@@ -1580,6 +1580,11 @@ msgstr ""
msgid "Invoicing address:"
msgstr ""
+#. module: sale
+#: model_terms:ir.ui.view,arch_db:sale.sale_order_portal_content
+msgid "Invoicing and Shipping Address"
+msgstr ""
+
#. module: sale
#: model_terms:ir.ui.view,arch_db:sale.report_saleorder_document
msgid "Invoicing and shipping address:"
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
index 4da13305464..3691067d106 100644
--- a/addons/sale/models/product_template.py
+++ b/addons/sale/models/product_template.py
@@ -261,7 +261,7 @@ class ProductTemplate(models.Model):
if pricelist and pricelist.currency_id != product_template.currency_id:
list_price = product_template.currency_id._convert(
- list_price, pricelist.currency_id, product_template.company_id,
+ list_price, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
fields.Date.today()
)
@@ -296,3 +296,11 @@ class ProductTemplate(models.Model):
return False
combination = self._get_first_possible_combination(parent_combination)
return True if combination else self._is_combination_possible(combination, parent_combination)
+
+ @api.multi
+ def _get_current_company_fallback(self, **kwargs):
+ """Override: if a pricelist is given, fallback to the company of the
+ pricelist if it is set, otherwise use the one from parent method."""
+ res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
+ pricelist = kwargs.get('pricelist')
+ return pricelist and pricelist.company_id or res
diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js
index 458dd5a580c..1a74764aacc 100644
--- a/addons/sale/static/src/js/product_configurator_mixin.js
+++ b/addons/sale/static/src/js/product_configurator_mixin.js
@@ -281,6 +281,7 @@ var ProductConfiguratorMixin = {
/**
* Will return a deferred:
+ *
* - If the product already exists, immediately resolves it with the product_id
* - If the product does not exist yet ("dynamic" variant creation), this method will
* create the product first and then resolve the deferred with the created product's id
diff --git a/addons/sale/static/src/js/product_configurator_renderer.js b/addons/sale/static/src/js/product_configurator_renderer.js
index 9a613501a2f..899656c8065 100644
--- a/addons/sale/static/src/js/product_configurator_renderer.js
+++ b/addons/sale/static/src/js/product_configurator_renderer.js
@@ -28,6 +28,7 @@ var ProductConfiguratorFormRenderer = FormRenderer.extend(ProductConfiguratorMix
* Renders the product configurator within the form
*
* Will also:
+ *
* - add events handling for variant changes
* - trigger variant change to compute the price and other
* variant specific changes
diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py
index dd36a916778..95b1814f50e 100644
--- a/addons/sale/tests/test_sale_product_attribute_value_config.py
+++ b/addons/sale/tests/test_sale_product_attribute_value_config.py
@@ -185,6 +185,11 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
do_test(self)
def test_02_get_combination_info(self):
+ # If using multi-company, company_id will be False, and this code should
+ # still work.
+ # The case with a company_id will be implicitly tested on website_sale.
+ self.computer.company_id = False
+
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml
index 7f0338d1b6d..4962253e810 100644
--- a/addons/sale/views/sale_portal_templates.xml
+++ b/addons/sale/views/sale_portal_templates.xml
@@ -381,13 +381,16 @@
' +
+ '',
+ groupBy: ['date:month'],
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have 2 columns");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2,
+ "1st column should contain 2 records of January month");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length , 2,
+ "2nd column should contain 2 records of February month");
+
+ // drag&drop a record in another column
+ var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
+ var $group = kanban.$('.o_kanban_group:nth-child(2)');
+ testUtils.dragAndDrop($record, $group);
+
+ // should not drag&drop record
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length , 2,
+ "Should remain same records in first column(2 records)");
+ assert.strictEqual(kanban.$('.o_kanban_group:nth-child(2) .o_kanban_record').length , 2,
+ "Should remain same records in 2nd column(2 record)");
+ kanban.destroy();
+ });
+
QUnit.test('kanban view with default_group_by', function (assert) {
assert.expect(7);
this.data.partner.records.product_id = 1;
diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml
index c9f78d20394..80479b0bcfa 100644
--- a/addons/web/views/report_templates.xml
+++ b/addons/web/views/report_templates.xml
@@ -282,7 +282,7 @@
-
+
diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js
index 194b7e15d4d..9b98dc7b89f 100644
--- a/addons/web_editor/static/src/js/widgets/widgets.js
+++ b/addons/web_editor/static/src/js/widgets/widgets.js
@@ -773,6 +773,7 @@ var IconWidget = MediaWidget.extend({
this.$('div.font-icons-icons').html(
QWeb.render('web_editor.dialog.font-icons.icons', {iconsParser: iconsParser})
);
+ return $.when();
},
//--------------------------------------------------------------------------
@@ -1299,6 +1300,7 @@ var MediaDialog = Dialog.extend({
/**
* @private
*/
+ ev.preventDefault();
_onSaveRequest: function (ev) {
ev.stopPropagation();
this.save();
diff --git a/addons/website/i18n/website.pot b/addons/website/i18n/website.pot
index eabf5704b9a..47348bd14aa 100644
--- a/addons/website/i18n/website.pot
+++ b/addons/website/i18n/website.pot
@@ -532,6 +532,26 @@ msgstr ""
msgid "Remove Slide"
msgstr ""
+#. module: website
+#: model_terms:ir.ui.view,arch_db:website.list_website_pages
+msgid ""
+msgstr ""
+
+#. module: website
+#: model_terms:ir.ui.view,arch_db:website.list_website_pages
+msgid ""
+msgstr ""
+
+#. module: website
+#: model_terms:ir.ui.view,arch_db:website.list_website_pages
+msgid ""
+msgstr ""
+
+#. module: website
+#: model_terms:ir.ui.view,arch_db:website.list_website_pages
+msgid ""
+msgstr ""
+
#. module: website
#: model_terms:ir.ui.view,arch_db:website.s_comparisons
msgid "Instant setup, satisfied or reimbursed."
@@ -569,6 +589,16 @@ msgid "\n"
" Previous"
msgstr ""
+#. module: website
+#: model_terms:ir.ui.view,arch_db:website.res_config_settings_view_form
+msgid ""
+msgstr ""
+
+#. module: website
+#: model_terms:ir.ui.view,arch_db:website.company_description
+msgid ""
+msgstr ""
+
#. module: website
#: model_terms:ir.ui.view,arch_db:website.user_navbar
msgid "Edit"
@@ -579,6 +609,11 @@ msgstr ""
msgid "New"
msgstr ""
+#. module: website
+#: model_terms:ir.ui.view,arch_db:website.list_website_pages
+msgid ""
+msgstr ""
+
#. module: website
#: model_terms:ir.ui.view,arch_db:website.s_facebook_page
msgid "\n"
@@ -617,6 +652,11 @@ msgstr ""
msgid "Toggle Dropdown"
msgstr ""
+#. module: website
+#: model_terms:ir.ui.view,arch_db:website.user_navbar
+msgid ""
+msgstr ""
+
#. module: website
#: model_terms:ir.ui.view,arch_db:website.user_navbar
msgid "\n"
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index 1a98713326d..d37852ae0de 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -143,8 +143,12 @@ class View(models.Model):
return self.filtered(lambda view: not view.website_id)
for view in self:
+ # specific view: add it if it's for the current website and ignore
+ # it if it's for another website
if view.website_id and view.website_id.id == current_website_id:
most_specific_views |= view
+ # generic view: add it only if, for the current website, there is no
+ # specific view for this view (based on the same `key` attribute)
elif not view.website_id and not any(view.key == view2.key and view2.website_id and view2.website_id.id == current_website_id for view2 in self):
most_specific_views |= view
@@ -201,6 +205,17 @@ class View(models.Model):
@api.model
@tools.ormcache_context('self._uid', 'xml_id', keys=('website_id',))
def get_view_id(self, xml_id):
+ """If a website_id is in the context and the given xml_id is not an int
+ then try to get the id of the specific view for that website, but
+ fallback to the id of the generic view if there is no specific.
+
+ If no website_id is in the context, it might randomly return the generic
+ or the specific view, so it's probably not recommanded to use this
+ method. `viewref` is probably more suitable.
+
+ Archived views are ignored (unless the active_test context is set, but
+ then the ormcache_context will not work as expected).
+ """
if 'website_id' in self._context and not isinstance(xml_id, int):
current_website = self.env['website'].browse(self._context.get('website_id'))
domain = ['&', ('key', '=', xml_id)] + current_website.website_domain()
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index 82709c4663d..4aea35aa481 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -503,12 +503,18 @@ class Website(models.Model):
def viewref(self, view_id, raise_if_not_found=True):
''' Given an xml_id or a view_id, return the corresponding view record.
In case of website context, return the most specific one.
+
+ If no website_id is in the context, it will return the generic view,
+ instead of a random one like `get_view_id`.
+
+ Look also for archived views, no matter the context.
+
:param view_id: either a string xml_id or an integer view_id
:param raise_if_not_found: should the method raise an error if no view found
:return: The view record or empty recordset
'''
View = self.env['ir.ui.view']
- view = None
+ view = View
if isinstance(view_id, str):
if 'website_id' in self._context:
domain = [('key', '=', view_id)] + self.env['website'].website_domain(self._context.get('website_id'))
@@ -520,20 +526,20 @@ class Website(models.Model):
if views:
view = views.filter_duplicate()
else:
- view = self.env.ref(view_id)
+ # we handle the raise below
+ view = self.env.ref(view_id, raise_if_not_found=False)
# self.env.ref might return something else than an ir.ui.view (eg: a theme.ir.ui.view)
- if view._name != 'ir.ui.view':
- view = None
+ if not view or view._name != 'ir.ui.view':
+ # make sure we always return a recordset
+ view = View
elif isinstance(view_id, int):
view = View.browse(view_id)
else:
raise ValueError('Expecting a string or an integer, not a %s.' % (type(view_id)))
- if view:
- return view
- if raise_if_not_found:
+ if not view and raise_if_not_found:
raise ValueError('No record found for unique ID %s. It may have been deleted.' % (view_id))
- return None
+ return view
@api.model
def get_template(self, template):
@@ -1151,9 +1157,12 @@ class Menu(models.Model):
@api.multi
def unlink(self):
default_menu = self.env.ref('website.main_menu', raise_if_not_found=False)
+ menus_to_remove = self
for menu in self.filtered(lambda m: default_menu and m.parent_id.id == default_menu.id):
- self.env['website.menu'].search([('url', '=', menu.url), ('id', '!=', menu.id)]).unlink()
- return super(Menu, self).unlink()
+ menus_to_remove |= self.env['website.menu'].search([('url', '=', menu.url),
+ ('website_id', '!=', False),
+ ('id', '!=', menu.id)])
+ return super(Menu, menus_to_remove).unlink()
@api.one
def _compute_visible(self):
diff --git a/addons/website/static/src/scss/bootstrap_overridden.scss b/addons/website/static/src/scss/bootstrap_overridden.scss
index 8c21c748fe1..b25b9af1f25 100644
--- a/addons/website/static/src/scss/bootstrap_overridden.scss
+++ b/addons/website/static/src/scss/bootstrap_overridden.scss
@@ -31,3 +31,7 @@ $font-family-sans-serif: nth($o-theme-fonts, $o-theme-font-number);
$headings-font-family: nth($o-theme-fonts, $o-theme-headings-font-number);
$headings-color: color('h1');
+
+// Jumbotron
+
+$jumbotron-bg: transparent !default;
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index ad5e297a3a2..05febbf1fae 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -847,7 +847,7 @@
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index aec6d3b17ed..0b5ed81a6e5 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -150,9 +150,10 @@ class WebsiteSale(ProductConfiguratorController):
return pricelist_context, pricelist
def _get_compute_currency(self, pricelist, product=None):
- from_currency = (product or request.env.user.company_id).currency_id
+ company = product and product._get_current_company(pricelist=pricelist, website=request.website) or pricelist.company_id or request.website.company_id
+ from_currency = (product or request.env['res.company']._get_main_company()).currency_id
to_currency = pricelist.currency_id
- return lambda price: from_currency._convert(price, to_currency, (product or request.env.user).company_id, fields.Date.today())
+ return lambda price: from_currency._convert(price, to_currency, company, fields.Date.today())
def _get_search_order(self, post):
# OrderBy will be parsed in orm and so no direct sql injection
@@ -312,7 +313,7 @@ class WebsiteSale(ProductConfiguratorController):
pricelist = request.website.get_current_pricelist()
def compute_currency(price):
- return product.currency_id._convert(price, pricelist.currency_id, product.company_id, fields.Date.today())
+ return product.currency_id._convert(price, pricelist.currency_id, product._get_current_company(pricelist=pricelist, website=request.website), fields.Date.today())
if not product_context.get('pricelist'):
product_context['pricelist'] = pricelist.id
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
index 8bf6fedcc6a..e0ee166ad39 100644
--- a/addons/website_sale/models/product.py
+++ b/addons/website_sale/models/product.py
@@ -287,6 +287,14 @@ class ProductTemplate(models.Model):
"""
return self._create_product_variant(self._get_first_possible_combination(), log_warning)
+ @api.multi
+ def _get_current_company_fallback(self, **kwargs):
+ """Override: if a website is set on the product or given, fallback to
+ the company of the website. Otherwise use the one from parent method."""
+ res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
+ website = self.website_id or kwargs.get('website')
+ return website and website.company_id or res
+
def _default_website_sequence(self):
self._cr.execute("SELECT MIN(website_sequence) FROM %s" % self._table)
min_sequence = self._cr.fetchone()[0]
diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss
index 29e685b8b4a..6a5d755ad64 100644
--- a/addons/website_sale/static/src/scss/website_sale.scss
+++ b/addons/website_sale/static/src/scss/website_sale.scss
@@ -690,7 +690,7 @@ a.no-decoration {
.o_website_sale_animate {
opacity: 0.7;
- position: absolute;
+ position: absolute !important;
height: 150px;
width: 150px;
z-index: 1020;
diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml
index d3f3fbd2a81..52b57be8deb 100644
--- a/addons/website_sale/views/product_views.xml
+++ b/addons/website_sale/views/product_views.xml
@@ -88,6 +88,7 @@
diff --git a/addons/website_sale_comparison/tests/__init__.py b/addons/website_sale_comparison/tests/__init__.py
new file mode 100644
index 00000000000..0a54e54e5cf
--- /dev/null
+++ b/addons/website_sale_comparison/tests/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_website_sale_comparison
diff --git a/addons/website_sale_comparison/tests/test_website_sale_comparison.py b/addons/website_sale_comparison/tests/test_website_sale_comparison.py
new file mode 100644
index 00000000000..91c68c7508a
--- /dev/null
+++ b/addons/website_sale_comparison/tests/test_website_sale_comparison.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import odoo.tests
+
+
+@odoo.tests.tagged('-at_install', 'post_install')
+class TestWebsiteSaleComparison(odoo.tests.TransactionCase):
+ def test_01_website_sale_comparison_remove(self):
+ """ This tour makes sure the product page still works after the module
+ `website_sale_comparison` has been removed.
+
+ Technically it tests the removal of copied views by the base method
+ `_remove_copied_views`. The problematic view that has to be removed is
+ `product_add_to_compare` because it has a reference to `add_to_compare`.
+ """
+
+ Website0 = self.env['website'].with_context(website_id=None)
+ Website1 = self.env['website'].with_context(website_id=1)
+
+ # Create a generic inherited view, with a key not starting with
+ # `website_sale_comparison` otherwise the unlink will work just based on
+ # the key, but we want to test also for `MODULE_UNINSTALL_FLAG`.
+ product_add_to_compare = Website0.viewref('website_sale_comparison.product_add_to_compare')
+ test_view_key = 'my_test.my_key'
+ self.env['ir.ui.view'].with_context(website_id=None).create({
+ 'name': 'test inherited view',
+ 'key': test_view_key,
+ 'inherit_id': product_add_to_compare.id,
+ 'arch': '',
+ })
+
+ # Retrieve the generic view
+ product = Website0.viewref('website_sale.product')
+ # Trigger COW to create specific views of the whole tree
+ product.with_context(website_id=1).write({'name': 'Trigger COW'})
+
+ # Verify initial state: the specific views exist
+ self.assertEquals(Website1.viewref('website_sale.product').website_id.id, 1)
+ self.assertEquals(Website1.viewref('website_sale_comparison.product_add_to_compare').website_id.id, 1)
+ self.assertEquals(Website1.viewref(test_view_key).website_id.id, 1)
+
+ # Remove the module (use `module_uninstall` because it is enough to test
+ # what we want here, no need/can't use `button_immediate_uninstall`
+ # because it would commit the test transaction)
+ website_sale_comparison = self.env['ir.module.module'].search([('name', '=', 'website_sale_comparison')])
+ website_sale_comparison.module_uninstall()
+
+ # Check that the generic view is correctly removed
+ self.assertFalse(Website0.viewref('website_sale_comparison.product_add_to_compare', raise_if_not_found=False))
+ # Check that the specific view is correctly removed
+ self.assertFalse(Website1.viewref('website_sale_comparison.product_add_to_compare', raise_if_not_found=False))
+
+ # Check that the generic inherited view is correctly removed
+ self.assertFalse(Website0.viewref(test_view_key, raise_if_not_found=False))
+ # Check that the specific inherited view is correctly removed
+ self.assertFalse(Website1.viewref(test_view_key, raise_if_not_found=False))
diff --git a/addons/website_sale_delivery/models/delivery.py b/addons/website_sale_delivery/models/delivery.py
index 5a7270c7f1c..6b14ba8004e 100644
--- a/addons/website_sale_delivery/models/delivery.py
+++ b/addons/website_sale_delivery/models/delivery.py
@@ -9,4 +9,3 @@ class DeliveryCarrier(models.Model):
_inherit = ['delivery.carrier', 'website.published.multi.mixin']
website_description = fields.Text(related='product_id.description_sale', string='Description for Online Quotations', readonly=False)
- website_published = fields.Boolean(default=False)
diff --git a/addons/website_sale_delivery/models/sale_order.py b/addons/website_sale_delivery/models/sale_order.py
index 4cf21e3b587..3103fb06ab3 100644
--- a/addons/website_sale_delivery/models/sale_order.py
+++ b/addons/website_sale_delivery/models/sale_order.py
@@ -75,6 +75,7 @@ class SaleOrder(models.Model):
def _get_delivery_methods(self):
address = self.partner_shipping_id
+ # searching on website_published will also search for available website (_search method on computed field)
return self.env['delivery.carrier'].sudo().search([('website_published', '=', True)]).available_carriers(address)
@api.multi
diff --git a/addons/website_sale_delivery/views/website_sale_delivery_views.xml b/addons/website_sale_delivery/views/website_sale_delivery_views.xml
index 0e60a35fb70..976b0971d7f 100644
--- a/addons/website_sale_delivery/views/website_sale_delivery_views.xml
+++ b/addons/website_sale_delivery/views/website_sale_delivery_views.xml
@@ -6,6 +6,9 @@
delivery.carrier
+
+
+
@@ -26,6 +29,7 @@
+
diff --git a/addons/website_slides/views/slide_channel_views.xml b/addons/website_slides/views/slide_channel_views.xml
index 308610c0e90..834ea11918a 100644
--- a/addons/website_slides/views/slide_channel_views.xml
+++ b/addons/website_slides/views/slide_channel_views.xml
@@ -52,6 +52,7 @@
@@ -492,7 +492,7 @@
-
+
@@ -513,7 +513,7 @@
The social sharing module will be unlocked when a moderator will allow your publication.
-
+
diff --git a/addons/website_theme_install/models/ir_module_module.py b/addons/website_theme_install/models/ir_module_module.py
index 00514b8fa3f..55f77590c6b 100644
--- a/addons/website_theme_install/models/ir_module_module.py
+++ b/addons/website_theme_install/models/ir_module_module.py
@@ -6,6 +6,7 @@ import os
from collections import OrderedDict
from odoo import api, fields, models
+from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
from odoo.exceptions import MissingError
from odoo.http import request
@@ -206,7 +207,7 @@ class IrModuleModule(models.Model):
for model_name in self._theme_model_names:
template = self._get_module_data(model_name)
- models = template.with_context(active_test=False).mapped('copy_ids').filtered(lambda m: m.website_id == website)
+ models = template.with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).mapped('copy_ids').filtered(lambda m: m.website_id == website)
models.unlink()
self._theme_cleanup(model_name, website)
@@ -235,8 +236,9 @@ class IrModuleModule(models.Model):
if model_name in ('website.page', 'website.menu'):
return model
-
- orphans = model.with_context(active_test=False).search([
+ # use active_test to also unlink archived models
+ # and use MODULE_UNINSTALL_FLAG to also unlink inherited models
+ orphans = model.with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).search([
('key', '=like', self.name + '.%'),
('website_id', '=', website.id),
('theme_template_id', '=', False),
@@ -334,9 +336,6 @@ class IrModuleModule(models.Model):
"""
Remove any existing theme on the current website and install the theme ``self`` instead.
- The removal of the previous theme will however keep the existing data
- that will also be used in the new theme.
-
The actual loading of the theme on the current website will be done
automatically on ``write`` thanks to the upgrade and/or install.
diff --git a/doc/cla/individual/alexcn.md b/doc/cla/individual/alexcn.md
new file mode 100644
index 00000000000..fb0cf0d8232
--- /dev/null
+++ b/doc/cla/individual/alexcn.md
@@ -0,0 +1,9 @@
+China, 2019-01-08
+
+I hereby agree to the terms of the Odoo Individual Contributor License Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this declaration.
+
+Signed,
+
+Alex Zhang zhilight@gmail.com https://github.com/Alexcn
\ No newline at end of file
diff --git a/doc/cla/individual/ivyshnevska.md b/doc/cla/individual/ivyshnevska.md
new file mode 100644
index 00000000000..0df4e78944d
--- /dev/null
+++ b/doc/cla/individual/ivyshnevska.md
@@ -0,0 +1,9 @@
+Ukraine, 2019-01-09
+
+I hereby agree to the terms of the Odoo Individual Contributor License Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this declaration.
+
+Signed,
+
+Vyshnevska Iryna i.vyshnevska@mobilunity.com https://github.com/i-vyshnevska/
\ No newline at end of file
diff --git a/doc/reference/guidelines.rst b/doc/reference/guidelines.rst
index 10bd221a51e..5710bd36ff6 100644
--- a/doc/reference/guidelines.rst
+++ b/doc/reference/guidelines.rst
@@ -281,6 +281,7 @@ Inherited XML
~~~~~~~~~~~~~
The naming pattern of inherited view is
+
#. Extension mode: Use the same xml id than the original view you are extending, and suffix it by :samp:`{_inherit}` . For instance, the view :samp:`project.project_view_form` can be extended by :samp:`project_forecast.project_view_form_inherit`.
#. Primary mode: Keep the original xml id.
diff --git a/doc/setup/install.rst b/doc/setup/install.rst
index 21a22c7cd0d..7393292eb46 100644
--- a/doc/setup/install.rst
+++ b/doc/setup/install.rst
@@ -121,10 +121,13 @@ PostgreSQL server :
# apt-get install postgresql -y
In order to print PDF reports, you must install wkhtmltopdf_ yourself:
-the version of wkhtmltopdf_ available in debian repositories does not support
-headers and footers so it can not be installed automatically.
-The recommended version is 0.12.1 and is available on `the wkhtmltopdf download page`_,
-in the archive section.
+the version of wkhtmltopdf_ available in Debian repositories does
+not support headers and footers so it is not used as a direct dependency.
+The recommended version is 0.12.5 and is available on
+`the wkhtmltopdf download page`_, in the archive section. Previously
+recommended version 0.12.1 is a good alternative.
+More details on the various versions and their respective quirks can be
+found in our `wiki `_.
Repository
^^^^^^^^^^
diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py
index 0463170ea90..ffa57d97e50 100644
--- a/odoo/addons/base/models/ir_actions_report.py
+++ b/odoo/addons/base/models/ir_actions_report.py
@@ -609,6 +609,7 @@ class IrActionsReport(models.Model):
def render_qweb_pdf(self, res_ids=None, data=None):
if not data:
data = {}
+ data.setdefault('report_type', 'pdf')
# remove editor feature in pdf generation
data.update(enable_editor=False)
@@ -703,6 +704,9 @@ class IrActionsReport(models.Model):
@api.model
def render_qweb_text(self, docids, data=None):
+ if not data:
+ data = {}
+ data.setdefault('report_type', 'text')
data = self._get_rendering_context(docids, data)
return self.render_template(self.report_name, data), 'text'
@@ -710,6 +714,9 @@ class IrActionsReport(models.Model):
def render_qweb_html(self, docids, data=None):
"""This method generates and returns html version of a report.
"""
+ if not data:
+ data = {}
+ data.setdefault('report_type', 'html')
data = self._get_rendering_context(docids, data)
return self.render_template(self.report_name, data), 'html'
diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index 624a2a32aeb..3b957792299 100644
--- a/odoo/addons/base/models/ir_model.py
+++ b/odoo/addons/base/models/ir_model.py
@@ -1540,7 +1540,8 @@ class IrModelData(models.Model):
raise AccessError(_('Administrator access is required to uninstall a module'))
# enable model/field deletion
- self = self.with_context(**{MODULE_UNINSTALL_FLAG: True})
+ # we deactivate prefetching to not try to read a column that has been deleted
+ self = self.with_context(**{MODULE_UNINSTALL_FLAG: True, 'prefetch_fields': False})
datas = self.search([('module', 'in', modules_to_remove)])
to_unlink = tools.OrderedSet()
@@ -1625,6 +1626,16 @@ class IrModelData(models.Model):
for (id, xmlid, model, res_id) in self._cr.fetchall():
if xmlid not in loaded_xmlids:
if model in self.env:
+ if self.search_count([
+ ("model", "=", model),
+ ("res_id", "=", res_id),
+ ("id", "!=", id),
+ ("id", "not in", bad_imd_ids),
+ ]):
+ # another external id is still linked to the same record, only deleting the old imd
+ bad_imd_ids.append(id)
+ continue
+
_logger.info('Deleting %s@%s (%s)', res_id, model, xmlid)
record = self.env[model].browse(res_id)
if record.exists():
diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py
index 824365424cc..e673ac016dd 100644
--- a/odoo/addons/base/models/ir_module.py
+++ b/odoo/addons/base/models/ir_module.py
@@ -22,7 +22,9 @@ import lxml.html
import odoo
from odoo import api, fields, models, modules, tools, _
+from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
from odoo.exceptions import AccessDenied, UserError
+from odoo.osv import expression
from odoo.tools.parse_version import parse_version
from odoo.tools.misc import topological_sort
from odoo.http import request
@@ -454,9 +456,24 @@ class Module(models.Model):
"""
modules_to_remove = self.mapped('name')
self.env['ir.model.data']._module_data_uninstall(modules_to_remove)
+ self._remove_copied_views()
self.write({'state': 'uninstalled', 'latest_version': False})
return True
+ @api.multi
+ def _remove_copied_views(self):
+ """ Remove the copies of the views installed by the modules in `self`.
+
+ Those copies do not have an external id so they will not be cleaned by
+ `_module_data_uninstall`. This is why we rely on `key` instead.
+
+ It is important to remove these copies because using them will crash if
+ they rely on data that don't exist anymore if the module is removed.
+ """
+ domain = expression.OR([[('key', '=like', m.name + '.%')] for m in self])
+ orphans = self.env['ir.ui.view'].with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).search(domain)
+ orphans.unlink()
+
@api.multi
@api.returns('self')
def downstream_dependencies(self, known_deps=None,
diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py
index 4aeae7c2cd6..2d9744abada 100644
--- a/odoo/addons/base/models/ir_translation.py
+++ b/odoo/addons/base/models/ir_translation.py
@@ -140,7 +140,8 @@ class IrTranslationImport(object):
WHERE type = 'code'
AND noupdate IS NOT TRUE
ON CONFLICT (type, lang, md5(src)) WHERE type = 'code'
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments);
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
""" % (self._model_table, self._table))
count += cr.rowcount
cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments)
@@ -149,7 +150,8 @@ class IrTranslationImport(object):
WHERE type = 'model'
AND noupdate IS NOT TRUE
ON CONFLICT (type, lang, name, res_id) WHERE type = 'model'
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments);
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
""" % (self._model_table, self._table))
count += cr.rowcount
cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments)
@@ -158,7 +160,8 @@ class IrTranslationImport(object):
WHERE type IN ('selection', 'constraint', 'sql_constraint')
AND noupdate IS NOT TRUE
ON CONFLICT (type, lang, name, md5(src)) WHERE type IN ('selection', 'constraint', 'sql_constraint')
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments);
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
""" % (self._model_table, self._table))
count += cr.rowcount
cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments)
@@ -167,7 +170,8 @@ class IrTranslationImport(object):
WHERE type = 'model_terms'
AND noupdate IS NOT TRUE
ON CONFLICT (type, name, lang, res_id, md5(src))
- DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments);
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) = (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type, EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
""" % (self._model_table, self._table))
count += cr.rowcount
cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments)
@@ -483,6 +487,8 @@ class IrTranslation(models.Model):
for translation in translations:
if not translation.value:
discarded += translation
+ # consider it done to avoid being matched against another term
+ done.add((translation.src, translation.lang))
elif translation.src in terms:
done.add((translation.src, translation.lang))
else:
diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py
index 4a72f20abb3..d8a15f000d9 100644
--- a/odoo/addons/base/models/ir_ui_view.py
+++ b/odoo/addons/base/models/ir_ui_view.py
@@ -1174,6 +1174,9 @@ actual arch.
""" Return the view ID corresponding to ``template``, which may be a
view ID or an XML ID. Note that this method may be overridden for other
kinds of template values.
+
+ This method could return the ID of something that is not a view (when
+ using fallback to `xmlid_to_res_id`).
"""
if isinstance(template, int):
return template
diff --git a/odoo/addons/base/models/res_company.py b/odoo/addons/base/models/res_company.py
index 64fb3ee6fb2..d62096fe561 100644
--- a/odoo/addons/base/models/res_company.py
+++ b/odoo/addons/base/models/res_company.py
@@ -296,3 +296,12 @@ class Company(models.Model):
def action_save_onboarding_company_step(self):
if bool(self.street):
self.set_onboarding_step_done('base_onboarding_company_state')
+
+ @api.model
+ def _get_main_company(self):
+ try:
+ main_company = self.sudo().env.ref('base.main_company')
+ except ValueError:
+ main_company = self.env['res.company'].sudo().search([], limit=1, order="id")
+
+ return main_company
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index dfa5f61e25b..73eb10b9f86 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -922,8 +922,26 @@ class GroupsImplied(models.Model):
if values.get('users') or values.get('implied_ids'):
# add all implied groups (to all users of each group)
for group in self:
- vals = {'users': list(zip(repeat(4), group.with_context(active_test=False).users.ids))}
- super(GroupsImplied, group.trans_implied_ids).write(vals)
+ self._cr.execute("""
+ WITH RECURSIVE group_imply(gid, hid) AS (
+ SELECT gid, hid
+ FROM res_groups_implied_rel
+ UNION
+ SELECT i.gid, r.hid
+ FROM res_groups_implied_rel r
+ JOIN group_imply i ON (i.hid = r.gid)
+ )
+ INSERT INTO res_groups_users_rel (gid, uid)
+ SELECT i.hid, r.uid
+ FROM group_imply i, res_groups_users_rel r
+ WHERE r.gid = i.gid
+ AND i.gid = %(gid)s
+ EXCEPT
+ SELECT r.gid, r.uid
+ FROM res_groups_users_rel r
+ JOIN group_imply i ON (r.gid = i.hid)
+ WHERE i.gid = %(gid)s
+ """, dict(gid=group.id))
return res
class UsersImplied(models.Model):
diff --git a/odoo/addons/base/static/src/css/modules.css b/odoo/addons/base/static/src/css/modules.css
index 0ac3e5cd939..f1156464ed1 100644
--- a/odoo/addons/base/static/src/css/modules.css
+++ b/odoo/addons/base/static/src/css/modules.css
@@ -13,10 +13,6 @@
padding-left: 80px;
}
-.o_modules_kanban.o_kanban_view h4.o_kanban_record_title {
- line-height: 1;
-}
-
.o_modules_kanban.o_kanban_view .o_kanban_record .o_dropdown_kanban {
visibility: visible;
opacity: 0.5;
diff --git a/odoo/addons/base/tests/test_cache.py b/odoo/addons/base/tests/test_cache.py
index 5ae2e8abaea..baa4bc2ab70 100644
--- a/odoo/addons/base/tests/test_cache.py
+++ b/odoo/addons/base/tests/test_cache.py
@@ -2,7 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import os
+import platform
import psutil
+import unittest
from odoo.exceptions import CacheMiss
from odoo.tests.common import TransactionCase
@@ -51,33 +53,35 @@ class TestRecordCache(TransactionCase):
self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), [1, 2])
# set values in one environment only
- for rec in [foo1, bar1]:
- cache.set(rec, name, 'NAME1')
- cache.set(rec, ref, 'REF1')
- check(foo1, 'NAME1', 'REF1')
+ cache.set(foo1, name, 'FOO1_NAME')
+ cache.set(foo1, ref, 'FOO1_REF')
+ cache.set(bar1, name, 'BAR1_NAME')
+ cache.set(bar1, ref, 'BAR1_REF')
+ check(foo1, 'FOO1_NAME', 'FOO1_REF')
check(foo2, None, None)
- check(bar1, 'NAME1', 'REF1')
+ check(bar1, 'BAR1_NAME', 'BAR1_REF')
check(bar2, None, None)
self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), [])
self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), [1, 2])
# set values in both environments
- for rec in [foo2, bar2]:
- cache.set(rec, name, 'NAME2')
- cache.set(rec, ref, 'REF2')
- check(foo1, 'NAME1', 'REF1')
- check(foo2, 'NAME2', 'REF2')
- check(bar1, 'NAME1', 'REF1')
- check(bar2, 'NAME2', 'REF2')
+ cache.set(foo2, name, 'FOO2_NAME')
+ cache.set(foo2, ref, 'FOO2_REF')
+ cache.set(bar2, name, 'BAR2_NAME')
+ cache.set(bar2, ref, 'BAR2_REF')
+ check(foo1, 'FOO1_NAME', 'FOO1_REF')
+ check(foo2, 'FOO2_NAME', 'FOO2_REF')
+ check(bar1, 'BAR1_NAME', 'BAR1_REF')
+ check(bar2, 'BAR2_NAME', 'BAR2_REF')
self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), [])
self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), [])
# remove value in one environment
cache.remove(foo1, name)
- check(foo1, None, 'REF1')
- check(foo2, 'NAME2', 'REF2')
- check(bar1, 'NAME1', 'REF1')
- check(bar2, 'NAME2', 'REF2')
+ check(foo1, None, 'FOO1_REF')
+ check(foo2, 'FOO2_NAME', 'FOO2_REF')
+ check(bar1, 'BAR1_NAME', 'BAR1_REF')
+ check(bar2, 'BAR2_NAME', 'BAR2_REF')
self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), [1])
self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), [])
@@ -85,8 +89,8 @@ class TestRecordCache(TransactionCase):
cache.invalidate([(name, None), (ref, foo1.ids)])
check(foo1, None, None)
check(foo2, None, None)
- check(bar1, None, 'REF1')
- check(bar2, None, 'REF2')
+ check(bar1, None, 'BAR1_REF')
+ check(bar2, None, 'BAR2_REF')
# total invalidation
cache.invalidate()
@@ -96,28 +100,34 @@ class TestRecordCache(TransactionCase):
check(bar2, None, None)
# set a special value
- cache.set_special(foo1, name, lambda: '42')
+ cache.set_special(foo1, name, lambda: 'FOO1_SPECIAL_NAME')
self.assertTrue(cache.contains(foo1, name))
self.assertFalse(cache.contains_value(foo1, name))
- self.assertEqual(cache.get(foo1, name), '42')
+ self.assertEqual(cache.get(foo1, name), 'FOO1_SPECIAL_NAME')
self.assertIsNone(cache.get_value(foo1, name))
self.assertIsNotNone(cache.get_special(foo1, name))
# copy cache
- for rec in [foo1, bar1]:
- cache.set(rec, name, 'NAME1')
- cache.set(rec, ref, 'REF1')
- check(foo1, 'NAME1', 'REF1')
- check(foo2, None, None)
- check(bar1, 'NAME1', 'REF1')
+ cache.set(foo1, name, 'FOO1_NAME')
+ cache.set(foo1, ref, 'FOO1_REF')
+ cache.set(bar1, name, 'BAR1_NAME')
+ cache.set(bar1, ref, 'BAR1_REF')
+ cache.set(foo2, name, 'FOO2_NAME')
+ check(foo1, 'FOO1_NAME', 'FOO1_REF')
+ check(foo2, 'FOO2_NAME', None)
+ check(bar1, 'BAR1_NAME', 'BAR1_REF')
check(bar2, None, None)
cache.copy(foo1 + bar1, foo2.env)
- check(foo1, 'NAME1', 'REF1')
- check(foo2, 'NAME1', 'REF1')
- check(bar1, 'NAME1', 'REF1')
- check(bar2, 'NAME1', 'REF1')
+ check(foo1, 'FOO1_NAME', 'FOO1_REF')
+ check(foo2, 'FOO1_NAME', 'FOO1_REF')
+ check(bar1, 'BAR1_NAME', 'BAR1_REF')
+ check(bar2, 'BAR1_NAME', 'BAR1_REF')
+ @unittest.skipIf(
+ not(platform.system() == 'Linux' and platform.machine() == 'x86_64'),
+ "This test only makes sense on 64-bit Linux-like systems",
+ )
def test_memory(self):
""" Check memory consumption of the cache. """
NB_RECORDS = 100000
diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py
index 4094fc4b829..676b0418794 100644
--- a/odoo/addons/base/tests/test_translate.py
+++ b/odoo/addons/base/tests/test_translate.py
@@ -405,3 +405,31 @@ class TestXMLTranslation(TransactionCase):
self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
+
+ def test_sync_update(self):
+ """ Check translations after minor change in source terms. """
+ archf = ''
+ terms_src = ('Subtotal', 'Subtotal:')
+ terms_en = ('Subtotal', 'Sub total:')
+ view = self.create_view(archf, terms_src, en_US=terms_en)
+
+ translations = self.env['ir.translation'].search([
+ ('type', '=', 'model_terms'),
+ ('name', '=', "ir.ui.view,arch_db"),
+ ('res_id', '=', view.id),
+ ])
+ self.assertEqual(len(translations), 2)
+
+ # modifying the arch should sync existing translations without errors
+ view.write({
+ "arch": archf % ('Subtotal', 'Subtotal: ')
+ })
+
+ translations = self.env['ir.translation'].search([
+ ('type', '=', 'model_terms'),
+ ('name', '=', "ir.ui.view,arch_db"),
+ ('res_id', '=', view.id),
+ ])
+ # 'Subtotal' being src==value, it will be discared
+ # 'Subtotal:' will be discarded as it match 'Subtotal' instead of 'Subtotal: '
+ self.assertEqual(len(translations), 0)
diff --git a/odoo/addons/base/wizard/base_update_translations.py b/odoo/addons/base/wizard/base_update_translations.py
index f1dc7a10ca4..0bbfc17ab24 100644
--- a/odoo/addons/base/wizard/base_update_translations.py
+++ b/odoo/addons/base/wizard/base_update_translations.py
@@ -32,5 +32,6 @@ class BaseUpdateTranslations(models.TransientModel):
lang_name = self._get_lang_name(this.lang)
with tempfile.NamedTemporaryFile() as buf:
tools.trans_export(this.lang, ['all'], buf, 'po', self._cr)
- tools.trans_load_data(self._cr, buf, 'po', this.lang, lang_name=lang_name)
+ context = {'create_empty_translation': True}
+ tools.trans_load_data(self._cr, buf, 'po', this.lang, lang_name=lang_name, context=context)
return {'type': 'ir.actions.act_window_close'}
diff --git a/odoo/addons/test_inherits/tests/test_inherits.py b/odoo/addons/test_inherits/tests/test_inherits.py
index 246222b14ff..85396de5441 100644
--- a/odoo/addons/test_inherits/tests/test_inherits.py
+++ b/odoo/addons/test_inherits/tests/test_inherits.py
@@ -26,3 +26,14 @@ class test_inherits(common.TransactionCase):
pallet = self.env.ref('test_inherits.pallet_a')
pallet.write({'name': 'C'})
self.assertEqual(pallet.name, 'C')
+
+ def test_ir_model_data_inherits(self):
+ """ Check the existence of the correct ir.model.data """
+ IrModelData = self.env['ir.model.data']
+ field = IrModelData.search([('name', '=', 'field_test_unit__name')])
+ self.assertEqual(len(field), 1)
+ self.assertEqual(field.module, 'test_inherits')
+
+ field = IrModelData.search([('name', '=', 'field_test_box__name')])
+ self.assertEqual(len(field), 1)
+ self.assertEqual(field.module, 'test_inherits')
diff --git a/odoo/addons/test_inherits_depends/__init__.py b/odoo/addons/test_inherits_depends/__init__.py
new file mode 100644
index 00000000000..d6210b1285d
--- /dev/null
+++ b/odoo/addons/test_inherits_depends/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/odoo/addons/test_inherits_depends/__manifest__.py b/odoo/addons/test_inherits_depends/__manifest__.py
new file mode 100644
index 00000000000..335fdee1362
--- /dev/null
+++ b/odoo/addons/test_inherits_depends/__manifest__.py
@@ -0,0 +1,12 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'test-inherits-depends',
+ 'version': '0.1',
+ 'category': 'Tests',
+ 'description': """A module to verify the inheritance using _inherits in non-original modules.""",
+ 'depends': ['test_inherits'],
+ 'data': [],
+ 'installable': True,
+ 'auto_install': False,
+}
diff --git a/odoo/addons/test_inherits_depends/models.py b/odoo/addons/test_inherits_depends/models.py
new file mode 100644
index 00000000000..77ad3ad1206
--- /dev/null
+++ b/odoo/addons/test_inherits_depends/models.py
@@ -0,0 +1,10 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields
+
+
+# We add a field on this model
+class Unit(models.Model):
+ _inherit = 'test.unit'
+
+ second_name = fields.Char()
diff --git a/odoo/addons/test_inherits_depends/tests/__init__.py b/odoo/addons/test_inherits_depends/tests/__init__.py
new file mode 100644
index 00000000000..5a5bb8a95f3
--- /dev/null
+++ b/odoo/addons/test_inherits_depends/tests/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_inherits
diff --git a/odoo/addons/test_inherits_depends/tests/test_inherits.py b/odoo/addons/test_inherits_depends/tests/test_inherits.py
new file mode 100644
index 00000000000..c758b47f8de
--- /dev/null
+++ b/odoo/addons/test_inherits_depends/tests/test_inherits.py
@@ -0,0 +1,29 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import common
+
+
+class test_inherits(common.TransactionCase):
+
+ def test_ir_model_data_inherits_again(self):
+ """ Re-run test_inherits test to make sure another imd hasn't been created """
+ IrModelData = self.env['ir.model.data']
+ field = IrModelData.search([('name', '=', 'field_test_unit__name')])
+ self.assertEqual(len(field), 1)
+ self.assertEqual(field.module, 'test_inherits')
+
+ field = IrModelData.search([('name', '=', 'field_test_box__name')])
+ self.assertEqual(len(field), 1)
+ self.assertEqual(field.module, 'test_inherits')
+
+
+ def test_ir_model_data_inherits_depends(self):
+ """ Check the existence of the correct ir.model.data """
+ IrModelData = self.env['ir.model.data']
+ field = IrModelData.search([('name', '=', 'field_test_unit__second_name')])
+ self.assertEqual(len(field), 1)
+ self.assertEqual(field.module, 'test_inherits_depends')
+
+ field = IrModelData.search([('name', '=', 'field_test_box__second_name')])
+ self.assertEqual(len(field), 1)
+ self.assertEqual(field.module, 'test_inherits_depends')
diff --git a/odoo/addons/test_translation_import/tests/test_term_count.py b/odoo/addons/test_translation_import/tests/test_term_count.py
index 8a4976e1771..bde7bceac4f 100644
--- a/odoo/addons/test_translation_import/tests/test_term_count.py
+++ b/odoo/addons/test_translation_import/tests/test_term_count.py
@@ -92,17 +92,23 @@ class TestTermCount(common.TransactionCase):
def test_export_empty_string(self):
"""When the string and the translation is equal the translation is empty"""
# Export the translations
- def update_translations():
+ def update_translations(context=None):
+ context = dict(context or {}, overwrite=True)
with closing(io.BytesIO()) as bufferobj:
odoo.tools.trans_export('fr_FR', ['test_translation_import'], bufferobj, 'po', self.cr)
bufferobj.name = 'test_translation_import/i18n/fr.po'
- odoo.tools.trans_load_data(self.cr, bufferobj, 'po', 'fr_FR', verbose=False, context={'overwrite': True})
+ odoo.tools.trans_load_data(self.cr, bufferobj, 'po', 'fr_FR', verbose=False, context=context)
- # Check if the not translated key is empty string
+ # Check that the not translated key is not created
update_translations()
translation = self.env['ir.translation'].search_count([('src', '=', 'Efgh'), ('value', '=', '')])
self.assertFalse(translation, 'An empty translation is not imported')
+ # Check that "Generate Missing Terms" create empty string for not translated key
+ update_translations({'create_empty_translation': True})
+ translation = self.env['ir.translation'].search_count([('src', '=', 'Efgh'), ('value', '=', '')])
+ self.assertTrue(translation, 'The translation of "Efgh" should be empty')
+
# Modify the value translated for the equal value of the key
menu = self.env.ref('test_translation_import.menu_test_translation_import')
menu.name = "New Name"
diff --git a/odoo/fields.py b/odoo/fields.py
index 04c70e930be..a0a08631c52 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -560,6 +560,9 @@ class Field(MetaField('DummyField', (object,), {})):
if self.inherited and field.required:
self.required = True
+ if self.inherited:
+ self._modules.update(field._modules)
+
def traverse_related(self, record):
""" Traverse the fields of the related field `self` except for the last
one, and return it as a pair `(last_record, last_field)`. """
diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py
index d458d779c27..e20ebf9fed7 100644
--- a/odoo/tools/translate.py
+++ b/odoo/tools/translate.py
@@ -1098,8 +1098,8 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
dic['lang'] = lang
dic.update(zip(fields, row))
- # ignore empty value that may be set from base language
- if not dic['value']:
+ # do not import empty values
+ if not env.context.get('create_empty_translation', False) and not dic['value']:
return
if use_pot_reference: