diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-01-29 13:17:12 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-01-29 13:17:12 +0100 |
| commit | cd5c8a02f9560b2a8b5c39182adee7ade1bf595c (patch) | |
| tree | c654b3d46020aa30fcc7c2724d200a93a807859b | |
| parent | 6c6ef6588fc374e86acdeeacba2842690b6f1a53 (diff) | |
| parent | 36d96e015032d42449fe149c050df75050276fdc (diff) | |
[MERGE] forward port branch 11.0 up to 36d96e01503
33 files changed, 629 insertions, 129 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 15669281ead..5bea59342b2 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -588,6 +588,8 @@ class AccountBankStatementLine(models.Model): :returns: The journal entries with which the transaction was matched. If there was at least an entry in counterpart_aml_dicts or new_aml_dicts, this list contains the move created by the reconciliation, containing entries for the statement.line (1), the counterpart move lines (0..*) and the new move lines (0..*). """ + payable_account_type = self.env.ref('account.data_account_type_payable') + receivable_account_type = self.env.ref('account.data_account_type_receivable') counterpart_aml_dicts = counterpart_aml_dicts or [] payment_aml_rec = payment_aml_rec or self.env['account.move.line'] new_aml_dicts = new_aml_dicts or [] @@ -608,10 +610,16 @@ class AccountBankStatementLine(models.Model): raise UserError(_('A selected move line was already reconciled.')) if isinstance(aml_dict['move_line'], pycompat.integer_types): aml_dict['move_line'] = aml_obj.browse(aml_dict['move_line']) + + account_types = self.env['account.account.type'] for aml_dict in (counterpart_aml_dicts + new_aml_dicts): if aml_dict.get('tax_ids') and isinstance(aml_dict['tax_ids'][0], pycompat.integer_types): # Transform the value in the format required for One2many and Many2many fields aml_dict['tax_ids'] = [(4, id, None) for id in aml_dict['tax_ids']] + + user_type_id = self.env['account.account'].browse(aml_dict.get('account_id')).user_type_id + if user_type_id in [payable_account_type, receivable_account_type] and user_type_id not in account_types: + account_types |= user_type_id if any(line.journal_entry_ids for line in self): raise UserError(_('A selected statement line was already reconciled with an account move.')) @@ -639,7 +647,9 @@ class AccountBankStatementLine(models.Model): if abs(total)>0.00001: partner_id = self.partner_id and self.partner_id.id or False partner_type = False - if partner_id: + if partner_id and len(account_types) == 1: + partner_type = 'customer' if account_types == receivable_account_type else 'supplier' + if partner_id and not partner_type: if total < 0: partner_type = 'supplier' else: diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 9b32833e8b4..1b7cc78269b 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -148,8 +148,13 @@ class AccountInvoice(models.Model): amount_to_show = line.company_id.currency_id.with_context(date=line.date).compute(abs(line.amount_residual), self.currency_id) if float_is_zero(amount_to_show, precision_rounding=self.currency_id.rounding): continue + if line.ref : + title = '%s : %s' % (line.move_id.name, line.ref) + else: + title = line.move_id.name info['content'].append({ 'journal_name': line.ref or line.move_id.name, + 'title': title, 'amount': amount_to_show, 'currency': currency_id.symbol, 'id': line.id, @@ -1277,6 +1282,20 @@ class AccountInvoice(models.Model): return result @api.model + def _refund_tax_lines_account_change(self, lines, taxes_to_change): + # Let's change the account on tax lines when + # @param {list} lines: a list of orm commands + # @param {dict} taxes_to_change + # key: tax ID, value: refund account + + if not taxes_to_change: + return lines + + for line in lines: + if isinstance(line[2], dict) and line[2]['tax_id'] in taxes_to_change: + line[2]['account_id'] = taxes_to_change[line[2]['tax_id']] + return lines + def _get_refund_common_fields(self): return ['partner_id', 'payment_term_id', 'account_id', 'currency_id', 'journal_id'] @@ -1322,7 +1341,12 @@ class AccountInvoice(models.Model): values['invoice_line_ids'] = self._refund_cleanup_lines(invoice.invoice_line_ids) tax_lines = invoice.tax_line_ids - values['tax_line_ids'] = self._refund_cleanup_lines(tax_lines) + taxes_to_change = { + line.tax_id.id: line.tax_id.refund_account_id.id + for line in tax_lines.filtered(lambda l: l.tax_id.refund_account_id != l.tax_id.account_id) + } + cleaned_tax_lines = self._refund_cleanup_lines(tax_lines) + values['tax_line_ids'] = self._refund_tax_lines_account_change(cleaned_tax_lines, taxes_to_change) if journal_id: journal = self.env['account.journal'].browse(journal_id) diff --git a/addons/account/static/src/xml/account_payment.xml b/addons/account/static/src/xml/account_payment.xml index 0a8abf28d72..02a6dac6657 100644 --- a/addons/account/static/src/xml/account_payment.xml +++ b/addons/account/static/src/xml/account_payment.xml @@ -16,8 +16,8 @@ <td> <a title="assign to invoice" role="button" class="oe_form_field outstanding_credit_assign" t-att-data-id="line.id" style="margin-right: 10px;">Add</a> </td> - <td> - <span class="oe_form_field" style="margin-right: 30px;"><t t-esc="line.journal_name"></t></span> + <td style="max-width: 10em;"> + <div class="oe_form_field" style="margin-right: 30px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;" t-att-title="line.title"><t t-esc="line.journal_name"></t></div> </td> </t> <t t-if="!outstanding"> diff --git a/addons/account/tests/test_account_customer_invoice.py b/addons/account/tests/test_account_customer_invoice.py index 00f7d732699..d071232f38e 100644 --- a/addons/account/tests/test_account_customer_invoice.py +++ b/addons/account/tests/test_account_customer_invoice.py @@ -167,3 +167,59 @@ class TestAccountCustomerInvoice(AccountTestUsers): )) self.assertEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids])) + + def test_customer_invoice_tax_refund(self): + company = self.env.user.company_id + tax_account = self.env['account.account'].create({ + 'name': 'TAX', + 'code': 'TAX', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'company_id': company.id, + }) + + tax_refund_account = self.env['account.account'].create({ + 'name': 'TAX_REFUND', + 'code': 'TAX_R', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'company_id': company.id, + }) + + 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', + 'account_id': tax_account.id, + 'refund_account_id': tax_refund_account.id + }) + + 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])], + } + )] + + invoice = self.env['account.invoice'].create(dict( + name="Test Customer Invoice", + reference_type="none", + journal_id=journalrec.id, + partner_id=partner3.id, + invoice_line_ids=invoice_line_data + )) + + invoice.action_invoice_open() + + refund = invoice.refund() + self.assertEqual(invoice.tax_line_ids.mapped('account_id'), tax_account) + self.assertEqual(refund.tax_line_ids.mapped('account_id'), tax_refund_account) diff --git a/addons/l10n_fr/data/account_chart_template_data.xml b/addons/l10n_fr/data/account_chart_template_data.xml index d184a621db5..6aa50ef5cbf 100644 --- a/addons/l10n_fr/data/account_chart_template_data.xml +++ b/addons/l10n_fr/data/account_chart_template_data.xml @@ -239,7 +239,7 @@ <record id="pcg_120" model="account.account.template"> <field name="name">Résultat de l'exercice (bénéfice)</field> <field name="code">120</field> - <field name="user_type_id" ref="account.data_unaffected_earnings"/> + <field name="user_type_id" ref="account.data_account_type_current_liabilities"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 25ba71e0e75..f82d1ee1cc8 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1210,7 +1210,8 @@ class MailThread(models.AbstractModel): subtype = 'mail.mt_note' if message_dict.get('parent_id'): parent_message = self.env['mail.message'].sudo().browse(message_dict['parent_id']) - partner_ids = [(4, parent_message.author_id.id)] + if parent_message.author_id: + partner_ids = [(4, parent_message.author_id.id)] else: subtype = 'mail.mt_comment' new_msg = thread.message_post(subtype=subtype, partner_ids=partner_ids, **message_dict) diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index 6e66d73ea07..aa537635938 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -277,7 +277,7 @@ var Activity = AbstractActivityField.extend({ $popover.find('#activity_feedback').val(self.feedbackValue[activity_id]); $popover.on('click', '.o_activity_popover_done_next', function () { var feedback = _.escape($popover.find('#activity_feedback').val()); - var previous_activity_type_id = $popover_el.data('previous-activity-type-id'); + var previous_activity_type_id = $popover_el.data('previous-activity-type-id') || false; self._markActivityDone(activity_id, feedback) .then(self.scheduleActivity.bind(self, previous_activity_type_id)); }); diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py index 5eaeb678539..008cdbf2594 100644 --- a/addons/mass_mailing/models/__init__.py +++ b/addons/mass_mailing/models/__init__.py @@ -4,6 +4,7 @@ from . import mass_mailing from . import mass_mailing_stats from . import mail_mail +from . import mail_template from . import mail_thread from . import res_config_settings from . import mass_mailing_report diff --git a/addons/mass_mailing/models/mail_template.py b/addons/mass_mailing/models/mail_template.py new file mode 100644 index 00000000000..68005ee0d10 --- /dev/null +++ b/addons/mass_mailing/models/mail_template.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class MailTemplate(models.Model): + _inherit = "mail.template" + + @api.model + def render_post_process(self, html): + # super will transform relative url to absolute + html = super(MailTemplate, self).render_post_process(html) + + # apply shortener after + if self.env.context.get('post_convert_links'): + html = self.env['link.tracker'].convert_links( + html, + self.env.context['post_convert_links'], + blacklist=['/unsubscribe_from_list'] + ) + return html diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index 9ea923168a1..ed6262e865f 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -643,6 +643,21 @@ class MassMailing(models.Model): _logger.info("Mass-mailing %s targets %s, no blacklist available", self, target._name) return blacklist + def _get_convert_links(self): + self.ensure_one() + utm_mixin = self.mass_mailing_campaign_id if self.mass_mailing_campaign_id else self + vals = {'mass_mailing_id': self.id} + + if self.mass_mailing_campaign_id: + vals['mass_mailing_campaign_id'] = self.mass_mailing_campaign_id.id + if utm_mixin.campaign_id: + vals['campaign_id'] = utm_mixin.campaign_id.id + if utm_mixin.source_id: + vals['source_id'] = utm_mixin.source_id.id + if utm_mixin.medium_id: + vals['medium_id'] = utm_mixin.medium_id.id + return vals + def _get_seen_list(self): """Returns a set of emails already targeted by current mailing/campaign (no duplicates)""" self.ensure_one() @@ -678,6 +693,7 @@ class MassMailing(models.Model): return { 'mass_mailing_blacklist': self._get_blacklist(), 'mass_mailing_seen_list': self._get_seen_list(), + 'post_convert_links': self._get_convert_links(), } def get_recipients(self): @@ -719,13 +735,10 @@ class MassMailing(models.Model): if not res_ids: raise UserError(_('Please select recipients.')) - # Convert links in absolute URLs before the application of the shortener - mailing.body_html = self.env['mail.thread']._replace_local_links(mailing.body_html) - composer_values = { 'author_id': author_id, 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], - 'body': mailing.convert_links()[mailing.id], + 'body': mailing.body_html, 'subject': mailing.name, 'model': mailing.mailing_model_real, 'email_from': mailing.email_from, diff --git a/addons/mass_mailing/tests/__init__.py b/addons/mass_mailing/tests/__init__.py index b02648f85eb..36745d1525a 100644 --- a/addons/mass_mailing/tests/__init__.py +++ b/addons/mass_mailing/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_mass_mailing_list_merge +from . import test_mass_mailing_shortener diff --git a/addons/mass_mailing/tests/test_mass_mailing_shortener.py b/addons/mass_mailing/tests/test_mass_mailing_shortener.py new file mode 100644 index 00000000000..99e3de1e864 --- /dev/null +++ b/addons/mass_mailing/tests/test_mass_mailing_shortener.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common +from lxml import etree + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +class TestMassMailingShortener(common.TransactionCase): + def getHrefFor(self, html, id): + return html.xpath("*[@id='%s']" % id)[0].attrib.get('href') + + def shorturl_to_link(self, short_url): + return self.env['link.tracker.code'].search([('code', '=', short_url.split('/r/')[-1])]).link_id + + def setUp(self): + super(TestMassMailingShortener, self).setUp() + + def _get_title_from_url(u): + return "Hello" + + def _compute_favicon(): + # 1px to avoid real request + return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' + + patcher = patch('odoo.addons.link_tracker.models.link_tracker.link_tracker._compute_favicon', wraps=_compute_favicon) + patcher2 = patch('odoo.addons.link_tracker.models.link_tracker.link_tracker._get_title_from_url', wraps=_get_title_from_url) + patcher.start() + patcher2.start() + self.addCleanup(patcher.stop) + self.addCleanup(patcher2.stop) + + def test_00_test_mass_mailing_shortener(self): + mailing_list_A = self.env['mail.mass_mailing.list'].create({ + 'name': 'A', + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 1', 'email': 'user1@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 2', 'email': 'user2@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 3', 'email': 'user3@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + + mass_mailing = self.env['mail.mass_mailing'].create({ + "reply_to_mode": "email", + "reply_to": "Administrator <admin@yourcompany.example.com>", + "mailing_model_id": self.env.ref('mass_mailing.model_mail_mass_mailing_list').id, + "mailing_domain": "[('list_ids', 'in', [%d])]" % mailing_list_A.id, + "contact_list_ids": [[6, False, [mailing_list_A.id]]], + "mass_mailing_campaign_id": False, + "name": "sdf", + "body_html": """ +Hi, +% set url = "www.odoo.com" +% set httpurl = "https://www.odoo.eu" +Website0: <a id="url0" href="https://www.odoo.tz/my/${object.name}">https://www.odoo.tz/my/${object.name}</h1> +Website1: <a id="url1" href="https://www.odoo.be">https://www.odoo.be</h1> +Website2: <a id="url2" href="https://${url}">https://${url}</h1> +Website3: <a id="url3" href="${httpurl}">${httpurl}</h1> +Email: <a id="url4" href="mailto:test@odoo.com">test@odoo.com</h1> + """, + "schedule_date": False, + "state": "draft", + "keep_archives": True, + }) + + mass_mailing.put_in_queue() + mass_mailing._process_mass_mailing_queue() + + sent_mails = self.env['mail.mail'].search([('mailing_id', '=', mass_mailing.id)]) + sent_messages = sent_mails.mapped('mail_message_id') + + self.assertEqual(mailing_list_A.contact_nbr, len(sent_messages), + 'Some message has not been sent') + + xbody = etree.fromstring(sent_messages[0].body) + after_url0 = self.getHrefFor(xbody, 'url0') + after_url1 = self.getHrefFor(xbody, 'url1') + after_url2 = self.getHrefFor(xbody, 'url2') + after_url3 = self.getHrefFor(xbody, 'url3') + after_url4 = self.getHrefFor(xbody, 'url4') + + self.assertTrue('/r/' in after_url0, 'URL0 should be shortened: %s' % after_url0) + self.assertTrue('/r/' in after_url1, 'URL1 should be shortened: %s' % after_url1) + self.assertTrue('/r/' in after_url2, 'URL2 should be shortened: %s' % after_url2) + self.assertTrue('/r/' in after_url3, 'URL3 should be shortened: %s' % after_url3) + self.assertEqual(after_url4, "mailto:test@odoo.com", 'mailto: has been converted') + + short0 = self.shorturl_to_link(after_url0) + short1 = self.shorturl_to_link(after_url1) + short2 = self.shorturl_to_link(after_url2) + short3 = self.shorturl_to_link(after_url3) + + self.assertTrue("https://www.odoo.tz/my/User" in short0.url, 'URL mismatch') + self.assertEqual(short1.url, "https://www.odoo.be", 'URL mismatch') + self.assertEqual(short2.url, "https://www.odoo.com", 'URL mismatch') + self.assertEqual(short3.url, "https://www.odoo.eu", 'URL mismatch') + + _xbody = etree.fromstring(sent_messages[1].body) + _after_url0 = self.getHrefFor(_xbody, 'url0') + _after_url1 = self.getHrefFor(_xbody, 'url1') + _after_url2 = self.getHrefFor(_xbody, 'url2') + _after_url3 = self.getHrefFor(_xbody, 'url3') + _after_url4 = self.getHrefFor(_xbody, 'url4') + + self.assertTrue('/r/' in _after_url0, 'URL0 should be shortened: %s' % _after_url0) + self.assertTrue('/r/' in _after_url1, 'URL1 should be shortened: %s' % _after_url1) + self.assertTrue('/r/' in _after_url2, 'URL2 should be shortened: %s' % _after_url2) + self.assertTrue('/r/' in _after_url3, 'URL3 should be shortened: %s' % _after_url3) + self.assertEqual(_after_url4, "mailto:test@odoo.com", 'mailto: has been converted') + + _short0 = self.shorturl_to_link(_after_url0) + _short1 = self.shorturl_to_link(_after_url1) + _short2 = self.shorturl_to_link(_after_url2) + _short3 = self.shorturl_to_link(_after_url3) + + self.assertTrue("https://www.odoo.tz/my/User" in _short0.url, 'URL mismatch') + self.assertEqual(_short1.url, "https://www.odoo.be", 'URL mismatch') + self.assertEqual(_short2.url, "https://www.odoo.com", 'URL mismatch') + self.assertEqual(_short3.url, "https://www.odoo.eu", 'URL mismatch') + + self.assertNotEqual(short0.url, _short0.url) + self.assertEqual(short1.url, _short1.url) + self.assertEqual(short2.url, _short2.url) + self.assertEqual(short3.url, _short3.url) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 5f2c296aeab..177324431f6 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -5,7 +5,7 @@ from collections import defaultdict from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.tools import float_compare, float_round class MrpProduction(models.Model): @@ -30,6 +30,10 @@ class MrpProduction(models.Model): location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_src_id if not location: location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id return location and location.id or False @api.model @@ -39,6 +43,10 @@ class MrpProduction(models.Model): location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_dest_id if not location: location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id return location and location.id or False name = fields.Char( @@ -295,6 +303,10 @@ class MrpProduction(models.Model): @api.onchange('picking_type_id') def onchange_picking_type(self): location = self.env.ref('stock.stock_location_stock') + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id self.location_src_id = self.picking_type_id.default_location_src_id.id or location.id self.location_dest_id = self.picking_type_id.default_location_dest_id.id or location.id diff --git a/addons/mrp/models/mrp_unbuild.py b/addons/mrp/models/mrp_unbuild.py index 7695cc0bec2..d3039b52a89 100644 --- a/addons/mrp/models/mrp_unbuild.py +++ b/addons/mrp/models/mrp_unbuild.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.tools import float_compare @@ -13,10 +13,20 @@ class MrpUnbuild(models.Model): _order = 'id desc' def _get_default_location_id(self): - return self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + stock_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + stock_location.check_access_rule('read') + return stock_location.id + except (AttributeError, AccessError): + return self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id.id def _get_default_location_dest_id(self): - return self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + stock_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + stock_location.check_access_rule('read') + return stock_location.id + except (AttributeError, AccessError): + return self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id.id name = fields.Char('Reference', copy=False, readonly=True, default=lambda x: _('New')) product_id = fields.Many2one( diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml index 222ae59dfe0..d89d6ca9e2c 100644 --- a/addons/mrp/views/mrp_unbuild_views.xml +++ b/addons/mrp/views/mrp_unbuild_views.xml @@ -128,8 +128,8 @@ </group> <group> <field name="mo_id" domain="[('state','=','done')]"/> - <field name="location_id" groups="stock.group_stock_multi_locations"/> - <field name="location_dest_id" groups="stock.group_stock_multi_locations"/> + <field name="location_id" groups="stock.group_stock_multi_locations" domain="[('usage','=','internal')]"/> + <field name="location_dest_id" groups="stock.group_stock_multi_locations" domain="[('usage','=','internal')]"/> <field name="has_tracking" invisible="1"/> <field name="lot_id" attrs="{'invisible': [('has_tracking', '=', 'none')], 'required': [('has_tracking', '!=', 'none')]}" groups="stock.group_production_lot"/> </group> diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 9ad975f4bbd..27950760e5b 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -893,12 +893,20 @@ exports.PosModel = Backbone.Model.extend({ transfer.pipe(function(order_server_id){ // generate the pdf and download it - self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{ - active_ids:order_server_id, - }}).done(function () { - invoiced.resolve(); - done.resolve(); - }); + if (order_server_id.length) { + self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{ + active_ids:order_server_id, + }}).done(function () { + invoiced.resolve(); + done.resolve(); + }); + } else { + // The order has been pushed separately in batch when + // the connection came back. + // The user has to go to the backend to print the invoice + invoiced.reject({code:401, message:'Backend Invoice', data:{order: order}}); + done.reject(); + } }); return done; diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 139a05459f7..08262f73336 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -2078,6 +2078,17 @@ var PaymentScreenWidget = ScreenWidget.extend({ self.gui.show_screen('clientlist'); }, }); + } else if (error.message === 'Backend Invoice') { + self.gui.show_popup('confirm',{ + 'title': _t('Please print the invoice from the backend'), + 'body': _t('The order has been synchronized earlier. Please make the invoice from the backend for the order: ') + error.data.order.name, + confirm: function () { + this.gui.show_screen('receipt'); + }, + cancel: function () { + this.gui.show_screen('receipt'); + }, + }); } else if (error.code < 0) { // XmlHttpRequest Errors self.gui.show_popup('error',{ 'title': _t('The order could not be sent'), diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py index 80fc6e0eabb..31fbbabec84 100644 --- a/addons/stock/models/stock_scrap.py +++ b/addons/stock/models/stock_scrap.py @@ -130,7 +130,8 @@ class StockScrap(models.Model): self.package_id, self.owner_id, strict=True).mapped('quantity')) - if float_compare(available_qty, self.scrap_qty, precision_digits=precision) >= 0: + scrap_qty = self.product_uom_id._compute_quantity(self.scrap_qty, self.product_id.uom_id) + if float_compare(available_qty, scrap_qty, precision_digits=precision) >= 0: return self.do_scrap() else: return { diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index 50ae3424060..dd12cbf8a22 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -3574,6 +3574,24 @@ class StockMove(TransactionCase): self.assertEqual(scrap.state, 'done') self.assertEqual(move1.reserved_availability, 0.25) + def test_scrap_6(self): + """ Check that scrap correctly handle UoM. """ + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1) + scrap = self.env['stock.scrap'].create({ + 'product_id': self.product1.id, + 'product_uom_id': self.uom_dozen.id, + 'scrap_qty': 1, + }) + warning_message = scrap.action_validate() + self.assertEqual(warning_message.get('res_model', 'Wrong Model'), 'stock.warn.insufficient.qty.scrap') + insufficient_qty_wizard = self.env['stock.warn.insufficient.qty.scrap'].create({ + 'product_id': self.product1.id, + 'location_id': self.stock_location.id, + 'scrap_id': scrap.id + }) + insufficient_qty_wizard.action_done() + self.assertEqual(self.env['stock.quant']._gather(self.product1, self.stock_location).quantity, -11) + def test_in_date_1(self): """ Check that moving a tracked quant keeps the incoming date. """ diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index b28527c7623..234b3b16b4e 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -212,7 +212,7 @@ <button name="%(act_stock_return_picking)d" string="Return" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('is_locked', '=', False)]}" type="action" groups="base.group_user"/> <button name="action_cancel" attrs="{'invisible': ['|', ('state', 'not in', ('assigned', 'confirmed', 'partially_available', 'draft', 'waiting')), ('is_locked', '=', False)]}" string="Cancel" groups="base.group_user" type="object"/> <field name="picking_type_code" invisible="1"/> - <button name="do_unreserve" string="Unreserve" groups="base.group_user" type="object" attrs="{'invisible': ['|', '|', ('picking_type_code', '=', 'incoming'), ('state', 'not in', ('assigned', 'partially_available')), ('is_locked', '=', False)]}"/> + <button name="do_unreserve" string="Unreserve" groups="base.group_user" type="object" attrs="{'invisible': ['|', '|', '|', ('picking_type_code', '=', 'incoming'), ('is_locked', '=', False), '&', ('state', 'not in', ('assigned', 'partially_available')), ('move_type', '!=', 'one'), '&', ('state', 'not in', ('assigned', 'partially_available', 'confirmed')), ('move_type', '=', 'one')]}"/> <field name="show_operations" invisible="1" readonly="1"/> <field name="move_line_exist" invisible="1"/> <field name="has_packages" invisible="1"/> diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index 8a32c6237e7..6a9f88e39a8 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -182,23 +182,38 @@ class ProductProduct(models.Model): StockMove = self.env['stock.move'] to_date = self.env.context.get('to_date') - self.env['account.move.line'].check_access_rights('read') - fifo_automated_values = {} - query = """SELECT aml.product_id, aml.account_id, sum(aml.debit) - sum(aml.credit), sum(quantity), array_agg(aml.id) - FROM account_move_line AS aml - WHERE aml.product_id IS NOT NULL AND aml.company_id=%%s %s - GROUP BY aml.product_id, aml.account_id""" - params = (self.env.user.company_id.id,) + real_time_product_ids = [product.id for product in self if product.product_tmpl_id.valuation == 'real_time'] + if real_time_product_ids: + self.env['account.move.line'].check_access_rights('read') + fifo_automated_values = {} + query = """SELECT aml.product_id, aml.account_id, sum(aml.debit) - sum(aml.credit), sum(quantity), array_agg(aml.id) + FROM account_move_line AS aml + WHERE aml.product_id IN %%s AND aml.company_id=%%s %s + GROUP BY aml.product_id, aml.account_id""" + params = (tuple(real_time_product_ids), self.env.user.company_id.id) + if to_date: + query = query % ('AND aml.date <= %s',) + params = params + (to_date,) + else: + query = query % ('',) + self.env.cr.execute(query, params=params) + + res = self.env.cr.fetchall() + for row in res: + fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4])) + + product_values = {product: 0 for product in self} + product_move_ids = {product: [] for product in self} if to_date: - query = query % ('AND aml.date <= %s',) - params = params + (to_date,) + domain = [('product_id', 'in', self.ids), ('date', '<=', to_date)] + StockMove._get_all_base_domain() + for move in StockMove.search(domain).with_context(prefetch_fields=False): + product_values[move.product_id] += move.value + product_move_ids[move.product_id].append(move.id) else: - query = query % ('',) - self.env.cr.execute(query, params=params) - - res = self.env.cr.fetchall() - for row in res: - fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4])) + domain = [('product_id', 'in', self.ids)] + StockMove._get_all_base_domain() + for move in StockMove.search(domain).with_context(prefetch_fields=False): + product_values[move.product_id] += move.remaining_value + product_move_ids[move.product_id].append(move.id) for product in self: if product.cost_method in ['standard', 'average']: @@ -214,11 +229,9 @@ class ProductProduct(models.Model): elif product.cost_method == 'fifo': if to_date: if product.product_tmpl_id.valuation == 'manual_periodic': - domain = [('product_id', '=', product.id), ('date', '<=', to_date)] + StockMove._get_all_base_domain() - moves = StockMove.search(domain) - product.stock_value = sum(moves.mapped('value')) + product.stock_value = product_values[product] product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available - product.stock_fifo_manual_move_ids = StockMove.browse(moves.ids) + product.stock_fifo_manual_move_ids = StockMove.browse(product_move_ids[product]) elif product.product_tmpl_id.valuation == 'real_time': valuation_account_id = product.categ_id.property_stock_valuation_account_id.id value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, []) @@ -226,7 +239,7 @@ class ProductProduct(models.Model): product.qty_at_date = quantity product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids) else: - product.stock_value, moves = product._sum_remaining_values() + product.stock_value, moves = product_values[product], StockMove.browse(product_move_ids[product]) product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available if product.product_tmpl_id.valuation == 'manual_periodic': product.stock_fifo_manual_move_ids = moves diff --git a/addons/web/static/src/js/chrome/search_filters.js b/addons/web/static/src/js/chrome/search_filters.js index 582af7e70ef..4628578f9fe 100644 --- a/addons/web/static/src/js/chrome/search_filters.js +++ b/addons/web/static/src/js/chrome/search_filters.js @@ -180,8 +180,10 @@ var DateTime = Field.extend({ operators: [ {value: "=", text: _lt("is equal to")}, {value: "!=", text: _lt("is not equal to")}, - {value: ">=", text: _lt("is after")}, - {value: "<=", text: _lt("is before")}, + {value: ">", text: _lt("is after")}, + {value: "<", text: _lt("is before")}, + {value: ">=", text: _lt("is after or equal")}, + {value: "<=", text: _lt("is before or equal")}, {value: "between", text: _lt("is between")}, {value: "∃", text: _lt("is set")}, {value: "∄", text: _lt("is not set")} diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js index f3bfa87341c..383247a5afb 100644 --- a/addons/web/static/src/js/services/crash_manager.js +++ b/addons/web/static/src/js/services/crash_manager.js @@ -22,6 +22,7 @@ var map_title ={ var CrashManager = core.Class.extend({ init: function() { this.active = true; + this.isConnected = true; }, enable: function () { this.active = true; @@ -29,8 +30,29 @@ var CrashManager = core.Class.extend({ disable: function () { this.active = false; }, - rpc_error: function(error) { + handleLostConnection: function () { var self = this; + if (!this.isConnected) { + // already handled, nothing to do. This can happen when several + // rpcs are done in parallel and fail because of a lost connection. + return; + } + this.isConnected = false; + var delay = 2000; + core.bus.trigger('connection_lost'); + + setTimeout(function checkConnection() { + ajax.jsonRpc('/web/webclient/version_info', 'call', {}, {shadow:true}).then(function () { + core.bus.trigger('connection_restored'); + self.isConnected = true; + }).fail(function () { + // exponential backoff, with some jitter + delay = (delay * 1.5) + 500*Math.random(); + setTimeout(checkConnection, delay); + }); + }, delay); + }, + rpc_error: function(error) { if (!this.active) { return; } @@ -38,16 +60,7 @@ var CrashManager = core.Class.extend({ return; } if (error.code === -32098) { - core.bus.trigger('connection_lost'); - this.connection_lost = true; - var timeinterval = setInterval(function() { - var options = {shadow: true}; - ajax.jsonRpc('/web/webclient/version_info', 'call', {}, options).then(function () { - clearInterval(timeinterval); - core.bus.trigger('connection_restored'); - self.connection_lost = false; - }); - }, 2000); + this.handleLostConnection(); return; } var handler = core.crash_registry.get(error.data.name, true); diff --git a/addons/web/static/src/less/navbar.less b/addons/web/static/src/less/navbar.less index cf14f9a8615..b280ad45173 100644 --- a/addons/web/static/src/less/navbar.less +++ b/addons/web/static/src/less/navbar.less @@ -155,7 +155,6 @@ position: relative; height: @odoo-navbar-height; - overflow: hidden; > ul { > li { @@ -171,6 +170,7 @@ &.o_menu_sections { width: 100%; + display: none; > li.open .dropdown-menu { position: static; @@ -219,6 +219,10 @@ .o_main_navbar { height: 100%; overflow: auto; + + .o_menu_sections { + display: block; + } } } } diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 3d938517b29..0950ce513da 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -50,11 +50,16 @@ class Web_Editor(http.Controller): kwargs.update(trans) - record = None - if model and kwargs.get('res_id'): - record = request.env[model].with_context(trans).browse(kwargs.get('res_id')) + content = None + if model: + Model = request.env[model].with_context(trans) + if kwargs.get('res_id'): + record = Model.browse(kwargs.get('res_id')) + content = record and getattr(record, field) + else: + content = Model.default_get([field]).get(field) - kwargs.update(content=record and getattr(record, field) or "") + kwargs.update(content=content or '') return request.render(kwargs.get("template") or "web_editor.FieldTextHtml", kwargs, uid=request.uid) diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 5352704b7e4..67907fd367e 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -986,7 +986,7 @@ class WebsiteSale(http.Controller): @http.route(['/shop/get_unit_price'], type='json', auth="public", methods=['POST'], website=True) def get_unit_price(self, product_ids, add_qty, **kw): - products = request.env['product.product'].with_context({'quantity': add_qty}).browse(product_ids) + products = request.env['product.product'].with_context(quantity=add_qty).browse(product_ids) return {product.id: product.website_price / add_qty for product in products} # ------------------------------------------------------ diff --git a/addons/website_sale_comparison/controllers/main.py b/addons/website_sale_comparison/controllers/main.py index 18c98f639e2..4c4bf5fc923 100644 --- a/addons/website_sale_comparison/controllers/main.py +++ b/addons/website_sale_comparison/controllers/main.py @@ -3,6 +3,7 @@ from odoo import http, _ from odoo.http import request from odoo.addons.website_sale.controllers.main import WebsiteSale import json +from collections import OrderedDict class WebsiteSaleProductComparison(WebsiteSale): @@ -19,12 +20,12 @@ class WebsiteSaleProductComparison(WebsiteSale): res = {} for num, product in enumerate(products): - for var in product.attribute_line_ids: + for var in product.attribute_line_ids.sorted(lambda x: x.attribute_id.sequence): cat_name = var.attribute_id.category_id.name or _('Uncategorized') att_name = var.attribute_id.name if not product.attribute_value_ids: # create_variant = False continue - res.setdefault(cat_name, {}).setdefault(att_name, [' - '] * len(products)) + res.setdefault(cat_name, OrderedDict()).setdefault(att_name, [' - '] * len(products)) val = product.attribute_value_ids.filtered(lambda x: x.attribute_id == var.attribute_id) res[cat_name][att_name][num] = val[0].name values['specs'] = res diff --git a/addons/website_sale_comparison/models/website_sale_comparison.py b/addons/website_sale_comparison/models/website_sale_comparison.py index 5a1ea69ca84..f19692efe81 100644 --- a/addons/website_sale_comparison/models/website_sale_comparison.py +++ b/addons/website_sale_comparison/models/website_sale_comparison.py @@ -26,6 +26,6 @@ class ProductTemplate(models.Model): def get_variant_groups(self): res = OrderedDict() - for var in self.attribute_line_ids: + for var in self.attribute_line_ids.sorted(lambda x: x.attribute_id.sequence): res.setdefault(var.attribute_id.category_id.name or _('Uncategorized'), []).append(var) return res diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 2ef722104b5..599f68deaec 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -182,7 +182,7 @@ class WebsiteSlides(http.Controller): @http.route('''/slides/slide/<model("slide.slide"):slide>/download''', type='http', auth="public", website=True, sitemap=False) def slide_download(self, slide, **kw): slide = slide.sudo() - if slide.download_security == 'public' or (slide.download_security == 'user' and request.session.uid): + if slide.download_security == 'public' or (slide.download_security == 'user' and request.env.user and request.env.user != request.website.user_id): filecontent = base64.b64decode(slide.datas) disposition = 'attachment; filename=%s.pdf' % werkzeug.urls.url_quote(slide.name) return request.make_response( diff --git a/odoo/addons/base/tests/__init__.py b/odoo/addons/base/tests/__init__.py index 069afe0cec1..a0099045a80 100644 --- a/odoo/addons/base/tests/__init__.py +++ b/odoo/addons/base/tests/__init__.py @@ -2,6 +2,7 @@ from . import test_acl from . import test_api from . import test_base from . import test_basecase +from . import test_cache from . import test_db_cursor from . import test_expression from . import test_float diff --git a/odoo/addons/base/tests/test_cache.py b/odoo/addons/base/tests/test_cache.py new file mode 100644 index 00000000000..3c8fbd9f603 --- /dev/null +++ b/odoo/addons/base/tests/test_cache.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import os +import psutil + +from odoo.tests.common import TransactionCase + + +class TestRecordCache(TransactionCase): + + def test_cache(self): + """ Check the record cache object. """ + Model = self.env['res.partner'] + name = type(Model).name + ref = type(Model).ref + + cache = self.env.cache + + def check1(record, field, value): + # value is None means no value in cache + self.assertEqual(cache.contains(record, field), value is not None) + self.assertEqual(cache.contains_value(record, field), value is not None) + self.assertEqual(cache.get_value(record, field), value) + try: + self.assertEqual(cache.get(record, field), value) + self.assertIsNotNone(value) + except KeyError: + self.assertIsNone(value) + self.assertIsNone(cache.get_special(record, field)) + self.assertEqual(field in cache.get_fields(record), value is not None) + self.assertEqual(record in cache.get_records(record, field), value is not None) + + def check(record, name_val, ref_val): + """ check the values of fields 'name' and 'ref' on record. """ + check1(record, name, name_val) + check1(record, ref, ref_val) + + foo1, bar1 = Model.browse([1, 2]) + foo2, bar2 = Model.sudo(self.env.ref('base.user_demo')).browse([1, 2]) + self.assertNotEqual(foo1.env.uid, foo2.env.uid) + + # cache is empty + cache.invalidate() + check(foo1, None, None) + check(foo2, None, None) + check(bar1, None, None) + check(bar2, None, None) + self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), [1, 2]) + 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') + check(foo2, None, None) + check(bar1, 'NAME1', 'REF1') + 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') + 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') + self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), [1]) + self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), []) + + # partial invalidation + cache.invalidate([(name, None), (ref, foo1.ids)]) + check(foo1, None, None) + check(foo2, None, None) + check(bar1, None, 'REF1') + check(bar2, None, 'REF2') + + # total invalidation + cache.invalidate() + check(foo1, None, None) + check(foo2, None, None) + check(bar1, None, None) + check(bar2, None, None) + + # set a special value + cache.set_special(foo1, name, lambda: '42') + self.assertTrue(cache.contains(foo1, name)) + self.assertFalse(cache.contains_value(foo1, name)) + self.assertEqual(cache.get(foo1, name), '42') + 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') + 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') + + def test_memory(self): + """ Check memory consumption of the cache. """ + NB_RECORDS = 100000 + MAX_MEMORY = 100 + + cache = self.env.cache + model = self.env['res.partner'] + records = [model.new() for index in range(NB_RECORDS)] + + process = psutil.Process(os.getpid()) + rss0 = process.memory_info().rss + + char_names = [ + 'name', 'display_name', 'email', 'website', 'phone', 'mobile', + 'street', 'street2', 'city', 'zip', 'vat', 'ref', + ] + for name in char_names: + field = model._fields[name] + for record in records: + cache.set(record, field, 'test') + + mem_usage = process.memory_info().rss - rss0 + self.assertLess( + mem_usage, MAX_MEMORY * 1024 * 1024, + "Caching %s records must take less than %sMB of memory" % (NB_RECORDS, MAX_MEMORY), + ) diff --git a/odoo/api.py b/odoo/api.py index 075d9ac3cab..9b904c497f3 100644 --- a/odoo/api.py +++ b/odoo/api.py @@ -741,6 +741,7 @@ class Environment(Mapping): self.cr, self.uid, self.context = self.args = (cr, uid, frozendict(context)) self.registry = Registry(cr.dbname) self.cache = envs.cache + self._cache_key = (cr, uid) self._protected = StackMap() # {field: ids, ...} self.dirty = defaultdict(set) # {record: set(field_name), ...} self.all = envs @@ -925,6 +926,12 @@ class Environment(Mapping): finally: self.all.recompute = tmp + def cache_key(self, field): + """ Return the key to store the value of ``field`` in cache, the full + cache key being ``(key, field, record.id)``. + """ + return self if field.context_dependent else self._cache_key + class Environments(object): """ A common object for all environments in a request. """ @@ -947,52 +954,52 @@ class Environments(object): class Cache(object): """ Implementation of the cache of records. """ def __init__(self): - # {field: {record_id: {key: value}}} + # {key: {field: {record_id: value}}} self._data = defaultdict(lambda: defaultdict(dict)) def contains(self, record, field): """ Return whether ``record`` has a value for ``field``. """ - key = field.cache_key(record) - return key in self._data[field].get(record.id, ()) + key = record.env.cache_key(field) + return record.id in self._data[key].get(field, ()) def get(self, record, field): """ Return the value of ``field`` for ``record``. """ - key = field.cache_key(record) - value = self._data[field][record.id][key] + key = record.env.cache_key(field) + value = self._data[key][field][record.id] return value.get() if isinstance(value, SpecialValue) else value def set(self, record, field, value): """ Set the value of ``field`` for ``record``. """ - key = field.cache_key(record) - self._data[field][record.id][key] = value + key = record.env.cache_key(field) + self._data[key][field][record.id] = value def remove(self, record, field): """ Remove the value of ``field`` for ``record``. """ - key = field.cache_key(record) - del self._data[field][record.id][key] + key = record.env.cache_key(field) + del self._data[key][field][record.id] def contains_value(self, record, field): """ Return whether ``record`` has a regular value for ``field``. """ - key = field.cache_key(record) - value = self._data[field][record.id].get(key, SpecialValue(None)) + key = record.env.cache_key(field) + value = self._data[key][field].get(record.id, SpecialValue(None)) return not isinstance(value, SpecialValue) def get_value(self, record, field, default=None): """ Return the regular value of ``field`` for ``record``. """ - key = field.cache_key(record) - value = self._data[field][record.id].get(key, SpecialValue(None)) + key = record.env.cache_key(field) + value = self._data[key][field].get(record.id, SpecialValue(None)) return default if isinstance(value, SpecialValue) else value def get_special(self, record, field, default=None): """ Return the special value of ``field`` for ``record``. """ - key = field.cache_key(record) - value = self._data[field][record.id].get(key) + key = record.env.cache_key(field) + value = self._data[key][field].get(record.id) return value.get if isinstance(value, SpecialValue) else default def set_special(self, record, field, getter): """ Set the value of ``field`` for ``record`` to return ``getter()``. """ - key = field.cache_key(record) - self._data[field][record.id][key] = SpecialValue(getter) + key = record.env.cache_key(field) + self._data[key][field][record.id] = SpecialValue(getter) def set_failed(self, records, fields, exception): """ Mark ``fields`` on ``records`` with the given exception. """ @@ -1005,67 +1012,66 @@ class Cache(object): def get_fields(self, record): """ Return the fields with a value for ``record``. """ for name, field in record._fields.items(): - key = field.cache_key(record) - if name != 'id' and key in self._data[field].get(record.id, ()): + key = record.env.cache_key(field) + if name != 'id' and record.id in self._data[key].get(field, ()): yield field def get_records(self, model, field): """ Return the records of ``model`` that have a value for ``field``. """ - key = field.cache_key(model) - # optimization: do not field.cache_key(record) for each record in cache - ids = [ - record_id - for record_id, field_record_cache in self._data[field].items() - if key in field_record_cache - ] + key = model.env.cache_key(field) + ids = list(self._data[key][field]) return model.browse(ids) def get_missing_ids(self, records, field): """ Return the ids of ``records`` that have no value for ``field``. """ - key = field.cache_key(records) - field_cache = self._data[field] + key = records.env.cache_key(field) + field_cache = self._data[key][field] for record_id in records._ids: - if key not in field_cache.get(record_id, ()): + if record_id not in field_cache: yield record_id def copy(self, records, env): """ Copy the cache of ``records`` to ``env``. """ - src = records - dst = records.with_env(env) - for field, field_cache in self._data.items(): - src_key = field.cache_key(src) - dst_key = field.cache_key(dst) - for record_cache in field_cache.values(): - if src_key in record_cache and not isinstance(record_cache[src_key], SpecialValue): - # But not if it's a SpecialValue, which often is an access error - # because the other environment (eg. sudo()) is well expected to have access. - record_cache[dst_key] = record_cache[src_key] + src, dst = records.env, env + for src_key, dst_key in [(src, dst), (src._cache_key, dst._cache_key)]: + if src_key == dst_key: + break + src_cache = self._data[src_key] + dst_cache = self._data[dst_key] + for field, src_field_cache in src_cache.items(): + dst_field_cache = dst_cache[field] + for record_id, value in src_field_cache.items(): + if not isinstance(value, SpecialValue): + # But not if it's a SpecialValue, which often is an access error + # because the other environment (eg. sudo()) is well expected to have access. + dst_field_cache[record_id] = value def invalidate(self, spec=None): """ Invalidate the cache, partially or totally depending on ``spec``. """ if spec is None: self._data.clear() elif spec: - data = self._data for field, ids in spec: if ids is None: - data.pop(field, None) + for data in self._data.values(): + data.pop(field, None) else: - field_cache = data[field] - for id in ids: - field_cache.pop(id, None) + for data in self._data.values(): + field_cache = data.get(field) + if field_cache: + for id in ids: + field_cache.pop(id, None) def check(self, env): """ Check the consistency of the cache for the given environment. """ # make a full copy of the cache, and invalidate it dump = defaultdict(dict) - for field, field_cache in self._data.items(): - browse = env[field.model_name].browse - for record_id, field_record_cache in field_cache.items(): - if record_id: - key = field.cache_key(browse(record_id)) - if key in field_record_cache: - dump[field][record_id] = field_record_cache[key] + for key in [env, env._cache_key]: + key_cache = self._data[key] + for field, field_cache in key_cache.items(): + for record_id, value in field_cache.items(): + if record_id: + dump[field][record_id] = value self.invalidate() diff --git a/odoo/fields.py b/odoo/fields.py index d39fa6e35fa..36d815215ff 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -769,16 +769,6 @@ class Field(MetaField('DummyField', (object,), {})): # Conversion of values # - def cache_key(self, record): - """ Return the key to get/set the value of ``self`` on ``record`` in - cache, the full cache key being ``(self, record.id, key)``. - """ - env = record.env - # IMPORTANT: odoo.api.Cache.get_records() depends on the fact that the - # result does not depend on record.id. If you ever make the following - # dependent on record.id, don't forget to fix the other method! - return env if self.context_dependent else (env.cr, env.uid) - def null(self, record): """ Return the null value for this field in the record format. """ return False |
