diff --git a/addons/analytic/models/analytic_account.py b/addons/analytic/models/analytic_account.py
index 187d53d1ad7..91815380304 100644
--- a/addons/analytic/models/analytic_account.py
+++ b/addons/analytic/models/analytic_account.py
@@ -160,7 +160,10 @@ class AccountAnalyticAccount(models.Model):
if operator == 'ilike' and not (name or '').strip():
domain = []
else:
- domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id.name', operator, name)]
+ # `partner_id` is in auto_join and the searches using ORs with auto_join fields doesn't work
+ # we have to cut the search in two searches ... https://github.com/odoo/odoo/issues/25175
+ partner_ids = self.env['res.partner']._search([('name', operator, name)], limit=limit, access_rights_uid=name_get_uid)
+ domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id', 'in', partner_ids)]
analytic_account_ids = self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
return self.browse(analytic_account_ids).name_get()
diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py
index 85d6d732055..db66467d7dd 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 3e864e34565..7ddc3a74581 100644
--- a/addons/base_automation/data/base_automation_demo.xml
+++ b/addons/base_automation/data/base_automation_demo.xml
@@ -84,4 +84,19 @@ record['name'] = record.name + 'X'
+
+ 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 ab460f1e875..e2620d202e7 100644
--- a/addons/base_automation/models/base_automation.py
+++ b/addons/base_automation/models/base_automation.py
@@ -164,14 +164,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']
@@ -198,7 +201,12 @@ class BaseAutomation(models.Model):
for record in records:
# we process the action if any watched field has been modified
if self._check_trigger_fields(record):
- 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()
def _check_trigger_fields(self, record):
@@ -274,7 +282,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 6b34c65c6a7..97fcb2d1827 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/crm/models/crm_team.py b/addons/crm/models/crm_team.py
index 30d7cc945ea..a101fb26848 100644
--- a/addons/crm/models/crm_team.py
+++ b/addons/crm/models/crm_team.py
@@ -31,7 +31,10 @@ class Team(models.Model):
compute='_compute_overdue_opportunities',
string='Overdue Opportunities Revenues')
- alias_user_id = fields.Many2one(domain=lambda self: [
+ # Since we are in a _inherits case, this is not an override
+ # but a plain definition of a field
+ # So we need to reset the property related of that field
+ alias_user_id = fields.Many2one('res.users', related='alias_id.alias_user_id', inherited=True, domain=lambda self: [
('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)])
def _compute_unassigned_leads_count(self):
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/data/gamification_karma_rank_data.xml b/addons/gamification/data/gamification_karma_rank_data.xml
index 2a5ad0231fe..3833dd24b7e 100644
--- a/addons/gamification/data/gamification_karma_rank_data.xml
+++ b/addons/gamification/data/gamification_karma_rank_data.xml
@@ -1,20 +1,75 @@
-
-
-
-
- 2500
-
-
- 2500
-
+
+
+
+ 2500
+
+
+ 2500
+
-
-
- Newbie
-
You just began the adventure! Welcome!
- 1
-
-
-
-
\ No newline at end of file
+
+
+ Newbie
+
You just began the adventure! Welcome!
+
+
+
Reach the next rank to show the rest of the world you exist!
+
+
+
+ 1
+
+
+
+
+ Student
+
You're a young padawan now. May the force be with you!
+
+
+
Reach the next rank to improve your status.
+
+
+
+ 100
+
+
+
+
+ Bachelor
+
You love learning things. Curiosity is a good way to progress.
+
+
+
Reach the next rank and become a Master!
+
+
+
+ 500
+
+
+
+
+ Master
+
You know what you are talking about. People learn from you.
diff --git a/addons/hr_holidays/security/hr_holidays_security.xml b/addons/hr_holidays/security/hr_holidays_security.xml
index 1d534a94648..8bc6852d3fe 100644
--- a/addons/hr_holidays/security/hr_holidays_security.xml
+++ b/addons/hr_holidays/security/hr_holidays_security.xml
@@ -8,6 +8,7 @@
Team Approver
+
@@ -48,7 +49,7 @@
Time Off: approver: write on employee's time off in his team
- [('employee_id.leave_manager_id', '=' , user.id), ('holiday_type', '=', 'employee')]
+ ['&', ('holiday_type', '=', 'employee'), '|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]
@@ -123,6 +124,16 @@
+
+ Allocations: Team Leader: read employee's allocations in his team
+
+ ['|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]
+
+
+
+
+
+
Time Off Resources: Approver
diff --git a/addons/hr_holidays/tests/common.py b/addons/hr_holidays/tests/common.py
index 0f9c6f1cc89..510c6883b41 100644
--- a/addons/hr_holidays/tests/common.py
+++ b/addons/hr_holidays/tests/common.py
@@ -23,6 +23,9 @@ class TestHrHolidaysBase(common.TransactionCase):
self.user_hrmanager_2 = mail_new_test_user(self.env, login='florence', groups='base.group_user,hr_holidays.group_hr_holidays_manager')
self.user_hrmanager_2_id = self.user_hrmanager_2.id
+ self.user_team_leader = mail_new_test_user(self.env, login='arthur', groups='base.group_user,hr_holidays.group_hr_holidays_team_leader')
+ self.user_team_leader_id = self.user_team_leader.id
+
# Hr Data
Department = self.env['hr.department'].with_context(tracking_disable=True)
diff --git a/addons/hr_holidays/tests/test_access_rights.py b/addons/hr_holidays/tests/test_access_rights.py
index ca651a69010..00ac2856d20 100644
--- a/addons/hr_holidays/tests/test_access_rights.py
+++ b/addons/hr_holidays/tests/test_access_rights.py
@@ -362,6 +362,37 @@ class TestLeavesRights(TestHrHolidaysBase):
self.employee_leave.sudo(self.user_hruser).action_approve()
self.employee_leave.sudo(self.user_hrmanager_id).action_validate()
+ # ----------------------------------------
+ # Validation: team leader
+ # ----------------------------------------
+
+ @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
+ def test_leave_validation_team_leader_other(self):
+ """ Team Leader validates its employee's leave only"""
+ self.leave_type.write({'allocation_type': 'fixed'})
+ self.env['hr.leave.allocation'].create({
+ 'holiday_status_id': self.leave_type.id,
+ 'employee_id': self.employee_emp.id,
+ 'number_of_days': 10,
+ }).action_approve()
+ with self.assertRaises(AccessError):
+ self.employee_leave.sudo(self.user_team_leader).action_approve()
+
+ @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
+ def test_leave_validation_team_leader(self):
+ """ Team Leader validates its employee's leave"""
+ self.leave_type.write({'allocation_type': 'fixed'})
+
+ # set the team leader
+ self.employee_emp.leave_manager_id = self.user_team_leader
+
+ self.env['hr.leave.allocation'].create({
+ 'holiday_status_id': self.leave_type.id,
+ 'employee_id': self.employee_emp.id,
+ 'number_of_days': 10,
+ }).action_approve()
+ self.employee_leave.sudo(self.user_team_leader).action_approve()
+
class TestMultiCompany(TestHrHolidaysBase):
diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py
index 67d468b0a44..ea025d571cd 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/hr_payroll/models/hr_benefit.py b/addons/hr_payroll/models/hr_benefit.py
index 6c3367fd3aa..f75e115108d 100644
--- a/addons/hr_payroll/models/hr_benefit.py
+++ b/addons/hr_payroll/models/hr_benefit.py
@@ -275,9 +275,9 @@ class HrBenefit(models.Model):
}]
self.env['resource.calendar.attendance'].create(vals_list)
- @api.model
- def action_validate(self, ids):
- benefits = self.env['hr.benefit'].search([('id', 'in', ids), ('state', '!=', 'validated')])
+ @api.multi
+ def action_validate(self):
+ benefits = self.filtered(lambda benefit: benefit.state != 'validated')
benefits.write({'display_warning': False})
if not benefits._check_if_error():
benefits.write({'state': 'validated'})
@@ -311,4 +311,4 @@ class Contacts(models.Model):
_sql_constraints = [
('user_id_employee_id_unique', 'UNIQUE(user_id,employee_id)', 'You cannot have twice the same employee.')
- ]
\ No newline at end of file
+ ]
diff --git a/addons/hr_payroll/tests/test_benefit.py b/addons/hr_payroll/tests/test_benefit.py
index 083a9c287ac..3100ff6db43 100644
--- a/addons/hr_payroll/tests/test_benefit.py
+++ b/addons/hr_payroll/tests/test_benefit.py
@@ -1,10 +1,8 @@
# # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from datetime import datetime
import pytz
from dateutil.relativedelta import relativedelta
-from odoo import exceptions
from odoo.fields import Datetime, Date
from odoo.tests.common import tagged
from odoo.addons.hr_payroll.tests.common import TestPayslipBase
@@ -130,7 +128,7 @@ class TestBenefit(TestPayslipBase):
'date_stop': end,
'benefit_type_id': self.benefit_type.id,
})
- benefit.action_validate(benefit.ids)
+ benefit.action_validate()
benefits = self.env['hr.benefit'].search([('employee_id', '=', self.richard_emp.id)])
self.assertTrue(all((b.state == 'validated' for b in benefits)), "Benefits should be approved")
self.assertEqual(len(benefits), 3, "Benefit should be split in three")
@@ -204,7 +202,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- benef.action_validate(benef.ids)
+ benef.action_validate()
calendar_leave = self.env['resource.calendar.leaves'].search([('name', '=', 'Richard leave from benef')])
self.assertTrue(calendar_leave, "It should have created a leave in the calendar")
self.assertEqual(calendar_leave.benefit_type_id, benef.benefit_type_id, "It should have the same benefit type")
@@ -220,7 +218,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end + relativedelta(hours=5),
})
- benef2 = self.env['hr.benefit'].create({
+ self.env['hr.benefit'].create({
'name': '2',
'employee_id': self.richard_emp.id,
'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id,
@@ -228,7 +226,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start + relativedelta(hours=3),
'date_stop': end,
})
- self.assertFalse(benef1.action_validate(benef1.ids), "It should not validate benefits conflicting with others")
+ self.assertFalse(benef1.action_validate(), "It should not validate benefits conflicting with others")
self.assertTrue(benef1.display_warning)
self.assertNotEqual(benef1.state, 'validated')
@@ -241,7 +239,7 @@ class TestBenefit(TestPayslipBase):
'date_start': self.start,
'date_stop': self.end,
})
- leave_1 = self.env['hr.leave'].create({
+ self.env['hr.leave'].create({
'name': 'Doctor Appointment',
'employee_id': self.richard_emp.id,
'holiday_status_id': self.leave_type.id,
@@ -249,7 +247,7 @@ class TestBenefit(TestPayslipBase):
'date_to': self.start + relativedelta(days=1),
'number_of_days': 2,
})
- self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits conflicting with non approved leaves")
+ self.assertFalse(benef1.action_validate(), "It should not validate benefits conflicting with non approved leaves")
self.assertTrue(benef1.display_warning)
def test_validate_undefined_benefit(self):
@@ -260,7 +258,7 @@ class TestBenefit(TestPayslipBase):
'date_start': self.start,
'date_stop': self.end,
})
- self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits without a type")
+ self.assertFalse(benef1.action_validate(), "It should not validate benefits without a type")
def test_approve_leave_benefit(self):
start = self.to_datetime_tz('2015-11-01 09:00:00')
@@ -273,7 +271,7 @@ class TestBenefit(TestPayslipBase):
'date_to': start + relativedelta(days=1),
'number_of_days': 2,
})
- benef = self.env['hr.benefit'].create({
+ self.env['hr.benefit'].create({
'name': '1',
'employee_id': self.richard_emp.id,
'contract_id': self.richard_emp.contract_id.id,
@@ -299,7 +297,7 @@ class TestBenefit(TestPayslipBase):
self.assertTrue(new_benef, "It should have created a benefit for the last two days")
self.assertTrue(new_leave_benef, "It should have created a leave benefit for the first day")
- self.assertTrue(benef.action_validate((new_benef | new_leave_benef).ids), "It should be able to validate the benefits")
+ self.assertTrue((new_benef | new_leave_benef).action_validate(), "It should be able to validate the benefits")
def test_refuse_leave_benefit(self):
start = self.to_datetime_tz('2015-11-01 09:00:00')
@@ -321,7 +319,7 @@ class TestBenefit(TestPayslipBase):
'date_stop': end,
'leave_id': leave.id
})
- benef.action_validate(benef.ids)
+ benef.action_validate()
self.assertTrue(benef.display_warning, "It should have an error (conflicting leave to approve")
leave.action_refuse()
self.assertFalse(benef.display_warning, "It should not have an error")
@@ -342,7 +340,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- benef.action_validate(benef.ids)
+ benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type, self.start, self.end)
self.assertEqual(data['hours'], 7.0)
@@ -358,7 +356,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- leave_benef.action_validate(leave_benef.ids)
+ leave_benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
self.assertEqual(data['hours'], 5.0, "It should equal the number of hours richard should have worked")
@@ -374,7 +372,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- leave_benef.action_validate(leave_benef.ids)
+ leave_benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
self.assertEqual(data['hours'], 0.0, "It should equal the number of hours richard should have worked")
@@ -390,7 +388,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- leave_benef.action_validate(leave_benef.ids)
+ leave_benef.action_validate()
payslip_wizard = self.env['hr.payslip.employees'].create({'employee_ids': [(4, self.richard_emp.id)]})
payslip_wizard.with_context({'default_date_start': Date.to_string(start), 'default_date_end': Date.to_string(end)}).compute_sheet()
payslip = self.env['hr.payslip'].search([('employee_id', '=', self.richard_emp.id)])
@@ -414,7 +412,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- benef.action_validate(benef.ids)
+ benef.action_validate()
payslip_wizard = self.env['hr.payslip.employees'].create({'employee_ids': [(4, self.richard_emp.id)]})
payslip_wizard.with_context({
'default_date_start': Date.to_string(start),
diff --git a/addons/hr_skills/static/src/js/resume_widget.js b/addons/hr_skills/static/src/js/resume_widget.js
index 4249fca884b..7bb2023b76f 100644
--- a/addons/hr_skills/static/src/js/resume_widget.js
+++ b/addons/hr_skills/static/src/js/resume_widget.js
@@ -90,7 +90,7 @@ var AbstractGroupedOne2ManyRenderer = ListRenderer.extend({
return qweb.render('hr_trash_button');
},
- _renderAddItemButton(group) {
+ _renderAddItemButton: function (group) {
return qweb.render(this.addLineButtonTemplate, {
context: JSON.stringify(this._getCreateLineContext(group)),
});
@@ -141,8 +141,8 @@ var ResumeLineRenderer = AbstractGroupedOne2ManyRenderer.extend({
var date_start = data.date_start && data.date_start.format(dateFormat) || "";
var date_end = data.date_end && data.date_end.format(dateFormat) || _t("Current");
return _.extend(data, {
- date_start,
- date_end,
+ date_start: date_start,
+ date_end: date_end,
});
},
diff --git a/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml b/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml
index f65c72e610e..947cfbb98f7 100644
--- a/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml
+++ b/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml
@@ -18,52 +18,52 @@
-
+
days / year
-
+
€ / month
-
+
€ / month
-
+
€ / month
-
+
€ / month
-
+
€ / month
-
+
€ / month
-
+
€ / month
-
+
€ / month
-
+
€ / year
diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py
index 25dcb719b5c..d91f4858c88 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/static/src/scss/lunch_kanban.scss b/addons/lunch/static/src/scss/lunch_kanban.scss
index 2e589ed912f..7c50ae36358 100644
--- a/addons/lunch/static/src/scss/lunch_kanban.scss
+++ b/addons/lunch/static/src/scss/lunch_kanban.scss
@@ -1,11 +1,3 @@
-.o_list_view {
- .o_data_row {
- .o_text_overflow {
- @include o-text-overflow(table-cell);
- }
- }
-}
-
.o_lunch_kanban {
display: flex;
flex-direction: column; // display lunch widget above kanban renderer
diff --git a/addons/lunch/views/lunch_orders_views.xml b/addons/lunch/views/lunch_orders_views.xml
index db39dabbc29..0a637869776 100644
--- a/addons/lunch/views/lunch_orders_views.xml
+++ b/addons/lunch/views/lunch_orders_views.xml
@@ -61,7 +61,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 d8ace68fd62..70072a16785 100644
--- a/addons/mail/models/mail_activity.py
+++ b/addons/mail/models/mail_activity.py
@@ -130,9 +130,9 @@ class MailActivity(models.Model):
activity_type_id = fields.Many2one(
'mail.activity.type', string='Activity Type',
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')
date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today)
@@ -149,14 +149,14 @@ 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',
help='Technical field for UX purpose')
- mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=False)
- force_next = fields.Boolean(related='activity_type_id.force_next', readonly=False)
+ mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=True)
+ force_next = fields.Boolean(related='activity_type_id.force_next', readonly=True)
# access
can_write = fields.Boolean(compute='_compute_can_write', help='Technical field to hide buttons if the current user has no access.')
@@ -296,6 +296,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/models/mail_tracking_value.py b/addons/mail/models/mail_tracking_value.py
index 707f3f6bd75..570757a6534 100644
--- a/addons/mail/models/mail_tracking_value.py
+++ b/addons/mail/models/mail_tracking_value.py
@@ -40,7 +40,9 @@ class MailTracking(models.Model):
def _compute_groups(self):
for tracking in self:
model = self.env[tracking.mail_message_id.model]
- tracking.groups = model._fields[tracking.field].groups
+ field = model._fields.get(tracking.field)
+ # field may not exist anymore if module has been uninstalled
+ tracking.groups = field.groups if field else 'base.group_system'
@api.model
def create_tracking_values(self, initial_value, new_value, col_name, col_info, tracking_sequence):
diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js
index 7ad8a36b39a..8aa8b251343 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 56945533946..ee4e543dbe9 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 7dc8be4320c..f25c7082648 100644
--- a/addons/mail/static/tests/discuss_tests.js
+++ b/addons/mail/static/tests/discuss_tests.js
@@ -507,6 +507,77 @@ QUnit.test('@ mention with special chars', async function (assert) {
discuss.destroy();
});
+QUnit.test('@ mention with removed space', async function (assert) {
+ assert.expect(4);
+ var fetchListenersDef = testUtils.makeTestPromise();
+ var receiveMessageDef = testUtils.makeTestPromise();
+ this.data.initMessaging = {
+ channel_slots: {
+ channel_channel: [{
+ id: 1,
+ channel_type: "channel",
+ name: "general",
+ }],
+ },
+ };
+ var objectDiscuss;
+ var discuss = await createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return Promise.resolve([
+ {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 Promise.resolve(42);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ 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');
+ await fetchListenersDef;
+ await testUtils.nextTick();
+ // equivalent to $mentionPropositions.find('active').click();
+ $input.trigger($.Event('keyup', {which: $.ui.keyCode.ENTER}));
+ $input.val('@Admin: hi');
+ // send message
+ await testUtils.fields.triggerKeydown($input, 'enter');
+ await receiveMessageDef;
+ await testUtils.nextTick();
+ 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();
+});
+
QUnit.test('@ mention in mailing channel', async function (assert) {
assert.expect(8);
diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py
index c83f52f68c6..2d569425119 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, ... """
@@ -675,6 +681,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/static/src/js/mass_mailing_widget.js b/addons/mass_mailing/static/src/js/mass_mailing_widget.js
index 866213b9092..ad3e047ec87 100644
--- a/addons/mass_mailing/static/src/js/mass_mailing_widget.js
+++ b/addons/mass_mailing/static/src/js/mass_mailing_widget.js
@@ -43,6 +43,9 @@ var MassMailingFieldHtml = FieldHtml.extend({
*/
commitChanges: function () {
var self = this;
+ if (!this.wysiwyg || !this.isRendered) {
+ return this._super();
+ }
var fieldName = this.nodeOptions['inline-field'];
if (this.mode == "readonly") {
diff --git a/addons/mass_mailing/tests/test_mass_mailing_shortener.py b/addons/mass_mailing/tests/test_mass_mailing_shortener.py
index 456498f3c74..d279208264d 100644
--- a/addons/mass_mailing/tests/test_mass_mailing_shortener.py
+++ b/addons/mass_mailing/tests/test_mass_mailing_shortener.py
@@ -56,6 +56,7 @@ class TestMassMailingShortener(common.TransactionCase):
"contact_list_ids": [[6, False, [mailing_list_A.id]]],
"mass_mailing_campaign_id": False,
"name": "sdf",
+ "subject": "test_00_test_mass_mailing_shortener",
"body_html": """
Hi,
% set url = "www.odoo.com"
diff --git a/addons/mass_mailing/views/unsubscribe_templates.xml b/addons/mass_mailing/views/unsubscribe_templates.xml
index 3e89131f7e5..bf7b4b54e84 100644
--- a/addons/mass_mailing/views/unsubscribe_templates.xml
+++ b/addons/mass_mailing/views/unsubscribe_templates.xml
@@ -46,7 +46,7 @@
Blacklist Me
Come Back
-
+
diff --git a/addons/mrp/models/mrp_abstract_workorder.py b/addons/mrp/models/mrp_abstract_workorder.py
index 4a6e65648e4..0cf35f4c7e8 100644
--- a/addons/mrp/models/mrp_abstract_workorder.py
+++ b/addons/mrp/models/mrp_abstract_workorder.py
@@ -84,10 +84,9 @@ class MrpAbstractWorkorder(models.AbstractModel):
for move_line in move_raw.move_line_ids:
# Get workorder lines that match reservation.
candidates = move_workorder_lines._find_candidate(move_line)
- while candidates:
+ for candidate in candidates:
if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0:
break
- candidate = candidates.pop()
qty_to_add = move_line.product_uom_qty - candidate.qty_done
line_values['to_update'][candidate] = {
'qty_done': candidate.qty_done + qty_to_add,
@@ -136,7 +135,7 @@ class MrpAbstractWorkorder(models.AbstractModel):
'qty_to_consume': to_consume_in_line,
'qty_reserved': move_line.product_uom_qty,
'lot_id': move_line.lot_id.id,
- 'qty_done': is_tracked and 0 or to_consume_in_line
+ 'qty_done': to_consume_in_line
}
lines.append(line)
qty_to_consume -= to_consume_in_line
@@ -159,7 +158,7 @@ class MrpAbstractWorkorder(models.AbstractModel):
'product_id': move.product_id.id,
'product_uom_id': move.product_uom.id,
'qty_to_consume': qty_to_consume,
- 'qty_done': is_tracked and 0 or qty_to_consume
+ 'qty_done': qty_to_consume
}
lines.append(line)
return lines
diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py
index 311b116c586..02418f2c588 100644
--- a/addons/mrp/models/mrp_bom.py
+++ b/addons/mrp/models/mrp_bom.py
@@ -94,6 +94,8 @@ class MrpBom(models.Model):
self.product_uom_id = self.product_tmpl_id.uom_id.id
if self.product_id.product_tmpl_id != self.product_tmpl_id:
self.product_id = False
+ for line in self.bom_line_ids:
+ line.attribute_value_ids = False
@api.onchange('routing_id')
def onchange_routing_id(self):
@@ -292,6 +294,8 @@ class MrpBomLine(models.Model):
@api.onchange('parent_product_tmpl_id')
def onchange_parent_product(self):
+ if not self.parent_product_tmpl_id:
+ return {}
return {'domain': {'attribute_value_ids': [
('id', 'in', self.parent_product_tmpl_id._get_valid_product_attribute_values().ids),
('attribute_id.create_variant', '!=', 'no_variant')
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index ba2afbc20dc..11c96450c2b 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -605,7 +605,8 @@ class MrpProduction(models.Model):
for move_raw in production.move_raw_ids:
move_raw.write({
'group_id': production.procurement_group_id.id,
- 'unit_factor': move_raw.product_uom_qty / production.product_qty
+ 'unit_factor': move_raw.product_uom_qty / production.product_qty,
+ 'reference': self.name, # set reference when MO name is different than 'New'
})
production._generate_finished_moves()
production.move_raw_ids._adjust_procure_method()
@@ -825,8 +826,8 @@ class MrpProduction(models.Model):
moves_to_finish = order.move_finished_ids.filtered(lambda x: x.state not in ('done','cancel'))
moves_to_finish._action_done()
order.action_assign()
- consume_move_lines = moves_to_do.mapped('active_move_line_ids')
- for moveline in moves_to_finish.mapped('active_move_line_ids'):
+ consume_move_lines = moves_to_do.mapped('move_line_ids')
+ for moveline in moves_to_finish.mapped('move_line_ids'):
if moveline.product_id == order.product_id and moveline.move_id.has_tracking != 'none':
if any([not ml.lot_produced_id for ml in consume_move_lines]):
raise UserError(_('You can not consume without telling for which lot you consumed it'))
diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py
index 9991be74969..e96527e1554 100644
--- a/addons/mrp/models/mrp_workorder.py
+++ b/addons/mrp/models/mrp_workorder.py
@@ -209,9 +209,6 @@ class MrpWorkorder(models.Model):
'location_dest_id': by_product_move.location_dest_id.id,
}
- def _link_to_quality_check(self, old_move_line, new_move_line):
- return True
-
@api.multi
def record_production(self):
if not self:
@@ -229,10 +226,6 @@ class MrpWorkorder(models.Model):
# TODO: should be same as checking if for every workorder something has been done?
if not self.next_work_order_id:
self._update_finished_move()
- self.production_id.move_finished_ids.filtered(
- lambda move: move.product_id == self.product_id and
- move.state not in ('done', 'cancel')
- ).workorder_id = self.id
# Transfer quantities from temporary to final move line or make them final
self._update_raw_moves()
diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py
index 85822eaa519..c04cdb2a063 100644
--- a/addons/mrp/models/stock_move.py
+++ b/addons/mrp/models/stock_move.py
@@ -16,7 +16,6 @@ class StockMoveLine(models.Model):
lot_produced_qty = fields.Float(
'Quantity Finished Product', digits=dp.get_precision('Product Unit of Measure'),
help="Informative, not used in matching")
- done_wo = fields.Boolean('Done for Work Order', default=True, help="Technical Field which is False when temporarily filled in in work order") # TDE FIXME: naming
done_move = fields.Boolean('Move Done', related='move_id.is_done', readonly=False, store=True) # TDE FIXME: naming
def _get_similar_move_lines(self):
@@ -24,12 +23,11 @@ class StockMoveLine(models.Model):
if self.move_id.production_id:
finished_moves = self.move_id.production_id.move_finished_ids
finished_move_lines = finished_moves.mapped('move_line_ids')
- lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name) and ml.done_wo == self.done_wo)
+ lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
if self.move_id.raw_material_production_id:
raw_moves = self.move_id.raw_material_production_id.move_raw_ids
raw_moves_lines = raw_moves.mapped('move_line_ids')
- raw_moves_lines |= self.move_id.active_move_line_ids
- lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name) and ml.done_wo == self.done_wo)
+ lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
return lines
def _reservation_is_updatable(self, quantity, reserved_quant):
@@ -46,7 +44,7 @@ class StockMoveLine(models.Model):
for move_line in self:
if move_line.move_id.production_id and 'lot_id' in vals:
move_line.production_id.move_raw_ids.mapped('move_line_ids')\
- .filtered(lambda r: r.done_wo and not r.done_move and r.lot_produced_id == move_line.lot_id)\
+ .filtered(lambda r: not r.done_move and r.lot_produced_id == move_line.lot_id)\
.write({'lot_produced_id': vals['lot_id']})
production = move_line.move_id.production_id or move_line.move_id.raw_material_production_id
if production and move_line.state == 'done' and any(field in vals for field in ('lot_id', 'location_id', 'qty_done')):
@@ -71,7 +69,6 @@ class StockMove(models.Model):
workorder_id = fields.Many2one(
'mrp.workorder', 'Work Order To Consume')
# Quantities to process, in normalized UoMs
- active_move_line_ids = fields.One2many('stock.move.line', 'move_id', domain=[('done_wo', '=', True)], string='Lots')
bom_line_id = fields.Many2one('mrp.bom.line', 'BoM Line')
unit_factor = fields.Float('Unit Factor')
is_done = fields.Boolean(
@@ -89,10 +86,6 @@ class StockMove(models.Model):
.filtered(lambda ml: ml.qty_done == 0.0)\
.write({'move_id': new_move, 'product_uom_qty': 0})
- @api.depends('active_move_line_ids.qty_done', 'active_move_line_ids.product_uom_id')
- def _compute_done_quantity(self):
- super(StockMove, self)._compute_done_quantity()
-
@api.depends('raw_material_production_id.move_finished_ids.move_line_ids.lot_id')
def _compute_order_finished_lot_ids(self):
for move in self:
@@ -116,13 +109,6 @@ class StockMove(models.Model):
if move.raw_material_production_id:
move.is_locked = move.raw_material_production_id.is_locked
- def _get_move_lines(self):
- self.ensure_one()
- if self.raw_material_production_id:
- return self.active_move_line_ids
- else:
- return super(StockMove, self)._get_move_lines()
-
@api.depends('state')
def _compute_is_done(self):
for move in self:
diff --git a/addons/mrp/models/stock_rule.py b/addons/mrp/models/stock_rule.py
index 4efbb4464ce..8f72e133c24 100644
--- a/addons/mrp/models/stock_rule.py
+++ b/addons/mrp/models/stock_rule.py
@@ -90,6 +90,7 @@ class StockRule(models.Model):
'picking_type_id': self.picking_type_id.id or values['warehouse_id'].manu_type_id.id,
'company_id': company_id.id,
'move_dest_ids': values.get('move_dest_ids') and [(4, x.id) for x in values['move_dest_ids']] or False,
+ 'user_id': False,
}
def _get_date_planned(self, product_id, company_id, values):
diff --git a/addons/mrp/models/stock_warehouse.py b/addons/mrp/models/stock_warehouse.py
index c66d15131c6..59fa4f39e36 100644
--- a/addons/mrp/models/stock_warehouse.py
+++ b/addons/mrp/models/stock_warehouse.py
@@ -168,9 +168,21 @@ class StockWarehouse(models.Model):
values = super(StockWarehouse, self)._get_locations_values(vals)
def_values = self.default_get(['manufacture_steps'])
manufacture_steps = vals.get('manufacture_steps', def_values['manufacture_steps'])
+ code = vals.get('code') or self.code
+ code = code.replace(' ', '').upper()
values.update({
- 'pbm_loc_id': {'name': _('Pre-Production'), 'active': manufacture_steps in ('pbm', 'pbm_sam'), 'usage': 'internal'},
- 'sam_loc_id': {'name': _('Post-Production'), 'active': manufacture_steps == 'pbm_sam', 'usage': 'internal'},
+ 'pbm_loc_id': {
+ 'name': _('Pre-Production'),
+ 'active': manufacture_steps in ('pbm', 'pbm_sam'),
+ 'usage': 'internal',
+ 'barcode': code + '-PREPRODUCTION'
+ },
+ 'sam_loc_id': {
+ 'name': _('Post-Production'),
+ 'active': manufacture_steps == 'pbm_sam',
+ 'usage': 'internal',
+ 'barcode': code + '-POSTPRODUCTION'
+ },
})
return values
diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py
index 25e693f3d0f..f970e5055e4 100644
--- a/addons/mrp/tests/test_order.py
+++ b/addons/mrp/tests/test_order.py
@@ -676,6 +676,32 @@ class TestMrpOrder(TestMrpCommon):
self.assertEqual(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).quantity_done, 20, 'Update the produce quantity should not impact already produced quantity.')
mo.button_mark_done()
+ def test_product_produce_6(self):
+ """ Build some final products and change the quantity to produce
+ directly in the wizard. The component line should be updated .
+ """
+ self.stock_location = self.env.ref('stock.stock_location_stock')
+ mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1)
+ self.assertEqual(len(mo), 1, 'MO should have been created')
+
+ mo.action_assign()
+
+ produce_form = Form(self.env['mrp.product.produce'].with_context({
+ 'active_id': mo.id,
+ 'active_ids': [mo.id],
+ }))
+ produce_form.qty_producing = 3
+ self.assertEqual(len(produce_form.workorder_line_ids._records), 4, 'Update the produce quantity should change the components quantity.')
+ self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 15, 'Update the produce quantity should change the components quantity.')
+ produce_form.qty_producing = 4
+ self.assertEqual(len(produce_form.workorder_line_ids._records), 6, 'Update the produce quantity should change the components quantity.')
+ self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 20, 'Update the produce quantity should change the components quantity.')
+ produce_form.qty_producing = 1
+ self.assertEqual(len(produce_form.workorder_line_ids._records), 2, 'Update the produce quantity should change the components quantity.')
+ self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 5, 'Update the produce quantity should change the components quantity.')
+ produce_wizard = produce_form.save()
+ produce_wizard.do_produce()
+
def test_product_produce_uom(self):
plastic_laminate = self.env.ref('mrp.product_product_plastic_laminate')
bom = self.env.ref('mrp.mrp_bom_plastic_laminate')
diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml
index 4e20348b328..2505a183016 100644
--- a/addons/mrp/views/mrp_unbuild_views.xml
+++ b/addons/mrp/views/mrp_unbuild_views.xml
@@ -85,7 +85,7 @@
-
+
diff --git a/addons/mrp/views/stock_move_views.xml b/addons/mrp/views/stock_move_views.xml
index ef531c2cc45..46b52a10513 100644
--- a/addons/mrp/views/stock_move_views.xml
+++ b/addons/mrp/views/stock_move_views.xml
@@ -49,7 +49,7 @@
-
+
@@ -61,7 +61,6 @@
-
diff --git a/addons/payment_authorize/models/payment.py b/addons/payment_authorize/models/payment.py
index c1380db1fd6..5a6ed0bf8e1 100644
--- a/addons/payment_authorize/models/payment.py
+++ b/addons/payment_authorize/models/payment.py
@@ -6,6 +6,7 @@ from datetime import datetime
import hashlib
import hmac
import logging
+import string
import time
from odoo import _, api, fields, models
@@ -54,8 +55,16 @@ class PaymentAcquirerAuthorize(models.Model):
values['x_fp_sequence'],
values['x_fp_timestamp'],
values['x_amount'],
- values['x_currency_code']])
- return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
+ values['x_currency_code']]).encode('utf-8')
+
+ # [BACKWARD COMPATIBILITY] Check that the merchant did update his transaction
+ # key to signature key (end of MD5 support from Authorize.net)
+ # The signature key is now '128-character hexadecimal format', while the
+ # transaction key was only 16-character.
+ if len(values['x_trans_key']) == 128:
+ return hmac.new(values['x_trans_key'].decode("hex").encode('utf-8'), data, hashlib.sha512).hexdigest().upper()
+ else:
+ return hmac.new(values['x_trans_key'].encode('utf-8'), data, hashlib.md5).hexdigest()
@api.multi
def authorize_form_generate_values(self, values):
@@ -172,7 +181,7 @@ class TxAuthorize(models.Model):
def _authorize_form_get_tx_from_data(self, data):
""" Given a data dict coming from authorize, verify it and find the related
transaction record. """
- reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_MD5_Hash')
+ reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_SHA2_Hash') or data.get('x_MD5_Hash')
if not reference or not trans_id or not fingerprint:
error_msg = _('Authorize: received data with missing reference (%s) or trans_id (%s) or fingerprint (%s)') % (reference, trans_id, fingerprint)
_logger.info(error_msg)
diff --git a/addons/payment_authorize/tests/test_authorize.py b/addons/payment_authorize/tests/test_authorize.py
index a45275b07d8..7e12b572419 100644
--- a/addons/payment_authorize/tests/test_authorize.py
+++ b/addons/payment_authorize/tests/test_authorize.py
@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
-import hashlib
-import hmac
import time
from werkzeug import urls
from lxml import objectify
@@ -29,15 +27,6 @@ class AuthorizeCommon(PaymentAcquirerCommon):
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'external')
class AuthorizeForm(AuthorizeCommon):
- def _authorize_generate_hashing(self, values):
- data = '^'.join([
- values['x_login'],
- values['x_fp_sequence'],
- values['x_fp_timestamp'],
- values['x_amount'],
- ]) + '^'
- return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
-
def test_10_Authorize_form_render(self):
self.assertEqual(self.authorize.environment, 'test', 'test without test environment')
@@ -82,7 +71,7 @@ class AuthorizeForm(AuthorizeCommon):
'x_ship_to_state': None,
}
- form_values['x_fp_hash'] = self._authorize_generate_hashing(form_values)
+ form_values['x_fp_hash'] = self.env['payment.acquirer']._authorize_generate_hashing(form_values)
# render the button
res = self.authorize.render('SO004', 56.16, self.currency_usd.id, values=self.buyer_values)
# check form result
@@ -109,7 +98,9 @@ class AuthorizeForm(AuthorizeCommon):
# typical data posted by authorize after client has successfully paid
authorize_post_data = {
'return_url': u'/shop/payment/validate',
+ # x_MD5_Hash will be empty starting the 28th March 2019
'x_MD5_Hash': u'7934485E1C105940BE854208D10FAB4F',
+ 'x_SHA2_Hash': u'7D3AC844BE8CA3F649AB885A90D22CFE35B850338EC91D1A5ADD819A85FF948A3D777334A18CDE36821DC8F2B42A6E1950C1FF96B52B60F23201483A656195FB',
'x_account_number': u'XXXX0027',
'x_address': u'Huge Street 2/543',
'x_amount': u'320.00',
diff --git a/addons/payment_payulatam/models/payment.py b/addons/payment_payulatam/models/payment.py
index 8389f6eb42e..df487543c45 100644
--- a/addons/payment_payulatam/models/payment.py
+++ b/addons/payment_payulatam/models/payment.py
@@ -82,7 +82,7 @@ class PaymentTransactionPayulatam(models.Model):
if not reference or not txnid or not sign:
raise ValidationError(_('PayUlatam: received data with missing reference (%s) or transaction id (%s) or sign (%s)') % (reference, txnid, sign))
- transaction = self.search([('acquirer_reference', '=', reference)])
+ transaction = self.search([('reference', '=', reference)])
if not transaction:
error_msg = (_('PayUlatam: received data for reference %s; no order found') % (reference))
diff --git a/addons/payment_payulatam/tests/test_payulatam.py b/addons/payment_payulatam/tests/test_payulatam.py
index 2bec371b81a..10914a98308 100644
--- a/addons/payment_payulatam/tests/test_payulatam.py
+++ b/addons/payment_payulatam/tests/test_payulatam.py
@@ -121,10 +121,9 @@ class PayUlatamForm(PayUlatamCommon):
'amount': 0.01,
'acquirer_id': self.payulatam.id,
'currency_id': self.currency_euro.id,
- 'reference': 'test_ref_2',
+ 'reference': 'test_ref_10',
'partner_name': 'Norbert Buyer',
'partner_country_id': self.country_france.id,
- 'acquirer_reference': 'test_ref_10',
'partner_id': self.buyer_id})
# validate transaction
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css
index ed137077f64..8fefe02fa09 100644
--- a/addons/point_of_sale/static/src/css/pos.css
+++ b/addons/point_of_sale/static/src/css/pos.css
@@ -1479,6 +1479,8 @@ td {
body {
margin: 0;
color: white !important;
+ /* avoid black background if backgrounds are printed */
+ background: initial;
}
.oe_leftbar,
.oe_loading,
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index 56e460c4a85..1d08da927ea 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -1326,7 +1326,7 @@ var ClientListScreenWidget = ScreenWidget.extend({
self.save_client_details(partner);
});
};
- this.reload_partners().then( function() {
+ return this.reload_partners().then( function() {
var partner = self.pos.db.get_partner_by_id(partner_id);
if (partner) {
self.new_client = partner;
diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml
index 18e6a3ec53e..6bccce47ce5 100644
--- a/addons/point_of_sale/views/pos_order_view.xml
+++ b/addons/point_of_sale/views/pos_order_view.xml
@@ -146,7 +146,7 @@
diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py
index fbfa39ebe89..2efa54b5c17 100644
--- a/addons/portal/controllers/portal.py
+++ b/addons/portal/controllers/portal.py
@@ -55,6 +55,10 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None):
'url': get_url(page),
'num': page
},
+ "page_first": {
+ 'url': get_url(1),
+ 'num': 1
+ },
"page_start": {
'url': get_url(pmin),
'num': pmin
@@ -71,6 +75,10 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None):
'url': get_url(pmax),
'num': pmax
},
+ "page_last": {
+ 'url': get_url(page_count),
+ 'num': page_count
+ },
"pages": [
{'url': get_url(page_num), 'num': page_num} for page_num in range(pmin, pmax+1)
]
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 24c7db102a9..d1f95da76b5 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -301,6 +301,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
@@ -309,6 +311,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
@@ -329,6 +338,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
@@ -617,6 +628,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 26a827cb9f1..56e817135ca 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')
@@ -145,6 +166,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()
@@ -464,7 +489,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 = {
@@ -490,8 +515,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:
@@ -523,6 +548,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):
@@ -536,7 +565,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.
@@ -545,42 +574,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):
@@ -589,6 +644,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.
@@ -688,19 +750,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):
@@ -712,19 +763,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):
@@ -760,17 +800,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
@@ -812,40 +855,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.
@@ -854,63 +921,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 faf0ce4e454..5aab1bd8977 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -52,7 +52,7 @@
-
+ per
@@ -62,6 +62,7 @@
+
diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py
index 6c9871cb6e9..2cf546eab41 100644
--- a/addons/purchase_requisition/models/purchase_requisition.py
+++ b/addons/purchase_requisition/models/purchase_requisition.py
@@ -244,7 +244,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 82496969d51..18b5a82dd3d 100644
--- a/addons/purchase_requisition/views/purchase_requisition_views.xml
+++ b/addons/purchase_requisition/views/purchase_requisition_views.xml
@@ -30,7 +30,7 @@
-
+
@@ -225,7 +225,7 @@
-
+
diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py
index a2d45c57c31..968bcffef67 100644
--- a/addons/purchase_stock/models/purchase.py
+++ b/addons/purchase_stock/models/purchase.py
@@ -370,12 +370,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 _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values):
""" Return the record in self where the procument with values passed as
diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml
index 787b43e7146..30b6f7a369c 100644
--- a/addons/repair/views/repair_views.xml
+++ b/addons/repair/views/repair_views.xml
@@ -208,7 +208,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 50c42763b67..70db8f8c5d2 100644
--- a/addons/sale/data/mail_data.xml
+++ b/addons/sale/data/mail_data.xml
@@ -57,7 +57,7 @@
-
+
diff --git a/addons/sale/models/payment.py b/addons/sale/models/payment.py
index 167889bfbbd..04af968fa3e 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._create_invoices()
trans.invoice_ids = [(6, 0, invoices)]
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
index 3867c8af56f..3ce86eab876 100644
--- a/addons/sale/models/product_template.py
+++ b/addons/sale/models/product_template.py
@@ -171,7 +171,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
@@ -196,6 +196,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)
@@ -217,6 +220,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)
@@ -224,10 +229,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)
@@ -255,8 +262,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')))
@@ -283,8 +288,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.
@@ -295,9 +299,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 27315c8d5a2..43cdf1d8c15 100644
--- a/addons/sale/models/res_config_settings.py
+++ b/addons/sale/models/res_config_settings.py
@@ -74,6 +74,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 cbcf2c79f23..22b8d24e131 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -304,7 +304,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('account.use_invoice_terms') and self.env.user.company_id.invoice_terms:
values['note'] = self.with_context(lang=self.partner_id.lang).env.user.company_id.invoice_terms
@@ -636,11 +636,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
@@ -1428,6 +1432,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 = {}
@@ -1440,16 +1454,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 1bf7cca730a..25cce5c4260 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 e8ad60b66d6..3bcebf88725 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
@@ -438,7 +438,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) {
+ $('
- We are conducting a survey and your response would be appreciated.
+ % if object.survey_id.certificate:
+ You have been invited to take a new certification.
+ % else:
+ We are conducting a survey and your response would be appreciated.
+ % endif
+
+
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 4719ad271d5..bc323eaec2f 100644
--- a/addons/test_website/static/src/js/reset_views.test.js
+++ b/addons/test_website/static/src/js/reset_views.test.js
@@ -21,7 +21,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.
@@ -77,7 +77,7 @@ tour.register('test_reset_page_view_complete_flow_part2', {
},
{
content: "select oe_structure view",
- trigger: '#ace-view-list',
+ trigger: '#s2id_ace-view-list', // use select2 version
run: function () {
var viewId = $('#ace-view-list option:contains("oe_structure")').val();
$('#ace-view-list').val(viewId).trigger('change');
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_reset_views.py b/addons/test_website/tests/test_reset_views.py
index 930d3b00a42..047b1abe3cd 100644
--- a/addons/test_website/tests/test_reset_views.py
+++ b/addons/test_website/tests/test_reset_views.py
@@ -1,6 +1,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
-import unittest
import odoo.tests
from odoo.tools import mute_logger
@@ -95,7 +94,6 @@ class TestWebsiteResetViews(odoo.tests.HttpCase):
self.assertEqual(total_views + 1, self.View.search_count([('type', '=', 'qweb')]), "Missing COW view (2)")
self.fix_it('/test_view')
- @unittest.skip("broken test. Temporary deactivated")
@mute_logger('odoo.addons.website.models.ir_http')
def test_07_reset_page_view_complete_flow(self):
self.do_test('test_reset_page_view_complete_flow_part1')
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': '
- Keep learning with . Collect points on the forum or on the eLearning platform. Those points will make you reach new ranks.
-
-
- How do I earn badges? When you finish a course or reach milestones, you're awarded badges.
-
-
- How do I score more points? You can score more points by answering quizzes at the end of each course content. Points can also be earned on the forum. Follow this link to the guidelines of the forum.
-
+
+
+
Ranks & Badges
+
Keep learning with . Collect points on the forum or on the eLearning platform. Those points will make you reach new ranks.
+
How do I earn badges?
+
When you finish a course or reach milestones, you're awarded badges.
+
How do I score more points?
+
You can score more points by answering quizzes at the end of each course content. Points can also be earned on the forum. Follow this link to the guidelines of the forum.
+
+
+
+
+
-
-
-
-
-
Ranks
-
-
-
-
-
points
+
Ranks
+
+
+
+
+
+
+
+
+
+ points
+
-
+
@@ -355,26 +333,23 @@
-
- Badges
-
-
+
Badges
+
Besides gaining reputation with your questions and answers,
- you receive badges for being especially helpful. Badges
+ you receive badges for being especially helpful. Badges
appear on your profile page, and your posts.
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
awarded users
-
+
+
@@ -398,79 +373,94 @@
-->
-
-
+
+
+
+
-
-
-
Users
+
+
+
All Users
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
1st
-
2nd
-
3rd
-
-
-
-
-
XP
-
Badges
+
+
+
+
+
+
+
-
-
-
-
-
th
-
-
-
-
-
-
-
-
-
-
XP
-
Badges
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ XP
+
+
+ Badges
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 5e4956a7319..d22b69b8636 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -230,7 +230,7 @@ class WebsiteSale(ProductConfiguratorController):
if attrib_list:
post['attrib'] = attrib_list
- Product = request.env['product.template']
+ Product = request.env['product.template'].with_context(bin_size=True)
Category = request.env['product.public.category']
search_categories = False
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
index 5670bbcb561..c1086108f73 100644
--- a/addons/website_sale/models/product.py
+++ b/addons/website_sale/models/product.py
@@ -138,7 +138,7 @@ class ProductTemplate(models.Model):
def _website_price(self):
current_website = self.env['website'].get_current_website()
for template in self.with_context(website_id=current_website.id):
- res = template._get_combination_info(template._get_first_possible_combination())
+ res = template._get_combination_info()
template.website_price = res.get('price')
template.website_public_price = res.get('list_price')
template.website_price_difference = res.get('has_discounted_price')
@@ -168,9 +168,12 @@ class ProductTemplate(models.Model):
@api.multi
def _is_quick_add_to_cart_possible(self, parent_combination=None):
"""
- It's possible to quickly add to cart if there's no optional product
- and there's only one possible combination, and no attribute is set
- to dynamic or no_variant, and no value is set to is_custom.
+ It's possible to quickly add to cart if there's no optional product,
+ there's only one possible combination and no value is set to is_custom.
+
+ Attributes set to dynamic or no_variant don't have to be tested
+ specifically because they will be taken into account when checking for
+ the possible combinations.
:param parent_combination: combination from which `self` is an
optional or accessory product
@@ -183,15 +186,14 @@ class ProductTemplate(models.Model):
if not self._is_add_to_cart_possible(parent_combination):
return False
- if len(self._get_possible_variants(parent_combination)) != 1:
- return False
- if self._has_no_variant_attributes():
- return False
- if self.has_dynamic_attributes():
+ gen = self._get_possible_combinations(parent_combination)
+ first_possible_combination = next(gen)
+ if next(gen, False) is not False:
+ # there are at least 2 possible combinations.
return False
if self._has_is_custom_values():
return False
- if self.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(self._get_first_possible_combination())):
+ if self.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(first_possible_combination)):
return False
return True
@@ -202,7 +204,8 @@ class ProductTemplate(models.Model):
The order is based on the order of the attributes and their values.
See `_get_possible_variants` for the limitations of this method with
- dynamic or no_variant attributes.
+ dynamic or no_variant attributes, and also for a warning about
+ performances.
:param parent_combination: combination from which `self` is an
optional or accessory product
@@ -233,7 +236,7 @@ class ProductTemplate(models.Model):
return self._get_possible_variants(parent_combination).sorted(_sort_key_variant)
@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):
"""Override for website, where we want to:
- take the website pricelist if no pricelist is set
- apply the b2b/b2c setting to the result
@@ -250,7 +253,9 @@ class ProductTemplate(models.Model):
if not pricelist:
pricelist = current_website.get_current_pricelist()
- combination_info = super(ProductTemplate, self)._get_combination_info(combination, product_id, add_qty, pricelist, parent_combination)
+ combination_info = super(ProductTemplate, self)._get_combination_info(
+ combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
+ parent_combination=parent_combination, only_template=only_template)
if self.env.context.get('website_id'):
partner = self.env.user.partner_id
diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py
index e848067f1b2..11bdba05079 100644
--- a/addons/website_sale/models/website.py
+++ b/addons/website_sale/models/website.py
@@ -246,7 +246,7 @@ class Website(models.Model):
country_code = request.session['geoip'].get('country_code')
if country_code:
country_id = request.env['res.country'].search([('code', '=', country_code)], limit=1).id
- fp_id = request.env['account.fiscal.position'].sudo()._get_fpos_by_region(country_id)
+ fp_id = request.env['account.fiscal.position'].sudo().with_context(force_company=request.website.company_id.id)._get_fpos_by_region(country_id)
sale_order.fiscal_position_id = fp_id
else:
# if no geolocation, use the public user fp
diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js
index 09b4677fe61..9cab8e8106e 100644
--- a/addons/website_sale/static/src/js/website_sale.js
+++ b/addons/website_sale/static/src/js/website_sale.js
@@ -144,6 +144,8 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(ProductConfigurat
'change #shipping_use_same': '_onChangeShippingUseSame',
'click .toggle_summary': '_onToggleSummary',
'click input.js_product_change': 'onChangeVariant',
+ // dirty fix: prevent options modal events to be triggered and bubbled
+ 'change oe_optional_products_modal [data-attribute_exclusions]': 'onChangeVariant',
}),
/**
@@ -587,6 +589,20 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(ProductConfigurat
_onChangeShippingUseSame: function (ev) {
$('.ship_to_other').toggle(!$(ev.currentTarget).prop('checked'));
},
+ /**
+ * @override
+ *
+ * Dirty fix: prevent options modal events to be triggered and bubbled
+ */
+ onChangeVariant: function (ev, data) {
+ var $originPath = ev.originalEvent && Array.isArray(ev.originalEvent.path) ? $(ev.originalEvent.path) : $();
+ var $container = data && data.$container ? data.$container : $();
+ if ($originPath.add($container).hasClass('oe_optional_products_modal')) {
+ ev.stopPropagation();
+ return;
+ }
+ return ProductConfiguratorMixin.onChangeVariant.apply(this, arguments);
+ },
/**
* @private
*/
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js
index 48d1eb81343..bc651d2801f 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js
@@ -3,6 +3,8 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
var tour = require("web_tour.tour");
+ var optionVariantImage;
+
tour.register("shop_custom_attribute_value", {
url: "/shop?search=Customizable Desk",
test: true,
@@ -32,6 +34,24 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
}, {
trigger: 'span.oe_price span:contains(600)',
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: 'li.js_attribute_value span:contains(Aluminium)',
extra_trigger: '.oe_optional_products_modal',
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js
index b7a6a32a1c4..8aa91ddc115 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js
@@ -67,6 +67,11 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
trigger: '#customize-menu a:contains(List View of Variants)',
run: function () {}, // it's a check
},
+ {
+ content: "check list view of variants is disabled initially",
+ trigger: 'body:not(:has(.js_product_change))',
+ run: function () {},
+ },
{
content: "click on 'List View of Variants'",
trigger: "#customize-menu a:contains(List View of Variants)",
@@ -76,6 +81,11 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
trigger: '.js_product_change',
run: function () {}, // it's a check
},
+ {
+ context: "check variant price",
+ trigger: '.custom-radio:contains("Aluminium") .badge:contains("+") .oe_currency_value:contains("50.4")',
+ run: function () {},
+ },
{
content: "check price is 750",
trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js b/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js
index 5d485b20c13..36adf9a9f88 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js
@@ -6,12 +6,16 @@ var tour = require('web_tour.tour');
// This tour relies on a data created from the python test.
tour.register('tour_shop_deleted_archived_variants', {
test: true,
- url: '/shop?search=Test Product',
+ url: '/shop?search=Test Product 2',
},
[
{
- content: "select Test Product",
- trigger: '.oe_product_cart a:containsExact("Test Product")',
+ content: "check price on /shop (template price)",
+ trigger: '.oe_product_cart .oe_currency_value:contains("1.00")',
+ },
+ {
+ content: "select Test Product 2",
+ trigger: '.oe_product_cart a:containsExact("Test Product 2")',
},
{
content: "check price (3rd variant)",
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js b/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js
index 3afcad271f7..7ae321912c4 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js
@@ -24,6 +24,7 @@ tour.register('tour_shop_dynamic_variants', {
},
{
content: "click add to cart",
+ extra_trigger: 'body:has(input[type="hidden"][name="product_id"][value=0])',
trigger: '#add_to_cart',
},
{
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js b/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js
new file mode 100644
index 00000000000..94178547a8b
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js
@@ -0,0 +1,96 @@
+odoo.define('website_sale.tour_shop_list_view_b2c', function (require) {
+'use strict';
+
+var rpc = require('web.rpc');
+var tour = require('web_tour.tour');
+
+tour.register('shop_list_view_b2c', {
+ test: true,
+ url: '/shop?search=Test Product',
+},
+ [
+ {
+ content: "activate b2c",
+ trigger: 'body',
+ run: function () {
+ return rpc.query({
+ model: 'res.config.settings',
+ method: 'create',
+ args: [{
+ 'auth_signup_uninvited': 'b2c',
+ 'show_line_subtotals_tax_selection': 'tax_included',
+ 'group_show_line_subtotals_tax_excluded': false,
+ 'group_show_line_subtotals_tax_included': true,
+ }],
+ }).then(function (resId) {
+ return rpc.query({
+ model: 'res.config.settings',
+ method: 'execute',
+ args: [[resId]],
+ }).then(function () {
+ window.location.reload();
+ });
+ });
+ },
+ },
+ {
+ content: "check price on /shop",
+ trigger: '.oe_product_cart .oe_currency_value:contains("825.00")',
+ run: function () {},
+ },
+ {
+ content: "select product",
+ trigger: '.oe_product_cart a:contains("Test Product")',
+ },
+ {
+ content: "check list view of variants is disabled initially (when on /product page)",
+ trigger: 'body:not(:has(.js_product_change))',
+ extra_trigger: '#product_details',
+ run: function () {},
+ },
+ {
+ content: "open customize menu",
+ trigger: '#customize-menu > a',
+ extra_trigger: 'body:not(.notReady)',
+ },
+ {
+ content: "click on 'List View of Variants'",
+ trigger: '#customize-menu a:contains(List View of Variants)',
+ },
+ {
+ content: "check page loaded after list of variant customization enabled",
+ trigger: '.js_product_change',
+ run: function () {},
+ },
+ {
+ context: "check variant price",
+ trigger: '.custom-radio:contains("Aluminium") .badge:contains("+") .oe_currency_value:contains("55.44")',
+ run: function () {},
+ },
+ {
+ content: "check price is 825",
+ trigger: '.product_price .oe_price .oe_currency_value:containsExact("825.00")',
+ run: function () {},
+ },
+ {
+ content: "switch to another variant",
+ trigger: '.js_product label:contains("Aluminium")',
+ },
+ {
+ content: "verify that price has changed when changing variant",
+ trigger: '.product_price .oe_price .oe_currency_value:containsExact("880.44")',
+ run: function () {},
+ },
+ {
+ content: "click on 'Add to Cart' button",
+ trigger: 'a:contains(Add to Cart)',
+ },
+ {
+ content: "check price on /cart",
+ trigger: '#cart_products .oe_currency_value:containsExact("880.44")',
+ run: function () {},
+ },
+ ]
+);
+
+});
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js b/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js
index 012072d178b..169819fe997 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js
@@ -6,12 +6,12 @@ var tour = require('web_tour.tour');
// This tour relies on a data created from the python test.
tour.register('tour_shop_no_variant_attribute', {
test: true,
- url: '/shop?search=Test Product',
+ url: '/shop?search=Test Product 3',
},
[
{
- content: "select Test Product",
- trigger: '.oe_product_cart a:containsExact("Test Product")',
+ content: "select Test Product 3",
+ trigger: '.oe_product_cart a:containsExact("Test Product 3")',
},
{
content: "check price",
diff --git a/addons/website_sale/tests/test_customize.py b/addons/website_sale/tests/test_customize.py
index 11f10c4c5e0..944d6f33a93 100644
--- a/addons/website_sale/tests/test_customize.py
+++ b/addons/website_sale/tests/test_customize.py
@@ -6,13 +6,18 @@ import odoo.tests
@odoo.tests.common.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
- def test_01_admin_shop_customize_tour(self):
+ def setUp(self):
+ super(TestUi, self).setUp()
# create a template
product_template = self.env['product.template'].create({
'name': 'Test Product',
'website_published': True,
'list_price': 750,
})
+
+ tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 10})
+ product_template.taxes_id = tax
+
product_attribute = self.env.ref('product.product_attribute_1')
product_attribute_value_1 = self.env.ref('product.product_attribute_value_1')
product_attribute_value_2 = self.env.ref('product.product_attribute_value_2')
@@ -36,6 +41,7 @@ class TestUi(odoo.tests.HttpCase):
product_template.create_variant_ids()
+ def test_01_admin_shop_customize_tour(self):
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_customize')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_customize.ready", login="admin")
def test_02_admin_shop_custom_attribute_value_tour(self):
@@ -140,7 +146,7 @@ class TestUi(odoo.tests.HttpCase):
# create the template
product_template = self.env['product.template'].create({
- 'name': 'Test Product',
+ 'name': 'Test Product 2',
'website_published': True,
})
@@ -161,9 +167,10 @@ class TestUi(odoo.tests.HttpCase):
product_template.create_variant_ids()
- # archive first and delete second
+ # archive first combination (first variant)
product_template.product_variant_ids[0].active = False
- product_template.product_variant_ids[1].unlink()
+ # delete second combination (which is now first variant since cache has been cleared)
+ product_template.product_variant_ids[0].unlink()
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_deleted_archived_variants')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_deleted_archived_variants.ready", login="portal")
@@ -188,7 +195,7 @@ class TestUi(odoo.tests.HttpCase):
# create the template
product_template = self.env['product.template'].create({
- 'name': 'Test Product',
+ 'name': 'Test Product 3',
'website_published': True,
})
@@ -205,3 +212,6 @@ class TestUi(odoo.tests.HttpCase):
product_template.create_variant_ids()
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_no_variant_attribute')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_no_variant_attribute.ready", login="demo")
+
+ def test_06_admin_list_view_b2c(self):
+ self.browser_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_list_view_b2c')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_list_view_b2c.ready", login="admin")
diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py
index 390b96e02d1..a9a5c4981b5 100644
--- a/addons/website_sale/tests/test_website_sale_pricelist.py
+++ b/addons/website_sale/tests/test_website_sale_pricelist.py
@@ -38,6 +38,10 @@ class TestWebsitePriceList(TransactionCase):
'country_group_ids': [(6, 0, [ca_group.id])],
'sequence': 10
})
+ self.args = {
+ 'show': False,
+ 'current_pl': False,
+ }
patcher = patch('odoo.addons.website_sale.models.website.Website.get_pricelist_available', wraps=self._get_pricelist_available)
patcher.start()
self.addCleanup(patcher.stop)
@@ -87,11 +91,6 @@ class TestWebsitePriceList(TransactionCase):
def test_get_pricelist_available_promocode(self):
christmas_pl = self.ref('website_sale.list_christmas')
- public_pl = self.ref('product.list0')
- self.args = {
- 'show': False,
- 'current_pl': public_pl,
- }
country_list = {
False: True,
diff --git a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py
index 1c08075178f..6bd1f72ddc7 100644
--- a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py
+++ b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py
@@ -32,7 +32,6 @@ class TestWebsiteSaleProductAttributeValueConfig(TestSaleProductAttributeValueSe
# ensure pricelist is set to with_discount
pricelist.discount_policy = 'with_discount'
- self.computer.invalidate_cache()
# CASE: B2B setting
self.env.ref('account.group_show_line_subtotals_tax_excluded').users |= self.env.user
@@ -54,7 +53,6 @@ class TestWebsiteSaleProductAttributeValueConfig(TestSaleProductAttributeValueSe
# CASE: pricelist 'without_discount'
pricelist.discount_policy = 'without_discount'
- self.computer.invalidate_cache()
# ideally we would need to use compare_amounts everywhere, but this is
# the only rounding where it fails without it
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 557b788e558..8c643c918e2 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -33,6 +33,7 @@
+
@@ -103,8 +104,7 @@
-
-
+