summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-01-31 16:59:58 +0100
committerChristophe Simonis <chs@odoo.com>2019-01-31 16:59:58 +0100
commitf927c68ddbe4dccbdd0174019d6028d30b0d8058 (patch)
treeaf24ebffe8241ee0c6ec9c911f2fe15f54237bcc /addons
parent24ba5ae276a7c25cdcd4d2db2e17e032cfa7e52a (diff)
parentcb8fefa8999bd0056c2191edbdbbb85918d221da (diff)
[MERGE] forward port branch 12.0 up to cb8fefa8999
Diffstat (limited to 'addons')
-rw-r--r--addons/account/models/account.py14
-rw-r--r--addons/account/models/account_bank_statement.py10
-rw-r--r--addons/account/models/account_invoice.py26
-rw-r--r--addons/account/models/account_journal_dashboard.py47
-rw-r--r--addons/account/models/account_move.py43
-rw-r--r--addons/account/models/account_payment.py9
-rw-r--r--addons/account/models/chart_template.py2
-rw-r--r--addons/account/models/company.py7
-rw-r--r--addons/account/models/partner.py2
-rw-r--r--addons/account/models/reconciliation_widget.py22
-rw-r--r--addons/account/report/account_aged_partner_balance.py2
-rw-r--r--addons/account/static/src/js/account_payment_field.js4
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_model.js11
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_renderer.js10
-rw-r--r--addons/account/static/src/xml/account_payment.xml4
-rw-r--r--addons/account/static/src/xml/account_reconciliation.xml4
-rw-r--r--addons/account/static/tests/reconciliation_tests.js4
-rw-r--r--addons/account/tests/test_account_customer_invoice.py173
-rw-r--r--addons/account/tests/test_payment.py34
-rw-r--r--addons/account/views/account_view.xml3
-rw-r--r--addons/account/wizard/account_invoice_send.py12
-rw-r--r--addons/attachment_indexation/models/ir_attachment.py5
-rw-r--r--addons/base_address_city/models/res_partner.py12
-rw-r--r--addons/base_gengo/wizard/base_gengo_translations.py1
-rw-r--r--addons/bus/models/res_partner.py5
-rw-r--r--addons/crm/views/crm_lead_views.xml1
-rw-r--r--addons/delivery/models/delivery_carrier.py2
-rw-r--r--addons/delivery/wizard/choose_delivery_package.py7
-rw-r--r--addons/delivery/wizard/choose_delivery_package_views.xml2
-rw-r--r--addons/fleet/models/fleet_vehicle.py10
-rw-r--r--addons/hr_fleet/controllers/i18n/fil.po115
-rw-r--r--addons/hr_holidays/models/hr_leave.py3
-rw-r--r--addons/l10n_be_hr_payroll_fleet/models/fleet.py33
-rw-r--r--addons/l10n_fr/data/account_chart_template_data.xml2
-rw-r--r--addons/l10n_nl/data/account_account_tag.xml13
-rw-r--r--addons/l10n_nl/data/account_tax_template.xml2
-rw-r--r--addons/mail/data/mail_data.xml4
-rw-r--r--addons/mail/models/mail_message.py10
-rw-r--r--addons/mail/models/mail_thread.py5
-rw-r--r--addons/mail/static/src/js/activity.js2
-rw-r--r--addons/mail/static/src/js/chatter.js7
-rw-r--r--addons/mail/static/src/js/composers/basic_composer.js3
-rw-r--r--addons/mail/static/src/js/services/mail_window_manager.js44
-rw-r--r--addons/mail/static/src/js/systray/systray_activity_menu.js2
-rw-r--r--addons/mail/static/src/js/thread_windows/thread_window.js6
-rw-r--r--addons/mail/static/src/scss/followers.scss2
-rw-r--r--addons/mail/static/tests/document_thread_window_tests.js31
-rw-r--r--addons/mass_mailing/models/__init__.py1
-rw-r--r--addons/mass_mailing/models/mail_template.py22
-rw-r--r--addons/mass_mailing/models/mass_mailing.py52
-rw-r--r--addons/mass_mailing/tests/__init__.py1
-rw-r--r--addons/mass_mailing/tests/test_mass_mailing_shortener.py132
-rw-r--r--addons/mrp/models/mrp_production.py14
-rw-r--r--addons/mrp/models/mrp_unbuild.py16
-rw-r--r--addons/mrp/views/mrp_unbuild_views.xml4
-rw-r--r--addons/mrp/views/mrp_workcenter_views.xml2
-rw-r--r--addons/pad/models/pad.py1
-rw-r--r--addons/payment/controllers/portal.py5
-rw-r--r--addons/payment/views/payment_portal_templates.xml2
-rw-r--r--addons/point_of_sale/models/pos_order.py12
-rw-r--r--addons/point_of_sale/static/src/js/models.js20
-rw-r--r--addons/point_of_sale/static/src/js/screens.js11
-rw-r--r--addons/portal/static/src/scss/portal.scss13
-rw-r--r--addons/portal/views/portal_templates.xml2
-rw-r--r--addons/pos_cache/models/pos_cache.py1
-rw-r--r--addons/product/report/product_product_templates.xml155
-rw-r--r--addons/rating/models/mail_message.py6
-rw-r--r--addons/resource/models/resource.py3
-rw-r--r--addons/sale/data/mail_data.xml2
-rw-r--r--addons/sale/models/analytic.py5
-rw-r--r--addons/sale/models/sale.py16
-rw-r--r--addons/sale/views/sale_views.xml7
-rw-r--r--addons/sale_expense/models/analytic.py2
-rw-r--r--addons/sale_timesheet/models/sale_order.py2
-rw-r--r--addons/stock/models/stock_move_line.py2
-rw-r--r--addons/stock/models/stock_scrap.py3
-rw-r--r--addons/stock/models/stock_warehouse.py2
-rw-r--r--addons/stock/report/report_stockpicking_operations.xml2
-rw-r--r--addons/stock/tests/test_move.py18
-rw-r--r--addons/stock/views/stock_picking_views.xml2
-rw-r--r--addons/stock_account/models/product.py53
-rw-r--r--addons/stock_account/models/stock.py9
-rw-r--r--addons/stock_account/tests/test_stockvaluation.py34
-rw-r--r--addons/stock_landed_costs/models/stock_landed_cost.py3
-rw-r--r--addons/survey/models/survey_user.py1
-rw-r--r--addons/survey/wizard/survey_invite.py4
-rw-r--r--addons/test_mail/tests/test_mail_channel.py10
-rw-r--r--addons/test_mass_mailing/tests/test_performance.py4
-rw-r--r--addons/web/controllers/main.py20
-rw-r--r--addons/web/models/models.py3
-rw-r--r--addons/web/static/lib/tempusdominus/tempusdominus.js7
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js6
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js1
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js6
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_filters.js6
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js9
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js46
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js10
-rw-r--r--addons/web/static/src/js/widgets/data_export.js2
-rw-r--r--addons/web/static/src/js/widgets/date_picker.js1
-rw-r--r--addons/web/static/src/scss/base_settings.scss2
-rw-r--r--addons/web/static/src/scss/form_view.scss1
-rw-r--r--addons/web/static/src/scss/kanban_view.scss4
-rw-r--r--addons/web/static/src/scss/navbar_mobile.scss5
-rw-r--r--addons/web/static/src/xml/base.xml2
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js41
-rw-r--r--addons/web/static/tests/fields/field_utils_tests.js56
-rw-r--r--addons/web/static/tests/views/calendar_tests.js38
-rw-r--r--addons/web/static/tests/views/form_tests.js202
-rw-r--r--addons/web/tests/__init__.py1
-rw-r--r--addons/web/tests/test_image.py19
-rw-r--r--addons/web/views/report_templates.xml2
-rw-r--r--addons/web_editor/controllers/main.py20
-rw-r--r--addons/web_editor/static/src/js/widgets/ace.js13
-rw-r--r--addons/web_editor/static/src/scss/web_editor.ui.scss5
-rw-r--r--addons/website/controllers/web_editor.py5
-rw-r--r--addons/website/models/ir_ui_view.py5
-rw-r--r--addons/website/models/website.py2
-rw-r--r--addons/website/static/src/js/content/menu.js4
-rw-r--r--addons/website/static/src/js/html_editor.test.js143
-rw-r--r--addons/website/static/src/js/widgets/ace.js32
-rw-r--r--addons/website/tests/test_ui.py29
-rw-r--r--addons/website/views/website_navbar_templates.xml2
-rw-r--r--addons/website/views/website_templates.xml6
-rw-r--r--addons/website_forum/models/forum.py1
-rw-r--r--addons/website_sale/static/src/js/website_sale_payment.js5
-rw-r--r--addons/website_sale/views/templates.xml3
-rw-r--r--addons/website_sale_delivery/static/src/js/website_sale_delivery.js9
-rw-r--r--addons/website_sale_stock/static/src/js/product_configurator_mixin.js4
-rw-r--r--addons/website_slides/controllers/main.py2
130 files changed, 1855 insertions, 353 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py
index d7613478ce6..872bd8f13d0 100644
--- a/addons/account/models/account.py
+++ b/addons/account/models/account.py
@@ -67,12 +67,11 @@ class AccountAccount(models.Model):
@api.constrains('user_type_id')
def _check_user_type_id(self):
data_unaffected_earnings = self.env.ref('account.data_unaffected_earnings')
- for company in self.mapped('company_id'):
- account_unaffected_earnings = self.search([
- ('company_id', '=', company.id),
- ('user_type_id', '=', data_unaffected_earnings.id),
- ])
- if len(account_unaffected_earnings) >= 2:
+ result = self.read_group([('user_type_id', '=', data_unaffected_earnings.id)], ['company_id'], ['company_id'])
+ for res in result:
+ if res.get('company_id_count', 0) >= 2:
+ account_unaffected_earnings = self.search([('company_id', '=', res['company_id'][0]),
+ ('user_type_id', '=', data_unaffected_earnings.id)])
raise ValidationError(_('You cannot have more than one account with "Current Year Earnings" as type. (accounts: %s)') % [a.code for a in account_unaffected_earnings])
name = fields.Char(required=True, index=True)
@@ -228,7 +227,6 @@ class AccountAccount(models.Model):
self.group_id = group
@api.multi
- @api.depends('name', 'code')
def name_get(self):
result = []
for account in self:
@@ -801,7 +799,6 @@ class AccountJournal(models.Model):
}).id
@api.multi
- @api.depends('name', 'currency_id', 'company_id', 'company_id.currency_id')
def name_get(self):
res = []
for journal in self:
@@ -946,7 +943,6 @@ class AccountTax(models.Model):
default = dict(default or {}, name=_("%s (Copy)") % self.name)
return super(AccountTax, self).copy(default=default)
- @api.depends('name', 'type_tax_use')
def name_get(self):
if not self._context.get('append_type_to_tax_name'):
return super(AccountTax, self).name_get()
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index b8a478cfef8..d0632970bed 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -550,6 +550,8 @@ class AccountBankStatementLine(models.Model):
:returns: The journal entries with which the transaction was matched. If there was at least an entry in counterpart_aml_dicts or new_aml_dicts, this list contains
the move created by the reconciliation, containing entries for the statement.line (1), the counterpart move lines (0..*) and the new move lines (0..*).
"""
+ payable_account_type = self.env.ref('account.data_account_type_payable')
+ receivable_account_type = self.env.ref('account.data_account_type_receivable')
counterpart_aml_dicts = counterpart_aml_dicts or []
payment_aml_rec = payment_aml_rec or self.env['account.move.line']
new_aml_dicts = new_aml_dicts or []
@@ -570,10 +572,16 @@ class AccountBankStatementLine(models.Model):
raise UserError(_('A selected move line was already reconciled.'))
if isinstance(aml_dict['move_line'], int):
aml_dict['move_line'] = aml_obj.browse(aml_dict['move_line'])
+
+ account_types = self.env['account.account.type']
for aml_dict in (counterpart_aml_dicts + new_aml_dicts):
if aml_dict.get('tax_ids') and isinstance(aml_dict['tax_ids'][0], int):
# Transform the value in the format required for One2many and Many2many fields
aml_dict['tax_ids'] = [(4, id, None) for id in aml_dict['tax_ids']]
+
+ user_type_id = self.env['account.account'].browse(aml_dict.get('account_id')).user_type_id
+ if user_type_id in [payable_account_type, receivable_account_type] and user_type_id not in account_types:
+ account_types |= user_type_id
if any(line.journal_entry_ids for line in self):
raise UserError(_('A selected statement line was already reconciled with an account move.'))
@@ -609,6 +617,8 @@ class AccountBankStatementLine(models.Model):
payment = self.env['account.payment']
if abs(total)>0.00001:
payment_vals = self._prepare_payment_vals(total)
+ if self.partner_id and len(account_types) == 1:
+ payment_vals['partner_type'] = 'customer' if account_types == receivable_account_type else 'supplier'
payment = payment.create(payment_vals)
# Complete dicts to create both counterpart move lines and write-offs
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index 8db6ced6104..91f06252680 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -147,8 +147,13 @@ class AccountInvoice(models.Model):
amount_to_show = currency._convert(abs(line.amount_residual), self.currency_id, self.company_id, line.date or fields.Date.today())
if float_is_zero(amount_to_show, precision_rounding=self.currency_id.rounding):
continue
+ if line.ref :
+ title = '%s : %s' % (line.move_id.name, line.ref)
+ else:
+ title = line.move_id.name
info['content'].append({
'journal_name': line.ref or line.move_id.name,
+ 'title': title,
'amount': amount_to_show,
'currency': currency_id.symbol,
'id': line.id,
@@ -1402,6 +1407,20 @@ class AccountInvoice(models.Model):
return result
@api.model
+ def _refund_tax_lines_account_change(self, lines, taxes_to_change):
+ # Let's change the account on tax lines when
+ # @param {list} lines: a list of orm commands
+ # @param {dict} taxes_to_change
+ # key: tax ID, value: refund account
+
+ if not taxes_to_change:
+ return lines
+
+ for line in lines:
+ if isinstance(line[2], dict) and line[2]['tax_id'] in taxes_to_change:
+ line[2]['account_id'] = taxes_to_change[line[2]['tax_id']]
+ return lines
+
def _get_refund_common_fields(self):
return ['partner_id', 'payment_term_id', 'account_id', 'currency_id', 'journal_id']
@@ -1447,7 +1466,12 @@ class AccountInvoice(models.Model):
values['invoice_line_ids'] = self._refund_cleanup_lines(invoice.invoice_line_ids)
tax_lines = invoice.tax_line_ids
- values['tax_line_ids'] = self._refund_cleanup_lines(tax_lines)
+ taxes_to_change = {
+ line.tax_id.id: line.tax_id.refund_account_id.id
+ for line in tax_lines.filtered(lambda l: l.tax_id.refund_account_id != l.tax_id.account_id)
+ }
+ cleaned_tax_lines = self._refund_cleanup_lines(tax_lines)
+ values['tax_line_ids'] = self._refund_tax_lines_account_change(cleaned_tax_lines, taxes_to_change)
if journal_id:
journal = self.env['account.journal'].browse(journal_id)
diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py
index f4753ef4796..ee8087a33b0 100644
--- a/addons/account/models/account_journal_dashboard.py
+++ b/addons/account/models/account_journal_dashboard.py
@@ -185,13 +185,14 @@ class account_journal(models.Model):
self.env.cr.execute(query, query_args)
query_results_drafts = self.env.cr.dictfetchall()
- today = datetime.today()
- query = """SELECT amount_total, currency_id AS currency, type, date_invoice, company_id FROM account_invoice WHERE journal_id = %s AND date <= %s AND state = 'open';"""
+ today = fields.Date.today()
+ query = """SELECT residual_signed as amount_total, currency_id AS currency, type, date_invoice, company_id FROM account_invoice WHERE journal_id = %s AND date <= %s AND state = 'open';"""
self.env.cr.execute(query, (self.id, today))
late_query_results = self.env.cr.dictfetchall()
- (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency)
- (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency)
- (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency)
+ curr_cache = {}
+ (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency, curr_cache=curr_cache)
+ (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency, curr_cache=curr_cache)
+ (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency, curr_cache=curr_cache)
difference = currency.round(last_balance-account_sum) + 0.0
return {
@@ -226,23 +227,45 @@ class account_journal(models.Model):
gather the bills in draft state data, and the arguments
dictionary to use to run it as its second.
"""
- return ("""SELECT state, amount_total, currency_id AS currency, type, date_invoice, company_id
- FROM account_invoice
+ # there is no account_move_lines for draft invoices, so no relevant residual_signed value
+ return ("""SELECT state,
+ (CASE WHEN inv.type in ('out_invoice', 'in_invoice')
+ THEN inv.amount_total
+ ELSE (-1 * inv.amount_total)
+ END) AS amount_total,
+ inv.currency_id AS currency,
+ inv.type,
+ inv.date_invoice,
+ inv.company_id
+ FROM account_invoice inv
WHERE journal_id = %(journal_id)s AND state = 'draft';""", {'journal_id':self.id})
- def _count_results_and_sum_amounts(self, results_dict, target_currency):
+ def _count_results_and_sum_amounts(self, results_dict, target_currency, curr_cache=None):
""" Loops on a query result to count the total number of invoices and sum
their amount_total field (expressed in the given target currency).
+ amount_total must be signed !
"""
rslt_count = 0
rslt_sum = 0.0
+ # Create a cache with currency rates to avoid unnecessary SQL requests. Do not copy
+ # curr_cache on purpose, so the dictionary is modified and can be re-used for subsequent
+ # calls of the method.
+ curr_cache = {} if curr_cache is None else curr_cache
for result in results_dict:
cur = self.env['res.currency'].browse(result.get('currency'))
company = self.env['res.company'].browse(result.get('company_id')) or self.env.user.company_id
rslt_count += 1
- type_factor = result.get('type') in ('in_refund', 'out_refund') and -1 or 1
- rslt_sum += type_factor * cur._convert(
- result.get('amount_total'), target_currency, company, result.get('date_invoice') or fields.Date.today())
+ date = result.get('date_invoice') or fields.Date.today()
+
+ amount = result.get('amount_total', 0)
+ if cur != target_currency:
+ key = (cur, target_currency, company, date)
+ # Using setdefault will call _get_conversion_rate, so we explicitly check the
+ # existence of the key in the cache instead.
+ if key not in curr_cache:
+ curr_cache[key] = self.env['res.currency']._get_conversion_rate(*key)
+ amount *= curr_cache[key]
+ rslt_sum += target_currency.round(amount)
return (rslt_count, rslt_sum)
@api.multi
@@ -360,7 +383,7 @@ class account_journal(models.Model):
action['view_id'] = False
if self.type == 'purchase':
new_help = self.env['account.invoice'].with_context(ctx).complete_empty_list_help()
- action.update({'help': action.get('help', '') + new_help})
+ action.update({'help': (action.get('help') or '') + new_help})
return action
@api.multi
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index b19485674a4..baf69b2e646 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -410,9 +410,6 @@ class AccountMove(models.Model):
journal_id=journal_id,
auto=auto)
reversed_moves |= reversed_move
- #unreconcile all lines reversed
- aml = ac_move.line_ids.filtered(lambda x: x.account_id.reconcile or x.account_id.internal_type == 'liquidity')
- aml.remove_move_reconcile()
#reconcile together the reconcilable (or the liquidity aml) and their newly created counterpart
for account in set([x.account_id for x in aml]):
to_rec = aml.filtered(lambda y: y.account_id == account)
@@ -429,6 +426,18 @@ class AccountMove(models.Model):
def open_reconcile_view(self):
return self.line_ids.open_reconcile_view()
+ # FIXME: Clarify me and change me in master
+ @api.multi
+ def action_duplicate(self):
+ self.ensure_one()
+ action = self.env.ref('account.action_move_journal_line').read()[0]
+ action['target'] = 'inline'
+ action['context'] = dict(self.env.context)
+ action['context']['view_no_maturity'] = False
+ action['views'] = [(self.env.ref('account.view_move_form').id, 'form')]
+ action['res_id'] = self.copy().id
+ return action
+
@api.model
def _run_reverses_entries(self):
''' This method is called from a cron job. '''
@@ -618,7 +627,7 @@ class AccountMoveLine(models.Model):
index=True, store=True, copy=False) # related is required
blocked = fields.Boolean(string='No Follow-up', default=False,
help="You can check this box to mark this journal item as a litigation with the associated partner")
- date_maturity = fields.Date(string='Due date', index=True, required=True,
+ date_maturity = fields.Date(string='Due date', index=True, required=True, copy=False,
help="This field is used for payable and receivable journal entries. You can put the limit date for the payment of this line.")
date = fields.Date(related='move_id.date', string='Date', index=True, store=True, copy=False, readonly=False) # related is required
analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines', oldname="analytic_lines")
@@ -877,22 +886,13 @@ class AccountMoveLine(models.Model):
ret = self._reconcile_lines(debit_moves, credit_moves, field)
return ret
- @api.multi
- def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False):
- # Empty self can happen if the user tries to reconcile entries which are already reconciled.
- # The calling method might have filtered out reconciled lines.
- if not self:
- return True
-
+ def _check_reconcile_validity(self):
#Perform all checks on lines
company_ids = set()
all_accounts = []
- partners = set()
for line in self:
company_ids.add(line.company_id.id)
all_accounts.append(line.account_id)
- if (line.account_id.internal_type in ('receivable', 'payable')):
- partners.add(line.partner_id.id)
if line.reconciled:
raise UserError(_('You are trying to reconcile some entries that are already reconciled.'))
if len(company_ids) > 1:
@@ -902,6 +902,14 @@ class AccountMoveLine(models.Model):
if not (all_accounts[0].reconcile or all_accounts[0].internal_type == 'liquidity'):
raise UserError(_('Account %s (%s) does not allow reconciliation. First change the configuration of this account to allow it.') % (all_accounts[0].name, all_accounts[0].code))
+ @api.multi
+ def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False):
+ # Empty self can happen if the user tries to reconcile entries which are already reconciled.
+ # The calling method might have filtered out reconciled lines.
+ if not self:
+ return True
+
+ self._check_reconcile_validity()
#reconcile everything that can be
remaining_moves = self.auto_reconcile_lines()
@@ -919,7 +927,7 @@ class AccountMoveLine(models.Model):
#add writeoff line to reconcile algorithm and finish the reconciliation
remaining_moves = (remaining_moves + writeoff_to_reconcile).auto_reconcile_lines()
# Check if reconciliation is total or needs an exchange rate entry to be created
- (self+writeoff_to_reconcile).check_full_reconcile()
+ (self + writeoff_to_reconcile).check_full_reconcile()
return True
def _create_writeoff(self, writeoff_vals):
@@ -963,8 +971,9 @@ class AccountMoveLine(models.Model):
raise UserError(_("Either pass both debit and credit or none."))
if 'date' not in vals:
vals['date'] = self._context.get('date_p') or fields.Date.today()
- if vals['date'] < date:
- date = vals['date']
+ vals['date'] = fields.Date.to_date(vals['date'])
+ if vals['date'] and vals['date'] < date:
+ date = vals['date']
if 'name' not in vals:
vals['name'] = self._context.get('comment') or _('Write-Off')
if 'analytic_account_id' not in vals:
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index c83d2b31ed3..bb2e7ff4f03 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -324,7 +324,7 @@ class account_register_payments(models.TransientModel):
pmt_communication = self.show_communication_field and self.communication \
or self.group_invoices and ' '.join([inv.reference or inv.number for inv in invoices]) \
or invoices[0].reference # in this case, invoices contains only one element, since group_invoices is False
- return {
+ values = {
'journal_id': self.journal_id.id,
'payment_method_id': self.payment_method_id.id,
'payment_date': self.payment_date,
@@ -337,8 +337,13 @@ class account_register_payments(models.TransientModel):
'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type],
'partner_bank_account_id': bank_account.id,
'multi': False,
+ 'payment_difference_handling': self.payment_difference_handling,
+ 'writeoff_account_id': self.writeoff_account_id.id,
+ 'writeoff_label': self.writeoff_label,
}
+ return values
+
@api.multi
def get_payments_vals(self):
'''Compute the values for payments.
@@ -429,6 +434,8 @@ class account_payment(models.Model):
if move_line.account_id.reconcile:
move_line_id = move_line.id
break;
+ if not self.partner_id:
+ raise UserError(_("Payments without a customer can't be matched"))
action_context = {'company_ids': [self.company_id.id], 'partner_ids': [self.partner_id.commercial_partner_id.id]}
if self.partner_type == 'customer':
action_context.update({'mode': 'customers'})
diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py
index 8d7a48424fe..717b99ad26d 100644
--- a/addons/account/models/chart_template.py
+++ b/addons/account/models/chart_template.py
@@ -173,7 +173,7 @@ class AccountChartTemplate(models.Model):
# (won't work well for multi-company)
company = self.env.user.company_id
# If we don't have any chart of account on this company, install this chart of account
- if not company.chart_template_id:
+ if not self.existing_accounting(company):
self.load_for_current_company(15.0, 15.0)
def load_for_current_company(self, sale_tax_rate, purchase_tax_rate):
diff --git a/addons/account/models/company.py b/addons/account/models/company.py
index 5ea0db1cd95..f2efe950361 100644
--- a/addons/account/models/company.py
+++ b/addons/account/models/company.py
@@ -381,8 +381,13 @@ Best Regards,'''))
('user_type_id', '=', unaffected_earnings_type.id)])
if account:
return account[0]
+ # Do not assume '999999' doesn't exist since the user might have created such an account
+ # manually.
+ code = 999999
+ while self.env['account.account'].search([('code', '=', str(code)), ('company_id', '=', self.id)]):
+ code -= 1
return self.env['account.account'].create({
- 'code': '999999',
+ 'code': str(code),
'name': _('Undistributed Profits/Losses'),
'user_type_id': unaffected_earnings_type.id,
'company_id': self.id,
diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py
index f2485b379e6..85ec5f9bb66 100644
--- a/addons/account/models/partner.py
+++ b/addons/account/models/partner.py
@@ -204,7 +204,7 @@ class ResPartner(models.Model):
@api.multi
def _credit_debit_get(self):
- tables, where_clause, where_params = self.env['account.move.line']._query_get()
+ tables, where_clause, where_params = self.env['account.move.line'].with_context(company_id=self.env.user.company_id.id)._query_get()
where_params = [tuple(self.ids)] + where_params
if where_clause:
where_clause = 'AND ' + where_clause
diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py
index 2fb6aad94b8..df92a5b45f1 100644
--- a/addons/account/models/reconciliation_widget.py
+++ b/addons/account/models/reconciliation_widget.py
@@ -224,6 +224,27 @@ class AccountReconciliation(models.AbstractModel):
""" Returns the data required for the invoices & payments matching of partners/accounts.
If an argument is None, fetch all related reconciliations. Use [] to fetch nothing.
"""
+ MoveLine = self.env['account.move.line']
+ aml_ids = self._context.get('active_ids') and self._context.get('active_model') == 'account.move.line' and tuple(self._context.get('active_ids'))
+ if aml_ids:
+ aml = MoveLine.browse(aml_ids)
+ aml._check_reconcile_validity()
+ account = aml[0].account_id
+ currency = account.currency_id or account.company_id.currency_id
+ return {
+ 'accounts': [{
+ 'reconciliation_proposition': self._prepare_move_lines(aml, target_currency=currency),
+ 'company_id': account.company_id.id,
+ 'currency_id': currency.id,
+ 'mode': 'accounts',
+ 'account_id': account.id,
+ 'account_name': account.name,
+ 'account_code': account.code,
+ }],
+ 'customers': [],
+ 'suppliers': [],
+ }
+
return {
'customers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'receivable'),
'suppliers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'payable'),
@@ -344,7 +365,6 @@ class AccountReconciliation(models.AbstractModel):
# be reconciled.
return [r for r in rows if r['reconciliation_proposition']] + [r for r in rows if not r['reconciliation_proposition']]
-
@api.model
def process_move_lines(self, data):
""" Used to validate a batch of reconciliations in a single call
diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py
index bc660b494b3..08d0b976263 100644
--- a/addons/account/report/account_aged_partner_balance.py
+++ b/addons/account/report/account_aged_partner_balance.py
@@ -142,6 +142,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
if not self.env.user.company_id.currency_id.is_zero(line_amount):
partners_amount[partner_id] += line_amount
+ lines.setdefault(partner_id, [])
lines[partner_id].append({
'line': line,
'amount': line_amount,
@@ -179,6 +180,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
line_amount -= partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, user_company, date_from)
if not self.env.user.company_id.currency_id.is_zero(line_amount):
undue_amounts[partner_id] += line_amount
+ lines.setdefault(partner_id, [])
lines[partner_id].append({
'line': line,
'amount': line_amount,
diff --git a/addons/account/static/src/js/account_payment_field.js b/addons/account/static/src/js/account_payment_field.js
index a20a681b0e6..c4dec36dda0 100644
--- a/addons/account/static/src/js/account_payment_field.js
+++ b/addons/account/static/src/js/account_payment_field.js
@@ -162,4 +162,8 @@ var ShowPaymentLineWidget = AbstractField.extend({
field_registry.add('payment', ShowPaymentLineWidget);
+return {
+ ShowPaymentLineWidget: ShowPaymentLineWidget
+};
+
});
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js
index 5e756f28247..06387df0579 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_model.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js
@@ -858,6 +858,7 @@ var StatementModel = BasicModel.extend({
'tax_id': [tax.id, null],
'amount': tax.amount,
'label': tax.name,
+ 'date': prop.date,
'account_id': tax.account_id ? [tax.account_id, null] : prop.account_id,
'analytic': tax.analytic,
'is_tax': true,
@@ -1072,6 +1073,7 @@ var StatementModel = BasicModel.extend({
*/
_formatQuickCreate: function (line, values) {
values = values || {};
+ var today = new moment().utc().format();
var account = this._formatNameGet(values.account_id);
var formatOptions = {
currency_id: line.st_line.currency_id,
@@ -1088,6 +1090,7 @@ var StatementModel = BasicModel.extend({
'tax_id': this._formatNameGet(values.tax_id),
'debit': 0,
'credit': 0,
+ 'date': values.date ? values.date : field_utils.parse.date(today, {}, {isUTC: true}),
'base_amount': values.amount_type !== "percentage" ?
(amount) : line.balance.amount * values.amount / 100,
'percent': values.amount_type === "percentage" ? values.amount : null,
@@ -1238,7 +1241,7 @@ var StatementModel = BasicModel.extend({
* datas allowing manual reconciliation
*/
var ManualModel = StatementModel.extend({
- quickCreateFields: ['account_id', 'journal_id', 'amount', 'analytic_account_id', 'label', 'tax_id', 'force_tax_included', 'analytic_tag_ids'],
+ quickCreateFields: ['account_id', 'journal_id', 'amount', 'analytic_account_id', 'label', 'tax_id', 'force_tax_included', 'analytic_tag_ids', 'date'],
//--------------------------------------------------------------------------
// Public
@@ -1636,6 +1639,12 @@ var ManualModel = StatementModel.extend({
})
.then(this._formatMoveLine.bind(this, handle));
},
+
+ _formatToProcessReconciliation: function (line, prop) {
+ var result = this._super(line, prop);
+ result['date'] = prop.date;
+ return result;
+ },
});
return {
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
index f0641eae409..23c680ddcda 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
@@ -542,10 +542,13 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
}, {
type: 'float',
name: 'amount',
+ }, {
+ type: 'char', //TODO is it a bug or a feature when type date exists ?
+ name: 'date',
}], {
account_id: {string: _t("Account")},
label: {string: _t("Label")},
- amount: {string: _t("Account")}
+ amount: {string: _t("Account")},
}).then(function (recordID) {
self.handleCreateRecord = recordID;
var record = self.model.get(self.handleCreateRecord);
@@ -573,6 +576,9 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
self.fields.amount = new basic_fields.FieldFloat(self,
'amount', record, {mode: 'edit'});
+
+ self.fields.date = new basic_fields.FieldDate(self,
+ 'date', record, {mode: 'edit'});
var $create = $(qweb.render("reconciliation.line.create", {'state': state}));
self.fields.account_id.appendTo($create.find('.create_account_id .o_td_field'))
@@ -586,6 +592,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
.then(addRequiredStyle.bind(self, self.fields.label));
self.fields.amount.appendTo($create.find('.create_amount .o_td_field'))
.then(addRequiredStyle.bind(self, self.fields.amount));
+ self.fields.date.appendTo($create.find('.create_date .o_td_field'))
self.$('.create').append($create);
function addRequiredStyle(widget) {
@@ -893,6 +900,7 @@ var ManualLineRenderer = LineRenderer.extend({
_renderCreate: function (state) {
this._super(state);
this.$('.create .create_journal_id').show();
+ this.$('.create .create_date').removeClass('d-none')
this.$('.create .create_journal_id .o_input').addClass('o_required_modifier');
},
diff --git a/addons/account/static/src/xml/account_payment.xml b/addons/account/static/src/xml/account_payment.xml
index 4e72bd67d06..55b327183fc 100644
--- a/addons/account/static/src/xml/account_payment.xml
+++ b/addons/account/static/src/xml/account_payment.xml
@@ -16,8 +16,8 @@
<td>
<a title="assign to invoice" role="button" class="oe_form_field btn btn-link outstanding_credit_assign" t-att-data-id="line.id" style="margin-right: 10px;" href="#">Add</a>
</td>
- <td>
- <span class="oe_form_field" style="margin-right: 30px;"><t t-esc="line.journal_name"></t></span>
+ <td style="max-width: 10em;">
+ <div class="oe_form_field" style="margin-right: 30px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;" t-att-title="line.title"><t t-esc="line.journal_name"></t></div>
</td>
</t>
<t t-if="!outstanding">
diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml
index 3caa61eb633..33c778a9b7c 100644
--- a/addons/account/static/src/xml/account_reconciliation.xml
+++ b/addons/account/static/src/xml/account_reconciliation.xml
@@ -212,6 +212,10 @@
<td class="o_td_label">Tax Included in Price</td>
<td class="o_td_field"></td>
</tr>
+ <tr class="create_date d-none">
+ <td class="o_td_label">Writeoff Date</td>
+ <td class="o_td_field"></td>
+ </tr>
</tbody>
</table>
<div class="add_line_container">
diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js
index 01645d5c97e..62fa37e1199 100644
--- a/addons/account/static/tests/reconciliation_tests.js
+++ b/addons/account/static/tests/reconciliation_tests.js
@@ -649,8 +649,8 @@ QUnit.module('account', {
assert.strictEqual(widget.$('.o_input_dropdown input').val(), "Agrolait", "the partner many2one should display agrolait");
assert.strictEqual(clientAction.widgets[2].$('.o_input_dropdown input').val(), "Camptocamp", "the partner many2one should display Camptocamp");
widget.$('.accounting_view tfoot td:first').trigger('click');
- assert.containsN(widget, '.create input.o_input', 7,
- "create panel should contain 7 fields (account_id, tax_id, journal_id, analytic_account_id, analytic_tag_ids, label, amount)");
+ assert.containsN(widget, '.create input.o_input', 8,
+ "create panel should contain 8 fields (account_id, tax_id, journal_id, analytic_account_id, analytic_tag_ids, label, amount, date)");
assert.containsN(widget, '.create .create_account_id .o_required_modifier, .create .create_label .o_required_modifier, .create .create_amount .o_required_modifier', 3,
"account_id, label and amount should be required fields");
assert.strictEqual(widget.$('.create .create_label input').val(), 'SAJ/2014/002 and SAJ/2014/003',
diff --git a/addons/account/tests/test_account_customer_invoice.py b/addons/account/tests/test_account_customer_invoice.py
index 0906eae595b..03ca61aecf5 100644
--- a/addons/account/tests/test_account_customer_invoice.py
+++ b/addons/account/tests/test_account_customer_invoice.py
@@ -1,3 +1,5 @@
+from unittest.mock import patch
+
from odoo.addons.account.tests.account_test_users import AccountTestUsers
import datetime
from odoo.tests import tagged
@@ -165,3 +167,174 @@ class TestAccountCustomerInvoice(AccountTestUsers):
))
self.assertEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids]))
+
+ def test_customer_invoice_tax_refund(self):
+ company = self.env.user.company_id
+ tax_account = self.env['account.account'].create({
+ 'name': 'TAX',
+ 'code': 'TAX',
+ 'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
+ 'company_id': company.id,
+ })
+
+ tax_refund_account = self.env['account.account'].create({
+ 'name': 'TAX_REFUND',
+ 'code': 'TAX_R',
+ 'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
+ 'company_id': company.id,
+ })
+
+ journalrec = self.env['account.journal'].search([('type', '=', 'sale')])[0]
+ partner3 = self.env.ref('base.res_partner_3')
+ account_id = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id
+
+ tax = self.env['account.tax'].create({
+ 'name': 'Tax 15.0',
+ 'amount': 15.0,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'account_id': tax_account.id,
+ 'refund_account_id': tax_refund_account.id
+ })
+
+ invoice_line_data = [
+ (0, 0,
+ {
+ 'product_id': self.env.ref('product.product_product_1').id,
+ 'quantity': 40.0,
+ 'account_id': account_id,
+ 'name': 'product test 1',
+ 'discount': 10.00,
+ 'price_unit': 2.27,
+ 'invoice_line_tax_ids': [(6, 0, [tax.id])],
+ }
+ )]
+
+ invoice = self.env['account.invoice'].create(dict(
+ name="Test Customer Invoice",
+ journal_id=journalrec.id,
+ partner_id=partner3.id,
+ invoice_line_ids=invoice_line_data
+ ))
+
+ invoice.action_invoice_open()
+
+ refund = invoice.refund()
+ self.assertEqual(invoice.tax_line_ids.mapped('account_id'), tax_account)
+ self.assertEqual(refund.tax_line_ids.mapped('account_id'), tax_refund_account)
+
+ def test_customer_invoice_dashboard(self):
+ def patched_today(*args, **kwargs):
+ return '2019-01-22'
+
+ date_invoice = '2019-01-21'
+ partner3 = self.env.ref('base.res_partner_3')
+ account_id = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id
+
+ journal = self.env['account.journal'].create({
+ 'name': 'sale_0',
+ 'code': 'SALE0',
+ 'type': 'sale',
+ })
+
+ invoice_line_data = [
+ (0, 0,
+ {
+ 'product_id': self.env.ref('product.product_product_1').id,
+ 'quantity': 40.0,
+ 'account_id': account_id,
+ 'name': 'product test 1',
+ 'discount': 10.00,
+ 'price_unit': 2.27,
+ }
+ )
+ ]
+
+ invoice = self.env['account.invoice'].create(dict(
+ name="Test Customer Invoice",
+ journal_id=journal.id,
+ partner_id=partner3.id,
+ invoice_line_ids=invoice_line_data,
+ date_invoice=date_invoice,
+ ))
+
+ refund_line_data = [
+ (0, 0,
+ {
+ 'product_id': self.env.ref('product.product_product_1').id,
+ 'quantity': 1.0,
+ 'account_id': account_id,
+ 'name': 'product test 1',
+ 'price_unit': 13.3,
+ }
+ )]
+
+ refund = self.env['account.invoice'].create(dict(
+ name="Test Customer Refund",
+ type='out_refund',
+ journal_id=journal.id,
+ partner_id=partner3.id,
+ invoice_line_ids=refund_line_data,
+ date_invoice=date_invoice,
+ ))
+
+ # Check Draft
+ dashboard_data = journal.get_journal_dashboard_datas()
+
+ self.assertEquals(dashboard_data['number_draft'], 2)
+ self.assertEquals(dashboard_data['sum_draft'], '$ 68.42')
+
+ self.assertEquals(dashboard_data['number_waiting'], 0)
+ self.assertEquals(dashboard_data['sum_waiting'], '$ 0.00')
+
+ # Check Both
+ invoice.action_invoice_open()
+
+ dashboard_data = journal.get_journal_dashboard_datas()
+ self.assertEquals(dashboard_data['number_draft'], 1)
+ self.assertEquals(dashboard_data['sum_draft'], '$ -13.30')
+
+ self.assertEquals(dashboard_data['number_waiting'], 1)
+ self.assertEquals(dashboard_data['sum_waiting'], '$ 81.72')
+
+ # Check waiting payment
+ refund.action_invoice_open()
+
+ dashboard_data = journal.get_journal_dashboard_datas()
+ self.assertEquals(dashboard_data['number_draft'], 0)
+ self.assertEquals(dashboard_data['sum_draft'], '$ 0.00')
+
+ self.assertEquals(dashboard_data['number_waiting'], 2)
+ self.assertEquals(dashboard_data['sum_waiting'], '$ 68.42')
+
+ # Check partial
+ receivable_account = refund.move_id.line_ids.mapped('account_id').filtered(lambda a: a.internal_type == 'receivable')
+ payment_move = self.env['account.move'].create({
+ 'journal_id': journal.id,
+ })
+ payment_move_line = self.env['account.move.line'].with_context(check_move_validity=False).create({
+ 'move_id': payment_move.id,
+ 'account_id': receivable_account.id,
+ 'debit': 10.00,
+ })
+ self.env['account.move.line'].with_context(check_move_validity=False).create({
+ 'move_id': payment_move.id,
+ 'account_id': self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_liquidity').id)], limit=1).id,
+ 'credit': 10.00,
+ })
+
+ payment_move.post()
+
+ refund.register_payment(payment_move_line)
+
+ dashboard_data = journal.get_journal_dashboard_datas()
+ self.assertEquals(dashboard_data['number_draft'], 0)
+ self.assertEquals(dashboard_data['sum_draft'], '$ 0.00')
+
+ self.assertEquals(dashboard_data['number_waiting'], 2)
+ self.assertEquals(dashboard_data['sum_waiting'], '$ 78.42')
+
+ with patch('odoo.fields.Date.today', patched_today):
+ dashboard_data = journal.get_journal_dashboard_datas()
+ self.assertEquals(dashboard_data['number_late'], 2)
+ self.assertEquals(dashboard_data['sum_late'], '$ 78.42')
diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py
index a061f0c7964..c010d0084aa 100644
--- a/addons/account/tests/test_payment.py
+++ b/addons/account/tests/test_payment.py
@@ -595,3 +595,37 @@ class TestPayment(AccountingTestCase):
# The invoice should now be paid
self.assertEqual(invoice.state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement")
+
+ def test_register_payments_multi_invoices(self):
+ inv1 = self.create_invoice(amount=40)
+ inv2 = inv1.copy()
+ inv2.action_invoice_open()
+
+ batch_payment = self.env['account.register.payments'].with_context(active_ids=(inv1 + inv2).ids).create({
+ 'amount': 70,
+ 'partner_id': inv1.partner_id.id,
+ 'journal_id': self.bank_journal_usd.id,
+ 'invoice_ids': [(6, False, (inv1 + inv2).ids)],
+ 'partner_type': 'customer',
+ 'payment_difference_handling': 'reconcile',
+ 'payment_type': 'inbound',
+ 'payment_method_id': self.payment_method_manual_in.id,
+ 'writeoff_account_id': self.account_revenue.id,
+ 'writeoff_label': "why can't we live together"})
+
+ payment_id = batch_payment.create_payments()['res_id']
+ payment_id = self.env['account.payment'].browse(payment_id)
+
+ self.assertEqual(inv1.state, 'paid')
+ self.assertEqual(inv2.state, 'paid')
+
+ receivable_inv1 = inv1.move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable')
+ receivable_inv2 = inv2.move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable')
+ receivable_payment = payment_id.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable')
+
+ self.assertEqual(
+ receivable_inv1.full_reconcile_id.reconciled_line_ids,
+ receivable_inv1 + receivable_inv2 + receivable_payment
+ )
+ write_off_line = payment_id.move_line_ids.filtered(lambda l: l.account_id == payment_id.writeoff_account_id)
+ self.assertEqual(write_off_line.debit, 10)
diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml
index 15219345394..197c6a1e1cf 100644
--- a/addons/account/views/account_view.xml
+++ b/addons/account/views/account_view.xml
@@ -1556,10 +1556,11 @@
<field name="name">account.move.form</field>
<field name="model">account.move</field>
<field name="arch" type="xml">
- <form string="Account Entry">
+ <form string="Account Entry" duplicate="false">
<header>
<button name="action_post" states="draft" string="Post" type="object" class="oe_highlight" groups="account.group_account_invoice"/>
<button name="%(action_view_account_move_reversal)d" states="posted" string="Reverse Entry" type="action" groups="account.group_account_invoice"/>
+ <button name="action_duplicate" string="Duplicate" type="object" groups="account.group_account_invoice"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py
index ba5da81b889..54fbd7ff4be 100644
--- a/addons/account/wizard/account_invoice_send.py
+++ b/addons/account/wizard/account_invoice_send.py
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models
-from odoo.exceptions import UserError
+from odoo import api, fields, models, _
+from odoo.addons.mail.wizard.mail_compose_message import _reopen
class AccountInvoiceSend(models.TransientModel):
@@ -67,3 +67,11 @@ class AccountInvoiceSend(models.TransientModel):
if self.is_print:
return self._print_document()
return {'type': 'ir.actions.act_window_close'}
+
+ @api.multi
+ def save_as_template(self):
+ self.ensure_one()
+ self.composer_id.save_as_template()
+ action = _reopen(self, self.id, self.model, context=self._context)
+ action.update({'name': _('Send Invoice')})
+ return action
diff --git a/addons/attachment_indexation/models/ir_attachment.py b/addons/attachment_indexation/models/ir_attachment.py
index 2bc6aee234d..bdc4559e893 100644
--- a/addons/attachment_indexation/models/ir_attachment.py
+++ b/addons/attachment_indexation/models/ir_attachment.py
@@ -9,7 +9,7 @@ import zipfile
from odoo import api, models
_logger = logging.getLogger(__name__)
-FTYPES = ['docx', 'pptx', 'xlsx', 'opendoc', 'pdf']
+FTYPES = ['docx', 'pptx', 'xlsx', 'opendoc']
def textToString(element):
buff = u""
@@ -92,6 +92,9 @@ class IrAttachment(models.Model):
def _index_pdf(self, bin_data):
'''Index PDF documents'''
+ # extractText gives very bad results for indexing, hence we don't index PDF anymore. A
+ # better alternative is probably PDFMiner.six, but not for stable.
+ # See POC at https://github.com/odoo/odoo/pull/27568.
buf = u""
if bin_data.startswith(b'%PDF-'):
f = io.BytesIO(bin_data)
diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py
index 7591299f3e2..f8f01119e7d 100644
--- a/addons/base_address_city/models/res_partner.py
+++ b/addons/base_address_city/models/res_partner.py
@@ -15,9 +15,10 @@ class Partner(models.Model):
@api.onchange('city_id')
def _onchange_city_id(self):
- self.city = self.city_id.name
- self.zip = self.city_id.zipcode
- self.state_id = self.city_id.state_id
+ if self.city_id:
+ self.city = self.city_id.name
+ self.zip = self.city_id.zipcode
+ self.state_id = self.city_id.state_id
@api.model
def _fields_view_get_address(self, arch):
@@ -37,7 +38,10 @@ class Partner(models.Model):
}"
/>
<field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s"
- context="{'default_country_id': country_id}"
+ context="{'default_country_id': country_id,
+ 'default_name': city,
+ 'default_zipcode': zip,
+ 'default_state_id': state_id}"
domain="[('country_id', '=', country_id)]"
attrs="{
'invisible': [('country_enforce_cities', '=', False)],
diff --git a/addons/base_gengo/wizard/base_gengo_translations.py b/addons/base_gengo/wizard/base_gengo_translations.py
index 3b260dd5a61..7ec81a25607 100644
--- a/addons/base_gengo/wizard/base_gengo_translations.py
+++ b/addons/base_gengo/wizard/base_gengo_translations.py
@@ -24,6 +24,7 @@ class BaseGengoTranslations(models.TransientModel):
GROUPS = ['base.group_system']
_name = 'base.gengo.translations'
+ _description = 'Base Gengo Translations'
@api.model
def default_get(self, fields):
diff --git a/addons/bus/models/res_partner.py b/addons/bus/models/res_partner.py
index b0b4c23d74f..a476e0c08dc 100644
--- a/addons/bus/models/res_partner.py
+++ b/addons/bus/models/res_partner.py
@@ -15,13 +15,14 @@ class ResPartner(models.Model):
self.env.cr.execute("""
SELECT
U.partner_id as id,
- CASE WHEN age(now() AT TIME ZONE 'UTC', B.last_poll) > interval %s THEN 'offline'
- WHEN age(now() AT TIME ZONE 'UTC', B.last_presence) > interval %s THEN 'away'
+ CASE WHEN age(now() AT TIME ZONE 'UTC', max(B.last_poll)) > interval %s THEN 'offline'
+ WHEN age(now() AT TIME ZONE 'UTC', max(B.last_presence)) > interval %s THEN 'away'
ELSE 'online'
END as status
FROM bus_presence B
JOIN res_users U ON B.user_id = U.id
WHERE U.partner_id IN %s AND U.active = 't'
+ GROUP BY U.partner_id
""", ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, tuple(self.ids)))
res = dict(((status['id'], status['status']) for status in self.env.cr.dictfetchall()))
for partner in self:
diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml
index acaf0f5ea72..794e37ddb66 100644
--- a/addons/crm/views/crm_lead_views.xml
+++ b/addons/crm/views/crm_lead_views.xml
@@ -888,6 +888,7 @@ for rec in records:
name="Team Pipelines"
parent="crm_menu_sales"
action="sales_team.crm_team_salesteams_pipelines_act"
+ groups="sales_team.group_sale_manager"
sequence="4"/>
<menuitem id="res_partner_menu_customer"
diff --git a/addons/delivery/models/delivery_carrier.py b/addons/delivery/models/delivery_carrier.py
index b5b0fcb0644..ad0d4f3cb30 100644
--- a/addons/delivery/models/delivery_carrier.py
+++ b/addons/delivery/models/delivery_carrier.py
@@ -70,7 +70,7 @@ class DeliveryCarrier(models.Model):
def install_more_provider(self):
return {
'name': 'New Providers',
- 'view_mode': 'kanban',
+ 'view_mode': 'kanban,form',
'res_model': 'ir.module.module',
'domain': [['name', 'ilike', 'delivery_']],
'type': 'ir.actions.act_window',
diff --git a/addons/delivery/wizard/choose_delivery_package.py b/addons/delivery/wizard/choose_delivery_package.py
index 70c06f06dac..e7ad1d75292 100644
--- a/addons/delivery/wizard/choose_delivery_package.py
+++ b/addons/delivery/wizard/choose_delivery_package.py
@@ -21,6 +21,7 @@ class ChooseDeliveryPackage(models.TransientModel):
string='Shipping Weight',
default=lambda self: self._default_shipping_weight()
)
+ weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name')
def _default_stock_quant_package_id(self):
if self.env.context.get('default_stock_quant_package_id'):
@@ -45,6 +46,12 @@ class ChooseDeliveryPackage(models.TransientModel):
total_weight = sum([po.qty_done * po.product_id.weight for po in move_line_ids])
return total_weight
+ @api.depends('stock_quant_package_id', 'delivery_packaging_id')
+ def _compute_weight_uom_name(self):
+ weight_uom_id = self.env['product.template']._get_weight_uom_id_from_ir_config_parameter()
+ for package in self:
+ package.weight_uom_name = weight_uom_id.name
+
@api.onchange('delivery_packaging_id', 'shipping_weight')
def _onchange_packaging_weight(self):
if self.delivery_packaging_id.max_weight and self.shipping_weight > self.delivery_packaging_id.max_weight:
diff --git a/addons/delivery/wizard/choose_delivery_package_views.xml b/addons/delivery/wizard/choose_delivery_package_views.xml
index c37231ed38d..72e5074def3 100644
--- a/addons/delivery/wizard/choose_delivery_package_views.xml
+++ b/addons/delivery/wizard/choose_delivery_package_views.xml
@@ -11,7 +11,7 @@
<label for="shipping_weight" attrs="{'invisible': [('delivery_packaging_id', '=', False)]}"/>
<div class="o_row" attrs="{'invisible': [('delivery_packaging_id', '=', False)]}">
<field name="shipping_weight"/>
- <span>kg</span>
+ <field name="weight_uom_name"/>
</div>
</group>
<footer>
diff --git a/addons/fleet/models/fleet_vehicle.py b/addons/fleet/models/fleet_vehicle.py
index e5101c76360..383b6e06cdb 100644
--- a/addons/fleet/models/fleet_vehicle.py
+++ b/addons/fleet/models/fleet_vehicle.py
@@ -23,7 +23,7 @@ class FleetVehicle(models.Model):
license_plate = fields.Char(track_visibility="onchange",
help='License plate number of the vehicle (i = plate number for a car)')
vin_sn = fields.Char('Chassis Number', help='Unique number written on the vehicle motor (VIN/SN number)', copy=False)
- driver_id = fields.Many2one('res.partner', 'Driver', track_visibility="onchange", help='Driver of the vehicle', copy=False)
+ driver_id = fields.Many2one('res.partner', 'Driver', track_visibility="onchange", help='Driver of the vehicle', copy=False, auto_join=True)
model_id = fields.Many2one('fleet.vehicle.model', 'Model',
track_visibility="onchange", required=True, help='Model of the vehicle')
brand_id = fields.Many2one('fleet.vehicle.model.brand', 'Brand', related="model_id.brand_id", store=True, readonly=False)
@@ -59,6 +59,7 @@ class FleetVehicle(models.Model):
fuel_type = fields.Selection([
('gasoline', 'Gasoline'),
('diesel', 'Diesel'),
+ ('lpg', 'LPG'),
('electric', 'Electric'),
('hybrid', 'Hybrid')
], 'Fuel Type', help='Fuel Used by the vehicle')
@@ -228,12 +229,7 @@ class FleetVehicle(models.Model):
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
domain = args or []
- domain = expression.AND([domain, [('name', operator, name)]])
- # we don't want to override the domain's filter on driver_id if present
- if not any(['driver_id' in element for element in domain]):
- partner_ids = self.env['res.partner']._search([('name', operator, name)], access_rights_uid=name_get_uid)
- if partner_ids:
- domain = expression.OR([domain, ['|', ('driver_id', 'in', partner_ids), ('driver_id', '=', False)]])
+ domain = expression.AND([domain, ['|', ('name', operator, name), ('driver_id.name', operator, name)]])
rec = self._search(domain, limit=limit, access_rights_uid=name_get_uid)
return self.browse(rec).name_get()
diff --git a/addons/hr_fleet/controllers/i18n/fil.po b/addons/hr_fleet/controllers/i18n/fil.po
new file mode 100644
index 00000000000..f3c56bf488c
--- /dev/null
+++ b/addons/hr_fleet/controllers/i18n/fil.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: Filipino (https://www.transifex.com/odoo/teams/41243/fil/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Language: fil\n"
+"Plural-Forms: nplurals=2; plural=(n == 1 || n==2 || n==3) || (n % 10 != 4 || n % 10 != 6 || n % 10 != 9);\n"
+
+#. module: crm_project
+#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
+msgid ""
+"<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/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 37e12bd0c86..d67ddc01904 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -497,7 +497,8 @@ class HolidaysRequest(models.Model):
holiday.sudo().action_validate()
holiday.message_subscribe(partner_ids=[holiday._get_responsible_for_approval().partner_id.id])
holiday.sudo().message_post(body=_("The leave has been automatically approved"), subtype="mt_comment") # Message from OdooBot (sudo)
- holiday.activity_update()
+ if not self._context.get('import_file'):
+ holiday.activity_update()
return holiday
def _read_from_database(self, field_names, inherited_field_names=[]):
diff --git a/addons/l10n_be_hr_payroll_fleet/models/fleet.py b/addons/l10n_be_hr_payroll_fleet/models/fleet.py
index c72f10117ba..2a89a67de88 100644
--- a/addons/l10n_be_hr_payroll_fleet/models/fleet.py
+++ b/addons/l10n_be_hr_payroll_fleet/models/fleet.py
@@ -45,15 +45,21 @@ class FleetVehicle(models.Model):
elif contract.cost_frequency == "yearly":
car.total_cost += contract.cost_generated / 12.0
- def _get_co2_fee(self, co2):
- return max((((co2 * 9.0) - 600.0) * 1.2488) / 12.0, 0.0)
-
- @api.depends('co2')
+ def _get_co2_fee(self, co2, fuel_type):
+ fuel_coefficient = {'diesel': 600, 'gasoline': 768, 'lpg': 990, 'electric': 0, 'hybrid': 600}
+ co2_fee = 0
+ if fuel_type and fuel_type != 'electric':
+ if not co2:
+ co2 = 165 if fuel_type in ['diesel', 'hybrid'] else 182
+ co2_fee = (((co2 * 9.0) - fuel_coefficient.get(fuel_type, 0)) * 144.97 / 114.08) / 12.0
+ return max(co2_fee, 26.47)
+
+ @api.depends('co2', 'fuel_type')
def _compute_co2_fee(self):
for car in self:
- car.co2_fee = self._get_co2_fee(car.co2)
+ car.co2_fee = self._get_co2_fee(car.co2, car.fuel_type)
- @api.depends('fuel_type', 'car_value', 'acquisition_date')
+ @api.depends('fuel_type', 'car_value', 'acquisition_date', 'co2')
def _compute_car_atn(self):
for car in self:
car.atn = car._get_car_atn(car.acquisition_date, car.car_value, car.fuel_type, car.co2)
@@ -111,15 +117,18 @@ class FleetVehicle(models.Model):
atn = 0.0
else:
if fuel_type in ['diesel', 'hybrid']:
- reference = 87.0
+ reference = 88.0
else:
- reference = 105.0
+ reference = 107.0
+
+ if not co2:
+ co2 = 195 if fuel_type in ['diesel', 'hybrid'] else 205
if co2 <= reference:
atn = car_value * max(0.04, (0.055 - 0.001 * (reference - co2))) * magic_coeff
else:
atn = car_value * min(0.18, (0.055 + 0.001 * (co2 - reference))) * magic_coeff
- return max(1280, atn) / 12.0
+ return max(1310, atn) / 12.0
@api.onchange('model_id')
def _onchange_model_id(self):
@@ -140,7 +149,7 @@ class FleetVehicleModel(models.Model):
default_recurring_cost_amount_depreciated = fields.Float(string="Cost (Depreciated)",
help="Default recurring cost amount that should be applied to a new car from this model")
default_co2 = fields.Float(string="CO2 emissions")
- default_fuel_type = fields.Selection([('gasoline', 'Gasoline'), ('diesel', 'Diesel'), ('electric', 'Electric'), ('hybrid', 'Hybrid')], 'Fuel Type', help='Fuel Used by the vehicle')
+ default_fuel_type = fields.Selection([('gasoline', 'Gasoline'), ('diesel', 'Diesel'), ('lpg', 'LPG'), ('electric', 'Electric'), ('hybrid', 'Hybrid')], 'Fuel Type', help='Fuel Used by the vehicle')
default_car_value = fields.Float(string="Catalog Value (VAT Incl.)")
can_be_requested = fields.Boolean(string="Can be requested", help="Can be requested on a contract as a new car")
default_atn = fields.Float(compute='_compute_atn', string="ATN")
@@ -158,7 +167,7 @@ class FleetVehicleModel(models.Model):
for model in self:
model.default_total_depreciated_cost = model.co2_fee + model.default_recurring_cost_amount_depreciated
- @api.depends('default_co2')
+ @api.depends('default_co2', 'default_fuel_type')
def _compute_co2_fee(self):
for model in self:
- model.co2_fee = self.env['fleet.vehicle']._get_co2_fee(model.default_co2)
+ model.co2_fee = self.env['fleet.vehicle']._get_co2_fee(model.default_co2, model.default_fuel_type)
diff --git a/addons/l10n_fr/data/account_chart_template_data.xml b/addons/l10n_fr/data/account_chart_template_data.xml
index d184a621db5..6aa50ef5cbf 100644
--- a/addons/l10n_fr/data/account_chart_template_data.xml
+++ b/addons/l10n_fr/data/account_chart_template_data.xml
@@ -239,7 +239,7 @@
<record id="pcg_120" model="account.account.template">
<field name="name">Résultat de l'exercice (bénéfice)</field>
<field name="code">120</field>
- <field name="user_type_id" ref="account.data_unaffected_earnings"/>
+ <field name="user_type_id" ref="account.data_account_type_current_liabilities"/>
<field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/>
</record>
diff --git a/addons/l10n_nl/data/account_account_tag.xml b/addons/l10n_nl/data/account_account_tag.xml
index 73520f6f031..f5519a03ee2 100644
--- a/addons/l10n_nl/data/account_account_tag.xml
+++ b/addons/l10n_nl/data/account_account_tag.xml
@@ -158,7 +158,7 @@
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_06" model="account.account.tag">
- <field name="name">1c. Leveringen/diensten belast met overige tarieven behalve 0% (omzet)</field>
+ <field name="name">1c. Leveringen/diensten belast met overige tarieven, behalve 0% (omzet)</field>
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_07" model="account.account.tag">
@@ -174,7 +174,7 @@
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_10" model="account.account.tag">
- <field name="name">2a. Leveringen/diensten waarbij de heffing van omzetbelasting naar u is verlegd (omzet)</field>
+ <field name="name">2a. Leveringen/diensten waarbij de omzetbelasting naar u is verlegd (omzet)</field>
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_11" model="account.account.tag">
@@ -187,11 +187,11 @@
</record>
<!-- Keep tag_nl_40 & tag_nl_41 to avoid migration issue. -->
<record id="tag_nl_40" model="account.account.tag">
- <field name="name">3b. Leveringen naar/diensten in landen binnen de EU (omzet) (producten)</field>
+ <field name="name">3b. Leveringen naar of diensten in landen binnen de EU (omzet) (producten)</field>
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_41" model="account.account.tag">
- <field name="name">3b. Leveringen naar/diensten in landen binnen de EU (omzet) (diensten)</field>
+ <field name="name">3b. Leveringen naar of diensten in landen binnen de EU (omzet) (diensten)</field>
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_14" model="account.account.tag">
@@ -231,7 +231,7 @@
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_23" model="account.account.tag">
- <field name="name">1c. Leveringen/diensten belast met overige tarieven behalve 0% (BTW)</field>
+ <field name="name">1c. Leveringen/diensten belast met overige tarieven, behalve 0% (BTW)</field>
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_24" model="account.account.tag">
@@ -247,7 +247,7 @@
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_27" model="account.account.tag">
- <field name="name">2a. Leveringen/diensten waarbij de heffing van Heffing van omzetbelasting naar u is verlegd (BTW)</field>
+ <field name="name">2a. Leveringen/diensten waarbij de omzetbelasting naar u is verlegd (BTW)</field>
<field name="applicability">taxes</field>
</record>
<record id="tag_nl_28" model="account.account.tag">
@@ -297,3 +297,4 @@
</data>
</odoo>
+
diff --git a/addons/l10n_nl/data/account_tax_template.xml b/addons/l10n_nl/data/account_tax_template.xml
index 0eccb372d52..44f42f7d71b 100644
--- a/addons/l10n_nl/data/account_tax_template.xml
+++ b/addons/l10n_nl/data/account_tax_template.xml
@@ -331,8 +331,6 @@
<field name="description">21% BTW verlegd</field>
<field eval="21" name="amount"/>
<field name="amount_type">group</field>
- <field name="refund_account_id" ref="vat_payable_v"/>
- <field name="account_id" ref="vat_payable_v"/>
<field name="type_tax_use">purchase</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_10')])]"/>
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_ink_0_1'), ref('btw_ink_0_2')])]"/>
diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml
index d2083aa1b65..a566d1e82ac 100644
--- a/addons/mail/data/mail_data.xml
+++ b/addons/mail/data/mail_data.xml
@@ -264,8 +264,8 @@
Best regards,
</div>
<div>&amp;nbsp;</div>
- <div t-if="record.user_id and record.user_id.signature" style="font-size: 13px;">
- <div t-raw="record.user_id.signature"/>
+ <div t-if="record.user_id and record.user_id.sudo().signature" style="font-size: 13px;">
+ <div t-raw="record.user_id.sudo().signature"/>
</div>
</td>
</tr>
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index 77469f2fe94..51e87a86982 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -534,6 +534,16 @@ class Message(models.Model):
failures_infos = []
# for each channel, build the information header and include the logged partner information
for message in self:
+ # Check if user has access to the record before displaying a notification about it.
+ # In case the user switches from one company to another, it might happen that he doesn't
+ # have access to the record related to the notification. In this case, we skip it.
+ if message.model and message.res_id:
+ record = self.env[message.model].browse(message.res_id)
+ try:
+ record.check_access_rights('read')
+ record.check_access_rule('read')
+ except AccessError:
+ continue
info = {
'message_id': message.id,
'record_name': message.record_name,
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index a27a8464c9c..82d45e8dce3 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -1335,7 +1335,8 @@ class MailThread(models.AbstractModel):
subtype = 'mail.mt_note'
if message_dict.get('parent_id'):
parent_message = self.env['mail.message'].sudo().browse(message_dict['parent_id'])
- partner_ids = [(4, parent_message.author_id.id)]
+ if parent_message.author_id:
+ partner_ids = [(4, parent_message.author_id.id)]
else:
subtype = 'mail.mt_comment'
@@ -2062,7 +2063,7 @@ class MailThread(models.AbstractModel):
prioritary_attachments = all_attachments.filtered(lambda x: x.mimetype.endswith('pdf')) \
or all_attachments.filtered(lambda x: x.mimetype.startswith('image')) \
or all_attachments
- self.write({'message_main_attachment_id': prioritary_attachments[0].id})
+ self.sudo().write({'message_main_attachment_id': prioritary_attachments[0].id})
# Notify recipients of the newly-created message (Inbox / Email + channels)
if msg_vals.get('moderation_status') != 'pending_moderation':
message._notify(
diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js
index b1f43e495c7..d99abb11e7d 100644
--- a/addons/mail/static/src/js/activity.js
+++ b/addons/mail/static/src/js/activity.js
@@ -273,7 +273,7 @@ var BasicActivity = AbstractField.extend({
var self = this;
var $markDoneBtn = $(ev.currentTarget);
var activityID = $markDoneBtn.data('activity-id');
- var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id');
+ var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id') || false;
var forceNextActivity = $markDoneBtn.data('force-next-activity');
if ($markDoneBtn.data('toggle') == 'collapse') {
diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js
index da23521f65f..330e56b77b4 100644
--- a/addons/mail/static/src/js/chatter.js
+++ b/addons/mail/static/src/js/chatter.js
@@ -300,6 +300,7 @@ var Chatter = Widget.extend({
recordName: this.recordName,
defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(),
defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(),
+ attachmentIds: (oldComposer && oldComposer.get('attachment_ids')) || [],
});
this._composer.on('input_focused', this, function () {
this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []);
@@ -309,9 +310,7 @@ var Chatter = Widget.extend({
if (oldComposer) {
oldComposer.destroy();
}
- if (!config.device.isMobile) {
- self._composer.focus();
- }
+ self._composer.focus();
self._composer.on('post_message', self, function (messageData) {
self._discardOnReload(messageData).then(function () {
self._disableComposer();
@@ -321,7 +320,7 @@ var Chatter = Widget.extend({
self.trigger_up('reload');
} else if (messageData.attachment_ids.length) {
self._reloadAttachmentBox();
- self.trigger_up('reload', {fieldNames: ['message_attachment_count']});
+ self.trigger_up('reload', {fieldNames: ['message_attachment_count'], keepChanges: true});
}
}).fail(function () {
self._enableComposer();
diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js
index af503ff2711..eef11bbab33 100644
--- a/addons/mail/static/src/js/composers/basic_composer.js
+++ b/addons/mail/static/src/js/composers/basic_composer.js
@@ -58,7 +58,7 @@ var BasicComposer = Widget.extend({
// Attachments
this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context);
this.fileuploadID = _.uniqueId('o_chat_fileupload');
- this.set('attachment_ids', []);
+ this.set('attachment_ids', options.attachmentIds || []);
// Mention
this._mentionManager = new MentionManager(this);
@@ -120,6 +120,7 @@ var BasicComposer = Widget.extend({
});
// Attachments
+ this._renderAttachments();
$(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this));
this.on('change:attachment_ids', this, this._renderAttachments);
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 c8e05957afb..4971e7f3f4b 100644
--- a/addons/mail/static/src/js/services/mail_window_manager.js
+++ b/addons/mail/static/src/js/services/mail_window_manager.js
@@ -101,31 +101,37 @@ MailManager.include({
// valid threadID, therefore no check
var thread = this.getThread(threadID);
var threadWindow = this._getThreadWindow(threadID);
+ var def = $.when();
if (!threadWindow) {
- threadWindow = this._makeNewThreadWindow(thread, options);
- this._placeNewThreadWindow(threadWindow, options.passively);
-
- threadWindow.appendTo($(this.THREAD_WINDOW_APPENDTO))
- .then(function () {
- self._repositionThreadWindows();
- return thread.fetchMessages();
- }).then(function () {
- threadWindow.render();
- threadWindow.scrollToBottom();
- if (
- !self._areAllThreadWindowsHidden() &&
- !thread.isFolded() &&
- !threadWindow.isPassive()
- ) {
- thread.markAsRead();
- }
- });
+ def = thread.fetchMessages().then(function () {
+ threadWindow = self._makeNewThreadWindow(thread, options);
+ self._placeNewThreadWindow(threadWindow, options.passively);
+ return threadWindow.appendTo($(self.THREAD_WINDOW_APPENDTO));
+ }).then(function () {
+ self._repositionThreadWindows();
+ threadWindow.render();
+ threadWindow.scrollToBottom();
+ if (
+ !self._areAllThreadWindowsHidden() &&
+ !thread.isFolded() &&
+ !threadWindow.isPassive()
+ ) {
+ thread.markAsRead();
+ }
+ }).fail(function () {
+ // 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();
+ });
} else if (!options.passively) {
if (threadWindow.isHidden()) {
this._makeThreadWindowVisible(threadWindow);
}
}
- threadWindow.updateVisualFoldState();
+ def.then(function () {
+ threadWindow.updateVisualFoldState();
+ });
},
/**
* Called when a thread has its window state that has been changed, so its
diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js
index 2b42167861c..593cac91506 100644
--- a/addons/mail/static/src/js/systray/systray_activity_menu.js
+++ b/addons/mail/static/src/js/systray/systray_activity_menu.js
@@ -46,7 +46,7 @@ var ActivityMenu = Widget.extend({
kwargs: {context: session.user_context},
}).then(function (data) {
self._activities = data;
- self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0);
+ self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count || 0; }, 0);
self.$('.o_notification_counter').text(self.activityCounter);
self.$el.toggleClass('o_no_notification', !self.activityCounter);
});
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 57907a55943..331228c91c8 100644
--- a/addons/mail/static/src/js/thread_windows/thread_window.js
+++ b/addons/mail/static/src/js/thread_windows/thread_window.js
@@ -280,8 +280,10 @@ var ThreadWindow = AbstractThreadWindow.extend({
clear_breadcrumbs: false,
active_id: this.hasThread() ? this._getThreadID() : undefined,
on_reverse_breadcrumb: function () {
- self.call('mail_service', 'getMailBus')
- .trigger('discuss_open', false);
+ var mailBus = self.call('mail_service', 'getMailBus');
+ if (mailBus) {
+ mailBus.trigger('discuss_open', false);
+ }
},
});
}
diff --git a/addons/mail/static/src/scss/followers.scss b/addons/mail/static/src/scss/followers.scss
index b2bbc77f7f2..934df9b7acc 100644
--- a/addons/mail/static/src/scss/followers.scss
+++ b/addons/mail/static/src/scss/followers.scss
@@ -84,6 +84,8 @@
.o_followers_list {
font-size: small;
+ max-height: 50vh; // ancestor has size 33px
+ overflow: auto;
.o_partner {
position: relative; // required for old design compatibility
diff --git a/addons/mail/static/tests/document_thread_window_tests.js b/addons/mail/static/tests/document_thread_window_tests.js
index 811b01f62e4..93095362306 100644
--- a/addons/mail/static/tests/document_thread_window_tests.js
+++ b/addons/mail/static/tests/document_thread_window_tests.js
@@ -271,6 +271,37 @@ QUnit.test('open, fold, unfold and close a document thread window', function (as
messagingMenu.destroy();
});
+QUnit.test('do not open thread window on fetch message failure', function (assert) {
+ // this may happen when the user receives a notification from a document
+ // that he does not have access rights at the moment.
+ assert.expect(4);
+
+ var messagingMenu = new MessagingMenu();
+ testUtils.addMockEnvironment(messagingMenu, {
+ services: this.services,
+ data: this.data,
+ session: this.session,
+ mockRPC: function (route, args) {
+ if (args.method === 'read' && args.model === 'some.res.model' && args.args[0][0] === 1) {
+ assert.step('some.res.model/1/read');
+ return $.Deferred().reject(); // simulate failure
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
+ assert.containsOnce(messagingMenu, '.o_mail_preview');
+
+ testUtils.dom.click(messagingMenu.$('.o_mail_preview'));
+ assert.verifySteps(['some.res.model/1/read']);
+ assert.strictEqual($('.o_thread_window').length, 0,
+ "should not have open the DocumentThread in a thread window on fetch messages failure");
+
+ messagingMenu.destroy();
+});
+
});
});
});
diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py
index 6afa7a98d09..c542abccfba 100644
--- a/addons/mass_mailing/models/__init__.py
+++ b/addons/mass_mailing/models/__init__.py
@@ -4,6 +4,7 @@
from . import mass_mailing
from . import mass_mailing_stats
from . import mail_mail
+from . import mail_template
from . import mail_thread
from . import res_config_settings
from . import mass_mailing_report
diff --git a/addons/mass_mailing/models/mail_template.py b/addons/mass_mailing/models/mail_template.py
new file mode 100644
index 00000000000..68005ee0d10
--- /dev/null
+++ b/addons/mass_mailing/models/mail_template.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+
+class MailTemplate(models.Model):
+ _inherit = "mail.template"
+
+ @api.model
+ def render_post_process(self, html):
+ # super will transform relative url to absolute
+ html = super(MailTemplate, self).render_post_process(html)
+
+ # apply shortener after
+ if self.env.context.get('post_convert_links'):
+ html = self.env['link.tracker'].convert_links(
+ html,
+ self.env.context['post_convert_links'],
+ blacklist=['/unsubscribe_from_list']
+ )
+ return html
diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py
index a4187e0ec6e..943059eb6d9 100644
--- a/addons/mass_mailing/models/mass_mailing.py
+++ b/addons/mass_mailing/models/mass_mailing.py
@@ -779,19 +779,47 @@ class MassMailing(models.Model):
_logger.info("Mass-mailing %s targets %s, no opt out list available", self, target._name)
return opt_out
+ def _get_convert_links(self):
+ self.ensure_one()
+ utm_mixin = self.mass_mailing_campaign_id if self.mass_mailing_campaign_id else self
+ vals = {'mass_mailing_id': self.id}
+
+ if self.mass_mailing_campaign_id:
+ vals['mass_mailing_campaign_id'] = self.mass_mailing_campaign_id.id
+ if utm_mixin.campaign_id:
+ vals['campaign_id'] = utm_mixin.campaign_id.id
+ if utm_mixin.source_id:
+ vals['source_id'] = utm_mixin.source_id.id
+ if utm_mixin.medium_id:
+ vals['medium_id'] = utm_mixin.medium_id.id
+ return vals
+
def _get_seen_list(self):
"""Returns a set of emails already targeted by current mailing/campaign (no duplicates)"""
self.ensure_one()
target = self.env[self.mailing_model_real]
- mail_field = 'email' if 'email' in target._fields else 'email_from'
- # avoid loading a large number of records in memory
- # + use a basic heuristic for extracting emails
- query = """
- SELECT lower(substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)'))
- FROM mail_mail_statistics s
- JOIN %(target)s t ON (s.res_id = t.id)
- WHERE substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL
- """
+ if set(['email', 'email_from']) & set(target._fields):
+ mail_field = 'email' if 'email' in target._fields else 'email_from'
+ # avoid loading a large number of records in memory
+ # + use a basic heuristic for extracting emails
+ query = """
+ SELECT lower(substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)'))
+ FROM mail_mail_statistics s
+ JOIN %(target)s t ON (s.res_id = t.id)
+ WHERE substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL
+ """
+ elif 'partner_id' in target._fields:
+ mail_field = 'email'
+ query = """
+ SELECT lower(substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)'))
+ FROM mail_mail_statistics s
+ JOIN %(target)s t ON (s.res_id = t.id)
+ JOIN res_partner p ON (t.partner_id = p.id)
+ WHERE substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL
+ """
+ else:
+ raise UserError(_("Unsupported mass mailing model %s") % self.mailing_model_id.name)
+
if self.mass_mailing_campaign_id.unique_ab_testing:
query +="""
AND s.mass_mailing_campaign_id = %%(mailing_campaign_id)s;
@@ -814,6 +842,7 @@ class MassMailing(models.Model):
return {
'mass_mailing_opt_out_list': self._get_opt_out_list(),
'mass_mailing_seen_list': self._get_seen_list(),
+ 'post_convert_links': self._get_convert_links(),
}
def get_recipients(self):
@@ -855,13 +884,10 @@ class MassMailing(models.Model):
if not res_ids:
raise UserError(_('There is no recipients selected.'))
- # Convert links in absolute URLs before the application of the shortener
- mailing.body_html = self.env['mail.thread']._replace_local_links(mailing.body_html)
-
composer_values = {
'author_id': author_id,
'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids],
- 'body': mailing.convert_links()[mailing.id],
+ 'body': mailing.body_html,
'subject': mailing.name,
'model': mailing.mailing_model_real,
'email_from': mailing.email_from,
diff --git a/addons/mass_mailing/tests/__init__.py b/addons/mass_mailing/tests/__init__.py
index b02648f85eb..36745d1525a 100644
--- a/addons/mass_mailing/tests/__init__.py
+++ b/addons/mass_mailing/tests/__init__.py
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_mass_mailing_list_merge
+from . import test_mass_mailing_shortener
diff --git a/addons/mass_mailing/tests/test_mass_mailing_shortener.py b/addons/mass_mailing/tests/test_mass_mailing_shortener.py
new file mode 100644
index 00000000000..99e3de1e864
--- /dev/null
+++ b/addons/mass_mailing/tests/test_mass_mailing_shortener.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import common
+from lxml import etree
+
+try:
+ from unittest.mock import patch
+except ImportError:
+ from mock import patch
+
+
+class TestMassMailingShortener(common.TransactionCase):
+ def getHrefFor(self, html, id):
+ return html.xpath("*[@id='%s']" % id)[0].attrib.get('href')
+
+ def shorturl_to_link(self, short_url):
+ return self.env['link.tracker.code'].search([('code', '=', short_url.split('/r/')[-1])]).link_id
+
+ def setUp(self):
+ super(TestMassMailingShortener, self).setUp()
+
+ def _get_title_from_url(u):
+ return "Hello"
+
+ def _compute_favicon():
+ # 1px to avoid real request
+ return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='
+
+ patcher = patch('odoo.addons.link_tracker.models.link_tracker.link_tracker._compute_favicon', wraps=_compute_favicon)
+ patcher2 = patch('odoo.addons.link_tracker.models.link_tracker.link_tracker._get_title_from_url', wraps=_get_title_from_url)
+ patcher.start()
+ patcher2.start()
+ self.addCleanup(patcher.stop)
+ self.addCleanup(patcher2.stop)
+
+ def test_00_test_mass_mailing_shortener(self):
+ mailing_list_A = self.env['mail.mass_mailing.list'].create({
+ 'name': 'A',
+ })
+ self.env['mail.mass_mailing.contact'].create({
+ 'name': 'User 1', 'email': 'user1@example.com', 'list_ids': [(4, mailing_list_A.id)]
+ })
+ self.env['mail.mass_mailing.contact'].create({
+ 'name': 'User 2', 'email': 'user2@example.com', 'list_ids': [(4, mailing_list_A.id)]
+ })
+ self.env['mail.mass_mailing.contact'].create({
+ 'name': 'User 3', 'email': 'user3@example.com', 'list_ids': [(4, mailing_list_A.id)]
+ })
+
+ mass_mailing = self.env['mail.mass_mailing'].create({
+ "reply_to_mode": "email",
+ "reply_to": "Administrator <admin@yourcompany.example.com>",
+ "mailing_model_id": self.env.ref('mass_mailing.model_mail_mass_mailing_list').id,
+ "mailing_domain": "[('list_ids', 'in', [%d])]" % mailing_list_A.id,
+ "contact_list_ids": [[6, False, [mailing_list_A.id]]],
+ "mass_mailing_campaign_id": False,
+ "name": "sdf",
+ "body_html": """
+Hi,
+% set url = "www.odoo.com"
+% set httpurl = "https://www.odoo.eu"
+Website0: <a id="url0" href="https://www.odoo.tz/my/${object.name}">https://www.odoo.tz/my/${object.name}</h1>
+Website1: <a id="url1" href="https://www.odoo.be">https://www.odoo.be</h1>
+Website2: <a id="url2" href="https://${url}">https://${url}</h1>
+Website3: <a id="url3" href="${httpurl}">${httpurl}</h1>
+Email: <a id="url4" href="mailto:test@odoo.com">test@odoo.com</h1>
+ """,
+ "schedule_date": False,
+ "state": "draft",
+ "keep_archives": True,
+ })
+
+ mass_mailing.put_in_queue()
+ mass_mailing._process_mass_mailing_queue()
+
+ sent_mails = self.env['mail.mail'].search([('mailing_id', '=', mass_mailing.id)])
+ sent_messages = sent_mails.mapped('mail_message_id')
+
+ self.assertEqual(mailing_list_A.contact_nbr, len(sent_messages),
+ 'Some message has not been sent')
+
+ xbody = etree.fromstring(sent_messages[0].body)
+ after_url0 = self.getHrefFor(xbody, 'url0')
+ after_url1 = self.getHrefFor(xbody, 'url1')
+ after_url2 = self.getHrefFor(xbody, 'url2')
+ after_url3 = self.getHrefFor(xbody, 'url3')
+ after_url4 = self.getHrefFor(xbody, 'url4')
+
+ self.assertTrue('/r/' in after_url0, 'URL0 should be shortened: %s' % after_url0)
+ self.assertTrue('/r/' in after_url1, 'URL1 should be shortened: %s' % after_url1)
+ self.assertTrue('/r/' in after_url2, 'URL2 should be shortened: %s' % after_url2)
+ self.assertTrue('/r/' in after_url3, 'URL3 should be shortened: %s' % after_url3)
+ self.assertEqual(after_url4, "mailto:test@odoo.com", 'mailto: has been converted')
+
+ short0 = self.shorturl_to_link(after_url0)
+ short1 = self.shorturl_to_link(after_url1)
+ short2 = self.shorturl_to_link(after_url2)
+ short3 = self.shorturl_to_link(after_url3)
+
+ self.assertTrue("https://www.odoo.tz/my/User" in short0.url, 'URL mismatch')
+ self.assertEqual(short1.url, "https://www.odoo.be", 'URL mismatch')
+ self.assertEqual(short2.url, "https://www.odoo.com", 'URL mismatch')
+ self.assertEqual(short3.url, "https://www.odoo.eu", 'URL mismatch')
+
+ _xbody = etree.fromstring(sent_messages[1].body)
+ _after_url0 = self.getHrefFor(_xbody, 'url0')
+ _after_url1 = self.getHrefFor(_xbody, 'url1')
+ _after_url2 = self.getHrefFor(_xbody, 'url2')
+ _after_url3 = self.getHrefFor(_xbody, 'url3')
+ _after_url4 = self.getHrefFor(_xbody, 'url4')
+
+ self.assertTrue('/r/' in _after_url0, 'URL0 should be shortened: %s' % _after_url0)
+ self.assertTrue('/r/' in _after_url1, 'URL1 should be shortened: %s' % _after_url1)
+ self.assertTrue('/r/' in _after_url2, 'URL2 should be shortened: %s' % _after_url2)
+ self.assertTrue('/r/' in _after_url3, 'URL3 should be shortened: %s' % _after_url3)
+ self.assertEqual(_after_url4, "mailto:test@odoo.com", 'mailto: has been converted')
+
+ _short0 = self.shorturl_to_link(_after_url0)
+ _short1 = self.shorturl_to_link(_after_url1)
+ _short2 = self.shorturl_to_link(_after_url2)
+ _short3 = self.shorturl_to_link(_after_url3)
+
+ self.assertTrue("https://www.odoo.tz/my/User" in _short0.url, 'URL mismatch')
+ self.assertEqual(_short1.url, "https://www.odoo.be", 'URL mismatch')
+ self.assertEqual(_short2.url, "https://www.odoo.com", 'URL mismatch')
+ self.assertEqual(_short3.url, "https://www.odoo.eu", 'URL mismatch')
+
+ self.assertNotEqual(short0.url, _short0.url)
+ self.assertEqual(short1.url, _short1.url)
+ self.assertEqual(short2.url, _short2.url)
+ self.assertEqual(short3.url, _short3.url)
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index 6310ffd8b82..c7942656e4d 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -5,7 +5,7 @@ from collections import defaultdict
from odoo import api, fields, models, _
from odoo.addons import decimal_precision as dp
-from odoo.exceptions import UserError
+from odoo.exceptions import AccessError, UserError
from odoo.tools import float_compare, float_round
class MrpProduction(models.Model):
@@ -30,6 +30,10 @@ class MrpProduction(models.Model):
location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_src_id
if not location:
location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False)
+ try:
+ location.check_access_rule('read')
+ except (AttributeError, AccessError):
+ location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id
return location and location.id or False
@api.model
@@ -39,6 +43,10 @@ class MrpProduction(models.Model):
location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_dest_id
if not location:
location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False)
+ try:
+ location.check_access_rule('read')
+ except (AttributeError, AccessError):
+ location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id
return location and location.id or False
name = fields.Char(
@@ -397,6 +405,10 @@ class MrpProduction(models.Model):
@api.onchange('picking_type_id')
def onchange_picking_type(self):
location = self.env.ref('stock.stock_location_stock')
+ try:
+ location.check_access_rule('read')
+ except (AttributeError, AccessError):
+ location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id
self.move_raw_ids.update({'picking_type_id': self.picking_type_id})
self.location_src_id = self.picking_type_id.default_location_src_id.id or location.id
self.location_dest_id = self.picking_type_id.default_location_dest_id.id or location.id
diff --git a/addons/mrp/models/mrp_unbuild.py b/addons/mrp/models/mrp_unbuild.py
index e33e0dfbea5..2bac0c1d0f7 100644
--- a/addons/mrp/models/mrp_unbuild.py
+++ b/addons/mrp/models/mrp_unbuild.py
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
-from odoo.exceptions import UserError
+from odoo.exceptions import AccessError, UserError
from odoo.tools import float_compare
@@ -13,10 +13,20 @@ class MrpUnbuild(models.Model):
_order = 'id desc'
def _get_default_location_id(self):
- return self.env.ref('stock.stock_location_stock', raise_if_not_found=False)
+ stock_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False)
+ try:
+ stock_location.check_access_rule('read')
+ return stock_location.id
+ except (AttributeError, AccessError):
+ return self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id.id
def _get_default_location_dest_id(self):
- return self.env.ref('stock.stock_location_stock', raise_if_not_found=False)
+ stock_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False)
+ try:
+ stock_location.check_access_rule('read')
+ return stock_location.id
+ except (AttributeError, AccessError):
+ return self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id.id
name = fields.Char('Reference', copy=False, readonly=True, default=lambda x: _('New'))
product_id = fields.Many2one(
diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml
index 58c1fb6014b..1a05dbc0b4a 100644
--- a/addons/mrp/views/mrp_unbuild_views.xml
+++ b/addons/mrp/views/mrp_unbuild_views.xml
@@ -128,8 +128,8 @@
</group>
<group>
<field name="mo_id" domain="[('state','=','done')]"/>
- <field name="location_id" groups="stock.group_stock_multi_locations"/>
- <field name="location_dest_id" groups="stock.group_stock_multi_locations"/>
+ <field name="location_id" groups="stock.group_stock_multi_locations" domain="[('usage','=','internal')]"/>
+ <field name="location_dest_id" groups="stock.group_stock_multi_locations" domain="[('usage','=','internal')]"/>
<field name="has_tracking" invisible="1"/>
<field name="lot_id" attrs="{'invisible': [('has_tracking', '=', 'none')], 'required': [('has_tracking', '!=', 'none')]}" groups="stock.group_production_lot"/>
</group>
diff --git a/addons/mrp/views/mrp_workcenter_views.xml b/addons/mrp/views/mrp_workcenter_views.xml
index 8172349a27b..906cbc0070b 100644
--- a/addons/mrp/views/mrp_workcenter_views.xml
+++ b/addons/mrp/views/mrp_workcenter_views.xml
@@ -171,7 +171,7 @@
<div class="col-6 o_kanban_primary_left">
<div class="btn-group" name="o_wo">
<t t-if="record.workorder_count.raw_value &gt; 0">
- <button class="btn btn-primary" name="%(action_work_orders)d" type="action" context="{'search_default_ready': 1, 'search_default_progress': 1, 'search_default_pending': 1}">
+ <button class="btn btn-primary" name="%(action_work_orders)d" type="action" context="{'search_default_ready': 1, 'search_default_progress': 1}">
<span>WORK ORDERS</span>
</button>
</t>
diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py
index 9cdcbec12af..6e3476099fd 100644
--- a/addons/pad/models/pad.py
+++ b/addons/pad/models/pad.py
@@ -18,6 +18,7 @@ _logger = logging.getLogger(__name__)
class PadCommon(models.AbstractModel):
_name = 'pad.common'
+ _description = 'Pad Common'
@api.model
def pad_is_configured(self):
diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py
index ba2a2551892..516eb8712a9 100644
--- a/addons/payment/controllers/portal.py
+++ b/addons/payment/controllers/portal.py
@@ -100,7 +100,10 @@ class PaymentProcessing(http.Controller):
class WebsitePayment(http.Controller):
@http.route(['/my/payment_method'], type='http', auth="user", website=True)
def payment_method(self, **kwargs):
- acquirers = list(request.env['payment.acquirer'].search([('website_published', '=', True), ('registration_view_template_id', '!=', False), ('payment_flow', '=', 's2s')]))
+ acquirers = list(request.env['payment.acquirer'].search([
+ ('website_published', '=', True), ('registration_view_template_id', '!=', False),
+ ('payment_flow', '=', 's2s'), ('company_id', '=', request.env.user.company_id.id)
+ ]))
partner = request.env.user.partner_id
payment_tokens = partner.payment_token_ids
payment_tokens |= partner.commercial_partner_id.sudo().payment_token_ids
diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml
index bac8783fcea..8328fac20c8 100644
--- a/addons/payment/views/payment_portal_templates.xml
+++ b/addons/payment/views/payment_portal_templates.xml
@@ -34,7 +34,7 @@
<!-- TDE FIXME: was customize_show=True -->
<template id="pay_meth_link" inherit_id="portal.portal_layout">
<xpath expr="//div[hasclass('o_portal_my_details')]" position="inside">
- <t t-if="request.env['payment.acquirer'].search([('website_published', '=', True), ('registration_view_template_id', '!=', False), ('payment_flow', '=', 's2s')])">
+ <t t-if="request.env['payment.acquirer'].search([('website_published', '=', True), ('registration_view_template_id', '!=', False), ('payment_flow', '=', 's2s'), ('company_id', '=', request.env.user.company_id.id)])">
<div class='manage_payment_method mt16'><a href="/my/payment_method">Manage your payment methods</a></div>
</t>
</xpath>
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index e133f006f7c..364aa6cd96e 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -583,21 +583,21 @@ class PosOrder(models.Model):
@api.onchange('statement_ids', 'lines')
def _onchange_amount_all(self):
- amounts = {order_id: {'paid': 0, 'return': 0, 'untaxed': 0} for order_id in self.ids}
+ amounts = {order_id: {'paid': 0, 'return': 0, 'taxed': 0, 'taxes': 0} for order_id in self.ids}
for order in self.env['account.bank.statement.line'].read_group([('pos_statement_id', 'in', self.ids)], ['pos_statement_id', 'amount'], ['pos_statement_id']):
amounts[order['pos_statement_id'][0]]['paid'] = order['amount']
for order in self.env['account.bank.statement.line'].read_group(['&', ('pos_statement_id', 'in', self.ids), ('amount', '<', 0)], ['pos_statement_id', 'amount'], ['pos_statement_id']):
amounts[order['pos_statement_id'][0]]['return'] = order['amount']
- for order in self.env['pos.order.line'].read_group([('order_id', 'in', self.ids)], ['order_id', 'price_subtotal'], ['order_id']):
- amounts[order['order_id'][0]]['untaxed'] = order['price_subtotal']
+ for order in self.env['pos.order.line'].read_group([('order_id', 'in', self.ids)], ['order_id', 'price_subtotal', 'price_subtotal_incl'], ['order_id']):
+ amounts[order['order_id'][0]]['taxed'] = order['price_subtotal_incl']
+ amounts[order['order_id'][0]]['taxes'] = order['price_subtotal_incl'] - order['price_subtotal']
for order in self:
currency = order.pricelist_id.currency_id
order.amount_paid = amounts[order.id]['paid']
order.amount_return = amounts[order.id]['return']
- order.amount_tax = currency.round(sum(self._amount_line_tax(line, order.fiscal_position_id) for line in order.lines))
- amount_untaxed = currency.round(amounts[order.id]['untaxed'])
- order.amount_total = order.amount_tax + amount_untaxed
+ order.amount_tax = currency.round(amounts[order.id]['taxes'])
+ order.amount_total = currency.round(amounts[order.id]['taxed'])
@api.onchange('partner_id')
def _onchange_partner_id(self):
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index a1c1c11340c..912858825fb 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -876,12 +876,20 @@ exports.PosModel = Backbone.Model.extend({
transfer.pipe(function(order_server_id){
// generate the pdf and download it
- self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{
- active_ids:order_server_id,
- }}).done(function () {
- invoiced.resolve();
- done.resolve();
- });
+ if (order_server_id.length) {
+ self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{
+ active_ids:order_server_id,
+ }}).done(function () {
+ invoiced.resolve();
+ done.resolve();
+ });
+ } else {
+ // The order has been pushed separately in batch when
+ // the connection came back.
+ // The user has to go to the backend to print the invoice
+ invoiced.reject({code:401, message:'Backend Invoice', data:{order: order}});
+ done.reject();
+ }
});
return done;
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index b82bcb38eb5..4420434229a 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -2062,6 +2062,17 @@ var PaymentScreenWidget = ScreenWidget.extend({
self.gui.show_screen('clientlist');
},
});
+ } else if (error.message === 'Backend Invoice') {
+ self.gui.show_popup('confirm',{
+ 'title': _t('Please print the invoice from the backend'),
+ 'body': _t('The order has been synchronized earlier. Please make the invoice from the backend for the order: ') + error.data.order.name,
+ confirm: function () {
+ this.gui.show_screen('receipt');
+ },
+ cancel: function () {
+ this.gui.show_screen('receipt');
+ },
+ });
} else if (error.code < 0) { // XmlHttpRequest Errors
self.gui.show_popup('error',{
'title': _t('The order could not be sent'),
diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss
index 26beabb5865..4bbb741a653 100644
--- a/addons/portal/static/src/scss/portal.scss
+++ b/addons/portal/static/src/scss/portal.scss
@@ -162,6 +162,19 @@ hr {
.container-fluid .container-fluid {
@extend %o-double-container-no-padding;
}
+#wrap {
+ > .container, > .container-fluid {
+ // BS3 used to do this on all containers so that margins and floats are
+ // cleared inside containers. As lots of current odoo layouts may rely
+ // on this for some alignments, this is restored (at least for a while)
+ // here only for main containers of the frontend.
+ &::before, &::after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+ }
+}
[class^="col-lg-"] {
min-height: 24px;
}
diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml
index 02f118e2bae..34e9cf3a9ef 100644
--- a/addons/portal/views/portal_templates.xml
+++ b/addons/portal/views/portal_templates.xml
@@ -439,7 +439,7 @@
<t t-if='prev_record or next_record'>
<div class="record_pager btn-group" role="group">
<a role="button" t-att-class="'btn btn-link %s' % ('disabled' if not prev_record else '')" t-att-href="prev_record or '#'" ><i class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"></i></a>
- <a role="button" t-att-class="'btn btn-link %s' % ('disabled' if not next_record else '')" t-att-href="next_record or '#'" ><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Previous"></i></a>
+ <a role="button" t-att-class="'btn btn-link %s' % ('disabled' if not next_record else '')" t-att-href="next_record or '#'" ><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"></i></a>
</div>
</t>
</template>
diff --git a/addons/pos_cache/models/pos_cache.py b/addons/pos_cache/models/pos_cache.py
index 86dbfdfb39f..e7b779581d9 100644
--- a/addons/pos_cache/models/pos_cache.py
+++ b/addons/pos_cache/models/pos_cache.py
@@ -9,6 +9,7 @@ from odoo import models, fields, api
class pos_cache(models.Model):
_name = 'pos.cache'
+ _description = 'Point of Sale Cache'
cache = fields.Binary(attachment=True)
product_domain = fields.Text(required=True)
diff --git a/addons/product/report/product_product_templates.xml b/addons/product/report/product_product_templates.xml
index 2a612f556bc..f8213d924b0 100644
--- a/addons/product/report/product_product_templates.xml
+++ b/addons/product/report/product_product_templates.xml
@@ -1,75 +1,80 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<odoo>
- <data>
- <template id="report_simple_label">
- <div class="col-4" style="padding:0;">
- <table class="table table-condensed" style="border-bottom: 0px solid white !important;width: 3in;">
- <tr>
- <th style="text-align: left;">
- <strong t-field="product.display_name"/>
- </th>
- </tr>
- <tr>
- <td>
- <strong>Price:</strong>
- <strong t-field="product.lst_price" t-options="{'widget': 'monetary', 'display_currency': product.company_id.currency_id}"/>
- </td>
- </tr>
- <t t-if="product.barcode">
- <tr>
- <td style="text-align: center; vertical-align: middle;" class="col-5">
- <img alt="Barcode" t-if="len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <img alt="Barcode" t-elif="len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <img alt="Barcode" t-else="" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <span t-field="product.barcode"/>
- </td>
- </tr>
- </t>
- </table>
- </div>
- </template>
- <template id="report_productlabel">
- <t t-call="web.basic_layout">
- <div class="page">
- <t t-foreach="docs" t-as="product">
- <t t-call="product.report_simple_label">
- <t t-set="product" t-value="product"/>
- </t>
- </t>
- </div>
- </t>
- </template>
- <template id="report_simple_barcode">
- <div class="col-4" style="padding:0;">
- <table class="table table-condensed" style="border-bottom: 0px solid white !important;width: 3in;">
- <tr>
- <th style="text-align: left;">
- <strong t-field="product.display_name"/>
- </th>
- </tr>
- <t t-if="product.barcode">
- <tr>
- <td style="text-align: center; vertical-align: middle;" class="col-5">
- <img alt="Barcode" t-if="len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <img alt="Barcode" t-elif="len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <img alt="Barcode" t-else="" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <span t-field="product.barcode"/>
- </td>
- </tr>
- </t>
- </table>
- </div>
- </template>
- <template id="report_productbarcode">
- <t t-call="web.basic_layout">
- <div class="page">
- <t t-foreach="docs" t-as="product">
- <t t-call="product.report_simple_barcode">
- <t t-set="product" t-value="product"/>
- </t>
- </t>
- </div>
- </t>
- </template>
- </data>
-</odoo>
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+ <data>
+ <template id="report_simple_label">
+ <div style="width: 32%; display: inline-table; height:14rem;">
+ <table class="table table-bordered mb-0" style="border: 2px solid black;">
+ <tr>
+ <th class="table-active text-left" style="height: 4rem;">
+ <strong t-field="product.display_name"/>
+ </th>
+ </tr>
+ <tr>
+ <td style="height: 2rem">
+ <strong>Price:</strong>
+ <strong t-field="product.lst_price" t-options="{'widget': 'monetary', 'display_currency': product.company_id.currency_id}"/>
+ </td>
+ </tr>
+ <tr>
+ <td class="text-center align-middle" style="height: 6rem">
+ <t t-if="product.barcode">
+ <img alt="Barcode" t-if="len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height::4rem;"/>
+ <img alt="Barcode" t-elif="len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height::4rem;"/>
+ <img alt="Barcode" t-else="" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height::4rem;"/>
+ <span t-field="product.barcode"/>
+ </t>
+ <t t-else=""><span class="text-muted">No barcode available</span></t>
+ </td>
+ </tr>
+ </table>
+ </div>
+ </template>
+
+ <template id="report_productlabel">
+ <t t-call="web.basic_layout">
+ <div class="page">
+ <t t-foreach="docs" t-as="product">
+ <t t-call="product.report_simple_label">
+ <t t-set="product" t-value="product"/>
+ </t>
+ </t>
+ </div>
+ </t>
+ </template>
+
+ <template id="report_simple_barcode">
+ <div style="width: 32%; display: inline-table; height: 10rem;">
+ <table class="table table-bordered mb-0" style="border: 2px solid black;">
+ <tr>
+ <th class="table-active text-left" style="height: 4rem;">
+ <strong t-field="product.display_name"/>
+ </th>
+ </tr>
+ <tr>
+ <td class="text-center align-middle" style="height: 6rem;">
+ <t t-if="product.barcode">
+ <img alt="Barcode" t-if="len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height:4rem;"/>
+ <img alt="Barcode" t-elif="len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height:4rem;"/>
+ <img alt="Barcode" t-else="" t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height:4rem"/>
+ <span t-field="product.barcode"/>
+ </t>
+ <t t-else=""><span class="text-muted">No barcode available</span></t>
+ </td>
+ </tr>
+ </table>
+ </div>
+ </template>
+
+ <template id="report_productbarcode">
+ <t t-call="web.basic_layout">
+ <div class="page">
+ <t t-foreach="docs" t-as="product">
+ <t t-call="product.report_simple_barcode">
+ <t t-set="product" t-value="product"/>
+ </t>
+ </t>
+ </div>
+ </t>
+ </template>
+ </data>
+</odoo>
diff --git a/addons/rating/models/mail_message.py b/addons/rating/models/mail_message.py
index f6b3553b7f4..aade3b9ade7 100644
--- a/addons/rating/models/mail_message.py
+++ b/addons/rating/models/mail_message.py
@@ -8,7 +8,7 @@ class MailMessage(models.Model):
_inherit = 'mail.message'
rating_ids = fields.One2many('rating.rating', 'message_id', string='Related ratings')
- rating_value = fields.Float("Rating Value", compute='_compute_rating_value', store=False)
+ rating_value = fields.Float("Rating Value", compute='_compute_rating_value', store=False, search='_search_rating_value')
@api.multi
@api.depends('rating_ids', 'rating_ids.rating')
@@ -17,3 +17,7 @@ class MailMessage(models.Model):
mapping = dict((r.message_id.id, r.rating) for r in ratings)
for message in self:
message.rating_value = mapping.get(message.id, 0.0)
+
+ @api.multi
+ def _search_rating_value(self, operator, operand):
+ return [('rating_ids.rating', operator, operand)]
diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py
index b16c121799b..fd0e8260fc5 100644
--- a/addons/resource/models/resource.py
+++ b/addons/resource/models/resource.py
@@ -196,7 +196,8 @@ class ResourceCalendar(models.Model):
hour_count = 0.0
for attendance in attendances:
hour_count += attendance.hour_to - attendance.hour_from
- self.hours_per_day = float_round(hour_count / float(len(set(attendances.mapped('dayofweek')))), precision_digits=2)
+ if attendances:
+ self.hours_per_day = float_round(hour_count / float(len(set(attendances.mapped('dayofweek')))), precision_digits=2)
# --------------------------------------------------
# Computation API
diff --git a/addons/sale/data/mail_data.xml b/addons/sale/data/mail_data.xml
index 5edabafb06b..a2abc42ff23 100644
--- a/addons/sale/data/mail_data.xml
+++ b/addons/sale/data/mail_data.xml
@@ -103,7 +103,7 @@
</div>
</field>
<field name="report_template" ref="action_report_saleorder"/>
- <field name="report_name">${(object.name or '').replace('/','_')}${object.state == 'draft' and '_draft' or ''}</field>
+ <field name="report_name">${(object.name or '').replace('/','_')}</field>
<field name="lang">${object.partner_id.lang}</field>
<field name="user_signature" eval="False"/>
<field name="auto_delete" eval="True"/>
diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py
index 8fb938582a0..a60dd11b023 100644
--- a/addons/sale/models/analytic.py
+++ b/addons/sale/models/analytic.py
@@ -19,7 +19,7 @@ class AccountAnalyticLine(models.Model):
@api.model
def create(self, values):
result = super(AccountAnalyticLine, self).create(values)
- if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy != 'no' and result.amount <= 0: # allow to force a False value for so_line
+ if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy not in [False, 'no'] and result.amount <= 0: # allow to force a False value for so_line
result.sudo()._sale_determine_order_line()
return result
@@ -28,7 +28,8 @@ class AccountAnalyticLine(models.Model):
result = super(AccountAnalyticLine, self).write(values)
if 'so_line' not in values: # allow to force a False value for so_line
# only take the AAL from expense or vendor bill, meaning having a negative amount
- self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no' and aal.amount <= 0)._sale_determine_order_line()
+ self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy not in [False, 'no'] and aal.amount <= 0)._sale_determine_order_line()
+ return result
# ----------------------------------------------------------
# Vendor Bill / Expense : determine the Sale Order to reinvoice
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index f4b4ceb6826..da8ee7fa25c 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -47,8 +47,8 @@ class SaleOrder(models.Model):
amount_untaxed += line.price_subtotal
amount_tax += line.price_tax
order.update({
- 'amount_untaxed': order.pricelist_id.currency_id.round(amount_untaxed),
- 'amount_tax': order.pricelist_id.currency_id.round(amount_tax),
+ 'amount_untaxed': amount_untaxed,
+ 'amount_tax': amount_tax,
'amount_total': amount_untaxed + amount_tax,
})
@@ -66,12 +66,14 @@ class SaleOrder(models.Model):
for possible refunds created directly from existing invoices. This is necessary since such a
refund is not directly linked to the SO.
"""
+ # Ignore the status of the deposit product
+ deposit_product_id = self.env['sale.advance.payment.inv']._default_product_id()
+ line_invoice_status_all = [(d['order_id'][0], d['invoice_status']) for d in self.env['sale.order.line'].read_group([('order_id', 'in', self.ids), ('product_id', '!=', deposit_product_id.id)], ['order_id', 'invoice_status'], ['order_id', 'invoice_status'], lazy=False)]
for order in self:
invoice_ids = order.order_line.mapped('invoice_lines.invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund'])
- # Ignore the status of the deposit product
- deposit_product_id = self.env['sale.advance.payment.inv']._default_product_id()
- line_invoice_status = [line.invoice_status for line in order.order_line if line.product_id != deposit_product_id]
+
+ line_invoice_status = [d[1] for d in line_invoice_status_all if d[0] == order.id]
if order.state not in ('sale', 'done'):
invoice_status = 'no'
@@ -652,7 +654,7 @@ class SaleOrder(models.Model):
# create an analytic account if at least an expense product
for order in self:
- if any([expense_policy != 'no' for expense_policy in order.order_line.mapped('product_id.expense_policy')]):
+ if any([expense_policy not in [False, 'no'] for expense_policy in order.order_line.mapped('product_id.expense_policy')]):
if not order.analytic_account_id:
order._create_analytic_account()
@@ -1016,7 +1018,7 @@ class SaleOrderLine(models.Model):
msg = _("Extra line with %s ") % (line.product_id.display_name,)
line.order_id.message_post(body=msg)
# create an analytic account if at least an expense product
- if line.product_id.expense_policy != 'no' and not self.order_id.analytic_account_id:
+ if line.product_id.expense_policy not in [False, 'no'] and not self.order_id.analytic_account_id:
self.order_id._create_analytic_account()
return line
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index ade6a2e3f52..d0f81c38a8a 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -6,7 +6,6 @@
name="Sales"
web_icon="sale,static/description/icon.png"
active="False"
- groups="sales_team.group_sale_salesman,sales_team.group_sale_salesman_all_leads,sales_team.group_sale_manager"
sequence="7"/>
<menuitem id="sale_order_menu"
@@ -17,6 +16,7 @@
<menuitem id="report_sales_team"
name="Sales Teams"
parent="sale_order_menu"
+ groups="sales_team.group_sale_manager"
action="sales_team.crm_team_salesteams_act"
sequence="3"/>
@@ -314,6 +314,7 @@
<field name="qty_delivered_manual" invisible="1"/>
<field name="qty_delivered_method" invisible="1"/>
<field name="price_total" invisible="1"/>
+ <field name="price_tax" invisible="1"/>
<field name="price_subtotal" invisible="1"/>
<label for="product_uom_qty" string="Ordered Quantity"/>
<div>
@@ -322,6 +323,7 @@
name="product_uom_qty" class="oe_inline"/>
<field
name="product_uom"
+ force_save="1"
groups="uom.group_uom"
class="oe_inline oe_no_button"
attrs="{
@@ -442,6 +444,7 @@
<field name="qty_to_invoice" invisible="1"/>
<field
name="product_uom"
+ force_save="1"
attrs="{
'readonly': [('state', 'in', ('sale','done', 'cancel'))],
'required': [('display_type', '=', False)],
@@ -473,6 +476,7 @@
<field name="invoice_status" invisible="1"/>
<field name="customer_lead" invisible="1"/>
<field name="currency_id" invisible="1"/>
+ <field name="price_tax" invisible="1"/>
</tree>
<kanban class="o_kanban_mobile">
<field name="name"/>
@@ -480,6 +484,7 @@
<field name="product_uom_qty"/>
<field name="product_uom" groups="uom.group_uom"/>
<field name="price_subtotal"/>
+ <field name="price_tax" invisible="1"/>
<field name="price_total" invisible="1"/>
<field name="price_unit"/>
<field name="display_type"/>
diff --git a/addons/sale_expense/models/analytic.py b/addons/sale_expense/models/analytic.py
index 9f037bfcae1..d387091adc1 100644
--- a/addons/sale_expense/models/analytic.py
+++ b/addons/sale_expense/models/analytic.py
@@ -10,7 +10,7 @@ class AccountAnalyticLine(models.Model):
@api.multi
def _sale_determine_order(self):
mapping = super(AccountAnalyticLine, self)._sale_determine_order()
- for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no'):
+ for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy not in [False, 'no']):
if analytic_line.move_id.expense_id.sale_order_id:
mapping[analytic_line.id] = analytic_line.move_id.expense_id.sale_order_id
return mapping
diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py
index 9f7613ccf51..75e7953e903 100644
--- a/addons/sale_timesheet/models/sale_order.py
+++ b/addons/sale_timesheet/models/sale_order.py
@@ -51,7 +51,7 @@ class SaleOrder(models.Model):
def _action_confirm(self):
""" On SO confirmation, some lines should generate a task or a project. """
result = super(SaleOrder, self)._action_confirm()
- self.mapped('order_line').sudo()._timesheet_service_generation()
+ self.mapped('order_line').sudo().with_context(default_company_id=self.company_id.id)._timesheet_service_generation()
return result
@api.multi
diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py
index b92954e3199..25215366e04 100644
--- a/addons/stock/models/stock_move_line.py
+++ b/addons/stock/models/stock_move_line.py
@@ -143,7 +143,7 @@ class StockMoveLine(models.Model):
"""
res = {}
if self.qty_done and self.product_id.tracking == 'serial':
- if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0:
+ if float_compare(self.qty_done, 1.0, precision_rounding=self.product_id.uom_id.rounding) != 0:
message = _('You can only process 1.0 %s of products with unique serial number.') % self.product_id.uom_id.name
res['warning'] = {'title': _('Warning'), 'message': message}
return res
diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py
index 16f6b505acb..7c8eeefb57a 100644
--- a/addons/stock/models/stock_scrap.py
+++ b/addons/stock/models/stock_scrap.py
@@ -131,7 +131,8 @@ class StockScrap(models.Model):
self.package_id,
self.owner_id,
strict=True).mapped('quantity'))
- if float_compare(available_qty, self.scrap_qty, precision_digits=precision) >= 0:
+ scrap_qty = self.product_uom_id._compute_quantity(self.scrap_qty, self.product_id.uom_id)
+ if float_compare(available_qty, scrap_qty, precision_digits=precision) >= 0:
return self.do_scrap()
else:
return {
diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py
index 2e2d171eddf..8e9ebbd980c 100644
--- a/addons/stock/models/stock_warehouse.py
+++ b/addons/stock/models/stock_warehouse.py
@@ -971,7 +971,7 @@ class Orderpoint(models.Model):
# These days will be substracted when creating the PO
days += self.product_id._select_seller(
quantity=product_qty,
- date=start_date,
+ date=fields.Date.context_today(self,start_date),
uom_id=self.product_uom).delay or 0.0
date_planned = start_date + relativedelta.relativedelta(days=days)
return date_planned.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
diff --git a/addons/stock/report/report_stockpicking_operations.xml b/addons/stock/report/report_stockpicking_operations.xml
index 63bfbd8cc14..09715938ca3 100644
--- a/addons/stock/report/report_stockpicking_operations.xml
+++ b/addons/stock/report/report_stockpicking_operations.xml
@@ -155,7 +155,7 @@
</tbody>
</table>
<t t-set="no_reserved_product" t-value="o.move_lines.filtered(lambda x: x.product_uom_qty != x.reserved_availability and x.move_line_ids and x.state!='done')"/>
- <p t-if="o.state in ['draft', 'waiting', 'confirmed'] or no_reserved_product" class="fa fa-exclamation-triangle">
+ <p t-if="o.state in ['draft', 'waiting', 'confirmed'] or no_reserved_product"><i class="fa fa-exclamation-triangle" />
All products could not be reserved. Click on the "Check Availability" button to try to reserve products
</p>
<p t-field="o.note"/>
diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py
index 2037ec4f34e..0939389dda9 100644
--- a/addons/stock/tests/test_move.py
+++ b/addons/stock/tests/test_move.py
@@ -3569,6 +3569,24 @@ class StockMove(TransactionCase):
self.assertEqual(scrap.state, 'done')
self.assertEqual(move1.reserved_availability, 0.25)
+ def test_scrap_6(self):
+ """ Check that scrap correctly handle UoM. """
+ self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1)
+ scrap = self.env['stock.scrap'].create({
+ 'product_id': self.product1.id,
+ 'product_uom_id': self.uom_dozen.id,
+ 'scrap_qty': 1,
+ })
+ warning_message = scrap.action_validate()
+ self.assertEqual(warning_message.get('res_model', 'Wrong Model'), 'stock.warn.insufficient.qty.scrap')
+ insufficient_qty_wizard = self.env['stock.warn.insufficient.qty.scrap'].create({
+ 'product_id': self.product1.id,
+ 'location_id': self.stock_location.id,
+ 'scrap_id': scrap.id
+ })
+ insufficient_qty_wizard.action_done()
+ self.assertEqual(self.env['stock.quant']._gather(self.product1, self.stock_location).quantity, -11)
+
def test_in_date_1(self):
""" Check that moving a tracked quant keeps the incoming date.
"""
diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml
index 3a75e9744a0..8629dbff92c 100644
--- a/addons/stock/views/stock_picking_views.xml
+++ b/addons/stock/views/stock_picking_views.xml
@@ -220,7 +220,7 @@
<button name="%(act_stock_return_picking)d" string="Return" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('is_locked', '=', False)]}" type="action" groups="base.group_user"/>
<button name="action_cancel" attrs="{'invisible': ['|', ('state', 'not in', ('assigned', 'confirmed', 'partially_available', 'draft', 'waiting')), ('is_locked', '=', False)]}" string="Cancel" groups="base.group_user" type="object"/>
<field name="picking_type_code" invisible="1"/>
- <button name="do_unreserve" string="Unreserve" groups="base.group_user" type="object" attrs="{'invisible': ['|', '|', ('picking_type_code', '=', 'incoming'), ('state', 'not in', ('assigned', 'partially_available')), ('is_locked', '=', False)]}"/>
+ <button name="do_unreserve" string="Unreserve" groups="base.group_user" type="object" attrs="{'invisible': ['|', '|', '|', ('picking_type_code', '=', 'incoming'), ('is_locked', '=', False), '&amp;', ('state', 'not in', ('assigned', 'partially_available')), ('move_type', '!=', 'one'), '&amp;', ('state', 'not in', ('assigned', 'partially_available', 'confirmed')), ('move_type', '=', 'one')]}"/>
<field name="show_operations" invisible="1" readonly="1"/>
<field name="move_line_exist" invisible="1"/>
<field name="has_packages" invisible="1"/>
diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py
index 8e4bde1a2e2..3ba8f85bf26 100644
--- a/addons/stock_account/models/product.py
+++ b/addons/stock_account/models/product.py
@@ -138,23 +138,38 @@ class ProductProduct(models.Model):
StockMove = self.env['stock.move']
to_date = self.env.context.get('to_date')
- self.env['account.move.line'].check_access_rights('read')
- fifo_automated_values = {}
- query = """SELECT aml.product_id, aml.account_id, sum(aml.debit) - sum(aml.credit), sum(quantity), array_agg(aml.id)
- FROM account_move_line AS aml
- WHERE aml.product_id IS NOT NULL AND aml.company_id=%%s %s
- GROUP BY aml.product_id, aml.account_id"""
- params = (self.env.user.company_id.id,)
+ real_time_product_ids = [product.id for product in self if product.product_tmpl_id.valuation == 'real_time']
+ if real_time_product_ids:
+ self.env['account.move.line'].check_access_rights('read')
+ fifo_automated_values = {}
+ query = """SELECT aml.product_id, aml.account_id, sum(aml.debit) - sum(aml.credit), sum(quantity), array_agg(aml.id)
+ FROM account_move_line AS aml
+ WHERE aml.product_id IN %%s AND aml.company_id=%%s %s
+ GROUP BY aml.product_id, aml.account_id"""
+ params = (tuple(real_time_product_ids), self.env.user.company_id.id)
+ if to_date:
+ query = query % ('AND aml.date <= %s',)
+ params = params + (to_date,)
+ else:
+ query = query % ('',)
+ self.env.cr.execute(query, params=params)
+
+ res = self.env.cr.fetchall()
+ for row in res:
+ fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4]))
+
+ product_values = {product: 0 for product in self}
+ product_move_ids = {product: [] for product in self}
if to_date:
- query = query % ('AND aml.date <= %s',)
- params = params + (to_date,)
+ domain = [('product_id', 'in', self.ids), ('date', '<=', to_date)] + StockMove._get_all_base_domain()
+ for move in StockMove.search(domain).with_context(prefetch_fields=False):
+ product_values[move.product_id] += move.value
+ product_move_ids[move.product_id].append(move.id)
else:
- query = query % ('',)
- self.env.cr.execute(query, params=params)
-
- res = self.env.cr.fetchall()
- for row in res:
- fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4]))
+ domain = [('product_id', 'in', self.ids)] + StockMove._get_all_base_domain()
+ for move in StockMove.search(domain).with_context(prefetch_fields=False):
+ product_values[move.product_id] += move.remaining_value
+ product_move_ids[move.product_id].append(move.id)
for product in self:
if product.cost_method in ['standard', 'average']:
@@ -170,11 +185,9 @@ class ProductProduct(models.Model):
elif product.cost_method == 'fifo':
if to_date:
if product.product_tmpl_id.valuation == 'manual_periodic':
- domain = [('product_id', '=', product.id), ('date', '<=', to_date)] + StockMove._get_all_base_domain()
- moves = StockMove.search(domain)
- product.stock_value = sum(moves.mapped('value'))
+ product.stock_value = product_values[product]
product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
- product.stock_fifo_manual_move_ids = StockMove.browse(moves.ids)
+ product.stock_fifo_manual_move_ids = StockMove.browse(product_move_ids[product])
elif product.product_tmpl_id.valuation == 'real_time':
valuation_account_id = product.categ_id.property_stock_valuation_account_id.id
value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, [])
@@ -182,7 +195,7 @@ class ProductProduct(models.Model):
product.qty_at_date = quantity
product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids)
else:
- product.stock_value, moves = product._sum_remaining_values()
+ product.stock_value, moves = product_values[product], StockMove.browse(product_move_ids[product])
product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
if product.product_tmpl_id.valuation == 'manual_periodic':
product.stock_fifo_manual_move_ids = moves
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index 3f362f29989..11d1bcb40a4 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -387,7 +387,7 @@ class StockMove(models.Model):
product_tot_qty_available = move.product_id.qty_available + tmpl_dict[move.product_id.id]
rounding = move.product_id.uom_id.rounding
- qty_done = 0.0
+ qty_done = move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id)
if float_is_zero(product_tot_qty_available, precision_rounding=rounding):
new_std_price = move._get_price_unit()
elif float_is_zero(product_tot_qty_available + move.product_qty, precision_rounding=rounding) or \
@@ -396,7 +396,6 @@ class StockMove(models.Model):
else:
# Get the standard price
amount_unit = std_price_update.get((move.company_id.id, move.product_id.id)) or move.product_id.standard_price
- qty_done = move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id)
qty = forced_qty or qty_done
new_std_price = ((amount_unit * product_tot_qty_available) + (move._get_price_unit() * qty)) / (product_tot_qty_available + qty_done)
@@ -483,10 +482,8 @@ class StockMove(models.Model):
fifo_valued_products = self.env['product.product']
fifo_valued_categories = self.env['product.category'].search([('property_cost_method', '=', 'fifo')])
fifo_valued_products |= self.env['product.product'].search([('categ_id', 'child_of', fifo_valued_categories.ids)])
- moves_to_vacuum = self.env['stock.move']
- for product in fifo_valued_products:
- moves_to_vacuum |= self.search(
- [('product_id', '=', product.id), ('remaining_qty', '<', 0)] + self._get_all_base_domain())
+ moves_to_vacuum = self.search(
+ [('product_id', 'in', fifo_valued_products.ids), ('remaining_qty', '<', 0)] + self._get_all_base_domain())
moves_to_vacuum._fifo_vacuum()
@api.multi
diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py
index 3720b453635..0c7be5df182 100644
--- a/addons/stock_account/tests/test_stockvaluation.py
+++ b/addons/stock_account/tests/test_stockvaluation.py
@@ -2587,6 +2587,40 @@ class TestStockValuation(TransactionCase):
self.assertAlmostEqual(self.product1.qty_at_date, 0.0)
self.assertAlmostEqual(self.product1.stock_value, 0.0)
+ def test_average_perpetual_6(self):
+ self.product1.product_tmpl_id.cost_method = 'average'
+
+ move1 = self.env['stock.move'].create({
+ 'name': 'Receive 1 unit at 10',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ 'price_unit': 10,
+ })
+ move1._action_confirm()
+ move1._action_assign()
+ move1.move_line_ids.qty_done = 1.0
+
+ move2 = self.env['stock.move'].create({
+ 'name': 'Receive 1 units at 5',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ 'price_unit': 5,
+ })
+ move2._action_confirm()
+ move2._action_assign()
+ move2.move_line_ids.qty_done = 1.0
+
+ # Receive both at the same time
+ (move1 | move2)._action_done()
+
+ self.assertAlmostEqual(self.product1.standard_price, 7.5)
+
def test_average_negative_1(self):
""" Test edit in the past. Receive 10, send 20, edit the second move to only send 10.
"""
diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py
index aadfa7eecdc..4d3bb1380e9 100644
--- a/addons/stock_landed_costs/models/stock_landed_cost.py
+++ b/addons/stock_landed_costs/models/stock_landed_cost.py
@@ -307,6 +307,9 @@ class AdjustmentLines(models.Model):
return False
accounts = self.product_id.product_tmpl_id.get_product_accounts()
debit_account_id = accounts.get('stock_valuation') and accounts['stock_valuation'].id or False
+ # If the stock move is dropshipped move we need to get the cost account instead the stock valuation account
+ if self.move_id._is_dropshipped():
+ debit_account_id = accounts.get('expense') and accounts['expense'].id or False
already_out_account_id = accounts['stock_output'].id
credit_account_id = self.cost_line_id.account_id.id or cost_product.property_account_expense_id.id or cost_product.categ_id.property_account_expense_categ_id.id
diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py
index 32250825bd3..6495a9a1ffb 100644
--- a/addons/survey/models/survey_user.py
+++ b/addons/survey/models/survey_user.py
@@ -77,6 +77,7 @@ class SurveyUserInput(models.Model):
""" Send again the invitation """
self.ensure_one()
local_context = {
+ 'survey_resent_user_input': self.id,
'survey_resent_token': True,
'default_partner_ids': self.partner_id and [self.partner_id.id] or [],
'default_multi_email': self.email or "",
diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py
index 7aa47259262..4cf1768b0e3 100644
--- a/addons/survey/wizard/survey_invite.py
+++ b/addons/survey/wizard/survey_invite.py
@@ -205,6 +205,10 @@ class SurveyInvite(models.TransientModel):
Mail.create(values).send()
def create_token(wizard, partner_id, email):
+ if context.get("survey_resent_user_input"):
+ survey_user_input = SurveyUserInput.browse(context.get("survey_resent_user_input"))
+ if survey_user_input.state in ('new', 'skip'):
+ return survey_user_input.token
if context.get("survey_resent_token"):
survey_user_input = SurveyUserInput.search([('survey_id', '=', wizard.survey_id.id),
('state', 'in', ['new', 'skip']), '|', ('partner_id', '=', partner_id),
diff --git a/addons/test_mail/tests/test_mail_channel.py b/addons/test_mail/tests/test_mail_channel.py
index 74e49ce7de4..79ab747fed5 100644
--- a/addons/test_mail/tests/test_mail_channel.py
+++ b/addons/test_mail/tests/test_mail_channel.py
@@ -155,7 +155,15 @@ class TestChannelFeatures(common.BaseFunctionalTest, common.MockEmails):
""" Posting a message on a mailing list should send one email to all recipients """
self.env['ir.config_parameter'].set_param('mail.catchall.domain', 'schlouby.fr')
self.test_channel.write({'email_send': True})
- self._join_channel(self.test_channel, self.user_employee.partner_id | self.test_partner)
+
+ # Subscribe an user without email. We shouldn't try to send email to them.
+ nomail = self.env['res.users'].create({
+ "login": "nomail",
+ "name": "No Mail",
+ "email": False,
+ "notification_type": "email",
+ })
+ self._join_channel(self.test_channel, self.user_employee.partner_id | self.test_partner | nomail.partner_id)
self.test_channel.message_post(body="Test", message_type='comment', subtype='mt_comment')
self.assertEqual(len(self._mails), 1)
diff --git a/addons/test_mass_mailing/tests/test_performance.py b/addons/test_mass_mailing/tests/test_performance.py
index 54f9df307b3..f69f2e74966 100644
--- a/addons/test_mass_mailing/tests/test_performance.py
+++ b/addons/test_mass_mailing/tests/test_performance.py
@@ -49,7 +49,7 @@ class TestMassMailPerformance(TestMassMailPerformanceBase):
'mailing_domain': [('id', 'in', self.mm_recs.ids)],
})
- with self.assertQueryCount(__system__=2435, marketing=3091):
+ with self.assertQueryCount(__system__=2480, marketing=3136):
mailing.send_mail()
self.assertEqual(mailing.sent, 50)
@@ -86,7 +86,7 @@ class TestMassMailBlPerformance(TestMassMailPerformanceBase):
'mailing_domain': [('id', 'in', self.mm_recs.ids)],
})
- with self.assertQueryCount(__system__=2807, marketing=3559):
+ with self.assertQueryCount(__system__=2867, marketing=3619):
mailing.send_mail()
self.assertEqual(mailing.sent, 50)
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index 57c86a2c6d6..3eaed012f25 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -1063,21 +1063,19 @@ class Binary(http.Controller):
if status != 200 and download:
return request.env['ir.http']._response_by_status(status, headers, content)
+ if not content:
+ content = base64.b64encode(self.placeholder(image='placeholder.png'))
+ headers = self.force_contenttype(headers, contenttype='image/png')
+ if not (width or height):
+ suffix = field.split('_')[-1]
+ if suffix in ('small', 'medium', 'big'):
+ content = getattr(odoo.tools, 'image_resize_image_%s' % suffix)(content)
+
content = limited_image_resize(
content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small)
- if content:
- image_base64 = base64.b64decode(content)
- else:
- suffix = field.split('_')[-1]
- if suffix in ('small', 'medium', 'big'):
- encoded_placeholder = base64.b64encode(self.placeholder(image='placeholder.png'))
- image_base64 = base64.b64decode(getattr(odoo.tools, 'image_resize_image_%s' % suffix)(encoded_placeholder))
- else:
- image_base64 = self.placeholder(image='placeholder.png') # could return (contenttype, content) in master
- headers = self.force_contenttype(headers, contenttype='image/png')
-
+ image_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(image_base64)))
response = request.make_response(image_base64, headers)
response.status_code = status
diff --git a/addons/web/models/models.py b/addons/web/models/models.py
index fe01ffe6938..768885f701f 100644
--- a/addons/web/models/models.py
+++ b/addons/web/models/models.py
@@ -63,7 +63,8 @@ class Base(models.AbstractModel):
locale=locale)
if field_type == 'selection':
- group_by_value = selection_labels[group_by_value]
+ group_by_value = selection_labels[group_by_value] \
+ if group_by_value in selection_labels else False
if type(group_by_value) == tuple:
group_by_value = group_by_value[1] # FIXME should use technical value (0)
diff --git a/addons/web/static/lib/tempusdominus/tempusdominus.js b/addons/web/static/lib/tempusdominus/tempusdominus.js
index a5dac48ac3a..ffca0c8e632 100644
--- a/addons/web/static/lib/tempusdominus/tempusdominus.js
+++ b/addons/web/static/lib/tempusdominus/tempusdominus.js
@@ -2093,6 +2093,9 @@ var TempusDominusBootstrap4 = function ($) {
}
currentDate = this._viewDate.clone().startOf('M').startOf('w').startOf('d');
+ // !! ODOO FIX START !!
+ var now = this.getMoment();
+ // !! ODOO FIX END !!
for (i = 0; i < 42; i++) {
//always display 42 days (should show 6 weeks)
@@ -2125,7 +2128,9 @@ var TempusDominusBootstrap4 = function ($) {
if (!this._isValid(currentDate, 'd')) {
clsName += ' disabled';
}
- if (currentDate.isSame(this.getMoment(), 'd')) {
+ // !! ODOO FIX START !!
+ if (currentDate.date() === now.date() && currentDate.month() === now.month() && currentDate.year() === now.year()) {
+ // !! ODOO FIX END !!
clsName += ' today';
}
if (currentDate.day() === 0 || currentDate.day() === 6) {
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 6d53d283f8a..14eb10c663c 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -240,6 +240,12 @@ var FieldMany2One = AbstractField.extend({
*/
_bindAutoComplete: function () {
var self = this;
+ // avoid ignoring autocomplete="off" by obfuscating placeholder, see #30439
+ if (this.$input.attr('placeholder')) {
+ this.$input.attr('placeholder', function (index, val) {
+ return val.split('').join('\ufeff');
+ });
+ }
this.$input.autocomplete({
source: function (req, resp) {
_.each(self._autocompleteSources, function (source) {
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 9706251ed07..646e883fb7e 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -432,6 +432,7 @@ var BasicModel = AbstractModel.extend({
var isNew = this.isNew(id);
var rollback = 'rollback' in options ? options.rollback : isNew;
var initialOffset = element.offset;
+ element._domains = {};
this._visitChildren(element, function (elem) {
if (rollback && elem._savePoint) {
if (elem._savePoint instanceof Array) {
diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js
index c2c90ea7e62..03c59885fbe 100644
--- a/addons/web/static/src/js/views/calendar/calendar_renderer.js
+++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js
@@ -362,6 +362,11 @@ return AbstractRenderer.extend({
this.$calendar = this.$(".o_calendar_widget");
+ // This seems like a workaround but apparently passing the locale
+ // in the options is not enough. We should initialize it beforehand
+ var locale = moment.locale();
+ $.fullCalendar.locale(locale);
+
//Documentation here : http://arshaw.com/fullcalendar/docs/
var fc_options = $.extend({}, this.state.fc_options, {
eventDrop: function (event) {
@@ -414,6 +419,7 @@ return AbstractRenderer.extend({
height: 'parent',
unselectAuto: false,
isRTL: _t.database.parameters.direction === "rtl",
+ locale: locale, // reset locale when fullcalendar has already been instanciated before now
});
this.$calendar.fullCalendar(fc_options);
diff --git a/addons/web/static/src/js/views/control_panel/search/search_filters.js b/addons/web/static/src/js/views/control_panel/search/search_filters.js
index 6ff84dce9c7..4703995ac7c 100644
--- a/addons/web/static/src/js/views/control_panel/search/search_filters.js
+++ b/addons/web/static/src/js/views/control_panel/search/search_filters.js
@@ -193,8 +193,10 @@ var DateTime = Field.extend({
operators: [
{value: "=", text: _lt("is equal to")},
{value: "!=", text: _lt("is not equal to")},
- {value: ">=", text: _lt("is after")},
- {value: "<=", text: _lt("is before")},
+ {value: ">", text: _lt("is after")},
+ {value: "<", text: _lt("is before")},
+ {value: ">=", text: _lt("is after or equal to")},
+ {value: "<=", text: _lt("is before or equal to")},
{value: "between", text: _lt("is between")},
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index 5feefce716f..d85a538d5ec 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -227,17 +227,16 @@ var FormController = BasicController.extend({
// are displayed with an alert
var fields = self.renderer.state.fields;
var data = self.renderer.state.data;
- var alertFields = [];
+ var alertFields = {};
for (var k = 0; k < changedFields.length; k++) {
var field = fields[changedFields[k]];
var fieldData = data[changedFields[k]];
if (field.translate && fieldData) {
- alertFields.push(field);
+ alertFields[changedFields[k]] = field;
}
}
- if (alertFields.length) {
- self.renderer.alertFields = alertFields;
- self.renderer.displayTranslationAlert();
+ if (!_.isEmpty(alertFields)) {
+ self.renderer.updateAlertFields(alertFields);
}
}
return changedFields;
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index 52bbc044d1f..2d6615b4b00 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -14,6 +14,7 @@ var FormRenderer = BasicRenderer.extend({
className: "o_form_view",
events: _.extend({}, BasicRenderer.prototype.events, {
'click .o_notification_box .oe_field_translate': '_onTranslate',
+ 'click .o_notification_box .close': '_onTranslateNotificationClose',
'click .oe_title, .o_inner_group': '_onClick',
}),
custom_events: _.extend({}, BasicRenderer.prototype.custom_events, {
@@ -31,6 +32,7 @@ var FormRenderer = BasicRenderer.extend({
this._super.apply(this, arguments);
this.idsForLabels = {};
this.lastActivatedFieldIndex = -1;
+ this.alertFields = {};
},
/**
* @override
@@ -104,23 +106,32 @@ var FormRenderer = BasicRenderer.extend({
});
return fieldNames;
},
+ /*
+ * Updates translation alert fields for the current state and display updated fields
+ *
+ * @param {Object} alertFields
+ */
+ updateAlertFields: function (alertFields) {
+ this.alertFields[this.state.res_id] = _.extend(this.alertFields[this.state.res_id] || {}, alertFields);
+ this.displayTranslationAlert();
+ },
/**
* Show a warning message if the user modified a translated field. For each
* field, the notification provides a link to edit the field's translations.
- *
- * @param {Object[]} alertFields field list
*/
displayTranslationAlert: function () {
this.$('.o_notification_box').remove();
- var $notification = $(qweb.render('notification-box', {type: 'info'}))
- .append(qweb.render('translation-alert', {
- fields: this.alertFields,
- lang: _t.database.parameters.name
- }));
- if (this.$('.o_form_statusbar').length) {
- this.$('.o_form_statusbar').after($notification);
- } else {
- this.$el.prepend($notification);
+ if (this.alertFields[this.state.res_id]) {
+ var $notification = $(qweb.render('notification-box', {type: 'info'}))
+ .append(qweb.render('translation-alert', {
+ fields: this.alertFields[this.state.res_id],
+ lang: _t.database.parameters.name
+ }));
+ if (this.$('.o_form_statusbar').length) {
+ this.$('.o_form_statusbar').after($notification);
+ } else {
+ this.$el.prepend($notification);
+ }
}
},
/**
@@ -334,6 +345,7 @@ var FormRenderer = BasicRenderer.extend({
});
self.trigger_up('swipe_right');
},
+ excludedElements: ".o_notebook .nav.nav-tabs",
});
},
/**
@@ -915,6 +927,9 @@ var FormRenderer = BasicRenderer.extend({
return $.when.apply($, defs).then(function () {
self._updateView($form.contents());
+ if (self.state.res_id in self.alertFields) {
+ self.displayTranslationAlert();
+ }
}, function () {
$form.remove();
}).then(function(){
@@ -1030,6 +1045,15 @@ var FormRenderer = BasicRenderer.extend({
ev.preventDefault();
this.trigger_up('translate', {fieldName: ev.target.name, id: this.state.id});
},
+ /**
+ * remove alert fields of record from alertFields object
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onTranslateNotificationClose: function(ev) {
+ delete this.alertFields[this.state.res_id];
+ },
});
return FormRenderer;
diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js
index 856882a303b..09706695bbc 100644
--- a/addons/web/static/src/js/views/graph/graph_renderer.js
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -198,10 +198,12 @@ return AbstractRenderer.extend({
}
}
- // For Bar chart View, we keep only groups where count > 0
- data[0].values = _.filter(data[0].values, function (elem, index) {
- return self.state.data[index].count > 0;
- });
+ // For one level Bar chart View, we keep only groups where count > 0
+ if (this.state.groupedBy.length <= 1) {
+ data[0].values = _.filter(data[0].values, function (elem, index) {
+ return self.state.data[index].count > 0;
+ });
+ }
var $svgContainer = $('<div/>', {class: 'o_graph_svg_container'});
this.$el.append($svgContainer);
diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js
index d05bf2d33fc..347769b4042 100644
--- a/addons/web/static/src/js/widgets/data_export.js
+++ b/addons/web/static/src/js/widgets/data_export.js
@@ -311,7 +311,7 @@ var DataExport = Dialog.extend({
});
self.$export_format_inputs = $fmts.find('input');
- self.$export_format_inputs.first().prop('checked', true);
+ self.$export_format_inputs.filter(':enabled').first().prop('checked', true);
}
},
show_exports_list: function() {
diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js
index b677abe377a..367ab23178d 100644
--- a/addons/web/static/src/js/widgets/date_picker.js
+++ b/addons/web/static/src/js/widgets/date_picker.js
@@ -25,6 +25,7 @@ var DateWidget = Widget.extend({
this.name = parent.name;
this.options = _.extend({
+ locale: moment.locale(),
format : this.type_of_date === 'datetime' ? time.getLangDatetimeFormat() : time.getLangDateFormat(),
minDate: moment({ y: 1900 }),
maxDate: moment().add(200, "y"),
diff --git a/addons/web/static/src/scss/base_settings.scss b/addons/web/static/src/scss/base_settings.scss
index 82137b00948..a7ff1d848a7 100644
--- a/addons/web/static/src/scss/base_settings.scss
+++ b/addons/web/static/src/scss/base_settings.scss
@@ -114,9 +114,11 @@
font-size: 13px;
line-height: 40px;
cursor: pointer;
+ white-space: nowrap;
.icon {
width: 23px;
+ min-width: 23px;
margin-right: 10px;
}
diff --git a/addons/web/static/src/scss/form_view.scss b/addons/web/static/src/scss/form_view.scss
index 31032a75cc2..697ec4dcd12 100644
--- a/addons/web/static/src/scss/form_view.scss
+++ b/addons/web/static/src/scss/form_view.scss
@@ -743,6 +743,7 @@
}
.o_field_widget {
width: 50%;
+ flex: 0 0 auto;
}
button.btn-link:first-child {
padding-left: 0;
diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss
index c771d6a37ab..c4c0e9d011d 100644
--- a/addons/web/static/src/scss/kanban_view.scss
+++ b/addons/web/static/src/scss/kanban_view.scss
@@ -564,6 +564,8 @@
@include o-kanban-record-color;
.oe_kanban_color_help {
- @include o-position-absolute(0, 5px, 0, -1px);
+ @include o-position-absolute(0, auto, 0, -1px);
+ width: $o-kanban-color-border-width;
+ z-index: 1; // show the title over kanban color
}
}
diff --git a/addons/web/static/src/scss/navbar_mobile.scss b/addons/web/static/src/scss/navbar_mobile.scss
index 6ef917b8517..f4593436163 100644
--- a/addons/web/static/src/scss/navbar_mobile.scss
+++ b/addons/web/static/src/scss/navbar_mobile.scss
@@ -23,7 +23,6 @@
position: relative;
height: $o-navbar-height;
- overflow: hidden;
> ul {
> li {
@@ -39,6 +38,7 @@
&.o_menu_sections {
width: 100%;
+ display: none;
.dropdown-menu.show {
position: static;
@@ -82,6 +82,9 @@
body.o_mobile_menu_opened > .o_main_navbar {
height: 100%;
overflow: auto;
+ .o_menu_sections {
+ display: block;
+ }
}
}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 55d744d3a60..5e2f27ccc66 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -1488,7 +1488,7 @@
<div role="alertdialog">
Please update translations of :
<t t-foreach="fields" t-as="field">
- <strong><a class="oe_field_translate" t-att-name="field.name" href="#"><t t-esc="field.string"/><t t-if="!field_last">, </t></a></strong>
+ <strong><a class="oe_field_translate" t-att-name="field" href="#"><t t-esc="field_value.string"/><t t-if="field_index &lt; _.size(fields)-1">, </t></a></strong>
</t>
</div>
</t>
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index f5568abe28e..b721861c4b6 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -2962,6 +2962,47 @@ QUnit.module('basic_fields', {
form.destroy();
});
+ QUnit.test('date field support internalization', function (assert) {
+ assert.expect(2);
+
+ var originalLocale = moment.locale();
+ var originalParameters = _.clone(core._t.database.parameters);
+
+ _.extend(core._t.database.parameters, {date_format: '%d. %b %Y', time_format: '%H:%M:%S'});
+ moment.defineLocale('norvegianForTest', {
+ monthsShort: 'jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.'.split('_'),
+ monthsParseExact: true,
+ dayOfMonthOrdinalParse: /\d{1,2}\./,
+ ordinal: '%d.',
+ });
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners"><field name="date"/></form>',
+ res_id: 1,
+ });
+
+ var dateViewForm = form.$('.o_field_date').text();
+ testUtils.dom.click(form.$buttons.find('.o_form_button_edit'));
+ testUtils.openDatepicker(form.$('.o_datepicker'));
+ assert.strictEqual(form.$('.o_datepicker_input').val(), dateViewForm,
+ "input date field should be the same as it was in the view form");
+
+ testUtils.dom.click($('.day:contains(30)'));
+ var dateEditForm = form.$('.o_datepicker_input').val()
+ testUtils.dom.click(form.$buttons.find('.o_form_button_save'));
+ assert.strictEqual(form.$('.o_field_date').text(), dateEditForm,
+ "date field should be the same as the one selected in the view form");
+
+ moment.locale(originalLocale);
+ moment.updateLocale('norvegianForTest', null);
+ core._t.database.parameters = originalParameters;
+
+ form.destroy();
+ });
+
QUnit.module('FieldDatetime');
QUnit.test('datetime field in form view', function (assert) {
diff --git a/addons/web/static/tests/fields/field_utils_tests.js b/addons/web/static/tests/fields/field_utils_tests.js
index 940f5674780..c4281b38c87 100644
--- a/addons/web/static/tests/fields/field_utils_tests.js
+++ b/addons/web/static/tests/fields/field_utils_tests.js
@@ -297,7 +297,61 @@ QUnit.test('parse percentage', function(assert) {
core._t.database.parameters = originalParameters;
-})
+});
+
+QUnit.test('parse datetime', function (assert) {
+ assert.expect(5);
+
+ var originalParameters = _.clone(core._t.database.parameters);
+ var originalLocale = moment.locale();
+ var dateStr, date1, date2;
+
+ moment.defineLocale('englishForTest', {
+ dayOfMonthOrdinalParse: /\d{1,2}(st|nd|rd|th)/,
+ ordinal: function (number) {
+ var b = number % 10,
+ output = (~~(number % 100 / 10) === 1) ? 'th' :
+ (b === 1) ? 'st' :
+ (b === 2) ? 'nd' :
+ (b === 3) ? 'rd' : 'th';
+ return number + output;
+ },
+ });
+
+ moment.defineLocale('norvegianForTest', {
+ monthsShort: 'jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.'.split('_'),
+ monthsParseExact: true,
+ dayOfMonthOrdinalParse: /\d{1,2}\./,
+ ordinal: '%d.',
+ });
+
+ moment.locale('englishForTest');
+ _.extend(core._t.database.parameters, {date_format: '%m/%d/%Y', time_format: '%H:%M:%S'});
+ assert.throws(function () {fieldUtils.parse.datetime("13/01/2019 12:00:00", {}, {})}, /is not a correct/, "Wrongly formated dates should be invalids");
+ assert.throws(function () {fieldUtils.parse.datetime("1899-01-01 12:00:00", {}, {})}, /is not a correct/, "Dates before 1900 should be invalids");
+
+ dateStr = '01/13/2019 10:05:45';
+ date1 = fieldUtils.parse.datetime(dateStr);
+ date2 = moment.utc(dateStr, ['MM/DD/YYYY HH:mm:ss'], true);
+ assert.equal(date1.format(), date2.format(), "Date with leading 0");
+
+ dateStr = '1/14/2019 10:5:45';
+ date1 = fieldUtils.parse.datetime(dateStr);
+ date2 = moment.utc(dateStr, ['M/D/YYYY H:m:s'], true);
+ assert.equal(date1.format(), date2.format(), "Date without leading 0");
+
+ moment.locale('norvegianForTest');
+ _.extend(core._t.database.parameters, {date_format: '%d. %b %Y', time_format: '%H:%M:%S'});
+ dateStr = '16. jan. 2019 10:05:45';
+ date1 = fieldUtils.parse.datetime(dateStr);
+ date2 = moment.utc(dateStr, ['DD. MMM YYYY HH:mm:ss'], true);
+ assert.equal(date1.format(), date2.format(), "Day/month inverted + month i18n");
+
+ moment.locale(originalLocale);
+ moment.updateLocale("englishForTest", null);
+ moment.updateLocale("norvegianForTest", null);
+ core._t.database.parameters = originalParameters;
+});
});
});
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index 264a00cc52a..9ea9992426b 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -2372,6 +2372,44 @@ QUnit.module('Views', {
calendar.destroy();
});
+ QUnit.test('fullcalendar initializes with right locale', function (assert) {
+ assert.expect(1);
+
+ var initialLocale = moment.locale();
+ // This will set the locale to zz
+ moment.defineLocale('zz', {
+ longDateFormat: {
+ L: 'DD/MM/YYYY'
+ },
+ weekdaysShort: ["zz1.", "zz2.", "zz3.", "zz4.", "zz5.", "zz6.", "zz7."],
+ });
+
+ var calendar = createView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week"> '+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ action: {views: [{viewID: 1, type: 'kanban'}, {viewID: 43, type: 'form'}]}
+ },
+
+ });
+
+ assert.strictEqual(calendar.$('.fc-day-header:first').text(), "zz1. 11/12",
+ 'The day should be in the given locale specific format');
+
+ moment.locale(initialLocale);
+
+ calendar.destroy();
+ });
+
});
});
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index e7d425b997e..e0d0b8daa83 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -16,6 +16,7 @@ var Widget = require('web.Widget');
var _t = core._t;
var createView = testUtils.createView;
+var createActionManager = testUtils.createActionManager;
var createAsyncView = testUtils.createAsyncView;
var createActionManager = testUtils.createActionManager;
@@ -5530,9 +5531,10 @@ QUnit.module('Views', {
});
QUnit.test('display translation alert', function (assert) {
- assert.expect(1);
+ assert.expect(2);
this.data.partner.fields.foo.translate = true;
+ this.data.partner.fields.display_name.translate = true;
var multi_lang = _t.database.multi_lang;
_t.database.multi_lang = true;
@@ -5545,6 +5547,7 @@ QUnit.module('Views', {
'<sheet>' +
'<group>' +
'<field name="foo"/>' +
+ '<field name="display_name"/>' +
'</group>' +
'</sheet>' +
'</form>',
@@ -5554,11 +5557,150 @@ QUnit.module('Views', {
testUtils.form.clickEdit(form);
testUtils.fields.editInput(form.$('input[name="foo"]'), "test");
testUtils.form.clickSave(form);
+ assert.containsOnce(form, '.o_form_view > .alert > div .oe_field_translate',
+ "should have single translation alert");
+
+ testUtils.form.clickEdit(form);
+ testUtils.fields.editInput(form.$('input[name="display_name"]'), "test2");
+ testUtils.form.clickSave(form);
+ assert.containsN(form, '.o_form_view > .alert > div .oe_field_translate', 2,
+ "should have two translate fields in translation alert");
+
+ form.destroy();
+
+ _t.database.multi_lang = multi_lang;
+ });
+
+ QUnit.test('translation alerts are preserved on pager change', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.foo.translate = true;
+
+ var multi_lang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '</form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ res_id: 1,
+ });
+
+ testUtils.form.clickEdit(form);
+ testUtils.fields.editInput(form.$('input[name="foo"]'), "test");
+ testUtils.form.clickSave(form);
assert.containsOnce(form, '.o_form_view > .alert > div',"should have a translation alert");
+
+ // click on the pager to switch to the next record
+ form.pager.$('.o_pager_next').click();
+ assert.strictEqual(form.$('.o_form_view > .alert > div').length, 0,
+ "should not have a translation alert");
+
+ // click on the pager to switch back to the previous record
+ form.pager.$('.o_pager_previous').click();
+ assert.containsOnce(form, '.o_form_view > .alert > div',"should have a translation alert");
+
+ // remove translation alert by click X and check alert even after form reload
+ form.$('.o_form_view > .alert > .close').click();
+ assert.strictEqual(form.$('.o_form_view > .alert > div').length, 0,
+ "should not have a translation alert");
+ form.reload();
+ assert.strictEqual(form.$('.o_form_view > .alert > div').length, 0,
+ "should not have a translation alert after reload");
+
form.destroy();
+ _t.database.multi_lang = multi_lang;
+ });
+
+ QUnit.test('translation alerts preseved on reverse breadcrumb', function (assert) {
+ assert.expect(2);
+
+ this.data['ir.translation'] = {
+ fields: {
+ name: { string: "name", type: "char" },
+ source: {string: "Source", type: "char"},
+ value: {string: "Value", type: "char"},
+ },
+ records: [],
+ };
+
+ this.data.partner.fields.foo.translate = true;
+
+ var multi_lang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+ var archs = {
+ 'partner,false,form': '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '</form>',
+ 'partner,false,search': '<search></search>',
+ 'ir.translation,false,list': '<tree>' +
+ '<field name="name"/>' +
+ '<field name="source"/>' +
+ '<field name="value"/>' +
+ '</tree>',
+ 'ir.translation,false,search': '<search></search>',
+ };
+
+ var actions = [{
+ id: 1,
+ name: 'Partner',
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'form']],
+ }, {
+ id: 2,
+ name: 'Translate',
+ res_model: 'ir.translation',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list']],
+ target: 'current',
+ flags: {'search_view': true, 'action_buttons': true},
+ }];
+
+ var actionManager = createActionManager({
+ actions: actions,
+ archs: archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(1);
+
+ actionManager.controlPanel.$el.find('.o_form_button_edit').click();
+ actionManager.$('input[name="foo"]').val("test").trigger("input");
+ actionManager.controlPanel.$el.find('.o_form_button_save').click();
+
+ assert.strictEqual(actionManager.$('.o_form_view > .alert > div').length, 1,
+ "should have a translation alert");
+
+ var currentController = actionManager.getCurrentController().widget;
+ actionManager.doAction(2, {
+ on_reverse_breadcrumb: function () {
+ if (!_.isEmpty(currentController.renderer.alertFields)) {
+ currentController.renderer.displayTranslationAlert();
+ }
+ return false;
+ },
+ });
+
+ $('.o_control_panel .breadcrumb a:first').click();
+ assert.strictEqual(actionManager.$('.o_form_view > .alert > div').length, 1,
+ "should have a translation alert");
+
+ actionManager.destroy();
_t.database.multi_lang = multi_lang;
});
@@ -7207,6 +7349,64 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('domain returned by onchange is cleared on discard', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.onchanges = {
+ foo: function () {},
+ };
+
+ var domain = ['id', '=', 1];
+ var expectedDomain = domain;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="foo"/><field name="trululu"/></form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange' && args.args[0][0] === 1) {
+ // onchange returns a domain only on record 1
+ return $.when({
+ domain: {
+ trululu: domain,
+ },
+ });
+ }
+ if (args.method === 'name_search') {
+ assert.deepEqual(args.kwargs.args, expectedDomain);
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ ids: [1, 2],
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "should be on record 1");
+
+ // change foo to trigger the onchange
+ testUtils.fields.editInput(form.$('input[name=foo]'), 'new value');
+
+ // open many2one dropdown to check if the domain is applied
+ testUtils.fields.many2one.clickOpenDropdown('trululu');
+
+ // switch to another record (should ask to discard changes, and reset the domain)
+ testUtils.dom.click(form.pager.$('.o_pager_next'));
+
+ // discard changes by clicking on confirm in the dialog
+ testUtils.dom.click($('.modal .modal-footer .btn-primary:first'));
+
+ assert.strictEqual(form.$('input[name=foo]').val(), 'blip', "should be on record 2");
+
+ // open many2one dropdown to check if the domain is applied
+ expectedDomain = [];
+ testUtils.fields.many2one.clickOpenDropdown('trululu');
+
+ form.destroy();
+ });
+
QUnit.module('FormViewTABMainButtons');
QUnit.test('using tab in an empty required string field should not move to the next field',function(assert) {
diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py
index ffa03b739d9..1ed329bafe1 100644
--- a/addons/web/tests/__init__.py
+++ b/addons/web/tests/__init__.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import test_image
from . import test_js
from . import test_menu
from . import test_serving_base
diff --git a/addons/web/tests/test_image.py b/addons/web/tests/test_image.py
new file mode 100644
index 00000000000..96e09444c05
--- /dev/null
+++ b/addons/web/tests/test_image.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import io
+
+from PIL import Image
+
+from odoo.tests.common import HttpCase
+
+
+class TestImage(HttpCase):
+ def test_01_content_image_resize_placeholder(self):
+ response = self.url_open('/web/image/0/200x150')
+ image = Image.open(io.BytesIO(response.content))
+ self.assertEqual(image.size, (200, 150))
+
+ response = self.url_open('/web/image/fake/0/image_small')
+ image = Image.open(io.BytesIO(response.content))
+ self.assertEqual(image.size, (64, 64))
diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml
index 80479b0bcfa..6b5a6a95b82 100644
--- a/addons/web/views/report_templates.xml
+++ b/addons/web/views/report_templates.xml
@@ -356,7 +356,7 @@
</t>
<t t-if="company.external_report_layout_id" t-call="{{company.external_report_layout_id.key}}"><t t-raw="0"/></t>
- <t t-else="else" t-raw="0"/>
+ <t t-else="else" t-call="web.external_layout_standard"><t t-raw="0"/></t>
</template>
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index 40cf2aba5ef..2ad2730dc93 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -50,11 +50,16 @@ class Web_Editor(http.Controller):
kwargs.update(trans)
- record = None
- if model and kwargs.get('res_id'):
- record = request.env[model].with_context(trans).browse(kwargs.get('res_id'))
+ content = None
+ if model:
+ Model = request.env[model].with_context(trans)
+ if kwargs.get('res_id'):
+ record = Model.browse(kwargs.get('res_id'))
+ content = record and getattr(record, field)
+ else:
+ content = Model.default_get([field]).get(field)
- kwargs.update(content=record and getattr(record, field) or "")
+ kwargs.update(content=content or '')
return request.render(kwargs.get("template") or "web_editor.FieldTextHtml", kwargs, uid=request.uid)
@@ -265,6 +270,9 @@ class Web_Editor(http.Controller):
result['originalSrc'] = record.url
return result
+ def _get_view_fields_to_read(self):
+ return ['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']
+
## The get_assets_editor_resources route is in charge of transmitting the resources the assets
## editor needs to work.
## @param key - the xml_id or id of the view the resources are related to
@@ -277,7 +285,7 @@ class Web_Editor(http.Controller):
def get_assets_editor_resources(self, key, get_views=True, get_scss=True, bundles=False, bundles_restriction=[]):
# Related views must be fetched if the user wants the views and/or the style
views = request.env["ir.ui.view"].get_related_views(key, bundles=bundles)
- views = views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id'])
+ views = views.read(self._get_view_fields_to_read())
scss_files_data_by_bundle = []
@@ -423,7 +431,7 @@ class Web_Editor(http.Controller):
(note: searching for the URL string is not enough as it could appear in a comment or an xpath expression.)
"""
- return bool(etree.XML(view.arch).xpath("link[@href='{}']".format(url)))
+ return bool(etree.XML(view.arch).xpath("//link[@href='{}']".format(url)))
view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(views_linking_url)
diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js
index f3c47f30050..37c21f2aa36 100644
--- a/addons/web_editor/static/src/js/widgets/ace.js
+++ b/addons/web_editor/static/src/js/widgets/ace.js
@@ -2,6 +2,7 @@ odoo.define('web_editor.ace', function (require) {
'use strict';
var ajax = require('web.ajax');
+var concurrency = require('web.concurrency');
var core = require('web.core');
var Dialog = require('web.Dialog');
var Widget = require('web.Widget');
@@ -359,7 +360,7 @@ var ViewEditor = Widget.extend({
this.aceEditor.setSession(editingSession);
if (this.currentType === 'xml') {
- this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].xml_id));
+ this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].key));
} else {
this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID));
}
@@ -536,6 +537,7 @@ var ViewEditor = Widget.extend({
* error occured.
*/
_saveResources: function () {
+ var self = this;
var toSave = {};
var errorFound = false;
_.each(this.editingSessions, (function (editingSessions, type) {
@@ -563,8 +565,15 @@ var ViewEditor = Widget.extend({
if (errorFound) return $.Deferred().reject(errorFound);
var defs = [];
+ var mutex = new concurrency.Mutex();
_.each(toSave, (function (_toSave, type) {
- defs = defs.concat(_.map(_toSave, (type === 'xml' ? this._saveView : this._saveSCSS).bind(this)));
+ // Child views first as COW on a parent would delete them
+ _toSave = _.sortBy(_toSave, 'id').reverse();
+ _.each(_toSave, function (session) {
+ defs.push(mutex.exec(function () {
+ return (type === 'xml' ? self._saveView(session) : self._saveSCSS(session));
+ }));
+ });
}).bind(this));
return $.when.apply($, defs).fail((function (session, error) {
diff --git a/addons/web_editor/static/src/scss/web_editor.ui.scss b/addons/web_editor/static/src/scss/web_editor.ui.scss
index 06e56ee657f..e615b3b5e38 100644
--- a/addons/web_editor/static/src/scss/web_editor.ui.scss
+++ b/addons/web_editor/static/src/scss/web_editor.ui.scss
@@ -1064,10 +1064,11 @@ body.editor_enable {
box-shadow: inset 0 0 0px 1px darken($o-we-switch-inactive-color,10%);
border-radius: 100rem;
height: $o-we-switch-size;
- width: $o-we-switch-size * 1.8;
- margin-right: 0.5em;
+ width: $o-we-switch-size * 1.81;
+ margin-right: 0.51em;
display: inline-block;
transition: all 0.3s $o-we-md-ease;
+ font-size: 15px;
&:after {
content: "";
diff --git a/addons/website/controllers/web_editor.py b/addons/website/controllers/web_editor.py
index 93c3aa46007..530c9ef2b10 100644
--- a/addons/website/controllers/web_editor.py
+++ b/addons/website/controllers/web_editor.py
@@ -6,6 +6,11 @@ from odoo.http import request
class Web_Editor(Web_Editor):
+ def _get_view_fields_to_read(self):
+ res = super(Web_Editor, self)._get_view_fields_to_read()
+ res.append('website_id')
+ return res
+
def save_scss_view_hook(self):
res = super(Web_Editor, self).save_scss_view_hook()
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index a94397e77ba..dc3ebb286c3 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -72,7 +72,7 @@ class View(models.Model):
website_specific_view = view.copy(copy_vals)
view._create_website_specific_pages_for_view(website_specific_view,
- view.env['website'].browse(current_website_id))
+ view.env['website'].browse(current_website_id))
for inherit_child in view.inherit_children_ids.filter_duplicate().sorted(key=lambda v: (v.priority, v.id)):
if inherit_child.website_id.id == current_website_id:
@@ -116,7 +116,7 @@ class View(models.Model):
if cow_view[key] == self[key]:
authorized_vals[key] = values[key]
cow_view.write(authorized_vals)
- super(View, self)._load_records_write(values)
+ super(View, self)._load_records_write(values)
def _load_records_create(self, values):
""" During module install, when creating a generic child view, we should
@@ -166,6 +166,7 @@ class View(models.Model):
# create new pages for this view
page.copy({
'view_id': new_view.id,
+ 'is_published': page.is_published,
})
@api.model
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index 7c789572093..96648264880 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -839,7 +839,7 @@ class WebsitePublishedMixin(models.AbstractModel):
_description = 'Website Published Mixin'
website_published = fields.Boolean('Visible on current website', related='is_published', readonly=False)
- is_published = fields.Boolean('Is published')
+ is_published = fields.Boolean('Is published', copy=False)
website_url = fields.Char('Website URL', compute='_compute_website_url', help='The full URL to access the document through the website.')
@api.multi
diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js
index 42a12414557..d64a3891bfd 100644
--- a/addons/website/static/src/js/content/menu.js
+++ b/addons/website/static/src/js/content/menu.js
@@ -60,6 +60,10 @@ sAnimation.registry.affixMenu = sAnimation.Class.extend({
* @private
*/
_onWindowUpdate: function () {
+ if (this.$navbarCollapses.hasClass('show')) {
+ return;
+ }
+
var wOffset = $(window).scrollTop();
var hOffset = this.$target.scrollTop();
this.$headerClone.toggleClass('affixed', wOffset > (hOffset + 300));
diff --git a/addons/website/static/src/js/html_editor.test.js b/addons/website/static/src/js/html_editor.test.js
new file mode 100644
index 00000000000..6c7633cf7aa
--- /dev/null
+++ b/addons/website/static/src/js/html_editor.test.js
@@ -0,0 +1,143 @@
+odoo.define('website.test.html_editor', function (require) {
+'use strict';
+
+var tour = require('web_tour.tour');
+var base = require('web_editor.base');
+
+tour.register('html_editor_multiple_templates', {
+ test: true,
+ url: '/aboutus',
+ wait_for: base.ready(),
+},
+ [
+ // 1. Edit the page through Edit Mode, it will COW the view
+ {
+ content: "enter edit mode",
+ trigger: 'a[data-action=edit]',
+ },
+ {
+ content: "drop a snippet",
+ trigger: '#oe_snippets .oe_snippet:has(.s_cover) .oe_snippet_thumbnail',
+ // id starting by 'oe_structure..' will actually create an inherited view
+ run: "drag_and_drop #oe_structure_test_ui",
+ },
+ {
+ content: "save the page",
+ extra_trigger: '#wrapwrap .s_cover',
+ trigger: "#web_editor-top-edit button[data-action=save]",
+ },
+ // 2. Edit generic aboutus view
+ {
+ content: "open customize menu",
+ extra_trigger: "body:not(.editor_enable)",
+ trigger: '#customize-menu > a',
+ },
+ {
+ content: "open html editor",
+ trigger: '#html_editor',
+ },
+ {
+ content: "add something in the aboutus view",
+ trigger: 'div.ace_line .ace_xml:contains("aboutus")',
+ run: function () {
+ ace.edit('ace-view-editor').getSession().insert({row: 3, column: 1}, '<p>somenewcontent</p>\n');
+ },
+ },
+ // 3. Edit oe_structure specific view
+ {
+ content: "select oe_structure specific view",
+ trigger: 'div.ace_line .ace_xml:contains("somenewcontent")',
+ run: function () {
+ var viewId = $('#ace-view-list option:contains("oe_structure")').val();
+ $('#ace-view-list').val(viewId).trigger('change');
+ },
+ },
+ {
+ content: "add something in the oe_structure specific view",
+ extra_trigger: '#ace-view-id:contains("website.aboutus_oe_structure_test_ui")', // If no xml_id it should show key
+ trigger: 'div.ace_line .ace_xml:contains("s_cover")',
+ run: function () {
+ ace.edit('ace-view-editor').getSession().insert({row: 2, column: 1}, '<p>anothernewcontent</p>\n');
+ },
+ },
+ {
+ content: "save the html editor",
+ extra_trigger: 'div.ace_line .ace_xml:contains("anothernewcontent")',
+ trigger: ".o_ace_view_editor button[data-action=save]",
+ },
+ {
+ content: "check that the page has both modification",
+ extra_trigger: '#wrapwrap:contains("anothernewcontent")',
+ trigger: '#wrapwrap:contains("somenewcontent")',
+ run: function () {}, // it's a check
+ },
+ ]
+);
+
+tour.register('test_html_editor_scss', {
+ test: true,
+ url: '/aboutus',
+ wait_for: base.ready(),
+},
+ [
+ // 1. Open Html Editor and select a scss file
+ {
+ content: "open customize menu",
+ extra_trigger: '#wrap:visible', // ensure state for later
+ trigger: '#customize-menu > a',
+ },
+ {
+ content: "open html editor",
+ trigger: '#html_editor',
+ },
+ {
+ content: "open type switcher",
+ trigger: '.o_ace_type_switcher button',
+ },
+ {
+ content: "select scss files",
+ trigger: '.o_ace_type_switcher_choice[data-type="scss"]',
+ },
+ {
+ content: "select 'website.ui.scss'",
+ trigger: '#ace-scss-list',
+ run: function () {
+ var scssId = $('#ace-scss-list option:contains("website.ui")').val();
+ $('#ace-scss-list').val(scssId).trigger('change');
+ },
+ },
+ // 2. Edit that file and ensure it was saved then reset it
+ {
+ content: "add some scss content in the file",
+ trigger: 'div.ace_line .ace_variable:contains("body.o_connected_user")',
+ run: function () {
+ ace.edit('ace-view-editor').getSession().insert({row: 2, column: 0}, '#wrap {display: none;}\n');
+ },
+ },
+ {
+ content: "save the html editor",
+ extra_trigger: 'div.ace_line:contains("#wrap {display: none;}")',
+ trigger: ".o_ace_view_editor button[data-action=save]",
+ },
+ {
+ content: "check that the scss modification got applied",
+ trigger: 'body:has(#wrap:hidden)',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "reset view (after reload, html editor should have been reopened where it was)",
+ trigger: '#ace-view-id button[data-action="reset"]',
+ },
+ {
+ content: "confirm reset warning",
+ trigger: '.modal-footer .btn-primary',
+ },
+ {
+ content: "check that the scss file was reset correctly, wrap content should now be visible again",
+ trigger: '#wrap:visible',
+ run: function () {}, // it's a check
+ },
+ ]
+);
+
+});
diff --git a/addons/website/static/src/js/widgets/ace.js b/addons/website/static/src/js/widgets/ace.js
index 22a1755bd82..f4fe74ddd38 100644
--- a/addons/website/static/src/js/widgets/ace.js
+++ b/addons/website/static/src/js/widgets/ace.js
@@ -2,6 +2,7 @@ odoo.define("website.ace", function (require) {
"use strict";
var AceEditor = require('web_editor.ace');
+var weContext = require('web_editor.context');
/**
* Extends the default view editor so that the URL hash is updated with view ID
@@ -37,9 +38,30 @@ var WebsiteAceEditor = AceEditor.extend({
*/
_saveResources: function () {
return this._super.apply(this, arguments).then((function () {
- this._updateHash();
- window.location.reload();
- return $.Deferred();
+ var defs = [];
+ if (this.currentType === 'xml') {
+ var selectedView = _.findWhere(this.views, {id: this._getSelectedResource()});
+ if (!selectedView.website_id[0]) {
+ // When saving a generic view, the view will be COW'd and
+ // replace by the specific view after the reload. Thus the id in
+ // URL won't exist anymore. We need to find the specific ID.
+ var context = weContext.get();
+ defs.push(this._rpc({
+ model: 'ir.ui.view',
+ method: 'search_read',
+ fields: ['id'],
+ domain: [['key', '=', selectedView.key], ['website_id', '=', context.website_id]],
+ }).then((function (view) {
+ if (view[0]) {
+ this._updateHash(view[0].id);
+ }
+ }).bind(this)));
+ }
+ }
+ return $.when.apply($, defs).then((function () {
+ window.location.reload();
+ return $.Deferred();
+ }));
}).bind(this));
},
/**
@@ -56,8 +78,8 @@ var WebsiteAceEditor = AceEditor.extend({
*
* @private
*/
- _updateHash: function () {
- window.location.hash = this.hash + "?res=" + this._getSelectedResource();
+ _updateHash: function (resID) {
+ window.location.hash = this.hash + "?res=" + (resID || this._getSelectedResource());
},
});
diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py
index 0cf61f88c30..a9e9d502dbf 100644
--- a/addons/website/tests/test_ui.py
+++ b/addons/website/tests/test_ui.py
@@ -4,6 +4,35 @@
import odoo
import odoo.tests
+
+@odoo.tests.tagged('-at_install', 'post_install')
+class TestUiHtmlEditor(odoo.tests.HttpCase):
+ def test_html_editor_multiple_templates(self):
+ Website = self.env['website']
+ View = self.env['ir.ui.view']
+ generic_aboutus = Website.viewref('website.aboutus')
+ # Use an empty page layout with oe_structure id for this test
+ oe_structure_layout = '''
+ <t name="About us" t-name="website.aboutus">
+ <t t-call="website.layout">
+ <p>aboutus</p>
+ <div id="oe_structure_test_ui" class="oe_structure oe_empty"/>
+ </t>
+ </t>
+ '''
+ generic_aboutus.arch = oe_structure_layout
+ self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('html_editor_multiple_templates')", "odoo.__DEBUG__.services['web_tour.tour'].tours.html_editor_multiple_templates.ready", login='admin')
+ self.assertEqual(View.search_count([('key', '=', 'website.aboutus')]), 2, "Aboutus view should have been COW'd")
+ self.assertTrue(generic_aboutus.arch == oe_structure_layout, "Generic Aboutus view should be untouched")
+ self.assertEqual(len(generic_aboutus.inherit_children_ids.filtered(lambda v: 'oe_structure' in v.name)), 0, "oe_structure view should have been deleted when aboutus was COW")
+ specific_aboutus = Website.with_context(website_id=1).viewref('website.aboutus')
+ self.assertTrue(specific_aboutus.arch != oe_structure_layout, "Specific Aboutus view should have been changed")
+ self.assertEqual(len(specific_aboutus.inherit_children_ids.filtered(lambda v: 'oe_structure' in v.name)), 1, "oe_structure view should have been created on the specific tree")
+
+ def test_html_editor_scss(self):
+ self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('test_html_editor_scss')", "odoo.__DEBUG__.services['web_tour.tour'].tours.test_html_editor_scss.ready", login='admin')
+
+
class TestUiTranslate(odoo.tests.HttpCase):
def test_admin_tour_rte_translator(self):
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('rte_translator')", "odoo.__DEBUG__.services['web_tour.tour'].tours.rte_translator.ready", login='admin', timeout=120)
diff --git a/addons/website/views/website_navbar_templates.xml b/addons/website/views/website_navbar_templates.xml
index 80eb4a2dfdd..3ce8155534f 100644
--- a/addons/website/views/website_navbar_templates.xml
+++ b/addons/website/views/website_navbar_templates.xml
@@ -171,7 +171,7 @@
<span class="sr-only">Toggle Dropdown</span>
</a>
<div class="dropdown-menu" role="menu">
- <a role="menuitem" style="text-align: left;" t-attf-href="/web#return_label=Website&amp;view_type=form&amp;model=#{main_object._name}&amp;id=#{main_object.id}&amp;action=#{action}"
+ <a role="menuitem" style="text-align: left;" t-attf-href="/web#return_label=Website&amp;view_type=form&amp;model=#{main_object._name}&amp;id=#{main_object.id}&amp;action=#{action}&amp;menu_id=#{menu or main_object.env.ref('website.menu_website_configuration').id}"
class="dropdown-item" title='Edit in backend' id="edit-in-backend">Edit in backend</a>
</div>
</li>
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index 05febbf1fae..60022a477a3 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -57,6 +57,8 @@
<script type="text/javascript" src="/website/static/src/js/content/zoomodoo.js"/>
<script type="text/javascript" src="/website/static/src/js/menu/navbar.js"/>
+
+ <script type="text/javascript" src="/website/static/src/js/html_editor.test.js"/>
</xpath>
</template>
@@ -313,7 +315,7 @@
</record>
<!-- Features template -->
-<template id="login_layout" inherit_id="web.login_layout" name="Website Login Layout">
+<template id="login_layout" inherit_id="web.login_layout" name="Website Login Layout" priority="20">
<xpath expr="t" position="replace">
<t t-call="website.layout">
<div class="oe_website_login_container" t-raw="0"/>
@@ -411,7 +413,7 @@
<button type="button" t-attf-class="btn btn-default dropdown-toggle dropdown-toggle-split" t-att-id="'dopprod-%s' % object.id" data-toggle="dropdown"/>
<div class="dropdown-menu" role="menu" t-att-aria-labelledby="'dopprod-%s' % object.id">
<t t-raw="0"/>
- <a role="menuitem" t-attf-href="/web#return_label=Website&amp;view_type=form&amp;model=#{object._name}&amp;id=#{object.id}&amp;action=#{action}"
+ <a role="menuitem" t-attf-href="/web#return_label=Website&amp;view_type=form&amp;model=#{object._name}&amp;id=#{object.id}&amp;action=#{action}&amp;menu_id=#{menu or object.env.ref('website.menu_website_configuration').id}"
title='Edit in backend' class="dropdown-item" t-if="publish_edit">Edit</a>
</div>
</div>
diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py
index a35b8a18f3b..ee9098dd614 100644
--- a/addons/website_forum/models/forum.py
+++ b/addons/website_forum/models/forum.py
@@ -842,6 +842,7 @@ class PostReason(models.Model):
class Vote(models.Model):
_name = 'forum.post.vote'
_description = 'Post Vote'
+ _order = 'create_date desc, id desc'
post_id = fields.Many2one('forum.post', string='Post', ondelete='cascade', required=True)
user_id = fields.Many2one('res.users', string='User', required=True, default=lambda self: self._uid)
diff --git a/addons/website_sale/static/src/js/website_sale_payment.js b/addons/website_sale/static/src/js/website_sale_payment.js
index 45373cea37a..cc104fecc39 100644
--- a/addons/website_sale/static/src/js/website_sale_payment.js
+++ b/addons/website_sale/static/src/js/website_sale_payment.js
@@ -7,9 +7,12 @@ var $checkbox = $("#checkbox_cgv");
if (!$checkbox.length) {
return;
}
+var $pay_button = $('button#o_payment_form_pay');
$checkbox.on('change', function (ev) {
- $('button#o_payment_form_pay').prop('disabled', !ev.target.checked);
+ $pay_button.data('disabled_reasons', $pay_button.data('disabled_reasons') || {});
+ $pay_button.data('disabled_reasons').cgv = !ev.target.checked;
+ $pay_button.prop('disabled', _.contains($pay_button.data('disabled_reasons'), true));
});
$checkbox.change();
});
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 8231728213b..2290b8025f9 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -417,9 +417,10 @@
<!-- /shop/product product page -->
<template id="product_edit_options" inherit_id="website.user_navbar" name="Edit Product Options">
<xpath expr="//li[@id='edit-page-menu']" position="after">
- <t t-if="main_object._name == 'product.template'" t-set="action" t-value="'product.product_template_action'" />
+ <t t-if="main_object._name == 'product.template'" t-set="action" t-value="'website_sale.product_template_action_website'" />
</xpath>
</template>
+
<template id="product" name="Product">
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
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 6d911b02f87..ef717be1f9a 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
@@ -62,8 +62,8 @@ sAnimations.registry.websiteSaleDelivery = sAnimations.Class.extend({
$carrierBadge.children('span').text(result.new_amount_delivery);
$carrierBadge.removeClass('d-none');
$computeBadge.addClass('d-none');
- $payButton.prop('disabled', false);
- } else {
+ $payButton.data('disabled_reasons').carrier_selection = false;
+ $payButton.prop('disabled', _.contains($payButton.data('disabled_reasons'), true));
console.error(result.error_message);
$computeBadge.text(result.error_message);
$amountDelivery.text(result.new_amount_delivery);
@@ -82,7 +82,10 @@ sAnimations.registry.websiteSaleDelivery = sAnimations.Class.extend({
* @param {Event} ev
*/
_onCarrierClick: function (ev) {
- $('#o_payment_form_pay').prop('disabled', true);
+ var $payButton = $('#o_payment_form_pay');
+ $payButton.prop('disabled', true);
+ $payButton.data('disabled_reasons', $payButton.data('disabled_reasons') || {});
+ $payButton.data('disabled_reasons').carrier_selection = true;
this._rpc({
route: '/shop/update_carrier',
params: {
diff --git a/addons/website_sale_stock/static/src/js/product_configurator_mixin.js b/addons/website_sale_stock/static/src/js/product_configurator_mixin.js
index fe9a92f1d5e..8781eba0779 100644
--- a/addons/website_sale_stock/static/src/js/product_configurator_mixin.js
+++ b/addons/website_sale_stock/static/src/js/product_configurator_mixin.js
@@ -45,7 +45,7 @@ ProductConfiguratorMixin._onChangeCombinationStock = function (ev, $parent, comb
var qty = $parent.find('input[name="add_qty"]').val();
$parent.find('#add_to_cart').removeClass('out_of_stock');
- if (_.contains(['always', 'threshold'], combination.inventory_availability)) {
+ if (combination.product_type === 'product' && _.contains(['always', 'threshold'], combination.inventory_availability)) {
combination.virtual_available -= parseInt(combination.cart_qty);
if (combination.virtual_available < 0) {
combination.virtual_available = 0;
@@ -88,4 +88,4 @@ sAnimations.registry.WebsiteSale.include({
return ProductConfiguratorMixin;
-}); \ No newline at end of file
+});
diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py
index f04a61c4903..e4f3f18a040 100644
--- a/addons/website_slides/controllers/main.py
+++ b/addons/website_slides/controllers/main.py
@@ -190,7 +190,7 @@ class WebsiteSlides(http.Controller):
@http.route('''/slides/slide/<model("slide.slide"):slide>/download''', type='http', auth="public", website=True, sitemap=False)
def slide_download(self, slide, **kw):
slide = slide.sudo()
- if slide.download_security == 'public' or (slide.download_security == 'user' and request.session.uid):
+ if slide.download_security == 'public' or (slide.download_security == 'user' and request.env.user and request.env.user != request.website.user_id):
filecontent = base64.b64decode(slide.datas)
disposition = 'attachment; filename=%s.pdf' % werkzeug.urls.url_quote(slide.name)
return request.make_response(