summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-09-13 13:32:51 +0000
committerChristophe Simonis <chs@odoo.com>2019-09-13 13:32:51 +0000
commit5a273e74f0fff18df5ab6cb240c9e260be4bb7ff (patch)
tree4fbc7e142335d110fa0a984ff2c345a487e37ca4
parentdc5f83b63313018999b598210b972636986c5bea (diff)
parentfe59754c5201f9392b75d5d4cce8ca1400a7ef5d (diff)
[MERGE] forward port branch saas-12.4 up to fe59754c520
closes odoo/odoo#36721 Signed-off-by: Christophe Simonis <chs@odoo.com>
-rw-r--r--addons/account/models/account.py61
-rw-r--r--addons/account/models/account_move.py234
-rw-r--r--addons/account/models/account_payment.py3
-rw-r--r--addons/account/models/account_reconcile_model.py38
-rw-r--r--addons/account/models/reconciliation_widget.py18
-rw-r--r--addons/account/static/tests/tours/reconciliation.js10
-rw-r--r--addons/account/tests/__init__.py1
-rw-r--r--addons/account/tests/test_account_move_entry.py31
-rw-r--r--addons/account/tests/test_account_move_in_invoice.py139
-rw-r--r--addons/account/tests/test_account_move_out_invoice.py131
-rw-r--r--addons/account/tests/test_account_move_tax_lock_date.py98
-rw-r--r--addons/account/tests/test_reconciliation.py96
-rw-r--r--addons/account/tests/test_reconciliation_widget.py79
-rw-r--r--addons/account/tests/test_tax.py262
-rw-r--r--addons/account/views/account_move_views.xml7
-rw-r--r--addons/account/views/report_invoice.xml2
-rw-r--r--addons/account_cancel/views/account_views.xml3
-rw-r--r--addons/account_check_printing/models/account_payment.py5
-rw-r--r--addons/account_check_printing/views/account_payment_views.xml1
-rw-r--r--addons/account_tax_python/models/account_tax.py4
-rw-r--r--addons/auth_oauth/controllers/main.py17
-rw-r--r--addons/base_address_city/models/res_partner.py1
-rw-r--r--addons/base_import/models/base_import.py6
-rw-r--r--addons/bus/static/src/js/crosstab_bus.js6
-rw-r--r--addons/calendar/models/calendar.py6
-rw-r--r--addons/calendar/tests/test_calendar.py58
-rw-r--r--addons/calendar/views/calendar_views.xml23
-rw-r--r--addons/gamification/models/challenge.py6
-rw-r--r--addons/gamification/models/goal.py2
-rw-r--r--addons/gamification/views/challenge.xml6
-rw-r--r--addons/gamification/views/goal.xml7
-rw-r--r--addons/hr_expense/models/hr_expense.py14
-rw-r--r--addons/hr_expense/tests/test_expenses.py66
-rw-r--r--addons/hr_expense/wizard/hr_expense_sheet_register_payment.py2
-rw-r--r--addons/hr_holidays/models/hr_leave.py16
-rw-r--r--addons/hr_holidays/tests/__init__.py1
-rw-r--r--addons/hr_holidays/tests/test_automatic_leave_dates.py212
-rw-r--r--addons/hr_recruitment/views/hr_recruitment_views.xml1
-rw-r--r--addons/im_livechat/controllers/main.py7
-rw-r--r--addons/im_livechat/static/src/js/im_livechat.js8
-rw-r--r--addons/l10n_ae/data/account_tax_template_data.xml62
-rw-r--r--addons/l10n_ch/models/account_invoice.py14
-rw-r--r--addons/l10n_ch/models/account_journal.py1
-rw-r--r--addons/l10n_es/data/account_tax_data.xml14
-rw-r--r--addons/l10n_in/views/report_invoice.xml18
-rw-r--r--addons/l10n_it_edi/models/ir_mail_server.py12
-rw-r--r--addons/l10n_lu/__init__.py7
-rw-r--r--addons/l10n_lu/__manifest__.py2
-rw-r--r--addons/l10n_ma/data/account_tax_data.xml6
-rw-r--r--addons/l10n_ma/data/l10n_ma_chart_data.xml19
-rw-r--r--addons/l10n_multilang/models/l10n_multilang.py13
-rw-r--r--addons/l10n_uk/__manifest__.py2
-rw-r--r--addons/lunch/static/src/js/lunch_kanban_model.js79
-rw-r--r--addons/lunch/static/src/js/lunch_kanban_view.js20
-rw-r--r--addons/lunch/static/tests/lunch_kanban_tests.js50
-rw-r--r--addons/mail/controllers/main.py2
-rw-r--r--addons/mail/static/src/js/models/threads/document_thread.js27
-rw-r--r--addons/mail/static/src/js/models/threads/searchable_thread.js6
-rw-r--r--addons/mail/static/src/js/services/mail_document_thread_manager.js73
-rw-r--r--addons/mail/static/src/js/services/mail_window_manager.js6
-rw-r--r--addons/mail/static/src/js/thread_windows/thread_window.js44
-rw-r--r--addons/mail/static/src/js/utils.js7
-rw-r--r--addons/mail/static/src/scss/composer.scss7
-rw-r--r--addons/mail/static/src/scss/thread.scss1
-rw-r--r--addons/mail/static/tests/document_thread_window_tests.js20
-rw-r--r--addons/mail/static/tests/systray/systray_messaging_menu_tests.js82
-rw-r--r--addons/mail/views/mail_activity_views.xml18
-rw-r--r--addons/maintenance/views/maintenance_views.xml8
-rw-r--r--addons/mrp/models/mrp_production.py3
-rw-r--r--addons/mrp/models/stock_move.py7
-rw-r--r--addons/payment/views/payment_portal_templates.xml2
-rw-r--r--addons/payment_sips/models/payment.py6
-rw-r--r--addons/payment_sips/views/payment_views.xml6
-rw-r--r--addons/point_of_sale/models/account_tax.py9
-rw-r--r--addons/point_of_sale/static/src/css/customer_facing_display.css21
-rw-r--r--addons/point_of_sale/static/src/js/models.js18
-rw-r--r--addons/point_of_sale/static/src/scss/customer_facing_display.scss36
-rw-r--r--addons/portal/__manifest__.py2
-rw-r--r--addons/product/models/product_pricelist.py5
-rw-r--r--addons/product/models/product_template.py9
-rw-r--r--addons/product/tests/test_variants.py22
-rw-r--r--addons/purchase/models/purchase.py2
-rw-r--r--addons/sale_coupon/models/sale_order.py6
-rw-r--r--addons/sale_coupon/views/sale_coupon_program_views.xml4
-rw-r--r--addons/sale_purchase/models/sale_order.py4
-rw-r--r--addons/sale_stock/models/stock.py2
-rw-r--r--addons/stock/models/stock_move.py2
-rw-r--r--addons/stock/models/stock_picking.py2
-rw-r--r--addons/stock/models/stock_quant.py4
-rw-r--r--addons/stock/models/stock_warehouse.py7
-rw-r--r--addons/stock/tests/test_packing.py47
-rw-r--r--addons/stock/tests/test_warehouse.py14
-rw-r--r--addons/stock_landed_costs/models/stock_landed_cost.py4
-rw-r--r--addons/web/models/ir_qweb.py1
-rw-r--r--addons/web/static/src/js/core/abstract_storage_service.js13
-rw-r--r--addons/web/static/src/js/core/local_storage.js10
-rw-r--r--addons/web/static/src/js/core/ram_storage.js14
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js12
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js8
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js4
-rw-r--r--addons/web/static/src/js/views/form/form_view.js4
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js3
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record.js8
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js2
-rw-r--r--addons/web/static/src/js/widgets/auto_complete.js12
-rw-r--r--addons/web/static/src/js/widgets/date_picker.js5
-rw-r--r--addons/web/static/src/scss/ui.scss8
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js39
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js39
-rw-r--r--addons/web/static/tests/helpers/mock_server.js21
-rw-r--r--addons/web/static/tests/helpers/test_utils_create.js3
-rw-r--r--addons/web/static/tests/views/form_tests.js75
-rw-r--r--addons/web/static/tests/views/graph_tests.js21
-rw-r--r--addons/web/static/tests/views/kanban_tests.js55
-rw-r--r--addons/web/static/tests/views/list_tests.js89
-rw-r--r--addons/web/static/tests/views/view_dialogs_tests.js6
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.options.js2
-rw-r--r--addons/website/models/ir_ui_view.py25
-rw-r--r--addons/website/models/website.py7
-rw-r--r--addons/website/static/src/js/content/menu.js3
-rw-r--r--addons/website/static/src/scss/website.scss10
-rw-r--r--addons/website/views/website_templates.xml2
-rw-r--r--addons/website_blog/controllers/main.py12
-rw-r--r--addons/website_blog/static/src/js/website_blog.js23
-rw-r--r--addons/website_blog/views/website_blog_templates.xml4
-rw-r--r--addons/website_event_sale/models/sale_order.py5
-rw-r--r--addons/website_event_track/models/event_track.py2
-rw-r--r--addons/website_sale/views/templates.xml2
-rw-r--r--addons/website_sale_coupon/views/website_sale_templates.xml22
-rw-r--r--addons/website_sale_delivery/static/src/js/website_sale_delivery.js2
-rw-r--r--addons/website_sale_delivery/static/tests/tours/website_free_delivery.js17
-rw-r--r--addons/website_sale_delivery/tests/test_ui.py8
-rw-r--r--addons/website_sale_digital/controllers/main.py11
-rw-r--r--addons/website_sale_slides/controllers/i18n/so.po115
-rw-r--r--addons/website_sale_stock/models/product_template.py4
-rw-r--r--addons/website_slides/static/src/js/slides_course_fullscreen_player.js30
-rw-r--r--doc/cla/corporate/arxilead.md15
-rw-r--r--doc/cla/corporate/karizma.md20
-rw-r--r--doc/cla/corporate/quartile.md1
-rw-r--r--doc/cla/corporate/tvtmarine-automation.md7
-rw-r--r--doc/cla/individual/hari4274.md11
-rw-r--r--doc/reference/cmdline.rst6
-rw-r--r--doc/reference/javascript_reference.rst4
-rw-r--r--doc/reference/orm.rst6
-rw-r--r--odoo/addons/base/data/ir_module_module.xml2
-rw-r--r--odoo/addons/base/models/ir_actions_report.py2
-rw-r--r--odoo/addons/base/models/ir_model.py2
-rw-r--r--odoo/addons/base/models/res_bank.py7
-rw-r--r--odoo/addons/base/models/res_partner.py8
-rw-r--r--odoo/addons/base/tests/test_base.py8
-rw-r--r--odoo/addons/base/views/ir_qweb_widget_templates.xml6
-rw-r--r--odoo/tools/mail.py2
152 files changed, 2859 insertions, 558 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py
index 197ef0a8b37..8817006b4ac 100644
--- a/addons/account/models/account.py
+++ b/addons/account/models/account.py
@@ -1441,20 +1441,27 @@ class AccountTax(models.Model):
cached_tax_amounts = {}
if handle_price_include:
for tax in reversed(taxes):
+ tax_repartition_lines = (
+ is_refund
+ and tax.refund_repartition_line_ids
+ or tax.invoice_repartition_line_ids
+ ).filtered(lambda x: x.repartition_type == "tax")
+ sum_repartition_factor = sum(tax_repartition_lines.mapped("factor"))
+
if tax.include_base_amount:
base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount)
incl_fixed_amount = incl_percent_amount = incl_division_amount = 0
store_included_tax_total = True
if tax.price_include or self._context.get('force_price_include'):
if tax.amount_type == 'percent':
- incl_percent_amount += tax.amount
+ incl_percent_amount += tax.amount * sum_repartition_factor
elif tax.amount_type == 'division':
- incl_division_amount += tax.amount
+ incl_division_amount += tax.amount * sum_repartition_factor
elif tax.amount_type == 'fixed':
- incl_fixed_amount += quantity * tax.amount
+ incl_fixed_amount += quantity * tax.amount * sum_repartition_factor
else:
# tax.amount_type == other (python)
- tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner)
+ tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) * sum_repartition_factor
incl_fixed_amount += tax_amount
# Avoid unecessary re-computation
cached_tax_amounts[i] = tax_amount
@@ -1473,6 +1480,9 @@ class AccountTax(models.Model):
i = 0
cumulated_tax_included_amount = 0
for tax in taxes:
+ tax_repartition_lines = (is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids).filtered(lambda x: x.repartition_type == 'tax')
+ sum_repartition_factor = sum(tax_repartition_lines.mapped('factor'))
+
#compute the tax_amount
if (self._context.get('force_price_include') or tax.price_include) and total_included_checkpoints.get(i):
# We know the total to reach for that tax, so we make a substraction to avoid any rounding issues
@@ -1480,13 +1490,14 @@ class AccountTax(models.Model):
cumulated_tax_included_amount = 0
else:
tax_amount = tax.with_context(force_price_include=False)._compute_amount(
- base, price_unit, quantity, product, partner)
+ base, sign * price_unit, quantity, product, partner)
- # Round the tax_amount
+ # Round the tax_amount multiplied by the computed repartition lines factor.
tax_amount = round(tax_amount, prec)
+ factorized_tax_amount = round(tax_amount * sum_repartition_factor, prec)
if tax.price_include and not total_included_checkpoints.get(i):
- cumulated_tax_included_amount += tax_amount
+ cumulated_tax_included_amount += factorized_tax_amount
# If the tax affects the base of subsequent taxes, its tax move lines must
# receive the base tags and tag_ids of these taxes, so that the tax report computes
@@ -1497,18 +1508,24 @@ class AccountTax(models.Model):
subsequent_taxes = taxes[i+1:]
subsequent_tags = subsequent_taxes.get_tax_tags(is_refund, 'base')
- # Compute the tax lines
- tax_repartition_lines = (is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids).filtered(lambda x: x.repartition_type == 'tax')
- sum_factor = sum(tax_repartition_lines.mapped('factor'))
- repartition_lines_to_treat = len(tax_repartition_lines)
- total_amount = 0
- for repartition_line in tax_repartition_lines:
- if repartition_lines_to_treat != 1 or sum_factor != 1.0:
- line_amount = round(tax_amount * repartition_line.factor, prec)
- else:
- # When the sum of the repartition lines factor is 100%, we need to ensure the whole tax amount has
- # been spread into the repartition lines.
- line_amount = round(tax_amount - total_amount, prec)
+ # Compute the tax line amounts by multiplying each factor with the tax amount.
+ # Then, spread the tax rounding to ensure the consistency of each line independently with the factorized
+ # amount. E.g:
+ #
+ # Suppose a tax having 4 x 50% repartition line applied on a tax amount of 0.03 with 2 decimal places.
+ # The factorized_tax_amount will be 0.06 (200% x 0.03). However, each line taken independently will compute
+ # 50% * 0.03 = 0.01 with rounding. It means there is 0.06 - 0.04 = 0.02 as total_rounding_error to dispatch
+ # in lines as 2 x 0.01.
+ repartition_line_amounts = [round(tax_amount * line.factor, prec) for line in tax_repartition_lines]
+ total_rounding_error = round(factorized_tax_amount - sum(repartition_line_amounts), prec)
+ nber_rounding_steps = int(abs(total_rounding_error / currency.rounding))
+ rounding_error = round(nber_rounding_steps and total_rounding_error / nber_rounding_steps or 0.0, prec)
+
+ for repartition_line, line_amount in zip(tax_repartition_lines, repartition_line_amounts):
+
+ if nber_rounding_steps:
+ line_amount += rounding_error
+ nber_rounding_steps -= 1
taxes_vals.append({
'id': tax.id,
@@ -1525,16 +1542,14 @@ class AccountTax(models.Model):
'tax_ids': subsequent_taxes.ids,
})
- total_amount += line_amount
if not repartition_line.account_id:
total_void += line_amount
- repartition_lines_to_treat -= 1
# Affect subsequent taxes
if tax.include_base_amount:
- base += tax_amount
+ base += factorized_tax_amount
- total_included += tax_amount
+ total_included += factorized_tax_amount
i += 1
return {
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index a3002fa1f8c..61afd62f4eb 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -172,6 +172,8 @@ class AccountMove(models.Model):
states={'draft': [('readonly', False)]},
string='Salesperson',
default=lambda self: self.env.user)
+ user_id = fields.Many2one(string='User', related='invoice_user_id',
+ help='Technical field used to fit the generic behavior in mail templates.')
invoice_payment_state = fields.Selection(selection=[
('not_paid', 'Not Paid'),
('in_payment', 'In Payment'),
@@ -426,25 +428,50 @@ class AccountMove(models.Model):
:param base_line: The account.move.line owning the taxes.
:return: The result of the compute_all method.
'''
+ move = base_line.move_id
+
+ if move.is_invoice():
+ sign = -1 if move.is_inbound() else 1
+ quantity = base_line.quantity
+ if base_line.currency_id:
+ price_unit_foreign_curr = sign * base_line.price_unit * (1 - (base_line.discount / 100.0))
+ price_unit_comp_curr = base_line.currency_id._convert(price_unit_foreign_curr, move.company_id.currency_id, move.company_id, move.date)
+ else:
+ price_unit_foreign_curr = 0.0
+ price_unit_comp_curr = sign * base_line.price_unit * (1 - (base_line.discount / 100.0))
+ else:
+ quantity = 1.0
+ price_unit_foreign_curr = base_line.amount_currency
+ price_unit_comp_curr = base_line.balance
+
balance_taxes_res = base_line.tax_ids._origin.compute_all(
- base_line.balance,
+ price_unit_comp_curr,
currency=base_line.company_currency_id,
+ quantity=quantity,
+ product=base_line.product_id,
partner=base_line.partner_id,
is_refund=self.type in ('out_refund', 'in_refund'),
- handle_price_include=False,
)
if base_line.currency_id:
# Multi-currencies mode: Taxes are computed both in company's currency / foreign currency.
amount_currency_taxes_res = base_line.tax_ids._origin.compute_all(
- base_line.amount_currency,
+ price_unit_foreign_curr,
currency=base_line.currency_id,
+ quantity=quantity,
+ product=base_line.product_id,
partner=base_line.partner_id,
is_refund=self.type in ('out_refund', 'in_refund'),
- handle_price_include=False,
)
for b_tax_res, ac_tax_res in zip(balance_taxes_res['taxes'], amount_currency_taxes_res['taxes']):
+ tax = self.env['account.tax'].browse(b_tax_res['id'])
b_tax_res['amount_currency'] = ac_tax_res['amount']
+
+ # A tax having a fixed amount must be converted into the company currency when dealing with a
+ # foreign currency.
+ if tax.amount_type == 'fixed':
+ b_tax_res['amount'] = base_line.currency_id._convert(b_tax_res['amount'], move.company_id.currency_id, move.company_id, move.date)
+
return balance_taxes_res
taxes_map = {}
@@ -897,35 +924,43 @@ class AccountMove(models.Model):
total_tax_currency = 0.0
total_residual = 0.0
total_residual_currency = 0.0
+ total = 0.0
+ total_currency = 0.0
currencies = set()
for line in move.line_ids:
if line.currency_id:
currencies.add(line.currency_id)
- # Untaxed amount.
- if (move.is_invoice(include_receipts=True) and not line.exclude_from_invoice_tab)\
- or (move.type == 'entry' and line.debit and not line.tax_line_id):
- total_untaxed += line.balance
- total_untaxed_currency += line.amount_currency
-
- # Tax amount.
- if line.tax_line_id:
- total_tax += line.balance
- total_tax_currency += line.amount_currency
-
- # Residual amount.
- if move.type == 'entry' or line.account_id.user_type_id.type in ('receivable', 'payable'):
- total_residual += line.amount_residual
- total_residual_currency += line.amount_residual_currency
-
- total = total_untaxed + total_tax
- total_currency = total_untaxed_currency + total_tax_currency
+ if move.is_invoice(include_receipts=True):
+ # === Invoices ===
+
+ if not line.exclude_from_invoice_tab:
+ # Untaxed amount.
+ total_untaxed += line.balance
+ total_untaxed_currency += line.amount_currency
+ total += line.balance
+ total_currency += line.amount_currency
+ elif line.tax_line_id:
+ # Tax amount.
+ total_tax += line.balance
+ total_tax_currency += line.amount_currency
+ total += line.balance
+ total_currency += line.amount_currency
+ elif line.account_id.user_type_id.type in ('receivable', 'payable'):
+ # Residual amount.
+ total_residual += line.amount_residual
+ total_residual_currency += line.amount_residual_currency
+ else:
+ # === Miscellaneous journal entry ===
+ if line.debit:
+ total += line.balance
+ total_currency += line.amount_currency
- if (move.type == 'entry' and total < 0.0) or move.is_inbound():
- sign = -1
- else:
+ if move.type == 'entry' or move.is_outbound():
sign = 1
+ else:
+ sign = -1
move.amount_untaxed = sign * (total_untaxed_currency if len(currencies) == 1 else total_untaxed)
move.amount_tax = sign * (total_tax_currency if len(currencies) == 1 else total_tax)
move.amount_total = sign * (total_currency if len(currencies) == 1 else total)
@@ -1209,27 +1244,25 @@ class AccountMove(models.Model):
if 'posted' in self.mapped('line_ids.payment_id.state'):
raise ValidationError(_("You cannot modify a journal entry linked to a posted payment."))
- @api.constrains('name', 'journal_id')
+ @api.constrains('name', 'journal_id', 'state')
def _check_unique_sequence_number(self):
- if not self:
+ moves = self.filtered(lambda move: move.state == 'posted')
+ if not moves:
return
+ self.flush()
+
+ # /!\ Computed stored fields are not yet inside the database.
self._cr.execute('''
- WITH current_moves (id, name, state, company_id, journal_id, type) as ( values {} )
SELECT move2.id
- FROM ( SELECT id, name, state, company_id, journal_id, type from account_move
- UNION ALL SELECT id, name, state, company_id, journal_id, type from current_moves
- ) move
+ FROM account_move move
INNER JOIN account_move move2 ON
move2.name = move.name
- AND move2.company_id = move.company_id
AND move2.journal_id = move.journal_id
AND move2.type = move.type
AND move2.id != move.id
- WHERE move.id IN %s
- AND move.state = 'posted'
- AND move2.state = 'posted'
- '''.format(", ".join(["(%s, %s, %s, %s, %s, %s)"] * len(self))), list(chain(*self.mapped(lambda r: [r.id, r.name, r.state, r.company_id.id, r.journal_id.id, r.type]))) + [tuple(self.ids)])
+ WHERE move.id IN %s AND move2.state = 'posted'
+ ''', [tuple(moves.ids)])
res = self._cr.fetchone()
if res:
raise ValidationError(_('Posted journal entry must have an unique sequence number per company.'))
@@ -1240,23 +1273,28 @@ class AccountMove(models.Model):
if not moves:
return
+ self.env["account.move"].flush([
+ "ref", "type", "invoice_date", "journal_id",
+ "company_id", "partner_id", "commercial_partner_id",
+ ])
+ self.env["account.journal"].flush(["company_id"])
+ self.env["res.partner"].flush(["commercial_partner_id"])
+
+ # /!\ Computed stored fields are not yet inside the database.
self._cr.execute('''
- WITH current_moves (id, ref, company_id, commercial_partner_id, type, invoice_date) as ( values {} )
SELECT move2.id
- FROM ( SELECT id, ref, company_id, commercial_partner_id, type, invoice_date from account_move
- UNION ALL SELECT id, ref, company_id, commercial_partner_id, type, invoice_date from current_moves
- ) move
+ FROM account_move move
+ JOIN account_journal journal ON journal.id = move.journal_id
+ JOIN res_partner partner ON partner.id = move.partner_id
INNER JOIN account_move move2 ON
move2.ref = move.ref
- AND move2.company_id = move.company_id
- AND move2.commercial_partner_id = move.commercial_partner_id
+ AND move2.company_id = journal.company_id
+ AND move2.commercial_partner_id = partner.commercial_partner_id
AND move2.type = move.type
- AND (move2.invoice_date = move.invoice_date OR move.invoice_date is NULL)
+ AND (move.invoice_date is NULL OR move2.invoice_date = move.invoice_date)
AND move2.id != move.id
WHERE move.id IN %s
- AND move.type in ('in_invoice', 'in_refund')
- AND move.ref IS NOT NULL
- '''.format(", ".join(["(%s, %s, %s, %s, %s, CAST(%s as DATE))"] * len(moves))), list(chain(*moves.mapped(lambda r: [r.id, r.ref, r.company_id.id, r.commercial_partner_id.id, r.type, r.invoice_date or None]))) + [tuple(moves.ids)])
+ ''', [tuple(moves.ids)])
if self._cr.fetchone():
raise ValidationError(_('Duplicated vendor reference detected. You probably encoded twice the same vendor bill/credit note.'))
@@ -1304,24 +1342,22 @@ class AccountMove(models.Model):
return True
def _check_tax_lock_date(self):
- if not self:
- return
-
- self._cr.execute('''
- SELECT move.id, company.tax_lock_date
- FROM account_move move
- JOIN account_journal journal ON journal.id = move.journal_id
- JOIN res_company company ON company.id = journal.company_id
- WHERE move.id IN %s
- AND move.date < company.tax_lock_date
- ''', [tuple(self.ids)])
-
- query_res = self._cr.fetchone()
- if query_res:
- raise UserError(_('''
- The operation is refused as it would impact an already issued tax statement.
- Please change the journal entry date or the tax lock date set in the settings (%s) to proceed
- ''' % query_res[1]))
+ for move in self:
+ if (
+ move.company_id.tax_lock_date
+ and move.date <= move.company_id.tax_lock_date
+ and any(
+ line.tax_ids
+ or line.tax_line_id
+ or line.tag_ids.filtered(lambda x: x.applicability == "taxes")
+ for line in move.line_ids
+ )
+ ):
+ raise UserError(
+ _(
+ "The operation is refused as it would impact an already issued tax statement. Please change the journal entry date or the tax lock date set in the settings ({}) to proceed"
+ ).format(move.company_id.tax_lock_date or date.min)
+ )
def _check_move_consistency(self):
for move in self:
@@ -1330,6 +1366,7 @@ class AccountMove(models.Model):
raise UserError(_("Cannot create moves for different companies."))
self._check_balanced()
+ self._check_tax_lock_date()
self._check_fiscalyear_lock_date()
# -------------------------------------------------------------------------
@@ -1694,6 +1731,13 @@ class AccountMove(models.Model):
else:
return abs(total_reconciled / total_amount)
+ def _get_reconciled_payments(self):
+ """Helper used to retrieve the reconciled payments on this journal entry"""
+ pay_term_line_ids = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable'))
+ reconciled_amls = pay_term_line_ids.mapped('matched_debit_ids.debit_move_id') + \
+ pay_term_line_ids.mapped('matched_credit_ids.credit_move_id')
+ return reconciled_amls.mapped('payment_id')
+
def _reverse_move_vals(self, default_values, cancel=True):
''' Reverse values passed as parameter being the copied values of the original journal entry.
For example, debit / credit must be switched. The tax lines must be edited in case of refunds.
@@ -1820,7 +1864,16 @@ class AccountMove(models.Model):
'reversed_entry_id': move.id,
})
move_vals_list.append(move._reverse_move_vals(default_values, cancel=cancel))
+
reverse_moves = self.env['account.move'].create(move_vals_list)
+ for move, reverse_move in zip(self, reverse_moves.with_context(check_move_validity=False)):
+ # Update amount_currency if the date has changed.
+ if move.date != reverse_move.date:
+ for line in reverse_move.line_ids:
+ if line.currency_id:
+ line._onchange_currency()
+ reverse_move._recompute_dynamic_lines(recompute_all_taxes=False)
+ reverse_moves._check_balanced()
# Reconcile moves together to cancel the previous one.
if cancel:
@@ -1861,7 +1914,6 @@ class AccountMove(models.Model):
move.invoice_date = fields.Date.context_today(self)
move.with_context(check_move_validity=False)._onchange_invoice_date()
- self._check_tax_lock_date()
self._check_move_consistency()
# Create the analytic lines in batch is faster as it leads to less cache invalidation.
self.mapped('line_ids').create_analytic_lines()
@@ -1946,6 +1998,7 @@ class AccountMove(models.Model):
self.write({'state': 'draft'})
def button_cancel(self):
+ self._check_tax_lock_date()
AccountMoveLine = self.env['account.move.line']
excluded_move_ids = []
@@ -1953,6 +2006,10 @@ class AccountMove(models.Model):
excluded_move_ids = AccountMoveLine.search(AccountMoveLine._get_suspense_moves_domain() + [('move_id', 'in', self.ids)]).mapped('move_id').ids
for move in self:
+ if move in move.line_ids.mapped('full_reconcile_id.exchange_move_id'):
+ raise UserError(_('You cannot cancel an exchange difference journal entry.'))
+ if move.tax_cash_basis_rec_id:
+ raise UserError(_('You cannot cancel a tax cash basis journal entry.'))
if not move.journal_id.update_posted and move.id not in excluded_move_ids:
raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.'))
# We remove all the analytics entries for this journal
@@ -2002,6 +2059,7 @@ class AccountMove(models.Model):
"""
if any(not move.is_invoice(include_receipts=True) for move in self):
raise UserError(_("Only invoices could be printed."))
+ self._check_tax_lock_date()
self.filtered(lambda inv: not inv.invoice_sent).write({'invoice_sent': True})
if self.user_has_groups('account.group_account_invoice'):
@@ -2695,7 +2753,7 @@ class AccountMoveLine(models.Model):
else:
date = partial_line.credit_move_id.date if partial_line.debit_move_id == line else partial_line.debit_move_id.date
rate = line.currency_id.with_context(date=date).rate
- amount_residual_currency += sign_partial_line * partial_line.amount * rate
+ amount_residual_currency += sign_partial_line * line.currency_id.round(partial_line.amount * rate)
#computing the `reconciled` field.
reconciled = False
@@ -2778,36 +2836,21 @@ class AccountMoveLine(models.Model):
if line.reconciled:
raise UserError(_('Lines from "Off-Balance Sheet" accounts cannot be reconciled'))
- @api.constrains('tax_ids', 'tax_line_id')
- def _check_tax_lock_date1(self):
- for line in self:
- if line.date <= (line.company_id.tax_lock_date or date.min):
- raise ValidationError(
- _("The operation is refused as it would impact an already issued tax statement. " +
- "Please change the journal entry date or the tax lock date set in the settings ({}) to proceed").format(
- line.company_id.tax_lock_date or date.min))
-
- @api.constrains('credit', 'debit', 'date')
- def _check_tax_lock_date2(self):
- for line in self:
- if (line.tax_ids or line.tax_line_id) and line.date <= (line.company_id.tax_lock_date or date.min):
- raise ValidationError(
- _("The operation is refused as it would impact an already issued tax statement. " +
- "Please change the journal entry date or the tax lock date set in the settings ({}) to proceed").format(
- line.company_id.tax_lock_date or date.min))
-
def _update_check(self):
""" Raise Warning to cause rollback if the move is posted, some entries are reconciled or the move is older than the lock date"""
move_ids = set()
for line in self:
err_msg = _('Move name (id): %s (%s)') % (line.move_id.name, str(line.move_id.id))
- if line.move_id.state != 'draft':
+ if line.move_id.state == 'posted':
raise UserError(_('You cannot do this modification on a posted journal entry, you can just change some non legal fields. You must revert the journal entry to cancel it.\n%s.') % err_msg)
if line.reconciled and not (line.debit == 0 and line.credit == 0):
raise UserError(_('You cannot do this modification on a reconciled entry. You can just change some non legal fields or you must unreconcile first.\n%s.') % err_msg)
if line.move_id.id not in move_ids:
move_ids.add(line.move_id.id)
- self.env['account.move'].browse(list(move_ids))._check_fiscalyear_lock_date()
+ if move_ids:
+ moves = self.env['account.move'].browse(list(move_ids))
+ moves._check_fiscalyear_lock_date()
+ moves._check_tax_lock_date()
return True
# -------------------------------------------------------------------------
@@ -2963,7 +3006,6 @@ class AccountMoveLine(models.Model):
def unlink(self):
self._update_check()
- self._check_tax_lock_date2()
move_ids = set()
for line in self:
if line.move_id.id not in move_ids:
@@ -2997,6 +3039,11 @@ class AccountMoveLine(models.Model):
# Suggest default value for debit / credit to balance the journal entry.
balance = sum(line['debit'] - line['credit'] for line in move.line_ids)
+ # if we are here, line_ids is in context, so journal_id should also be.
+ journal = self.env['account.journal'].browse(self._context.get('default_journal_id') or self._context['journal_id'])
+ currency = journal.exists() and journal.company_id.currency_id
+ if currency:
+ balance = currency.round(balance)
if balance < 0.0:
values.update({'debit': -balance})
if balance > 0.0:
@@ -3201,7 +3248,11 @@ class AccountMoveLine(models.Model):
for after_rec_dict in cash_basis_subjected:
new_rec = part_rec.create(after_rec_dict)
# if the pair belongs to move being reverted, do not create CABA entry
- if cash_basis and not (new_rec.debit_move_id + new_rec.credit_move_id).mapped('move_id.reversed_entry_id'):
+ if cash_basis and not (
+ new_rec.debit_move_id.move_id == new_rec.credit_move_id.move_id.reversed_entry_id
+ or
+ new_rec.credit_move_id.move_id == new_rec.debit_move_id.move_id.reversed_entry_id
+ ):
new_rec.create_tax_cash_basis_entry(cash_basis_percentage_before_rec)
return debit_moves+credit_moves
@@ -3369,8 +3420,11 @@ class AccountMoveLine(models.Model):
def copy_data(self, default=None):
res = super(AccountMoveLine, self).copy_data(default=default)
- if self._context.get('include_business_fields'):
- for line, values in zip(self, res):
+ for line, values in zip(self, res):
+ # Don't copy the name of a payment term line.
+ if line.move_id.is_invoice() and line.account_id.user_type_id.type in ('receivable', 'payable'):
+ values['name'] = ''
+ if self._context.get('include_business_fields'):
line._copy_data_extend_business_fields(values)
return res
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index b9e381f7fbf..25ddf2a3544 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -443,7 +443,8 @@ class account_payment(models.Model):
for move in rec.move_line_ids.mapped('move_id'):
if rec.reconciled_invoice_ids:
move.line_ids.remove_move_reconcile()
- move.button_cancel()
+ if move.state != 'draft':
+ move.button_cancel()
move.unlink()
rec.write({
'state': 'cancelled',
diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py
index 6ac0ad9986c..3a70d0516ae 100644
--- a/addons/account/models/account_reconcile_model.py
+++ b/addons/account/models/account_reconcile_model.py
@@ -399,7 +399,7 @@ class AccountReconcileModel(models.Model):
query += ' AND st_line.name NOT ILIKE %s'
params += ['%%%s%%' % rule['match_' + field + '_param']]
elif rule['match_' + field] == 'match_regex':
- query += ' AND st_line.name ~ %s'
+ query += ' AND st_line.name ~* %s'
params += [rule['match_' + field + '_param']]
# Filter on partners.
@@ -469,13 +469,24 @@ class AccountReconcileModel(models.Model):
aml.amount_currency AS aml_amount_currency,
account.internal_type AS account_internal_type,
- -- Determine a matching or not with the statement line communication using the move.name or move.ref.
- regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+')
- && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+')
+ -- Determine a matching or not with the statement line communication using the aml.name, move.name or move.ref.
+ (
+ aml.name IS NOT NULL
+ AND
+ TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')) != ''
+ AND
+ regexp_split_to_array(TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')),'\s+')
+ && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+')
+ )
+ OR
+ regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+')
+ && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+')
OR
(
move.ref IS NOT NULL
AND
+ TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')) != ''
+ AND
regexp_split_to_array(TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')),'\s+')
&& regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+')
) AS communication_flag
@@ -518,12 +529,23 @@ class AccountReconcileModel(models.Model):
TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')) != ''
AND
(
- regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+')
- && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+')
+ (
+ aml.name IS NOT NULL
+ AND
+ TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')) != ''
+ AND
+ regexp_split_to_array(TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')),'\s+')
+ && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+')
+ )
+ OR
+ regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+')
+ && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+')
OR
(
move.ref IS NOT NULL
AND
+ TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')) != ''
+ AND
regexp_split_to_array(TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')),'\s+')
&& regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+')
)
@@ -633,8 +655,10 @@ class AccountReconcileModel(models.Model):
if line_residual > total_residual:
amount_percentage = (total_residual / line_residual) * 100
- else:
+ elif total_residual:
amount_percentage = (line_residual / total_residual) * 100 if total_residual else 0
+ else:
+ return False
return amount_percentage >= self.match_total_amount_param
def _apply_rules(self, st_lines, excluded_ids=None, partner_map=None):
diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py
index 42f8eabb0a7..367b8f82a93 100644
--- a/addons/account/models/reconciliation_widget.py
+++ b/addons/account/models/reconciliation_widget.py
@@ -141,7 +141,8 @@ class AccountReconciliation(models.AbstractModel):
query += 'LEFT JOIN res_partner_bank bank ON bank.id = st_line.bank_account_id OR bank.acc_number = st_line.account_number %s\n' % (where_bank)
query += 'LEFT JOIN res_partner p1 ON st_line.partner_id=p1.id \n'
query += 'LEFT JOIN res_partner p2 ON bank.partner_id=p2.id \n'
- query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s\n' % (where_partner)
+ # By definition the commercial partner_id doesn't have a parent_id set
+ query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s AND p3.parent_id is NULL \n' % (where_partner)
query += 'WHERE st_line.id IN %s'
params += [tuple(st_lines.ids)]
@@ -538,8 +539,10 @@ class AccountReconciliation(models.AbstractModel):
])
domain = expression.OR([domain_reconciliation, domain_matching])
+ partner_domain = []
if partner_id:
- domain = expression.AND([domain, [('partner_id', '=', partner_id)]])
+ partner_domain = [('partner_id', '=', partner_id)]
+ domain = expression.AND([domain, partner_domain])
if mode == 'rp':
domain = expression.AND([domain,
[('account_id.internal_type', 'in', ['receivable', 'payable', 'liquidity'])]
@@ -553,10 +556,13 @@ class AccountReconciliation(models.AbstractModel):
if search_str:
str_domain = self._domain_move_lines(search_str=search_str)
if not partner_id:
- str_domain = expression.OR([
- str_domain,
- [('partner_id.name', 'ilike', search_str)]
- ])
+ partner_domain = [('partner_id.name', 'ilike', search_str)]
+
+ str_domain = expression.OR([
+ str_domain,
+ partner_domain,
+ ])
+
domain = expression.AND([
domain,
str_domain
diff --git a/addons/account/static/tests/tours/reconciliation.js b/addons/account/static/tests/tours/reconciliation.js
index 803acef88d0..6566c55e09f 100644
--- a/addons/account/static/tests/tours/reconciliation.js
+++ b/addons/account/static/tests/tours/reconciliation.js
@@ -63,19 +63,19 @@ Tour.register('bank_statement_reconciliation', {
},
{
content: "use filter",
- trigger: '.o_reconciliation_line:nth-child(2) .match .match_controls .filter',
+ trigger: '.o_reconciliation_line:nth-child(1) .match .match_controls .filter',
run: 'text 4610'
},
{
content: "select a line linked to Deco Addict ",
- extra_trigger: ".o_reconciliation_line:nth-child(2) .match .line_info_button:last[data-content*='4,610']",
- trigger: ".o_reconciliation_line:nth-child(2) .match .line_info_button[data-content*='Deco Addict']"
+ extra_trigger: ".o_reconciliation_line:nth-child(1) .match .line_info_button:last[data-content*='4,610']",
+ trigger: ".o_reconciliation_line:nth-child(1) .match .line_info_button[data-content*='Deco Addict']"
},
{
content: "deselect the line",
- trigger: '.o_reconciliation_line:nth-child(2) .accounting_view tbody .cell_label:first',
+ trigger: '.o_reconciliation_line:nth-child(1) .accounting_view tbody .cell_label:first',
run: function() {
- $('.o_reconciliation_line:nth-child(2) .accounting_view tbody .cell_label:first').trigger('click');
+ $('.o_reconciliation_line:nth-child(1) .accounting_view tbody .cell_label:first').trigger('click');
}
},
{
diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py
index 0cca1a3c3a1..59dd42269fe 100644
--- a/addons/account/tests/__init__.py
+++ b/addons/account/tests/__init__.py
@@ -20,5 +20,6 @@ from . import test_templates_consistency
from . import test_account_fiscal_year
from . import test_account_all_l10n
from . import test_reconciliation_matching_rules
+from . import test_account_move_tax_lock_date
from . import test_account_onboarding
from . import test_portal_attachment
diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py
index 4a0fbf2848a..587dfc48deb 100644
--- a/addons/account/tests/test_account_move_entry.py
+++ b/addons/account/tests/test_account_move_entry.py
@@ -69,34 +69,27 @@ class TestAccountMove(InvoiceTestCommon):
# lines[3] = 'revenue line 2'
lines = self.test_move.line_ids.sorted('debit')
- # Writing not affecting a tax is allowed.
- self.test_move.write({
- 'line_ids': [
- (1, lines[0].id, {'credit': 1750.0}), # counterpart line
- (1, lines[2].id, {'debit': 600.0}), # revenue line 1
- ],
- })
self.test_move.flush()
self.cr.execute('SAVEPOINT test_misc_tax_lock_date_1')
# Writing something affecting a tax is not allowed.
- with self.assertRaises(ValidationError):
+ with self.assertRaises(UserError):
self.test_move.write({
'line_ids': [
(1, lines[0].id, {'credit': 2750.0}),
- (1, lines[3].id, {'debit': 2000.0}),
+ (1, lines[3].id, {'debit': 2100.0}),
],
})
- with self.assertRaises(ValidationError):
+ with self.assertRaises(UserError):
self.test_move.write({
'line_ids': [
(1, lines[3].id, {'tax_ids': [(6, 0, self.company_data['default_tax_purchase'].ids)]}),
],
})
- with self.assertRaises(ValidationError):
+ with self.assertRaises(UserError):
self.test_move.write({
'line_ids': [
(1, lines[0].id, {'credit': 1900.0}),
@@ -104,12 +97,22 @@ class TestAccountMove(InvoiceTestCommon):
],
})
- with self.assertRaises(ValidationError):
+ with self.assertRaises(UserError):
self.test_move.unlink()
self.test_move.flush()
self.test_move.invalidate_cache()
self.cr.execute('ROLLBACK TO SAVEPOINT test_misc_tax_lock_date_1')
- with self.assertRaises(UserError):
- self.test_move.post()
+ def test_misc_unique_sequence_number(self):
+ ''' Ensure two journal entries can't share the same name when using the same sequence. '''
+ self.test_move.post()
+
+ # Edit the sequence to force the next move to get the same name.
+ self.test_move.journal_id\
+ .sequence_id.date_range_ids\
+ .filtered(lambda seq: seq.date_from == fields.Date.from_string('2016-01-01')).number_next -= 1
+
+ test_move2 = self.test_move.copy()
+ with self.assertRaises(ValidationError):
+ test_move2.post()
diff --git a/addons/account/tests/test_account_move_in_invoice.py b/addons/account/tests/test_account_move_in_invoice.py
index dd4a9a834e5..9696c31065f 100644
--- a/addons/account/tests/test_account_move_in_invoice.py
+++ b/addons/account/tests/test_account_move_in_invoice.py
@@ -3,6 +3,7 @@ from odoo.addons.account.tests.invoice_test_common import InvoiceTestCommon
from odoo.tests.common import Form
from odoo.tests import tagged
from odoo import fields
+from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
@@ -851,7 +852,7 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon):
self.term_line_vals_1,
], self.move_vals)
- def test_in_invoice_create_draft_refund(self):
+ def test_in_invoice_create_refund(self):
self.invoice.post()
move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({
@@ -885,8 +886,10 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon):
},
{
**self.term_line_vals_1,
+ 'name': '',
'debit': 1128.0,
'credit': 0.0,
+ 'date_maturity': move_reversal.date,
},
], {
**self.move_vals,
@@ -898,7 +901,7 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon):
move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({
'date': fields.Date.from_string('2019-02-01'),
- 'reason': 'because',
+ 'reason': 'no reason again',
'refund_method': 'cancel',
})
reversal = move_reversal.reverse_moves()
@@ -927,8 +930,10 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon):
},
{
**self.term_line_vals_1,
+ 'name': '',
'debit': 1128.0,
'credit': 0.0,
+ 'date_maturity': move_reversal.date,
},
], {
**self.move_vals,
@@ -938,6 +943,128 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon):
'invoice_payment_state': 'paid',
})
+ def test_in_invoice_create_refund_multi_currency(self):
+ ''' Test the account.move.reversal takes care about the currency rates when setting
+ a custom reversal date.
+ '''
+ move_form = Form(self.invoice)
+ move_form.date = '2016-01-01'
+ move_form.currency_id = self.currency_data['currency']
+ move_form.save()
+
+ self.invoice.post()
+
+ # The currency rate changed from 1/3 to 1/2.
+ move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({
+ 'date': fields.Date.from_string('2017-01-01'),
+ 'reason': 'no reason',
+ 'refund_method': 'refund',
+ })
+ reversal = move_reversal.reverse_moves()
+ reverse_move = self.env['account.move'].browse(reversal['res_id'])
+
+ self.assertInvoiceValues(reverse_move, [
+ {
+ **self.product_line_vals_1,
+ 'amount_currency': -800.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 400.0,
+ },
+ {
+ **self.product_line_vals_2,
+ 'amount_currency': -160.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 80.0,
+ },
+ {
+ **self.tax_line_vals_1,
+ 'amount_currency': -144.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 72.0,
+ },
+ {
+ **self.tax_line_vals_2,
+ 'amount_currency': -24.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 12.0,
+ },
+ {
+ **self.term_line_vals_1,
+ 'name': '',
+ 'amount_currency': 1128.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 564.0,
+ 'credit': 0.0,
+ 'date_maturity': move_reversal.date,
+ },
+ ], {
+ **self.move_vals,
+ 'currency_id': self.currency_data['currency'].id,
+ 'date': move_reversal.date,
+ 'state': 'draft',
+ 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason),
+ 'invoice_payment_state': 'not_paid',
+ })
+
+ move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({
+ 'date': fields.Date.from_string('2017-01-01'),
+ 'reason': 'no reason again',
+ 'refund_method': 'cancel',
+ })
+ reversal = move_reversal.reverse_moves()
+ reverse_move = self.env['account.move'].browse(reversal['res_id'])
+
+ self.assertInvoiceValues(reverse_move, [
+ {
+ **self.product_line_vals_1,
+ 'amount_currency': -800.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 400.0,
+ },
+ {
+ **self.product_line_vals_2,
+ 'amount_currency': -160.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 80.0,
+ },
+ {
+ **self.tax_line_vals_1,
+ 'amount_currency': -144.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 72.0,
+ },
+ {
+ **self.tax_line_vals_2,
+ 'amount_currency': -24.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 12.0,
+ },
+ {
+ **self.term_line_vals_1,
+ 'name': '',
+ 'amount_currency': 1128.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 564.0,
+ 'credit': 0.0,
+ 'date_maturity': move_reversal.date,
+ },
+ ], {
+ **self.move_vals,
+ 'currency_id': self.currency_data['currency'].id,
+ 'date': move_reversal.date,
+ 'state': 'posted',
+ 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason),
+ 'invoice_payment_state': 'paid',
+ })
+
def test_in_invoice_create_1(self):
# Test creating an account_move with the least information.
move = self.env['account.move'].create({
@@ -1042,6 +1169,14 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon):
'currency_id': self.currency_data['currency'].id,
})
+ def test_in_invoice_duplicate_supplier_reference(self):
+ ''' Ensure two vendor bills can't share the same vendor reference. '''
+ self.invoice.ref = 'a supplier reference'
+ invoice2 = self.invoice.copy()
+
+ with self.assertRaises(ValidationError):
+ invoice2.ref = 'a supplier reference'
+
def test_in_invoice_switch_in_refund_1(self):
# Test creating an account_move with an in_invoice_type and switch it in an in_refund.
move = self.env['account.move'].create({
diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py
index c7a6330db8e..d3c68051ee1 100644
--- a/addons/account/tests/test_account_move_out_invoice.py
+++ b/addons/account/tests/test_account_move_out_invoice.py
@@ -836,7 +836,7 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon):
self.assertRecordValues(invoice_copy, [{'name': 'INV/2019/0043'}])
- def test_out_invoice_create_draft_refund(self):
+ def test_out_invoice_create_refund(self):
self.invoice.post()
move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({
@@ -870,12 +870,14 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon):
},
{
**self.term_line_vals_1,
- 'name': self.invoice.name,
+ 'name': '',
'debit': 0.0,
'credit': 1410.0,
+ 'date_maturity': move_reversal.date,
},
], {
**self.move_vals,
+ 'name': '/',
'date': move_reversal.date,
'state': 'draft',
'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason),
@@ -913,9 +915,10 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon):
},
{
**self.term_line_vals_1,
- 'name': self.invoice.name,
+ 'name': '',
'debit': 0.0,
'credit': 1410.0,
+ 'date_maturity': move_reversal.date,
},
], {
**self.move_vals,
@@ -925,6 +928,128 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon):
'invoice_payment_state': 'paid',
})
+ def test_out_invoice_create_refund_multi_currency(self):
+ ''' Test the account.move.reversal takes care about the currency rates when setting
+ a custom reversal date.
+ '''
+ move_form = Form(self.invoice)
+ move_form.date = '2016-01-01'
+ move_form.currency_id = self.currency_data['currency']
+ move_form.save()
+
+ self.invoice.post()
+
+ # The currency rate changed from 1/3 to 1/2.
+ move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({
+ 'date': fields.Date.from_string('2017-01-01'),
+ 'reason': 'no reason',
+ 'refund_method': 'refund',
+ })
+ reversal = move_reversal.reverse_moves()
+ reverse_move = self.env['account.move'].browse(reversal['res_id'])
+
+ self.assertInvoiceValues(reverse_move, [
+ {
+ **self.product_line_vals_1,
+ 'amount_currency': 1000.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 500.0,
+ 'credit': 0.0,
+ },
+ {
+ **self.product_line_vals_2,
+ 'amount_currency': 200.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 100.0,
+ 'credit': 0.0,
+ },
+ {
+ **self.tax_line_vals_1,
+ 'amount_currency': 180.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 90.0,
+ 'credit': 0.0,
+ },
+ {
+ **self.tax_line_vals_2,
+ 'amount_currency': 30.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 15.0,
+ 'credit': 0.0,
+ },
+ {
+ **self.term_line_vals_1,
+ 'name': '',
+ 'amount_currency': -1410.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 705.0,
+ 'date_maturity': move_reversal.date,
+ },
+ ], {
+ **self.move_vals,
+ 'currency_id': self.currency_data['currency'].id,
+ 'date': move_reversal.date,
+ 'state': 'draft',
+ 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason),
+ 'invoice_payment_state': 'not_paid',
+ })
+
+ move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({
+ 'date': fields.Date.from_string('2017-01-01'),
+ 'reason': 'no reason',
+ 'refund_method': 'cancel',
+ })
+ reversal = move_reversal.reverse_moves()
+ reverse_move = self.env['account.move'].browse(reversal['res_id'])
+
+ self.assertInvoiceValues(reverse_move, [
+ {
+ **self.product_line_vals_1,
+ 'amount_currency': 1000.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 500.0,
+ 'credit': 0.0,
+ },
+ {
+ **self.product_line_vals_2,
+ 'amount_currency': 200.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 100.0,
+ 'credit': 0.0,
+ },
+ {
+ **self.tax_line_vals_1,
+ 'amount_currency': 180.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 90.0,
+ 'credit': 0.0,
+ },
+ {
+ **self.tax_line_vals_2,
+ 'amount_currency': 30.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 15.0,
+ 'credit': 0.0,
+ },
+ {
+ **self.term_line_vals_1,
+ 'name': '',
+ 'amount_currency': -1410.0,
+ 'currency_id': self.currency_data['currency'].id,
+ 'debit': 0.0,
+ 'credit': 705.0,
+ 'date_maturity': move_reversal.date,
+ },
+ ], {
+ **self.move_vals,
+ 'currency_id': self.currency_data['currency'].id,
+ 'date': move_reversal.date,
+ 'state': 'posted',
+ 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason),
+ 'invoice_payment_state': 'paid',
+ })
+
def test_out_invoice_create_1(self):
# Test creating an account_move with the least information.
move = self.env['account.move'].create({
diff --git a/addons/account/tests/test_account_move_tax_lock_date.py b/addons/account/tests/test_account_move_tax_lock_date.py
new file mode 100644
index 00000000000..280536e6b46
--- /dev/null
+++ b/addons/account/tests/test_account_move_tax_lock_date.py
@@ -0,0 +1,98 @@
+from odoo.addons.account.tests.account_test_classes import AccountingTestCase
+from odoo.exceptions import UserError
+from datetime import date
+from calendar import monthrange
+# from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestTaxBlockDate(AccountingTestCase):
+ """
+ Forbid creation, edition and deletion of Journal Items related to taxes with
+ a date prior to the Tax Block Date.
+ """
+
+ def setUp(self):
+ super(TestTaxBlockDate, self).setUp()
+ self.user_id = self.env.user
+ self.company = self.env.company
+ company_id = self.company.id
+
+ last_day_month = date.today()
+ self.last_day_month = last_day_month.replace(day=monthrange(last_day_month.year, last_day_month.month)[1])
+ first_day_month = date.today()
+ self.first_day_month = first_day_month.replace(day=1)
+ middle_day_month = date.today()
+ middle_day_month = middle_day_month.replace(day=15)
+
+ self.sale_journal_id = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company_id)], limit=1)[0]
+ self.account_id = self.env['account.account'].search([('internal_type', '=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0]
+ self.other_account_id = self.env['account.account'].search([('internal_type', '!=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0]
+ self.tax_id = self.env['account.tax'].search([('company_id', '=', company_id)], limit=1)[0]
+
+ self.move = {
+ 'name': '/',
+ 'journal_id': self.sale_journal_id.id,
+ 'date': middle_day_month,
+ 'line_ids': [(0, 0, {
+ 'name': 'foo',
+ 'debit': 10,
+ 'account_id': self.account_id.id,
+ 'tax_ids': [(6, False, [self.tax_id.id])]
+ }), (0, 0, {
+ 'name': 'bar',
+ 'credit': 10,
+ 'account_id': self.account_id.id,
+ })]
+ }
+
+ self.move_no_tax = {
+ 'name': '/',
+ 'journal_id': self.sale_journal_id.id,
+ 'date': middle_day_month,
+ 'line_ids': [(0, 0, {
+ 'name': 'foo',
+ 'debit': 10,
+ 'account_id': self.account_id.id,
+ }), (0, 0, {
+ 'name': 'bar',
+ 'credit': 10,
+ 'account_id': self.account_id.id,
+ })]
+ }
+
+ def test_tax_lock_date_post(self):
+ """ Checks that it is impossible to post an entry
+ before the tax lock date.
+ """
+ # Posting after the lock date is always allowed
+ self.company.tax_lock_date = self.first_day_month
+ # copy() because mail.thread modifies the dictionary given to create
+ move_after_lock = self.env['account.move'].create(self.move.copy())
+ move_after_lock.post()
+
+ # Posting before the tax lock date is allowed only if the move doesn't contain any tax
+ self.user_id.company_id.tax_lock_date = self.last_day_month
+ move_before_lock_no_tax = self.env['account.move'].create(self.move_no_tax)
+ move_before_lock_no_tax.post()
+
+ # Posting before the tax lock date is not allowed for moves impacting the tax report
+ self.user_id.company_id.tax_lock_date = self.last_day_month
+ with self.assertRaises(UserError):
+ self.env['account.move'].create(self.move.copy())
+
+ def test_tax_lock_date_cancel(self):
+ """ Checks that it is not possible to cancel an entry posted before the
+ tax lock date if it impacts the tax report. """
+ self.sale_journal_id.update_posted = True
+ move = self.env['account.move'].create(self.move)
+ move.post()
+ move_no_tax = self.env['account.move'].create(self.move_no_tax)
+ move_no_tax.post()
+
+ self.user_id.company_id.tax_lock_date = self.last_day_month
+
+ move_no_tax.button_cancel()
+ with self.assertRaises(UserError):
+ move.button_cancel()
diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py
index fa94e34018e..94e5e69bee4 100644
--- a/addons/account/tests/test_reconciliation.py
+++ b/addons/account/tests/test_reconciliation.py
@@ -149,6 +149,24 @@ class TestReconciliation(AccountingTestCase):
invoice.post()
return invoice
+ def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False):
+ move = self.env['account.move'].with_context(default_type=type).create({
+ 'type': type,
+ 'partner_id': partner_id,
+ 'invoice_date': time.strftime('%Y') + '-07-01',
+ 'date': time.strftime('%Y') + '-07-01',
+ 'currency_id': currency_id,
+ 'invoice_payment_term_id': payment_term_id,
+ 'invoice_line_ids': [(0, 0, {
+ 'quantity': 1,
+ 'price_unit': invoice_amount,
+
+ 'name': 'product that cost ' + str(invoice_amount),
+ })],
+ })
+ move.post()
+ return move
+
def make_payment(self, invoice_record, bank_journal, amount=0.0, amount_currency=0.0, currency_id=None):
bank_stmt = self.acc_bank_stmt_model.create({
'journal_id': bank_journal.id,
@@ -895,22 +913,6 @@ class TestReconciliationExec(TestReconciliation):
self.assertEqual(reversed_bank_line.full_reconcile_id.id, bank_line.full_reconcile_id.id)
self.assertEqual(reversed_customer_line.full_reconcile_id.id, customer_line.full_reconcile_id.id)
- def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False):
- move = self.env['account.move'].with_context(default_type=type).create({
- 'type': type,
- 'partner_id': partner_id,
- 'invoice_date': time.strftime('%Y') + '-07-01',
- 'date': time.strftime('%Y') + '-07-01',
- 'currency_id': currency_id,
- 'invoice_payment_term_id': payment_term_id,
- 'invoice_line_ids': [(0, 0, {
- 'quantity': 1,
- 'price_unit': invoice_amount,
- 'name': 'product that cost ' + str(invoice_amount),
- })],
- })
- move.post()
- return move
def test_aged_report(self):
AgedReport = self.env['report.account.report_agedpartnerbalance'].with_context(include_nullified_amount=True)
@@ -1169,6 +1171,68 @@ class TestReconciliationExec(TestReconciliation):
# because they owe us still 50 CC.
self.assertEqual(invoice_cust_1.invoice_payment_state, 'not_paid', 'Invoice is in status %s' % invoice_cust_1.state)
+ def test_inv_refund_foreign_payment_writeoff_domestic(self):
+ company = self.env.ref('base.main_company')
+ self.env['res.currency.rate'].search([]).unlink()
+ self.env['res.currency.rate'].create({
+ 'name': time.strftime('%Y') + '-07-01',
+ 'rate': 1.0,
+ 'currency_id': self.currency_euro_id,
+ 'company_id': company.id
+ })
+ self.env['res.currency.rate'].create({
+ 'name': time.strftime('%Y') + '-07-01',
+ 'rate': 1.113900, # Don't change this !
+ 'currency_id': self.currency_usd_id,
+ 'company_id': self.env.ref('base.main_company').id
+ })
+ inv1 = self.create_invoice(invoice_amount=480, currency_id=self.currency_usd_id)
+ inv2 = self.create_invoice(type="out_refund", invoice_amount=140, currency_id=self.currency_usd_id)
+
+ payment = self.env['account.payment'].create({
+ 'payment_method_id': self.inbound_payment_method.id,
+ 'payment_type': 'inbound',
+ 'partner_type': 'customer',
+ 'partner_id': inv1.partner_id.id,
+ 'amount': 287.20,
+ 'journal_id': self.bank_journal_euro.id,
+ 'company_id': company.id,
+ })
+ payment.post()
+
+ inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable')
+ inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable')
+ pay_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable')
+
+ data_for_reconciliation = [
+ {
+ 'type': 'partner',
+ 'id': inv1.partner_id.id,
+ 'mv_line_ids': (inv1_receivable + inv2_receivable + pay_receivable).ids,
+ 'new_mv_line_dicts': [
+ {
+ 'credit': 18.04,
+ 'debit': 0.00,
+ 'journal_id': self.bank_journal_euro.id,
+ 'name': 'Total WriteOff (Fees)',
+ 'account_id': self.diff_expense_account.id
+ }
+ ]
+ }
+ ]
+
+ self.env["account.reconciliation.widget"].process_move_lines(data_for_reconciliation)
+
+ self.assertTrue(inv1_receivable.full_reconcile_id.exists())
+ self.assertEquals(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id)
+ self.assertEquals(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id)
+
+ self.assertTrue(all(l.reconciled for l in inv1_receivable))
+ self.assertTrue(all(l.reconciled for l in inv2_receivable))
+
+ self.assertEquals(inv1.invoice_payment_state, 'paid')
+ self.assertEquals(inv2.invoice_payment_state, 'paid')
+
def test_multiple_term_reconciliation_opw_1906665(self):
'''Test that when registering a payment to an invoice with multiple
payment term lines the reconciliation happens against the line
diff --git a/addons/account/tests/test_reconciliation_widget.py b/addons/account/tests/test_reconciliation_widget.py
index a81b3471d9b..99769bc2f2e 100644
--- a/addons/account/tests/test_reconciliation_widget.py
+++ b/addons/account/tests/test_reconciliation_widget.py
@@ -53,3 +53,82 @@ class TestReconciliationWidget(TestReconciliation):
result = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids)
self.assertEqual(result['lines'][0]['reconciliation_proposition'][0]['amount_str'], '$ 50.00')
+
+ def test_filter_partner1(self):
+ inv1 = self.create_invoice(currency_id=self.currency_euro_id)
+ inv2 = self.create_invoice(currency_id=self.currency_euro_id)
+
+ receivable1 = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable')
+ receivable2 = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable')
+
+ bank_stmt = self.acc_bank_stmt_model.create({
+ 'company_id': self.env.ref('base.main_company').id,
+ 'journal_id': self.bank_journal_euro.id,
+ 'date': time.strftime('%Y-07-15'),
+ 'name': 'test',
+ })
+
+ bank_stmt_line = self.acc_bank_stmt_line_model.create({
+ 'name': 'testLine',
+ 'statement_id': bank_stmt.id,
+ 'amount': 100,
+ 'date': time.strftime('%Y-07-15'),
+ })
+
+ # This is like opening the widget, and type "deco" in the filter
+ mv_lines_rec = self.env['account.reconciliation.widget'].get_move_lines_for_bank_statement_line(
+ bank_stmt_line.id,
+ partner_id=False,
+ excluded_ids=[],
+ search_str="deco",
+ mode="rp",
+ )
+ mv_lines_ids = [l['id'] for l in mv_lines_rec]
+
+ self.assertIn(receivable1.id, mv_lines_ids)
+ self.assertIn(receivable2.id, mv_lines_ids)
+
+ # This is like clicking on the first receivable
+ partner = inv1.partner_id
+ mv_lines_rec = self.env['account.reconciliation.widget'].get_move_lines_for_bank_statement_line(
+ bank_stmt_line.id,
+ partner_id=partner.id,
+ excluded_ids=[receivable1.id],
+ search_str="deco",
+ mode="rp",
+ )
+ mv_lines_ids = [l['id'] for l in mv_lines_rec]
+
+ self.assertNotIn(receivable1.id, mv_lines_ids)
+ self.assertIn(receivable2.id, mv_lines_ids)
+
+ def test_partner_name_with_parent(self):
+ parent_partner = self.env['res.partner'].create({
+ 'name': 'test',
+ })
+ child_partner = self.env['res.partner'].create({
+ 'name': 'test',
+ 'parent_id': parent_partner.id,
+ 'type': 'delivery',
+ })
+ self.create_invoice_partner(currency_id=self.currency_euro_id, partner_id=child_partner.id)
+
+ bank_stmt = self.acc_bank_stmt_model.create({
+ 'company_id': self.env.ref('base.main_company').id,
+ 'journal_id': self.bank_journal_euro.id,
+ 'date': time.strftime('%Y-07-15'),
+ 'name': 'test',
+ })
+
+ bank_stmt_line = self.acc_bank_stmt_line_model.create({
+ 'name': 'testLine',
+ 'statement_id': bank_stmt.id,
+ 'amount': 100,
+ 'date': time.strftime('%Y-07-15'),
+ 'partner_name': 'test',
+ })
+
+ bkstmt_data = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids)
+
+ self.assertEqual(len(bkstmt_data['lines']), 1)
+ self.assertEqual(bkstmt_data['lines'][0]['partner_id'], parent_partner.id)
diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py
index 903699dc4d9..48119a58cba 100644
--- a/addons/account/tests/test_tax.py
+++ b/addons/account/tests/test_tax.py
@@ -348,12 +348,32 @@ class TestTax(AccountTestUsers):
'price_include': True,
'include_base_amount': True,
'sequence': 1,
+ 'invoice_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
})
tax_2 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_2',
'amount_type': 'percent',
'amount': 10,
'sequence': 2,
+ 'invoice_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
})
tax_3 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_3',
@@ -361,12 +381,32 @@ class TestTax(AccountTestUsers):
'amount': 10,
'price_include': True,
'sequence': 3,
+ 'invoice_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
})
tax_4 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_4',
'amount_type': 'percent',
'amount': 10,
'sequence': 4,
+ 'invoice_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
})
tax_5 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_5',
@@ -374,21 +414,229 @@ class TestTax(AccountTestUsers):
'amount': 10,
'price_include': True,
'sequence': 5,
+ 'invoice_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
})
taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5
- res = taxes.compute_all(132.0)
+
+ # Test with positive amount.
self._check_compute_all_results(
154, # 'total_included'
100, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
- (100.0, 10.0), # | 1 | 10% | t | t
- (110.0, 11.0), # | 2 | 10% | |
- (110.0, 11.0), # | 3 | 10% | t |
- (110.0, 11.0), # | 4 | 10% | |
- (110.0, 11.0), # | 5 | 10% | t |
+ (100.0, 5.0), # | 1 | 10% | t | t
+ (100.0, 5.0), # | 1 | 10% | t | t
+ (110.0, 5.5), # | 2 | 10% | |
+ (110.0, 5.5), # | 2 | 10% | |
+ (110.0, 5.5), # | 3 | 10% | t |
+ (110.0, 5.5), # | 3 | 10% | t |
+ (110.0, 5.5), # | 4 | 10% | |
+ (110.0, 5.5), # | 4 | 10% | |
+ (110.0, 5.5), # | 5 | 10% | t |
+ (110.0, 5.5), # | 5 | 10% | t |
# ---------------------------------------------------
],
- res
+ taxes.compute_all(132.0)
+ )
+
+ # Test with negative amount.
+ self._check_compute_all_results(
+ -154, # 'total_included'
+ -100, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (-100.0, -5.0), # | 1 | 10% | t | t
+ (-100.0, -5.0), # | 1 | 10% | t | t
+ (-110.0, -5.5), # | 2 | 10% | |
+ (-110.0, -5.5), # | 2 | 10% | |
+ (-110.0, -5.5), # | 3 | 10% | t |
+ (-110.0, -5.5), # | 3 | 10% | t |
+ (-110.0, -5.5), # | 4 | 10% | |
+ (-110.0, -5.5), # | 4 | 10% | |
+ (-110.0, -5.5), # | 5 | 10% | t |
+ (-110.0, -5.5), # | 5 | 10% | t |
+ # ---------------------------------------------------
+ ],
+ taxes.compute_all(-132.0)
+ )
+
+ def test_intracomm_taxes_computation_0(self):
+ ''' Test usage of intracomm taxes having e.g.+100%, -100% as repartition lines. '''
+ intracomm_tax = self.env['account.tax'].create({
+ 'name': 'test_intracomm_taxes_computation_0_1',
+ 'amount_type': 'percent',
+ 'amount': 21,
+ 'invoice_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}),
+ ],
+ })
+
+ # Test with positive amount.
+ self._check_compute_all_results(
+ 100, # 'total_included'
+ 100, # 'total_excluded'
+ [
+ # base , amount
+ # ---------------
+ (100.0, 21.0),
+ (100.0, -21.0),
+ # ---------------
+ ],
+ intracomm_tax.compute_all(100.0)
+ )
+
+ # Test with negative amount.
+ self._check_compute_all_results(
+ -100, # 'total_included'
+ -100, # 'total_excluded'
+ [
+ # base , amount
+ # ---------------
+ (-100.0, -21.0),
+ (-100.0, 21.0),
+ # ---------------
+ ],
+ intracomm_tax.compute_all(-100.0)
+ )
+
+ def test_rounding_issues_0(self):
+ ''' Test taxes having a complex setup of repartition lines. '''
+ tax = self.env['account.tax'].create({
+ 'name': 'test_rounding_issues_0',
+ 'amount_type': 'percent',
+ 'amount': 3,
+ 'invoice_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ ],
+ })
+
+ # Test with positive amount.
+ self._check_compute_all_results(
+ 1.09, # 'total_included'
+ 1, # 'total_excluded'
+ [
+ # base , amount
+ # ---------------
+ (1.0, 0.01),
+ (1.0, 0.01),
+ (1.0, 0.01),
+ (1.0, 0.02),
+ (1.0, 0.02),
+ (1.0, 0.02),
+ # ---------------
+ ],
+ tax.compute_all(1.0)
+ )
+
+ # Test with negative amount.
+ self._check_compute_all_results(
+ -1.09, # 'total_included'
+ -1, # 'total_excluded'
+ [
+ # base , amount
+ # ---------------
+ (-1.0, -0.01),
+ (-1.0, -0.01),
+ (-1.0, -0.01),
+ (-1.0, -0.02),
+ (-1.0, -0.02),
+ (-1.0, -0.02),
+ # ---------------
+ ],
+ tax.compute_all(-1.0)
+ )
+
+ def test_rounding_issues_1(self):
+ ''' Test taxes having a complex setup of repartition lines. '''
+ tax = self.env['account.tax'].create({
+ 'name': 'test_advanced_taxes_repartition_lines_computation_1',
+ 'amount_type': 'percent',
+ 'amount': 3,
+ 'invoice_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}),
+ ],
+ 'refund_repartition_line_ids': [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}),
+ ],
+ })
+
+ # Test with positive amount.
+ self._check_compute_all_results(
+ 1, # 'total_included'
+ 1, # 'total_excluded'
+ [
+ # base , amount
+ # ---------------
+ (1.0, 0.02),
+ (1.0, -0.02),
+ (1.0, 0.01),
+ (1.0, 0.01),
+ (1.0, -0.01),
+ (1.0, -0.01),
+ # ---------------
+ ],
+ tax.compute_all(1.0)
+ )
+
+ # Test with negative amount.
+ self._check_compute_all_results(
+ -1, # 'total_included'
+ -1, # 'total_excluded'
+ [
+ # base , amount
+ # ---------------
+ (-1.0, -0.02),
+ (-1.0, 0.02),
+ (-1.0, -0.01),
+ (-1.0, -0.01),
+ (-1.0, 0.01),
+ (-1.0, 0.01),
+ # ---------------
+ ],
+ tax.compute_all(-1.0)
)
diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml
index bd0cbd95093..f5443190945 100644
--- a/addons/account/views/account_move_views.xml
+++ b/addons/account/views/account_move_views.xml
@@ -61,6 +61,9 @@
<field name="currency_id" invisible="1"/>
<field name="amount_currency"/>
</group>
+ <group string="Product" attrs="{'invisible': [('product_id', '=', False)]}">
+ <field name="product_id"/>
+ </group>
<group string="States">
<field name="blocked"/>
</group>
@@ -611,7 +614,7 @@
<label for="invoice_date" string="Bill Date" style="font-weight:bold;"
attrs="{'invisible': [('type', 'not in', ('in_invoice', 'in_refund', 'in_receipt'))]}"/>
</div>
- <field name="invoice_date" nolabel="1"
+ <field name="invoice_date" nolabel="1" options="{'datepicker': {'warn_future': true}}"
attrs="{'invisible': [('type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt'))]}"/>
<field name="date" string="Accounting Date"
@@ -880,6 +883,8 @@
attrs="{'invisible': [('type', '!=', 'entry')], 'readonly': [('reversed_entry_id', '!=', False)]}"/>
<field name="reversed_entry_id"
attrs="{'invisible': [('type', '!=', 'entry')]}"/>
+ <field name="to_check"
+ attrs="{'invisible': [('type', '!=', 'entry')]}" />
</group>
<group>
<field name="company_id" groups="base.group_multi_company" required="1"/>
diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml
index b1fc237d77c..4269adb3fa9 100644
--- a/addons/account/views/report_invoice.xml
+++ b/addons/account/views/report_invoice.xml
@@ -182,7 +182,7 @@
<p t-if="o.fiscal_position_id.note" name="note">
<span t-field="o.fiscal_position_id.note"/>
</p>
- <div id="qrcode" t-if="(o.partner_id.country_id.code in ['BE', 'AT', 'DE', 'FI', 'NL']) and (o.company_id.qr_code) and (o.currency_id.name == 'EUR') and (o.invoice_partner_bank_id.acc_number != False)">
+ <div id="qrcode" t-if="(o.company_id.qr_code) and (o.currency_id.name == 'EUR') and (o.invoice_partner_bank_id.acc_number != False)">
<p t-if="(o.invoice_partner_bank_id.qr_code_valid)">
<strong class="text-center">Scan me with your banking app.</strong><br /><br />
<img class="border border-dark rounded" t-att-src="o.invoice_partner_bank_id.build_qr_code_url(o.amount_residual,(o.ref) if (o.ref) else o.name)"/>
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 @@
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.view_account_journal_form"/>
<field name="arch" type="xml">
+ <xpath expr="//field[@name='loss_account_id']/parent::group" position="attributes">
+ <attribute name="attrs">{'invisible': 0}</attribute>
+ </xpath>
<field name="loss_account_id" position="after">
<field name="update_posted" groups="base.group_no_one"/>
</field>
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 @@
<xpath expr="//button[@name='post']" position="before">
<button name="print_checks" class="oe_highlight" attrs="{'invisible': ['|', ('payment_method_code', '!=', 'check_printing'), ('state', '!=', 'posted')]}" string="Print Check" type="object"/>
<button name="unmark_sent" attrs="{'invisible': ['|', ('payment_method_code', '!=', 'check_printing'), ('state', '!=', 'sent')]}" string="Unmark Sent" type="object"/>
+ <button name="set_check_amount_in_words" attrs="{'invisible': ['|', ('payment_method_code', '!=', 'check_printing'), ('check_amount_in_words', '!=', False)]}" string="Reset Amount in Words" type="object" groups="base.group_no_one"/>
</xpath>
<xpath expr="//div[@name='amount_div']" position="after">
<field name="check_amount_in_words" attrs="{'invisible': [('payment_method_code', '!=', 'check_printing')], 'readonly': [('state', '!=', 'draft')]}" groups="base.group_no_one"/>
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 = """
<div>
<field name="country_enforce_cities" invisible="1"/>
+ <field name="parent_id" invisible="1"/>
<field name='city' placeholder="%(placeholder)s" class="o_address_city"
attrs="{
'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)],
diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py
index cb4bacbe3ce..9fc3feff1bd 100644
--- a/addons/base_import/models/base_import.py
+++ b/addons/base_import/models/base_import.py
@@ -445,7 +445,11 @@ class Import(models.TransientModel):
'date_format') else []
user_date_format = self.env['res.lang']._lang_get(self.env.user.lang).date_format
if user_date_format:
- date_patterns.append(user_date_format)
+ try:
+ to_re(user_date_format)
+ date_patterns.append(user_date_format)
+ except KeyError:
+ pass
date_patterns.extend(DATE_PATTERNS)
match = check_patterns(date_patterns, preview_values)
if match:
diff --git a/addons/bus/static/src/js/crosstab_bus.js b/addons/bus/static/src/js/crosstab_bus.js
index 46643cad0de..9d0e413ab94 100644
--- a/addons/bus/static/src/js/crosstab_bus.js
+++ b/addons/bus/static/src/js/crosstab_bus.js
@@ -80,6 +80,12 @@ var CrossTabBus = Longpolling.extend({
this._callLocalStorage('setItem', 'channels', this._channels);
},
/**
+ * @return {string}
+ */
+ getTabId: function () {
+ return this._id;
+ },
+ /**
* Tells whether this bus is related to the master tab.
*
* @returns {boolean}
diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py
index 45125f8aab4..74073286b36 100644
--- a/addons/calendar/models/calendar.py
+++ b/addons/calendar/models/calendar.py
@@ -1051,11 +1051,13 @@ class Meeting(models.Model):
meeting_attendees |= attendee
meeting_partners |= partner
- if meeting_attendees:
+ if meeting_attendees and not self._context.get('detaching'):
to_notify = meeting_attendees.filtered(lambda a: a.email != current_user.email)
to_notify._send_mail_to_attendees('calendar.calendar_template_meeting_invitation')
+ if meeting_attendees:
meeting.write({'attendee_ids': [(4, meeting_attendee.id) for meeting_attendee in meeting_attendees]})
+
if meeting_partners:
meeting.message_subscribe(partner_ids=meeting_partners.ids)
@@ -1397,7 +1399,7 @@ class Meeting(models.Model):
# do not copy the id
if data.get('id'):
del data['id']
- return meeting_origin.copy(default=data)
+ return meeting_origin.with_context(detaching=True).copy(default=data)
def action_detach_recurring_event(self):
meeting = self.detach_recurring_event()
diff --git a/addons/calendar/tests/test_calendar.py b/addons/calendar/tests/test_calendar.py
index 11e38a09c1d..6b7d7f1a550 100644
--- a/addons/calendar/tests/test_calendar.py
+++ b/addons/calendar/tests/test_calendar.py
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, time
from odoo import fields
from odoo.tests.common import TransactionCase
import pytz
+import re
class TestCalendar(TransactionCase):
@@ -384,3 +385,60 @@ class TestCalendar(TransactionCase):
})
self.assertEqual(str(activity_id.date_deadline), '2018-10-16')
+
+ def test_event_creation_mail(self):
+ """
+ Check that mail are sent to the attendees on event creation
+ Check that mail are sent to the added attendees on event edit
+ Check that mail are NOT sent to the attendees when detaching a recurring event
+ """
+
+ def _test_one_mail_per_attendee(self, m, partners):
+ # check that every attendee receive a (single) mail for the event
+ for partner in partners:
+ mail = self.env['mail.mail'].search([
+ ('recipient_ids', 'in', partner.id),
+ ('subject', 'like', m.name),
+ ])
+ self.assertEqual(len(mail), 1)
+
+ partners = [
+ self.env['res.partner'].create({'name':'testuser0','email': u'bob@example.com'}),
+ self.env['res.partner'].create({'name':'testuser1','email': u'alice@example.com'}),
+ ]
+ partner_ids = [(6, False, [p.id for p in partners]),]
+ now = fields.Datetime.now()
+ m = self.CalendarEvent.create({
+ 'name': "mailTest1",
+ 'allday': False,
+ 'rrule': u'FREQ=DAILY;INTERVAL=1;COUNT=5',
+ 'duration': 0.5,
+ 'partner_ids': partner_ids,
+ 'start': fields.Datetime.to_string(now + timedelta(days=10)),
+ 'stop': fields.Datetime.to_string(now + timedelta(days=15)),
+ })
+
+ # every partner should have 1 mail sent
+ _test_one_mail_per_attendee(self, m, partners)
+
+ # adding more partners to the event
+ partners.extend([
+ self.env['res.partner'].create({'name':'testuser2','email': u'marc@example.com'}),
+ self.env['res.partner'].create({'name':'testuser3','email': u'carl@example.com'}),
+ self.env['res.partner'].create({'name':'testuser4','email': u'alain@example.com'}),
+ ])
+ partner_ids = [(6, False, [p.id for p in partners]),]
+ m.write({'partner_ids': partner_ids})
+
+ # more email should be sent
+ _test_one_mail_per_attendee(self, m, partners)
+
+ # calculate virtualid to detach one event
+ virtid = str(m.id) + '-' + ''.join(re.split('[\D]', fields.Datetime.to_string(now + timedelta(days=12))))
+
+ # detaching a virtual event in the chain
+ self.env['calendar.event'].browse(virtid).detach_recurring_event(values={'active':False})
+
+ # since the detach actually create an event in the backend
+ # we check that no mail notifications are sent to the attendees
+ _test_one_mail_per_attendee(self, m, partners)
diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml
index aae443953ee..b00fd2358c4 100644
--- a/addons/calendar/views/calendar_views.xml
+++ b/addons/calendar/views/calendar_views.xml
@@ -202,7 +202,7 @@
<page name="page_invitations" string="Invitations" groups="base.group_no_one">
<button name="action_sendmail" type="object" string="Send mail" icon="fa-envelope" class="oe_link"/>
- <field name="attendee_ids" widget="one2many" >
+ <field name="attendee_ids" widget="one2many" mode="tree,kanban">
<tree string="Invitation details" editable="top" create="false" delete="false">
<field name="partner_id" />
<field name="state" />
@@ -212,6 +212,27 @@
<button name="do_accept" string="Accept" states="needsAction,tentative,declined" type="object" icon="fa-check text-success"/>
<button name="do_decline" string="Decline" states="needsAction,tentative,accepted" type="object" icon="fa-times-circle text-danger"/>
</tree>
+ <kanban class="o_kanban_mobile" create="false" delete="false">
+ <field name="partner_id" />
+ <field name="state" />
+ <field name="email" widget="email"/>
+
+ <templates>
+ <t t-name="kanban-box">
+ <div class="d-flex flex-column justify-content-between">
+ <field name="partner_id"/>
+ <field name="email" widget="email"/>
+ <span>Status: <field name="state" /></span>
+
+ <div class="text-right">
+ <button name="do_tentative" states="needsAction,declined,accepted" string="Uncertain" type="object" class="btn fa fa-asterisk"/>
+ <button name="do_accept" states="needsAction,tentative,declined" string="Accept" type="object" class="btn fa fa-check text-success"/>
+ <button name="do_decline" states="needsAction,tentative,accepted" string="Decline" type="object" class="btn fa fa-times-circle text-danger"/>
+ </div>
+ </div>
+ </t>
+ </templates>
+ </kanban>
</field>
</page>
<page name="page_misc" string="Misc" groups="base.group_no_one">
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 @@
<record id="goals_from_challenge_act" model="ir.actions.act_window">
<field name="res_model">gamification.goal</field>
<field name="name">Related Goals</field>
- <field name="view_mode">kanban,tree</field>
+ <field name="view_mode">kanban,tree,form</field>
<field name="context">{'search_default_group_by_definition': True, 'search_default_inprogress': True, 'search_default_challenge_id': active_id, 'default_challenge_id': active_id}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
@@ -61,7 +61,7 @@
<label for="user_domain" class="oe_edit_only" string="Assign Challenge To"/>
<div>
<field name="user_domain" widget="domain" options="{'model': 'res.users'}" />
- <field name="user_ids" groups="base.group_no_one" widget="many2many_tags" readonly="True" />
+ <field name="user_ids" widget="many2many_tags" />
</div>
</div>
@@ -171,7 +171,7 @@
<record id="challenge_list_action" model="ir.actions.act_window">
<field name="name">Challenges</field>
<field name="res_model">gamification.challenge</field>
- <field name="view_mode">kanban,tree,form</field>
+ <field name="view_mode">kanban,tree</field>
<field name="context">{'search_default_inprogress':True, 'default_inprogress':True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
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 @@
<field name="completeness"/>
<field name="state"/>
<field name="target_goal"/>
+ <field name="definition_description"/>
<field name="definition_condition"/>
<field name="definition_suffix"/>
<field name="definition_display"/>
@@ -143,10 +144,12 @@
<field name="last_update"/>
<templates>
<t t-name="kanban-tooltip">
- <field name="definition_description"/>
+ <ul class="oe_kanban_tooltip">
+ <li><t t-esc="record.definition_description.value" /></li>
+ </ul>
</t>
<t t-name="kanban-box">
- <div t-attf-class="oe_kanban_card o_kanban_gamification #{record.end_date.raw_value &lt; record.last_update.raw_value &amp; record.state.raw_value == 'failed' ? 'oe_kanban_color_2' : ''} #{record.end_date.raw_value &lt; record.last_update.raw_value &amp; record.state.raw_value == 'reached' ? 'oe_kanban_color_5' : ''}">
+ <div t-attf-class="oe_kanban_card oe_kanban_global_click o_kanban_gamification #{record.end_date.raw_value &lt; record.last_update.raw_value &amp; record.state.raw_value == 'failed' ? 'oe_kanban_color_2' : ''} #{record.end_date.raw_value &lt; record.last_update.raw_value &amp; record.state.raw_value == 'reached' ? 'oe_kanban_color_5' : ''}">
<div class="o_kanban_content">
<p><strong><h4 class="oe_goal_name text-center" tooltip="kanban-tooltip"><field name="definition_id" /></h4></strong></p>
<div class="float-left">
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 @@
<field name="user_email"/>
<field name="partner_name"/>
<field name="type_id"/>
+ <field name="partner_id"/>
<field name="job_id"/>
<field name="department_id"/>
<field name="message_needaction_counter"/>
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 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="uae_sale_tax_5_dubai" model="account.tax.template">
- <field name="name">TVA 5% (Dubai)</field>
+ <field name="name">VAT 5% (Dubai)</field>
<field name="type_tax_use">sale</field>
<field name="amount">5</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
+ <field name="description">VAT 5%</field>
<field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -36,11 +36,11 @@
]"/>
</record>
<record id="uae_sale_tax_5_abu_dhabi" model="account.tax.template">
- <field name="name">TVA 5% (Abu Dhabi)</field>
+ <field name="name">VAT 5% (Abu Dhabi)</field>
<field name="type_tax_use">sale</field>
<field name="amount">5</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
+ <field name="description">VAT 5%</field>
<field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -71,11 +71,11 @@
]"/>
</record>
<record id="uae_sale_tax_5_sharjah" model="account.tax.template">
- <field name="name">TVA 5% (Sharjah)</field>
+ <field name="name">VAT 5% (Sharjah)</field>
<field name="type_tax_use">sale</field>
<field name="amount">5</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
+ <field name="description">VAT 5%</field>
<field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -106,11 +106,11 @@
]"/>
</record>
<record id="uae_sale_tax_5_ajman" model="account.tax.template">
- <field name="name">TVA 5% (Ajman)</field>
+ <field name="name">VAT 5% (Ajman)</field>
<field name="type_tax_use">sale</field>
<field name="amount">5</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
+ <field name="description">VAT 5%</field>
<field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -141,11 +141,11 @@
]"/>
</record>
<record id="uae_sale_tax_5_umm_al_quwain" model="account.tax.template">
- <field name="name">TVA 5% (Umm Al Quwain)</field>
+ <field name="name">VAT 5% (Umm Al Quwain)</field>
<field name="type_tax_use">sale</field>
<field name="amount">5</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
+ <field name="description">VAT 5%</field>
<field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -176,11 +176,11 @@
]"/>
</record>
<record id="uae_sale_tax_5_ras_al_khaima" model="account.tax.template">
- <field name="name">TVA 5% (Ras Al-Khaima)</field>
+ <field name="name">VAT 5% (Ras Al-Khaima)</field>
<field name="type_tax_use">sale</field>
<field name="amount">5</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
+ <field name="description">VAT 5%</field>
<field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -211,11 +211,11 @@
]"/>
</record>
<record id="uae_sale_tax_5_fujairah" model="account.tax.template">
- <field name="name">TVA 5% (Fujairah)</field>
+ <field name="name">VAT 5% (Fujairah)</field>
<field name="type_tax_use">sale</field>
<field name="amount">5</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
+ <field name="description">VAT 5%</field>
<field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -277,11 +277,11 @@
]"/>
</record>
<record id="uae_sale_tax_0" model="account.tax.template">
- <field name="name">TVA 0%</field>
+ <field name="name">VAT 0%</field>
<field name="type_tax_use">sale</field>
<field name="amount">0</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 0%</field>
+ <field name="description">VAT 0%</field>
<field name="tax_group_id" ref="ae_tax_group_0"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -693,11 +693,11 @@
</record>
<!-- purchase taxes -->
<record id="uae_purchase_tax_5" model="account.tax.template">
- <field name="name">TVA 5%</field>
+ <field name="name">VAT 5%</field>
<field name="type_tax_use">purchase</field>
<field name="amount">5</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
+ <field name="description">VAT 5%</field>
<field name="tax_group_id" ref="ae_tax_group_5"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -757,11 +757,11 @@
]"/>
</record>
<record id="uae_purchase_tax_0" model="account.tax.template">
- <field name="name">TVA 0%</field>
+ <field name="name">VAT 0%</field>
<field name="type_tax_use">purchase</field>
<field name="amount">0</field>
<field name="amount_type">percent</field>
- <field name="description">TVA 0%</field>
+ <field name="description">VAT 0%</field>
<field name="tax_group_id" ref="ae_tax_group_0"/>
<field name="chart_template_id" ref="uae_chart_template_standard"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -817,28 +817,6 @@
}),
]"/>
</record>
- <record id="uae_purchase_tax_5_child_global" model="account.tax.template">
- <field name="name">TVA 5%</field>
- <field name="type_tax_use">none</field>
- <field name="amount">5</field>
- <field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
- <field name="account_id" ref="uae_account_3726"/>
- <field name="refund_account_id" ref="uae_account_3726"/>
- <field name="tax_group_id" ref="ae_tax_group_5"/>
- <field name="chart_template_id" ref="uae_chart_template_standard"/>
- </record>
- <record id="uae_purchase_tax_5_child_reverse" model="account.tax.template">
- <field name="name">Reverse Charge TVA +5%(Purchase)</field>
- <field name="type_tax_use">none</field>
- <field name="amount">5</field>
- <field name="amount_type">percent</field>
- <field name="description">TVA 5%</field>
- <field name="account_id" ref="uae_account_3726"/>
- <field name="refund_account_id" ref="uae_account_3726"/>
- <field name="tax_group_id" ref="ae_tax_group_5"/>
- <field name="chart_template_id" ref="uae_chart_template_standard"/>
- </record>
<record id="uae_purchase_tax_reverse_charge" model="account.tax.template">
<field name="name">Reverse Charge Provision</field>
<field name="type_tax_use">purchase</field>
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 @@
<field name="description">P_IVA21_SP_IN</field>
<field name="type_tax_use">purchase</field>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_10_11'), ref('mod_303_14_15_purchase'), ref('mod_303_36_37'), ref('mod_303_40_41')])]"/>
<field name="amount_type">percent</field>
<field name="amount" eval="21"/>
<field name="tax_group_id" ref="tax_group_iva_21"/>
@@ -551,7 +550,6 @@
<field name="type_tax_use">purchase</field>
<field name="amount_type">percent</field>
<field name="amount" eval="21"/>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_38_39'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
<field name="tax_group_id" ref="tax_group_iva_21"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -718,7 +716,6 @@
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_12_13'), ref('mod_303_14_15_purchase'), ref('mod_303_28_29'), ref('mod_303_40_41')])]"/>
<field name="name">IVA 4% Adquisición de servicios extracomunitarios</field>
<field name="tax_group_id" ref="tax_group_iva_4"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -770,7 +767,6 @@
<field name="description">P_IVA10_SP_EX</field>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
<field name="amount_type">percent</field>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_12_13'), ref('mod_303_14_15_purchase'), ref('mod_303_28_29'), ref('mod_303_40_41')])]"/>
<field name="type_tax_use">purchase</field>
<field name="name">IVA 10% Adquisición de servicios extracomunitarios</field>
<field name="tax_group_id" ref="tax_group_iva_10"/>
@@ -824,7 +820,6 @@
<field name="type_tax_use">purchase</field>
<field name="amount" eval="21"/>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_12_13'), ref('mod_303_14_15_purchase'), ref('mod_303_28_29'), ref('mod_303_40_41')])]"/>
<field name="amount_type">percent</field>
<field name="tax_group_id" ref="tax_group_iva_21"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
@@ -874,7 +869,6 @@
<field name="amount" eval="4"/>
<field name="description">P_IVA4_IC_BC</field>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_36_37'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/>
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
<field name="name">IVA 4% Adquisición Intracomunitario. Bienes corrientes</field>
@@ -930,7 +924,6 @@
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
<field name="name">IVA 4% Adquisición Intracomunitario. Bienes de inversión</field>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_38_39'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/>
<field name="tax_group_id" ref="tax_group_iva_4"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {
@@ -979,7 +972,6 @@
<field name="amount" eval="10"/>
<field name="description">P_IVA10_IC_BC</field>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_36_37'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/>
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
<field name="name">IVA 10% Adquisición Intracomunitario. Bienes corrientes</field>
@@ -1034,7 +1026,6 @@
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
<field name="name">IVA 10% Adquisición Intracomunitario. Bienes de inversión</field>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_38_39'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/>
<field name="tax_group_id" ref="tax_group_iva_10"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {
@@ -3077,7 +3068,6 @@
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
<field name="name">IVA 10% Adquisición de servicios intracomunitarios</field>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_10_11'), ref('mod_303_14_15_purchase'), ref('mod_303_36_37'), ref('mod_303_40_41')])]"/>
<field name="tax_group_id" ref="tax_group_iva_10"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {
@@ -3129,7 +3119,6 @@
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
<field name="name">IVA 4% Adquisición de servicios intracomunitarios</field>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_10_11'), ref('mod_303_14_15_purchase'), ref('mod_303_36_37'), ref('mod_303_40_41')])]"/>
<field name="tax_group_id" ref="tax_group_iva_4"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
(0,0, {
@@ -3403,7 +3392,6 @@
<field name="description">P_IVA4_ISP</field>
<field name="name">IVA 4% Compra con Inversión del Sujeto Pasivo Nacional</field>
<field name="type_tax_use">purchase</field>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_28_29'), ref('mod_303_40_41'), ref('mod_303_12_13'), ref('mod_303_14_15_purchase')])]"/>
<field name="amount_type">percent</field>
<field name="amount" eval="4"/>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
@@ -3455,7 +3443,6 @@
<field name="description">P_IVA10_ISP</field>
<field name="name">IVA 10% Compra con Inversión del Sujeto Pasivo Nacional</field>
<field name="type_tax_use">purchase</field>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_28_29'), ref('mod_303_40_41'), ref('mod_303_12_13'), ref('mod_303_14_15_purchase')])]"/>
<field name="amount_type">percent</field>
<field name="amount" eval="10"/>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
@@ -3509,7 +3496,6 @@
<field name="type_tax_use">purchase</field>
<field name="amount_type">percent</field>
<field name="amount" eval="21"/>
- <field name="tag_ids" eval="[(6, False, [ref('mod_303_28_29'), ref('mod_303_40_41'), ref('mod_303_12_13'), ref('mod_303_14_15_purchase')])]"/>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
<field name="tax_group_id" ref="tax_group_iva_21"/>
<field name="invoice_repartition_line_ids" eval="[(5, 0, 0),
diff --git a/addons/l10n_in/views/report_invoice.xml b/addons/l10n_in/views/report_invoice.xml
index 029e3bd726d..0871aed87d7 100644
--- a/addons/l10n_in/views/report_invoice.xml
+++ b/addons/l10n_in/views/report_invoice.xml
@@ -5,11 +5,11 @@
<xpath expr="//table[@name='invoice_line_table']" position="before">
<t t-if="o.company_id.country_id.code == 'IN'">
<!-- Tag wise Tax Calculation -->
- <t t-set="tag_cgst_id" t-value="env.ref('l10n_in.cgst_tag_tax').id"/>
- <t t-set="tag_sgst_id" t-value="env.ref('l10n_in.sgst_tag_tax').id"/>
- <t t-set="tag_igst_id" t-value="env.ref('l10n_in.igst_tag_tax').id"/>
- <t t-set="tag_cess_id" t-value="env.ref('l10n_in.cess_tag_tax').id"/>
- <t t-set="tax_map" t-value="o.invoice_line_ids.mapped('tax_ids.tag_ids.id')"/>
+ <t t-set="tag_cgst_id" t-value="env.ref('l10n_in.tax_report_line_cgst').id"/>
+ <t t-set="tag_sgst_id" t-value="env.ref('l10n_in.tax_report_line_sgst').id"/>
+ <t t-set="tag_igst_id" t-value="env.ref('l10n_in.tax_report_line_igst').id"/>
+ <t t-set="tag_cess_id" t-value="env.ref('l10n_in.tax_report_line_cess').id"/>
+ <t t-set="tax_map" t-value="o.tax_line_ids.mapped('tag_ids.tax_report_line_ids').ids"/>
<t t-set="is_igst" t-value="tag_igst_id in tax_map"/>
<t t-set="is_cgst" t-value="tag_cgst_id in tax_map"/>
<t t-set="is_cess" t-value="tag_cess_id in tax_map"/>
@@ -43,21 +43,21 @@
<t t-if="o.company_id.country_id.code == 'IN'">
<td class="text-right" t-if="is_cgst">
<t t-foreach="tax_datas[line.id]" t-as="tax_data">
- <t t-if="tag_cgst_id in tax_data['tag_ids']">
+ <t t-if="tag_cgst_id in tax_data['report_line_ids']">
<t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</t>
</t>
</td>
<td class="text-right" t-if="is_cgst">
<t t-foreach="tax_datas[line.id]" t-as="tax_data">
- <t t-if="tag_sgst_id in tax_data['tag_ids']">
+ <t t-if="tag_sgst_id in tax_data['report_line_ids']">
<t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</t>
</t>
</td>
<td class="text-right" t-if="is_igst">
<t t-foreach="tax_datas[line.id]" t-as="tax_data">
- <t t-if="tag_igst_id in tax_data['tag_ids']">
+ <t t-if="tag_igst_id in tax_data['report_line_ids']">
<t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</t>
</t>
@@ -65,7 +65,7 @@
<td class="text-right" t-if="is_cess">
<t t-set="total_cess" t-value="0.0"/>
<t t-foreach="tax_datas[line.id]" t-as="tax_data">
- <t t-if="tag_cess_id in tax_data['tag_ids']">
+ <t t-if="tag_cess_id in tax_data['report_line_ids']">
<t t-set="total_cess" t-value="total_cess + tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</t>
</t>
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 @@
<odoo>
<!-- account chart -->
<record id="l10n_kzc_temp_chart" model="account.chart.template">
- <field name="property_account_receivable_id" ref="pcg_3488"/>
- <field name="property_account_payable_id" ref="pcg_4488"/>
+ <field name="property_account_receivable_id" ref="pcg_34211"/>
+ <field name="property_account_payable_id" ref="pcg_4411"/>
<field name="property_account_income_categ_id" ref="pcg_7111"/>
- <field name="property_account_expense_categ_id" ref="pcg_1486"/>
+ <field name="property_account_expense_categ_id" ref="pcg_6111"/>
<field name="income_currency_exchange_account_id" ref="pcg_733"/>
<field name="expense_currency_exchange_account_id" ref="pcg_633"/>
<field name="default_pos_receivable_account_id" ref="pcg_3489" />
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 @@
<record id="pcg_34211" model="account.account.template">
<field name="name">Clients - catégorie A</field>
<field name="code">34211</field>
- <field name="user_type_id" ref="account.data_account_type_current_assets"/>
+ <field name="user_type_id" ref="account.data_account_type_receivable"/>
+ <field name="reconcile" eval='True'/>
<field name="chart_template_id" ref="l10n_kzc_temp_chart"/>
</record>
<record id="pcg_34212" model="account.account.template">
<field name="name">Clients - catégorie B</field>
<field name="code">34212</field>
- <field name="user_type_id" ref="account.data_account_type_current_assets"/>
+ <field name="user_type_id" ref="account.data_account_type_receivable"/>
+ <field name="reconcile" eval='True'/>
<field name="chart_template_id" ref="l10n_kzc_temp_chart"/>
</record>
<record id="pcg_3423" model="account.account.template">
@@ -1370,7 +1372,7 @@
<record id="pcg_3488" model="account.account.template">
<field name="name">Divers débiteurs</field>
<field name="code">3488</field>
- <field name="user_type_id" ref="account.data_account_type_receivable"/>
+ <field name="user_type_id" ref="account.data_account_type_current_assets"/>
<field name="reconcile" eval='True'/>
<field name="chart_template_id" ref="l10n_kzc_temp_chart"/>
</record>
@@ -1522,7 +1524,8 @@
<record id="pcg_4411" model="account.account.template">
<field name="name">Fournisseurs</field>
<field name="code">4411</field>
- <field name="user_type_id" ref="account.data_account_type_current_liabilities"/>
+ <field name="user_type_id" ref="account.data_account_type_payable"/>
+ <field name="reconcile" eval='True'/>
<field name="chart_template_id" ref="l10n_kzc_temp_chart"/>
</record>
<record id="pcg_4413" model="account.account.template">
@@ -1768,7 +1771,7 @@
<record id="pcg_4488" model="account.account.template">
<field name="name">Divers créanciers</field>
<field name="code">4488</field>
- <field name="user_type_id" ref="account.data_account_type_payable"/>
+ <field name="user_type_id" ref="account.data_account_type_current_liabilities"/>
<field name="chart_template_id" ref="l10n_kzc_temp_chart"/>
<field name="reconcile" eval='True'/>
</record>
@@ -2072,6 +2075,12 @@
<field name="user_type_id" ref="account.data_account_type_expenses"/>
<field name="chart_template_id" ref="l10n_kzc_temp_chart"/>
</record>
+ <record id="pcg_61263" model="account.account.template">
+ <field name="name">Achats des prestations de service</field>
+ <field name="code">61263</field>
+ <field name="user_type_id" ref="account.data_account_type_expenses"/>
+ <field name="chart_template_id" ref="l10n_kzc_temp_chart"/>
+ </record>
<record id="pcg_6128" model="account.account.template">
<field name="name">Achats de matières et de fournitures des exercices antérieurs</field>
<field name="code">6128</field>
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 (index < 0) {
- domain.push(subDomain);
- } else {
- domain[index] = subDomain;
+ 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;
+ }
+
+ 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: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="name"/></div>
+ </t>
+ </templates>
+ </kanban>
+ `,
+ 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');
});
@@ -130,24 +140,6 @@ var ThreadWindow = AbstractThreadWindow.extend({
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(/&lt;/g, openToken);
- var children = $('<div>').html(string).contents();
+ var children;
+ try {
+ children = $('<div>').html(string).contents();
+ } catch (e) {
+ children = $('<div>').html('<pre>' + string + '</pre>').contents();
+ }
return _parseAndTransform(children, transformFunction)
.replace(new RegExp(openToken, "g"), "&lt;");
}
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: "<p>Left #general</p>",
+ 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 @@
<group name="activity_details">
<field name="active" invisible="1"/>
<field name="category"/>
+ <field name="default_user_id" widget="selection"/>
<field name="res_model_id" groups="base.group_no_one"/>
<field name="res_model_change" invisible="1"/>
<field name="initial_res_model_id" invisible="1"/>
<field name="summary"/>
<field name="icon" groups="base.group_no_one"/>
<field name="decoration_type" groups="base.group_no_one"/>
- <label for="delay_count"/>
- <div>
- <div class="o_row">
- <field name="delay_count"/>
- <field name="delay_unit"/>
- </div>
- <field name="delay_from"/>
- </div>
- <field name="default_user_id" widget="selection"/>
</group>
<group name="activity_planning">
<field name="force_next"/>
@@ -43,6 +35,14 @@
</group>
<group>
<field name="default_description"/>
+ <label for="delay_count"/>
+ <div>
+ <div class="o_row">
+ <field name="delay_count"/>
+ <field name="delay_unit"/>
+ </div>
+ <field name="delay_from"/>
+ </div>
</group>
</group>
<p class="alert alert-info" role="alert" attrs="{'invisible': [('res_model_change', '=', False)]}">Modifying the model can have an impact on existing activities using this activity type, be careful.</p>
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 @@
<templates>
<t t-name="kanban-tooltip">
<ul class="oe_kanban_tooltip">
- <li><b>Category:</b> <field name="category_id"/></li>
- <li><b>Request to:</b> <field name="user_id"/></li>
+ <li t-if="record.category_id.raw_value"><b>Category:</b> <t t-esc="record.category_id.value"/></li>
+ <li t-if="record.user_id.raw_value"><b>Request to:</b> <t t-esc="record.user_id.value"/></li>
</ul>
</t>
<t t-name="kanban-box">
@@ -430,8 +430,8 @@
<templates>
<t t-name="kanban-tooltip">
<ul class="oe_kanban_tooltip">
- <li><b>Serial Number:</b> <field name="serial_no"/></li>
- <li><b>Model Number:</b> <field name="model"/></li>
+ <li t-if="record.serial_no.raw_value"><b>Serial Number:</b> <t t-esc="record.serial_no.value"/></li>
+ <li t-if="record.model.raw_value"><b>Model Number:</b> <t t-esc="record.model.value"/></li>
</ul>
</t>
<t t-name="kanban-box">
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 @@
<div t-if="payment_tx_id.acquirer_id.provider == 'transfer' and reference">
<b>Communication: </b><span t-esc='reference'/>
</div>
- <div t-if="(payment_tx_id.partner_id.country_id.code in ['BE', 'AT', 'DE', 'FI', 'NL']) and (payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.currency_id.name == 'EUR')">
+ <div t-if="(payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.currency_id.name == 'EUR')">
<div t-if="payment_tx_id.acquirer_id.journal_id.bank_account_id.qr_code_valid">
<h3>Or scan me with your banking app.</h3>
<img class="border border-dark rounded" t-att-src="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(payment_tx_id.amount,payment_tx_id.reference)"/>
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 @@
<group attrs="{'invisible': [('provider', '!=', 'sips')]}">
<field name="sips_merchant_id" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
<field name="sips_secret" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
- <field name="sips_test_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
- <field name="sips_prod_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
- <field name="sips_version" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
+ <field name="sips_test_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/>
+ <field name="sips_prod_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/>
+ <field name="sips_version" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/>
</group>
</xpath>
</field>
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 @@
<group name="conditions" string="Conditions">
<field name="active" invisible="1"/>
<field name="program_type" invisible="1"/>
- <field name="rule_products_domain" placeholder="Select product" widget="domain" options="{'model': 'product.product'}"/>
+ <field name="rule_products_domain" placeholder="Select product" widget="domain" options="{'model': 'product.product', 'in_dialog': true}"/>
<label string="Quantity" for="rule_min_quantity" attrs="{'invisible': [('rule_products_domain', '=', False)]}"/>
<div attrs="{'invisible': [('rule_products_domain', '=',False)]}">
<field name="rule_min_quantity" class="oe_inline"/>
@@ -211,7 +211,7 @@
<field name="sequence" invisible="1"/>
</xpath>
<xpath expr="//field[@name='program_type']" position="after">
- <field name="rule_partners_domain" placeholder="Select customer" widget="domain" options="{'model': 'res.partner'}"/>
+ <field name="rule_partners_domain" placeholder="Select customer" widget="domain" options="{'model': 'res.partner', 'in_dialog': true}"/>
</xpath>
<xpath expr="//div[@name='rule_minimum_amount']" position="after">
<field name="promo_code_usage" widget="radio"/>
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
@@ -45,6 +45,19 @@ var AbstractStorageService = AbstractService.extend({
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
*
* @param {string} key
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
@@ -42,11 +43,21 @@ var RamStorage = Class.extend(mixins.EventDispatcherMixin, {
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($('<option/>', {
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 = $('<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: '<form string="Partners"><field name="date"/></form>',
+ 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: '<form string="Partners"><field name="date"/></form>',
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: '<form><field name="product_ids" mode="kanban"/></form>',
+ archs: {
+ "product,false,kanban": `<kanban>
+ <templates><t t-name="kanban-box">
+ <div><field name="name"/></div>
+ </t></templates>
+ </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: `<form>
+ <field name="foo"/>
+ <field name="bar" widget="toggle_button"/>
+ </form>`,
+ 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: '<graph string="Partners">' +
+ '<field name="id"/>' +
+ '</graph>',
+ 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: '<kanban class="o_kanban_test">' +
+ '<field name="color"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div color="color">' +
+ '<div class="o_dropdown_kanban dropdown">' +
+ '<a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#">' +
+ '<span class="fa fa-bars fa-lg"/>' +
+ '</a>' +
+ '<ul class="dropdown-menu" role="menu">' +
+ '<li>' +
+ '<ul class="oe_kanban_colorpicker"/>' +
+ '</li>' +
+ '</ul>' +
+ '</div>' +
+ '<field name="name" widget="test_widget"/>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>',
+ });
+
+ // 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: `<tree editable="bottom" expand="1">
+ <field name="foo"/>
+ <field name="currency_id"/>
+ <groupby name="currency_id">
+ <field name="position" invisible="1"/>
+ </groupby>
+ </tree>`,
+ 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: '<tree editable="top"><field name="foo"/><field name="m2o"/></tree>',
+ groupBy: ['bar'],
+ archs: {
+ 'bar,false,list': '<tree><field name="cornichon"/></tree>',
+ 'bar,false,search': '<search><filter context="{\'group_by\': \'cornichon\'}" string="cornichon"/></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 = '<script>throw Error();</script>';
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
- ]
-
- 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
- ]
+ 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
+ ]
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 @@
<h5>Connect with us</h5>
<ul class="list-unstyled">
<li><a href="/contactus">Contact us</a></li>
- <li><i class="fa fa-phone"/> <span t-field="res_company.phone"/></li>
+ <li><i class="fa fa-phone"/> <span class="o_force_ltr" t-field="res_company.phone"/></li>
<li><i class="fa fa-envelope"/> <span t-field="res_company.email"/></li>
</ul>
<p>
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).
<div t-if="len(blogs) > 1">in <a t-attf-href="#{blog_url(blog=blog_post.blog_id)}"><b t-field="blog.name"/></a></div>
<div t-if="len(blog_post.tag_ids) > 0">#
<t t-foreach="blog_post.tag_ids" t-as="one_tag">
- <a class="badge border mr-1" t-attf-href="#{blog_url(tag=one_tag.id, date_begin=False, date_end=False)}" t-esc="one_tag.name"/>
+ <a class="badge border mr-1" t-attf-href="#{blog_url(tag=slug(one_tag), date_begin=False, date_end=False)}" t-esc="one_tag.name"/>
</t>
</div>
</div>
@@ -571,7 +571,7 @@ list of filtered posts (by date or tag).
<title t-esc="blog.name"/>
<link t-att-href="'%s/blog/%s' % (base_url ,blog.id)"/>
<id t-esc="'%s/blog/%s' % (base_url, blog.id)"/>
- <updated t-esc="str((posts[0] if posts else blog).post_date).replace(' ', 'T') + 'Z'"/>
+ <updated t-esc="str(posts[0].post_date).replace(' ', 'T') + 'Z' if posts else ''"/>
<entry t-foreach="posts" t-as="post">
<title t-esc="post.name"/>
<link t-att-href="'%s%s' % (base_url, post.website_url)"/>
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 @@
<div t-if="payment_tx_id.acquirer_id.provider == 'transfer' and order.reference" class="card-body">
<b>Communication: </b><span t-esc='order.reference'/>
</div>
- <div t-if="(payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.acquirer_id.provider == 'transfer') and (payment_tx_id.partner_id.country_id.code in ['BE', 'AT', 'DE', 'FI', 'NL']) and (payment_tx_id.currency_id.name == 'EUR')">
+ <div t-if="(payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.acquirer_id.provider == 'transfer') and (payment_tx_id.currency_id.name == 'EUR')">
<div class="card-body" t-if="payment_tx_id.acquirer_id.journal_id.bank_account_id.qr_code_valid">
<h3>Or scan me with your banking app.</h3>
<img class="border border-dark rounded" t-att-src="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(order.amount_total,payment_tx_id.reference)"/>
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 @@
</xpath>
</template>
<template id="website_sale_coupon_cart_hide_qty" inherit_id="website_sale.cart_lines">
+ <xpath expr="//td[hasclass('td-img')]/span" position="replace">
+ <t t-if="line.is_reward_line and line.sudo().product_id.image_64">
+ <span>
+ <img t-att-src="image_data_uri(line.sudo().product_id.image_64)" class="img img-fluid rounded" t-att-alt="line.name_short"/>
+ </span>
+ </t>
+ <t t-else="">
+ <t>$0</t>
+ </t>
+ </xpath>
<xpath expr="//td[hasclass('td-qty')]/div" position="attributes">
<attribute name="t-att-style">line.is_reward_line and "display:none;"</attribute>
</xpath>
@@ -24,6 +34,18 @@
<attribute name="t-if">not line.is_reward_line</attribute>
</xpath>
</template>
+<template id="cart_popover" inherit_id="website_sale.cart_popover" name="Cart Popover">
+ <xpath expr="//div[hasclass('cart_line')]/div[1]/span" position="replace">
+ <t t-if="line.is_reward_line and line.sudo().product_id.image_64">
+ <span>
+ <img t-att-src="image_data_uri(line.sudo().product_id.image_64)" class="img img-fluid rounded" t-att-alt="line.name_short"/>
+ </span>
+ </t>
+ <t t-else="">
+ <t>$0</t>
+ </t>
+ </xpath>
+</template>
<template id="sale_coupon_result" inherit_id="website_sale.coupon_form">
<xpath expr="//form[@name='coupon_code']" position="after">
<t t-if="website_sale_order and website_sale_order.applied_coupon_ids">
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 ""
+"<span class=\"o_stat_text\">Convert To</span>\n"
+" <span class=\"o_stat_text\">Task</span>"
+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 = $('<script/>', {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 = $('<script/>', {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/postgresql/binaries>
+
+ 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
@@ -1113,6 +1113,12 @@ A domain is a list of criteria, each criterion being a triple (either a
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`).
``value``
variable type, must be comparable (through ``operator``) to the named
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 @@
<field name="author">Odoo S.A.</field>
<field name="to_buy" eval="True"/>
<field name="icon">/base/static/img/icons/project_forecast.png</field>
- <field name="website">https://www.odoo.com/page/project?utm_source=db&amp;utm_medium=module</field>
+ <field name="website">https://www.odoo.com/page/project-management?utm_source=db&amp;utm_medium=module</field>
</record>
<record model="ir.module.module" id="base.module_sale_subscription">
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 <r.g@grosbedon.fr>"``"""
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 @@
<span itemprop="addressCountry" t-esc="country_id"/>
</div>
</div>
- <div t-if="phone and 'phone' in fields"><i t-if="not options.get('no_marker') or options.get('phone_icons')" class='fa fa-phone fa-fw' role="img" aria-label="Phone" title="Phone"/> <span itemprop="telephone" t-esc="phone"/></div>
- <div t-if="mobile and 'mobile' in fields"><i t-if="not options.get('no_marker') or options.get('phone_icons')" class='fa fa-mobile fa-fw' role="img" aria-label="Mobile" title="Mobile"/> <span itemprop="telephone" t-esc="mobile"/></div>
- <div t-if="fax and 'fax' in fields"><i t-if="not options.get('no_marker') or options.get('phone_icons')" class='fa fa-fax fa-fw' role="img" aria-label="Fax" title="Fax"/> <span itemprop="faxNumber" t-esc="fax"/></div>
+ <div t-if="phone and 'phone' in fields"><i t-if="not options.get('no_marker') or options.get('phone_icons')" class='fa fa-phone fa-fw' role="img" aria-label="Phone" title="Phone"/> <span class="o_force_ltr" itemprop="telephone" t-esc="phone"/></div>
+ <div t-if="mobile and 'mobile' in fields"><i t-if="not options.get('no_marker') or options.get('phone_icons')" class='fa fa-mobile fa-fw' role="img" aria-label="Mobile" title="Mobile"/> <span class="o_force_ltr" itemprop="telephone" t-esc="mobile"/></div>
+ <div t-if="fax and 'fax' in fields"><i t-if="not options.get('no_marker') or options.get('phone_icons')" class='fa fa-fax fa-fw' role="img" aria-label="Fax" title="Fax"/> <span class="o_force_ltr" itemprop="faxNumber" t-esc="fax"/></div>
<div t-if="website and 'website' in fields">
<i t-if="not options.get('no_marker')" class='fa fa-globe' role="img" aria-label="Website" title="Website"/>
<a t-att-href="website and '%s%s' % ('http://' if '://' not in website else '',website)"><span itemprop="website" t-esc="website"/></a>
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',
])