Scan me with your banking app.
diff --git a/addons/account_cancel/views/account_views.xml b/addons/account_cancel/views/account_views.xml
index 7d8591f1146..2a6162a64f9 100644
--- a/addons/account_cancel/views/account_views.xml
+++ b/addons/account_cancel/views/account_views.xml
@@ -5,6 +5,9 @@
account.journal
+
+ {'invisible': 0}
+
diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py
index fdf0ecdf726..52bd7dc6149 100644
--- a/addons/account_check_printing/models/account_payment.py
+++ b/addons/account_check_printing/models/account_payment.py
@@ -99,6 +99,11 @@ class AccountPayment(models.Model):
""" This method is a hook for l10n_xx_check_printing modules to implement actual check printing capabilities """
raise UserError(_("You have to choose a check layout. For this, go in Apps, search for 'Checks layout' and install one."))
+
+ def set_check_amount_in_words(self):
+ for payment in self:
+ payment.check_amount_in_words = payment.currency_id.amount_to_text(payment.amount) if payment.currency_id else ''
+
#######################
#CHECK PRINTING METHODS
#######################
diff --git a/addons/account_check_printing/views/account_payment_views.xml b/addons/account_check_printing/views/account_payment_views.xml
index df880212f36..91f27101fd2 100644
--- a/addons/account_check_printing/views/account_payment_views.xml
+++ b/addons/account_check_printing/views/account_payment_views.xml
@@ -8,6 +8,7 @@
+
diff --git a/addons/account_tax_python/models/account_tax.py b/addons/account_tax_python/models/account_tax.py
index 1870ea6b55e..dbd143a58b5 100644
--- a/addons/account_tax_python/models/account_tax.py
+++ b/addons/account_tax_python/models/account_tax.py
@@ -28,6 +28,8 @@ class AccountTaxPython(models.Model):
def _compute_amount(self, base_amount, price_unit, quantity=1.0, product=None, partner=None):
self.ensure_one()
+ if product and product._name == 'product.template':
+ product = product.product_variant_id
if self.amount_type == 'code':
company = self.env.company
localdict = {'base_amount': base_amount, 'price_unit':price_unit, 'quantity': quantity, 'product':product, 'partner':partner, 'company': company}
@@ -38,6 +40,8 @@ class AccountTaxPython(models.Model):
def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None, is_refund=False, handle_price_include=True):
taxes = self.filtered(lambda r: r.amount_type != 'code')
company = self.env.company
+ if product and product._name == 'product.template':
+ product = product.product_variant_id
for tax in self.filtered(lambda r: r.amount_type == 'code'):
localdict = self._context.get('tax_computation_context', {})
localdict.update({'price_unit': price_unit, 'quantity': quantity, 'product': product, 'partner': partner, 'company': company})
diff --git a/addons/auth_oauth/controllers/main.py b/addons/auth_oauth/controllers/main.py
index 3b300070929..00eea3268b6 100644
--- a/addons/auth_oauth/controllers/main.py
+++ b/addons/auth_oauth/controllers/main.py
@@ -109,19 +109,10 @@ class OAuthLogin(Home):
return response
- @http.route()
- def web_auth_signup(self, *args, **kw):
- providers = self.list_providers()
- response = super(OAuthLogin, self).web_auth_signup(*args, **kw)
- response.qcontext.update(providers=providers)
- return response
-
- @http.route()
- def web_auth_reset_password(self, *args, **kw):
- providers = self.list_providers()
- response = super(OAuthLogin, self).web_auth_reset_password(*args, **kw)
- response.qcontext.update(providers=providers)
- return response
+ def get_auth_signup_qcontext(self):
+ result = super(OAuthLogin, self).get_auth_signup_qcontext()
+ result["providers"] = self.list_providers()
+ return result
class OAuthController(http.Controller):
diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py
index c9ee67f2c7b..8540d916a5e 100644
--- a/addons/base_address_city/models/res_partner.py
+++ b/addons/base_address_city/models/res_partner.py
@@ -31,6 +31,7 @@ class Partner(models.Model):
replacement_xml = """
+
-
+
@@ -212,6 +212,27 @@
+
+
+
+
+
+
+
+
+
+
+
Status:
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py
index 08194e0d7ec..72103f89205 100644
--- a/addons/gamification/models/challenge.py
+++ b/addons/gamification/models/challenge.py
@@ -379,9 +379,9 @@ class Challenge(models.Model):
# the goal is initialised over the limit to make sure we will compute it at least once
if line.condition == 'higher':
- values['current'] = line.target_goal - 1
+ values['current'] = min(line.target_goal - 1, 0)
else:
- values['current'] = line.target_goal + 1
+ values['current'] = max(line.target_goal + 1, 0)
if challenge.remind_update_delay:
values['remind_update_delay'] = challenge.remind_update_delay
@@ -737,7 +737,7 @@ class Challenge(models.Model):
all_reached = False
if goal.definition_condition == 'higher':
# can be over 100
- total_completeness += 100.0 * goal.current / goal.target_goal
+ total_completeness += (100.0 * goal.current / goal.target_goal) if goal.target_goal else 0
elif goal.state == 'reached':
# for lower goals, can not get percentage so 0 or 100
total_completeness += 100
diff --git a/addons/gamification/models/goal.py b/addons/gamification/models/goal.py
index 75db403647e..10452e3b850 100644
--- a/addons/gamification/models/goal.py
+++ b/addons/gamification/models/goal.py
@@ -195,7 +195,7 @@ class Goal(models.Model):
if goal.current >= goal.target_goal:
goal.completeness = 100.0
else:
- goal.completeness = round(100.0 * goal.current / goal.target_goal, 2)
+ goal.completeness = round(100.0 * goal.current / goal.target_goal, 2) if goal.target_goal else 0
elif goal.current < goal.target_goal:
# a goal 'lower than' has only two values possible: 0 or 100%
goal.completeness = 100.0
diff --git a/addons/gamification/views/challenge.xml b/addons/gamification/views/challenge.xml
index 745a9b5204c..48214d14774 100644
--- a/addons/gamification/views/challenge.xml
+++ b/addons/gamification/views/challenge.xml
@@ -15,7 +15,7 @@
gamification.goal
Related Goals
- kanban,tree
+ kanban,tree,form
{'search_default_group_by_definition': True, 'search_default_inprogress': True, 'search_default_challenge_id': active_id, 'default_challenge_id': active_id}
@@ -61,7 +61,7 @@
-
+
@@ -171,7 +171,7 @@
Challenges
gamification.challenge
- kanban,tree,form
+ kanban,tree
{'search_default_inprogress':True, 'default_inprogress':True}
diff --git a/addons/gamification/views/goal.xml b/addons/gamification/views/goal.xml
index f571e28a4ec..26409c21306 100644
--- a/addons/gamification/views/goal.xml
+++ b/addons/gamification/views/goal.xml
@@ -135,6 +135,7 @@
+
@@ -143,10 +144,12 @@
-
+
-
+
diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py
index 4f87c906429..d768e6227a9 100644
--- a/addons/hr_expense/models/hr_expense.py
+++ b/addons/hr_expense/models/hr_expense.py
@@ -33,10 +33,16 @@ class HrExpense(models.Model):
if self.user_has_groups('hr_expense.group_hr_expense_user') or self.user_has_groups('account.group_account_user'):
res = "['|', ('company_id', '=', False), ('company_id', '=', company_id)]" # Then, domain accepts everything
elif self.user_has_groups('hr_expense.group_hr_expense_team_approver') and self.env.user.employee_ids:
- employee = self.env.user.employee_ids[0]
- res = ['|', '|', '|', ('department_id.manager_id.id', '=', employee.id),
- ('parent_id.id', '=', employee.id), ('expense_manager_id.id', '=', employee.user_id.id), ('id', '=', employee.id),
- '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id)]
+ user = self.env.user
+ employee = user.employee_ids[0]
+ res = [
+ '|', '|', '|',
+ ('department_id.manager_id', '=', employee.id),
+ ('parent_id', '=', employee.id),
+ ('id', '=', employee.id),
+ ('expense_manager_id', '=', user.id),
+ '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id),
+ ]
elif self.env.user.employee_ids:
employee = self.env.user.employee_ids[0]
res = [('id', '=', employee.id), '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id)]
diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py
index 8f93144d25d..c4958f41108 100644
--- a/addons/hr_expense/tests/test_expenses.py
+++ b/addons/hr_expense/tests/test_expenses.py
@@ -187,6 +187,72 @@ class TestAccountEntry(TestExpenseCommon):
self.assertEquals(expense.total_amount, 9876.0)
self.assertTrue(expense.employee_id in user_demo.employee_ids)
+ def test_partial_payment_multiexpense(self):
+ bank_journal = self.env['account.journal'].create({
+ 'name': 'Payment Journal',
+ 'code': 'PAY',
+ 'type': 'bank',
+ 'company_id': self.env.company.id,
+ })
+
+ outbound_pay_method = self.env['account.payment.method'].create({
+ 'name': 'outbound',
+ 'code': 'out',
+ 'payment_type': 'outbound',
+ })
+
+ expense = self.env['hr.expense.sheet'].create({
+ 'name': 'Expense for John Smith',
+ 'employee_id': self.employee.id,
+ })
+ expense_line = self.env['hr.expense'].create({
+ 'name': 'Car Travel Expenses',
+ 'employee_id': self.employee.id,
+ 'product_id': self.product_expense.id,
+ 'unit_amount': 200.00,
+ 'tax_ids': [(6, 0, [self.tax.id])],
+ 'sheet_id': expense.id,
+ 'analytic_account_id': self.analytic_account.id,
+ })
+ expense_line.copy({
+ 'sheet_id': expense.id
+ })
+ expense.approve_expense_sheets()
+ expense.action_sheet_move_create()
+
+ exp_move_lines = expense.account_move_id.line_ids
+ payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable')
+ self.assertEquals(len(payable_move_lines), 2)
+
+ WizardRegister = self.env["hr.expense.sheet.register.payment.wizard"].with_context(
+ active_model=expense._name, active_id=expense.id, active_ids=expense.ids
+ )
+
+ register_pay1 = WizardRegister.create({
+ 'journal_id': bank_journal.id,
+ 'payment_method_id': outbound_pay_method.id,
+ 'amount': 300,
+ })
+ register_pay1.expense_post_payment()
+
+ exp_move_lines = expense.account_move_id.line_ids
+ payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable')
+ self.assertEquals(len(payable_move_lines.filtered(lambda l: l.reconciled)), 1)
+
+ register_pay2 = WizardRegister.create({
+ 'journal_id': bank_journal.id,
+ 'payment_method_id': outbound_pay_method.id,
+ 'amount': 100,
+ })
+ register_pay2.expense_post_payment()
+ exp_move_lines = expense.account_move_id.line_ids
+ payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable')
+ self.assertEquals(len(payable_move_lines.filtered(lambda l: l.reconciled)), 2)
+
+ full_reconcile = payable_move_lines.mapped('full_reconcile_id')
+ self.assertEquals(len(full_reconcile), 1)
+
+
class TestExpenseRights(TestExpenseCommon):
@classmethod
diff --git a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py
index 6eb28a2f7f5..be9583b423c 100644
--- a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py
+++ b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py
@@ -119,7 +119,7 @@ class HrExpenseSheetRegisterPaymentWizard(models.TransientModel):
# Reconcile the payment and the expense, i.e. lookup on the payable account move lines
account_move_lines_to_reconcile = self.env['account.move.line']
for line in payment.move_line_ids + expense_sheet.account_move_id.line_ids:
- if line.account_id.internal_type == 'payable':
+ if line.account_id.internal_type == 'payable' and not line.reconciled:
account_move_lines_to_reconcile |= line
account_move_lines_to_reconcile.reconcile()
diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 633b8370733..963fee9bd51 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -6,6 +6,8 @@
import logging
import math
+from collections import namedtuple
+
from datetime import datetime, time
from pytz import timezone, UTC
@@ -18,6 +20,9 @@ from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
+# Used to agglomerate the attendances in order to find the hour_from and hour_to
+# See _onchange_request_parameters
+DummyAttendance = namedtuple('DummyAttendance', 'hour_from, hour_to, dayofweek, day_period')
class HolidaysRequest(models.Model):
""" Leave Requests Access specifications
@@ -279,7 +284,12 @@ class HolidaysRequest(models.Model):
resource_calendar_id = self.employee_id.resource_calendar_id or self.env.company.resource_calendar_id
domain = [('calendar_id', '=', resource_calendar_id.id), ('display_type', '=', False)]
- attendances = self.env['resource.calendar.attendance'].search(domain, order='dayofweek, day_period DESC')
+ attendances = self.env['resource.calendar.attendance'].read_group(domain, ['ids:array_agg(id)', 'hour_from:min(hour_from)', 'hour_to:max(hour_to)', 'dayofweek', 'day_period'], ['dayofweek', 'day_period'], lazy=False)
+
+ # Must be sorted by dayofweek ASC and day_period DESC
+ attendances = sorted([DummyAttendance(group['hour_from'], group['hour_to'], group['dayofweek'], group['day_period']) for group in attendances], key=lambda att: (att.dayofweek, att.day_period != 'morning'))
+
+ default_value = DummyAttendance(0, 0, 0, 'morning')
if resource_calendar_id.two_weeks_calendar:
# find week type of start_date
@@ -306,9 +316,9 @@ class HolidaysRequest(models.Model):
attendance_to = attendance_filtred_reversed[0]
else:
# find first attendance coming after first_day
- attendance_from = next((att for att in attendances if int(att.dayofweek) >= self.request_date_from.weekday()), attendances[0])
+ attendance_from = next((att for att in attendances if int(att.dayofweek) >= self.request_date_from.weekday()), attendances[0] if attendances else default_value)
# find last attendance coming before last_day
- attendance_to = next((att for att in reversed(attendances) if int(att.dayofweek) <= self.request_date_to.weekday()), attendances[-1])
+ attendance_to = next((att for att in reversed(attendances) if int(att.dayofweek) <= self.request_date_to.weekday()), attendances[-1] if attendances else default_value)
if self.request_unit_half:
if self.request_date_from_period == 'am':
diff --git a/addons/hr_holidays/tests/__init__.py b/addons/hr_holidays/tests/__init__.py
index 67c8327e999..3c15cd779a1 100644
--- a/addons/hr_holidays/tests/__init__.py
+++ b/addons/hr_holidays/tests/__init__.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_access_rights
+from . import test_automatic_leave_dates
from . import test_holidays_flow
from . import test_hr_leave_type
from . import test_accrual_allocations
diff --git a/addons/hr_holidays/tests/test_automatic_leave_dates.py b/addons/hr_holidays/tests/test_automatic_leave_dates.py
new file mode 100644
index 00000000000..5b39259d053
--- /dev/null
+++ b/addons/hr_holidays/tests/test_automatic_leave_dates.py
@@ -0,0 +1,212 @@
+# -*- coding: utf-8 -*-
+from datetime import date, datetime
+
+from odoo.tests.common import Form, tagged
+
+from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase
+
+
+@tagged('prout')
+class TestAutomaticLeaveDates(TestHrHolidaysBase):
+ def setUp(self):
+ super(TestAutomaticLeaveDates, self).setUp()
+
+ self.leave_type = self.env['hr.leave.type'].create({
+ 'name': 'Automatic Test',
+ 'time_type': 'leave',
+ 'allocation_type': 'no',
+ 'validity_start': False,
+ })
+
+ def test_no_attendances(self):
+ calendar = self.env['resource.calendar'].create({
+ 'name': 'No Attendances',
+ 'attendance_ids': [(5, 0, 0)],
+ })
+ employee = self.employee_emp
+ employee.resource_calendar_id = calendar
+
+ with Form(self.env['hr.leave']) as leave_form:
+ leave_form.employee_id = employee
+ leave_form.holiday_status_id = self.leave_type
+ leave_form.request_date_from = date(2019, 9, 2)
+ leave_form.request_date_to = date(2019, 9, 2)
+ leave_form.request_unit_half = True
+ leave_form.request_date_from_period = 'am'
+
+ self.assertEqual(leave_form.number_of_days_display, 0)
+ self.assertEqual(leave_form.number_of_hours_display, 0)
+
+ def test_single_attendance_on_morning_and_afternoon(self):
+ calendar = self.env['resource.calendar'].create({
+ 'name': 'simple morning + afternoon',
+ 'attendance_ids': [(5, 0, 0),
+ (0, 0, {
+ 'name': 'monday morning',
+ 'hour_from': 8,
+ 'hour_to': 12,
+ 'day_period': 'morning',
+ 'dayofweek': '0',
+ }),
+ (0, 0, {
+ 'name': 'monday afternoon',
+ 'hour_from': 13,
+ 'hour_to': 17,
+ 'day_period': 'afternoon',
+ 'dayofweek': '0',
+ })]
+ })
+
+ employee = self.employee_emp
+ employee.resource_calendar_id = calendar
+
+ with Form(self.env['hr.leave']) as leave_form:
+ leave_form.employee_id = employee
+ leave_form.holiday_status_id = self.leave_type
+ leave_form.request_date_from = date(2019, 9, 2)
+ leave_form.request_date_to = date(2019, 9, 2)
+ leave_form.request_unit_half = True
+ leave_form.request_date_from_period = 'am'
+
+ self.assertEqual(leave_form.number_of_days_display, .5)
+ self.assertEqual(leave_form.number_of_hours_display, 4)
+
+ leave_form.request_date_from_period = 'pm'
+
+ self.assertEqual(leave_form.number_of_days_display, .5)
+ self.assertEqual(leave_form.number_of_hours_display, 4)
+
+ def test_multiple_attendance_on_morning(self):
+ calendar = self.env['resource.calendar'].create({
+ 'name': 'multi morning',
+ 'attendance_ids': [(5, 0, 0),
+ (0, 0, {
+ 'name': 'monday morning 1',
+ 'hour_from': 8,
+ 'hour_to': 10,
+ 'day_period': 'morning',
+ 'dayofweek': '0',
+ }),
+ (0, 0, {
+ 'name': 'monday morning 2',
+ 'hour_from': 10.25,
+ 'hour_to': 12.25,
+ 'day_period': 'morning',
+ 'dayofweek': '0',
+ }),
+ (0, 0, {
+ 'name': 'monday afternoon',
+ 'hour_from': 13,
+ 'hour_to': 17,
+ 'day_period': 'afternoon',
+ 'dayofweek': '0',
+ })]
+ })
+ employee = self.employee_emp
+ employee.resource_calendar_id = calendar
+
+ with Form(self.env['hr.leave']) as leave_form:
+ leave_form.employee_id = employee
+ leave_form.holiday_status_id = self.leave_type
+ leave_form.request_date_from = date(2019, 9, 2)
+ leave_form.request_date_to = date(2019, 9, 2)
+ leave_form.request_unit_half = True
+ leave_form.request_date_from_period = 'am'
+
+ self.assertEqual(leave_form.number_of_days_display, .5)
+ self.assertEqual(leave_form.number_of_hours_display, 4)
+
+ leave_form.request_date_from_period = 'pm'
+
+ self.assertEqual(leave_form.number_of_days_display, .5)
+ self.assertEqual(leave_form.number_of_hours_display, 4)
+
+ def test_attendance_on_morning(self):
+ calendar = self.env['resource.calendar'].create({
+ 'name': 'Morning only',
+ 'attendance_ids': [(5, 0, 0),
+ (0, 0, {
+ 'name': 'Monday All day',
+ 'hour_from': 8,
+ 'hour_to': 16,
+ 'day_period': 'morning',
+ 'dayofweek': '0',
+ })],
+ })
+ employee = self.employee_emp
+ employee.resource_calendar_id = calendar
+ with Form(self.env['hr.leave']) as leave_form:
+ leave_form.employee_id = employee
+ leave_form.holiday_status_id = self.leave_type
+ leave_form.request_date_from = date(2019, 9, 2)
+ leave_form.request_date_to = date(2019, 9, 2)
+ leave_form.request_unit_half = True
+ # Ask for morning
+ leave_form.request_date_from_period = 'am'
+
+ self.assertEqual(leave_form.number_of_days_display, 1)
+ self.assertEqual(leave_form.number_of_hours_display, 8)
+
+ # Ask for afternoon
+ leave_form.request_date_from_period = 'pm'
+
+ self.assertEqual(leave_form.number_of_days_display, 1)
+ self.assertEqual(leave_form.number_of_hours_display, 8)
+
+ def test_attendance_next_day(self):
+ calendar = self.env['resource.calendar'].create({
+ 'name': 'auto next day',
+ 'attendance_ids': [(5, 0, 0),
+ (0, 0, {
+ 'name': 'tuesday morning',
+ 'hour_from': 8,
+ 'hour_to': 12,
+ 'day_period': 'morning',
+ 'dayofweek': '1',
+ })]
+ })
+ employee = self.employee_emp
+ employee.resource_calendar_id = calendar
+
+ with Form(self.env['hr.leave']) as leave_form:
+ leave_form.employee_id = employee
+ leave_form.holiday_status_id = self.leave_type
+ leave_form.request_date_from = date(2019, 9, 2)
+ leave_form.request_date_to = date(2019, 9, 2)
+ leave_form.request_unit_half = True
+ leave_form.request_date_from_period = 'am'
+
+
+ self.assertEqual(leave_form.number_of_days_display, 0)
+ self.assertEqual(leave_form.number_of_hours_display, 0)
+ self.assertEqual(leave_form.date_from, datetime(2019, 9, 2, 6, 0, 0))
+ self.assertEqual(leave_form.date_to, datetime(2019, 9, 2, 10, 0, 0))
+
+ def test_attendance_previous_day(self):
+ calendar = self.env['resource.calendar'].create({
+ 'name': 'auto next day',
+ 'attendance_ids': [(5, 0, 0),
+ (0, 0, {
+ 'name': 'monday morning',
+ 'hour_from': 8,
+ 'hour_to': 12,
+ 'day_period': 'morning',
+ 'dayofweek': '0',
+ })]
+ })
+ employee = self.employee_emp
+ employee.resource_calendar_id = calendar
+
+ with Form(self.env['hr.leave']) as leave_form:
+ leave_form.employee_id = employee
+ leave_form.holiday_status_id = self.leave_type
+ leave_form.request_date_from = date(2019, 9, 3)
+ leave_form.request_date_to = date(2019, 9, 3)
+ leave_form.request_unit_half = True
+ leave_form.request_date_from_period = 'am'
+
+
+ self.assertEqual(leave_form.number_of_days_display, 0)
+ self.assertEqual(leave_form.number_of_hours_display, 0)
+ self.assertEqual(leave_form.date_from, datetime(2019, 9, 3, 6, 0, 0))
+ self.assertEqual(leave_form.date_to, datetime(2019, 9, 3, 10, 0, 0))
diff --git a/addons/hr_recruitment/views/hr_recruitment_views.xml b/addons/hr_recruitment/views/hr_recruitment_views.xml
index 3b65a7eb8b0..5634abf2aac 100644
--- a/addons/hr_recruitment/views/hr_recruitment_views.xml
+++ b/addons/hr_recruitment/views/hr_recruitment_views.xml
@@ -275,6 +275,7 @@
+
diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py
index 6803f637e93..ff12b4c2c4a 100644
--- a/addons/im_livechat/controllers/main.py
+++ b/addons/im_livechat/controllers/main.py
@@ -103,7 +103,6 @@ class LivechatController(http.Controller):
@http.route('/im_livechat/feedback', type='json', auth='public', cors="*")
def feedback(self, uuid, rate, reason=None, **kwargs):
Channel = request.env['mail.channel']
- Rating = request.env['rating.rating']
channel = Channel.sudo().search([('uuid', '=', uuid)], limit=1)
if channel:
# limit the creation : only ONE rating per session
@@ -124,7 +123,11 @@ class LivechatController(http.Controller):
# if logged in user, set its partner on rating
values['partner_id'] = request.env.user.partner_id.id if request.session.uid else False
# create the rating
- rating = Rating.sudo().create(values)
+
+ channel.write({
+ 'rating_ids': [(0, False, values)]
+ })
+ rating = channel.rating_ids[0]
else:
rating = channel.rating_ids[0]
rating.write(values)
diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js
index 596357ef3d4..5a893f2499a 100644
--- a/addons/im_livechat/static/src/js/im_livechat.js
+++ b/addons/im_livechat/static/src/js/im_livechat.js
@@ -123,6 +123,14 @@ var LivechatButton = Widget.extend({
serverURL: this._serverURL,
});
var message = new WebsiteLivechatMessage(this, data, options);
+
+ var hasAlreadyMessage = _.some(this._messages, function (msg) {
+ return message.id === msg.id;
+ });
+ if (hasAlreadyMessage) {
+ return;
+ }
+
if (this._livechat) {
this._livechat.addMessage(message);
}
diff --git a/addons/l10n_ae/data/account_tax_template_data.xml b/addons/l10n_ae/data/account_tax_template_data.xml
index b3cec92c1c5..2993be745de 100644
--- a/addons/l10n_ae/data/account_tax_template_data.xml
+++ b/addons/l10n_ae/data/account_tax_template_data.xml
@@ -1,11 +1,11 @@
- TVA 5% (Dubai)
+ VAT 5% (Dubai)
sale
5
percent
- TVA 5%
+ VAT 5%
- TVA 5% (Abu Dhabi)
+ VAT 5% (Abu Dhabi)
sale
5
percent
- TVA 5%
+ VAT 5%
- TVA 5% (Sharjah)
+ VAT 5% (Sharjah)
sale
5
percent
- TVA 5%
+ VAT 5%
- TVA 5% (Ajman)
+ VAT 5% (Ajman)
sale
5
percent
- TVA 5%
+ VAT 5%
- TVA 5% (Umm Al Quwain)
+ VAT 5% (Umm Al Quwain)
sale
5
percent
- TVA 5%
+ VAT 5%
- TVA 5% (Ras Al-Khaima)
+ VAT 5% (Ras Al-Khaima)
sale
5
percent
- TVA 5%
+ VAT 5%
- TVA 5% (Fujairah)
+ VAT 5% (Fujairah)
sale
5
percent
- TVA 5%
+ VAT 5%
- TVA 0%
+ VAT 0%
sale
0
percent
- TVA 0%
+ VAT 0%
- TVA 5%
+ VAT 5%
purchase
5
percent
- TVA 5%
+ VAT 5%
- TVA 0%
+ VAT 0%
purchase
0
percent
- TVA 0%
+ VAT 0%
-
- TVA 5%
- none
- 5
- percent
- TVA 5%
-
-
-
-
-
-
- Reverse Charge TVA +5%(Purchase)
- none
- 5
- percent
- TVA 5%
-
-
-
-
-
Reverse Charge Provision
purchase
diff --git a/addons/l10n_ch/models/account_invoice.py b/addons/l10n_ch/models/account_invoice.py
index 194ec3869e7..583f5580f12 100644
--- a/addons/l10n_ch/models/account_invoice.py
+++ b/addons/l10n_ch/models/account_invoice.py
@@ -189,3 +189,17 @@ class AccountMove(models.Model):
if self.env.context.get('l10n_ch_mark_isr_as_sent'):
self.filtered(lambda inv: not inv.l10n_ch_isr_sent).write({'l10n_ch_isr_sent': True})
return super(AccountMove, self.with_context(mail_post_autofollow=True)).message_post(**kwargs)
+
+ def _get_reference_ch_invoice(self):
+ """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as
+ `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard
+ """
+ self.ensure_one()
+ return self.l10n_ch_isr_number_spaced
+
+ def _get_reference_ch_partner(self):
+ """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as
+ `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard
+ """
+ self.ensure_one()
+ return self.l10n_ch_isr_number_spaced
diff --git a/addons/l10n_ch/models/account_journal.py b/addons/l10n_ch/models/account_journal.py
index a42e163a299..85646dfa396 100644
--- a/addons/l10n_ch/models/account_journal.py
+++ b/addons/l10n_ch/models/account_journal.py
@@ -14,6 +14,7 @@ class AccountJournal(models.Model):
# creation of bank journals by giving the account number, allow craetion of the
l10n_ch_postal = fields.Char(related='bank_account_id.l10n_ch_postal', readonly=False)
+ invoice_reference_model = fields.Selection(selection_add=[('ch', 'Switzerland')])
@api.model
def create(self, vals):
diff --git a/addons/l10n_es/data/account_tax_data.xml b/addons/l10n_es/data/account_tax_data.xml
index dca71560db1..9461c865a72 100644
--- a/addons/l10n_es/data/account_tax_data.xml
+++ b/addons/l10n_es/data/account_tax_data.xml
@@ -445,7 +445,6 @@
P_IVA21_SP_IN
purchase
-
percent
@@ -551,7 +550,6 @@
purchase
percent
-
percent
purchase
-
IVA 4% Adquisición de servicios extracomunitarios
P_IVA10_SP_EX
percent
-
purchase
IVA 10% Adquisición de servicios extracomunitarios
@@ -824,7 +820,6 @@
purchase
-
percent
P_IVA4_IC_BC
-
percent
purchase
IVA 4% Adquisición Intracomunitario. Bienes corrientes
@@ -930,7 +924,6 @@
percent
purchase
IVA 4% Adquisición Intracomunitario. Bienes de inversión
-
P_IVA10_IC_BC
-
percent
purchase
IVA 10% Adquisición Intracomunitario. Bienes corrientes
@@ -1034,7 +1026,6 @@
percent
purchase
IVA 10% Adquisición Intracomunitario. Bienes de inversión
-
percent
purchase
IVA 10% Adquisición de servicios intracomunitarios
-
percent
purchase
IVA 4% Adquisición de servicios intracomunitarios
-
P_IVA4_ISP
IVA 4% Compra con Inversión del Sujeto Pasivo Nacional
purchase
-
percent
@@ -3455,7 +3443,6 @@
P_IVA10_ISP
IVA 10% Compra con Inversión del Sujeto Pasivo Nacional
purchase
-
percent
@@ -3509,7 +3496,6 @@
purchase
percent
-
-
-
-
-
-
+
+
+
+
+
@@ -43,21 +43,21 @@
-
+
-
+
-
+
@@ -65,7 +65,7 @@
-
+
diff --git a/addons/l10n_it_edi/models/ir_mail_server.py b/addons/l10n_it_edi/models/ir_mail_server.py
index 7b030b32234..51e4a0a23a3 100644
--- a/addons/l10n_it_edi/models/ir_mail_server.py
+++ b/addons/l10n_it_edi/models/ir_mail_server.py
@@ -31,10 +31,10 @@ class FetchmailServer(models.Model):
l10n_it_is_pec = fields.Boolean('PEC server', help="If PEC Server, only mail from '...@pec.fatturapa.it' will be processed.")
l10n_it_last_uid = fields.Integer(string='Last message UID', default=1)
- @api.constrains('l10n_it_is_pec', 'type')
+ @api.constrains('l10n_it_is_pec', 'server_type')
def _check_pec(self):
for record in self:
- if record.l10n_it_is_pec and record.type != 'imap':
+ if record.l10n_it_is_pec and record.server_type != 'imap':
raise ValidationError(_("PEC mail server must be of type IMAP."))
def fetch_mail(self):
@@ -42,7 +42,7 @@ class FetchmailServer(models.Model):
MailThread = self.env['mail.thread']
for server in self.filtered(lambda s: s.l10n_it_is_pec):
- _logger.info('start checking for new emails on %s PEC server %s', server.type, server.name)
+ _logger.info('start checking for new emails on %s PEC server %s', server.server_type, server.name)
count, failed = 0, 0
imap_server = None
@@ -80,14 +80,14 @@ class FetchmailServer(models.Model):
self._attachment_invoice(msg_txt)
new_max_uid = max(new_max_uid, int(uid))
except Exception:
- _logger.info('Failed to process mail from %s server %s.', server.type, server.name, exc_info=True)
+ _logger.info('Failed to process mail from %s server %s.', server.server_type, server.name, exc_info=True)
failed += 1
self._cr.commit()
count += 1
server.write({'l10n_it_last_uid': new_max_uid})
- _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.type, server.name, (count - failed), failed)
+ _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.server_type, server.name, (count - failed), failed)
except Exception:
- _logger.info("General failure when trying to fetch mail from %s server %s.", server.type, server.name, exc_info=True)
+ _logger.info("General failure when trying to fetch mail from %s server %s.", server.server_type, server.name, exc_info=True)
finally:
if imap_server:
imap_server.close()
diff --git a/addons/l10n_lu/__init__.py b/addons/l10n_lu/__init__.py
index 1947891c8f5..79508eb4f80 100644
--- a/addons/l10n_lu/__init__.py
+++ b/addons/l10n_lu/__init__.py
@@ -1,8 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import api, SUPERUSER_ID
+
from . import models
+def _post_init_hook(cr, registry):
+ _preserve_tag_on_taxes(cr, registry)
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ env.ref('l10n_lu.lu_2011_chart_1').process_coa_translations()
+
def _preserve_tag_on_taxes(cr, registry):
from odoo.addons.account.models.chart_template import preserve_existing_tags_on_taxes
preserve_existing_tags_on_taxes(cr, registry, 'l10n_lu')
diff --git a/addons/l10n_lu/__manifest__.py b/addons/l10n_lu/__manifest__.py
index c5457fd82d4..9eabf73f08d 100644
--- a/addons/l10n_lu/__manifest__.py
+++ b/addons/l10n_lu/__manifest__.py
@@ -43,5 +43,5 @@ Notes:
'data/account.chart.template.csv',
'data/account_chart_template_data.xml',
],
- 'post_init_hook': '_preserve_tag_on_taxes',
+ 'post_init_hook': '_post_init_hook',
}
diff --git a/addons/l10n_ma/data/account_tax_data.xml b/addons/l10n_ma/data/account_tax_data.xml
index 0842eeb1076..3309e6959f3 100644
--- a/addons/l10n_ma/data/account_tax_data.xml
+++ b/addons/l10n_ma/data/account_tax_data.xml
@@ -2,10 +2,10 @@
-
-
+
+
-
+
diff --git a/addons/l10n_ma/data/l10n_ma_chart_data.xml b/addons/l10n_ma/data/l10n_ma_chart_data.xml
index d15a3bc99c3..76c0d257799 100644
--- a/addons/l10n_ma/data/l10n_ma_chart_data.xml
+++ b/addons/l10n_ma/data/l10n_ma_chart_data.xml
@@ -1190,13 +1190,15 @@
Clients - catégorie A
34211
-
+
+
Clients - catégorie B
34212
-
+
+
@@ -1370,7 +1372,7 @@
Divers débiteurs
3488
-
+
@@ -1522,7 +1524,8 @@
Fournisseurs
4411
-
+
+
@@ -1768,7 +1771,7 @@
Divers créanciers
4488
-
+
@@ -2072,6 +2075,12 @@
+
+ Achats des prestations de service
+ 61263
+
+
+
Achats de matières et de fournitures des exercices antérieurs
6128
diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py
index 40433eaed92..3fbdd494a5e 100644
--- a/addons/l10n_multilang/models/l10n_multilang.py
+++ b/addons/l10n_multilang/models/l10n_multilang.py
@@ -12,6 +12,19 @@ _logger = logging.getLogger(__name__)
class AccountChartTemplate(models.Model):
_inherit = 'account.chart.template'
+ def load_for_current_company(self, sale_tax_rate, purchase_tax_rate):
+ res = super(AccountChartTemplate, self).load_for_current_company(sale_tax_rate, purchase_tax_rate)
+ # Copy chart of account translations when loading chart of account
+ for chart_template in self.filtered('spoken_languages'):
+ external_id = self.env['ir.model.data'].search([
+ ('model', '=', 'account.chart.template'),
+ ('res_id', '=', chart_template.id),
+ ], order='id', limit=1)
+ module = external_id and self.env.ref('base.module_' + external_id.module)
+ if module and module.state == 'installed':
+ chart_template.process_coa_translations()
+ return res
+
def process_translations(self, langs, in_field, in_ids, out_ids):
"""
This method copies translations values of templates into new Accounts/Taxes/Journals for languages selected
diff --git a/addons/l10n_uk/__manifest__.py b/addons/l10n_uk/__manifest__.py
index d62be514bfe..e9c035fdcae 100644
--- a/addons/l10n_uk/__manifest__.py
+++ b/addons/l10n_uk/__manifest__.py
@@ -15,7 +15,7 @@ This is the latest UK Odoo localisation necessary to run Odoo accounting for UK
- InfoLogic UK counties listing
- a few other adaptations""",
'author': 'SmartMode LTD',
- 'website': 'http://www.smartmode.co.uk',
+ 'website': 'https://www.odoo.com/page/accounting',
'depends': [
'account',
'base_iban',
diff --git a/addons/lunch/static/src/js/lunch_kanban_model.js b/addons/lunch/static/src/js/lunch_kanban_model.js
index ca2e39e5767..7ab7b279e43 100644
--- a/addons/lunch/static/src/js/lunch_kanban_model.js
+++ b/addons/lunch/static/src/js/lunch_kanban_model.js
@@ -13,6 +13,7 @@ var LunchKanbanModel = KanbanModel.extend({
init: function () {
this.locationId = false;
this.userId = false;
+ this._promInitLocation = null;
this._super.apply(this, arguments);
},
@@ -27,15 +28,23 @@ var LunchKanbanModel = KanbanModel.extend({
getCurrentLocationId: function () {
return this.locationId;
},
+ /**
+ * @return {Promise} resolved with the location domain
+ */
+ getLocationDomain: function () {
+ var self = this;
+ return this._initUserLocation().then(function () {
+ return self._buildLocationDomainLeaf() ? [self._buildLocationDomainLeaf()]: [];
+ });
+ },
load: function () {
var self = this;
var args = arguments;
var _super = this._super;
- return this._getUserLocation().then(function (locationId) {
+ return this._initUserLocation().then(function () {
var params = args[0];
- self.locationId = locationId;
- self._addOrUpdate(params.domain, ['is_available_at', 'in', [locationId]]);
+ self._addOrUpdate(params.domain, self._buildLocationDomainLeaf());
return _super.apply(self, args);
});
@@ -43,7 +52,7 @@ var LunchKanbanModel = KanbanModel.extend({
reload: function (id, options) {
var domain = options && options.domain || this.localData[id].domain;
- this._addOrUpdate(domain, ['is_available_at', 'in', [this.locationId]]);
+ this._addOrUpdate(domain, this._buildLocationDomainLeaf());
options = _.extend(options, {domain: domain});
return this._super.apply(this, arguments);
@@ -54,19 +63,35 @@ var LunchKanbanModel = KanbanModel.extend({
//--------------------------------------------------------------------------
_addOrUpdate: function (domain, subDomain) {
- var key = subDomain[0];
- var index = _.findIndex(domain, function (val) {
- return val[0] === key;
- });
+ if (subDomain && subDomain.length) {
+ var key = subDomain[0];
+ var index = _.findIndex(domain, function (val) {
+ return val[0] === key;
+ });
- if (index < 0) {
- domain.push(subDomain);
- } else {
- domain[index] = subDomain;
+ if (index < 0) {
+ domain.push(subDomain);
+ } else {
+ domain[index] = subDomain;
+ }
+
+ return domain;
}
return domain;
},
+ /**
+ * Builds the domain leaf corresponding to the current user's location
+ *
+ * @private
+ * @return {Array}
+ */
+ _buildLocationDomainLeaf: function () {
+ var locationId = this.getCurrentLocationId();
+ if (locationId) {
+ return ['is_available_at', 'in', [locationId]];
+ }
+ },
_getUserLocation: function () {
return this._rpc({
route: '/lunch/user_location_get',
@@ -76,18 +101,34 @@ var LunchKanbanModel = KanbanModel.extend({
},
});
},
+ /**
+ * Gets the user location once.
+ * Can be triggered from anywhere
+ * Useful to inject the location domain in the search panel
+ *
+ * @private
+ * @return {Promise}
+ */
+ _initUserLocation: function () {
+ var self = this;
+ if (!this._promInitLocation) {
+ this._promInitLocation = new Promise(function (resolve) {
+ self._getUserLocation().then(function (locationId) {
+ self.locationId = locationId;
+ resolve();
+ });
+ });
+ }
+ return this._promInitLocation;
+ },
_updateLocation: function (locationId) {
this.locationId = locationId;
return Promise.resolve();
},
_updateUser: function (userId) {
- var self = this;
-
this.userId = userId;
-
- return this._getUserLocation().then(function (locationId) {
- self.locationId = locationId;
- });
+ this._promInitLocation = null;
+ return this._initUserLocation();
}
});
diff --git a/addons/lunch/static/src/js/lunch_kanban_view.js b/addons/lunch/static/src/js/lunch_kanban_view.js
index a0cd6d3f3e0..8dda159f006 100644
--- a/addons/lunch/static/src/js/lunch_kanban_view.js
+++ b/addons/lunch/static/src/js/lunch_kanban_view.js
@@ -18,6 +18,26 @@ var LunchKanbanView = KanbanView.extend({
Renderer: LunchKanbanRenderer,
}),
display_name: _lt('Lunch Kanban'),
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Injects an additional domain in the search panel
+ *
+ * @private
+ * @override
+ */
+ _createSearchPanel: function (parent, params) {
+ var self = this;
+ var model = this.getModel(parent);
+ var _super = this._super.bind(this);
+ return model.getLocationDomain().then(function (domain) {
+ self.loadParams.domain = self.loadParams.domain.concat(domain);
+ return _super(parent, params);
+ });
+ },
});
view_registry.add('lunch_kanban', LunchKanbanView);
diff --git a/addons/lunch/static/tests/lunch_kanban_tests.js b/addons/lunch/static/tests/lunch_kanban_tests.js
index 8437a4a8d43..80e7747cbdd 100644
--- a/addons/lunch/static/tests/lunch_kanban_tests.js
+++ b/addons/lunch/static/tests/lunch_kanban_tests.js
@@ -211,6 +211,56 @@ QUnit.module('LunchKanbanView', {
kanban.destroy();
});
+ QUnit.test('search panel domain location', async function (assert) {
+ assert.expect(10);
+ const locationId = this.data['lunch.location'].records[0].id;
+ const regularInfos = _.extend({}, this.regularInfos);
+
+ const kanban = await createLunchKanbanView({
+ View: LunchKanbanView,
+ model: 'product',
+ data: this.data,
+ arch: `
+
+
+
+
+
+
+
+ `,
+ mockRPC: function (route, args) {
+ assert.step(route);
+
+ if (route.startsWith('/lunch')) {
+ return mockLunchRPC({
+ infos: regularInfos,
+ userLocation: locationId,
+ }).apply(this, arguments);
+ }
+ if (args.method === 'search_panel_select_multi_range') {
+ assert.deepEqual(args.kwargs.search_domain, [["is_available_at", "in", [locationId]]],
+ 'The initial domain of the search panel must contain the user location');
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, [["is_available_at", "in", [locationId]]],
+ 'The domain for fetching actual data should be correct');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+ assert.verifySteps([
+ '/lunch/user_location_get',
+ '/web/dataset/call_kw/product/search_panel_select_multi_range',
+ '/web/dataset/call_kw/product/search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ '/lunch/infos',
+ '/web/dataset/call_kw/ir.model.data/xmlid_to_res_id',
+ ])
+
+ kanban.destroy();
+ });
+
QUnit.test('non-empty cart', async function (assert) {
assert.expect(17);
diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py
index 9e194628211..a144112502b 100644
--- a/addons/mail/controllers/main.py
+++ b/addons/mail/controllers/main.py
@@ -143,7 +143,7 @@ class MailController(http.Controller):
@http.route('/mail/read_followers', type='json', auth='user')
def read_followers(self, follower_ids, res_model):
followers = []
- is_editable = request.env.user.has_group('base.group_no_one')
+ is_editable = request.env['mail.followers'].user_has_groups('base.group_no_one')
partner_id = request.env.user.partner_id
follower_id = None
follower_recs = request.env['mail.followers'].sudo().browse(follower_ids)
diff --git a/addons/mail/static/src/js/models/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js
index 0ef02e5f57a..cb860b70beb 100644
--- a/addons/mail/static/src/js/models/threads/document_thread.js
+++ b/addons/mail/static/src/js/models/threads/document_thread.js
@@ -62,9 +62,16 @@ var DocumentThread = Thread.extend({
* shared between tabs, and restored on F5.
*
* @override
+ * @param {Object} [options]
+ * @param {boolean} [options.skipCrossTabSync=false] if set, it should
+ * not notify other tabs from updating document thread state. This happens
+ * in case the `close` operation comes from local storage event.
*/
- close: function () {
+ close: function (options) {
this._super.apply(this, arguments);
+ if (options && options.skipCrossTabSync) {
+ return;
+ }
this.call('mail_service', 'updateDocumentThreadState', this._id, {
name: this.getName(),
windowState: 'closed',
@@ -75,9 +82,16 @@ var DocumentThread = Thread.extend({
* shared between tabs, and restored on F5.
*
* @override
+ * @param {Object} [options]
+ * @param {boolean} [options.skipCrossTabSync=false] if set, it should
+ * not notify other tabs from updating document thread state. This happens
+ * in case the `detach` operation comes from local storage event.
*/
- detach: function () {
+ detach: function (options) {
this._super.apply(this, arguments);
+ if (options && options.skipCrossTabSync) {
+ return;
+ }
var windowState = this._folded ? 'folded' : 'open';
this.call('mail_service', 'updateDocumentThreadState', this._id, {
name: this.getName(),
@@ -105,9 +119,16 @@ var DocumentThread = Thread.extend({
* shared between tabs, and restored on F5.
*
* @override
+ * @param {Object} [options]
+ * @param {boolean} [options.skipCrossTabSync=false] if set, it should
+ * not notify other tabs from updating document thread state. This happens
+ * in case the `detach` operation comes from local storage event.
*/
- fold: function () {
+ fold: function (folded, options) {
this._super.apply(this, arguments);
+ if (options && options.skipCrossTabSync) {
+ return;
+ }
var windowState = this._folded ? 'folded' : 'open';
this.call('mail_service', 'updateDocumentThreadState', this._id, {
name: this.getName(),
diff --git a/addons/mail/static/src/js/models/threads/searchable_thread.js b/addons/mail/static/src/js/models/threads/searchable_thread.js
index 74018af1bc0..c2a48a2d410 100644
--- a/addons/mail/static/src/js/models/threads/searchable_thread.js
+++ b/addons/mail/static/src/js/models/threads/searchable_thread.js
@@ -149,7 +149,11 @@ var SearchableThread = Thread.extend({
if (cache.messages[index] !== message) {
cache.messages.splice(index, 0, message);
}
- if (!message.isMyselfAuthor() && options.incrementUnread) {
+ if (
+ !message.isMyselfAuthor() &&
+ options.incrementUnread &&
+ message.getType() !== 'notification'
+ ) {
this._incrementUnreadCounter();
}
},
diff --git a/addons/mail/static/src/js/services/mail_document_thread_manager.js b/addons/mail/static/src/js/services/mail_document_thread_manager.js
index acf06461a04..6bb66b1b439 100644
--- a/addons/mail/static/src/js/services/mail_document_thread_manager.js
+++ b/addons/mail/static/src/js/services/mail_document_thread_manager.js
@@ -21,17 +21,23 @@ MailManager.include({
start: function () {
this._super.apply(this, arguments);
-
+ this._documentThreadWindowStates = {};
+ this._tabId = this.call('bus_service', 'getTabId');
// retrieve the open DocumentThreads from the localStorage
- var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
- if (!state) {
- this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, {});
- } else {
- this.isReady().then(this._updateDocumentThreadWindows.bind(this, state));
+ var localStorageLength = this.call('local_storage', 'length');
+ for (var i = 0; i < localStorageLength; i++) {
+ var key = this.call('local_storage', 'key', i);
+ if (key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) {
+ // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId
+ var documentThreadId = key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1);
+ this._documentThreadWindowStates[documentThreadId] =
+ this.call('local_storage', 'getItem', key);
+ }
}
+ this.isReady().then(this._updateDocumentThreadWindows.bind(this, this._documentThreadWindowStates));
// listen to localStorage changes to synchronize DocumentThread's
// windows between tabs
- window.addEventListener('storage', this._onStorage.bind(this));
+ this.call('local_storage', 'onStorage', this, this._onStorage);
},
//--------------------------------------------------------------------------
@@ -60,7 +66,10 @@ MailManager.include({
options.postedFromDocumentThread
) {
var key = this.DOCUMENT_THREAD_MESSAGE_KEY;
- this.call('local_storage', 'setItem', key, data);
+ this.call('local_storage', 'setItem', key, {
+ messageData: data,
+ tabId: this._tabId,
+ });
}
return this._super.apply(this, arguments);
},
@@ -144,12 +153,11 @@ MailManager.include({
* @param {string} state.windowState ('closed', 'folded' or 'open')
*/
updateDocumentThreadState: function (threadID, state) {
- var states = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
- states = _.omit(states, function (state) {
- return state.windowState === 'closed';
+ this._documentThreadWindowStates[threadID] = state;
+ this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY + '/' + threadID, {
+ state: state,
+ tabId: this._tabId,
});
- states[threadID] = state;
- this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, states);
},
//--------------------------------------------------------------------------
@@ -194,10 +202,17 @@ MailManager.include({
resModel: info[0],
});
if (state.windowState === 'closed') {
- documentThread.close();
+ documentThread.close({
+ skipCrossTabSync: true,
+ });
} else {
- documentThread.fold(state.windowState === 'folded');
- self.openThreadWindow(documentThread.getID(), { keepFoldState: true });
+ documentThread.fold(state.windowState === 'folded', {
+ skipCrossTabSync: true,
+ });
+ self.openThreadWindow(documentThread.getID(), {
+ keepFoldState: true,
+ skipCrossTabSync: true,
+ });
}
});
},
@@ -213,13 +228,27 @@ MailManager.include({
* @param {StorageEvent} ev
*/
_onStorage: function (ev) {
- if (ev.key === this.DOCUMENT_THREAD_STATE_KEY) {
- var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
- this._updateDocumentThreadWindows(state);
+ var value;
+ try {
+ value = JSON.parse(ev.newValue);
+ } catch (err) {
+ return;
+ }
+ if (ev.key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) {
+ if (value.tabId === this._tabId) {
+ return;
+ }
+ // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId
+ var documentThreadId = ev.key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1);
+ var param = {};
+ param[documentThreadId] = value.state;
+ this._updateDocumentThreadWindows(param);
} else if (ev.key === this.DOCUMENT_THREAD_MESSAGE_KEY) {
- var message = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_MESSAGE_KEY);
- if (message) {
- this.addMessage(message);
+ if (value.tabId === this._tabId) {
+ return;
+ }
+ if (value.messageData) {
+ this.addMessage(value.messageData);
}
}
},
diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js
index a832b0b0793..d8174da5a73 100644
--- a/addons/mail/static/src/js/services/mail_window_manager.js
+++ b/addons/mail/static/src/js/services/mail_window_manager.js
@@ -94,6 +94,8 @@ MailManager.include({
* is not open yet, and do nothing otherwise.
* @param {boolean} [options.keepFoldState=false] if set to true, keep the
* fold state of the thread
+ * @param {boolean} [options.skipCrossTabSync=false] if set, thread
+ * should not notify other tabs from new document thread chat window state.
* @returns {Promise}
*/
openThreadWindow: function (threadID, options) {
@@ -132,7 +134,9 @@ MailManager.include({
// thread window could not be open, which may happen due to
// access error while fetching messages to the document.
// abort opening the thread window in this case.
- thread.close();
+ thread.close({
+ skipCrossTabSync: true,
+ });
thread.isCreatingWindow = false;
});
}
diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js
index 16d5cd31c86..08671354a3e 100644
--- a/addons/mail/static/src/js/thread_windows/thread_window.js
+++ b/addons/mail/static/src/js/thread_windows/thread_window.js
@@ -3,7 +3,9 @@ odoo.define('mail.ThreadWindow', function (require) {
var AbstractThreadWindow = require('mail.AbstractThreadWindow');
var BasicComposer = require('mail.composer.Basic');
+var ExtendedComposer = require('mail.composer.Extended');
+var config = require('web.config');
var core = require('web.core');
var QWeb = core.qweb;
@@ -65,19 +67,27 @@ var ThreadWindow = AbstractThreadWindow.extend({
if (!this.hasThread()) {
this._startWithoutThread();
} else if (this.needsComposer()) {
- var basicComposer = new BasicComposer(this, {
+ var composer;
+ var composerParams = {
mentionPartnersRestricted: this._thread.getType() !== 'document_thread',
- isMini: true,
thread: this._thread,
- });
- basicComposer.on('post_message', this, this._postMessage);
- basicComposer.once('input_focused', this, function () {
+ };
+ if (
+ config.device.isMobile &&
+ this._thread.isMassMailing()
+ ) {
+ composer = new ExtendedComposer(this, composerParams);
+ } else {
+ composer = new BasicComposer(this, _.extend({ isMini: true }, composerParams));
+ }
+ composer.on('post_message', this, this._postMessage);
+ composer.once('input_focused', this, function () {
var commands = this._thread.getCommands();
var partners = this._thread.getMentionPartnerSuggestions();
- basicComposer.mentionSetCommands(commands);
- basicComposer.mentionSetPrefetchedPartners(partners);
+ composer.mentionSetCommands(commands);
+ composer.mentionSetPrefetchedPartners(partners);
});
- composerDef = basicComposer.replace(this.$('.o_thread_composer'));
+ composerDef = composer.replace(this.$('.o_thread_composer'));
composerDef.then(function () {
self.$input = self.$('.o_composer_text_field');
});
@@ -129,24 +139,6 @@ var ThreadWindow = AbstractThreadWindow.extend({
isPassive: function () {
return this._passive;
},
- /**
- * States whether the input of the thread window should be displayed or not.
- * This is based on the type of the thread:
- *
- * Do not display the input in the following cases:
- *
- * - no thread related to this window
- * - window of a mailbox (temp: let us have mailboxes in window mode)
- * - window of a thread with mass mailing
- *
- * Any other threads show the input in the window.
- *
- * @override
- * @returns {boolean}
- */
- needsComposer: function () {
- return this._super() && !this._thread.isMassMailing();
- },
/**
* Turn the thread window in active mode, so that when the bottom of the
* thread is visible, it is automatically marked as read.
diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js
index 14ac97def76..25e327b0428 100644
--- a/addons/mail/static/src/js/utils.js
+++ b/addons/mail/static/src/js/utils.js
@@ -8,7 +8,12 @@ var _t = core._t;
function parseAndTransform(htmlString, transformFunction) {
var openToken = "OPEN" + Date.now();
var string = htmlString.replace(/</g, openToken);
- var children = $('').html(string).contents();
+ var children;
+ try {
+ children = $('
').html(string).contents();
+ } catch (e) {
+ children = $('
').html('
' + string + ' ').contents();
+ }
return _parseAndTransform(children, transformFunction)
.replace(new RegExp(openToken, "g"), "<");
}
diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss
index 90793c91e95..5a9bad8e2dc 100644
--- a/addons/mail/static/src/scss/composer.scss
+++ b/addons/mail/static/src/scss/composer.scss
@@ -251,6 +251,9 @@
.o_composer_send .o_composer_button_send, .o_chatter_avatar {
display: none !important;
}
+ .o_attachment {
+ width: 50%;
+ }
}
.o_file_drop_zone_container {
@@ -292,6 +295,10 @@
flex-flow: row wrap;
}
+ .o_attachment_wrap {
+ padding-right: 0px;
+ }
+
.o_attachments_previews {
> .o_attachment {
width: 50%;
diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss
index 5654020c783..3931c06004a 100644
--- a/addons/mail/static/src/scss/thread.scss
+++ b/addons/mail/static/src/scss/thread.scss
@@ -183,6 +183,7 @@
}
img {
max-width: 100%;
+ height: auto;
}
.o_mail_body_long {
diff --git a/addons/mail/static/tests/document_thread_window_tests.js b/addons/mail/static/tests/document_thread_window_tests.js
index 86d06ba36c4..0748168a0cb 100644
--- a/addons/mail/static/tests/document_thread_window_tests.js
+++ b/addons/mail/static/tests/document_thread_window_tests.js
@@ -201,7 +201,7 @@ QUnit.test('post messages in a document thread window', async function (assert)
testUtils.mock.intercept(messagingMenu, 'call_service', function (ev) {
if (ev.data.service === 'local_storage' && ev.data.method === 'setItem' &&
ev.data.args[0] === 'mail.document_threads_last_message') {
- assert.deepEqual(ev.data.args[1], newMessage,
+ assert.deepEqual(ev.data.args[1].messageData, newMessage,
"should write sent message in local storage, to share info with other tabs");
}
}, true);
@@ -227,7 +227,7 @@ QUnit.test('post messages in a document thread window', async function (assert)
});
QUnit.test('open, fold, unfold and close a document thread window', async function (assert) {
- assert.expect(8);
+ assert.expect(24);
var messagingMenu = new MessagingMenu();
testUtils.mock.addMockEnvironment(messagingMenu, {
services: this.services,
@@ -237,7 +237,13 @@ QUnit.test('open, fold, unfold and close a document thread window', async functi
await testUtils.nextTick();
testUtils.mock.intercept(messagingMenu, 'call_service', function (ev) {
if (ev.data.service === 'local_storage' && ev.data.method === 'setItem') {
- assert.step(JSON.stringify(ev.data.args));
+ const key = ev.data.args[0];
+ const state = ev.data.args[1].state;
+ assert.strictEqual(key, 'mail.document_threads_state/some.res.model_1');
+ assert.ok(state);
+ assert.ok(state.name);
+ assert.ok(state.windowState);
+ assert.step(`${key}: { name: "${state.name}", windowState: '${state.windowState}' }`);
}
}, true);
await messagingMenu.appendTo($('#qunit-fixture'));
@@ -263,10 +269,10 @@ QUnit.test('open, fold, unfold and close a document thread window', async functi
await testUtils.nextTick();
assert.verifySteps([
- '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"open"}}]',
- '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"folded"}}]',
- '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"open"}}]',
- '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"closed"}}]',
+ `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'open' }`,
+ `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'folded' }`,
+ `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'open' }`,
+ `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'closed' }`,
]);
messagingMenu.destroy();
diff --git a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js
index 610e689ae4a..047d723a425 100644
--- a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js
+++ b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js
@@ -427,6 +427,88 @@ QUnit.test('update messaging preview on receiving a new message in channel previ
messagingMenu.destroy();
});
+QUnit.test('new message of type "notification" are not considered as unread messages', async function (assert) {
+ assert.expect(8);
+
+ const messagingMenu = new MessagingMenu();
+ testUtils.mock.addMockEnvironment(messagingMenu, {
+ data: this.data,
+ services: this.services,
+ });
+ await messagingMenu.appendTo($('#qunit-fixture'));
+ // open messaging menu
+ await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
+ assert.containsOnce(
+ messagingMenu,
+ '.o_mail_preview',
+ "should display a single channel preview");
+ assert.strictEqual(
+ messagingMenu
+ .$('.o_preview_name')
+ .text()
+ .trim(),
+ "general",
+ "should display channel preview of 'general' channel");
+ assert.strictEqual(
+ messagingMenu
+ .$('.o_preview_counter')
+ .text()
+ .trim(),
+ "",
+ "should have unread counter of 0 for 'general' channel (no unread messages)");
+ assert.strictEqual(
+ messagingMenu
+ .$('.o_last_message_preview')
+ .text()
+ .replace(/\s/g, ""),
+ "Me:test",
+ "should display author name and inline body of currently last message in the channel");
+ // close messaging menu
+ await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
+
+ // simulate receiving a new message on the channel 'general' (id 1)
+ // of type 'notification'
+ const data = {
+ id: 100,
+ author_id: [42, "Someone"],
+ body: "
Left #general
",
+ channel_ids: [1],
+ message_type: 'notification',
+ };
+ const notification = [[false, 'mail.channel', 1], data];
+ messagingMenu.call('bus_service', 'trigger', 'notification', [notification]);
+ await testUtils.nextTick();
+ // open messaging menu
+ await testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
+ assert.containsOnce(
+ messagingMenu,
+ '.o_mail_preview',
+ "should still display a single channel preview");
+ assert.strictEqual(
+ messagingMenu
+ .$('.o_preview_name')
+ .text()
+ .trim(),
+ "general",
+ "should still display channel preview of 'general' channel");
+ assert.strictEqual(
+ messagingMenu
+ .$('.o_preview_counter')
+ .text()
+ .trim(),
+ "",
+ "should still have unread counter of 0 for 'general' channel (no unread messages)");
+ assert.strictEqual(
+ messagingMenu
+ .$('.o_last_message_preview')
+ .text()
+ .replace(/\s/g, ""),
+ "Someone:Left#general",
+ "should display author name and inline body of notification message (last message of channel)");
+
+ messagingMenu.destroy();
+});
+
QUnit.test('preview of inbox message not linked to document + mark as read', async function (assert) {
assert.expect(17);
diff --git a/addons/mail/views/mail_activity_views.xml b/addons/mail/views/mail_activity_views.xml
index fdbfa3a5bdd..1d9d9f5fcc5 100644
--- a/addons/mail/views/mail_activity_views.xml
+++ b/addons/mail/views/mail_activity_views.xml
@@ -15,21 +15,13 @@
+
-
-
-
@@ -43,6 +35,14 @@
+
+
Modifying the model can have an impact on existing activities using this activity type, be careful.
diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml
index 923b32cc4d8..45254bcbacd 100644
--- a/addons/maintenance/views/maintenance_views.xml
+++ b/addons/maintenance/views/maintenance_views.xml
@@ -128,8 +128,8 @@
@@ -430,8 +430,8 @@
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index 3ad0a565770..d72caa41b24 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -369,6 +369,7 @@ class MrpProduction(models.Model):
order.post_visible = order.is_locked and any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_raw_ids | order.move_finished_ids)
else:
order.post_visible = order.is_locked and any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_finished_ids)
+ order.post_visible &= all(wo.state in ['done', 'cancel'] for wo in order.workorder_ids) or all(m.product_id.tracking == 'none' for m in order.move_raw_ids)
@api.depends('workorder_ids.state', 'move_finished_ids', 'move_finished_ids.quantity_done', 'is_locked')
def _get_produced_qty(self):
@@ -669,6 +670,8 @@ class MrpProduction(models.Model):
def open_produce_product(self):
self.ensure_one()
+ if self.bom_id.type == 'phantom':
+ raise UserError(_('You cannot produce a MO with a bom kit product.'))
action = self.env.ref('mrp.act_mrp_product_produce').read()[0]
return action
diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py
index bd60fcc530f..479fad8a5fc 100644
--- a/addons/mrp/models/stock_move.py
+++ b/addons/mrp/models/stock_move.py
@@ -234,6 +234,11 @@ class StockMove(models.Model):
res = super(StockMove, self)._should_be_assigned()
return bool(res and not (self.production_id or self.raw_material_production_id))
+ def _key_assign_picking(self):
+ keys = super(StockMove, self)._key_assign_picking()
+ return keys + (self.created_production_id,)
+
+
def _compute_kit_quantities(self, product_id, kit_qty, kit_bom, filters):
""" Computes the quantity delivered or received when a kit is sold or purchased.
A ratio 'qty_processed/qty_needed' is computed for each component, and the lowest one is kept
@@ -267,4 +272,4 @@ class StockMove(models.Model):
# doesn't make sense.
return min(qty_ratios) // 1
else:
- return 0.0
+ return 0.0
\ No newline at end of file
diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml
index 1ea13889aa4..24fd3307adf 100644
--- a/addons/payment/views/payment_portal_templates.xml
+++ b/addons/payment/views/payment_portal_templates.xml
@@ -130,7 +130,7 @@
Communication:
-
+
Or scan me with your banking app.
diff --git a/addons/payment_sips/models/payment.py b/addons/payment_sips/models/payment.py
index 7d37cf93c36..3f2087e5940 100644
--- a/addons/payment_sips/models/payment.py
+++ b/addons/payment_sips/models/payment.py
@@ -44,9 +44,9 @@ class AcquirerSips(models.Model):
provider = fields.Selection(selection_add=[('sips', 'Sips')])
sips_merchant_id = fields.Char('Merchant ID', help="Used for production only", required_if_provider='sips', groups='base.group_user')
sips_secret = fields.Char('Secret Key', size=64, required_if_provider='sips', groups='base.group_user')
- sips_test_url = fields.Char("Test url", required_if_provider='sips', groups='base.group_no_one', default='https://payment-webinit.sips-atos.com/paymentInit')
- sips_prod_url = fields.Char("Production url", required_if_provider='sips', groups='base.group_no_one', default='https://payment-webinit.simu.sips-atos.com/paymentInit')
- sips_version = fields.Char("Interface Version", required_if_provider='sips', groups='base.group_no_one', default='HP_2.3')
+ sips_test_url = fields.Char("Test url", required_if_provider='sips', default='https://payment-webinit.simu.sips-atos.com/paymentInit')
+ sips_prod_url = fields.Char("Production url", required_if_provider='sips', default='https://payment-webinit.sips-atos.com/paymentInit')
+ sips_version = fields.Char("Interface Version", required_if_provider='sips', default='HP_2.3')
def _sips_generate_shasign(self, values):
""" Generate the shasign for incoming or outgoing communications.
diff --git a/addons/payment_sips/views/payment_views.xml b/addons/payment_sips/views/payment_views.xml
index bdd85f68132..b6b6c2fae1c 100644
--- a/addons/payment_sips/views/payment_views.xml
+++ b/addons/payment_sips/views/payment_views.xml
@@ -11,9 +11,9 @@
-
-
-
+
+
+
diff --git a/addons/point_of_sale/models/account_tax.py b/addons/point_of_sale/models/account_tax.py
index 11375ec9ec8..8c130630d78 100644
--- a/addons/point_of_sale/models/account_tax.py
+++ b/addons/point_of_sale/models/account_tax.py
@@ -24,3 +24,12 @@ class AccountTax(models.Model):
'You must close the POS sessions before modifying the tax.'
))
return super(AccountTax, self).write(vals)
+
+ def get_real_tax_amount(self):
+ tax_list = []
+ for tax in self:
+ tax_repartition_lines = tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax')
+ total_factor = sum(tax_repartition_lines.mapped('factor'))
+ real_amount = tax.amount * total_factor
+ tax_list.append({'id': tax.id, 'amount': real_amount})
+ return tax_list
diff --git a/addons/point_of_sale/static/src/css/customer_facing_display.css b/addons/point_of_sale/static/src/css/customer_facing_display.css
index d9be0c89c35..bb699672ad8 100644
--- a/addons/point_of_sale/static/src/css/customer_facing_display.css
+++ b/addons/point_of_sale/static/src/css/customer_facing_display.css
@@ -122,8 +122,7 @@ body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_order
box-shadow: none;
animation: none;
}
-body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div,
-body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child {
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div, body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child {
border-left-width: 0;
text-align: center;
font-size: 70%;
@@ -423,7 +422,7 @@ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .p
text-align: center;
}
body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
- display: none!important;
+ display: none !important;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details {
-webkit-box-flex: 1 0 100%;
@@ -439,6 +438,7 @@ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .p
font-size: 6.5vw;
}
}
+
body .pos-hidden {
opacity: 0;
}
@@ -460,7 +460,7 @@ body.editor_enable #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-gro
}
body.editor_enable #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group > .dropdown.oe_options.btn-group.snippet-option-pos_palette > .dropdown-menu > a {
position: relative;
- padding: 14px 0!important;
+ padding: 14px 0 !important;
margin-bottom: 1px;
}
body.editor_enable #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group > .dropdown.oe_options.btn-group.snippet-option-pos_palette > .dropdown-menu > a:before {
@@ -470,6 +470,7 @@ body.editor_enable #oe_manipulators .no_parent_options .snippet-option-pos_palet
body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
display: none;
}
+
.pos-palette_01 .pos-payment_info {
background: #3E3E3E;
color: #f6f6f6;
@@ -506,6 +507,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_01:before {
text-shadow: 0 0 1px #090909;
}
+
.pos-palette_02 .pos-payment_info {
background: #364152;
color: #e6e7e8;
@@ -542,6 +544,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_02:before {
text-shadow: 0 0 1px #130d09;
}
+
.pos-palette_03 .pos-payment_info {
background: #1BA39C;
color: #f6f6f6;
@@ -578,6 +581,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_03:before {
text-shadow: 0 0 1px #131313;
}
+
.pos-palette_04 .pos-payment_info {
background: #0b7b6c;
color: #f6f6f6;
@@ -614,6 +618,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_04:before {
text-shadow: 0 0 1px #101113;
}
+
.pos-palette_05 .pos-payment_info {
background: #E26868;
color: #f6f6f6;
@@ -650,6 +655,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_05:before {
text-shadow: 0 0 1px #131313;
}
+
.pos-palette_06 .pos-payment_info {
background: #9E373B;
color: #f6f6f6;
@@ -686,6 +692,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_06:before {
text-shadow: 0 0 1px #090909;
}
+
.pos-palette_07 .pos-payment_info {
background: #ce9934;
color: white;
@@ -722,6 +729,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_07:before {
text-shadow: 0 0 1px #131313;
}
+
.pos-palette_08 .pos-payment_info {
background: #a48c77;
color: #f6f6f6;
@@ -758,6 +766,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_08:before {
text-shadow: 0 0 1px #131313;
}
+
.pos-palette_09 .pos-payment_info {
background: linear-gradient(30deg, #014d43, #127e71);
color: #f6f6f6;
@@ -794,6 +803,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_09:before {
text-shadow: 0 0 1px #131313;
}
+
.pos-palette_10 .pos-payment_info {
background: linear-gradient(30deg, #e2316c, #ea4c89);
color: white;
@@ -830,6 +840,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_10:before {
text-shadow: 0 0 1px #131313;
}
+
.pos-palette_11 .pos-payment_info {
background: linear-gradient(30deg, #362b3d, #5b4a63);
color: white;
@@ -866,6 +877,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_11:before {
text-shadow: 0 0 1px #131313;
}
+
.pos-palette_12 .pos-payment_info {
background: #434343;
color: #e6e6e6;
@@ -902,6 +914,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header {
#oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_12:before {
text-shadow: 0 0 1px #a4a4a4;
}
+
.pos-palette_13 .pos-payment_info {
background: linear-gradient(30deg, #1a1b1f, #3d3f45);
color: white;
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index d413b9428b8..ab01cb1cb29 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -239,6 +239,19 @@ exports.PosModel = Backbone.Model.extend({
return self.taxes_by_id[child_tax_id];
});
});
+ return new Promise(function (resolve, reject) {
+ var tax_ids = _.pluck(self.taxes, 'id');
+ rpc.query({
+ model: 'account.tax',
+ method: 'get_real_tax_amount',
+ args: [tax_ids],
+ }).then(function (taxes) {
+ _.each(taxes, function (tax) {
+ self.taxes_by_id[tax.id].amount = tax.amount;
+ resolve();
+ });
+ });
+ });
},
},{
model: 'pos.session',
@@ -848,7 +861,7 @@ exports.PosModel = Backbone.Model.extend({
self.flush_mutex.exec(function () {
var flushed = self._flush_orders(self.db.get_orders(), opts);
- flushed.then(resolve, resolve);
+ flushed.then(resolve, reject);
return flushed;
});
@@ -929,8 +942,9 @@ exports.PosModel = Backbone.Model.extend({
return this._save_to_server(orders, options).then(function (server_ids) {
self.set_synch('connected');
return _.pluck(server_ids, 'id');
- }).catch(function(){
+ }).catch(function(error){
self.set_synch(self.get('failed') ? 'error' : 'disconnected');
+ return Promise.reject(error);
});
},
diff --git a/addons/point_of_sale/static/src/scss/customer_facing_display.scss b/addons/point_of_sale/static/src/scss/customer_facing_display.scss
index 25e0a0bc324..6677b6a572e 100644
--- a/addons/point_of_sale/static/src/scss/customer_facing_display.scss
+++ b/addons/point_of_sale/static/src/scss/customer_facing_display.scss
@@ -99,7 +99,8 @@ body {
@include flex-display;
@include flex-direction(row);
- .pos-customer_products, .pos-payment_info {
+ .pos-customer_products,
+ .pos-payment_info {
height: 100%;
padding: 2%;
@include flex-display;
@@ -139,11 +140,14 @@ body {
box-shadow: none;
animation: none;
- > div, > div:last-child {
- border-left-width: 0;
- text-align: center;
- font-size: 70%;
- font-weight: normal;
+ > div {
+ &,
+ &:last-child {
+ border-left-width: 0;
+ text-align: center;
+ font-size: 70%;
+ font-weight: normal;
+ }
}
> div:last-child {
@@ -194,7 +198,8 @@ body {
@include flex-direction(column);
@include justify-content(space-between);
- .pos-adv, .pos-company_logo {
+ .pos-adv,
+ .pos-company_logo {
@include pos-bg;
}
@@ -212,7 +217,8 @@ body {
}
.pos-payment_info_details{
- .pos-total, .pos-paymentlines {
+ .pos-total,
+ .pos-paymentlines {
@include flex-direction(row);
@include flex-display;
@include flex-wrap(wrap);
@@ -221,6 +227,7 @@ body {
> div {
@include flex(1 0 48%);
+
&:nth-child(even) {
font-weight: bold;
font-size: 120%;
@@ -433,7 +440,7 @@ body {
}
}
- #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group> .dropdown.oe_options.btn-group.snippet-option-pos_palette > ul {
+ #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group> .dropdown.oe_options.btn-group.snippet-option-pos_palette > .dropdown-menu {
min-width: 100px;
max-width: 100px;
@@ -441,7 +448,7 @@ body {
display: none;
}
- > li > a {
+ > a {
position: relative;
padding: 14px 0!important;
margin-bottom: 1px;
@@ -453,7 +460,8 @@ body {
}
#oe_manipulators .no_parent_options {
- .snippet-option-pos_palette, .o_parent_editor_header {
+ .snippet-option-pos_palette,
+ .o_parent_editor_header {
display: none;
}
}
@@ -462,7 +470,7 @@ body {
// =========== PALETTE GENERATOR ===========
-.palette_variant (
+@mixin palette-variant (
$bg-info: $color-gray-dark,
$text-info: $color-gray-lighter,
$bg-products: $color-gray-lighter,
@@ -497,7 +505,7 @@ body {
}
// Editor dropdown buttons
- #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > ul > li > & {
+ #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > & {
background: $bg-products!important;
&:after {
@@ -511,7 +519,7 @@ body {
}
&:before {
- text-shadow: 0 0 1px (#fff - $bg-products);
+ text-shadow: 0 0 1px invert($bg-products);
}
}
};
diff --git a/addons/portal/__manifest__.py b/addons/portal/__manifest__.py
index 1fb78f87335..b2e9843f8ca 100644
--- a/addons/portal/__manifest__.py
+++ b/addons/portal/__manifest__.py
@@ -15,7 +15,7 @@ portal.
This module contains most code coming from odoo v10 website_portal. Purpose
of this module is to allow the display of a customer portal without having
a dependency towards website edition and customization capabilities.""",
- 'depends': ['web', 'web_editor', 'http_routing', 'mail'],
+ 'depends': ['web', 'web_editor', 'http_routing', 'mail', 'auth_signup'],
'data': [
'data/portal_data.xml',
'views/assets.xml',
diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py
index 225347df3fa..82fed4993c5 100644
--- a/addons/product/models/product_pricelist.py
+++ b/addons/product/models/product_pricelist.py
@@ -130,6 +130,7 @@ class Pricelist(models.Model):
self.ensure_one()
if not date:
date = self._context.get('date') or fields.Date.context_today(self)
+ date = fields.Date.to_date(date) # handwritten query below does not work with non-dates (e.g. datetimes)
if not uom_id and self._context.get('uom'):
uom_id = self._context['uom']
if uom_id:
@@ -305,10 +306,10 @@ class Pricelist(models.Model):
list(zip(**products_by_qty_by_partner)))
def _get_partner_pricelist_multi_search_domain_hook(self):
- return []
+ return [('active', '=', True)]
def _get_partner_pricelist_multi_filter_hook(self):
- return self
+ return self.filtered('active')
def _get_partner_pricelist_multi(self, partner_ids, company_id=None):
""" Retrieve the applicable pricelist for given partners in a given company.
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index 012f41abc0b..75167a7184a 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -420,13 +420,20 @@ class ProductTemplate(models.Model):
Product = self.env['product.product']
templates = self.browse([])
+ domain_no_variant = [('product_variant_ids', '=', False)]
while True:
domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or []
args = args if args is not None else []
products_ns = Product._name_search(name, args+domain, operator=operator, name_get_uid=name_get_uid)
products = Product.browse([x[0] for x in products_ns])
templates |= products.mapped('product_tmpl_id')
- if (not products) or (limit and (len(templates) > limit)):
+ current_round_templates = self.browse([])
+ if not products:
+ domain_template = args + domain_no_variant + (templates and [('id', 'not in', templates.ids)] or [])
+ template_ns = super(ProductTemplate, self)._name_search(name=name, args=domain_template, operator=operator, limit=limit, name_get_uid=name_get_uid)
+ current_round_templates |= self.browse([ns[0] for ns in template_ns])
+ templates |= current_round_templates
+ if (not products and not current_round_templates) or (limit and (len(templates) > limit)):
break
searched_ids = set(templates.ids)
diff --git a/addons/product/tests/test_variants.py b/addons/product/tests/test_variants.py
index a7d66176982..8c092ecfefe 100644
--- a/addons/product/tests/test_variants.py
+++ b/addons/product/tests/test_variants.py
@@ -827,6 +827,28 @@ class TestVariantsArchive(common.TestProductCommon):
Product._revert_method('unlink')
+ def test_name_search_dynamic_attributes(self):
+ dynamic_attr = self.env['product.attribute'].create({
+ 'name': 'Dynamic',
+ 'create_variant': 'dynamic',
+ 'value_ids': [(0, False, {'name': 'ValueDynamic'})],
+ })
+ template = self.env['product.template'].create({
+ 'name': 'cimanyd'
+ })
+ self.assertEqual(len(template.product_variant_ids), 1)
+
+ template.write({
+ 'attribute_line_ids': [(0, False, {
+ 'attribute_id': dynamic_attr.id,
+ 'value_ids': [(4, dynamic_attr.value_ids[0].id, False)],
+ })]
+ })
+ self.assertEqual(len(template.product_variant_ids), 0)
+
+ name_searched = self.env['product.template'].name_search(name='cima')
+ self.assertIn(template.id, [ng[0] for ng in name_searched])
+
def _update_color_vars(self, ptal):
self.ptal_color = ptal
self.assertEqual(self.ptal_color.attribute_id, self.pa_color)
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
index aa3edd7d987..d55f7b00fd1 100644
--- a/addons/purchase/models/purchase.py
+++ b/addons/purchase/models/purchase.py
@@ -396,8 +396,6 @@ class PurchaseOrder(models.Model):
'default_company_id': self.company_id.id,
'default_purchase_id': self.id,
}
- if self.user_id:
- result['context']['default_user_id'] = self.user_id.id
# choose the view_mode accordingly
if len(self.invoice_ids) > 1 and not create_bill:
result['domain'] = "[('id', 'in', " + str(self.invoice_ids.ids) + ")]"
diff --git a/addons/sale_coupon/models/sale_order.py b/addons/sale_coupon/models/sale_order.py
index 899f203ba0a..25f0751ff4b 100644
--- a/addons/sale_coupon/models/sale_order.py
+++ b/addons/sale_coupon/models/sale_order.py
@@ -206,10 +206,12 @@ class SaleOrder(models.Model):
def _get_reward_line_values(self, program):
self.ensure_one()
+ self = self.with_context(lang=self.partner_id.lang)
+ program = program.with_context(lang=self.partner_id.lang)
if program.reward_type == 'discount':
- return self._get_reward_values_discount(program.with_context(lang=self.partner_id.lang))
+ return self._get_reward_values_discount(program)
elif program.reward_type == 'product':
- return [self._get_reward_values_product(program.with_context(lang=self.partner_id.lang))]
+ return [self._get_reward_values_product(program)]
def _create_reward_line(self, program):
self.write({'order_line': [(0, False, value) for value in self._get_reward_line_values(program)]})
diff --git a/addons/sale_coupon/views/sale_coupon_program_views.xml b/addons/sale_coupon/views/sale_coupon_program_views.xml
index db87e052f84..45871b72c29 100644
--- a/addons/sale_coupon/views/sale_coupon_program_views.xml
+++ b/addons/sale_coupon/views/sale_coupon_program_views.xml
@@ -21,7 +21,7 @@
-
+
@@ -211,7 +211,7 @@
-
+
diff --git a/addons/sale_purchase/models/sale_order.py b/addons/sale_purchase/models/sale_order.py
index 5ad2d3e6c99..890316542a6 100644
--- a/addons/sale_purchase/models/sale_order.py
+++ b/addons/sale_purchase/models/sale_order.py
@@ -67,11 +67,11 @@ class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
purchase_line_ids = fields.One2many('purchase.order.line', 'sale_line_id', string="Generated Purchase Lines", readonly=True, help="Purchase line generated by this Sales item on order confirmation, or when the quantity was increased.")
- purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count', compute_sudo=True)
+ purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count')
@api.depends('purchase_line_ids')
def _compute_purchase_count(self):
- database_data = self.env['purchase.order.line'].read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id'])
+ database_data = self.env['purchase.order.line'].sudo().read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id'])
mapped_data = dict([(db['sale_line_id'][0], db['sale_line_id_count']) for db in database_data])
for line in self:
line.purchase_line_count = mapped_data.get(line.id, 0)
diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py
index 3e4eb8c9180..7b1b9ee56a2 100644
--- a/addons/sale_stock/models/stock.py
+++ b/addons/sale_stock/models/stock.py
@@ -11,7 +11,7 @@ class StockLocationRoute(models.Model):
class StockMove(models.Model):
_inherit = "stock.move"
- sale_line_id = fields.Many2one('sale.order.line', 'Sale Line')
+ sale_line_id = fields.Many2one('sale.order.line', 'Sale Line', index=True)
@api.model
def _prepare_merge_moves_distinct_fields(self):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index a359140ae85..034f04774a2 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -1325,7 +1325,7 @@ class StockMove(models.Model):
for result_package in moves_todo\
.mapped('move_line_ids.result_package_id')\
.filtered(lambda p: p.quant_ids and len(p.quant_ids) > 1):
- if len(result_package.quant_ids.mapped('location_id')) > 1:
+ if len(result_package.quant_ids.filtered(lambda q: float_is_zero(abs(q.quantity) + abs(q.reserved_quantity), precision_rounding=q.product_uom_id.rounding)).mapped('location_id')) > 1:
raise UserError(_('You cannot move the same package content more than once in the same transfer or split the same package into two location.'))
picking = moves_todo.mapped('picking_id')
moves_todo.write({'state': 'done', 'date': fields.Datetime.now()})
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index 3b5bab16629..2264891a7cc 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -549,7 +549,7 @@ class Picking(models.Model):
# As the on_change in one2many list is WIP, we will overwrite the locations on the stock moves here
# As it is a create the format will be a list of (0, 0, dict)
- moves = vals.get('move_lines') or vals.get('move_ids_without_package')
+ moves = vals.get('move_lines', []) + vals.get('move_ids_without_package', [])
if moves and vals.get('location_id') and vals.get('location_dest_id'):
for move in moves:
if len(move) == 3 and move[0] == 0:
diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py
index ea7e26eec2f..ba8ea9cd119 100644
--- a/addons/stock/models/stock_quant.py
+++ b/addons/stock/models/stock_quant.py
@@ -50,7 +50,7 @@ class StockQuant(models.Model):
product_id = fields.Many2one(
'product.product', 'Product',
domain=lambda self: self._domain_product_id(),
- ondelete='restrict', readonly=True, required=True, check_company=True)
+ ondelete='restrict', readonly=True, required=True, index=True, check_company=True)
# so user can filter on template in webclient
product_tmpl_id = fields.Many2one(
'product.template', string='Product Template',
@@ -63,7 +63,7 @@ class StockQuant(models.Model):
location_id = fields.Many2one(
'stock.location', 'Location',
domain=lambda self: self._domain_location_id(),
- auto_join=True, ondelete='restrict', readonly=True, required=True, check_company=True)
+ auto_join=True, ondelete='restrict', readonly=True, required=True, index=True, check_company=True)
lot_id = fields.Many2one(
'stock.production.lot', 'Lot/Serial Number',
ondelete='restrict', readonly=True, check_company=True,
diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py
index 240f589f76b..36acbf4d000 100644
--- a/addons/stock/models/stock_warehouse.py
+++ b/addons/stock/models/stock_warehouse.py
@@ -515,7 +515,7 @@ class Warehouse(models.Model):
delivery_steps = vals.get('delivery_steps', def_values['delivery_steps'])
code = vals.get('code') or code
code = code.replace(' ', '').upper()
- company_id = vals.get('company_id', self.company_id.id)
+ company_id = vals.get('company_id', self.default_get(['company_id'])['company_id'])
sub_locations = {
'lot_stock_id': {
'name': _('Stock'),
@@ -564,12 +564,13 @@ class Warehouse(models.Model):
avoid mistakes during picking types and rules creation.
"""
for warehouse in self:
- sub_locations = warehouse._get_locations_values(vals, warehouse.code)
+ company_id = vals.get('company_id', warehouse.company_id.id)
+ sub_locations = warehouse._get_locations_values(dict(vals, company_id=company_id), warehouse.code)
missing_location = {}
for location, location_values in sub_locations.items():
if not warehouse[location] and location not in vals:
location_values['location_id'] = vals.get('view_location_id', warehouse.view_location_id.id)
- location_values['company_id'] = vals.get('company_id', warehouse.company_id.id)
+ location_values['company_id'] = company_id
missing_location[location] = self.env['stock.location'].create(location_values).id
if missing_location:
warehouse.write(missing_location)
diff --git a/addons/stock/tests/test_packing.py b/addons/stock/tests/test_packing.py
index c6f2d5f0462..4f135c70cb7 100644
--- a/addons/stock/tests/test_packing.py
+++ b/addons/stock/tests/test_packing.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import SavepointCase
+from odoo.tools import float_round
class TestPacking(SavepointCase):
@@ -265,3 +266,49 @@ class TestPacking(SavepointCase):
qp1 = pack2.quant_ids[0]
qp2 = pack2.quant_ids[1]
self.assertEqual(qp1.quantity + qp2.quantity, 12, 'The quant has not the good quantity')
+
+ def test_move_picking_with_package(self):
+ """
+ 355.4 rounded with 0.001 precision is 355.40000000000003.
+ check that nonetheless, moving a picking is accepted
+ """
+ self.assertEqual(self.productA.uom_id.rounding, 0.001)
+ self.assertEqual(
+ float_round(355.4, precision_rounding=self.productA.uom_id.rounding),
+ 355.40000000000003,
+ )
+ location_dict = {
+ 'location_id': self.stock_location.id,
+ }
+ quant = self.env['stock.quant'].create({
+ **location_dict,
+ **{'product_id': self.productA.id, 'quantity': 355.4}, # important number
+ })
+ package = self.env['stock.quant.package'].create({
+ **location_dict, **{'quant_ids': [(6, 0, [quant.id])]},
+ })
+ location_dict.update({
+ 'state': 'draft',
+ 'location_dest_id': self.ship_location.id,
+ })
+ move = self.env['stock.move'].create({
+ **location_dict,
+ **{
+ 'name': "XXX",
+ 'product_id': self.productA.id,
+ 'product_uom': self.productA.uom_id.id,
+ 'product_uom_qty': 355.40000000000003, # other number
+ }})
+ picking = self.env['stock.picking'].create({
+ **location_dict,
+ **{
+ 'picking_type_id': self.warehouse.in_type_id.id,
+ 'move_lines': [(6, 0, [move.id])],
+ }})
+
+ picking.action_confirm()
+ picking.action_assign()
+ move.quantity_done = move.reserved_availability
+ picking.action_done()
+ # if we managed to get there, there was not any exception
+ # complaining that 355.4 is not 355.40000000000003. Good job!
diff --git a/addons/stock/tests/test_warehouse.py b/addons/stock/tests/test_warehouse.py
index 8867f098761..c6e2093d50c 100644
--- a/addons/stock/tests/test_warehouse.py
+++ b/addons/stock/tests/test_warehouse.py
@@ -3,6 +3,7 @@
from odoo.addons.stock.tests.common2 import TestStockCommon
from odoo.tests import Form
from odoo.exceptions import AccessError
+from odoo.tools import mute_logger
class TestWarehouse(TestStockCommon):
@@ -449,6 +450,19 @@ class TestWarehouse(TestStockCommon):
# Ensure there still no quants in distribution warehouse
self.assertEqual(sum(self.env['stock.quant']._gather(product, warehouse_distribution_wavre.lot_stock_id).mapped('quantity')), 0)
+ def test_noleak(self):
+ # non-regression test to avoid company_id leaking to other warehouses (see blame)
+ wh = self.env["stock.warehouse"].search([])
+
+ assert len(set(wh.mapped("company_id.id"))) > 1
+
+ companies_before = wh.mapped(lambda w: (w.id, w.company_id))
+ # writing on any field should change the company of warehouses
+ wh.write({"name": "whatever"})
+ companies_after = wh.mapped(lambda w: (w.id, w.company_id))
+
+ self.assertEqual(companies_after, companies_before)
+
def test_toggle_active_warehouse_1(self):
""" Basic test that create a warehouse with classic configuration.
Archive it and check that locations, picking types, routes, rules are
diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py
index 17cbe9df4cf..0a3e4368835 100644
--- a/addons/stock_landed_costs/models/stock_landed_cost.py
+++ b/addons/stock_landed_costs/models/stock_landed_cost.py
@@ -172,13 +172,13 @@ class LandedCost(models.Model):
prec_digits = self.env.company.currency_id.decimal_places
for landed_cost in self:
total_amount = sum(landed_cost.valuation_adjustment_lines.mapped('additional_landed_cost'))
- if not tools.float_compare(total_amount, landed_cost.amount_total, precision_digits=prec_digits) == 0:
+ if not tools.float_is_zero(total_amount - landed_cost.amount_total, precision_digits=prec_digits):
return False
val_to_cost_lines = defaultdict(lambda: 0.0)
for val_line in landed_cost.valuation_adjustment_lines:
val_to_cost_lines[val_line.cost_line_id] += val_line.additional_landed_cost
- if any(tools.float_compare(cost_line.price_unit, val_amount, precision_digits=prec_digits) != 0
+ if any(not tools.float_is_zero(cost_line.price_unit - val_amount, precision_digits=prec_digits)
for cost_line, val_amount in val_to_cost_lines.items()):
return False
return True
diff --git a/addons/web/models/ir_qweb.py b/addons/web/models/ir_qweb.py
index e8a966647e3..1083916ebf5 100644
--- a/addons/web/models/ir_qweb.py
+++ b/addons/web/models/ir_qweb.py
@@ -52,6 +52,7 @@ class Image(models.AbstractModel):
filename = options['filename']
else:
filename = record.display_name
+ filename = filename.replace('/', '-').replace('\\', '-')
src = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha)
diff --git a/addons/web/static/src/js/core/abstract_storage_service.js b/addons/web/static/src/js/core/abstract_storage_service.js
index ef828b5dd5c..5d724f87911 100644
--- a/addons/web/static/src/js/core/abstract_storage_service.js
+++ b/addons/web/static/src/js/core/abstract_storage_service.js
@@ -44,6 +44,19 @@ var AbstractStorageService = AbstractService.extend({
var val = this.storage.getItem(key);
return val ? JSON.parse(val) : defaultValue;
},
+ /**
+ * @param {integer} index
+ * @return {string}
+ */
+ key: function (index) {
+ return this.storage.key(index);
+ },
+ /**
+ * @return {integer}
+ */
+ length: function () {
+ return this.storage.length;
+ },
/**
* Removes the given key from the storage
*
diff --git a/addons/web/static/src/js/core/local_storage.js b/addons/web/static/src/js/core/local_storage.js
index 0ae98498b31..ecc0cb7b78f 100644
--- a/addons/web/static/src/js/core/local_storage.js
+++ b/addons/web/static/src/js/core/local_storage.js
@@ -35,12 +35,10 @@ try {
var newValue = e.originalEvent.newValue;
try {
JSON.parse(newValue);
- if (localStorage.getItem(key) === newValue) {
- storage.trigger('storage', {
- key: key,
- newValue: newValue,
- });
- }
+ storage.trigger('storage', {
+ key: key,
+ newValue: newValue,
+ });
} catch (error) {}
});
return storage;
diff --git a/addons/web/static/src/js/core/ram_storage.js b/addons/web/static/src/js/core/ram_storage.js
index 9bf0fc29f93..abad86c4538 100644
--- a/addons/web/static/src/js/core/ram_storage.js
+++ b/addons/web/static/src/js/core/ram_storage.js
@@ -31,6 +31,7 @@ var RamStorage = Class.extend(mixins.EventDispatcherMixin, {
*/
clear: function () {
this.storage = Object.create(null);
+ this.length = 0;
},
/**
* Returns the value associated with a given key in the storage
@@ -41,12 +42,22 @@ var RamStorage = Class.extend(mixins.EventDispatcherMixin, {
getItem: function (key) {
return this.storage[key];
},
+ /**
+ * @param {integer} index
+ * @return {string}
+ */
+ key: function (index) {
+ return _.keys(this.storage)[index];
+ },
/**
* Removes the given key from the storage
*
* @param {string} key
*/
removeItem: function (key) {
+ if (key in this.storage) {
+ this.length--;
+ }
delete this.storage[key];
this.trigger('storage', {key: key, newValue: null});
},
@@ -58,6 +69,9 @@ var RamStorage = Class.extend(mixins.EventDispatcherMixin, {
* @param {string} value
*/
setItem: function (key, value) {
+ if (!(key in this.storage)) {
+ this.length++;
+ }
this.storage[key] = value;
this.trigger('storage', {key: key, newValue: value});
},
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index f4337d0c8f9..b2d3379aef1 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -2576,7 +2576,7 @@ var FieldSelection = AbstractField.extend({
description: _lt("Selection"),
template: 'FieldSelection',
specialData: "_fetchSpecialRelation",
- supportedFieldTypes: ['selection', 'many2one'],
+ supportedFieldTypes: ['selection'],
events: _.extend({}, AbstractField.prototype.events, {
'change': '_onChange',
}),
@@ -2629,10 +2629,14 @@ var FieldSelection = AbstractField.extend({
*/
_renderEdit: function () {
this.$el.empty();
+ var required = this.attrs.modifiersValue && this.attrs.modifiersValue.required;
for (var i = 0 ; i < this.values.length ; i++) {
+ var disabled = required && this.values[i][0] === false;
+
this.$el.append($(' ', {
value: JSON.stringify(this.values[i][0]),
- text: this.values[i][1]
+ text: this.values[i][1],
+ style: disabled ? "display: none" : "",
}));
}
var value = this.value;
@@ -2671,9 +2675,7 @@ var FieldSelection = AbstractField.extend({
return v[0] === false && v[1] === '';
});
}
- if (!this.attrs.modifiersValue || !this.attrs.modifiersValue.required) {
- this.values = [[false, this.attrs.placeholder || '']].concat(this.values);
- }
+ this.values = [[false, this.attrs.placeholder || '']].concat(this.values);
},
//--------------------------------------------------------------------------
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 614602a5791..2d239520dfb 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -2068,9 +2068,11 @@ var BasicModel = AbstractModel.extend({
if (field.onChange) {
hasOnchange = true;
}
- _.each(fieldInfo.views, function (view) {
- generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.');
- });
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ _.each(fieldInfo.views, function (view) {
+ generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.');
+ });
+ }
});
}
return hasOnchange ? specs : false;
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index 22de0e8d68b..bd31cddd50d 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -631,7 +631,9 @@ var FormController = BasicController.extend({
* @private
*/
_onEdit: function () {
- this._setMode('edit');
+ // wait for potential pending changes to be saved (done with widgets
+ // allowing to edit in readonly)
+ this.mutex.getUnlockedDef().then(this._setMode.bind(this, 'edit'));
},
/**
* This method is called when someone tries to freeze the order, most likely
diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js
index 95b84423444..13d73cf37c2 100644
--- a/addons/web/static/src/js/views/form/form_view.js
+++ b/addons/web/static/src/js/views/form/form_view.js
@@ -123,6 +123,10 @@ var FormView = BasicView.extend({
var refinedContext = _.pick(self.loadParams.context, function (value, key) {
return key.indexOf('_view_ref') === -1;
});
+ // Specify the main model to prevent access rights defined in the context
+ // (e.g. create: 0) to apply to subviews. We use here the same logic as
+ // the one applied by the server for inline views.
+ refinedContext.base_model_name = self.controllerParams.modelName;
defs.push(parent.loadViews(
field.relation,
new Context(context, self.userContext, refinedContext).eval(),
diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js
index f8125675d4e..c3ad22f6ced 100644
--- a/addons/web/static/src/js/views/graph/graph_view.js
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -48,6 +48,9 @@ var GraphView = AbstractView.extend({
this.arch.children.forEach(function (field) {
var fieldName = field.attrs.name;
+ if (fieldName === "id") {
+ return;
+ }
var interval = field.attrs.interval;
if (interval) {
fieldName = fieldName + ':' + interval;
diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js
index 701180a357f..0c4213c60b7 100644
--- a/addons/web/static/src/js/views/kanban/kanban_record.js
+++ b/addons/web/static/src/js/views/kanban/kanban_record.js
@@ -70,12 +70,14 @@ var KanbanRecord = Widget.extend({
* Called each time the record is attached to the DOM.
*/
on_attach_callback: function () {
+ this.isInDOM = true;
_.invoke(this.subWidgets, 'on_attach_callback');
},
/**
* Called each time the record is detached from the DOM.
*/
on_detach_callback: function () {
+ this.isInDOM = false;
_.invoke(this.subWidgets, 'on_detach_callback');
},
@@ -256,6 +258,9 @@ var KanbanRecord = Widget.extend({
// a widget already exists for that field, so reset it with the new state
widget.reset(self.state);
$field.replaceWith(widget.$el);
+ if (self.isInDOM && widget.on_attach_callback) {
+ widget.on_attach_callback();
+ }
}
} else {
self._processField($field, field_name);
@@ -342,6 +347,9 @@ var KanbanRecord = Widget.extend({
*/
_render: function () {
this.defs = [];
+ // call 'on_detach_callback' on each subwidget as they will be removed
+ // from the DOM at the next line
+ _.invoke(this.subWidgets, 'on_detach_callback');
this._replaceElement(this.qweb.render('kanban-box', this.qweb_context));
this.$el.addClass('o_kanban_record').attr("tabindex", 0);
this.$el.attr('role', 'article');
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index 1e109d7c0b8..5ec42156000 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -622,6 +622,7 @@ var ListController = BasicController.extend({
_onEditGroupClicked: function (ev) {
ev.stopPropagation();
this.do_action({
+ context: {create: false},
type: 'ir.actions.act_window',
views: [[false, 'form']],
res_model: ev.data.record.model,
@@ -704,6 +705,7 @@ var ListController = BasicController.extend({
* @param {OdooEvent} ev
*/
_onToggleGroup: function (ev) {
+ ev.stopPropagation();
var self = this;
this.model
.toggleGroup(ev.data.group.id)
diff --git a/addons/web/static/src/js/widgets/auto_complete.js b/addons/web/static/src/js/widgets/auto_complete.js
index 4da54cf8b5c..1bb00be8d38 100644
--- a/addons/web/static/src/js/widgets/auto_complete.js
+++ b/addons/web/static/src/js/widgets/auto_complete.js
@@ -140,12 +140,22 @@ return Widget.extend({
var $item = self.make_list_item(result).appendTo($list);
result.$el = $item;
});
+ // IE9 doesn't support addEventListener with option { once: true }
+ this.el.onmousemove = function (ev) {
+ self.$('li').each(function (index, li) {
+ li.onmouseenter = self.focus_element.bind(self, $(li));
+ });
+ var targetFocus = ev.target.tagName === 'LI' ?
+ ev.target :
+ ev.target.closest('li');
+ self.focus_element($(targetFocus));
+ self.el.onmousemove = null;
+ };
this.show();
},
make_list_item: function (result) {
var self = this;
var $li = $('')
- .hover(function () {self.focus_element($li);})
.mousedown(function (ev) {
if (ev.button === 0) { // left button
self.select(ev, {item: {facet: result.facet}});
diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js
index 5b4480838fe..ac8e83c8e30 100644
--- a/addons/web/static/src/js/widgets/date_picker.js
+++ b/addons/web/static/src/js/widgets/date_picker.js
@@ -92,8 +92,8 @@ var DateWidget = Widget.extend({
* set datetime value
*/
changeDatetime: function () {
+ var oldValue = this.getValue();
if (this.isValid()) {
- var oldValue = this.getValue();
this._setValueFromUi();
var newValue = this.getValue();
var hasChanged = !oldValue !== !newValue;
@@ -110,6 +110,9 @@ var DateWidget = Widget.extend({
}
this.trigger("datetime_changed");
}
+ } else {
+ var formattedValue = oldValue ? this._formatClient(oldValue) : null;
+ this.$input.val(formattedValue);
}
},
/**
diff --git a/addons/web/static/src/scss/ui.scss b/addons/web/static/src/scss/ui.scss
index 44d639805be..9ab1729a4ac 100644
--- a/addons/web/static/src/scss/ui.scss
+++ b/addons/web/static/src/scss/ui.scss
@@ -83,3 +83,11 @@
.o_debounce_disabled {
pointer-events: none;
}
+
+span.o_force_ltr {
+ display: inline-block;
+}
+.o_force_ltr, .o_field_phone {
+ /*rtl:ignore*/
+ direction: ltr;
+}
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index e8060e23e58..eca412c036a 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -2848,8 +2848,32 @@ QUnit.module('basic_fields', {
form.destroy();
});
- QUnit.test('date field should remove the date if the date is not valid', async function (assert) {
- assert.expect(1);
+ QUnit.test('date field: set an invalid date when the field is already set', async function (assert) {
+ assert.expect(2);
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ var $input = form.$('.o_field_widget[name=date] input');
+
+ assert.strictEqual($input.val(), "02/03/2017");
+
+ $input.val('mmmh').trigger('change');
+ assert.strictEqual($input.val(), "02/03/2017", "should have reset the original value");
+
+ form.destroy();
+ });
+
+ QUnit.test('date field: set an invalid date when the field is not set yet', async function (assert) {
+ assert.expect(2);
var form = await createView({
View: FormView,
@@ -2857,13 +2881,18 @@ QUnit.module('basic_fields', {
data: this.data,
arch: '',
res_id: 4,
+ viewOptions: {
+ mode: 'edit',
+ },
});
- // switch to edit mode
- await testUtils.form.clickEdit(form);
- // set an invalid date
+
var $input = form.$('.o_field_widget[name=date] input');
+
+ assert.strictEqual($input.text(), "");
+
$input.val('mmmh').trigger('change');
assert.strictEqual($input.text(), "", "The date field should be empty");
+
form.destroy();
});
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 7284b9099d8..7f3f54e3ad7 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1191,7 +1191,7 @@ QUnit.module('relational_fields', {
});
QUnit.test('required selection widget should not have blank option', async function (assert) {
- assert.expect(3);
+ assert.expect(12);
this.data.partner.fields.feedback_value = {
type: "selection",
@@ -1212,15 +1212,38 @@ QUnit.module('relational_fields', {
});
await testUtils.form.clickEdit(form);
- assert.strictEqual(form.$('.o_field_widget[name=color]')[0].options.length, 3,
- "non required selection field must have 3 options 1 blank option and 2 value options");
- assert.strictEqual(form.$('.o_field_widget[name=feedback_value]')[0].options.length, 2,
- "should have only 2 options without blank option");
+
+ var $colorField = form.$('.o_field_widget[name=color]');
+ assert.containsN($colorField, 'option', 3, "Three options in non required field");
+
+ assert.hasAttrValue($colorField.find('option:first()'), 'style', "",
+ "Should not have display=none");
+ assert.hasAttrValue($colorField.find('option:eq(1)'), 'style', "",
+ "Should not have display=none");
+ assert.hasAttrValue($colorField.find('option:eq(2)'), 'style', "",
+ "Should not have display=none");
+
+ const $requiredSelect = form.$('.o_field_widget[name=feedback_value]');
+
+ assert.containsN($requiredSelect, 'option', 3, "Three options in required field");
+ assert.hasAttrValue($requiredSelect.find('option:first()'), 'style', "display: none",
+ "Should have display=none");
+ assert.hasAttrValue($requiredSelect.find('option:eq(1)'), 'style', "",
+ "Should not have display=none");
+ assert.hasAttrValue($requiredSelect.find('option:eq(2)'), 'style', "",
+ "Should not have display=none");
// change value to update widget modifier values
- await testUtils.fields.editSelect(form.$('.o_field_widget[name=feedback_value]'), '"bad"');
- assert.strictEqual(form.$('.o_field_widget[name=color]')[0].options.length, 2,
- "should have only 2 options");
+ await testUtils.fields.editSelect($requiredSelect, '"bad"');
+ $colorField = form.$('.o_field_widget[name=color]');
+
+ assert.containsN($colorField, 'option', 3, "Three options in required field");
+ assert.hasAttrValue($colorField.find('option:first()'), 'style', "display: none",
+ "Should have display=none");
+ assert.hasAttrValue($colorField.find('option:eq(1)'), 'style', "",
+ "Should not have display=none");
+ assert.hasAttrValue($colorField.find('option:eq(2)'), 'style', "",
+ "Should not have display=none");
form.destroy();
});
diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js
index be6dd4c8e72..d77e8ceae79 100644
--- a/addons/web/static/tests/helpers/mock_server.js
+++ b/addons/web/static/tests/helpers/mock_server.js
@@ -85,7 +85,7 @@ var MockServer = Class.extend({
throw new Error('Model ' + model + ' was not defined in mock server data');
}
var fields = $.extend(true, {}, this.data[model].fields);
- var fvg = this._fieldsViewGet(params.arch, model, fields, viewOptions.context);
+ var fvg = this._fieldsViewGet(params.arch, model, fields, viewOptions.context || {});
if (toolbar) {
fvg.toolbar = toolbar;
}
@@ -214,6 +214,17 @@ var MockServer = Class.extend({
var inTreeView = (doc.tagName === 'tree');
+ // mock _postprocess_access_rights
+ const isBaseModel = !context.base_model_name || (model === context.base_model_name);
+ var views = ['kanban', 'tree', 'form', 'gantt', 'activity'];
+ if (isBaseModel && views.indexOf(doc.tagName) !== -1) {
+ for (let action of ['create', 'delete', 'edit', 'write']) {
+ if (!doc.getAttribute(action) && action in context && !context[action]) {
+ doc.setAttribute(action, 'false');
+ }
+ }
+ }
+
this._traverse(doc, function (node) {
if (node.nodeType === Node.TEXT_NODE) {
return false;
@@ -318,7 +329,7 @@ var MockServer = Class.extend({
relModel = field.relation;
relFields = $.extend(true, {}, self.data[relModel].fields);
field.views[children.tagName] = self._fieldsViewGet(children, relModel,
- relFields, context);
+ relFields, _.extend({}, context, {base_model_name: model}));
});
}
@@ -338,9 +349,9 @@ var MockServer = Class.extend({
node._isProcessed = true;
// postprocess simulation
field.views.groupby = self._fieldsViewGet(node, relModel, relFields, context);
- node.childNodes.forEach(function (child) {
- node.removeChild(child);
- });
+ while (node.firstChild) {
+ node.removeChild(node.firstChild);
+ }
});
var xmlSerializer = new XMLSerializer();
diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js
index b8f42236c24..892a946bba0 100644
--- a/addons/web/static/tests/helpers/test_utils_create.js
+++ b/addons/web/static/tests/helpers/test_utils_create.js
@@ -140,6 +140,7 @@ async function createView(params) {
_updateMVCParams: function () {
this._super.apply(this, arguments);
this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || [];
+ testUtilsMock.unpatch(View);
},
});
if ('hasSelectors' in params) {
@@ -178,8 +179,6 @@ async function createView(params) {
delete view.destroy;
widget.destroy();
$webClient.remove();
-
- testUtilsMock.unpatch(View);
};
// render the view in a fragment as they must be able to render correctly
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 8e4b2e82d02..16524ba171d 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -490,8 +490,10 @@ QUnit.module('Views', {
'The correct _view_ref should have been sent to the server, first time');
}
if (args.model === 'partner_type') {
- assert.deepEqual(context, {tree_view_ref: 'some_other_tree_view'},
- 'The correct _view_ref should have been sent to the server for the subview');
+ assert.deepEqual(context, {
+ base_model_name: 'product',
+ tree_view_ref: 'some_other_tree_view',
+ }, 'The correct _view_ref should have been sent to the server for the subview');
}
}
return this._super.apply(this, arguments);
@@ -4757,6 +4759,35 @@ QUnit.module('Views', {
});
});
+ QUnit.test('non inline subview and create=0 in action context', async function (assert) {
+ // the create=0 should apply on the main view (form), but not on subviews
+ assert.expect(2);
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ archs: {
+ "product,false,kanban": `
+
+
+
+ `,
+ },
+ res_id: 1,
+ viewOptions: {
+ context: {create: false},
+ mode: 'edit',
+ },
+ });
+
+ assert.containsNone(form, '.o_form_button_create');
+ assert.containsOnce(form, '.o-kanban-button-new');
+
+ form.destroy();
+ });
+
QUnit.test('readonly fields with modifiers may be saved', async function (assert) {
// the readonly property on the field description only applies on view,
// this is not a DB constraint. It should be seen as a default value,
@@ -8032,6 +8063,46 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('edit a record in readonly and switch to edit before it is actually saved', async function (assert) {
+ assert.expect(3);
+
+ const prom = testUtils.makeTestPromise();
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: ``,
+ mockRPC: function (route, args) {
+ const result = this._super.apply(this, arguments);
+ if (args.method === 'write') { // delay the write RPC
+ assert.deepEqual(args.args[1], {bar: false});
+ return prom.then(_.constant(result));
+ }
+ return result;
+ },
+ res_id: 1,
+ });
+
+ // edit the record (in readonly) with toogle_button widget (and delay the write RPC)
+ await testUtils.dom.click(form.$('.o_field_widget[name=bar]'));
+
+ // switch to edit mode
+ await testUtils.form.clickEdit(form);
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_readonly'); // should wait for the RPC to return
+
+ // make write RPC return
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.hasClass(form.$('.o_form_view'), 'o_form_editable');
+
+ form.destroy();
+ });
+
QUnit.test('"bare" buttons in template should not trigger button click', async function (assert) {
assert.expect(3);
diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js
index f958924061b..7cbab784829 100644
--- a/addons/web/static/tests/views/graph_tests.js
+++ b/addons/web/static/tests/views/graph_tests.js
@@ -176,6 +176,27 @@ QUnit.module('Views', {
graph.destroy();
});
+ QUnit.test('field id not in groupBy', async function (assert) {
+ assert.expect(1);
+
+ var graph = await createView({
+ View: GraphView,
+ model: "foo",
+ data: this.data,
+ arch: '' +
+ ' ' +
+ ' ',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ assert.deepEqual(args.kwargs.groupby, [],
+ 'groupby should not contain id field');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+ graph.destroy();
+ });
+
QUnit.test('switching mode', async function (assert) {
assert.expect(6);
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index ca04c9413a8..fcb7df4ff29 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -1,6 +1,7 @@
odoo.define('web.kanban_tests', function (require) {
"use strict";
+var AbstractField = require('web.AbstractField');
var fieldRegistry = require('web.field_registry');
var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
var kanbanExamplesRegistry = require('web.kanban_examples_registry');
@@ -4595,6 +4596,60 @@ QUnit.module('Views', {
delete widgetRegistry.map.test;
});
+ QUnit.test('subwidgets with on_attach_callback when changing record color', async function (assert) {
+ assert.expect(3);
+
+ var counter = 0;
+ var MyTestWidget = AbstractField.extend({
+ on_attach_callback: function () {
+ counter++;
+ },
+ });
+ fieldRegistry.add('test_widget', MyTestWidget);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'category',
+ data: this.data,
+ arch: '' +
+ ' ' +
+ '' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ',
+ });
+
+ // counter should be 2 as there are 2 records
+ assert.strictEqual(counter, 2, "on_attach_callback should have been called twice");
+
+ // set a color to kanban record
+ var $firstRecord = kanban.$('.o_kanban_record:first()');
+ testUtils.kanban.toggleRecordDropdown($firstRecord);
+ await testUtils.dom.click($firstRecord.find('.oe_kanban_colorpicker a.oe_kanban_color_9'));
+
+ // first record has replaced its $el with a new one
+ $firstRecord = kanban.$('.o_kanban_record:first()');
+ assert.hasClass($firstRecord, 'oe_kanban_color_9');
+ assert.strictEqual(counter, 3, "on_attach_callback method should be called 3 times");
+
+ delete fieldRegistry.map.test_widget;
+ kanban.destroy();
+ });
+
QUnit.test('column progressbars properly work', async function (assert) {
assert.expect(2);
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index e49bd499c42..c97b1916265 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -2879,6 +2879,7 @@ QUnit.module('Views', {
intercepts: {
do_action: function (event) {
assert.deepEqual(event.data.action, {
+ context: {create: false},
res_id: 2,
res_model: 'res_currency',
type: 'ir.actions.act_window',
@@ -2892,6 +2893,42 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('groupby node with subfields, and onchange', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.onchanges = {
+ foo: function () {},
+ };
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+
+
+
+
+
+ `,
+ groupBy: ['currency_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.deepEqual(args.args[3], {
+ foo: "1",
+ currency_id: "",
+ }, 'onchange spec should not follow relation of many2one fields');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "new value");
+
+ list.destroy();
+ });
+
QUnit.test('list view, editable, without data', async function (assert) {
assert.expect(12);
@@ -4432,6 +4469,58 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('grouped list with another grouped list parent, click unfold', async function (assert) {
+ assert.expect(3);
+ this.data.bar.fields = {
+ cornichon: {string: 'cornichon', type: 'char'},
+ };
+
+ var rec = this.data.bar.records[0];
+ // create records to have the search more button
+ var newRecs = [];
+ for (var i=0; i<8; i++) {
+ var newRec = _.extend({}, rec);
+ newRec.id = 10 + i;
+ newRec.cornichon = 'extra fin';
+ newRecs.push(newRec);
+ }
+ this.data.bar.records = newRecs;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: ' ',
+ groupBy: ['bar'],
+ archs: {
+ 'bar,false,list': ' ',
+ 'bar,false,search': ' ',
+ },
+ });
+
+ await list.update({groupBy: []});
+
+ await testUtils.dom.click(list.$('.o_data_cell:eq(0)'));
+
+ await testUtils.dom.click(list.$('.o_selected_row .o_data_cell .o_field_many2one input'));
+ await testUtils.dom.triggerEvents($('.ui-autocomplete a:contains(Search More)'),
+ ['mouseenter', 'click']);
+
+ assert.containsOnce($('body'), '.modal-content');
+
+ assert.containsNone($('body'), '.modal-content .o_group_name', 'list in modal not grouped');
+
+ await testUtils.dom.click($('body .modal-content button:contains(Group By)'));
+
+ await testUtils.dom.click($('body .modal-content .o_menu_item a:contains(cornichon)'));
+
+ await testUtils.dom.click($('body .modal-content .o_group_header'));
+
+ assert.containsOnce($('body'), '.modal-content .o_group_open');
+
+ list.destroy();
+ });
+
QUnit.test('field values are escaped', async function (assert) {
assert.expect(1);
var value = '';
diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js
index 558caeeaf37..b68d676a125 100644
--- a/addons/web/static/tests/views/view_dialogs_tests.js
+++ b/addons/web/static/tests/views/view_dialogs_tests.js
@@ -411,8 +411,10 @@ QUnit.module('Views', {
'The correct _view_ref should have been sent to the server, first time');
}
if (ev.data.modelName === 'badassery') {
- assert.deepEqual(evaluatedContext, {tree_view_ref: 'some_other_tree_view'},
- 'The correct _view_ref should have been sent to the server for the subview');
+ assert.deepEqual(evaluatedContext, {
+ base_model_name: 'instrument',
+ tree_view_ref: 'some_other_tree_view',
+ }, 'The correct _view_ref should have been sent to the server for the subview');
}
},
},
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
index 63f0c38c7c3..f124d1841f0 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -767,7 +767,7 @@ registry.colorpicker = SnippetOption.extend({
if ($selected.length) {
if ($selected.data('color')) {
this.$target.addClass(this.colorPrefix + $selected.data('color'));
- } else {
+ } else if ($selected.hasClass('o_custom_color')) {
this.$target.css('background-color', $selected.css('background-color'));
}
}
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index 6b1efe5a717..a3aca36ced5 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -336,18 +336,19 @@ class View(models.Model):
qcontext['main_object'] = self
cur = Website.get_current_website()
- qcontext['multi_website_websites_current'] = {'website_id': cur.id, 'name': cur.name, 'domain': cur.domain}
- qcontext['multi_website_websites'] = [
- {'website_id': website.id, 'name': website.name, 'domain': website.domain}
- for website in Website.search([]) if website != cur
- ]
+ if self.env.user.has_group('website.group_website_publisher') and self.env.user.has_group('website.group_multi_website'):
+ qcontext['multi_website_websites_current'] = {'website_id': cur.id, 'name': cur.name, 'domain': cur.domain}
+ qcontext['multi_website_websites'] = [
+ {'website_id': website.id, 'name': website.name, 'domain': website.domain}
+ for website in Website.search([]) if website != cur
+ ]
- cur_company = self.env.company
- qcontext['multi_website_companies_current'] = {'company_id': cur_company.id, 'name': cur_company.name}
- qcontext['multi_website_companies'] = [
- {'company_id': comp.id, 'name': comp.name}
- for comp in self.env.user.company_ids if comp != cur_company
- ]
+ cur_company = self.env.company
+ qcontext['multi_website_companies_current'] = {'company_id': cur_company.id, 'name': cur_company.name}
+ qcontext['multi_website_companies'] = [
+ {'company_id': comp.id, 'name': comp.name}
+ for comp in self.env.user.company_ids if comp != cur_company
+ ]
qcontext.update(dict(
self._context.copy(),
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index 338cdba591b..0ddca6810e2 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -740,9 +740,6 @@ class Website(models.Model):
domain_part, url = rule.build(value, append_unknown=False)
if not query_string or query_string.lower() in url.lower():
page = {'loc': url}
- for key, val in value.items():
- if key.startswith('__'):
- page[key[2:]] = val
if url in ('/sitemap.xml',):
continue
if url in url_set:
@@ -766,9 +763,9 @@ class Website(models.Model):
for page in pages:
record = {'loc': page['url'], 'id': page['id'], 'name': page['name']}
if page.view_id and page.view_id.priority != 16:
- record['__priority'] = min(round(page.view_id.priority / 32.0, 1), 1)
+ record['priority'] = min(round(page.view_id.priority / 32.0, 1), 1)
if page['write_date']:
- record['__lastmod'] = page['write_date'].date()
+ record['lastmod'] = page['write_date'].date()
yield record
def get_website_pages(self, domain=[], order='name', limit=None):
diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js
index 9d3d5621f65..1b6fa07a5c0 100644
--- a/addons/website/static/src/js/content/menu.js
+++ b/addons/website/static/src/js/content/menu.js
@@ -19,6 +19,7 @@ publicWidget.registry.affixMenu = publicWidget.Widget.extend({
this.$headerClone.insertAfter(this.$target);
this.$headers = this.$target.add(this.$headerClone);
this.$dropdowns = this.$headers.find('.dropdown');
+ this.$dropdownMenus = this.$headers.find('.dropdown-menu');
this.$navbarCollapses = this.$headers.find('.navbar-collapse');
this._adaptDefaultOffset();
@@ -100,7 +101,7 @@ publicWidget.registry.affixMenu = publicWidget.Widget.extend({
this.$headerClone.toggleClass('affixed', wOffset > (hOffset + 300));
// Reset opened menus
- this.$dropdowns.removeClass('show');
+ this.$dropdowns.add(this.$dropdownMenus).removeClass('show');
this.$navbarCollapses.removeClass('show').attr('aria-expanded', false);
},
});
diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss
index 34fcab86fc9..b5ce0126afb 100644
--- a/addons/website/static/src/scss/website.scss
+++ b/addons/website/static/src/scss/website.scss
@@ -914,8 +914,14 @@ table.table_desc tr td {
background-color: inherit;
}
}
- &.s_parallax_is_fixed > .s_parallax_bg {
- background-attachment: fixed;
+ @include media-breakpoint-up(xl) {
+ // Fixed backgrounds are disabled when using a mobile/tablet device,
+ // which is not a big deal but, on some of them (iOS...), defining the
+ // background as fixed breaks the background-size/position props.
+ // So we enable this only for >= XL devices.
+ &.s_parallax_is_fixed > .s_parallax_bg {
+ background-attachment: fixed;
+ }
}
}
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index 258dd6d3fab..6cb36994dc5 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -364,7 +364,7 @@
Connect with us
diff --git a/addons/website_blog/controllers/main.py b/addons/website_blog/controllers/main.py
index 2547cf6abd7..c7549a1209a 100644
--- a/addons/website_blog/controllers/main.py
+++ b/addons/website_blog/controllers/main.py
@@ -67,6 +67,10 @@ class WebsiteBlog(http.Controller):
active_tag_ids = tags and [unslug(tag)[1] for tag in tags.split(',')] or []
if active_tag_ids:
+ fixed_tag_slug = ",".join(slug(t) for t in request.env['blog.tag'].browse(active_tag_ids))
+ if fixed_tag_slug != tags:
+ return request.redirect(request.httprequest.full_path.replace("/tag/%s/" % tags, "/tag/%s/" % fixed_tag_slug, 1), 301)
+
domain += [('tag_ids', 'in', active_tag_ids)]
if request.env.user.has_group('website.group_website_designer'):
@@ -214,14 +218,16 @@ class WebsiteBlog(http.Controller):
blog_url = QueryURL('', ['blog', 'tag'], blog=blog_post.blog_id, tag=tag, date_begin=date_begin, date_end=date_end)
if not blog_post.blog_id.id == blog.id:
- return request.redirect("/blog/%s/post/%s" % (slug(blog_post.blog_id), slug(blog_post)))
+ return request.redirect("/blog/%s/post/%s" % (slug(blog_post.blog_id), slug(blog_post)), code=301)
tags = request.env['blog.tag'].search([])
# Find next Post
- all_post = BlogPost.search([('blog_id', '=', blog.id)])
+ blog_post_domain = [('blog_id', '=', blog.id)]
if not request.env.user.has_group('website.group_website_designer'):
- all_post = all_post.filtered(lambda r: r.post_date <= fields.Datetime.now())
+ blog_post_domain += [('post_date', '<=', fields.Datetime.now())]
+
+ all_post = BlogPost.search(blog_post_domain)
if blog_post not in all_post:
return request.redirect("/blog/%s" % (slug(blog_post.blog_id)))
diff --git a/addons/website_blog/static/src/js/website_blog.js b/addons/website_blog/static/src/js/website_blog.js
index 3004b28523f..246151922d5 100644
--- a/addons/website_blog/static/src/js/website_blog.js
+++ b/addons/website_blog/static/src/js/website_blog.js
@@ -63,31 +63,34 @@ publicWidget.registry.websiteBlog = publicWidget.Widget.extend({
*/
_onShareArticle: function (ev) {
var url = '';
+ var articleURL;
ev.preventDefault();
var $element = $(ev.currentTarget);
if ($element.is('*[class*="_complete"]')) {
- var blogTitleComplete = $('#blog_post_name').html() || '';
+ var blogTitleComplete = encodeURIComponent($('#blog_post_name').html() || '');
+ articleURL = encodeURIComponent(window.location.href);
if ($element.hasClass('o_twitter_complete')) {
- url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=Amazing blog article : ' + blogTitleComplete + "! Check it live: " + window.location.href;
+ url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=Amazing blog article : ' + blogTitleComplete + "! Check it live: " + articleURL;
} else if ($element.hasClass('o_facebook_complete')) {
- url = 'https://www.facebook.com/sharer/sharer.php?u=' + window.location.href;
+ url = 'https://www.facebook.com/sharer/sharer.php?u=' + articleURL;
} else if ($element.hasClass('o_linkedin_complete')) {
- url = 'https://www.linkedin.com/shareArticle?mini=true&url=' + window.location.href + '&title=' + blogTitleComplete;
+ url = 'https://www.linkedin.com/shareArticle?mini=true&url=' + articleURL + '&title=' + blogTitleComplete;
} else {
- url = 'https://plus.google.com/share?url=' + window.location.href;
+ url = 'https://plus.google.com/share?url=' + articleURL;
}
} else {
var blogPost = $element.parents('[name="blog_post"]');
- var blogPostTitle = blogPost.find('.o_blog_post_title').html() || '';
+ var blogPostTitle = encodeURIComponent(blogPost.find('.o_blog_post_title').html() || '');
var blogArticleLink = blogPost.find('.o_blog_post_title').parent('a').attr('href');
+ articleURL = encodeURIComponent(window.location.host + blogArticleLink);
if ($element.hasClass('o_twitter')) {
- url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=Amazing blog article : ' + blogPostTitle + "! " + window.location.host + blogArticleLink;
+ url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=Amazing blog article : ' + blogPostTitle + "! " + articleURL;
} else if ($element.hasClass('o_facebook')) {
- url = 'https://www.facebook.com/sharer/sharer.php?u=' + window.location.host + blogArticleLink;
+ url = 'https://www.facebook.com/sharer/sharer.php?u=' + articleURL;
} else if ($element.hasClass('o_linkedin')) {
- url = 'https://www.linkedin.com/shareArticle?mini=true&url=' + window.location.host + blogArticleLink + '&title=' + blogPostTitle;
+ url = 'https://www.linkedin.com/shareArticle?mini=true&url=' + articleURL + '&title=' + blogPostTitle;
} else if ($element.hasClass('o_google')) {
- url = 'https://plus.google.com/share?url=' + window.location.host + blogArticleLink;
+ url = 'https://plus.google.com/share?url=' + articleURL;
}
}
window.open(url, '', 'menubar=no, width=500, height=400');
diff --git a/addons/website_blog/views/website_blog_templates.xml b/addons/website_blog/views/website_blog_templates.xml
index dac8c3ea9ff..20659dbb592 100644
--- a/addons/website_blog/views/website_blog_templates.xml
+++ b/addons/website_blog/views/website_blog_templates.xml
@@ -293,7 +293,7 @@ list of filtered posts (by date or tag).
@@ -571,7 +571,7 @@ list of filtered posts (by date or tag).
-
+
diff --git a/addons/website_event_sale/models/sale_order.py b/addons/website_event_sale/models/sale_order.py
index a65b007a71f..d1c8183cef8 100644
--- a/addons/website_event_sale/models/sale_order.py
+++ b/addons/website_event_sale/models/sale_order.py
@@ -39,7 +39,10 @@ class SaleOrder(models.Model):
values['product_id'] = ticket.product_id.id
values['event_id'] = ticket.event_id.id
values['event_ticket_id'] = ticket.id
- values['price_unit'] = ticket.price_reduce
+ if order.pricelist_id.discount_policy == 'without_discount':
+ values['price_unit'] = ticket.price
+ else:
+ values['price_unit'] = ticket.price_reduce
values['name'] = ticket.get_ticket_multiline_description_sale()
# avoid writing related values that end up locking the product record
diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py
index 1a2052c1eb1..195cad6870c 100644
--- a/addons/website_event_track/models/event_track.py
+++ b/addons/website_event_track/models/event_track.py
@@ -163,7 +163,7 @@ class Track(models.Model):
def _message_get_suggested_recipients(self):
recipients = super(Track, self)._message_get_suggested_recipients()
for track in self:
- if track.partner_email != track.partner_id.email:
+ if track.partner_email and track.partner_email != track.partner_id.email:
track._message_add_suggested_recipient(recipients, email=track.partner_email, reason=_('Speaker Email'))
return recipients
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 6e1eab7c323..f14eb73e4e9 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -1625,7 +1625,7 @@
Communication:
-
+
Or scan me with your banking app.
diff --git a/addons/website_sale_coupon/views/website_sale_templates.xml b/addons/website_sale_coupon/views/website_sale_templates.xml
index ce3604e57a6..e1f91e25200 100644
--- a/addons/website_sale_coupon/views/website_sale_templates.xml
+++ b/addons/website_sale_coupon/views/website_sale_templates.xml
@@ -7,6 +7,16 @@
+
+
+
+
+
+
+
+ $0
+
+
line.is_reward_line and "display:none;"
@@ -24,6 +34,18 @@
not line.is_reward_line
+
+
+
+
+
+
+
+
+ $0
+
+
+
diff --git a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js
index 4210f3e5d85..6c316a70540 100644
--- a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js
+++ b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js
@@ -59,7 +59,7 @@ publicWidget.registry.websiteSaleDelivery = publicWidget.Widget.extend({
*/
_handleCarrierUpdateResult: function (result) {
this._handleCarrierUpdateResultBadge(result);
- var $payButton = $('#o_payment_form_pay .monetary_field');
+ var $payButton = $('#o_payment_form_pay');
var $amountDelivery = $('#order_delivery .monetary_field');
var $amountUntaxed = $('#order_total_untaxed .monetary_field');
var $amountTax = $('#order_total_taxes .monetary_field');
diff --git a/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js b/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js
index 4ac148854f9..d99af8e56e1 100644
--- a/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js
+++ b/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js
@@ -8,6 +8,7 @@ tour.register('check_free_delivery', {
url: '/shop?search=office chair black',
},
[
+ // Part 1: Check free delivery
{
content: "select office chair black",
trigger: '.oe_product_cart:first a:contains("Office Chair Black")',
@@ -26,5 +27,21 @@ tour.register('check_free_delivery', {
extra_trigger: '#delivery_carrier label:containsExact("Delivery Now Free Over 10")',
trigger: "#delivery_carrier span:contains('0.0')"
},
+ // Part 2: check multiple delivery & price loaded asynchronously
+ {
+ content: "Ensure price was loaded asynchronously",
+ extra_trigger: '#delivery_carrier input[name="delivery_type"]:checked',
+ trigger: '#delivery_method .o_delivery_carrier_select:contains("20.0"):contains("The Poste")',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "Click on Pay Now",
+ trigger: 'button[id="o_payment_form_pay"]:visible:not(:disabled)',
+ },
+ {
+ content: "Confirmation page should be shown",
+ trigger: '#oe_structure_website_sale_confirmation_1',
+ run: function () {}, // it's a check
+ }
]);
});
diff --git a/addons/website_sale_delivery/tests/test_ui.py b/addons/website_sale_delivery/tests/test_ui.py
index 34dcf086a48..6b1a2232f4a 100644
--- a/addons/website_sale_delivery/tests/test_ui.py
+++ b/addons/website_sale_delivery/tests/test_ui.py
@@ -12,4 +12,12 @@ class TestUi(odoo.tests.HttpCase):
'free_over': True,
'amount': 10,
})
+ self.env.ref("delivery.delivery_carrier").write({
+ 'sequence': 9999, # ensure last to load price async
+ 'website_published': True,
+ })
+ # Ensure "Wire Transfer" is the default acquirer.
+ # Acquirers are sorted by state, showing `test` acquirers first (don't ask why).
+ self.env.ref("payment.payment_acquirer_transfer").write({"state": "test"})
+
self.start_tour("/", 'check_free_delivery', login="admin")
diff --git a/addons/website_sale_digital/controllers/main.py b/addons/website_sale_digital/controllers/main.py
index 717a96bb445..1184655e263 100644
--- a/addons/website_sale_digital/controllers/main.py
+++ b/addons/website_sale_digital/controllers/main.py
@@ -3,6 +3,8 @@
import base64
import io
+import os
+import mimetypes
from werkzeug.utils import redirect
from odoo import http
@@ -71,7 +73,7 @@ class WebsiteSaleDigital(CustomerPortal):
# Check if this is a valid attachment id
attachment = request.env['ir.attachment'].sudo().search_read(
[('id', '=', int(attachment_id))],
- ["name", "datas", "file_type", "res_model", "res_id", "type", "url"]
+ ["name", "datas", "mimetype", "res_model", "res_id", "type", "url"]
)
if attachment:
@@ -105,6 +107,11 @@ class WebsiteSaleDigital(CustomerPortal):
return request.not_found()
elif attachment["datas"]:
data = io.BytesIO(base64.standard_b64decode(attachment["datas"]))
- return http.send_file(data, filename=attachment['name'], as_attachment=True)
+ # we follow what is done in ir_http's binary_content for the extension management
+ extension = os.path.splitext(attachment["name"] or '')[1]
+ extension = extension if extension else mimetypes.guess_extension(attachment["mimetype"] or '')
+ filename = attachment['name']
+ filename = filename if os.path.splitext(filename)[1] else filename + extension
+ return http.send_file(data, filename=filename, as_attachment=True)
else:
return request.not_found()
diff --git a/addons/website_sale_slides/controllers/i18n/so.po b/addons/website_sale_slides/controllers/i18n/so.po
new file mode 100644
index 00000000000..3c154e53a86
--- /dev/null
+++ b/addons/website_sale_slides/controllers/i18n/so.po
@@ -0,0 +1,115 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * crm_project
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server saas~11.5\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2018-09-21 13:17+0000\n"
+"PO-Revision-Date: 2018-08-24 09:17+0000\n"
+"Language-Team: Somali (https://www.transifex.com/odoo/teams/41243/so/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Language: so\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#. module: crm_project
+#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
+msgid ""
+"Convert To \n"
+" Task "
+msgstr ""
+
+#. module: crm_project
+#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
+msgid "Cancel"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
+#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
+#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
+msgid "Convert to Task"
+msgstr ""
+
+#. module: crm_project
+#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
+msgid "Create Task"
+msgstr ""
+
+#. module: crm_project
+#: selection:crm.lead.convert2task,action:0
+msgid "Create a new customer"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
+msgid "Customer"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
+msgid "Display Name"
+msgstr ""
+
+#. module: crm_project
+#: selection:crm.lead.convert2task,action:0
+msgid "Do not link to a customer"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
+msgid "ID"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
+msgid "Last Modified on"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
+msgid "Lead"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model,name:crm_project.model_crm_lead_convert2task
+msgid "Lead convert to Task"
+msgstr ""
+
+#. module: crm_project
+#: selection:crm.lead.convert2task,action:0
+msgid "Link to an existing customer"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
+msgid "Project"
+msgstr ""
+
+#. module: crm_project
+#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
+msgid "Related Customer"
+msgstr ""
diff --git a/addons/website_sale_stock/models/product_template.py b/addons/website_sale_stock/models/product_template.py
index 0b16fbad1c2..0562e43ac61 100644
--- a/addons/website_sale_stock/models/product_template.py
+++ b/addons/website_sale_stock/models/product_template.py
@@ -16,10 +16,10 @@ class ProductTemplate(models.Model):
available_threshold = fields.Float(string='Availability Threshold', default=5.0)
custom_message = fields.Text(string='Custom Message', default='', translate=True)
- def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False, only_template=False):
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
combination_info = super(ProductTemplate, self)._get_combination_info(
combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
- parent_combination=reference_product, only_template=only_template)
+ parent_combination=parent_combination, only_template=only_template)
if not self.env.context.get('website_sale_stock_get_quantity'):
return combination_info
diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js
index 250e78e3421..05ce9b73a7a 100644
--- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js
+++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js
@@ -51,23 +51,21 @@ odoo.define('website_slides.fullscreen', function (require) {
},
_loadYoutubeAPI: function () {
var self = this;
- var def = new Promise(function (resolve) {
- self.resolveLoadYoutube = resolve;
+ var prom = new Promise(function (resolve, reject) {
+ if ($(document).find('script[src="' + self.youtubeUrl + '"]').length === 0) {
+ var $youtubeElement = $('', {src: self.youtubeUrl});
+ $(document.head).append($youtubeElement);
+
+ // function called when the Youtube asset is loaded
+ // see https://developers.google.com/youtube/iframe_api_reference#Requirements
+ onYouTubeIframeAPIReady = function () {
+ resolve();
+ };
+ } else {
+ resolve();
+ }
});
- if ($(document).find('script[src="' + this.youtubeUrl + '"]').length === 0) {
- var $youtubeElement = $('', {src: this.youtubeUrl});
- $(document.head).append($youtubeElement);
-
- // function called when the Youtube asset is loaded
- // see https://developers.google.com/youtube/iframe_api_reference#Requirements
- onYouTubeIframeAPIReady = function () {
- self.resolveLoadYoutube();
- };
-
- } else {
- self.resolveLoadYoutube();
- }
- return def;
+ return prom;
},
/**
* Links the youtube api to the iframe present in the template
diff --git a/doc/cla/corporate/arxilead.md b/doc/cla/corporate/arxilead.md
new file mode 100644
index 00000000000..8640fa2ff95
--- /dev/null
+++ b/doc/cla/corporate/arxilead.md
@@ -0,0 +1,15 @@
+Portugal, 2019-08-13
+
+Arxilead Tecnologia e Gestão Lda. agrees to the terms of the Odoo Corporate Contributor License Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this declaration.
+
+Signed,
+
+Rita Santos rita.santos@arxi.pt https://github.com/Arxilead
+
+List of contributors:
+
+* Marcelo Pereira marcelo.pereira@arxi.pt https://github.com/marcelopereiraarxi
+* Nuno Silva nuno.silva@arxi.pt https://github.com/nunosilvaarxi
+* Reinaldo Ramos reinaldo.ramos@arxi.pt https://github.com/reinaldoramosarxi
diff --git a/doc/cla/corporate/karizma.md b/doc/cla/corporate/karizma.md
new file mode 100644
index 00000000000..d81f6bf13cc
--- /dev/null
+++ b/doc/cla/corporate/karizma.md
@@ -0,0 +1,20 @@
+Morocco, 2019-03-21
+
+KARIZMA CONSEIL agrees to the terms of the Odoo Corporate Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+
+Signed,
+
+Saad THAIFA saad.thaifa@gmail.com https://github.com/saadthaifa
+
+List of contributors:
+
+Saad THAIFA saad.thaifa@karizma.ma https://github.com/saadthaifa
+Ahmed MOUFID a.moufid@karizma.ma https://github.com/moufidahmed
+Reda ROUICHI r.rouichi@karizma.ma https://github.com/r2gtx
+Abdelmajid ELHAMDAOUI a.elhamdaoui@karizma.ma https://github.com/elhamdaoui
+Ayoub LABIBI a.labibi@karizma.ma https://github.com/labibi
diff --git a/doc/cla/corporate/quartile.md b/doc/cla/corporate/quartile.md
index b43892e1227..d7e41ffbabc 100644
--- a/doc/cla/corporate/quartile.md
+++ b/doc/cla/corporate/quartile.md
@@ -12,3 +12,4 @@ List of contributors:
Yoshi Tashiro tashiro@quartile.co https://github.com/yostashiro
Tim Lai tl@quartile.co https://github.com/TimLai125
+Tomoko Matsumura nako@quartile.co https://github.com/nakometal
diff --git a/doc/cla/corporate/tvtmarine-automation.md b/doc/cla/corporate/tvtmarine-automation.md
index 59e3c250ab3..d5f077c852b 100644
--- a/doc/cla/corporate/tvtmarine-automation.md
+++ b/doc/cla/corporate/tvtmarine-automation.md
@@ -13,7 +13,10 @@ David Tran david.tran@tvtmarine.com https://github.com/tvtma/
List of contributors:
David Tran david.tran@tvtmarine.com https://github.com/davidtranhp
-Long Do long.do@tvtmarine.com https://github.com/hoanglong87
+Phong Dao phong.dao@tvtmarine.com https://github.com/phongdao
Hao Hoang hao.hoang@ma.tvtmarine.com https://github.com/hoanghao2001
Tommy Tran tu.tran@tvtmarine.com https://github.com/tutran81
-Emily Ha emily.ha@tvtmarine.com https://github.com/emilyha
+Trinh Anh Ngoc ngoc.trinh@tvtmarine.com https://github.com/trinhanhngoc
+
+(up to 2019-07-14)
+Long Do long.do@tvtmarine.com https://github.com/hoanglong87
\ No newline at end of file
diff --git a/doc/cla/individual/hari4274.md b/doc/cla/individual/hari4274.md
new file mode 100644
index 00000000000..24ff61a8cd6
--- /dev/null
+++ b/doc/cla/individual/hari4274.md
@@ -0,0 +1,11 @@
+India, 2019-08-24
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Hariprasath bala.hariprasath@gmail.com https://github.com/hari4274
\ No newline at end of file
diff --git a/doc/reference/cmdline.rst b/doc/reference/cmdline.rst
index 6b1b755d0e2..8ab38abdc51 100644
--- a/doc/reference/cmdline.rst
+++ b/doc/reference/cmdline.rst
@@ -228,6 +228,12 @@ database
when creating new databases from the database-management screens, use the
specified `template database`_. Defaults to ``template0``.
+.. option:: --pg_path
+
+ Path to the PostgreSQL binaries that are used by the database manager to
+ dump and restore databases. You have to specify this option only if these
+ binaries are located in a non-standard directory.
+
.. option:: --no-database-list
Suppresses the ability to list databases available on the system
diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst
index 2b2b51e4dc8..a6eeb5e8d4e 100644
--- a/doc/reference/javascript_reference.rst
+++ b/doc/reference/javascript_reference.rst
@@ -1987,7 +1987,7 @@ Relational fields
.. class:: web.relational_fields.FieldSelection
- Supported field types: *selection*, *many2one*
+ Supported field types: *selection*
.. attribute:: placeholder
@@ -2028,7 +2028,7 @@ Relational fields
- many2one (FieldMany2One)
Default widget for many2one fields.
- - Supported field types: *selection, many2one*
+ - Supported field types: *many2one*
Attributes:
diff --git a/doc/reference/orm.rst b/doc/reference/orm.rst
index 47b3d508a37..4cdbf78f65c 100644
--- a/doc/reference/orm.rst
+++ b/doc/reference/orm.rst
@@ -1110,6 +1110,12 @@ A domain is a list of criteria, each criterion being a triple (either a
``child_of``
is a child (descendant) of a ``value`` record.
+ Takes the semantics of the model into account (i.e following the
+ relationship field named by
+ :attr:`~odoo.models.Model._parent_name`).
+ ``parent_of``
+ is a parent (ascendant) of a ``value`` record.
+
Takes the semantics of the model into account (i.e following the
relationship field named by
:attr:`~odoo.models.Model._parent_name`).
diff --git a/odoo/addons/base/data/ir_module_module.xml b/odoo/addons/base/data/ir_module_module.xml
index 058a7880b3a..79b27dfd4fe 100644
--- a/odoo/addons/base/data/ir_module_module.xml
+++ b/odoo/addons/base/data/ir_module_module.xml
@@ -136,7 +136,7 @@
Odoo S.A.
/base/static/img/icons/project_forecast.png
- https://www.odoo.com/page/project?utm_source=db&utm_medium=module
+ https://www.odoo.com/page/project-management?utm_source=db&utm_medium=module
diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py
index 49a53270894..a3d354cfabc 100644
--- a/odoo/addons/base/models/ir_actions_report.py
+++ b/odoo/addons/base/models/ir_actions_report.py
@@ -188,7 +188,7 @@ class IrActionsReport(models.Model):
:param attachment_name: The optional name of the attachment.
:return: A recordset of length <=1 or None
'''
- attachment_name = safe_eval(self.attachment, {'object': record, 'time': time})
+ attachment_name = safe_eval(self.attachment, {'object': record, 'time': time}) if self.attachment else ''
if not attachment_name:
return None
return self.env['ir.attachment'].search([
diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index 0a1e1c7864d..1ec2cd6de2a 100644
--- a/odoo/addons/base/models/ir_model.py
+++ b/odoo/addons/base/models/ir_model.py
@@ -1481,7 +1481,7 @@ class IrModelAccess(models.Model):
msg_params['groups_list'] = groups
else:
msg_tail = _("No group currently allows this operation.")
- msg_tail += ' - ({} {}, {} {})'.format(_('Operation:'), mode, _('User:'), self._uid)
+ msg_tail += u' - ({} {}, {} {})'.format(_('Operation:'), mode, _('User:'), self._uid)
_logger.info('Access Denied by ACLs for operation: %s, uid: %s, model: %s', mode, self._uid, model)
msg = '%s %s' % (msg_heads[mode], msg_tail)
raise AccessError(msg % msg_params)
diff --git a/odoo/addons/base/models/res_bank.py b/odoo/addons/base/models/res_bank.py
index 57255cdce65..1ff217c4af9 100644
--- a/odoo/addons/base/models/res_bank.py
+++ b/odoo/addons/base/models/res_bank.py
@@ -137,11 +137,6 @@ class ResPartnerBank(models.Model):
def _validate_qr_code_arguments(self):
for bank in self:
- if bank.currency_id.name == False:
- currency = bank.company_id.currency_id
- else:
- currency = bank.currency_id
bank.qr_code_valid = (bank.bank_bic
and bank.company_id.name
- and bank.acc_number
- and (currency.name == 'EUR'))
+ and bank.acc_number)
diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py
index 268494fb1a0..7cf95adc2e1 100644
--- a/odoo/addons/base/models/res_partner.py
+++ b/odoo/addons/base/models/res_partner.py
@@ -260,7 +260,7 @@ class Partner(models.Model):
domain = [('vat', '=', partner.vat)]
if partner_id:
domain += [('id', '!=', partner_id), '!', ('id', 'child_of', partner_id)]
- partner.same_vat_partner_id = partner.vat and not partner.parent_id and self.env['res.partner'].search(domain, limit=1)
+ partner.same_vat_partner_id = bool(partner.vat) and not partner.parent_id and self.env['res.partner'].search(domain, limit=1)
@api.depends(lambda self: self._display_address_depends())
def _compute_contact_address(self):
@@ -763,10 +763,14 @@ class Partner(models.Model):
e.g. ``"Raoul Grosbedon "``"""
assert email, 'an email is required for find_or_create to work'
emails = tools.email_split(email)
+ name_emails = tools.email_split_and_format(email)
if emails:
email = emails[0]
+ name_email = name_emails[0]
+ else:
+ name_email = email
partners = self.search([('email', '=ilike', email)], limit=1)
- return partners.id or self.name_create(email)[0]
+ return partners.id or self.name_create(name_email)[0]
def _get_gravatar_image(self, email):
email_hash = hashlib.md5(email.lower().encode('utf-8')).hexdigest()
diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py
index 231f1daadd2..5051fec4dad 100644
--- a/odoo/addons/base/tests/test_base.py
+++ b/odoo/addons/base/tests/test_base.py
@@ -69,17 +69,25 @@ class TestBase(TransactionCase):
def test_10_res_partner_find_or_create(self):
res_partner = self.env['res.partner']
+
email = SAMPLES[0][0]
partner_id, dummy = res_partner.name_create(email)
found_id = res_partner.find_or_create(email)
self.assertEqual(partner_id, found_id, 'find_or_create failed')
+ self.assertEqual(SAMPLES[0][1], res_partner.browse([found_id]).name, 'Partner name is incorrect')
+
partner_id2, dummy2 = res_partner.name_create('sarah.john@connor.com')
found_id2 = res_partner.find_or_create('john@connor.com')
self.assertNotEqual(partner_id2, found_id2, 'john@connor.com match sarah.john@connor.com')
+ self.assertEqual('john@connor.com', res_partner.browse([found_id2]).name, 'Partner name is incorrect')
+
new_id = res_partner.find_or_create(SAMPLES[1][0])
self.assertTrue(new_id > partner_id, 'find_or_create failed - should have created new one')
+ self.assertEqual(SAMPLES[1][2], res_partner.browse([new_id]).name, 'Partner name is incorrect')
+
new_id2 = res_partner.find_or_create(SAMPLES[2][0])
self.assertTrue(new_id2 > new_id, 'find_or_create failed - should have created new one again')
+ self.assertEqual(SAMPLES[2][1], res_partner.browse([new_id2]).name, 'Partner name is incorrect')
def test_15_res_partner_name_search(self):
res_partner = self.env['res.partner']
diff --git a/odoo/addons/base/views/ir_qweb_widget_templates.xml b/odoo/addons/base/views/ir_qweb_widget_templates.xml
index 6da8ec0efd6..47bf04a62ff 100644
--- a/odoo/addons/base/views/ir_qweb_widget_templates.xml
+++ b/odoo/addons/base/views/ir_qweb_widget_templates.xml
@@ -32,9 +32,9 @@
-
-
-
+
+
+
diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py
index b32934101e8..50ccd6b0375 100644
--- a/odoo/tools/mail.py
+++ b/odoo/tools/mail.py
@@ -58,7 +58,7 @@ safe_attrs = clean.defs.safe_attrs | frozenset(
['style',
'data-o-mail-quote', # quote detection
'data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-type', 'data-oe-expression', 'data-oe-translation-id', 'data-oe-nodeid',
- 'data-publish', 'data-id', 'data-res_id', 'data-member_id', 'data-view-id'
+ 'data-publish', 'data-id', 'data-res_id', 'data-interval', 'data-member_id', 'data-scroll-background-ratio', 'data-view-id',
])