diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py
index 5703e018548..7b8e176c9ac 100644
--- a/addons/auth_signup/models/res_partner.py
+++ b/addons/auth_signup/models/res_partner.py
@@ -27,16 +27,16 @@ class ResPartner(models.Model):
signup_token = fields.Char(copy=False, groups="base.group_erp_manager")
signup_type = fields.Char(string='Signup Token Type', copy=False, groups="base.group_erp_manager")
signup_expiration = fields.Datetime(copy=False, groups="base.group_erp_manager")
- signup_valid = fields.Boolean(compute='_compute_signup_valid', compute_sudo=True, string='Signup Token is Valid')
+ signup_valid = fields.Boolean(compute='_compute_signup_valid', string='Signup Token is Valid')
signup_url = fields.Char(compute='_compute_signup_url', string='Signup URL')
@api.multi
@api.depends('signup_token', 'signup_expiration')
def _compute_signup_valid(self):
dt = now()
- for partner in self:
- partner.signup_valid = bool(partner.signup_token) and \
- (not partner.signup_expiration or dt <= partner.signup_expiration)
+ for partner, partner_sudo in zip(self, self.sudo()):
+ partner.signup_valid = bool(partner_sudo.signup_token) and \
+ (not partner_sudo.signup_expiration or dt <= partner_sudo.signup_expiration)
@api.multi
def _compute_signup_url(self):
diff --git a/addons/base_automation/data/base_automation_demo.xml b/addons/base_automation/data/base_automation_demo.xml
index 246320ecf4a..5ffdc94d6a3 100644
--- a/addons/base_automation/data/base_automation_demo.xml
+++ b/addons/base_automation/data/base_automation_demo.xml
@@ -71,4 +71,19 @@ if 'user_id' in env.context['old_values'][record.id]:
+
+ Template Automation
+
+ <div>Email automation</div>
+
+
+ Base Automation: test send an email
+
+
+ email
+ on_write
+
+ [('deadline', '!=', False)]
+ [('deadline', '=', False)]
+
diff --git a/addons/base_automation/models/base_automation.py b/addons/base_automation/models/base_automation.py
index ac3e6305c6b..a482d7d5911 100644
--- a/addons/base_automation/models/base_automation.py
+++ b/addons/base_automation/models/base_automation.py
@@ -161,14 +161,17 @@ class BaseAutomation(models.Model):
return records
def _filter_post(self, records):
+ return self._filter_post_export_domain(records)[0]
+
+ def _filter_post_export_domain(self, records):
""" Filter the records that satisfy the postcondition of action ``self``. """
if self.filter_domain and records:
domain = [('id', 'in', records.ids)] + safe_eval(self.filter_domain, self._get_eval_context())
- return records.search(domain)
+ return records.search(domain), domain
else:
- return records
+ return records, None
- def _process(self, records):
+ def _process(self, records, domain_post=None):
""" Process action ``self`` on the ``records`` that have not been done yet. """
# filter out the records on which self has already been done
action_done = self._context['__action_done']
@@ -193,7 +196,12 @@ class BaseAutomation(models.Model):
# execute server actions
if self.action_server_id:
for record in records:
- ctx = {'active_model': record._name, 'active_ids': record.ids, 'active_id': record.id}
+ ctx = {
+ 'active_model': record._name,
+ 'active_ids': record.ids,
+ 'active_id': record.id,
+ 'domain_post': domain_post,
+ }
self.action_server_id.with_context(**ctx).run()
@api.model_cr
@@ -247,7 +255,8 @@ class BaseAutomation(models.Model):
_write.origin(records, vals, **kw)
# check postconditions, and execute actions on the records that satisfy them
for action in actions.with_context(old_values=old_values):
- action._process(action._filter_post(pre[action]))
+ records, domain_post = action._filter_post_export_domain(pre[action])
+ action._process(records, domain_post=domain_post)
return True
return _write
diff --git a/addons/base_automation/tests/test_base_automation.py b/addons/base_automation/tests/test_base_automation.py
index d71423f4e69..c3fcf6e7ad5 100644
--- a/addons/base_automation/tests/test_base_automation.py
+++ b/addons/base_automation/tests/test_base_automation.py
@@ -1,5 +1,6 @@
# # -*- coding: utf-8 -*-
# # Part of Odoo. See LICENSE file for full copyright and licensing details.
+from unittest.mock import patch
from odoo.tests import common
@@ -9,13 +10,14 @@ class base_automation_test(common.TransactionCase):
def setUp(self):
super(base_automation_test, self).setUp()
- self.user_admin = self.env.ref('base.user_root')
+ self.user_root = self.env.ref('base.user_root')
+ self.user_admin = self.env.ref('base.user_admin')
self.user_demo = self.env.ref('base.user_demo')
def create_lead(self, **kwargs):
vals = {
'name': "Lead Test",
- 'user_id': self.user_admin.id,
+ 'user_id': self.user_root.id,
}
vals.update(kwargs)
return self.env['base.automation.lead.test'].create(vals)
@@ -27,7 +29,7 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open')
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
def test_01_check_to_state_draft_post(self):
"""
@@ -49,15 +51,15 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
# change state to pending and check that responsible has not changed
lead.write({'state': 'pending'})
self.assertEqual(lead.state, 'pending', "Lead state should be 'pending'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
# change state to done and check that responsible has not changed
lead.write({'state': 'done'})
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
def test_03_check_from_draft_to_done_without_steps(self):
"""
@@ -70,7 +72,7 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
# change state to done and check that responsible has changed
lead.write({'state': 'done'})
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
@@ -85,7 +87,7 @@ class base_automation_test(common.TransactionCase):
partner.write({'customer': False})
lead = self.create_lead(state='open', partner_id=partner.id)
self.assertFalse(lead.customer, "Customer field should updated to False")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
# change partner, recompute on lead should trigger the rule
partner.write({'customer': True})
self.assertTrue(lead.customer, "Customer field should updated to True")
@@ -101,15 +103,52 @@ class base_automation_test(common.TransactionCase):
self.assertFalse(lead.deadline, 'There should not be a deadline defined')
# change priority and user; this triggers deadline recomputation, and
# the server action should set the boolean field to True
- lead.write({'priority': True, 'user_id': self.user_admin.id})
+ lead.write({'priority': True, 'user_id': self.user_root.id})
self.assertTrue(lead.deadline, 'Deadline should be defined')
self.assertTrue(lead.is_assigned_to_admin, 'Lead should be assigned to admin')
+ def test_11b_recomputed_field(self):
+ mail_automation = self.env.ref('base_automation.test_rule_on_write_recompute_send_email')
+ send_mail_count = 0
+
+ def _patched_get_actions(*args, **kwargs):
+ obj = args[0]
+ if '__action_done' not in obj._context:
+ obj = obj.with_context(__action_done={})
+ return mail_automation.with_env(obj.env)
+
+ def _patched_send_mail(*args, **kwargs):
+ nonlocal send_mail_count
+ send_mail_count += 1
+
+ patchers = [
+ patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._get_actions', _patched_get_actions),
+ patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail),
+ ]
+
+ patchers[0].start()
+
+ lead = self.create_lead()
+ self.assertFalse(lead.priority)
+ self.assertFalse(lead.deadline)
+
+ patchers[1].start()
+
+ lead.write({'priority': True})
+
+ self.assertTrue(lead.priority)
+ self.assertTrue(lead.deadline)
+
+ for patcher in patchers:
+ patcher.stop()
+
+ self.assertEqual(send_mail_count, 1)
+
def test_12_recursive(self):
""" Check that a rule is executed recursively by a secondary change. """
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open')
- self.assertEqual(lead.user_id, self.user_admin)
+ self.assertEqual(lead.user_id, self.user_root)
# change partner; this should trigger the rule that modifies the state
partner = self.env.ref('base.res_partner_1')
lead.write({'partner_id': partner.id})
diff --git a/addons/base_import/static/csv/o2m_customers_contacts.csv b/addons/base_import/static/csv/o2m_customers_contacts.csv
index bf142142d3c..d29a600f18b 100644
--- a/addons/base_import/static/csv/o2m_customers_contacts.csv
+++ b/addons/base_import/static/csv/o2m_customers_contacts.csv
@@ -1,9 +1,9 @@
Name,Is a company,Related company,Address type,Customer,Supplier,Street,ZIP,City,State,Country
-Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,CA,United States
-Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,CA,United States
-House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,CA,United States
-Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,CA,United States
-Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,CA,United States
-Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,CA,United States
-International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,DC,United States
-Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,DC,United States
+Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,California,United States
+Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,California,United States
+House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,California,United States
+Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,California,United States
+Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,California,United States
+Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,California,United States
+International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,D.C.,United States
+Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,D.C.,United States
diff --git a/addons/bus/static/src/js/longpolling_bus.js b/addons/bus/static/src/js/longpolling_bus.js
index a24dcb593fd..750db65eba2 100644
--- a/addons/bus/static/src/js/longpolling_bus.js
+++ b/addons/bus/static/src/js/longpolling_bus.js
@@ -32,31 +32,34 @@ var LongpollingBus = Bus.extend(ServicesMixin, {
init: function (parent, params) {
this._super.apply(this, arguments);
this._id = _.uniqueId('bus');
+
+ // the _id is modified by crosstab_bus, so we can't use it to unbind the events in the destroy.
+ this._longPollingBusId = this._id;
this._options = {};
this._channels = [];
// bus presence
this._lastPresenceTime = new Date().getTime();
this._lastPartnersPresenceCheck = this._lastPresenceTime;
- $(window).on("focus." + this._id, this._onFocusChange.bind(this, {focus: true}));
- $(window).on("blur." + this._id, this._onFocusChange.bind(this, {focus: false}));
- $(window).on("unload." + this._id, this._onFocusChange.bind(this, {focus: false}));
+ $(window).on("focus." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: true}));
+ $(window).on("blur." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: false}));
+ $(window).on("unload." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: false}));
- $(window).on("click." + this._id, this._onPresence.bind(this));
- $(window).on("keydown." + this._id, this._onPresence.bind(this));
- $(window).on("keyup." + this._id, this._onPresence.bind(this));
+ $(window).on("click." + this._longPollingBusId, this._onPresence.bind(this));
+ $(window).on("keydown." + this._longPollingBusId, this._onPresence.bind(this));
+ $(window).on("keyup." + this._longPollingBusId, this._onPresence.bind(this));
},
/**
* @override
*/
destroy: function () {
this.stopPolling();
- $(window).off("focus." + this._id);
- $(window).off("blur." + this._id);
- $(window).off("unload." + this._id);
- $(window).off("click." + this._id);
- $(window).off("keydown." + this._id);
- $(window).off("keyup." + this._id);
+ $(window).off("focus." + this._longPollingBusId);
+ $(window).off("blur." + this._longPollingBusId);
+ $(window).off("unload." + this._longPollingBusId);
+ $(window).off("click." + this._longPollingBusId);
+ $(window).off("keydown." + this._longPollingBusId);
+ $(window).off("keyup." + this._longPollingBusId);
this._super();
},
//--------------------------------------------------------------------------
diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py
index 1b15093de03..fac1730bead 100644
--- a/addons/event_sale/models/sale_order.py
+++ b/addons/event_sale/models/sale_order.py
@@ -73,6 +73,11 @@ class SaleOrderLine(models.Model):
if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id):
self.event_ticket_id = None
+ @api.onchange('product_uom', 'product_uom_qty')
+ def product_uom_change(self):
+ if not self.event_ticket_id:
+ super(SaleOrderLine, self).product_uom_change()
+
@api.onchange('event_ticket_id')
def _onchange_event_ticket_id(self):
company = self.event_id.company_id or self.env.user.company_id
diff --git a/addons/gamification/models/badge.py b/addons/gamification/models/badge.py
index 6686b29fee5..9997b443d33 100644
--- a/addons/gamification/models/badge.py
+++ b/addons/gamification/models/badge.py
@@ -231,7 +231,7 @@ class GamificationBadge(models.Model):
elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids:
return self.USER_NOT_VIP
elif self.rule_auth == 'having':
- all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)])
+ all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)]).mapped('badge_id')
if self.rule_auth_badge_ids - all_user_badges:
return self.BADGE_REQUIRED
diff --git a/addons/hr_fleet/controllers/i18n/az.po b/addons/hr_fleet/controllers/i18n/az.po
deleted file mode 100644
index 23fa2458e4d..00000000000
--- a/addons/hr_fleet/controllers/i18n/az.po
+++ /dev/null
@@ -1,115 +0,0 @@
-# Translation of Odoo Server.
-# This file contains the translation of the following modules:
-# * crm_project
-#
-msgid ""
-msgstr ""
-"Project-Id-Version: Odoo Server saas~11.5\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2018-09-21 13:17+0000\n"
-"PO-Revision-Date: 2018-08-24 09:17+0000\n"
-"Language-Team: Azerbaijani (https://www.transifex.com/odoo/teams/41243/az/)\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: \n"
-"Language: az\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid ""
-"Convert To\n"
-" Task"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Cancel"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid "Convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Create Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Create a new customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
-msgid "Created by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
-msgid "Created on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
-msgid "Customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
-msgid "Display Name"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Do not link to a customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
-msgid "ID"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
-msgid "Last Modified on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
-msgid "Last Updated by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
-msgid "Last Updated on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
-msgid "Lead"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model,name:crm_project.model_crm_lead_convert2task
-msgid "Lead convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Link to an existing customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
-msgid "Project"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
-msgid "Related Customer"
-msgstr ""
diff --git a/addons/hr_fleet/controllers/i18n/fil.po b/addons/hr_fleet/controllers/i18n/fil.po
deleted file mode 100644
index f3c56bf488c..00000000000
--- a/addons/hr_fleet/controllers/i18n/fil.po
+++ /dev/null
@@ -1,115 +0,0 @@
-# Translation of Odoo Server.
-# This file contains the translation of the following modules:
-# * crm_project
-#
-msgid ""
-msgstr ""
-"Project-Id-Version: Odoo Server saas~11.5\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2018-09-21 13:17+0000\n"
-"PO-Revision-Date: 2018-08-24 09:17+0000\n"
-"Language-Team: Filipino (https://www.transifex.com/odoo/teams/41243/fil/)\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: \n"
-"Language: fil\n"
-"Plural-Forms: nplurals=2; plural=(n == 1 || n==2 || n==3) || (n % 10 != 4 || n % 10 != 6 || n % 10 != 9);\n"
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid ""
-"Convert To\n"
-" Task"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Cancel"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid "Convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Create Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Create a new customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
-msgid "Created by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
-msgid "Created on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
-msgid "Customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
-msgid "Display Name"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Do not link to a customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
-msgid "ID"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
-msgid "Last Modified on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
-msgid "Last Updated by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
-msgid "Last Updated on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
-msgid "Lead"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model,name:crm_project.model_crm_lead_convert2task
-msgid "Lead convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Link to an existing customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
-msgid "Project"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
-msgid "Related Customer"
-msgstr ""
diff --git a/addons/hr_gamification/wizard/gamification_badge_user_wizard.py b/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
index 4e486a0e048..7c4c174129f 100644
--- a/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
+++ b/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
@@ -10,7 +10,7 @@ class GamificationBadgeUserWizard(models.TransientModel):
employee_id = fields.Many2one('hr.employee', string='Employee', required=True)
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id',
- store=False, readonly=True)
+ store=False, readonly=True, compute_sudo=True)
@api.multi
def action_grant_badge(self):
diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 9d24ea47916..cded512698d 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -111,13 +111,12 @@ class HolidaysRequest(models.Model):
"\nThe status is 'Approved', when leave request is approved by manager.")
payslip_status = fields.Boolean('Reported in last payslips', help='Green this button when the leave has been taken into account in the payslip.')
report_note = fields.Text('HR Comments')
- user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
+ user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, compute_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
# leave type configuration
holiday_status_id = fields.Many2one(
"hr.leave.type", string="Leave Type", required=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
domain=[('valid', '=', True)])
- leave_type_request_unit = fields.Selection(related='holiday_status_id.request_unit', readonly=True)
validation_type = fields.Selection('Validation Type', related='holiday_status_id.validation_type', readonly=False)
# HR data
employee_id = fields.Many2one(
diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py
index 5c128df5af3..840537796e4 100644
--- a/addons/hr_holidays/models/hr_leave_allocation.py
+++ b/addons/hr_holidays/models/hr_leave_allocation.py
@@ -252,7 +252,7 @@ class HolidaysAllocation(models.Model):
if self.env.user.employee_ids:
self.department_id = self.department_id or self.env.user.employee_ids[0].department_id
self.employee_id = None
- elif self.holiday_type == 'category':
+ else:
self.employee_id = None
self.department_id = None
diff --git a/addons/hr_holidays/report/hr_leave_reports.xml b/addons/hr_holidays/report/hr_leave_reports.xml
index f818b8fbec6..94502e269ea 100644
--- a/addons/hr_holidays/report/hr_leave_reports.xml
+++ b/addons/hr_holidays/report/hr_leave_reports.xml
@@ -77,7 +77,7 @@
-
+
diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py
index 8dad86eae39..e6351430615 100644
--- a/addons/hr_holidays/tests/test_leave_requests.py
+++ b/addons/hr_holidays/tests/test_leave_requests.py
@@ -138,7 +138,7 @@ class TestLeaveRequests(TestHrHolidaysBase):
'number_of_days': 1,
})
- @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
+ @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.models')
def test_accrual_validity_time_not_valid(self):
""" Employee ask leav during a not valid validity time """
with self.assertRaises(ValidationError):
diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py
index c606ce0972c..2215764fdf4 100644
--- a/addons/l10n_fr_fec/wizard/account_fr_fec.py
+++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py
@@ -76,6 +76,27 @@ class AccountFrFec(models.TransientModel):
listrow = list(row)
return listrow
+ def _get_company_legal_data(self, company):
+ """
+ Dom-Tom are excluded from the EU's fiscal territory
+ Those regions do not have SIREN
+ sources:
+ https://www.service-public.fr/professionnels-entreprises/vosdroits/F23570
+ http://www.douane.gouv.fr/articles/a11024-tva-dans-les-dom
+ """
+ dom_tom_group = self.env.ref('l10n_fr.dom-tom')
+ is_dom_tom = company.country_id.code in dom_tom_group.country_ids.mapped('code')
+ if not is_dom_tom and not company.vat:
+ raise Warning(
+ _("Missing VAT number for company %s") % company.name)
+ if not is_dom_tom and company.vat[0:2] != 'FR':
+ raise Warning(
+ _("FEC is for French companies only !"))
+
+ return {
+ 'siren': company.vat[4:13] if not is_dom_tom else '',
+ }
+
@api.multi
def generate_fec(self):
self.ensure_one()
@@ -87,6 +108,9 @@ class AccountFrFec(models.TransientModel):
# 2) CSV files are easier to read/use for a regular accountant.
# So it will be easier for the accountant to check the file before
# sending it to the fiscal administration
+ company = self.env.user.company_id
+ company_legal_data = self._get_company_legal_data(company)
+
header = [
u'JournalCode', # 0
u'JournalLib', # 1
@@ -108,18 +132,7 @@ class AccountFrFec(models.TransientModel):
u'Idevise', # 17
]
- company = self.env.user.company_id
- if not company.vat:
- raise Warning(
- _("Missing VAT number for company %s") % company.name)
- if company.vat[0:2] != 'FR':
- raise Warning(
- _("FEC is for French companies only !"))
-
- fecfile = io.BytesIO()
- w = pycompat.csv_writer(fecfile, delimiter='|')
- w.writerow(header)
-
+ rows_to_write = [header]
# INITIAL BALANCE
unaffected_earnings_xml_ref = self.env.ref('account.data_unaffected_earnings')
unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once
@@ -195,7 +208,8 @@ class AccountFrFec(models.TransientModel):
else:
listrow[11] = '0,00'
listrow[12] = str(-listrow_amount).replace('.', ',')
- w.writerow(listrow)
+ rows_to_write.append(listrow)
+
#if the unaffected earnings account wasn't in the selection yet: add it manually
if (not unaffected_earnings_line
and unaffected_earnings_results
@@ -206,7 +220,7 @@ class AccountFrFec(models.TransientModel):
if unaffected_earnings_account:
unaffected_earnings_results[4] = unaffected_earnings_account.code
unaffected_earnings_results[5] = unaffected_earnings_account.name
- w.writerow(unaffected_earnings_results)
+ rows_to_write.append(unaffected_earnings_results)
# INITIAL BALANCE - receivable/payable
sql_query = '''
@@ -264,7 +278,7 @@ class AccountFrFec(models.TransientModel):
for row in self._cr.fetchall():
listrow = list(row)
account_id = listrow.pop()
- w.writerow(listrow)
+ rows_to_write.append(listrow)
# LINES
sql_query = '''
@@ -329,20 +343,19 @@ class AccountFrFec(models.TransientModel):
sql_query, (self.date_from, self.date_to, company.id))
for row in self._cr.fetchall():
- w.writerow(list(row))
+ rows_to_write.append(list(row))
- siren = company.vat[4:13]
+ fecvalue = self._csv_write_rows(rows_to_write)
end_date = fields.Date.to_string(self.date_to).replace('-', '')
suffix = ''
if self.export_type == "nonofficial":
suffix = '-NONOFFICIAL'
- fecvalue = fecfile.getvalue()
+
self.write({
'fec_data': base64.encodestring(fecvalue),
# Filename = FECYYYYMMDD where YYYMMDD is the closing date
- 'filename': '%sFEC%s%s.csv' % (siren, end_date, suffix),
+ 'filename': '%sFEC%s%s.csv' % (company_legal_data['siren'], end_date, suffix),
})
- fecfile.close()
action = {
'name': 'FEC',
@@ -351,3 +364,29 @@ class AccountFrFec(models.TransientModel):
'target': 'self',
}
return action
+
+ def _csv_write_rows(self, rows, lineterminator=u'\r\n'):
+ """
+ Write FEC rows into a file
+ It seems that Bercy's bureaucracy is not too happy about the
+ empty new line at the End Of File.
+
+ @param {list(list)} rows: the list of rows. Each row is a list of strings
+ @param {unicode string} [optional] lineterminator: effective line terminator
+ Has nothing to do with the csv writer parameter
+ The last line written won't be terminated with it
+
+ @return the value of the file
+ """
+ fecfile = io.BytesIO()
+ writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='')
+
+ rows_length = len(rows)
+ for i, row in enumerate(rows):
+ if not i == rows_length - 1:
+ row.append(lineterminator)
+ writer.writerow(row)
+
+ fecvalue = fecfile.getvalue()
+ fecfile.close()
+ return fecvalue
diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py
index 4bde8505d5c..08ecb01a665 100644
--- a/addons/l10n_multilang/models/l10n_multilang.py
+++ b/addons/l10n_multilang/models/l10n_multilang.py
@@ -87,11 +87,20 @@ class AccountChartTemplate(models.Model):
return self.process_translations(langs, field, in_ids, out_ids)
def _get_template_from_model(self, company_id, model):
+ """ Find the records and their matching template """
+ # genereated records have an external id with the format _
out_data = self.env['ir.model.data'].search([('model', '=', model), ('name', '=like', str(company_id)+'\_%')])
- out_ids = self.env[model].search([('id', 'in', out_data.mapped('res_id'))], order='id')
+ out_ids = self.env[model].browse(out_data.mapped('res_id'))
+
+ # templates and records may have been created in a different order
+ # reorder them based on external id names
in_xml_id_names = [xml_id.partition(str(company_id) + '_')[-1] for xml_id in out_data.mapped('name')]
- in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', in_xml_id_names)])
- in_ids = self.env[model+'.template'].search([('id', 'in', in_xml_ids.mapped('res_id'))], order='id')
+ in_xml_id_names = {name: index for index, name in enumerate(in_xml_id_names)}
+
+ in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', list(in_xml_id_names))])
+ in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
+
+ in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id'))
return (in_ids, out_ids)
class BaseLanguageInstall(models.TransientModel):
diff --git a/addons/lunch/views/lunch_orders_views.xml b/addons/lunch/views/lunch_orders_views.xml
index 8cd4658a8cb..33201b5fea8 100644
--- a/addons/lunch/views/lunch_orders_views.xml
+++ b/addons/lunch/views/lunch_orders_views.xml
@@ -139,7 +139,7 @@
-
+
diff --git a/addons/mail/models/ir_actions.py b/addons/mail/models/ir_actions.py
index bfecd028152..40582b1d803 100644
--- a/addons/mail/models/ir_actions.py
+++ b/addons/mail/models/ir_actions.py
@@ -80,18 +80,27 @@ class ServerActions(models.Model):
def _is_recompute(self, action):
"""When an activity is set on update of a record,
update might be triggered many times by recomputes.
- When need to know it to skip these steps."""
+ When need to know it to skip these steps.
+ Except if the computed field is supposed to trigger the action
+ """
records = self.env[action.model_id.model].browse(
self._context.get('active_ids', self._context.get('active_id')))
old_values = action._context.get('old_values')
if old_values:
- field_names = list(old_values[records[0].id])
- field = records._fields[field_names[0]]
- # Pick an arbitrary field; if it is marked to be recomputed,
- # it means we are in an extraneous write triggered by the recompute.
- # In this case, we should not create a new activity.
- if records._recompute_check(field):
- return True
+ domain_post = action._context.get('domain_post')
+ tracked_fields = []
+ if domain_post:
+ for leaf in domain_post:
+ if isinstance(leaf, (tuple, list)):
+ tracked_fields.append(leaf[0])
+ fields_to_check = [field for record, field_names in old_values.items() for field in field_names if field not in tracked_fields]
+ if fields_to_check:
+ field = records._fields[fields_to_check[0]]
+ # Pick an arbitrary field; if it is marked to be recomputed,
+ # it means we are in an extraneous write triggered by the recompute.
+ # In this case, we should not create a new activity.
+ if records._recompute_check(field):
+ return True
return False
@api.model
diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py
index 893b6ea78af..1efffbf061f 100644
--- a/addons/mail/models/mail_activity.py
+++ b/addons/mail/models/mail_activity.py
@@ -121,9 +121,9 @@ class MailActivity(models.Model):
activity_type_id = fields.Many2one(
'mail.activity.type', 'Activity',
domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]", ondelete='restrict')
- activity_category = fields.Selection(related='activity_type_id.category', readonly=False)
- activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=False)
- icon = fields.Char('Icon', related='activity_type_id.icon', readonly=False)
+ activity_category = fields.Selection(related='activity_type_id.category', readonly=True)
+ activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=True)
+ icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True)
summary = fields.Char('Summary')
note = fields.Html('Note')
feedback = fields.Html('Feedback')
@@ -145,8 +145,8 @@ class MailActivity(models.Model):
('today', 'Today'),
('planned', 'Planned')], 'State',
compute='_compute_state')
- recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type")
- previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type')
+ recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type", readonly=True)
+ previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type', readonly=True)
has_recommended_activities = fields.Boolean(
'Next activities available',
compute='_compute_has_recommended_activities',
@@ -258,6 +258,12 @@ class MailActivity(models.Model):
activity.user_id.display_name)
else:
try:
+ target_user = activity.user_id
+ target_record = self.env[activity.res_model].browse(activity.res_id)
+ if hasattr(target_record, 'company_id') and (
+ target_record.company_id != target_user.company_id and (
+ len(target_user.company_ids) > 1)):
+ return # in that case we skip the check, assuming it would fail because of the company
model.browse(activity.res_id).check_access_rule('read')
except exceptions.AccessError:
raise exceptions.UserError(
diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js
index c76c6279861..87261c14b6c 100644
--- a/addons/mail/static/src/js/composers/mention_manager.js
+++ b/addons/mail/static/src/js/composers/mention_manager.js
@@ -53,7 +53,7 @@ var MentionManager = Widget.extend({
var listener = _.findWhere(this._listeners, { delimiter: delimiter });
if (listener) {
var escapedVal = _.escape(this._composer.$input.val());
- var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= | )', 'g'));
+ var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= | |$)', 'g'));
return this._validateSelection(listener.selection, inputMentions);
}
return [];
@@ -166,9 +166,8 @@ var MentionManager = Widget.extend({
listener.model,
listener.delimiter,
matchName);
- var subtext = s.substring(startIndex, endIndex)
- .replace(match[0], processedText);
- substrings.push(subtext);
+ substrings.push(s.substring(startIndex, match.index));
+ substrings.push(processedText);
startIndex = endIndex;
}
substrings.push(s.substring(startIndex, s.length));
@@ -258,7 +257,7 @@ var MentionManager = Widget.extend({
// create the regex of all mention's names
var names = _.pluck(listener.selection, 'name');
var escapedNames = _.map(names, function (str) {
- return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
+ return "("+_.str.escapeRegExp(listener.delimiter+str)+")(?= | |$)";
});
var regexStr = escapedNames.join('|');
// extract matches
diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js
index cbac2c3b417..c80ac0110ba 100644
--- a/addons/mail/static/src/js/thread_widget.js
+++ b/addons/mail/static/src/js/thread_widget.js
@@ -466,7 +466,7 @@ var ThreadWidget = Widget.extend({
return message.getID() === messageID;
});
return QWeb.render('mail.widget.Thread.Message.MailTooltip', {
- data: message.getCustomerEmailData()
+ data: message.hasCustomerEmailData() ? message.getCustomerEmailData() : [],
});
},
});
diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js
index d9d563a1ce2..9b2db263689 100644
--- a/addons/mail/static/tests/discuss_tests.js
+++ b/addons/mail/static/tests/discuss_tests.js
@@ -520,6 +520,85 @@ QUnit.test('@ mention with special chars', function (assert) {
});
});
+QUnit.test('@ mention with removed space', function (assert) {
+ assert.expect(4);
+ var done = assert.async();
+ var fetchListenersDef = $.Deferred();
+ var receiveMessageDef = $.Deferred();
+ this.data.initMessaging = {
+ channel_slots: {
+ channel_channel: [{
+ id: 1,
+ channel_type: "channel",
+ name: "general",
+ }],
+ },
+ };
+ var objectDiscuss;
+ createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return $.when([
+ {id: 1, name: 'Admin'},
+ ]);
+ }
+ if (args.method === 'message_post') {
+ assert.deepEqual(args.kwargs.partner_ids, [],
+ "mentioned partners are sent to server"
+ )
+ var data = {
+ author_id: ["42", "Me"],
+ body: args.kwargs.body,
+ channel_ids: [1],
+ };
+ var notification = [[false, 'mail.channel', 1], data];
+ objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
+ receiveMessageDef.resolve();
+ return $.when(42);
+ }
+ return this._super.apply(this, arguments);
+ },
+ })
+ .then(function (discuss) {
+ objectDiscuss = discuss;
+ var $general = discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]');
+ // click on general
+ $general.click();
+ var $input = discuss.$('textarea.o_composer_text_field').first();
+ assert.ok($input.length, "should display a composer input");
+ // Note: focus is needed in order to trigger rpc 'channel_fetch_listeners'
+ $input.focus();
+ $input.val("@");
+ $input.trigger('keyup');
+ fetchListenersDef
+ .then(concurrency.delay.bind(concurrency, 0))
+ .then(function () {
+ // equivalent to $mentionPropositions.find('active').click();
+ $input.trigger($.Event('keyup', {which: $.ui.keyCode.ENTER}));
+ $input.val('@Admin: hi');
+ // send message
+ $input.trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
+ receiveMessageDef
+ .then(concurrency.delay.bind(concurrency, 0))
+ .then(function () {
+ assert.containsNone(discuss, '.o_thread_message_content a',
+ "should not contain a link in the message content");
+ assert.strictEqual(discuss.$('.o_thread_message_content').html().trim(),
+ "@Admin: hi", "should not have linked mention without space");
+ discuss.destroy();
+ done();
+ });
+ });
+ });
+});
+
QUnit.test('@ mention in mailing channel', function (assert) {
assert.expect(8);
var done = assert.async();
diff --git a/addons/maintenance/models/maintenance.py b/addons/maintenance/models/maintenance.py
index acb6455e83d..440be988f42 100644
--- a/addons/maintenance/models/maintenance.py
+++ b/addons/maintenance/models/maintenance.py
@@ -302,7 +302,7 @@ class MaintenanceRequest(models.Model):
maintenance_type = fields.Selection([('corrective', 'Corrective'), ('preventive', 'Preventive')], string='Maintenance Type', default="corrective")
schedule_date = fields.Datetime('Scheduled Date', help="Date the maintenance team plans the maintenance. It should not differ much from the Request Date. ")
maintenance_team_id = fields.Many2one('maintenance.team', string='Team', required=True, default=_get_default_team_id)
- duration = fields.Float(help="Duration in minutes and seconds.")
+ duration = fields.Float(help="Duration in hours and minutes.")
@api.multi
def archive_equipment_request(self):
diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py
index 601576c4bf0..e8a904f2d37 100644
--- a/addons/mass_mailing/models/mass_mailing.py
+++ b/addons/mass_mailing/models/mass_mailing.py
@@ -4,10 +4,12 @@
import hashlib
import hmac
import logging
+import lxml
import random
import re
import threading
from ast import literal_eval
+from base64 import b64encode
from odoo import api, fields, models, tools, _, SUPERUSER_ID
from odoo.exceptions import UserError
@@ -26,6 +28,10 @@ MASS_MAILING_BUSINESS_MODELS = [
'mail.mass_mailing.contact'
]
+# Syntax of the data URL Scheme: https://tools.ietf.org/html/rfc2397#section-3
+# Used to find inline images
+image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)")
+
class MassMailingTag(models.Model):
"""Model of categories of mass mailing, i.e. marketing, newsletter, ... """
@@ -662,6 +668,53 @@ class MassMailing(models.Model):
if len(record_lists) > 0:
record.sudo().message_post(body=_(message % ', '.join(str(list.name) for list in record_lists.mapped('list_id'))))
+ @api.model
+ def create(self, values):
+ if values.get('body_html'):
+ values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
+ return super(MassMailing, self).create(values)
+
+ def write(self, values):
+ if values.get('body_html'):
+ values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
+ return super(MassMailing, self).write(values)
+
+ def _convert_inline_images_to_urls(self, body_html):
+ """
+ Find inline base64 encoded images, make an attachement out of
+ them and replace the inline image with an url to the attachement.
+ """
+
+ def _image_to_url(b64image: bytes):
+ """Store an image in an attachement and returns an url"""
+ attachment = self.env['ir.attachment'].create({
+ 'name': "cropped_image",
+ 'datas': b64image,
+ 'datas_fname': "cropped_image_mailing_{}".format(self.id),
+ 'type': 'binary',})
+
+ attachment.generate_access_token()
+
+ return '/web/image/%s?access_token=%s' % (
+ attachment.id, attachment.access_token)
+
+
+ modified = False
+ root = lxml.html.fromstring(body_html)
+ for node in root.iter('img'):
+ match = image_re.match(node.attrib.get('src', ''))
+ if match:
+ mime = match.group(1) # unsed
+ image = match.group(2).encode() # base64 image as bytes
+
+ node.attrib['src'] = _image_to_url(image)
+ modified = True
+
+ if modified:
+ return lxml.html.tostring(root)
+
+ return body_html
+
#------------------------------------------------------
# Views & Actions
#------------------------------------------------------
diff --git a/addons/mass_mailing/views/unsubscribe_templates.xml b/addons/mass_mailing/views/unsubscribe_templates.xml
index dfad695abf6..f26436a98d1 100644
--- a/addons/mass_mailing/views/unsubscribe_templates.xml
+++ b/addons/mass_mailing/views/unsubscribe_templates.xml
@@ -46,7 +46,7 @@
diff --git a/addons/portal/wizard/portal_share.py b/addons/portal/wizard/portal_share.py
index 8474f2c810c..0da5ce44a7b 100644
--- a/addons/portal/wizard/portal_share.py
+++ b/addons/portal/wizard/portal_share.py
@@ -10,9 +10,9 @@ class PortalShare(models.TransientModel):
@api.model
def default_get(self, fields):
result = super(PortalShare, self).default_get(fields)
- record = self.env[result['res_model']].browse(result['res_id'])
result['res_model'] = self._context.get('active_model')
result['res_id'] = self._context.get('active_id')
+ record = self.env[result['res_model']].browse(result['res_id'])
result['share_link'] = record.get_base_url() + record._get_share_url(redirect=True)
return result
diff --git a/addons/portal/wizard/portal_wizard.py b/addons/portal/wizard/portal_wizard.py
index ee5b82eecda..2f17441f451 100644
--- a/addons/portal/wizard/portal_wizard.py
+++ b/addons/portal/wizard/portal_wizard.py
@@ -35,7 +35,7 @@ class PortalWizard(models.TransientModel):
contact_ids = set()
user_changes = []
for partner in self.env['res.partner'].sudo().browse(partner_ids):
- contact_partners = partner.child_ids or [partner]
+ contact_partners = partner.child_ids | partner
for contact in contact_partners:
# make sure that each contact appears at most once in the list
if contact.id not in contact_ids:
diff --git a/addons/product/models/product.py b/addons/product/models/product.py
index ef770c8ac38..7a711f801e6 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -277,7 +277,11 @@ class ProductProduct(models.Model):
if isinstance(value, str):
value = value.encode('ascii')
image = tools.image_resize_image_big(value)
- if self.product_tmpl_id.image:
+
+ # This is needed because when there is only one variant, the user
+ # doesn't know there is a difference between template and variant, he
+ # expects both images to be the same.
+ if self.product_tmpl_id.image and self.product_variant_count > 1:
self.image_variant = image
else:
self.product_tmpl_id.image = image
@@ -336,6 +340,8 @@ class ProductProduct(models.Model):
# When a unique variant is created from tmpl then the standard price is set by _set_standard_price
if not (self.env.context.get('create_from_tmpl') and len(product.product_tmpl_id.product_variant_ids) == 1):
product._set_standard_price(vals.get('standard_price') or 0.0)
+ # `_get_variant_id_for_combination` depends on existing variants
+ self.clear_caches()
return products
@api.multi
@@ -344,6 +350,13 @@ class ProductProduct(models.Model):
res = super(ProductProduct, self).write(values)
if 'standard_price' in values:
self._set_standard_price(values['standard_price'])
+ if 'attribute_value_ids' in values:
+ # `_get_variant_id_for_combination` depends on `attribute_value_ids`
+ self.clear_caches()
+ if 'active' in values:
+ # prefetched o2m have to be reloaded (because of active_test)
+ # (eg. product.template: product_variant_ids)
+ self.invalidate_cache()
return res
@api.multi
@@ -364,6 +377,8 @@ class ProductProduct(models.Model):
# delete templates after calling super, as deleting template could lead to deleting
# products due to ondelete='cascade'
unlink_templates.unlink()
+ # `_get_variant_id_for_combination` depends on existing variants
+ self.clear_caches()
return res
@api.multi
@@ -652,6 +667,10 @@ class ProductProduct(models.Model):
We must make sure that all attributes are used to take into account the case where
attributes would be added to the template.
+ This method does not check if the combination is possible, it just
+ checks if it has valid attributes and values. A possible combination
+ is always valid, but a valid combination is not always possible.
+
:param valid_attributes: a recordset of product.attribute
:param valid_values: a recordset of product.attribute.value
:return: True if the attibutes and values are correct, False instead
diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py
index ed1f70b4bc4..32f86b79eea 100644
--- a/addons/product/models/product_attribute.py
+++ b/addons/product/models/product_attribute.py
@@ -44,7 +44,13 @@ class ProductAttribute(models.Model):
if products:
message = ', '.join(products.mapped('name'))
raise UserError(_('You are trying to change the type of an attribute value still referenced on at least one product template: %s') % message)
- return super(ProductAttribute, self).write(vals)
+ invalidate_cache = 'sequence' in vals and any(record.sequence != vals['sequence'] for record in self)
+ res = super(ProductAttribute, self).write(vals)
+ if invalidate_cache:
+ # prefetched o2m have to be resequenced
+ # (eg. product.template: attribute_line_ids)
+ self.invalidate_cache()
+ return res
@api.multi
def _get_related_product_templates(self):
@@ -78,6 +84,16 @@ class ProductAttributeValue(models.Model):
def _variant_name(self, variable_attributes):
return ", ".join([v.name for v in self if v.attribute_id in variable_attributes])
+ @api.multi
+ def write(self, values):
+ invalidate_cache = 'sequence' in values and any(record.sequence != values['sequence'] for record in self)
+ res = super(ProductAttributeValue, self).write(values)
+ if invalidate_cache:
+ # prefetched o2m have to be resequenced
+ # (eg. product.template.attribute.line: value_ids)
+ self.invalidate_cache()
+ return res
+
@api.multi
def unlink(self):
linked_products = self._get_related_product_templates()
@@ -136,8 +152,8 @@ class ProductTemplateAttributeLine(models.Model):
for product_template_attribute_line in self:
product_template_attribute_line.product_template_value_ids = self.env['product.template.attribute.value'].search([
('product_tmpl_id', 'in', product_template_attribute_line.product_tmpl_id.ids),
- ('product_attribute_value_id.attribute_id', 'in', product_template_attribute_line.value_ids.mapped('attribute_id').ids)]
- ).sorted(lambda product_product_attribute: product_product_attribute.sequence)
+ ('product_attribute_value_id', 'in', product_template_attribute_line.value_ids.ids)]
+ )
@api.multi
def unlink(self):
@@ -246,9 +262,9 @@ class ProductTemplateAttributeExclusion(models.Model):
_description = 'Product Template Attribute Exclusion'
product_template_attribute_value_id = fields.Many2one(
- 'product.template.attribute.value', string="Attribute Value", ondelete='cascade')
+ 'product.template.attribute.value', string="Attribute Value", ondelete='cascade', index=True)
product_tmpl_id = fields.Many2one(
- 'product.template', string='Product Template', ondelete='cascade', required=True)
+ 'product.template', string='Product Template', ondelete='cascade', required=True, index=True)
value_ids = fields.Many2many(
'product.template.attribute.value', relation="product_attr_exclusion_value_ids_rel",
string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id)]")
diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py
index 4cae61d9a0b..5f40c360d23 100644
--- a/addons/product/models/product_pricelist.py
+++ b/addons/product/models/product_pricelist.py
@@ -246,7 +246,11 @@ class Pricelist(models.Model):
break
# Final price conversion into pricelist currency
if suitable_rule and suitable_rule.compute_price != 'fixed' and suitable_rule.base != 'pricelist':
- price = product.currency_id._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
+ if suitable_rule.base == 'standard_price':
+ cur = product.cost_currency_id
+ else:
+ cur = product.currency_id
+ price = cur._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
results[product.id] = (price, suitable_rule and suitable_rule.id or False)
@@ -494,3 +498,11 @@ class PricelistItem(models.Model):
'price_min_margin': 0.0,
'price_max_margin': 0.0,
})
+
+ @api.multi
+ def write(self, values):
+ res = super(PricelistItem, self).write(values)
+ # When the pricelist changes we need the product.template price
+ # to be invalided and recomputed.
+ self.invalidate_cache()
+ return res
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index 94858d7a98d..da355aec664 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -62,6 +62,8 @@ class ProductTemplate(models.Model):
currency_id = fields.Many2one(
'res.currency', 'Currency', compute='_compute_currency_id')
+ cost_currency_id = fields.Many2one(
+ 'res.currency', 'Cost Currency', compute='_compute_cost_currency_id')
# price fields
# price: total template price, context dependent (partner, pricelist, quantity)
@@ -119,6 +121,25 @@ class ProductTemplate(models.Model):
is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant')
attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes')
+
+ valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Lines', help="Technical compute")
+ valid_product_attribute_value_ids = fields.Many2many('product.attribute.value',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Values', help="Technical compute")
+ valid_product_attribute_ids = fields.Many2many('product.attribute',
+ compute="_compute_valid_attributes", string='Valid Product Attributes', help="Technical compute")
+ # wnva = without no_variant attributes
+ valid_product_template_attribute_line_wnva_ids = fields.Many2many('product.template.attribute.line',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Lines Without No Variant Attributes', help="Technical compute")
+ valid_product_attribute_value_wnva_ids = fields.Many2many('product.attribute.value',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Values Without No Variant Attributes', help="Technical compute")
+ valid_product_attribute_wnva_ids = fields.Many2many('product.attribute',
+ compute="_compute_valid_attributes", string='Valid Product Attributes Without No Variant Attributes', help="Technical compute")
+ valid_archived_variant_ids = fields.Many2many('product.product',
+ compute="_compute_valid_attributes", string='Valid Archived Variants', help="Technical compute")
+ valid_existing_variant_ids = fields.Many2many('product.product',
+ compute="_compute_valid_existing_variant_ids", string='Valid Existing Variants', help="Technical compute")
+
product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True)
# performance: product_variant_id provides prefetching on the first product variant only
product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id')
@@ -160,6 +181,10 @@ class ProductTemplate(models.Model):
for template in self:
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
+ def _compute_cost_currency_id(self):
+ for template in self:
+ template.cost_currency_id = self.env.user.company_id.currency_id.id
+
@api.multi
def _compute_template_price(self):
prices = self._compute_template_price_no_inverse()
@@ -478,7 +503,7 @@ class ProductTemplate(models.Model):
# Iterator containing all possible `product.attribute.value` combination
# The iterator is used to avoid MemoryError in case of a huge number of combination.
all_variants = itertools.product(*(
- line.value_ids.ids for line in tmpl_id._get_valid_product_template_attribute_lines()._without_no_variant_attributes()
+ line.value_ids.ids for line in tmpl_id.valid_product_template_attribute_line_wnva_ids
))
# Set containing existing `product.attribute.value` combination
existing_variants = {
@@ -504,8 +529,8 @@ class ProductTemplate(models.Model):
# should be activated
# - if the product does not have valid attributes or attribute values, it should be
# deleted
- valid_value_ids = tmpl_id._get_valid_product_attribute_values()._without_no_variant_attributes()
- valid_attribute_ids = tmpl_id._get_valid_product_attributes()._without_no_variant_attributes()
+ valid_value_ids = tmpl_id.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = tmpl_id.valid_product_attribute_wnva_ids
for product_id in tmpl_id.product_variant_ids:
if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids):
if not product_id.active:
@@ -537,6 +562,10 @@ class ProductTemplate(models.Model):
# This is the case from existing stock reordering rules.
variant.write({'active': False})
+ # prefetched o2m have to be reloaded (because of active_test)
+ # (eg. product.template: product_variant_ids)
+ # We can't rely on existing invalidate_cache because of the savepoint.
+ self.invalidate_cache()
return True
def has_dynamic_attributes(self):
@@ -550,7 +579,7 @@ class ProductTemplate(models.Model):
return any(a.create_variant == 'dynamic' for a in self._get_valid_product_attributes())
@api.multi
- def _get_valid_product_template_attribute_lines(self):
+ def _compute_valid_attributes(self):
"""A product template attribute line is considered valid if it has at
least one possible value.
@@ -559,42 +588,68 @@ class ProductTemplate(models.Model):
value to input), indeed single value attributes can be used to filter
products among others based on that attribute/value.
- This method is necessary because it was previously possible to save a
- line without any value on it, so the database might not be consistent in
- that regard.
+ A product attribute value is considered valid for a template if it is
+ defined on a product template attribute line.
- :return: all the valid product template attribute lines of this template
- :rtype: recordset `product.template.attribute.line`
+ A product attribute is considered valid for a template if it
+ has at least one possible value set on the template.
+
+ For what is considered an archived variant, see `_has_valid_attributes`.
"""
+ archived_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', False)])
+
+ # prefetch
+ self.mapped('attribute_line_ids.value_ids.id')
+ self.mapped('attribute_line_ids.attribute_id.create_variant')
+
+ for record in self:
+ record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
+ record.valid_product_template_attribute_line_wnva_ids = record.valid_product_template_attribute_line_ids._without_no_variant_attributes()
+
+ record.valid_product_attribute_value_ids = record.valid_product_template_attribute_line_ids.mapped('value_ids')
+ record.valid_product_attribute_value_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('value_ids')
+
+ record.valid_product_attribute_ids = record.valid_product_template_attribute_line_ids.mapped('attribute_id')
+ record.valid_product_attribute_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('attribute_id')
+
+ valid_value_ids = record.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = record.valid_product_attribute_wnva_ids
+
+ record.valid_archived_variant_ids = archived_variants.filtered(
+ lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
+ )
+
+ @api.multi
+ def _compute_valid_existing_variant_ids(self):
+ """This compute is done outside of `_compute_valid_attributes` because
+ it is often not needed, and it can be very bad on performance."""
+ existing_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', True)])
+ for record in self:
+
+ valid_value_ids = record.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = record.valid_product_attribute_wnva_ids
+
+ record.valid_existing_variant_ids = existing_variants.filtered(
+ lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
+ )
+
+ @api.multi
+ def _get_valid_product_template_attribute_lines(self):
+ """deprecated, use `valid_product_template_attribute_line_ids`"""
self.ensure_one()
- return self.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
+ return self.valid_product_template_attribute_line_ids
@api.multi
def _get_valid_product_attributes(self):
- """A product attribute is considered valid for a template if it
- has at least one possible value set on the template.
-
- See `_get_valid_product_template_attribute_lines`.
-
- :return: all the valid product attributes of this template
- :rtype: recordset `product.attribute`
- """
+ """deprecated, use `valid_product_attribute_ids`"""
self.ensure_one()
- product_attributes = self.env['product.attribute']
- for ptal in self._get_valid_product_template_attribute_lines():
- product_attributes |= ptal.attribute_id
- return product_attributes
+ return self.valid_product_attribute_ids
@api.multi
def _get_valid_product_attribute_values(self):
- """A product attribute value is considered valid for a template if it is
- defined on a product template attribute line.
-
- :return: all the valid product attribute values of this template
- :rtype: recordset `product.attribute.value`
- """
+ """deprecated, use `valid_product_attribute_value_ids`"""
self.ensure_one()
- return self._get_valid_product_template_attribute_lines().mapped('value_ids')
+ return self.valid_product_attribute_value_ids
@api.multi
def _get_possible_variants(self, parent_combination=None):
@@ -603,6 +658,13 @@ class ProductTemplate(models.Model):
For dynamic attributes, it will only return the variants that have been
created already. For no_variant attributes, it will return an empty
recordset because the variants themselves are not a full combination.
+ If there are a lot of variants, this method might be slow. Even if there
+ aren't too many variants, for performance reasons, do not call this
+ method in a loop over the product templates.
+
+ Therefore this method has a very restricted reasonable use case and you
+ should strongly consider doing things differently if you consider using
+ this method.
:param parent_combination: combination from which `self` is an
optional or accessory product.
@@ -702,19 +764,8 @@ class ProductTemplate(models.Model):
Array, each element is an array with ids of an archived combination.
"""
- valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
- valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
-
- # Search only among those having the right set of attributes.
- domain = [('product_tmpl_id', '=', self.id), ('active', '=', False)]
- for pa in valid_attribute_ids:
- domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
- archived_variants = self.env['product.product'].search(domain)
-
- archived_variants = archived_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
-
return [archived_variant.product_template_attribute_value_ids.ids
- for archived_variant in archived_variants]
+ for archived_variant in self.valid_archived_variant_ids]
@api.multi
def _get_existing_combinations(self):
@@ -726,19 +777,8 @@ class ProductTemplate(models.Model):
Array, each element is an array with ids of an existing combination.
"""
- valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
- valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
-
- # Search only among those having the right set of attributes.
- domain = [('product_tmpl_id', '=', self.id), ('active', '=', True)]
- for pa in valid_attribute_ids:
- domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
- existing_variants = self.env['product.product'].search(domain)
-
- existing_variants = existing_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
-
return [variant.product_template_attribute_value_ids.ids
- for variant in existing_variants]
+ for variant in self.valid_existing_variant_ids]
@api.multi
def _get_no_variant_product_template_attribute_values(self):
@@ -774,17 +814,20 @@ class ProductTemplate(models.Model):
"""
self.ensure_one()
- if len(combination) != len(self._get_valid_product_template_attribute_lines()):
+ if len(combination) != len(self.valid_product_template_attribute_line_ids):
# number of attribute values passed is different than the
# configuration of attributes on the template
return False
- if self._get_valid_product_attributes() != combination.mapped('attribute_id'):
+ if self.valid_product_attribute_ids != combination.mapped('attribute_id'):
# combination has different attributes than the ones configured on the template
return False
- variant = self._get_variant_for_combination(combination)
- if not self.has_dynamic_attributes() and not variant:
+ if self.valid_product_attribute_value_ids < combination.mapped('product_attribute_value_id'):
+ # combination has different values than the ones configured on the template
+ return False
+
+ if not self.has_dynamic_attributes() and not self._get_variant_for_combination(combination):
# the variant has been deleted
return False
@@ -826,40 +869,64 @@ class ProductTemplate(models.Model):
self.ensure_one()
filtered_combination = combination._without_no_variant_attributes()
+ attribute_values = filtered_combination.mapped('product_attribute_value_id')
+ return self.env['product.product'].browse(self._get_variant_id_for_combination(attribute_values))
+ @api.multi
+ @tools.ormcache('self', 'attribute_values')
+ def _get_variant_id_for_combination(self, attribute_values):
+ """See `_get_variant_for_combination`. This method returns an ID
+ so it can be cached."""
+ self.ensure_one()
# If there are a lot of variants on this template, it is much faster to
# build a query than using the existing o2m.
domain = [('product_tmpl_id', '=', self.id)]
- for ptav in filtered_combination:
- domain = expression.AND([[('attribute_value_ids.id', '=', ptav.product_attribute_value_id.id)], domain])
- res = self.env['product.product'].search(domain)
+ for pav in attribute_values:
+ domain = expression.AND([[('attribute_value_ids', 'in', pav.id)], domain])
+
+ res = self.env['product.product'].with_context(active_test=False).search(domain)
# The domain above is checking for the `product.attribute.value`, but we
# need to make sure it's the same `product.template.attribute.value`.
# Also there should theorically be only 0 or 1 but an existing database
# might not be consistent so we need to make sure to take max 1.
return res.filtered(
- lambda v: v.product_template_attribute_value_ids == filtered_combination
- )[:1]
+ lambda v: v.attribute_value_ids == attribute_values
+ )[:1].id
+
+ @api.multi
+ @tools.ormcache('self')
+ def _get_first_possible_variant_id(self):
+ """See `_create_first_product_variant`. This method returns an ID
+ so it can be cached."""
+ self.ensure_one()
+ return self._create_first_product_variant().id
@api.multi
def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
- """
- Iterate the attributes and values in order and stop at the first
- combination of values that is possible.
+ """See `_get_possible_combinations` (one iteration).
- When encountering an impossible combination, try to change the
- value of latest attributes first.
-
- Ignore attributes that have no values.
-
- Note this method return the same result (empty recordset) if no
+ This method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
+ """
+ return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value'])
+
+ @api.multi
+ def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
+ """Generator returning combinations that are possible, following the
+ sequence of attributes and values.
+
+ See `_is_combination_possible` for what is a possible combination.
+
+ When encountering an impossible combination, try to change the value
+ of attributes by starting with the further regarding their sequences.
+
+ Ignore attributes that have no values.
:param parent_combination: combination from which `self` is an
optional or accessory product.
@@ -868,63 +935,81 @@ class ProductTemplate(models.Model):
:param necessary_values: values that must be in the returned combination
:type necessary_values: recordset of `product.template.attribute.value`
- :return: the first possible combination found, or empty if none possible
- :rtype: recordset of `product.template.attribute.value`
+ :return: the possible combinations
+ :rtype: generator of recordset of `product.template.attribute.value`
"""
self.ensure_one()
if not self.active:
- return self.env['product.template.attribute.value']
+ return _("The product template is archived so no combination is possible.")
- attribute_lines = self._get_valid_product_template_attribute_lines()
+ ptal_stack = [self._get_valid_product_template_attribute_lines()]
+ combination_stack = [self.env['product.template.attribute.value']]
- def iterate_attribute_lines(attribute_lines, combination):
- """
- :param attribute_lines: recordset of product.template.attribute.line
- that are still to iterate
- :param combination: recordset of product.template.attribute.value
- that have to be tested for possibility
+ # keep going while we have attribute lines to test
+ while len(ptal_stack):
+ attribute_lines = ptal_stack.pop()
+ combination = combination_stack.pop()
- :return: the first possible combination found, or empty
- :rtype: recordset of `product.template.attribute.value`
- """
if not attribute_lines:
- if self._is_combination_possible(combination, parent_combination):
- return combination
- else:
- return self.env['product.template.attribute.value']
- for cur in attribute_lines[0].product_template_value_ids:
- res = iterate_attribute_lines(attribute_lines[1:], combination + cur)
- if res and all(v in res for v in (necessary_values or [])):
- return res
- return self.env['product.template.attribute.value']
+ # full combination, if it's possible return it, otherwise skip it
+ if self._is_combination_possible(combination, parent_combination) and all(v in combination for v in (necessary_values or [])):
+ yield(combination)
+ else:
+ # we have remaining attribute lines to consider
+ for ptav in reversed(attribute_lines[0].product_template_value_ids):
+ ptal_stack.append(attribute_lines[1:])
+ combination_stack.append(combination + ptav)
- return iterate_attribute_lines(attribute_lines, self.env['product.template.attribute.value'])
+ return _("There are no remaining possible combination.")
@api.multi
def _get_closest_possible_combination(self, combination):
- """Get the first possible combination that is the closest to the given
- combination.
+ """See `_get_closest_possible_combinations` (one iteration).
+
+ This method return the same result (empty recordset) if no
+ combination is possible at all which would be considered a negative
+ result, or if there are no attribute lines on the template in which
+ case the "empty combination" is actually a possible combination.
+ Therefore the result of this method when empty should be tested
+ with `_is_combination_possible` if it's important to know if the
+ resulting empty combination is actually possible or not.
+ """
+ return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
+
+ @api.multi
+ def _get_closest_possible_combinations(self, combination):
+ """Generator returning the possible combinations that are the closest to
+ the given combination.
If the given combination is incomplete, try to complete it.
If the given combination is invalid, try to remove values from it before
completing it.
- See `_get_first_possible_combination` note about empty result.
-
:param combination: the values to include if they are possible
:type combination: recordset `product.template.attribute.value`
- :return: the first possible combination that is including as much
+ :return: the possible combinations that are including as much
elements as possible from the given combination.
- :rtype: recordset of product.template.attribute.value
+ :rtype: generator of recordset of product.template.attribute.value
"""
while True:
- result = self._get_first_possible_combination(necessary_values=combination)
- if result or not combination:
- return result
- combination = combination[:-1]
+ res = self._get_possible_combinations(necessary_values=combination)
+ try:
+ # If there is at least one result for the given combination
+ # we consider that combination set, and we yield all the
+ # possible combinations for it.
+ yield(next(res))
+ for cur in res:
+ yield(cur)
+ return _("There are no remaining closest combination.")
+ except StopIteration:
+ # There are no results for the given combination, we try to
+ # progressively remove values from it.
+ if not combination:
+ return _("There are no possible combination.")
+ combination = combination[:-1]
@api.multi
def _get_current_company(self, **kwargs):
diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py
index 19070a1c887..aa1de67d16b 100644
--- a/addons/product/tests/test_product_attribute_value_config.py
+++ b/addons/product/tests/test_product_attribute_value_config.py
@@ -204,8 +204,8 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
self.assertEqual(len(variant), 0)
# also test _has_valid_attributes (case ok):
- valid_value_ids = self.computer._get_valid_product_attribute_values()._without_no_variant_attributes()
- valid_attribute_ids = self.computer._get_valid_product_attributes()._without_no_variant_attributes()
+ valid_value_ids = self.computer.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = self.computer.valid_product_attribute_wnva_ids
self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids))
# also test _has_valid_attributes (case not ok):
@@ -321,34 +321,52 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
- # ram_8 is allowed with the other attributes
- self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
-
- # Below invalidate cache between every test to force reload the
- # o2m attribute_line_ids to order it by the new sequence
+ # Basic case: test all iterations of generator
+ gen = self.computer._get_possible_combinations()
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
+ with self.assertRaises(StopIteration):
+ next(gen)
# Give priority to ram_16 but it is not allowed by hdd_1 so it should return hhd_2 instead
+ # Test invalidate_cache on product.attribute.value write
computer_ram_16.product_attribute_value_id.sequence = -1
- self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
+ # Move down the ram, so it will try to change the ram instead of the hdd
+ # Test invalidate_cache on product.attribute write
+ self.ram_attribute.sequence = 10
+ self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+
# Give priority to ram_32 and is allowed with the rest so it should return it
+ self.ram_attribute.sequence = 2
computer_ram_16.product_attribute_value_id.sequence = 2
computer_ram_32.product_attribute_value_id.sequence = -1
- self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
# Give priority to ram_16 but now it is not allowing any hdd so it should return ram_8 instead
computer_ram_32.product_attribute_value_id.sequence = 3
computer_ram_16.product_attribute_value_id.sequence = -1
- self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_16, computer_hdd_2)
self._add_exclude(computer_ram_16, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Only the last combination is possible
computer_ram_16.product_attribute_value_id.sequence = 2
- self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_8, computer_hdd_1)
self._add_exclude(computer_ram_8, computer_hdd_2)
self._add_exclude(computer_ram_8, computer_hdd_4)
@@ -357,26 +375,37 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
self._add_exclude(computer_ram_32, computer_ssd_256)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
- # No possible combination
+ # No possible combination (test helper and iterator)
self._add_exclude(computer_ram_32, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), self.env['product.template.attribute.value'])
+ gen = self.computer._get_possible_combinations()
+ with self.assertRaises(StopIteration):
+ next(gen)
- def test_get_closest_possible_combination(self):
+ def test_get_closest_possible_combinations(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
+ computer_ram_32 = self._get_product_template_attribute_value(self.ram_32)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
- # CASE nothing special
- self.assertEqual(self.computer._get_closest_possible_combination(None),
- computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ # CASE nothing special (test 2 iterations)
+ gen = self.computer._get_closest_possible_combinations(None)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
- # CASE contains computer_hdd_1
- self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1),
- computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ # CASE contains computer_hdd_1 (test all iterations)
+ gen = self.computer._get_closest_possible_combinations(computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
+ with self.assertRaises(StopIteration):
+ next(gen)
# CASE contains computer_hdd_2
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2),
@@ -393,3 +422,27 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
# CASE invalid combination (too much):
self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2),
computer_ssd_256 + computer_ram_8 + computer_hdd_4)
+
+ def test_clear_caches(self):
+ """The goal of this test is to make sure the cache is invalidated when
+ it should be."""
+ attribute_values = self.ssd_256 + self.ram_8 + self.hdd_1
+
+ # CASE: initial result of _get_variant_id_for_combination
+ variant_id = self.computer._get_variant_id_for_combination(attribute_values)
+ self.assertTrue(variant_id)
+
+ # CASE: clear_caches in product.product unlink
+ self.env['product.product'].browse(variant_id).unlink()
+ self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
+
+ # CASE: clear_caches in product.product create
+ variant = self.env['product.product'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_value_ids': [(6, 0, attribute_values.ids)],
+ })
+ self.assertEqual(variant.id, self.computer._get_variant_id_for_combination(attribute_values))
+
+ # CASE: clear_caches in product.product write
+ variant.attribute_value_ids = False
+ self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index bd70a9f0047..dbf8a4ab4dc 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -52,13 +52,14 @@
-
+
+
diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py
index 49fd7c9da88..ba676bf2fad 100644
--- a/addons/purchase_requisition/models/purchase_requisition.py
+++ b/addons/purchase_requisition/models/purchase_requisition.py
@@ -258,7 +258,7 @@ class PurchaseRequisitionLine(models.Model):
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
- self.product_uom_id = self.product_id.uom_id
+ self.product_uom_id = self.product_id.uom_po_id
self.product_qty = 1.0
if not self.schedule_date:
self.schedule_date = self.requisition_id.schedule_date
diff --git a/addons/purchase_requisition/views/purchase_requisition_views.xml b/addons/purchase_requisition/views/purchase_requisition_views.xml
index cb4bb9ed82c..ccc537df2cf 100644
--- a/addons/purchase_requisition/views/purchase_requisition_views.xml
+++ b/addons/purchase_requisition/views/purchase_requisition_views.xml
@@ -30,7 +30,7 @@
-
+
@@ -226,7 +226,7 @@
-
+
diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py
index 3cc797255cd..4813bf2fe16 100644
--- a/addons/purchase_stock/models/purchase.py
+++ b/addons/purchase_stock/models/purchase.py
@@ -372,12 +372,11 @@ class PurchaseOrderLine(models.Model):
@api.multi
def _create_stock_moves(self, picking):
- moves = self.env['stock.move']
- done = self.env['stock.move'].browse()
+ values = []
for line in self:
for val in line._prepare_stock_moves(picking):
- done += moves.create(val)
- return done
+ values.append(val)
+ return self.env['stock.move'].create(values)
def _merge_in_existing_line(self, product_id, product_qty, product_uom, location_id, name, origin, values):
""" This function purpose is to be override with the purpose to forbide _run_buy method
diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml
index 937301a7176..f8d0d840697 100644
--- a/addons/repair/views/repair_views.xml
+++ b/addons/repair/views/repair_views.xml
@@ -206,7 +206,7 @@
-
+
diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py
index 5a5ae1b26d9..eea3dfe67fd 100644
--- a/addons/sale/controllers/product_configurator.py
+++ b/addons/sale/controllers/product_configurator.py
@@ -41,7 +41,10 @@ class ProductConfiguratorController(http.Controller):
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
- return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
+ ProductTemplate = request.env['product.template']
+ if 'context' in kw:
+ ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
+ return ProductTemplate.browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
@http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
diff --git a/addons/sale/data/mail_data.xml b/addons/sale/data/mail_data.xml
index 938a39e1576..b39a793f83b 100644
--- a/addons/sale/data/mail_data.xml
+++ b/addons/sale/data/mail_data.xml
@@ -56,7 +56,7 @@
-
+
diff --git a/addons/sale/models/payment.py b/addons/sale/models/payment.py
index c97d93430cd..3d650c647fe 100644
--- a/addons/sale/models/payment.py
+++ b/addons/sale/models/payment.py
@@ -112,7 +112,7 @@ class PaymentTransaction(models.Model):
ctx_company = {'company_id': self.acquirer_id.company_id.id,
'force_company': self.acquirer_id.company_id.id}
for trans in self.filtered(lambda t: t.sale_order_ids):
- trans = trans.with_context(ctx_company)
+ trans = trans.with_context(**ctx_company)
trans.sale_order_ids._force_lines_to_invoice_policy_order()
invoices = trans.sale_order_ids.action_invoice_create()
trans.invoice_ids = [(6, 0, invoices)]
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
index 3691067d106..3e191b13cc1 100644
--- a/addons/sale/models/product_template.py
+++ b/addons/sale/models/product_template.py
@@ -169,7 +169,7 @@ class ProductTemplate(models.Model):
return res
@api.multi
- def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
""" Return info about a given combination.
Note: this method does not take into account whether the combination is
@@ -194,6 +194,9 @@ class ProductTemplate(models.Model):
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
+ :param only_template: boolean, if set to True, get the info for the
+ template only: ignore combination and don't try to find variant
+
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
@@ -215,6 +218,8 @@ class ProductTemplate(models.Model):
discount applied (price < list_price), else False
"""
self.ensure_one()
+ # get the name before the change of context to benefit from prefetch
+ display_name = self.name
quantity = self.env.context.get('quantity', add_qty)
context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
@@ -222,10 +227,12 @@ class ProductTemplate(models.Model):
combination = combination or product_template.env['product.template.attribute.value']
- if not product_id and not combination:
+ if not product_id and not combination and not only_template:
combination = product_template._get_first_possible_combination(parent_combination)
- if product_id and not combination:
+ if only_template:
+ product = product_template.env['product.product']
+ elif product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
@@ -253,8 +260,6 @@ class ProductTemplate(models.Model):
list_price = product_template.price_compute('list_price')[product_template.id]
price = product_template.price if pricelist else list_price
- display_name = product_template.name
-
filtered_combination = combination._without_no_variant_attributes()
if filtered_combination:
display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name')))
@@ -281,8 +286,7 @@ class ProductTemplate(models.Model):
def _is_add_to_cart_possible(self, parent_combination=None):
"""
It's possible to add to cart (potentially after configuration) if
- there is at least one possible combination, or if there is no
- `product.template.attribute.line` at all.
+ there is at least one possible combination.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
@@ -293,9 +297,9 @@ class ProductTemplate(models.Model):
"""
self.ensure_one()
if not self.active:
+ # for performance: avoid calling `_get_possible_combinations`
return False
- combination = self._get_first_possible_combination(parent_combination)
- return True if combination else self._is_combination_possible(combination, parent_combination)
+ return next(self._get_possible_combinations(parent_combination), False) is not False
@api.multi
def _get_current_company_fallback(self, **kwargs):
diff --git a/addons/sale/models/res_config_settings.py b/addons/sale/models/res_config_settings.py
index 0a5060483a4..9af48d1a011 100644
--- a/addons/sale/models/res_config_settings.py
+++ b/addons/sale/models/res_config_settings.py
@@ -79,6 +79,8 @@ class ResConfigSettings(models.TransientModel):
def set_values(self):
super(ResConfigSettings, self).set_values()
+ if self.default_invoice_policy != 'order':
+ self.env['ir.config_parameter'].set_param('sale.automatic_invoice', False)
if not self.group_discount_per_so_line:
pl = self.env['product.pricelist'].search([('discount_policy', '=', 'without_discount')])
pl.write({'discount_policy': 'with_discount'})
diff --git a/addons/sale/models/res_partner.py b/addons/sale/models/res_partner.py
index d03c877b938..a1f60defb2d 100644
--- a/addons/sale/models/res_partner.py
+++ b/addons/sale/models/res_partner.py
@@ -14,14 +14,20 @@ class ResPartner(models.Model):
sale_warn_msg = fields.Text('Message for Sales Order')
def _compute_sale_order_count(self):
- sale_data = self.env['sale.order'].read_group(domain=[('partner_id', 'child_of', self.ids)],
- fields=['partner_id'], groupby=['partner_id'])
- mapped_data = dict([(m['partner_id'][0], m['partner_id_count']) for m in sale_data])
- for partner in self:
- # let's obtain the partner id and all its child ids
- partner_ids = [partner.id] + partner.child_ids.ids
- # then we can sum for all the partner's child
- partner.sale_order_count = sum(mapped_data.get(child, 0) for child in partner_ids)
+ # retrieve all children partners and prefetch 'parent_id' on them
+ all_partners = self.search([('id', 'child_of', self.ids)])
+ all_partners.read(['parent_id'])
+
+ sale_order_groups = self.env['sale.order'].read_group(
+ domain=[('partner_id', 'in', all_partners.ids)],
+ fields=['partner_id'], groupby=['partner_id']
+ )
+ for group in sale_order_groups:
+ partner = self.browse(group['partner_id'][0])
+ while partner:
+ if partner in self:
+ partner.sale_order_count += group['partner_id_count']
+ partner = partner.parent_id
def can_edit_vat(self):
can_edit_vat = super(ResPartner, self).can_edit_vat()
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 48a37020c96..562da160396 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -303,7 +303,7 @@ class SaleOrder(models.Model):
'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False,
'partner_invoice_id': addr['invoice'],
'partner_shipping_id': addr['delivery'],
- 'user_id': self.partner_id.user_id.id or self.env.uid
+ 'user_id': self.partner_id.user_id.id or self.partner_id.commercial_partner_id.user_id.id or self.env.uid
}
if self.env['ir.config_parameter'].sudo().get_param('sale.use_sale_note') and self.env.user.company_id.sale_note:
values['note'] = self.with_context(lang=self.partner_id.lang).env.user.company_id.sale_note
@@ -634,11 +634,15 @@ class SaleOrder(models.Model):
if email_act and email_act.get('context'):
email_ctx = email_act['context']
email_ctx.update(default_email_from=order.company_id.email)
- order.with_context(email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
+ order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
return True
@api.multi
def action_done(self):
+ tx = self.sudo().transaction_ids.get_last_transaction()
+ if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer':
+ tx._set_transaction_done()
+ tx.write({'is_processed': True})
return self.write({'state': 'done'})
@api.multi
@@ -1429,6 +1433,16 @@ class SaleOrderLine(models.Model):
result = {'domain': domain}
+ name = self.get_sale_order_line_multiline_description_sale(product)
+
+ vals.update(name=name)
+
+ self._compute_tax_id()
+
+ if self.order_id.pricelist_id and self.order_id.partner_id:
+ vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
+ self.update(vals)
+
title = False
message = False
warning = {}
@@ -1441,16 +1455,6 @@ class SaleOrderLine(models.Model):
if product.sale_line_warn == 'block':
self.product_id = False
- name = self.get_sale_order_line_multiline_description_sale(product)
-
- vals.update(name=name)
-
- self._compute_tax_id()
-
- if self.order_id.pricelist_id and self.order_id.partner_id:
- vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
- self.update(vals)
-
return result
@api.onchange('product_uom', 'product_uom_qty')
diff --git a/addons/sale/report/sale_report_templates.xml b/addons/sale/report/sale_report_templates.xml
index 8e678b1fcd6..60757646486 100644
--- a/addons/sale/report/sale_report_templates.xml
+++ b/addons/sale/report/sale_report_templates.xml
@@ -192,9 +192,9 @@
-
+
Fiscal Position Remark:
-
+
diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js
index 1a74764aacc..0d49a4f245e 100644
--- a/addons/sale/static/src/js/product_configurator_mixin.js
+++ b/addons/sale/static/src/js/product_configurator_mixin.js
@@ -183,7 +183,7 @@ var ProductConfiguratorMixin = {
*/
triggerVariantChange: function ($container) {
var self = this;
- $container.find('ul[data-attribute_exclusions]').trigger('change');
+ $container.find('ul[data-attribute_exclusions]').trigger('change', {$container: $container});
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
self.handleCustomValues($(this));
});
@@ -559,14 +559,16 @@ var ProductConfiguratorMixin = {
_updateProductImage: function ($productContainer, productId, productTemplateId) {
var model = productId ? 'product.product' : 'product.template';
var modelId = productId || productTemplateId;
- var imageSrc = '/web/image/{0}/{1}/image'
+ var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image');
+ var imageSrc = imageUrl
.replace("{0}", model)
.replace("{1}", modelId);
var imagesSelectors = [
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
'img.product_detail_img',
- 'span.variant_image img'
+ 'span.variant_image img',
+ 'img.variant_image',
];
var $img = $productContainer.find(imagesSelectors.join(', '));
diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js
index 630774b8da5..60544ada7c4 100644
--- a/addons/sale/static/src/js/product_configurator_modal.js
+++ b/addons/sale/static/src/js/product_configurator_modal.js
@@ -69,9 +69,9 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : '');
if (this.isWebsite) {
- delete this.events['change [data-attribute_exclusions]'];
delete this.events['click button.js_add_cart_json'];
}
+ this._productImageField = 'image_medium';
},
/**
* @override
@@ -440,7 +440,17 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
delete optionalProductsMap[optionId];
}
},
+ /**
+ * @override
+ */
+ _onChangeCombination:function (ev, $parent, combination) {
+ $parent
+ .find('.td-product_name .product-name')
+ .first()
+ .text(combination.display_name);
+ ProductConfiguratorMixin._onChangeCombination.apply(this, arguments);
+ },
/**
* When the quantity of the root product is updated, we need to update
* the quantity of all the selected optional products.
diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py
index 95b1814f50e..087cbe78de5 100644
--- a/addons/sale/tests/test_sale_product_attribute_value_config.py
+++ b/addons/sale/tests/test_sale_product_attribute_value_config.py
@@ -80,8 +80,13 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
def test_01_is_combination_possible_archived(self):
"""The goal is to test the possibility of archived combinations.
+
This test could not be put into product module because there was no
- field which had product_id as required and without cascade on delete.
+ model which had product_id as required and without cascade on delete.
+ Here we have the sales order line in this situation.
+
+ This is a necessary condition for `create_variant_ids` to archive
+ instead of delete the variants.
"""
def do_test(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
@@ -110,6 +115,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
variant2.active = False
# CASE: 1 not archived, 2 archived
+ # Test invalidate_cache on product.product write
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
# CASE: both archived combination (without no_variant)
@@ -261,6 +267,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: dynamic combination, no variant existing
+ # Test invalidate_cache on product.template create_variant_ids
self._add_keyboard_attribute()
self.computer.create_variant_ids()
combination += self._get_product_template_attribute_value(self.keyboard_excluded)
@@ -272,8 +279,8 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
# CASE: pricelist set value to 0, no variant
+ # Test invalidate_cache on product.pricelist write
pricelist_item.percent_price = 100
- self.computer.invalidate_cache() # need o2m to be refetched
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], False)
@@ -346,7 +353,6 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.hdd_attribute.create_variant = 'dynamic'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
- self.computer.invalidate_cache()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
diff --git a/addons/sale_management/models/sale_order.py b/addons/sale_management/models/sale_order.py
index afa13aed50d..6ebed05a2bc 100644
--- a/addons/sale_management/models/sale_order.py
+++ b/addons/sale_management/models/sale_order.py
@@ -209,4 +209,5 @@ class SaleOrderOption(models.Model):
'product_uom_qty': self.quantity,
'product_uom': self.uom_id.id,
'discount': self.discount,
+ 'company_id': self.order_id.company_id.id,
}
diff --git a/addons/sale_management/models/sale_order_template.py b/addons/sale_management/models/sale_order_template.py
index 45bcf2bb752..8eef6efd405 100644
--- a/addons/sale_management/models/sale_order_template.py
+++ b/addons/sale_management/models/sale_order_template.py
@@ -120,7 +120,10 @@ class SaleOrderTemplateOption(models.Model):
return
product = self.product_id
self.price_unit = product.list_price
- self.name = product.name
+ name = product.name
+ if self.product_id.description_sale:
+ name += '\n' + self.product_id.description_sale
+ self.name = name
self.uom_id = product.uom_id
domain = {'uom_id': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
return {'domain': domain}
diff --git a/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js b/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js
index 1cccd7fe0e1..074da95f06e 100644
--- a/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js
+++ b/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js
@@ -3,6 +3,8 @@ odoo.define('sale.sale_product_configurator_advanced_tour', function (require) {
var tour = require('web_tour.tour');
+var optionVariantImage;
+
tour.register('sale_product_configurator_advanced_tour', {
url: "/web",
test: true,
@@ -95,6 +97,25 @@ tour.register('sale_product_configurator_advanced_tour', {
trigger: '.main_product div:contains("PA8: PAV1")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")',
+ run: function () {
+ optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ }
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")',
+ run: function () {
+ var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ if (newVariantImage !== optionVariantImage) {
+ $('
').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
+ }
+
+ }
+}, {
+ extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
index 263476c140e..d3111d2564f 100644
--- a/addons/stock/models/stock_inventory.py
+++ b/addons/stock/models/stock_inventory.py
@@ -267,7 +267,7 @@ class Inventory(models.Model):
args += (self.package_id.id,)
#case 5: Filter on One product category + Exahausted Products
if self.category_id:
- categ_products = Product.search([('categ_id', '=', self.category_id.id)])
+ categ_products = Product.search([('categ_id', 'child_of', self.category_id.id)])
domain += ' AND product_id = ANY (%s)'
args += (categ_products.ids,)
products_to_filter |= categ_products
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
index 1f32abd5130..b0a36dd29f8 100644
--- a/addons/stock/models/stock_location.py
+++ b/addons/stock/models/stock_location.py
@@ -182,9 +182,9 @@ class Route(models.Model):
'res.company', 'Company',
default=lambda self: self.env['res.company']._company_default_get('stock.location.route'), index=True,
help='Leave this field empty if this route is shared between all companies')
- product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products')
- categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories')
- warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses')
+ product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products', copy=False)
+ categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories', copy=False)
+ warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses', copy=False)
@api.onchange('warehouse_selectable')
def _onchange_warehouse_selectable(self):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 4881c84d591..156ed59eb32 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -1136,7 +1136,8 @@ class StockMove(models.Model):
moves_todo.mapped('move_dest_ids')._action_assign()
# We don't want to create back order for scrap moves
- if all(move_todo.scrapped for move_todo in moves_todo):
+ # Replace by a kwarg in master
+ if self.env.context.get('is_scrap'):
return moves_todo
if picking and not cancel_backorder:
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index 4eb563013c6..69f5aaf5e70 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -718,7 +718,7 @@ class Picking(models.Model):
# If no lots when needed, raise error
picking_type = self.picking_type_id
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids)
+ no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel')))
no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
if no_reserved_quantities and no_quantities_done:
raise UserError(_('You cannot validate a transfer if no quantites are reserved nor done. To force the transfer, switch in edit more and encode the done quantities.'))
diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py
index 7c8eeefb57a..9a9ccd51943 100644
--- a/addons/stock/models/stock_scrap.py
+++ b/addons/stock/models/stock_scrap.py
@@ -106,7 +106,8 @@ class StockScrap(models.Model):
def do_scrap(self):
for scrap in self:
move = self.env['stock.move'].create(scrap._prepare_move_values())
- move._action_done()
+ # master: replace context by cancel_backorder
+ move.with_context(is_scrap=True)._action_done()
scrap.write({'move_id': move.id, 'state': 'done'})
return True
diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py
index b7c83300245..56f82f9d4ba 100644
--- a/addons/stock/models/stock_warehouse.py
+++ b/addons/stock/models/stock_warehouse.py
@@ -495,12 +495,39 @@ class Warehouse(models.Model):
def_values = self.default_get(['reception_steps', 'delivery_steps'])
reception_steps = vals.get('reception_steps', def_values['reception_steps'])
delivery_steps = vals.get('delivery_steps', def_values['delivery_steps'])
+ code = vals.get('code') or self.code
+ code = code.replace(' ', '').upper()
sub_locations = {
- 'lot_stock_id': {'name': _('Stock'), 'active': True, 'usage': 'internal'},
- 'wh_input_stock_loc_id': {'name': _('Input'), 'active': reception_steps != 'one_step', 'usage': 'internal'},
- 'wh_qc_stock_loc_id': {'name': _('Quality Control'), 'active': reception_steps == 'three_steps', 'usage': 'internal'},
- 'wh_output_stock_loc_id': {'name': _('Output'), 'active': delivery_steps != 'ship_only', 'usage': 'internal'},
- 'wh_pack_stock_loc_id': {'name': _('Packing Zone'), 'active': delivery_steps == 'pick_pack_ship', 'usage': 'internal'},
+ 'lot_stock_id': {
+ 'name': _('Stock'),
+ 'active': True,
+ 'usage': 'internal',
+ 'barcode': code + '-STOCK'
+ },
+ 'wh_input_stock_loc_id': {
+ 'name': _('Input'),
+ 'active': reception_steps != 'one_step',
+ 'usage': 'internal',
+ 'barcode': code + '-INPUT'
+ },
+ 'wh_qc_stock_loc_id': {
+ 'name': _('Quality Control'),
+ 'active': reception_steps == 'three_steps',
+ 'usage': 'internal',
+ 'barcode': code + '-QUALITY'
+ },
+ 'wh_output_stock_loc_id': {
+ 'name': _('Output'),
+ 'active': delivery_steps != 'ship_only',
+ 'usage': 'internal',
+ 'barcode': code + '-OUTPUT'
+ },
+ 'wh_pack_stock_loc_id': {
+ 'name': _('Packing Zone'),
+ 'active': delivery_steps == 'pick_pack_ship',
+ 'usage': 'internal',
+ 'barcode': code + '-PACKING'
+ },
}
return sub_locations
diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py
index 0939389dda9..8a6cb87da67 100644
--- a/addons/stock/tests/test_move.py
+++ b/addons/stock/tests/test_move.py
@@ -3379,6 +3379,15 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+
+ scrap = self.env['stock.scrap'].create({
+ 'picking_id': picking.id,
+ 'product_id': self.product1.id,
+ 'product_uom_id': self.uom_unit.id,
+ 'scrap_qty': 5.0,
+ })
+ scrap.do_scrap()
+
# No products are reserved on the move of 10, click on `button_validate`.
with self.assertRaises(UserError):
picking.button_validate()
diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py
index 13b092a9407..dd75243ffa3 100644
--- a/addons/stock/tests/test_move2.py
+++ b/addons/stock/tests/test_move2.py
@@ -1811,6 +1811,9 @@ class TestSinglePicking(TestStockCommon):
class TestStockUOM(TestStockCommon):
def setUp(self):
super(TestStockUOM, self).setUp()
+ self.setUpCache()
+
+ def setUpCache(self):
dp = self.env.ref('product.decimal_product_uom')
dp.digits = 7
@@ -1845,6 +1848,10 @@ class TestStockUOM(TestStockCommon):
'uom_po_id': T_LBS.id,
'tracking': 'lot',
})
+
+ # creating a variant invalidates the cache
+ self.setUpCache()
+
picking_in = self.env['stock.picking'].create({
'partner_id': self.partner_delta_id,
'picking_type_id': self.picking_type_in,
diff --git a/addons/stock/views/stock_inventory_views.xml b/addons/stock/views/stock_inventory_views.xml
index 0aef58e898c..eb1fd43c0d5 100644
--- a/addons/stock/views/stock_inventory_views.xml
+++ b/addons/stock/views/stock_inventory_views.xml
@@ -97,7 +97,7 @@
@@ -219,4 +219,4 @@
-
\ No newline at end of file
+
diff --git a/addons/test_website/data/test_website_data.xml b/addons/test_website/data/test_website_data.xml
index df827d542a3..0d829aaab84 100644
--- a/addons/test_website/data/test_website_data.xml
+++ b/addons/test_website/data/test_website_data.xml
@@ -1,6 +1,8 @@
+
+
Test Viewqweb
@@ -50,5 +52,11 @@
placeholder
+
+
+
+
I am a base view
+
+
diff --git a/addons/test_website/static/src/js/reset_views.test.js b/addons/test_website/static/src/js/reset_views.test.js
index 957cda6d594..2b0e2b71a6b 100644
--- a/addons/test_website/static/src/js/reset_views.test.js
+++ b/addons/test_website/static/src/js/reset_views.test.js
@@ -23,7 +23,7 @@ tour.register('test_reset_page_view_complete_flow_part1', {
},
{
content: "save the page",
- extra_trigger: '#oe_structure_test_website_page .s_cover',
+ extra_trigger: '#oe_structure_test_website_page.o_dirty',
trigger: "#web_editor-top-edit button[data-action=save]",
},
// 2. Edit that COW'd view in the HTML editor to break it.
diff --git a/addons/test_website/tests/__init__.py b/addons/test_website/tests/__init__.py
index 4c32d5f1959..57d8fcd0c25 100644
--- a/addons/test_website/tests/__init__.py
+++ b/addons/test_website/tests/__init__.py
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_reset_views
+from . import test_views_during_module_operation
diff --git a/addons/test_website/tests/test_views_during_module_operation.py b/addons/test_website/tests/test_views_during_module_operation.py
new file mode 100644
index 00000000000..58cf943daf4
--- /dev/null
+++ b/addons/test_website/tests/test_views_during_module_operation.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import TransactionCase, tagged
+
+
+@tagged('-at_install', 'post_install')
+class TestViewsDuringModuleOperation(TransactionCase):
+ def test_01_cow_views_unlink_on_module_update(self):
+ """ Ensure COW views are correctly removed during module update.
+ Not removing the view could lead to traceback:
+ - Having a view A
+ - Having a view B that inherits from a view C
+ - View B t-call view A
+ - COW view B
+ - Delete view A and B from module datas and update it
+ - Rendering view C will crash since it will render child view B that
+ t-call unexisting view A
+ """
+
+ View = self.env['ir.ui.view']
+ Imd = self.env['ir.model.data']
+
+ update_module_base_view = self.env.ref('test_website.update_module_base_view')
+ update_module_view_to_be_t_called = View.create({
+ 'name': 'View to be t-called',
+ 'type': 'qweb',
+ 'arch': '