diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index 065fc4b73be..3ddc34fc822 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -55,7 +55,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): arg_list = (tuple(move_state), tuple(account_type)) #build the reconciliation clause to see what partner needs to be printed reconciliation_clause = '(l.reconciled IS FALSE)' - cr.execute('SELECT debit_move_id, credit_move_id FROM account_partial_reconcile where create_date > %s', (date_from,)) + cr.execute('SELECT debit_move_id, credit_move_id FROM account_partial_reconcile where max_date > %s', (date_from,)) reconciled_after_date = [] for row in cr.fetchall(): reconciled_after_date += [row[0], row[1]] diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 32d130704e1..4bd383b6892 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -889,6 +889,83 @@ class TestReconciliation(AccountingTestCase): _move_revert_test_pair(payment_move, reverted_payment_move) _move_revert_test_pair(exchange_move, reverted_exchange_move) + def test_aged_report_future_payment(self): + AgedReport = self.env['report.account.report_agedpartnerbalance'].with_context(include_nullified_amount=True) + account_type = ['receivable'] + partner = self.env['res.partner'].create({'name': 'AgedPartner'}) + currency = self.env.user.company_id.currency_id + + invoice = self.create_invoice_partner(currency_id=currency.id, partner_id=partner.id) + journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE'}) + + statement = self.make_payment(invoice, journal, 50) + + # Force the payment recording to take place on the invoice date + # Although the payment due_date is in the future relative to the invoice + # Also, in this case, there can be only 1 partial_reconcile + statement_partial_id = statement.move_line_ids.mapped(lambda l: l.matched_credit_ids + l.matched_debit_ids) + self.env.cr.execute('UPDATE account_partial_reconcile SET create_date = %(date)s WHERE id = %(partial_id)s', + {'date': invoice.date_invoice + ' 00:00:00', + 'partial_id': statement_partial_id.id}) + + # Case 1: report date is invoice date + # There should be an entry for the partner + report_date_to = invoice.date_invoice + report_lines, total, amls = AgedReport._get_partner_move_lines(account_type, report_date_to, 'posted', 30) + + partner_lines = [line for line in report_lines if line['partner_id'] == partner.id] + self.assertEqual(partner_lines, [{ + 'name': 'AgedPartner', + 'trust': 'normal', + 'partner_id': partner.id, + '0': 0.0, + '1': 0.0, + '2': 0.0, + '3': 0.0, + '4': 0.0, + 'total': 50.0, + 'direction': 50.0, + }], 'We should have a line in the report for the partner') + self.assertEqual(len(amls[partner.id]), 1, 'We should have 1 account move lines for the partner') + + positive_line = [line for line in amls[partner.id] if line['line'].balance > 0] + + self.assertEqual(positive_line[0]['amount'], 50.0, 'The amount of the amls should be 50') + + # Case 2: report date between invoice date and payment date + # There should be an entry for the partner + # And the amount has shifted to '1-30 due' + report_date_to = time.strftime('%Y') + '-07-08' + report_lines, total, amls = AgedReport._get_partner_move_lines(account_type, report_date_to, 'posted', 30) + + partner_lines = [line for line in report_lines if line['partner_id'] == partner.id] + self.assertEqual(partner_lines, [{ + 'name': 'AgedPartner', + 'trust': 'normal', + 'partner_id': partner.id, + '0': 0.0, + '1': 0.0, + '2': 0.0, + '3': 0.0, + '4': 50.0, + 'total': 50.0, + 'direction': 0.0, + }], 'We should have a line in the report for the partner') + self.assertEqual(len(amls[partner.id]), 1, 'We should have 1 account move lines for the partner') + + positive_line = [line for line in amls[partner.id] if line['line'].balance > 0] + + self.assertEqual(positive_line[0]['amount'], 50.0, 'The amount of the amls should be 50') + + # Case 2: report date on payment date + # There should not be an entry for the partner + report_date_to = time.strftime('%Y') + '-07-15' + report_lines, total, amls = AgedReport._get_partner_move_lines(account_type, report_date_to, 'posted', 30) + + partner_lines = [line for line in report_lines if line['partner_id'] == partner.id] + self.assertEqual(partner_lines, [], 'The aged receivable shouldn\'t have lines at this point') + self.assertFalse(amls.get(partner.id, False), 'The aged receivable should not have amls either') + def test_partial_reconcile_currencies_02(self): #### # Day 1: Invoice Cust/001 to customer (expressed in USD) diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index cc91739fb6a..ef717319aef 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -1393,7 +1393,6 @@ class Meeting(models.Model): meeting_origin = self.browse(real_id) data = self.read(['allday', 'start', 'stop', 'rrule', 'duration'])[0] - d_class = fields.Date if data['allday'] else fields.Datetime if data.get('rrule'): data.update( values, @@ -1402,8 +1401,8 @@ class Meeting(models.Model): rrule_type=False, rrule='', recurrency=False, - final_date=d_class.from_string(data.get('start')) \ - + timedelta(hours=values.get('duration', False) or data.get('duration')) + final_date=False, + end_type=False ) # do not copy the id diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 766227d51ee..1a361177cd5 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -342,7 +342,7 @@ class Message(models.Model): 'id': attachment['id'], 'filename': attachment['datas_fname'], 'name': attachment['name'], - 'mimetype': 'application/octet-stream' if safari and 'video' in attachment['mimetype'] else attachment['mimetype'], + 'mimetype': 'application/octet-stream' if safari and attachment['mimetype'] and 'video' in attachment['mimetype'] else attachment['mimetype'], }) for attachment in attachments_data) # 3. Tracking values diff --git a/addons/mail/views/mail_activity_views.xml b/addons/mail/views/mail_activity_views.xml index 0da4b763b37..b540844fc39 100644 --- a/addons/mail/views/mail_activity_views.xml +++ b/addons/mail/views/mail_activity_views.xml @@ -163,7 +163,7 @@ diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 6c32b2de21f..7682a2c03b1 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -52,7 +52,7 @@ class MrpProduction(models.Model): domain=[('type', 'in', ['product', 'consu'])], readonly=True, required=True, states={'confirmed': [('readonly', False)]}) - product_tmpl_id = fields.Many2one('product.template', 'Product Template', related='product_id.product_tmpl_id', readonly=False) + product_tmpl_id = fields.Many2one('product.template', 'Product Template', related='product_id.product_tmpl_id', readonly=True) product_qty = fields.Float( 'Quantity To Produce', default=1.0, digits=dp.get_precision('Product Unit of Measure'), diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index ff98c6c944d..71928e3024a 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -197,13 +197,15 @@ class PosOrder(models.Model): values['partner_id'], (values['product_id'], tuple(values['tax_ids'][0][2]), values['name']), values['analytic_account_id'], - values['debit'] > 0) + values['debit'] > 0, + values.get('currency_id')) elif data_type == 'tax': order_id = values.pop('order_id', False) tax_key = ('tax', values['partner_id'], values['tax_line_id'], - values['debit'] > 0) + values['debit'] > 0, + values.get('currency_id')) if options.get('rounding_method') == 'round_globally': tax_key = ('tax', values['tax_line_id'], @@ -213,7 +215,8 @@ class PosOrder(models.Model): return ('counter_part', values['partner_id'], values['account_id'], - values['debit'] > 0) + values['debit'] > 0, + values.get('currency_id')) return False def _action_create_invoice_line(self, line=False, invoice_id=False): @@ -332,6 +335,8 @@ class PosOrder(models.Model): current_value['quantity'] = current_value.get('quantity', 0.0) + values.get('quantity', 0.0) current_value['credit'] = current_value.get('credit', 0.0) + values.get('credit', 0.0) current_value['debit'] = current_value.get('debit', 0.0) + values.get('debit', 0.0) + if 'currency_id' in values: + current_value['amount_currency'] = current_value.get('amount_currency', 0.0) + values.get('amount_currency', 0.0) if key[0] == 'tax' and rounding_method == 'round_globally': if current_value['debit'] - current_value['credit'] > 0: current_value['debit'] = current_value['debit'] - current_value['credit'] @@ -351,8 +356,14 @@ class PosOrder(models.Model): assert order.lines, _('The POS order must have lines when calling this method') # Create an move for each order line cur = order.pricelist_id.currency_id + cur_company = order.company_id.currency_id + amount_cur_company = 0.0 + date_order = (order.date_order or fields.Datetime.now())[:10] for line in order.lines: - amount = line.price_subtotal + if cur != cur_company: + amount_subtotal = cur.with_context(date=date_order).compute(line.price_subtotal, cur_company) + else: + amount_subtotal = line.price_subtotal # Search for the income account if line.product_id.property_account_income_id.id: @@ -373,17 +384,22 @@ class PosOrder(models.Model): # Just like for invoices, a group of taxes must be present on this base line # As well as its children base_line_tax_ids = _flatten_tax_and_children(line.tax_ids_after_fiscal_position).filtered(lambda tax: tax.type_tax_use in ['sale', 'none']) - insert_data('product', { + data = { 'name': name, 'quantity': line.qty, 'product_id': line.product_id.id, 'account_id': income_account, 'analytic_account_id': self._prepare_analytic_account(line), - 'credit': ((amount > 0) and amount) or 0.0, - 'debit': ((amount < 0) and -amount) or 0.0, + 'credit': ((amount_subtotal > 0) and amount_subtotal) or 0.0, + 'debit': ((amount_subtotal < 0) and -amount_subtotal) or 0.0, 'tax_ids': [(6, 0, base_line_tax_ids.ids)], 'partner_id': partner_id - }) + } + if cur != cur_company: + data['currency_id'] = cur.id + data['amount_currency'] = -abs(line.price_subtotal) if data.get('credit') else abs(line.price_subtotal) + amount_cur_company += data['credit'] - data['debit'] + insert_data('product', data) # Create the tax lines taxes = line.tax_ids_after_fiscal_position.filtered(lambda t: t.company_id.id == current_company.id) @@ -391,34 +407,58 @@ class PosOrder(models.Model): continue price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) for tax in taxes.compute_all(price, cur, line.qty)['taxes']: - insert_data('tax', { + if cur != cur_company: + round_tax = False if rounding_method == 'round_globally' else True + amount_tax = cur.with_context(date=date_order).compute(tax['amount'], cur_company, round=round_tax) + else: + amount_tax = tax['amount'] + data = { 'name': _('Tax') + ' ' + tax['name'], 'product_id': line.product_id.id, 'quantity': line.qty, 'account_id': tax['account_id'] or income_account, - 'credit': ((tax['amount'] > 0) and tax['amount']) or 0.0, - 'debit': ((tax['amount'] < 0) and -tax['amount']) or 0.0, + 'credit': ((amount_tax > 0) and amount_tax) or 0.0, + 'debit': ((amount_tax < 0) and -amount_tax) or 0.0, 'tax_line_id': tax['id'], 'partner_id': partner_id, 'order_id': order.id - }) + } + if cur != cur_company: + data['currency_id'] = cur.id + data['amount_currency'] = -abs(tax['amount']) if data.get('credit') else abs(tax['amount']) + amount_cur_company += data['credit'] - data['debit'] + insert_data('tax', data) # round tax lines per order if rounding_method == 'round_globally': for group_key, group_value in grouped_data.items(): if group_key[0] == 'tax': for line in group_value: - line['credit'] = cur.round(line['credit']) - line['debit'] = cur.round(line['debit']) + line['credit'] = cur_company.round(line['credit']) + line['debit'] = cur_company.round(line['debit']) + if line.get('currency_id'): + line['amount_currency'] = cur.round(line.get('amount_currency', 0.0)) # counterpart - insert_data('counter_part', { + if cur != cur_company: + # 'amount_cur_company' contains the sum of the AML converted in the company + # currency. This makes the logic consistent with 'compute_invoice_totals' from + # 'account.invoice'. It ensures that the counterpart line is the same amount than + # the sum of the product and taxes lines. + amount_total = amount_cur_company + else: + amount_total = order.amount_total + data = { 'name': _("Trade Receivables"), # order.name, 'account_id': order_account, - 'credit': ((order.amount_total < 0) and -order.amount_total) or 0.0, - 'debit': ((order.amount_total > 0) and order.amount_total) or 0.0, + 'credit': ((amount_total < 0) and -amount_total) or 0.0, + 'debit': ((amount_total > 0) and amount_total) or 0.0, 'partner_id': partner_id - }) + } + if cur != cur_company: + data['currency_id'] = cur.id + data['amount_currency'] = -abs(order.amount_total) if data.get('credit') else abs(order.amount_total) + insert_data('counter_part', data) order.write({'state': 'done', 'account_move': move.id}) diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py index cea15d9e5a6..2e1c50e36d9 100644 --- a/addons/point_of_sale/tests/test_frontend.py +++ b/addons/point_of_sale/tests/test_frontend.py @@ -187,6 +187,10 @@ class TestUi(odoo.tests.HttpCase): one_week_from_now = today + timedelta(weeks=1) two_weeks_from_now = today + timedelta(weeks=2) + public_pricelist = env['product.pricelist'].create({ + 'name': 'Public Pricelist', + }) + env['product.pricelist'].create({ 'name': 'Dates', 'item_ids': [(0, 0, { @@ -273,9 +277,19 @@ class TestUi(odoo.tests.HttpCase): 'type': 'cash', 'company_id': main_company.id, 'journal_user': True})], + 'pricelist_id': public_pricelist.id, 'available_pricelist_ids': [(4, pricelist.id) for pricelist in all_pricelists], }) + # Change the default sale pricelist of customers, + # so the js tests can expect deterministically this pricelist when selecting a customer. + field = env['ir.model.fields']._get('res.partner', 'property_product_pricelist') + env['ir.property'].search([ + ('name', '=', 'property_product_pricelist'), + ('fields_id', '=', field.id), + ('res_id', '=', False) + ]).write({'value_reference': 'product.pricelist,%s' % public_pricelist.id}) + # open a session, the /pos/web controller will redirect to it main_pos_config.open_session_cb() diff --git a/addons/point_of_sale/tests/test_point_of_sale_flow.py b/addons/point_of_sale/tests/test_point_of_sale_flow.py index 4f982486bf0..de3c18ce27b 100644 --- a/addons/point_of_sale/tests/test_point_of_sale_flow.py +++ b/addons/point_of_sale/tests/test_point_of_sale_flow.py @@ -549,3 +549,123 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): # I close the rescue session rescue_session.action_pos_session_closing_control() self.assertEqual(rescue_session.state, 'closed', "Rescue session was not properly closed") + + def test_order_to_payment_currency(self): + """ + In order to test the Point of Sale in module, I will do a full flow from the sale to the payment and invoicing. + I will use two products, one with price including a 10% tax, the other one with 5% tax excluded from the price. + The order will be in a different currency than the company currency. + """ + # Make sure the company is in USD + self.env.cr.execute( + "UPDATE res_company SET currency_id = %s WHERE id = %s", + [self.env.ref('base.USD').id, self.env.user.company_id.id]) + + # Demo data are crappy, clean-up the rates + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': '2010-01-01', + 'rate': 2.0, + 'currency_id': self.env.ref('base.EUR').id, + }) + + # I click on create a new session button + self.pos_config.open_session_cb() + + # I create a PoS order with 2 units of PCSC234 at 450 EUR (Tax Incl) + # and 3 units of PCSC349 at 300 EUR. (Tax Excl) + self.pos_order_pos0 = self.PosOrder.create({ + 'company_id': self.company_id, + 'pricelist_id': self.partner1.property_product_pricelist.copy(default={'currency_id': self.env.ref('base.EUR').id}).id, + 'partner_id': self.partner1.id, + 'lines': [(0, 0, { + 'name': "OL/0001", + 'product_id': self.product3.id, + 'price_unit': 450, + 'discount': 0.0, + 'qty': 2.0, + 'tax_ids': [(6, 0, self.product3.taxes_id.ids)], + }), (0, 0, { + 'name': "OL/0002", + 'product_id': self.product4.id, + 'price_unit': 300, + 'discount': 0.0, + 'qty': 3.0, + 'tax_ids': [(6, 0, self.product4.taxes_id.ids)], + })] + }) + + # I check that the total of the order is equal to 450*2 + 300*3*1.05 + # and the tax of the order is equal to 900 -(450 * 2 / 1.1) + + # 300*0.05*3 + self.assertLess( + abs(self.pos_order_pos0.amount_total - (450 * 2 + 300 * 3 * 1.05)), + 0.01, 'The order has a wrong amount, tax included.') + + self.assertLess( + abs(self.pos_order_pos0.amount_tax - (900 - (450 * 2 / 1.1) + 300 * 0.05 * 3)), + 0.01, 'The order has a wrong tax amount.') + + # I want to add a global discount of 5 percent using the wizard + + self.pos_discount_0 = self.env['pos.discount'].create({'discount': 5.0}) + + context = {"active_model": "pos.order", "active_ids": [self.pos_order_pos0.id], "active_id": self.pos_order_pos0.id} + + # I click the apply button to set the discount on all lines + self.pos_discount_0.with_context(context).apply_discount() + + # I check that the total of the order is now equal to (450*2 + + # 300*3*1.05)*0.95 + self.assertLess( + abs(self.pos_order_pos0.amount_total - (450 * 2 + 300 * 3 * 1.05) * 0.95), + 0.01, 'The order has a wrong total including tax and discounts') + + # I click on the "Make Payment" wizard to pay the PoS order with a + # partial amount of 100.0 EUR + context_make_payment = {"active_ids": [self.pos_order_pos0.id], "active_id": self.pos_order_pos0.id} + self.pos_make_payment_0 = self.PosMakePayment.with_context(context_make_payment).create({ + 'amount': 100.0 + }) + + # I click on the validate button to register the payment. + context_payment = {'active_id': self.pos_order_pos0.id} + self.pos_make_payment_0.with_context(context_payment).check() + + # I check that the order is not marked as paid yet + self.assertEqual(self.pos_order_pos0.state, 'draft', 'Order should be in draft state.') + + # On the second payment proposition, I check that it proposes me the + # remaining balance which is 1790.0 EUR + defs = self.pos_make_payment_0.with_context({'active_id': self.pos_order_pos0.id}).default_get(['amount']) + + self.assertLess( + abs(defs['amount'] - ((450 * 2 + 300 * 3 * 1.05) * 0.95 - 100.0)), 0.01, "The remaining balance is incorrect.") + + #'I pay the remaining balance. + context_make_payment = { + "active_ids": [self.pos_order_pos0.id], "active_id": self.pos_order_pos0.id} + + self.pos_make_payment_1 = self.PosMakePayment.with_context(context_make_payment).create({ + 'amount': (450 * 2 + 300 * 3 * 1.05) * 0.95 - 100.0 + }) + + # I click on the validate button to register the payment. + self.pos_make_payment_1.with_context(context_make_payment).check() + + # I check that the order is marked as paid + self.assertEqual(self.pos_order_pos0.state, 'paid', 'Order should be in paid state.') + + # I generate the journal entries + self.pos_order_pos0._create_account_move_line() + + # I test that the generated journal entry is attached to the PoS order + self.assertTrue(self.pos_order_pos0.account_move, "Journal entry has not been attached to Pos order.") + + # Check the amounts + debit_lines = self.pos_order_pos0.account_move.mapped('line_ids.debit') + credit_lines = self.pos_order_pos0.account_move.mapped('line_ids.credit') + amount_currency_lines = self.pos_order_pos0.account_move.mapped('line_ids.amount_currency') + self.assertEqual(set(debit_lines), {876.39, 0.0}) + self.assertEqual(set(credit_lines), {0.0, 21.38, 427.5, 38.87, 388.64}) + self.assertEqual(set(amount_currency_lines), {1752.75, -42.75, -855.0, -77.73, -777.27}) diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index b30c3d89c57..4e749778f12 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -552,7 +552,7 @@ - + diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index d262610462c..43f131d631a 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -527,7 +527,6 @@ - diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py index 8c0ac199dbf..0b7f5feeb3f 100644 --- a/addons/resource/tests/test_resource.py +++ b/addons/resource/tests/test_resource.py @@ -224,6 +224,15 @@ class TestCalendar(TestResourceCommon): leave.unlink() + def test_calendar_working_hours_count(self): + calendar = self.env.ref('resource.resource_calendar_std_35h') + calendar.tz = 'UTC' + res = calendar.get_work_hours_count( + fields.Datetime.from_string('2017-05-03 14:03:00'), # Wednesday (8:00-12:00, 13:00-16:00) + fields.Datetime.from_string('2017-05-04 11:03:00'), # Thursday (8:00-12:00, 13:00-16:00) + compute_leaves=False) + self.assertEqual(res, 5.0) + def test_calendar_working_hours_24(self): self.att_4 = self.env['resource.calendar.attendance'].create({ 'name': 'Att4', diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 2513d6e3e62..f631f59d66f 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -570,6 +570,7 @@ class StockMove(models.Model): moves_to_merge = [] for candidate_moves in candidate_moves_list: # First step find move to merge. + candidate_moves = candidate_moves.with_context(prefetch_fields=False) for k, g in groupby(sorted(candidate_moves, key=self._prepare_merge_move_sort_method), key=itemgetter(*distinct_fields)): moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft')) # If we have multiple records we will merge then in a single one. diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 5f15e641ad7..d51d5ac2035 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -21,7 +21,7 @@ class StockMoveLine(models.Model): help='The stock operation where the packing has been made') move_id = fields.Many2one( 'stock.move', 'Stock Move', - help="Change to a better name") + help="Change to a better name", index=True) product_id = fields.Many2one('product.product', 'Product', ondelete="cascade") product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure', required=True) product_qty = fields.Float( diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index f13f8316977..44079d0f725 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -458,7 +458,7 @@ class StockMove(models.Model): # force the amount in the context, but in the case it is 0 it'll create an entry # for the entire cost of the move. This case happens when the candidates moves # entirely compensate the problematic move. - if not corrected_value: + if move.company_id.currency_id.is_zero(corrected_value): continue if move._is_in(): diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index f87106ec0c2..b31106b151e 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1596,6 +1596,11 @@ var BasicModel = AbstractModel.extend({ record._rawChanges[name] = val; return; } + if (record._rawChanges[name]) { + // if previous _rawChanges exists, clear them since the field is now knwon + // and restoring outdated onchange over posterious change is wrong + delete record._rawChanges[name]; + } var oldValue = name in record._changes ? record._changes[name] : record.data[name]; var id; if (field.type === 'many2one') { @@ -2733,11 +2738,6 @@ var BasicModel = AbstractModel.extend({ relationField: field.relation_field, viewType: view ? view.type : fieldInfo.viewType, }); - // set existing changes to the list - if (record._changes && record._changes[fieldName]) { - list._changes = self.localData[record._changes[fieldName]]._changes; - record._changes[fieldName] = list.id; - } record.data[fieldName] = list.id; if (!fieldInfo.__no_fetch) { var def = self._readUngroupedList(list).then(function () { @@ -3528,7 +3528,7 @@ var BasicModel = AbstractModel.extend({ model: params.modelName, offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0), openGroupByDefault: params.openGroupByDefault, - orderedBy: params.orderedBy || [], + orderedBy: params.orderedBy || (params.context && params.context.orderedBy) || [], orderedResIDs: params.orderedResIDs, parentID: params.parentID, rawContext: params.rawContext, @@ -4230,6 +4230,7 @@ var BasicModel = AbstractModel.extend({ if (options.context !== undefined) { element.context = options.context; + element.orderedBy = options.context.orderedBy || element.orderedBy; } if (options.domain !== undefined) { element.domain = options.domain; diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 3691903c079..955ef2ade39 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -79,6 +79,14 @@ var ListController = BasicController.extend({ return $.Deferred().resolve(); } }, + /* + * @override + */ + getContext: function () { + var context = this._super.apply(this, arguments); + context.orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || []; + return context; + }, /** * Returns the list of currently selected res_ids (with the check boxes on * the left) @@ -199,6 +207,7 @@ var ListController = BasicController.extend({ } else { this.selectedRecords = []; } + params.selectedRecords = this.selectedRecords; return this._super.apply(this, arguments); }, diff --git a/addons/web/static/src/js/views/search/favorites_menu.js b/addons/web/static/src/js/views/search/favorites_menu.js index a40f38d4095..46949e901a3 100644 --- a/addons/web/static/src/js/views/search/favorites_menu.js +++ b/addons/web/static/src/js/views/search/favorites_menu.js @@ -103,9 +103,17 @@ return Widget.extend({ var controllerContext; this.trigger_up('get_controller_context', { callback: function (ctx) { - controllerContext = ctx; + if (ctx && ctx.orderedBy) { + var sort = _.map(ctx.orderedBy, function (obj) { + var order = obj.asc ? 'ASC' : 'DESC'; + return obj.name + ' ' + order; + }); + self.searchview.dataset.set_sort(sort); + } + controllerContext = _.omit(ctx, ['orderedBy']); }, }); + var results = pyUtils.eval_domains_and_contexts({ domains: [], contexts: [user_context].concat(search.contexts.concat(controllerContext || [])), @@ -186,7 +194,18 @@ return Widget.extend({ category: _t("Custom Filter"), icon: 'fa-star', field: { - get_context: function () { return filter.context; }, + get_context: function () { + var sortParsed = JSON.parse(filter.sort || "[]"); + var orderedBy = []; + _.each(sortParsed, function (sort) { + orderedBy.push({ + name: sort[0] === '-' ? sort.slice(1) : sort, + asc: sort[0] === '-' ? false : true, + }); + }); + + return _.defaults({}, filter.context, {orderedBy : orderedBy}); + }, get_groupby: function () { return [filter.context]; }, // facet is not used get_domain: function (facet, noEvaluation) { diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 9c7c6363a40..52f181a24ec 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -10346,6 +10346,95 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('onchange on unloaded record clearing posterious change', function (assert) { + // when we got onchange result for fields of record that were not + // already available because they were in a inline view not already + // opened, in a given configuration the change were applied ignoring + // posteriously changed data, thus an added/removed/modified line could + // be reset to the original onchange data + assert.expect(5); + + var numUserOnchange = 0; + + this.data.user.onchanges = { + partner_ids: function (obj) { + // simulate actual server onchange after save of modal with new record + if (numUserOnchange === 0) { + obj.partner_ids = _.clone(obj.partner_ids); + obj.partner_ids.unshift([5]); + obj.partner_ids[1][2].turtles.unshift([5]); + obj.partner_ids[2] = [1, 2, { + display_name: 'second record', + trululu: 1, + turtles: [[5]], + }]; + } else if (numUserOnchange === 1) { + obj.partner_ids = _.clone(obj.partner_ids); + obj.partner_ids.unshift([5]); + obj.partner_ids[1][2].turtles.unshift([5]); + obj.partner_ids[2][2].turtles.unshift([5]); + } + numUserOnchange++; + }, + }; + + var form = createView({ + View: FormView, + model: 'user', + data: this.data, + arch: '
' + + '' + + ''+ + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 17, + }); + + // open first partner and change turtle name + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_data_row:eq(0)').click(); + $('.modal .o_data_cell:eq(0)').click(); + $('.modal input[name="display_name"]').val('Donatello').change(); + $('.modal .btn-primary').click(); + + // open second partner and add a turtle + form.$('.o_data_row:eq(1)').click(); + $('.modal .o_field_x2many_list_row_add a').click(); + $('.modal input[name="display_name"]').val('Michelangelo').change(); + $('.modal .btn-primary').click(); + + assert.strictEqual(numUserOnchange, 2, + 'there should 2 and only 2 onchange from closing the partner modal'); + + // check first record still has change + form.$('.o_data_row:eq(0)').click(); + assert.strictEqual($('.modal .o_data_row').length, 1, + 'only 1 turtle for first partner'); + assert.strictEqual($('.modal .o_data_row').text(), 'Donatello', + 'first partner turtle is Donatello'); + $('.modal .o_form_button_cancel').click(); + + // check second record still has changes + form.$('.o_data_row:eq(1)').click(); + assert.strictEqual($('.modal .o_data_row').length, 1, + 'only 1 turtle for second partner'); + assert.strictEqual($('.modal .o_data_row').text(), 'Michelangelo', + 'second partner turtle is Michelangelo'); + $('.modal .o_form_button_cancel').click(); + + form.destroy(); + }); + QUnit.test('click on URL should not open the record', function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 030a676f4eb..76f4d3f6808 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -378,6 +378,99 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('ordered list, sort attribute in context', function (assert) { + assert.expect(1); + // Equivalent to saving a custom filter + + this.data.foo.fields.foo.sortable = true; + this.data.foo.fields.date.sortable = true; + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '', + }); + + // Descending order on Foo + list.$('th.o_column_sortable:contains("Foo")').click(); + list.$('th.o_column_sortable:contains("Foo")').click(); + + // Ascending order on Date + list.$('th.o_column_sortable:contains("Date")').click(); + + var listContext = list.getContext(); + assert.deepEqual(listContext, + { + orderedBy: [{ + name: 'date', + asc: true, + }, { + name: 'foo', + asc: false, + }] + }, 'the list should have the right orderedBy in context'); + list.destroy(); + }); + + QUnit.test('Loading a filter with a sort attribute', function (assert) { + assert.expect(2); + + this.data.foo.fields.foo.sortable = true; + this.data.foo.fields.date.sortable = true; + + var searchReads = 0; + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '', + context: { + orderedBy: [{ + name: 'date', + asc: true, + }, { + name: 'foo', + asc: false, + }] + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + if (searchReads === 0) { + assert.strictEqual(args.sort, 'date ASC, foo DESC', + 'The sort attribute of the filter should be used by the initial search_read'); + } else if (searchReads === 1) { + assert.strictEqual(args.sort, 'date DESC, foo ASC', + 'The sort attribute of the filter should be used by the next search_read'); + } + searchReads += 1; + } + return this._super.apply(this,arguments); + }, + }); + + // Simulate loading a filter + list.update({ + context: { + orderedBy: [{ + name: 'date', + asc: false, + }, { + name: 'foo', + asc: true, + }] + } + }); + + list.destroy() + }); + QUnit.test('many2one field rendering', function (assert) { assert.expect(1); diff --git a/addons/web_editor/static/lib/summernote/src/js/EventHandler.js b/addons/web_editor/static/lib/summernote/src/js/EventHandler.js index 578bcfd7a0c..a924e7def8a 100644 --- a/addons/web_editor/static/lib/summernote/src/js/EventHandler.js +++ b/addons/web_editor/static/lib/summernote/src/js/EventHandler.js @@ -365,7 +365,7 @@ define([ (keycode > 185 && keycode < 193) || // ;=,-./` (in order) (keycode > 218 && keycode < 223))) { // [\]' (in order)) eventName = 'visible'; - } else if (!keycode) { + } else if (!keycode && event.key !== 'Dead') { self.invoke('restoreRange', $editable); } // ODOO: end_modification) diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 4e7c350ac06..93915ad93fc 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -56,7 +56,7 @@ class SaleOrder(models.Model): ('date_order', '<=', abandoned_datetime), ('team_id.team_type', '=', 'website'), ('state', '=', 'draft'), - ('partner_id.id', '!=', self.env.ref('base.public_partner').id), + ('partner_id', '!=', self.env.ref('base.public_partner').id), ('order_line', '!=', False) ]) # is_abandoned domain possibilities diff --git a/addons/website_sale_digital/controllers/main.py b/addons/website_sale_digital/controllers/main.py index 94c52ecfded..c9708912a67 100644 --- a/addons/website_sale_digital/controllers/main.py +++ b/addons/website_sale_digital/controllers/main.py @@ -36,6 +36,10 @@ class WebsiteSaleDigital(CustomerPortal): order = response.qcontext['sale_order'] invoiced_lines = request.env['account.invoice.line'].sudo().search([('invoice_id', 'in', order.invoice_ids.ids), ('invoice_id.state', '=', 'paid')]) products = invoiced_lines.mapped('product_id') | order.order_line.filtered(lambda r: not r.price_subtotal).mapped('product_id') + if not order.amount_total: + # in that case, we should add all download links to the products + # since there is nothing to pay, so we shouldn't wait for an invoice + products = order.order_line.mapped('product_id') purchased_products_attachments = {} for product in products: diff --git a/odoo/addons/base/data/res.country.state.csv b/odoo/addons/base/data/res.country.state.csv index b64c8f78f12..1f2c8f1cbb8 100644 --- a/odoo/addons/base/data/res.country.state.csv +++ b/odoo/addons/base/data/res.country.state.csv @@ -551,7 +551,7 @@ state_ae_fu,ae,"Fujairah","FU" state_ae_rk,ae,"Ras al-Khaimah","RK" state_ae_sh,ae,"Sharjah","SH" state_ae_uq,ae,"Umm al-Quwain","UQ" -state_ar_c,ar,"Buenos Aires City","C" +state_ar_c,ar,"Ciudad Autónoma de Buenos Aires","C" state_ar_b,ar,"Buenos Aires","B" state_ar_k,ar,"Catamarca","K" state_ar_h,ar,"Chaco","H" diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py index b89807d23f9..19b5ded55e8 100644 --- a/odoo/addons/base/models/ir_actions_report.py +++ b/odoo/addons/base/models/ir_actions_report.py @@ -44,6 +44,7 @@ def _get_wkhtmltopdf_bin(): # Check the presence of Wkhtmltopdf and return its version at Odoo start-up wkhtmltopdf_state = 'install' +wkhtmltopdf_dpi_zoom_ratio = False try: process = subprocess.Popen( [_get_wkhtmltopdf_bin(), '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE @@ -61,6 +62,8 @@ else: wkhtmltopdf_state = 'upgrade' else: wkhtmltopdf_state = 'ok' + if LooseVersion(version) >= LooseVersion('0.12.2'): + wkhtmltopdf_dpi_zoom_ratio = True if config['workers'] == 1: _logger.info('You need to start Odoo with at least two workers to print a pdf version of the reports.') @@ -246,14 +249,19 @@ class IrActionsReport(models.Model): else: command_args.extend(['--margin-top', str(paperformat_id.margin_top)]) + dpi = None if specific_paperformat_args and specific_paperformat_args.get('data-report-dpi'): - command_args.extend(['--dpi', str(specific_paperformat_args['data-report-dpi'])]) + dpi = int(specific_paperformat_args['data-report-dpi']) elif paperformat_id.dpi: if os.name == 'nt' and int(paperformat_id.dpi) <= 95: _logger.info("Generating PDF on Windows platform require DPI >= 96. Using 96 instead.") - command_args.extend(['--dpi', '96']) + dpi = 96 else: - command_args.extend(['--dpi', str(paperformat_id.dpi)]) + dpi = paperformat_id.dpi + if dpi: + command_args.extend(['--dpi', str(dpi)]) + if wkhtmltopdf_dpi_zoom_ratio: + command_args.extend(['--zoom', str(96.0 / dpi)]) if specific_paperformat_args and specific_paperformat_args.get('data-report-header-spacing'): command_args.extend(['--header-spacing', str(specific_paperformat_args['data-report-header-spacing'])]) diff --git a/odoo/addons/base/models/ir_property.py b/odoo/addons/base/models/ir_property.py index 6b7493bb2e8..59a75458e04 100644 --- a/odoo/addons/base/models/ir_property.py +++ b/odoo/addons/base/models/ir_property.py @@ -18,6 +18,18 @@ TYPE2FIELD = { 'selection': 'value_text', } +TYPE2CLEAN = { + 'boolean': bool, + 'integer': lambda val: val or False, + 'float': lambda val: val or False, + 'char': lambda val: val or False, + 'text': lambda val: val or False, + 'selection': lambda val: val or False, + 'binary': lambda val: val or False, + 'date': lambda val: val or False, + 'datetime': lambda val: val or False, +} + class Property(models.Model): _name = 'ir.property' @@ -203,48 +215,61 @@ class Property(models.Model): if not ids: return {} - domain = self._get_domain(name, model) - if domain is None: - return dict.fromkeys(ids, False) - - # retrieve the values for the given ids and the default value, too - refs = {('%s,%s' % (model, id)): id for id in ids} - refs[False] = False - domain += [('res_id', 'in', list(refs))] - - # note: order by 'company_id asc' will return non-null values first - props = self.search(domain, order='company_id asc') - result = {} - field = self.env[model]._fields[name] + field_id = self.env['ir.model.fields']._get(model, name).id + company_id = ( + self._context.get('force_company') + or self.env['res.company']._company_default_get(model, field_id).id + ) + if field.type == 'many2one': - # optimization for many2one fields - Comodel = self.env[field.comodel_name] - co_ids = set() - for prop in props: - # for a given res_id, take the first property only - id = refs.pop(prop.res_id, None) - if id is not None: - val = prop.value_reference - if val: - val = int(val.split(',')[1]) - co_ids.add(val) - result[id] = val - # check for existence in batch, and update result accordingly - existing = {rec.id: rec for rec in Comodel.browse(co_ids).exists()} - result = {id: existing.get(val, Comodel) for id, val in result.items()} + comodel = self.env[field.comodel_name] + model_pos = len(model) + 2 + value_pos = len(comodel._name) + 2 + # retrieve values: both p.res_id and p.value_reference are formatted + # as ","; the purpose of the LEFT JOIN is to + # return the value id if it exists, NULL otherwise + query = """ + SELECT substr(p.res_id, %s)::integer, r.id + FROM ir_property p + LEFT JOIN {} r ON substr(p.value_reference, %s)::integer=r.id + WHERE p.fields_id=%s + AND (p.company_id=%s OR p.company_id IS NULL) + AND (p.res_id IN %s OR p.res_id IS NULL) + ORDER BY p.company_id NULLS FIRST + """.format(comodel._table) + params = [model_pos, value_pos, field_id, company_id] + clean = comodel.browse + + elif field.type in TYPE2FIELD: + model_pos = len(model) + 2 + # retrieve values: p.res_id is formatted as "," + query = """ + SELECT substr(p.res_id, %s)::integer, p.{} + FROM ir_property p + WHERE p.fields_id=%s + AND (p.company_id=%s OR p.company_id IS NULL) + AND (p.res_id IN %s OR p.res_id IS NULL) + ORDER BY p.company_id NULLS FIRST + """.format(TYPE2FIELD[field.type]) + params = [model_pos, field_id, company_id] + clean = TYPE2CLEAN[field.type] else: - for prop in props: - # for a given res_id, take the first property only - id = refs.pop(prop.res_id, None) - if id is not None: - result[id] = prop.get_by_record() + return dict.fromkeys(ids, False) - # set the default value to the ids that are not in result - default_value = result.pop(False, False) + # retrieve values + cr = self.env.cr + result = {} + refs = {"%s,%s" % (model, id) for id in ids} + for sub_refs in cr.split_for_in_conditions(refs): + cr.execute(query, params + [sub_refs]) + result.update(cr.fetchall()) + + # remove default value, add missing values, and format them + default = result.pop(None, None) for id in ids: - result.setdefault(id, default_value) + result[id] = clean(result.get(id, default)) return result @api.model diff --git a/odoo/addons/test_new_api/models.py b/odoo/addons/test_new_api/models.py index 7d7ad777b44..a35c886b905 100644 --- a/odoo/addons/test_new_api/models.py +++ b/odoo/addons/test_new_api/models.py @@ -408,6 +408,8 @@ class CompanyDependent(models.Model): _description = 'Test New API Company' foo = fields.Char(company_dependent=True) + date = fields.Date(company_dependent=True) + moment = fields.Datetime(company_dependent=True) tag_id = fields.Many2one('test_new_api.multi.tag', company_dependent=True) diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index 9b3da038c5c..1430d5ec978 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -772,20 +772,42 @@ class TestFields(common.TransactionCase): 'value': tag0, 'type': 'many2one'}) # create/modify a record, and check the value for each user - record = self.env['test_new_api.company'].create({'foo': 'main', 'tag_id': tag1}) + record = self.env['test_new_api.company'].create({ + 'foo': 'main', + 'date': '1932-11-09', + 'moment': '1932-11-09 00:00:00', + 'tag_id': tag1.id, + }) record.invalidate_cache() self.assertEqual(record.sudo(user0).foo, 'main') self.assertEqual(record.sudo(user1).foo, 'default') self.assertEqual(record.sudo(user2).foo, 'default') + self.assertEqual(record.sudo(user0).date, '1932-11-09') + self.assertEqual(record.sudo(user1).date, False) + self.assertEqual(record.sudo(user2).date, False) + self.assertEqual(record.sudo(user0).moment, '1932-11-09 00:00:00') + self.assertEqual(record.sudo(user1).moment, False) + self.assertEqual(record.sudo(user2).moment, False) self.assertEqual(record.sudo(user0).tag_id, tag1) self.assertEqual(record.sudo(user1).tag_id, tag0) self.assertEqual(record.sudo(user2).tag_id, tag0) - record.sudo(user1).write({'foo': 'alpha', 'tag_id': tag2.id}) + record.sudo(user1).write({ + 'foo': 'alpha', + 'date': '1932-12-10', + 'moment': '1932-12-10 23:59:59', + 'tag_id': tag2.id, + }) record.invalidate_cache() self.assertEqual(record.sudo(user0).foo, 'main') self.assertEqual(record.sudo(user1).foo, 'alpha') self.assertEqual(record.sudo(user2).foo, 'default') + self.assertEqual(record.sudo(user0).date, '1932-11-09') + self.assertEqual(record.sudo(user1).date, '1932-12-10') + self.assertEqual(record.sudo(user2).date, False) + self.assertEqual(record.sudo(user0).moment, '1932-11-09 00:00:00') + self.assertEqual(record.sudo(user1).moment, '1932-12-10 23:59:59') + self.assertEqual(record.sudo(user2).moment, False) self.assertEqual(record.sudo(user0).tag_id, tag1) self.assertEqual(record.sudo(user1).tag_id, tag2) self.assertEqual(record.sudo(user2).tag_id, tag0) diff --git a/odoo/http.py b/odoo/http.py index f223d684238..d5650e4ef9b 100644 --- a/odoo/http.py +++ b/odoo/http.py @@ -580,6 +580,7 @@ class JsonRequest(WebRequest): super(JsonRequest, self).__init__(*args) self.jsonp_handler = None + self.params = {} args = self.httprequest.args jsonp = args.get('jsonp') diff --git a/odoo/tools/cache.py b/odoo/tools/cache.py index a4f49831d6c..052f89545a2 100644 --- a/odoo/tools/cache.py +++ b/odoo/tools/cache.py @@ -205,16 +205,22 @@ def log_ormcache_stats(sig=None, frame=None): me = threading.currentThread() me_dbname = getattr(me, 'dbname', 'n/a') - entries = defaultdict(int) - for dbname, reg in Registry.registries.items(): - for key in reg.cache: - entries[(dbname,) + key[:2]] += 1 - for key, count in sorted(entries.items()): - dbname, model_name, method = key + + for dbname, reg in sorted(Registry.registries.items()): + # set logger prefix to dbname me.dbname = dbname - stat = STAT[key] - _logger.info("%6d entries, %6d hit, %6d miss, %6d err, %4.1f%% ratio, for %s.%s", - count, stat.hit, stat.miss, stat.err, stat.ratio, model_name, method.__name__) + entries = defaultdict(int) + # beware: we use .keys() on purpose here (reg.cache is not a real dict) + for key in reg.cache.keys(): + entries[key[:2]] += 1 + # show entries sorted by model name, method name + for key in sorted(entries, key=lambda key: (key[0], key[1].__name__)): + model, method = key + stat = STAT[(dbname, model, method)] + _logger.info( + "%6d entries, %6d hit, %6d miss, %6d err, %4.1f%% ratio, for %s.%s", + entries[key], stat.hit, stat.miss, stat.err, stat.ratio, model, method.__name__, + ) me.dbname = me_dbname