summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2018-06-28 19:01:52 +0200
committerChristophe Simonis <chs@odoo.com>2018-06-28 19:01:52 +0200
commitc8bb02141d2801c243ae65ba6800b07f090bb5c6 (patch)
tree2d099c5577df4783a6db3d853cf0141bf41c1aa9
parent4e2733e83a1f42773fb28f66431a0d6fb0393be9 (diff)
parent3cdcbce93cbde548d2d24426ee8de1d93f06706e (diff)
[MERGE] forward port branch saas-11.3 up to 3cdcbce93cb
-rw-r--r--.tx/config20
-rw-r--r--addons/account/models/product.py16
-rw-r--r--addons/account/report/account_aged_partner_balance.py2
-rw-r--r--addons/account/report/account_general_ledger.py2
-rw-r--r--addons/account/tests/test_reconciliation.py57
-rw-r--r--addons/account_check_printing/models/account_payment.py6
-rw-r--r--addons/account_payment/controllers/payment.py18
-rw-r--r--addons/account_payment/controllers/portal.py15
-rw-r--r--addons/account_payment/views/account_portal_templates.xml5
-rw-r--r--addons/auth_signup/models/res_users.py2
-rw-r--r--addons/barcodes/static/src/js/barcode_parser.js3
-rw-r--r--addons/base_address_extended/__manifest__.py3
-rw-r--r--addons/base_address_extended/data/base_address_extended_data.xml8
-rw-r--r--addons/calendar/models/calendar.py8
-rw-r--r--addons/calendar/static/src/js/base_calendar.js6
-rw-r--r--addons/crm/__init__.py8
-rw-r--r--addons/crm/__manifest__.py1
-rw-r--r--addons/crm/models/crm_lead.py3
-rw-r--r--addons/crm/wizard/crm_lead_to_opportunity_views.xml3
-rw-r--r--addons/delivery/models/sale_order.py4
-rw-r--r--addons/event/security/event_security.xml7
-rw-r--r--addons/event/security/ir.model.access.csv3
-rw-r--r--addons/event_sale/__manifest__.py1
-rw-r--r--addons/event_sale/security/event_security.xml8
-rw-r--r--addons/hr_gamification/views/hr_employee_views.xml4
-rw-r--r--addons/hr_holidays/models/hr_leave.py9
-rw-r--r--addons/hr_holidays/models/hr_leave_allocation.py9
-rw-r--r--addons/hr_payroll/models/hr_salary_rule.py10
-rw-r--r--addons/im_livechat/controllers/main.py2
-rw-r--r--addons/im_support/__init__.py2
-rw-r--r--addons/im_support/controllers/__init__.py4
-rw-r--r--addons/im_support/controllers/main.py12
-rw-r--r--addons/im_support/static/src/js/discuss.js4
-rw-r--r--addons/im_support/static/tests/helpers/test_utils.js81
-rw-r--r--addons/im_support/static/tests/systray_no_support_tests.js51
-rw-r--r--addons/im_support/static/tests/systray_tests.js342
-rw-r--r--addons/im_support/tests/__init__.py4
-rw-r--r--addons/im_support/tests/test_js.py11
-rw-r--r--addons/im_support/views/assets.xml29
-rw-r--r--addons/mail/models/mail_message.py4
-rw-r--r--addons/mail/static/src/js/chatter.js70
-rw-r--r--addons/mail/static/src/js/chatter_composer.js25
-rw-r--r--addons/mail/static/src/js/services/chat_window_manager.js2
-rw-r--r--addons/mail/static/src/scss/composer.scss2
-rw-r--r--addons/mail/static/tests/chatter_tests.js279
-rw-r--r--addons/mail/static/tests/helpers/test_utils.js3
-rw-r--r--addons/mail/views/mail_templates.xml11
-rw-r--r--addons/mass_mailing_event/views/event_views.xml4
-rw-r--r--addons/mrp/report/mrp_report_bom_structure.py2
-rw-r--r--addons/mrp/report/mrp_report_bom_structure.xml2
-rw-r--r--addons/payment_stripe/static/src/js/stripe.js4
-rw-r--r--addons/payment_stripe/tests/test_stripe.py25
-rw-r--r--addons/point_of_sale/models/pos_config.py5
-rw-r--r--addons/point_of_sale/models/pos_order.py6
-rw-r--r--addons/point_of_sale/static/src/js/devices.js6
-rw-r--r--addons/point_of_sale/views/pos_session_view.xml10
-rw-r--r--addons/pos_restaurant/static/src/js/multiprint.js9
-rw-r--r--addons/product/report/product_pricelist_templates.xml5
-rw-r--r--addons/product/views/product_views.xml5
-rw-r--r--addons/project/models/project.py6
-rw-r--r--addons/purchase/models/purchase.py2
-rw-r--r--addons/purchase_requisition/models/purchase_requisition.py8
-rw-r--r--addons/purchase_stock/models/procurement.py3
-rw-r--r--addons/resource/models/resource.py6
-rw-r--r--addons/resource/tests/test_resource.py15
-rw-r--r--addons/sale/data/mail_data.xml2
-rw-r--r--addons/sale/models/analytic.py2
-rw-r--r--addons/sale/models/sale.py5
-rw-r--r--addons/sale/views/res_config_settings_views.xml2
-rw-r--r--addons/sale_management/static/src/js/tour.js2
-rw-r--r--addons/sale_mrp/models/sale_mrp.py3
-rw-r--r--addons/sale_mrp/tests/test_sale_mrp_flow.py7
-rw-r--r--addons/sale_order_dates/models/sale_order.py2
-rw-r--r--addons/sale_payment/controllers/payment.py23
-rw-r--r--addons/sale_payment/controllers/portal.py13
-rw-r--r--addons/sale_payment/views/sale_portal_templates.xml5
-rw-r--r--addons/sale_stock/models/account_invoice.py4
-rw-r--r--addons/sale_timesheet/models/project.py5
-rw-r--r--addons/stock/models/product.py24
-rw-r--r--addons/stock/models/stock_location.py15
-rw-r--r--addons/stock/models/stock_move.py2
-rw-r--r--addons/stock/models/stock_picking.py5
-rw-r--r--addons/stock/models/stock_quant.py6
-rw-r--r--addons/stock/models/stock_warehouse.py2
-rw-r--r--addons/stock/tests/__init__.py1
-rw-r--r--addons/stock/tests/common.py7
-rw-r--r--addons/stock/tests/test_robustness.py147
-rw-r--r--addons/stock/tests/test_warehouse.py209
-rw-r--r--addons/stock/views/stock_location_views.xml1
-rw-r--r--addons/stock_account/models/account_chart_template.py10
-rw-r--r--addons/stock_account/models/product.py60
-rw-r--r--addons/stock_dropshipping/tests/test_stockvaluation.py15
-rw-r--r--addons/stock_landed_costs/tests/test_stock_landed_costs_rounding.py2
-rw-r--r--addons/web/models/ir_qweb.py25
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js10
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js16
-rw-r--r--addons/web/static/src/js/services/crash_manager.js3
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js42
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js6
-rw-r--r--addons/web/static/tests/chrome/action_manager_tests.js73
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js221
-rw-r--r--addons/web/static/tests/views/form_tests.js45
-rw-r--r--addons/web_editor/controllers/main.py7
-rw-r--r--addons/web_editor/models/ir_qweb.py1
-rw-r--r--addons/web_editor/static/src/js/editor/transcoder.js18
-rw-r--r--addons/web_editor/static/src/js/widgets/widgets.js8
-rw-r--r--addons/website/models/ir_qweb.py100
-rw-r--r--addons/website/models/website.py22
-rw-r--r--addons/website/tests/__init__.py1
-rw-r--r--addons/website/tests/template_qweb_test.xml36
-rw-r--r--addons/website/tests/test_qweb.py65
-rw-r--r--addons/website_event/controllers/main.py2
-rw-r--r--addons/website_event/views/event_templates.xml2
-rw-r--r--addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js4
-rw-r--r--addons/website_sale/models/res_config_settings.py2
-rw-r--r--addons/website_sale_options/static/src/js/website_sale.js4
-rw-r--r--addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js10
-rw-r--r--debian/postrm3
-rw-r--r--doc/cla/corporate/accomodata.md20
-rw-r--r--odoo/addons/base/models/assetsbundle.py106
-rw-r--r--odoo/addons/base/models/ir_actions_report.py32
-rw-r--r--odoo/addons/base/models/ir_property.py2
-rw-r--r--odoo/addons/base/models/ir_qweb.py187
-rw-r--r--odoo/addons/base/models/ir_qweb_fields.py16
-rw-r--r--odoo/addons/base/models/qweb.py147
-rw-r--r--odoo/addons/base/static/src/js/res_config_settings.js4
-rw-r--r--odoo/addons/base/static/tests/base_settings_tests.js39
-rw-r--r--odoo/addons/base/wizard/base_partner_merge.py17
-rw-r--r--odoo/addons/test_assetsbundle/tests/test_assetsbundle.py50
-rw-r--r--odoo/addons/test_assetsbundle/views/views.xml29
130 files changed, 2766 insertions, 492 deletions
diff --git a/.tx/config b/.tx/config
index ba9318f5a58..da3e448a067 100644
--- a/.tx/config
+++ b/.tx/config
@@ -362,6 +362,11 @@ file_filter = addons/im_livechat/i18n/<lang>.po
source_file = addons/im_livechat/i18n/im_livechat.pot
source_lang = en
+[odoo-master.im_support]
+file_filter = addons/im_support/i18n/<lang>.po
+source_file = addons/im_support/i18n/im_support.pot
+source_lang = en
+
[odoo-master.link_tracker]
file_filter = addons/link_tracker/i18n/<lang>.po
source_file = addons/link_tracker/i18n/link_tracker.pot
@@ -407,6 +412,11 @@ file_filter = addons/mrp/i18n/<lang>.po
source_file = addons/mrp/i18n/mrp.pot
source_lang = en
+[odoo-master.mrp_bom_cost]
+file_filter = addons/mrp_bom_cost/i18n/<lang>.po
+source_file = addons/mrp_bom_cost/i18n/mrp_bom_cost.pot
+source_lang = en
+
[odoo-master.mrp_byproduct]
file_filter = addons/mrp_byproduct/i18n/<lang>.po
source_file = addons/mrp_byproduct/i18n/mrp_byproduct.pot
@@ -552,11 +562,6 @@ file_filter = addons/product_expiry/i18n/<lang>.po
source_file = addons/product_expiry/i18n/product_expiry.pot
source_lang = en
-[odoo-master.product_extended]
-file_filter = addons/product_extended/i18n/<lang>.po
-source_file = addons/product_extended/i18n/product_extended.pot
-source_lang = en
-
[odoo-master.product_margin]
file_filter = addons/product_margin/i18n/<lang>.po
source_file = addons/product_margin/i18n/product_margin.pot
@@ -722,6 +727,11 @@ file_filter = addons/transifex/i18n/<lang>.po
source_file = addons/transifex/i18n/transifex.pot
source_lang = en
+[odoo-master.uom]
+file_filter = addons/uom/i18n/<lang>.po
+source_file = addons/uom/i18n/uom.pot
+source_lang = en
+
[odoo-master.utm]
file_filter = addons/utm/i18n/<lang>.po
source_file = addons/utm/i18n/utm.pot
diff --git a/addons/account/models/product.py b/addons/account/models/product.py
index c52355f6f4f..b72bc31d7bd 100644
--- a/addons/account/models/product.py
+++ b/addons/account/models/product.py
@@ -36,22 +36,6 @@ class ProductTemplate(models.Model):
help="The expense is accounted for when a vendor bill is validated, except in anglo-saxon accounting with perpetual inventory valuation in which case the expense (Cost of Goods Sold account) is recognized at the customer invoice validation. If the field is empty, it uses the one defined in the product category.")
@api.multi
- def write(self, vals):
- #TODO: really? i don't see the reason we'd need that constraint..
- check = self.ids and 'uom_po_id' in vals
- if check:
- self._cr.execute("SELECT id, uom_po_id FROM product_template WHERE id IN %s", [tuple(self.ids)])
- uoms = dict(self._cr.fetchall())
- res = super(ProductTemplate, self).write(vals)
- if check:
- self._cr.execute("SELECT id, uom_po_id FROM product_template WHERE id IN %s", [tuple(self.ids)])
- if dict(self._cr.fetchall()) != uoms:
- products = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids)])
- if self.env['account.move.line'].search_count([('product_id', 'in', products.ids)]):
- raise UserError(_('You can not change the unit of measure of a product that has been already used in an account journal item. If you need to change the unit of measure, you should archive this product and create a new one.'))
- return res
-
- @api.multi
def _get_product_accounts(self):
return {
'income': self.property_account_income_id or self.categ_id.property_account_income_categ_id,
diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py
index 75891ff7a54..95a01bfd0c2 100644
--- a/addons/account/report/account_aged_partner_balance.py
+++ b/addons/account/report/account_aged_partner_balance.py
@@ -203,7 +203,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
values['name'] = _('Unknown Partner')
values['trust'] = False
- if at_least_one_amount or self._context.get('include_nullified_amount'):
+ if at_least_one_amount or (self._context.get('include_nullified_amount') and lines[partner['partner_id']]):
res.append(values)
return res, total, lines
diff --git a/addons/account/report/account_general_ledger.py b/addons/account/report/account_general_ledger.py
index 922ec24ff0a..be46e7b361b 100644
--- a/addons/account/report/account_general_ledger.py
+++ b/addons/account/report/account_general_ledger.py
@@ -38,7 +38,7 @@ class ReportGeneralLedger(models.AbstractModel):
init_wheres.append(init_where_clause.strip())
init_filters = " AND ".join(init_wheres)
filters = init_filters.replace('account_move_line__move_id', 'm').replace('account_move_line', 'l')
- sql = ("""SELECT 0 AS lid, l.account_id AS account_id, '' AS ldate, '' AS lcode, NULL AS amount_currency, '' AS lref, 'Initial Balance' AS lname, COALESCE(SUM(l.debit),0.0) AS debit, COALESCE(SUM(l.credit),0.0) AS credit, COALESCE(SUM(l.debit),0) - COALESCE(SUM(l.credit), 0) as balance, '' AS lpartner_id,\
+ sql = ("""SELECT 0 AS lid, l.account_id AS account_id, '' AS ldate, '' AS lcode, 0.0 AS amount_currency, '' AS lref, 'Initial Balance' AS lname, COALESCE(SUM(l.debit),0.0) AS debit, COALESCE(SUM(l.credit),0.0) AS credit, COALESCE(SUM(l.debit),0) - COALESCE(SUM(l.credit), 0) as balance, '' AS lpartner_id,\
'' AS move_name, '' AS mmove_id, '' AS currency_code,\
NULL AS currency_id,\
'' AS invoice_id, '' AS invoice_type, '' AS invoice_number,\
diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py
index 814fda8c7d7..3cacc14d212 100644
--- a/addons/account/tests/test_reconciliation.py
+++ b/addons/account/tests/test_reconciliation.py
@@ -760,6 +760,63 @@ class TestReconciliation(AccountingTestCase):
self.assertEqual(reversed_bank_line.full_reconcile_id.id, bank_line.full_reconcile_id.id)
self.assertEqual(reversed_customer_line.full_reconcile_id.id, customer_line.full_reconcile_id.id)
+ def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False):
+ #we create an invoice in given currency
+ invoice = self.account_invoice_model.create({'partner_id': partner_id,
+ 'reference_type': 'none',
+ 'currency_id': currency_id,
+ 'name': type == 'out_invoice' and 'invoice to client' or 'invoice to vendor',
+ 'account_id': self.account_rcv.id,
+ 'type': type,
+ 'date_invoice': time.strftime('%Y') + '-07-01',
+ })
+ self.account_invoice_line_model.create({'product_id': self.product.id,
+ 'quantity': 1,
+ 'price_unit': invoice_amount,
+ 'invoice_id': invoice.id,
+ 'name': 'product that cost ' + str(invoice_amount),
+ 'account_id': self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id,
+ })
+
+ #validate invoice
+ invoice.action_invoice_open()
+ return invoice
+
+ def test_aged_report(self):
+ AgedReport = self.env['report.account.report_agedpartnerbalance'].with_context(include_nullified_amount=True)
+ account_type = ['receivable']
+ report_date_to = time.strftime('%Y') + '-07-15'
+ partner = self.env['res.partner'].create({'name': 'AgedPartner'})
+ currency = self.env.user.company_id.currency_id
+
+ invoice = self.create_invoice_partner(currency_id=currency.id, partner_id=partner.id)
+ journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE', 'currency_id': currency.id})
+
+ statement = self.make_payment(invoice, journal, 50)
+
+ # Case 1: The invoice and payment are reconciled: Nothing should appear
+ report_lines, total, amls = AgedReport._get_partner_move_lines(account_type, report_date_to, 'posted', 30)
+
+ partner_lines = [line for line in report_lines if line['partner_id'] == partner.id]
+ self.assertEqual(partner_lines, [], 'The aged receivable shouldn\'t have lines at this point')
+ self.assertFalse(partner.id in amls, 'The aged receivable should not have amls either')
+
+ # Case 2: The invoice and payment are not reconciled: we should have one line on the report
+ # and 2 amls
+ invoice.move_id.line_ids.with_context(invoice_id=invoice.id).remove_move_reconcile()
+ report_lines, total, amls = AgedReport._get_partner_move_lines(account_type, report_date_to, 'posted', 30)
+
+ partner_lines = [line for line in report_lines if line['partner_id'] == partner.id]
+ self.assertEqual(partner_lines, [{'trust': 'normal', '1': 0.0, '0': 0.0, 'direction': 0.0, 'partner_id': partner.id, '3': 0.0, 'total': 0.0, 'name': 'AgedPartner', '4': 0.0, '2': 0.0}],
+ 'We should have a line in the report for the partner')
+ self.assertEqual(len(amls[partner.id]), 2, 'We should have 2 account move lines for the partner')
+
+ positive_line = [line for line in amls[partner.id] if line['line'].balance > 0]
+ negative_line = [line for line in amls[partner.id] if line['line'].balance < 0]
+
+ self.assertEqual(positive_line[0]['amount'], 50.0, 'The amount of the amls should be 50')
+ self.assertEqual(negative_line[0]['amount'], -50.0, 'The amount of the amls should be -50')
+
def test_partial_reconcile_currencies_02(self):
####
# Day 1: Invoice Cust/001 to customer (expressed in USD)
diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py
index 15421a6e7c2..51870ebeeb3 100644
--- a/addons/account_check_printing/models/account_payment.py
+++ b/addons/account_check_printing/models/account_payment.py
@@ -162,12 +162,12 @@ class AccountPayment(models.Model):
# Prepare the stub lines
if not credits:
- stub_lines = [self.make_stub_line(inv) for inv in invoices]
+ stub_lines = [self._check_make_stub_line(inv) for inv in invoices]
else:
stub_lines = [{'header': True, 'name': "Bills"}]
- stub_lines += [self.make_stub_line(inv) for inv in debits]
+ stub_lines += [self._check_make_stub_line(inv) for inv in debits]
stub_lines += [{'header': True, 'name': "Refunds"}]
- stub_lines += [self.make_stub_line(inv) for inv in credits]
+ stub_lines += [self._check_make_stub_line(inv) for inv in credits]
# Crop the stub lines or split them on multiple pages
if not multi_stub:
diff --git a/addons/account_payment/controllers/payment.py b/addons/account_payment/controllers/payment.py
index 7f08570de8e..5c3f6f0ea31 100644
--- a/addons/account_payment/controllers/payment.py
+++ b/addons/account_payment/controllers/payment.py
@@ -17,7 +17,6 @@ class PaymentPortal(http.Controller):
:return html: form containing all values related to the acquirer to
redirect customers to the acquirer website """
success_url = kwargs.get('success_url', '/my')
- callback_method = kwargs.get('callback_method', '')
invoice_sudo = request.env['account.invoice'].sudo().browse(invoice_id)
if not invoice_sudo:
@@ -28,11 +27,10 @@ class PaymentPortal(http.Controller):
except:
return False
+ if request.env.user == request.env.ref('base.public_user'):
+ save_token = False # we avoid to create a token for the public user
vals = {
'acquirer_id': acquirer_id,
- 'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', invoice_sudo._name)], limit=1).id,
- 'callback_res_id': invoice_sudo.id,
- 'callback_method': callback_method,
}
if save_token:
@@ -55,7 +53,6 @@ class PaymentPortal(http.Controller):
""" Use a token to perform a s2s transaction """
error_url = kwargs.get('error_url', '/my')
success_url = kwargs.get('success_url', '/my')
- callback_method = kwargs.get('callback_method', '')
access_token = kwargs.get('access_token')
params = {}
if access_token:
@@ -67,18 +64,17 @@ class PaymentPortal(http.Controller):
return request.redirect(_build_url_w_params(error_url, params))
try:
- pm_id = int(pm_id)
+ token = request.env['payment.token'].sudo().browse(int(pm_id))
except (ValueError, TypeError):
+ token = False
+ token_owner = invoice_sudo.partner_id if request.env.user == request.env.ref('base.public_user') else request.env.user.partner_id
+ if not token or token.partner_id != token_owner:
params['error'] = 'pay_invoice_invalid_token'
return request.redirect(_build_url_w_params(error_url, params))
vals = {
- 'payment_token_id': pm_id,
+ 'payment_token_id': token.id,
'type': 'server2server',
- 'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', invoice_sudo._name)],
- limit=1).id,
- 'callback_res_id': invoice_sudo.id,
- 'callback_method': callback_method,
}
invoice_sudo._create_payment_transaction(vals)
diff --git a/addons/account_payment/controllers/portal.py b/addons/account_payment/controllers/portal.py
index a2247288fdb..28bca2a12a8 100644
--- a/addons/account_payment/controllers/portal.py
+++ b/addons/account_payment/controllers/portal.py
@@ -9,5 +9,18 @@ class PortalAccount(PortalAccount):
def _invoice_get_page_view_values(self, invoice, access_token, **kwargs):
values = super(PortalAccount, self)._invoice_get_page_view_values(invoice, access_token, **kwargs)
- values.update(request.env['payment.acquirer']._get_available_payment_input(invoice.partner_id, invoice.company_id))
+ payment_inputs = request.env['payment.acquirer']._get_available_payment_input(company=invoice.company_id)
+ # if not connected (using public user), the method _get_available_payment_input will return public user tokens
+ is_public_user = request.env.ref('base.public_user') == request.env.user
+ if is_public_user:
+ # we should not display payment tokens owned by the public user
+ payment_inputs.pop('pms', None)
+ token_count = request.env['payment.token'].sudo().search_count([('acquirer_id.company_id', '=', invoice.company_id.id),
+ ('partner_id', '=', invoice.partner_id.id),
+ ])
+ values['existing_token'] = token_count > 0
+ values.update(payment_inputs)
+ # if the current user is connected we set partner_id to his partner otherwise we set it as the invoice partner
+ # we do this to force the creation of payment tokens to the correct partner and avoid token linked to the public user
+ values['partner_id'] = invoice.partner_id if is_public_user else request.env.user.partner_id,
return values
diff --git a/addons/account_payment/views/account_portal_templates.xml b/addons/account_payment/views/account_portal_templates.xml
index 36bab9558ea..0a8de0cc973 100644
--- a/addons/account_payment/views/account_portal_templates.xml
+++ b/addons/account_payment/views/account_portal_templates.xml
@@ -53,6 +53,11 @@
</t>
</div>
</div>
+ <div class="panel-body" t-if="existing_token">
+ <div class="col-md-offset-3 col-md-6">
+ <i class="fa fa-info"></i> You have credits card registered, you can log-in to be able to use them.
+ </div>
+ </div>
</xpath>
</template>
diff --git a/addons/auth_signup/models/res_users.py b/addons/auth_signup/models/res_users.py
index 2dcd9cb373a..598d390dbd5 100644
--- a/addons/auth_signup/models/res_users.py
+++ b/addons/auth_signup/models/res_users.py
@@ -196,5 +196,5 @@ class ResUsers(models.Model):
sup = super(ResUsers, self)
if not default or not default.get('email'):
# avoid sending email to the user we are duplicating
- sup = super(ResUsers, self.with_context(reset_password=False))
+ sup = super(ResUsers, self.with_context(no_reset_password=True))
return sup.copy(default=default)
diff --git a/addons/barcodes/static/src/js/barcode_parser.js b/addons/barcodes/static/src/js/barcode_parser.js
index d834f3cbd27..cf357a438b2 100644
--- a/addons/barcodes/static/src/js/barcode_parser.js
+++ b/addons/barcodes/static/src/js/barcode_parser.js
@@ -18,6 +18,9 @@ var BarcodeParser = Class.extend({
// only when those data have been loaded
load: function(){
var self = this;
+ if (!this.nomenclature_id) {
+ return;
+ }
var id = this.nomenclature_id[0];
rpc.query({
model: 'barcode.nomenclature',
diff --git a/addons/base_address_extended/__manifest__.py b/addons/base_address_extended/__manifest__.py
index c9c78846b26..adb27e8bb90 100644
--- a/addons/base_address_extended/__manifest__.py
+++ b/addons/base_address_extended/__manifest__.py
@@ -16,7 +16,8 @@ For example, in legal reports, some countries need to split the street into seve
with the street name, the house number, and room number.
""",
'data': [
- 'views/base_address_extended.xml'
+ 'views/base_address_extended.xml',
+ 'data/base_address_extended_data.xml',
],
'depends': ['base'],
}
diff --git a/addons/base_address_extended/data/base_address_extended_data.xml b/addons/base_address_extended/data/base_address_extended_data.xml
new file mode 100644
index 00000000000..1e76cb4c5df
--- /dev/null
+++ b/addons/base_address_extended/data/base_address_extended_data.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data noupdate="1">
+ <record id="base.nl" model="res.country">
+ <field eval="'%(street_name)s %(street_number)s/%(street_number2)s'" name="street_format" />
+ </record>
+ </data>
+</odoo>
diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py
index 6ae096d7eb1..06272c99925 100644
--- a/addons/calendar/models/calendar.py
+++ b/addons/calendar/models/calendar.py
@@ -4,7 +4,7 @@ import base64
import babel.dates
import collections
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, MAXYEAR
from dateutil import parser
from dateutil import rrule
from dateutil.relativedelta import relativedelta
@@ -633,7 +633,7 @@ class Meeting(models.Model):
recurring_date = todate(meeting.recurrent_id_date)
rset1.exdate(recurring_date)
invalidate = True
- return [d.astimezone(pytz.UTC) if d.tzinfo else d for d in rset1]
+ return [d.astimezone(pytz.UTC) if d.tzinfo else d for d in rset1 if d.year < MAXYEAR]
@api.multi
def _get_recurrency_end_date(self):
@@ -903,8 +903,8 @@ class Meeting(models.Model):
startdate = startdate.astimezone(pytz.utc) # Convert to UTC
meeting.start = fields.Datetime.to_string(startdate)
else:
- meeting.start = meeting.start_datetime
- meeting.stop = meeting.stop_datetime
+ meeting.write({'start': meeting.start_datetime,
+ 'stop': meeting.stop_datetime})
@api.depends('byday', 'recurrency', 'final_date', 'rrule_type', 'month_by', 'interval', 'count', 'end_type', 'mo', 'tu', 'we', 'th', 'fr', 'sa', 'su', 'day', 'week_list')
def _compute_rrule(self):
diff --git a/addons/calendar/static/src/js/base_calendar.js b/addons/calendar/static/src/js/base_calendar.js
index 3752b7aa94a..e09222b6025 100644
--- a/addons/calendar/static/src/js/base_calendar.js
+++ b/addons/calendar/static/src/js/base_calendar.js
@@ -40,8 +40,10 @@ var CalendarNotification = Notification.extend({
},
'click .link2showed': function() {
- this.destroy(true);
- this._rpc({route: '/calendar/notify_ack'});
+ var self = this;
+ this._rpc({route: '/calendar/notify_ack'}).always(function() {
+ self.destroy();
+ });
},
});
},
diff --git a/addons/crm/__init__.py b/addons/crm/__init__.py
index 4e9b019bd7a..3398a4b470d 100644
--- a/addons/crm/__init__.py
+++ b/addons/crm/__init__.py
@@ -5,3 +5,11 @@ from . import controllers
from . import models
from . import report
from . import wizard
+
+from odoo import api, SUPERUSER_ID
+
+
+def uninstall_hook(cr, registry):
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ teams = env['crm.team'].search([('dashboard_graph_model', '=', 'crm.opportunity.report')])
+ teams.update({'dashboard_graph_model': None})
diff --git a/addons/crm/__manifest__.py b/addons/crm/__manifest__.py
index e224a6d8101..b09e6c9c70d 100644
--- a/addons/crm/__manifest__.py
+++ b/addons/crm/__manifest__.py
@@ -54,4 +54,5 @@
'installable': True,
'application': True,
'auto_install': False,
+ 'uninstall_hook': 'uninstall_hook',
}
diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py
index 3ec21c7eb1e..5945470f51c 100644
--- a/addons/crm/models/crm_lead.py
+++ b/addons/crm/models/crm_lead.py
@@ -450,7 +450,8 @@ class Lead(models.Model):
'res_id': self.id,
'views': [(form_view.id, 'form'),],
'type': 'ir.actions.act_window',
- 'target': 'inline'
+ 'target': 'inline',
+ 'context': {'default_type': 'opportunity'}
}
# ----------------------------------------
diff --git a/addons/crm/wizard/crm_lead_to_opportunity_views.xml b/addons/crm/wizard/crm_lead_to_opportunity_views.xml
index 201f2957636..5b29d25b934 100644
--- a/addons/crm/wizard/crm_lead_to_opportunity_views.xml
+++ b/addons/crm/wizard/crm_lead_to_opportunity_views.xml
@@ -56,8 +56,7 @@
<group string="Assign these opportunities to">
<field name="team_id"/>
<field name="user_ids" widget="many2many_tags" domain="[('share', '=', False)]"/>
- <!-- Uncomment me in trunk -->
- <!-- <field name="force_assignation" /> -->
+ <field name="force_assignation"/>
</group>
<label for="opportunity_ids" string="Leads with existing duplicates (for information)" help="Leads that you selected that have duplicates. If the list is empty, it means that no duplicates were found" attrs="{'invisible': [('deduplicate', '=', False)]}"/>
<group attrs="{'invisible': [('deduplicate', '=', False)]}">
diff --git a/addons/delivery/models/sale_order.py b/addons/delivery/models/sale_order.py
index 4402cb5a127..e08f37b135e 100644
--- a/addons/delivery/models/sale_order.py
+++ b/addons/delivery/models/sale_order.py
@@ -124,3 +124,7 @@ class SaleOrderLine(models.Model):
if not line.product_id or not line.product_uom or not line.product_uom_qty:
return 0.0
line.product_qty = line.product_uom._compute_quantity(line.product_uom_qty, line.product_id.uom_id)
+
+ def _is_delivery(self):
+ self.ensure_one()
+ return self.is_delivery
diff --git a/addons/event/security/event_security.xml b/addons/event/security/event_security.xml
index 110262928b6..7664085fc0e 100644
--- a/addons/event/security/event_security.xml
+++ b/addons/event/security/event_security.xml
@@ -41,5 +41,12 @@
]
</field>
</record>
+ <record model="ir.rule" id="event_registration_portal">
+ <field name="name">Event/Registration: Portal</field>
+ <field name="model_id" ref="model_event_registration"/>
+ <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
+ <field name="domain_force">['|', ('email', '=', user.partner_id.email), ('partner_id', '=', user.partner_id.id)]
+ </field>
+ </record>
</data>
</odoo>
diff --git a/addons/event/security/ir.model.access.csv b/addons/event/security/ir.model.access.csv
index ed7ec0e0cc6..8a49f01f191 100644
--- a/addons/event/security/ir.model.access.csv
+++ b/addons/event/security/ir.model.access.csv
@@ -5,7 +5,8 @@ access_event_event_portal,event.event.portal,model_event_event,,1,0,0,0
access_event_event_user,event.event.user,model_event_event,event.group_event_user,1,0,0,0
access_event_event_manager,event.event.manager,model_event_event,event.group_event_manager,1,1,1,1
access_event_registration,event.registration,model_event_registration,event.group_event_user,1,1,1,1
-access_event_registration_portal,event.registration,model_event_registration,,0,0,0,0
+access_event_registration_employee,event.registration,model_event_registration,base.group_user,1,0,0,0
+access_event_registration_portal,event.registration,model_event_registration,base.group_portal,1,0,0,0
access_event_mail,event.mail,model_event_mail,event.group_event_user,1,0,0,0
access_event_mail_manager,event.mail manager,model_event_mail,event.group_event_manager,1,1,1,1
access_event_mail_registration,event.mail.registration,model_event_mail_registration,event.group_event_user,1,0,0,0
diff --git a/addons/event_sale/__manifest__.py b/addons/event_sale/__manifest__.py
index 97140bd471f..2293b52dfb5 100644
--- a/addons/event_sale/__manifest__.py
+++ b/addons/event_sale/__manifest__.py
@@ -26,6 +26,7 @@ this event.
'data/event_sale_data.xml',
'report/event_event_templates.xml',
'security/ir.model.access.csv',
+ 'security/event_security.xml',
'wizard/event_edit_registration.xml',
],
'demo': ['data/event_demo.xml'],
diff --git a/addons/event_sale/security/event_security.xml b/addons/event_sale/security/event_security.xml
new file mode 100644
index 00000000000..a5bc0885b9b
--- /dev/null
+++ b/addons/event_sale/security/event_security.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data noupdate="0">
+ <record id="sales_team.group_sale_salesman" model="res.groups">
+ <field name="implied_ids" eval="[(4, ref('event.group_event_user'))]"/>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/hr_gamification/views/hr_employee_views.xml b/addons/hr_gamification/views/hr_employee_views.xml
index c464f4a48a0..4390f50327b 100644
--- a/addons/hr_gamification/views/hr_employee_views.xml
+++ b/addons/hr_gamification/views/hr_employee_views.xml
@@ -11,8 +11,8 @@
<page string="Received Badges" attrs="{'invisible': [('user_id', '=', False)]}">
<field name="has_badges" invisible="1"/>
<button string="Grant a Badge" type="action" name="%(action_reward_wizard)d"/> to reward this employee for a good action
- <div class="o_view_nocontent" attrs="{'invisible': [('has_badges', '=', True)]}">
- <p class="o_view_nocontent_smiling_face">
+ <div class="o_field_nocontent" attrs="{'invisible': [('has_badges', '=', True)]}">
+ <p>
Grant this employee his first badge
</p><p class="oe_grey">
Badges are rewards of good work. Give them to people you believe deserve it.
diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 1a97029e4e0..c8959775c77 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -40,12 +40,17 @@ class HolidaysRequest(models.Model):
_order = "date_from desc"
_inherit = ['mail.thread', 'mail.activity.mixin']
+ def _default_domain_holiday_status(self):
+ if self.user_has_groups('hr_holidays.group_hr_holidays_manager'):
+ return [('valid', '=', True)]
+ return [('valid', '=', True), ('employee_applicability', 'in', ['leave', 'both'])]
+
@api.model
def default_get(self, fields_list):
defaults = super(HolidaysRequest, self).default_get(fields_list)
LeaveType = self.env['hr.leave.type'].with_context(employee_id=defaults.get('employee_id'), default_date_from=defaults.get('date_from', fields.Datetime.now()))
- lt = LeaveType.search([('valid', '=', True), ('employee_applicability', 'in', ['leave', 'both'])])
+ lt = LeaveType.search(self._default_domain_holiday_status())
defaults['holiday_status_id'] = lt[0].id if len(lt) > 0 else defaults.get('holiday_status_id')
return defaults
@@ -77,7 +82,7 @@ class HolidaysRequest(models.Model):
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, track_visibility='onchange')
holiday_status_id = fields.Many2one("hr.leave.type", string="Leave Type", required=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
- domain="[('valid', '=', True), ('employee_applicability', 'in', ['leave', 'both'])]")
+ domain=lambda self: self._default_domain_holiday_status())
employee_id = fields.Many2one('hr.employee', string='Employee', index=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, default=_default_employee, track_visibility='onchange')
manager_id = fields.Many2one('hr.employee', string='Manager', readonly=True)
diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py
index 32c656a77a9..987f5256246 100644
--- a/addons/hr_holidays/models/hr_leave_allocation.py
+++ b/addons/hr_holidays/models/hr_leave_allocation.py
@@ -40,12 +40,17 @@ class HolidaysAllocation(models.Model):
_description = "Leaves Allocation"
_inherit = ['mail.thread', 'mail.activity.mixin']
+ def _default_domain_holiday_status_id(self):
+ if self.user_has_groups('hr_holidays.group_hr_holidays_manager'):
+ return [('valid', '=', True), ('limit', '=', False)]
+ return [('valid', '=', True), ('employee_applicability', 'in', ['allocation', 'both']), ('limit', '=', False)]
+
def _default_employee(self):
return self.env.context.get('default_employee_id') or self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1)
def _default_holiday_status_id(self):
LeaveType = self.env['hr.leave.type'].with_context(employee_id=self._default_employee().id)
- lt = LeaveType.search([('valid', '=', True), ('employee_applicability', 'in', ['leave', 'both']), ('limit', '=', False)])
+ lt = LeaveType.search(self._default_domain_holiday_status_id())
return lt[:1]
name = fields.Char('Description')
@@ -67,7 +72,7 @@ class HolidaysAllocation(models.Model):
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, track_visibility='onchange')
holiday_status_id = fields.Many2one("hr.leave.type", string="Leave Type", required=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
- domain="[('valid', '=', True), ('employee_applicability', 'in', ['allocation', 'both']), ('limit', '=', False)]", default=_default_holiday_status_id)
+ domain=lambda self: self._default_domain_holiday_status_id(), default=_default_holiday_status_id)
employee_id = fields.Many2one('hr.employee', string='Employee', index=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, default=_default_employee, track_visibility='onchange')
notes = fields.Text('Reasons', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]})
diff --git a/addons/hr_payroll/models/hr_salary_rule.py b/addons/hr_payroll/models/hr_salary_rule.py
index ed9798c941d..2c0209c06ed 100644
--- a/addons/hr_payroll/models/hr_salary_rule.py
+++ b/addons/hr_payroll/models/hr_salary_rule.py
@@ -86,6 +86,11 @@ class HrSalaryRuleCategory(models.Model):
company_id = fields.Many2one('res.company', string='Company',
default=lambda self: self.env['res.company']._company_default_get())
+ @api.constrains('parent_id')
+ def _check_parent_id(self):
+ if not self._check_recursion():
+ raise ValidationError(_('Error! You cannot create recursive hierarchy of Salary Rule Category.'))
+
class HrSalaryRule(models.Model):
_name = 'hr.salary.rule'
@@ -166,6 +171,11 @@ class HrSalaryRule(models.Model):
input_ids = fields.One2many('hr.rule.input', 'input_id', string='Inputs', copy=True)
note = fields.Text(string='Description')
+ @api.constrains('parent_rule_id')
+ def _check_parent_rule_id(self):
+ if not self._check_recursion(parent='parent_rule_id'):
+ raise ValidationError(_('Error! You cannot create recursive hierarchy of Salary Rules.'))
+
@api.multi
def _recursive_search_of_rules(self):
"""
diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py
index e228524893a..79a5f28b99b 100644
--- a/addons/im_livechat/controllers/main.py
+++ b/addons/im_livechat/controllers/main.py
@@ -16,7 +16,7 @@ class LivechatController(http.Controller):
# _get_asset return the bundle html code (script and link list) but we want to use the attachment content
xmlid = 'im_livechat.external_lib'
files, remains = request.env["ir.qweb"]._get_asset_content(xmlid, options=request.context)
- asset = AssetsBundle(xmlid, files, remains)
+ asset = AssetsBundle(xmlid, files)
mock_attachment = getattr(asset, ext)()
if isinstance(mock_attachment, list): # suppose that CSS asset will not required to be split in pages
diff --git a/addons/im_support/__init__.py b/addons/im_support/__init__.py
index a0fdc10fe11..aa4d0fd63a9 100644
--- a/addons/im_support/__init__.py
+++ b/addons/im_support/__init__.py
@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
+
+from . import controllers
from . import models
diff --git a/addons/im_support/controllers/__init__.py b/addons/im_support/controllers/__init__.py
new file mode 100644
index 00000000000..5d4b25db9c0
--- /dev/null
+++ b/addons/im_support/controllers/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import main
diff --git a/addons/im_support/controllers/main.py b/addons/im_support/controllers/main.py
new file mode 100644
index 00000000000..c95e642cdef
--- /dev/null
+++ b/addons/im_support/controllers/main.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import http
+from odoo.http import request
+
+
+class ImSupport(http.Controller):
+
+ @http.route('/im_support/tests', type='http', auth="user")
+ def test_suite(self, mod=None, **kwargs):
+ return request.render('im_support.support_qunit_suite')
diff --git a/addons/im_support/static/src/js/discuss.js b/addons/im_support/static/src/js/discuss.js
index df2526a2a26..0f29370b049 100644
--- a/addons/im_support/static/src/js/discuss.js
+++ b/addons/im_support/static/src/js/discuss.js
@@ -42,9 +42,9 @@ Discuss.include({
if (!channel.available) {
self.basicComposer.do_hide();
}
- $buttonAddAttachment.hide();
+ $buttonAddAttachment.toggleClass('o_hidden', true);
} else {
- $buttonAddAttachment.show();
+ $buttonAddAttachment.toggleClass('o_hidden', false);
}
});
},
diff --git a/addons/im_support/static/tests/helpers/test_utils.js b/addons/im_support/static/tests/helpers/test_utils.js
new file mode 100644
index 00000000000..5f90097a8f1
--- /dev/null
+++ b/addons/im_support/static/tests/helpers/test_utils.js
@@ -0,0 +1,81 @@
+odoo.define('im_support.test_utils', function (require) {
+"use strict";
+
+var supportSession = require('im_support.SupportSession');
+
+var testUtils = require('web.test_utils');
+
+
+/**
+ * Extended version of addMockEnvironment that mocks RPCs done to the Support
+ * server (CORS), and enables a longpolling to the Support server, optionally.
+ *
+ * @param {Widget} widget
+ * @param {Object} params
+ * @param {function} [params.mockSupportRPC]
+ * @param {boolean} [params.enableSupportPoll=false]
+ */
+function addMockSupportEnvironment(widget, params) {
+ // mock CORS RPCs
+ var originalRPC = supportSession.rpc;
+ var defaultMockSupportRPC = function (route, args) {
+ if (route === '/odoo_im_support/get_support_channel') {
+ return $.when({
+ available: true,
+ channel_type: 'livechat',
+ public: 'private',
+ uuid: args.channel_uuid,
+ });
+ }
+ if (route === '/odoo_im_support/fetch_messages') { // fetching history
+ return $.when([]);
+ }
+ if (route === '/odoo_im_support/chat_post') {
+ return $.when();
+ }
+ if (route === '/longpolling/support_poll') {
+ return $.Deferred();
+ }
+ };
+ supportSession.rpc = function (route, args) {
+ var result;
+ if (params.mockSupportRPC) {
+ var _super = this._super;
+ this._super = defaultMockSupportRPC.bind(this, route, args);
+ result = params.mockSupportRPC.apply(this, arguments);
+ this._super = _super;
+ } else {
+ result = defaultMockSupportRPC.apply(this, arguments);
+ }
+ return result;
+ };
+ testUtils.addMockEnvironment(widget, params);
+ var widgetDestroy = widget.destroy;
+ widget.destroy = function () {
+ supportSession.rpc = originalRPC;
+ widgetDestroy.call(this);
+ };
+
+ // intercepts the getItem call to localStorage service for key
+ // 'im_support.poll_timeout', to simulate a pending longpolling connection to
+ // the Support server, such that the initSupport initiates a poll connection
+ if (params.enableSupportPoll) {
+ testUtils.intercept(widget, 'call_service', function (ev) {
+ if (ev.data.service === 'local_storage') {
+ if (ev.data.method === 'getItem' && ev.data.args[0] === 'im_support.poll_timeout') {
+ ev.data.callback(Date.now() + (60 * 1000));
+ // hack to prevent the default mock to handle the request and erase the above value
+ ev.data.callback = function () {};
+ }
+ }
+ }, true);
+ // manually call initSupport (which is supposed to be called at webclient startup)
+ widget.call('chat_manager', 'initSupport');
+ }
+}
+
+return {
+ addMockSupportEnvironment: addMockSupportEnvironment,
+};
+
+});
diff --git a/addons/im_support/static/tests/systray_no_support_tests.js b/addons/im_support/static/tests/systray_no_support_tests.js
new file mode 100644
index 00000000000..d2f50817d8b
--- /dev/null
+++ b/addons/im_support/static/tests/systray_no_support_tests.js
@@ -0,0 +1,51 @@
+odoo.define('im_support.systray_no_support_tests', function (require) {
+"use strict";
+
+/**
+ * The purpose of these tests is to ensure that im_support doesn't have an impact
+ * on the webclient when the support is not available.
+ */
+
+var ChatManager = require('mail.ChatManager');
+var systray = require('mail.systray');
+var mailTestUtils = require('mail.testUtils');
+
+var testUtils = require('web.test_utils');
+
+var createBusService = mailTestUtils.createBusService;
+
+QUnit.module('im_support', {}, function () {
+
+QUnit.module('systray', {
+ beforeEach: function () {
+ this.services = [ChatManager, createBusService()];
+ this.data = {
+ 'mail.message': {
+ fields: {},
+ },
+ };
+ },
+});
+
+QUnit.test('messaging menu does not display the Support channel if not available', function (assert) {
+ // the Support channel should only be displayed if a support_token and a support_origin are
+ // specified in the session, which is not the case for this test
+ assert.expect(1);
+
+ var messagingMenu = new systray.MessagingMenu();
+ testUtils.addMockEnvironment(messagingMenu, {
+ services: this.services,
+ data: this.data,
+ });
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ messagingMenu.$('.dropdown-toggle').click();
+ assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
+ 0, "should not display the Support channel");
+
+ messagingMenu.destroy();
+});
+
+});
+
+});
diff --git a/addons/im_support/static/tests/systray_tests.js b/addons/im_support/static/tests/systray_tests.js
new file mode 100644
index 00000000000..97d82cf2379
--- /dev/null
+++ b/addons/im_support/static/tests/systray_tests.js
@@ -0,0 +1,342 @@
+odoo.define('im_support.systray_tests', function (require) {
+"use strict";
+
+var supportBus = require('im_support.SupportBus');
+var imSupportTestUtils = require('im_support.test_utils');
+
+var ChatManager = require('mail.ChatManager');
+var ChatWindowManager = require('mail.ChatWindowManager');
+var systray = require('mail.systray');
+var mailTestUtils = require('mail.testUtils');
+
+var testUtils = require('web.test_utils');
+
+var addMockSupportEnvironment = imSupportTestUtils.addMockSupportEnvironment;
+var createBusService = mailTestUtils.createBusService;
+
+QUnit.module('im_support', {}, function () {
+
+QUnit.module('systray', {
+ beforeEach: function () {
+ this.services = [ChatManager, ChatWindowManager, createBusService()];
+ this.supportParams = {
+ db_uuid: 'some_uuid',
+ support_token: 'ABCDEFGHIJ',
+ support_origin: 'something.com',
+ };
+ this.data = {
+ 'mail.message': {
+ fields: {},
+ },
+ };
+ },
+});
+
+QUnit.test('messaging menu displays the Support channel', function (assert) {
+ assert.expect(1);
+
+ var messagingMenu = new systray.MessagingMenu();
+ addMockSupportEnvironment(messagingMenu, {
+ services: this.services,
+ session: this.supportParams,
+ data: this.data,
+ });
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ messagingMenu.$('.dropdown-toggle').click();
+ assert.strictEqual(messagingMenu.$('.o_mail_navbar_dropdown_bottom .o_mail_channel_preview[data-channel_id=SupportChannel]').length,
+ 1, "should display the Support channel");
+
+ messagingMenu.destroy();
+});
+
+QUnit.test('clicking on Support channel: channel not available', function (assert) {
+ assert.expect(9);
+
+ var messagingMenu = new systray.MessagingMenu();
+ addMockSupportEnvironment(messagingMenu, {
+ mockRPC: function (route, args) {
+ if (!_.str.contains(route, '/static/')) {
+ assert.step(args.method || route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ mockSupportRPC: function (route) {
+ assert.step('cors: ' + route);
+ if (route === '/odoo_im_support/get_support_channel') {
+ return this._super.apply(this, arguments).then(function (supportChannel) {
+ supportChannel.available = false;
+ return supportChannel;
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ session: this.supportParams,
+ data: this.data,
+ });
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ messagingMenu.$('.dropdown-toggle').click();
+ assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
+ 1, "should display the Support channel");
+
+ messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').click();
+ assert.strictEqual($('.o_chat_window').length, 1,
+ "should have open a chat window");
+ assert.strictEqual($('.o_chat_window .o_chat_title').text().trim(), 'Support (offline)',
+ "should display the offline status in the header");
+ assert.strictEqual($('.o_chat_window .o_composer_input').length, 0,
+ "should have no composer");
+
+ assert.verifySteps([
+ '/mail/init_messaging',
+ 'message_fetch',
+ 'cors: /odoo_im_support/get_support_channel',
+ 'cors: /odoo_im_support/fetch_messages',
+ ]);
+
+ messagingMenu.destroy();
+});
+
+QUnit.test('clicking on Support channel: channel available', function (assert) {
+ assert.expect(9);
+
+ var messagingMenu = new systray.MessagingMenu();
+ addMockSupportEnvironment(messagingMenu, {
+ mockRPC: function (route, args) {
+ if (!_.str.contains(route, '/static/')) {
+ assert.step(args.method || route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ mockSupportRPC: function (route) {
+ if (route.split('/')[1] === 'odoo_im_support') {
+ assert.step('cors: ' + route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ session: this.supportParams,
+ data: this.data,
+ });
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ messagingMenu.$('.dropdown-toggle').click();
+ assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
+ 1, "should display the Support channel");
+
+ messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').click();
+ assert.strictEqual($('.o_chat_window').length, 1,
+ "should have open a chat window");
+ assert.strictEqual($('.o_chat_window .o_chat_title').text().trim(), 'Support',
+ "should display the correct channel title");
+ assert.strictEqual($('.o_chat_window .o_composer_input').length, 1,
+ "should have a composer");
+
+ assert.verifySteps([
+ '/mail/init_messaging',
+ 'message_fetch',
+ 'cors: /odoo_im_support/get_support_channel',
+ 'cors: /odoo_im_support/fetch_messages',
+ ]);
+
+ messagingMenu.destroy();
+});
+
+QUnit.test('post messages in Support channel', function (assert) {
+ assert.expect(8);
+
+ var messagingMenu = new systray.MessagingMenu();
+ addMockSupportEnvironment(messagingMenu, {
+ mockRPC: function (route, args) {
+ if (!_.str.contains(route, '/static/')) {
+ assert.step(args.method || route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ mockSupportRPC: function (route) {
+ if (route.split('/')[1] === 'odoo_im_support') {
+ assert.step('cors: ' + route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ session: this.supportParams,
+ data: this.data,
+ });
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ messagingMenu.$('.dropdown-toggle').click();
+ assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
+ 1, "should display the Support channel");
+
+ messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').click();
+ assert.strictEqual($('.o_chat_window .o_composer_input').length, 1,
+ "should have a composer");
+
+ $('.o_chat_window .o_composer_input .o_input')
+ .val('some message')
+ .trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
+
+ assert.verifySteps([
+ '/mail/init_messaging',
+ 'message_fetch',
+ 'cors: /odoo_im_support/get_support_channel',
+ 'cors: /odoo_im_support/fetch_messages',
+ 'cors: /odoo_im_support/chat_post',
+ ]);
+
+ messagingMenu.destroy();
+});
+
+QUnit.test('fold Support channel', function (assert) {
+ assert.expect(11);
+
+ var messagingMenu = new systray.MessagingMenu();
+ addMockSupportEnvironment(messagingMenu, {
+ mockRPC: function (route, args) {
+ if (!_.str.contains(route, '/static/')) {
+ assert.step(args.method || route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ mockSupportRPC: function (route) {
+ if (route.split('/')[1] === 'odoo_im_support') {
+ assert.step('cors: ' + route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ session: this.supportParams,
+ data: this.data,
+ });
+ testUtils.intercept(messagingMenu, 'call_service', function (ev) {
+ if (ev.data.service === 'local_storage') {
+ assert.step('LocalStorage: ' + ev.data.method + ' ' + ev.data.args);
+ }
+ }, true);
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ messagingMenu.$('.dropdown-toggle').click();
+ assert.strictEqual(messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').length,
+ 1, "should display the Support channel");
+
+ messagingMenu.$('.o_mail_channel_preview[data-channel_id=SupportChannel]').click();
+ assert.strictEqual($('.o_chat_window').length, 1,
+ "should have open a chat window");
+
+ // fold, re-open and close channel
+ $('.o_chat_window .o_chat_title').click();
+ $('.o_chat_window .o_chat_title').click();
+ $('.o_chat_window .o_chat_window_close').click();
+
+ assert.verifySteps([
+ '/mail/init_messaging',
+ 'message_fetch',
+ 'cors: /odoo_im_support/get_support_channel',
+ 'LocalStorage: setItem im_support.channel_state,open',
+ 'cors: /odoo_im_support/fetch_messages',
+ 'LocalStorage: setItem im_support.channel_state,folded',
+ 'LocalStorage: setItem im_support.channel_state,open',
+ 'LocalStorage: setItem im_support.channel_state,closed',
+ ]);
+
+ messagingMenu.destroy();
+});
+
+QUnit.test('restore Support channel if necessary', function (assert) {
+ assert.expect(5);
+
+ var messagingMenu = new systray.MessagingMenu();
+ addMockSupportEnvironment(messagingMenu, {
+ enableSupportPoll: true,
+ mockRPC: function (route, args) {
+ if (!_.str.contains(route, '/static/')) {
+ assert.step(args.method || route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ mockSupportRPC: function (route) {
+ if (route.split('/')[1] === 'odoo_im_support') {
+ assert.step('cors: ' + route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ session: this.supportParams,
+ data: this.data,
+ });
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ assert.strictEqual($('.o_chat_window').length, 1,
+ "should have open a chat window");
+
+ assert.verifySteps([
+ '/mail/init_messaging',
+ 'cors: /odoo_im_support/get_support_channel',
+ 'cors: /odoo_im_support/fetch_messages',
+ ]);
+
+ messagingMenu.destroy();
+});
+
+QUnit.test('receive messages in the Support channel', function (assert) {
+ assert.expect(9);
+
+ var supportChannelID;
+
+ var messagingMenu = new systray.MessagingMenu();
+ addMockSupportEnvironment(messagingMenu, {
+ enableSupportPoll: true,
+ mockRPC: function (route, args) {
+ if (!_.str.contains(route, '/static/')) {
+ assert.step(args.method || route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ mockSupportRPC: function (route) {
+ if (route.split('/')[1] === 'odoo_im_support') {
+ assert.step('cors: ' + route);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ session: this.supportParams,
+ data: this.data,
+ });
+ messagingMenu.appendTo($('#qunit-fixture'));
+
+ assert.strictEqual($('.o_chat_window').length, 1,
+ "should have open a chat window");
+ assert.strictEqual($('.o_chat_window .o_thread_message').length, 0,
+ "there should be no message in the thread");
+
+ // simulate an incoming message on the supportBus
+ var data = {
+ author_id: [42, 'An operator'],
+ body: 'A message',
+ channel_ids: [supportChannelID],
+ };
+ var notification = [[false, 'mail.channel'], data];
+ supportBus.trigger('notification', [notification]);
+
+ assert.strictEqual($('.o_chat_window .o_thread_message').length, 1,
+ "there should be a new message in the thread");
+ assert.strictEqual($('.o_chat_window .o_thread_message .o_thread_author ').text().trim(),
+ 'An operator', "should correctly display the author");
+ assert.strictEqual($('.o_chat_window .o_thread_message .o_thread_message_content ').text().trim(),
+ 'A message', "message is correct");
+
+ assert.verifySteps([
+ '/mail/init_messaging',
+ 'cors: /odoo_im_support/get_support_channel',
+ 'cors: /odoo_im_support/fetch_messages',
+ ]);
+
+ messagingMenu.destroy();
+});
+
+});
+
+});
diff --git a/addons/im_support/tests/__init__.py b/addons/im_support/tests/__init__.py
new file mode 100644
index 00000000000..b9df3527588
--- /dev/null
+++ b/addons/im_support/tests/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_js
diff --git a/addons/im_support/tests/test_js.py b/addons/im_support/tests/test_js.py
new file mode 100644
index 00000000000..0b52ea4a596
--- /dev/null
+++ b/addons/im_support/tests/test_js.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import odoo.tests
+
+
+@odoo.tests.tagged('post_install', '-at_install')
+class IMSupportSuite(odoo.tests.HttpCase):
+
+ def test_im_support_js(self):
+ self.phantom_js('/im_support/tests?mod=web&failfast', "", "", login='admin', timeout=180)
diff --git a/addons/im_support/views/assets.xml b/addons/im_support/views/assets.xml
index 18ecaa37c1e..de7e8289b8d 100644
--- a/addons/im_support/views/assets.xml
+++ b/addons/im_support/views/assets.xml
@@ -12,5 +12,34 @@
<script type="text/javascript" src="/im_support/static/src/js/systray.js"></script>
</xpath>
</template>
+
+ <template id="qunit_suite" name="im_support_tests" inherit_id="web.qunit_suite">
+ <xpath expr="//t[@t-set='head']" position="inside">
+ <script type="text/javascript" src="/im_support/static/tests/systray_no_support_tests.js"></script>
+ </xpath>
+ </template>
+
+ <template id="im_support.support_qunit_suite">
+ <t t-call="web.layout">
+ <t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
+ <t t-set="title">IM Support Tests</t>
+ <t t-set="head">
+ <script type="text/javascript">
+ odoo.session_info = {
+ support_token: 'ABCDEFGHIJ',
+ support_origin: 'https://something.com'
+ };
+ </script>
+ <t t-call="web.js_tests_assets"/>
+
+ <script type="text/javascript" src="/im_support/static/tests/helpers/test_utils.js"></script>
+
+ <script type="text/javascript" src="/im_support/static/tests/systray_tests.js"></script>
+ </t>
+
+ <div id="qunit"/>
+ <div id="qunit-fixture"/>
+ </t>
+ </template>
</data>
</openerp>
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index c5d3abda206..a32ccfa9b54 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -6,6 +6,7 @@ import re
from operator import itemgetter
from email.utils import formataddr
+from openerp.http import request
from odoo import _, api, fields, models, modules, SUPERUSER_ID, tools
from odoo.exceptions import UserError, AccessError
@@ -334,11 +335,12 @@ class Message(models.Model):
# 2. Attachments as SUPERUSER, because could receive msg and attachments for doc uid cannot see
attachments_data = attachments.sudo().read(['id', 'datas_fname', 'name', 'mimetype'])
+ safari = request and request.httprequest.user_agent.browser == 'safari'
attachments_tree = dict((attachment['id'], {
'id': attachment['id'],
'filename': attachment['datas_fname'],
'name': attachment['name'],
- 'mimetype': attachment['mimetype'],
+ 'mimetype': 'application/octet-stream' if safari and 'video' in attachment['mimetype'] else attachment['mimetype'],
}) for attachment in attachments_data)
# 3. Tracking values
diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js
index e48a7c60028..63948b8429a 100644
--- a/addons/mail/static/src/js/chatter.js
+++ b/addons/mail/static/src/js/chatter.js
@@ -24,6 +24,7 @@ var QWeb = core.qweb;
var Chatter = Widget.extend({
template: 'mail.Chatter',
custom_events: {
+ discard_record_changes: '_onDiscardRecordChanges',
reload_mail_fields: '_onReloadMailFields',
},
events: {
@@ -155,6 +156,37 @@ var Chatter = Widget.extend({
}
},
/**
+ * Discard changes on the record.
+ *
+ * @private
+ * @returns {$.Deferred} resolved if successfully discarding changes on
+ * the record, rejected otherwise
+ */
+ _discardChanges: function () {
+ var def = $.Deferred();
+ this.trigger_up('discard_changes', {
+ recordID: this.record.id,
+ onSuccess: def.resolve.bind(def),
+ onFailure: def.reject.bind(def),
+ });
+ return def;
+ },
+ /**
+ * Discard changes on the record if the message will reload the record
+ * after posting it
+ *
+ * @private
+ * @param {Object} message
+ * @return {$.Deferred} resolved if no reload or proceed to discard the
+ * changes on the record, rejected otherwise
+ */
+ _discardOnReload: function (message) {
+ if (this._reloadAfterPost(message)) {
+ return this._discardChanges();
+ }
+ return $.when();
+ },
+ /**
* @private
*/
_disableChatter: function () {
@@ -199,11 +231,13 @@ var Chatter = Widget.extend({
self.composer.focus();
}
self.composer.on('post_message', self, function (message) {
- self.fields.thread.postMessage(message).then(function () {
- self._closeComposer(true);
- if (self.postRefresh === 'always' || (self.postRefresh === 'recipients' && message.partner_ids.length)) {
- self.trigger_up('reload');
- }
+ self._discardOnReload(message).then(function () {
+ self.fields.thread.postMessage(message).then(function () {
+ self._closeComposer(true);
+ if (self._reloadAfterPost(message)) {
+ self.trigger_up('reload');
+ }
+ });
});
});
self.composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload'));
@@ -216,6 +250,24 @@ var Chatter = Widget.extend({
});
},
/**
+ * State if the record will be reloaded after posting a message.
+ * Useful to warn the user of unsaved changes if the record is dirty.
+ *
+ * @private
+ * @param {Object} message
+ * @param {Array} [message.partner_ids] list of recipients of a message
+ * @return {boolean} true if record will be reloaded after posting the
+ * message, false otherwise
+ */
+ _reloadAfterPost: function (message) {
+ return this.postRefresh === 'always' ||
+ (
+ this.postRefresh === 'recipients' &&
+ message.partner_ids &&
+ message.partner_ids.length
+ );
+ },
+ /**
* @private
* @param {Deferred} def
* @returns {Deferred}
@@ -314,8 +366,16 @@ var Chatter = Widget.extend({
//--------------------------------------------------------------------------
/**
+ * Discard changes on the record.
+ * This is notified by the composer, when opening the full-composer.
+ *
* @private
+ * @param {OdooEvent} ev
+ * @param {function} ev.data.proceed callback to tell to proceed
*/
+ _onDiscardRecordChanges: function (ev) {
+ this._discardChanges().then(ev.data.proceed);
+ },
_onOpenComposerMessage: function () {
var self = this;
if (!this.suggested_partners_def) {
diff --git a/addons/mail/static/src/js/chatter_composer.js b/addons/mail/static/src/js/chatter_composer.js
index da85f331863..b5c17f3f593 100644
--- a/addons/mail/static/src/js/chatter_composer.js
+++ b/addons/mail/static/src/js/chatter_composer.js
@@ -202,14 +202,23 @@ var ChatterComposer = composer.BasicComposer.extend({
}
var self = this;
- var recipient_done = $.Deferred();
- if (this.options.is_log) {
- recipient_done.resolve([]);
- } else {
- var checked_suggested_partners = this.get_checked_suggested_partners();
- recipient_done = this.check_suggested_partners(checked_suggested_partners);
- }
- recipient_done.then(function (partner_ids) {
+ var recipientDoneDef = $.Deferred();
+
+ // any operation on the full-composer will reload the record, so
+ // warn the user that any unsaved changes on the record will be lost.
+ this.trigger_up('discard_record_changes', {
+ proceed: function () {
+ if (self.options.is_log) {
+ recipientDoneDef.resolve([]);
+ } else {
+ var checked_suggested_partners = self.get_checked_suggested_partners();
+ self.check_suggested_partners(checked_suggested_partners)
+ .then(recipientDoneDef.resolve.bind(recipientDoneDef));
+ }
+ },
+ });
+
+ recipientDoneDef.then(function (partner_ids) {
var context = {
default_parent_id: self.id,
default_body: utils.get_text2html(self.$input.val()),
diff --git a/addons/mail/static/src/js/services/chat_window_manager.js b/addons/mail/static/src/js/services/chat_window_manager.js
index bea6eaf0ace..fd45b336c23 100644
--- a/addons/mail/static/src/js/services/chat_window_manager.js
+++ b/addons/mail/static/src/js/services/chat_window_manager.js
@@ -108,7 +108,7 @@ var ChatWindowManager = AbstractService.extend({
uuid: session.uuid,
name: session.name,
keep_unread: options.passively, // don't automatically mark unread messages as seen
- window: new ExtendedChatWindow(web_client, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions),
+ window: new ExtendedChatWindow(this, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions),
};
chatSession.window.on("close_chat_session", null, function () {
self._closeChat(chatSession);
diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss
index cb079f81558..fed625ace35 100644
--- a/addons/mail/static/src/scss/composer.scss
+++ b/addons/mail/static/src/scss/composer.scss
@@ -152,7 +152,7 @@
&.o_chat_inline_composer {
.o_composer_container {
- @include o-flex(1, 0, auto);
+ @include o-flex(1, 1, auto);
}
.o_composer {
padding: $o-mail-chatter-gap $o-mail-chatter-gap 0 $o-mail-chatter-gap;
diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js
index 46de2e22d3f..f1f6097cb97 100644
--- a/addons/mail/static/tests/chatter_tests.js
+++ b/addons/mail/static/tests/chatter_tests.js
@@ -611,6 +611,285 @@ QUnit.test('chatter: post a message and switch in edit mode', function (assert)
form.destroy();
});
+QUnit.test('chatter: discard changes on message post with post_refresh "always"', function (assert) {
+ // After posting a message that always reloads the record, if the record
+ // is dirty (= has some unsaved changes), we should warn the user that
+ // these changes will be lost if he proceeds.
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '<div class="oe_chatter">' +
+ '<field name="message_ids" widget="mail_thread"' +
+ ' options="{\'display_log_button\': True, \'post_refresh\': \'always\'}"/>' +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ session: {},
+ mockRPC: function (route, args) {
+ if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") {
+ return $.when({2: []});
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // Make record dirty
+ form.$('.o_form_sheet input').val('trululu').trigger('input');
+
+ // Send a message
+ form.$('.o_chatter_button_new_message').click();
+ form.$('.oe_chatter .o_composer_text_field:first()').val("My first message");
+ form.$('.oe_chatter .o_composer_button_send').click();
+
+ var $modal = $('.modal-dialog');
+ assert.strictEqual($modal.length, 1, "should have a modal opened");
+ assert.strictEqual($modal.find('.modal-body').text(),
+ "The record has been modified, your changes will be discarded. Do you want to proceed?",
+ "should warn the user that any unsaved changes will be lost");
+
+ form.destroy();
+});
+
+QUnit.test('chatter: discard changes on message post without post_refresh', function (assert) {
+ // After posting a message, if the record is dirty and there are no
+ // post_refresh rule, it will not discard the changes on the record.
+ assert.expect(2);
+
+ var hasDiscardChanges = false; // set if `discard_changes` has been triggered up
+ var messages = [];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '<div class="oe_chatter">' +
+ '<field name="message_ids" widget="mail_thread"' +
+ ' options="{\'display_log_button\': True}"/>' +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ session: {},
+ mockRPC: function (route, args) {
+ if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") {
+ return $.when({2: []});
+ }
+ if (args.method === 'message_format') {
+ var requested_msgs = _.filter(messages, function (msg) {
+ return _.contains(args.args[0], msg.id);
+ });
+ return $.when(requested_msgs);
+ }
+ if (args.method === 'message_post') {
+ messages.push({
+ attachment_ids: [],
+ author_id: ["42", "Me"],
+ body: args.kwargs.body,
+ date: moment().format('YYYY-MM-DD HH:MM:SS'), // now
+ displayed_author: "Me",
+ id: 42,
+ is_note: args.kwargs.subtype === 'mail.mt_note',
+ is_starred: false,
+ model: 'partner',
+ res_id: 2,
+ });
+ return $.when(42);
+ }
+ return this._super(route, args);
+ },
+ intercepts: {
+ discard_changes: function () {
+ hasDiscardChanges = true; // should not do that
+ },
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // Make record dirty
+ form.$('.o_form_sheet input').val('trululu').trigger('input');
+
+ // Send a message
+ form.$('.o_chatter_button_new_message').click();
+ form.$('.oe_chatter .o_composer_text_field:first()').val("My first message");
+ form.$('.oe_chatter .o_composer_button_send').click();
+
+ var $modal = $('.modal-dialog');
+ assert.strictEqual($modal.length, 0, "should have no modal opened");
+ assert.notOk(hasDiscardChanges);
+
+ form.destroy();
+});
+
+QUnit.test('chatter: discard changes on message post with post_refresh "recipients"', function (assert) {
+ // After posting a message with mentions, the record will be reloaded,
+ // as the rpc `message_post` may make changes on some fields of the record.
+ // If the record is dirty (= has some unsaved changes), we should warn the
+ // user that these changes will be lost if he proceeds.
+ assert.expect(2);
+ var done = assert.async();
+
+ var getSuggestionsDef = $.Deferred();
+
+ var messages = [];
+ var bus = new Bus();
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '<div class="oe_chatter">' +
+ '<field name="message_ids" widget="mail_thread"' +
+ ' options="{\'display_log_button\': True, \'post_refresh\': \'recipients\'}"/>' +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ session: {},
+ mockRPC: function (route, args) {
+ if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") {
+ return $.when({2: [[42, "Me"]]});
+ }
+ if (args.method === 'get_mention_suggestions') {
+ getSuggestionsDef.resolve();
+ return $.when([{email: "me@odoo.com", id: 42, name: "Me"}]);
+ }
+ if (args.method === 'message_format') {
+ var requested_msgs = _.filter(messages, function (msg) {
+ return _.contains(args.args[0], msg.id);
+ });
+ return $.when(requested_msgs);
+ }
+ if (args.method === 'message_post') {
+ messages.push({
+ attachment_ids: [],
+ author_id: ["42", "Me"],
+ body: args.kwargs.body,
+ date: moment().format('YYYY-MM-DD HH:MM:SS'), // now
+ displayed_author: "Me",
+ id: 42,
+ is_note: args.kwargs.subtype === 'mail.mt_note',
+ is_starred: false,
+ model: 'partner',
+ res_id: 2,
+ });
+ return $.when(42);
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // Make record dirty
+ form.$('.o_form_sheet input').val('trululu').trigger('input');
+
+ // create a new message
+ form.$('.o_chatter_button_new_message').click();
+
+ // Add a user as mention
+ form.$('.oe_chatter .o_composer_text_field:first()').val("@");
+
+ var $input = form.$('.oe_chatter .o_composer_text_field:first()');
+ $input.val('@');
+ // the cursor position must be set for the mention manager to detect that we are mentionning
+ $input[0].selectionStart = 1;
+ $input[0].selectionEnd = 1;
+ $input.trigger('keyup');
+
+ getSuggestionsDef
+ .then(concurrency.delay.bind(concurrency, 0))
+ .then(function () {
+ // click on mention
+ $input.trigger($.Event('keyup', {which: $.ui.keyCode.ENTER}));
+
+ // untick recipient as follower (prompts a res.partner form otherwise)
+ form.$('.o_checkbox input').prop('checked', false);
+
+ // send message
+ form.$('.oe_chatter .o_composer_button_send').click();
+
+ var $modal = $('.modal-dialog');
+ assert.strictEqual($modal.length, 1, "should have a modal opened");
+ assert.strictEqual($modal.find('.modal-body').text(),
+ "The record has been modified, your changes will be discarded. Do you want to proceed?",
+ "should warn the user that any unsaved changes will be lost");
+
+ form.destroy();
+ done();
+ });
+});
+
+QUnit.test('chatter: discard changes on opening full-composer', function (assert) {
+ // When we open the full-composer, any following operations by the user
+ // will reload the record (even closing the full-composer). Therefore,
+ // we should warn the user when we open the full-composer if the record
+ // is dirty (= has some unsaved changes).
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="foo"/>' +
+ '</sheet>' +
+ '<div class="oe_chatter">' +
+ '<field name="message_ids" widget="mail_thread"' +
+ ' options="{\'display_log_button\': True,' +
+ ' \'post_refresh\': \'always\'}"/>' +
+ '</div>' +
+ '</form>',
+ res_id: 2,
+ session: {},
+ mockRPC: function (route, args) {
+ if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") {
+ return $.when({2: []});
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // Make record dirty
+ form.$('.o_form_sheet input').val('trululu').trigger('input');
+
+ // Open full-composer
+ form.$('.o_chatter_button_new_message').click();
+ form.$('.o_composer_button_full_composer').click();
+
+ var $modal = $('.modal-dialog');
+ assert.strictEqual($modal.length, 1, "should have a modal opened");
+ assert.strictEqual($modal.find('.modal-body').text(),
+ "The record has been modified, your changes will be discarded. Do you want to proceed?",
+ "should warn the user that any unsaved changes will be lost");
+
+ form.destroy();
+});
+
QUnit.test('chatter: Attachment viewer', function (assert) {
assert.expect(6);
this.data.partner.records[0].message_ids = [1];
diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js
index 3382fa90dd0..527d873863e 100644
--- a/addons/mail/static/tests/helpers/test_utils.js
+++ b/addons/mail/static/tests/helpers/test_utils.js
@@ -38,6 +38,9 @@ function createBusService(bus) {
* Do nothing
*/
start_polling: function () {},
+ is_odoo_focused: function () {
+ return true;
+ },
});
},
diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml
index 5dd1b057bd9..7283014052e 100644
--- a/addons/mail/views/mail_templates.xml
+++ b/addons/mail/views/mail_templates.xml
@@ -46,6 +46,13 @@
</xpath>
</template>
+ <template id="js_test_assets" name="mail_js_test_assets" inherit_id="web.js_tests_assets">
+ <xpath expr="." position="inside">
+ <script type="text/javascript" src="/mail/static/tests/helpers/mock_server.js"></script>
+ <script type="text/javascript" src="/mail/static/tests/helpers/test_utils.js"></script>
+ </xpath>
+ </template>
+
<template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/chat_window_tests.js"></script>
@@ -54,16 +61,12 @@
<script type="text/javascript" src="/mail/static/tests/discuss_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/discuss_moderation_tests.js"></script>
- <script type="text/javascript" src="/mail/static/tests/helpers/mock_server.js"></script>
- <script type="text/javascript" src="/mail/static/tests/helpers/test_utils.js"></script>
</xpath>
</template>
<template id="qunit_mobile_suite" name="mail_mobile_tests" inherit_id="web.qunit_mobile_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/discuss_mobile_tests.js"></script>
- <script type="text/javascript" src="/mail/static/tests/helpers/mock_server.js"></script>
- <script type="text/javascript" src="/mail/static/tests/helpers/test_utils.js"></script>
</xpath>
</template>
diff --git a/addons/mass_mailing_event/views/event_views.xml b/addons/mass_mailing_event/views/event_views.xml
index e3105fad8eb..480c0dddf21 100644
--- a/addons/mass_mailing_event/views/event_views.xml
+++ b/addons/mass_mailing_event/views/event_views.xml
@@ -10,7 +10,9 @@
name="action_mass_mailing_attendees"
icon="fa-envelope-o"
attrs="{'invisible': [('seats_expected', '=', 0)]}">
- Mail Attendees
+ <div class="o_field_widget o_stat_info">
+ <span class="o_stat_text">Mail Attendees</span>
+ </div>
</button>
</button>
</field>
diff --git a/addons/mrp/report/mrp_report_bom_structure.py b/addons/mrp/report/mrp_report_bom_structure.py
index 03d193cb7b5..c9a461815a1 100644
--- a/addons/mrp/report/mrp_report_bom_structure.py
+++ b/addons/mrp/report/mrp_report_bom_structure.py
@@ -164,7 +164,7 @@ class ReportBomStructure(models.AbstractModel):
if line.child_bom_id:
qty = line.product_uom_id._compute_quantity(line.product_qty * factor, line.child_bom_id.product_uom_id)
sub_price = self._get_price(line.child_bom_id, qty)
- price += sub_price * qty
+ price += sub_price
else:
prod_qty = line.product_qty * factor
price += (line.product_id.uom_id._compute_price(line.product_id.standard_price, line.product_uom_id) * prod_qty)
diff --git a/addons/mrp/report/mrp_report_bom_structure.xml b/addons/mrp/report/mrp_report_bom_structure.xml
index 2122a9e10f7..e7cc9964230 100644
--- a/addons/mrp/report/mrp_report_bom_structure.xml
+++ b/addons/mrp/report/mrp_report_bom_structure.xml
@@ -3,7 +3,7 @@
<template id="report_mrp_bom">
<t t-raw="'&lt;base href=%s&gt;' % base_url"/>
<div class="container o_mrp_bom_report_page">
- <t t-if="data['components'] or data['lines']">
+ <t t-if="data.get('components') or data.get('lines')">
<div class="row">
<div class="col-md-12">
<h1>BoM Structure &amp; Cost</h1>
diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js
index 956765e8042..529e0dc43d2 100644
--- a/addons/payment_stripe/static/src/js/stripe.js
+++ b/addons/payment_stripe/static/src/js/stripe.js
@@ -54,8 +54,8 @@ odoo.define('payment_stripe.stripe', function(require) {
}).done(function(data){
handler.isTokenGenerate = false;
window.location.href = data;
- }).fail(function(){
- var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.arguments && arguments[1].data.arguments[0];
+ }).fail(function(data){
+ var msg = data && data.data && data.data.message;
var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
wizard.appendTo($('body')).modal({'keyboard': true});
});
diff --git a/addons/payment_stripe/tests/test_stripe.py b/addons/payment_stripe/tests/test_stripe.py
index 33d7e72c671..5308f54dbe0 100644
--- a/addons/payment_stripe/tests/test_stripe.py
+++ b/addons/payment_stripe/tests/test_stripe.py
@@ -56,31 +56,14 @@ class StripeTest(StripeCommon):
# ----------------------------------------
# Test: button direct rendering
# ----------------------------------------
- form_values = {
- 'amount': 320.0,
- 'currency': 'EUR',
- 'address_line1': 'Huge Street 2/543',
- 'address_city': 'Sin City',
- 'address_country': 'Belgium',
- 'email': 'norbert.buyer@example.com',
- 'address_zip': '1000',
- 'name': 'Norbert Buyer',
- 'phone': '0032 12 34 56 78'
- }
# render the button
- res = self.stripe.render('SO404', 320.0, self.currency_euro.id, values=self.buyer_values)
- post_url = "https://checkout.stripe.com/checkout.js"
- email = "norbert.buyer@example.com"
+ res = self.stripe.render('SO404', 320.0, self.currency_euro.id, values=self.buyer_values).decode('utf-8')
+ popup_script_src = 'script src="https://checkout.stripe.com/checkout.js"'
# check form result
- if "https://checkout.stripe.com/checkout.js" in res[0]:
- self.assertEqual(post_url, 'https://checkout.stripe.com/checkout.js', 'Stripe: wrong form POST url')
+ self.assertIn(popup_script_src, res, "Stripe: popup script not found in template render")
# Generated and received
- if email in res[0]:
- self.assertEqual(
- email, form_values.get('email'),
- 'Stripe: wrong value for input %s: received %s instead of %s' % (email, email, form_values.get('email'))
- )
+ self.assertIn(self.buyer_values.get('partner_email'), res, 'Stripe: email input not found in rendered template')
def test_30_stripe_form_management(self):
self.assertEqual(self.stripe.environment, 'test', 'test without test environment')
diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py
index 15e64548aad..f6b12b05bef 100644
--- a/addons/point_of_sale/models/pos_config.py
+++ b/addons/point_of_sale/models/pos_config.py
@@ -326,10 +326,11 @@ class PosConfig(models.Model):
def name_get(self):
result = []
for config in self:
- if (not config.session_ids) or (config.session_ids[0].state == 'closed'):
+ last_session = self.env['pos.session'].search([('config_id', '=', config.id)], limit=1)
+ if (not last_session) or (last_session.state == 'closed'):
result.append((config.id, config.name + ' (' + _('not used') + ')'))
continue
- result.append((config.id, config.name + ' (' + config.session_ids[0].user_id.name + ')'))
+ result.append((config.id, config.name + ' (' + last_session.user_id.name + ')'))
return result
@api.model
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index f5f20530004..2972e9f7e2c 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -418,8 +418,10 @@ class PosOrder(models.Model):
def _get_pos_anglo_saxon_price_unit(self, product, partner_id, quantity):
price_unit = product._get_anglo_saxon_price_unit()
if product._get_invoice_policy() == "delivery":
- moves = self.filtered(lambda o: o.partner_id.id == partner_id).mapped('picking_id.move_lines').filtered(lambda m: m.product_id.id == product.id)
- moves.sorted(lambda x: x.date)
+ moves = self.filtered(lambda o: o.partner_id.id == partner_id)\
+ .mapped('picking_id.move_lines')\
+ .filtered(lambda m: m.product_id.id == product.id)\
+ .sorted(lambda x: x.date)
average_price_unit = product._compute_average_price(0, quantity, moves)
price_unit = average_price_unit or price_unit
# In the SO part, the entries will be inverted by function compute_invoice_totals
diff --git a/addons/point_of_sale/static/src/js/devices.js b/addons/point_of_sale/static/src/js/devices.js
index f06abc15c66..f45a48319ba 100644
--- a/addons/point_of_sale/static/src/js/devices.js
+++ b/addons/point_of_sale/static/src/js/devices.js
@@ -215,7 +215,7 @@ var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
var self = this;
function status(){
- self.connection.rpc('/hw_proxy/status_json',{},{timeout:2500})
+ self.connection.rpc('/hw_proxy/status_json',{},{shadow: true, timeout:2500})
.then(function(driver_status){
self.set_connection_status('connected',driver_status);
},function(){
@@ -239,7 +239,7 @@ var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
callbacks[i](params);
}
if(this.get('status').status !== 'disconnected'){
- return this.connection.rpc('/hw_proxy/' + name, params || {});
+ return this.connection.rpc('/hw_proxy/' + name, params || {}, {shadow: true});
}else{
return (new $.Deferred()).reject();
}
@@ -588,7 +588,7 @@ var BarcodeReader = core.Class.extend({
this.remote_active = 1;
function waitforbarcode(){
- return self.proxy.connection.rpc('/hw_proxy/scanner',{},{timeout:7500})
+ return self.proxy.connection.rpc('/hw_proxy/scanner',{},{shadow: true, timeout:7500})
.then(function(barcode){
if(!self.remote_scanning){
self.remote_active = 0;
diff --git a/addons/point_of_sale/views/pos_session_view.xml b/addons/point_of_sale/views/pos_session_view.xml
index bd18d81d329..5a82b72b0f2 100644
--- a/addons/point_of_sale/views/pos_session_view.xml
+++ b/addons/point_of_sale/views/pos_session_view.xml
@@ -92,11 +92,11 @@
<field name="cash_register_balance_end" nolabel="1" attrs="{'invisible' : [('state', '=', 'opening_control')]}" class="oe_bold oe_pos_total" widget="monetary" options="{'currency_field': 'currency_id'}"/>
</group>
<div class="oe_clear"/>
- <div attrs="{'invisible' : ['|', ('cash_journal_id', '=', False), ('state', '!=', 'opening_control')]}" class="o_view_nocontent" groups="point_of_sale.group_pos_manager">
- <p class="o_view_nocontent_smiling_face">
- You can define another list of available currencies on the
- <i>Cash Registers</i> tab of the <b><field name="cash_journal_id" class="oe_inline"/></b>
- payment method.
+ <div attrs="{'invisible' : ['|', ('cash_journal_id', '=', False), ('state', '!=', 'opening_control')]}" class="o_field_nocontent" groups="point_of_sale.group_pos_manager">
+ <p>
+ You can define another list of available currencies on the
+ <i>Cash Registers</i> tab of the <b><field name="cash_journal_id" class="oe_inline"/></b>
+ payment method.
</p>
</div>
</div>
diff --git a/addons/pos_restaurant/static/src/js/multiprint.js b/addons/pos_restaurant/static/src/js/multiprint.js
index 1a135bdfe03..738b13d9362 100644
--- a/addons/pos_restaurant/static/src/js/multiprint.js
+++ b/addons/pos_restaurant/static/src/js/multiprint.js
@@ -27,11 +27,14 @@ var Printer = core.Class.extend(mixins.PropertiesMixin,{
function send_printing_job(){
if(self.receipt_queue.length > 0){
var r = self.receipt_queue.shift();
- self.connection.rpc('/hw_proxy/print_xml_receipt',{receipt: r},{timeout: 5000})
+ var options = {shadow: true, timeout: 5000};
+ self.connection.rpc('/hw_proxy/print_xml_receipt', {receipt: r}, options)
.then(function(){
send_printing_job();
- },function(){
+ },function(error, event){
self.receipt_queue.unshift(r);
+ console.log('There was an error while trying to print the order:');
+ console.log(error);
});
}
}
@@ -55,7 +58,7 @@ models.load_models({
for(var i = 0; i < printers.length; i++){
if(active_printers[printers[i].id]){
- var url = printers[i].proxy_ip;
+ var url = printers[i].proxy_ip || '';
if(url.indexOf('//') < 0){
url = 'http://'+url;
}
diff --git a/addons/product/report/product_pricelist_templates.xml b/addons/product/report/product_pricelist_templates.xml
index 43a127cdc6a..25d5f040330 100644
--- a/addons/product/report/product_pricelist_templates.xml
+++ b/addons/product/report/product_pricelist_templates.xml
@@ -45,6 +45,11 @@
[<span t-esc="product.code"/>]
</t>
<span t-esc="product.name"/>
+ <span t-foreach="product.attribute_value_ids" t-as="attribute_value">
+ <span t-if="attribute_value_first">-</span>
+ <span t-if="not attribute_value_last" t-esc="attribute_value.name+','"/>
+ <span t-else="" t-esc="attribute_value.name"/>
+ </span>
</td>
<t t-foreach="data['quantities']" t-as="quantity">
<td><strong t-esc="categ_data['prices'][product.id][quantity]"
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index 259798735bf..c6c498927bd 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -109,6 +109,11 @@
<field name="packaging_ids" nolabel="1" context="{'default_product_id': product_variant_id,'tree_view_ref':'product.product_packaging_tree_view', 'form_view_ref':'product.product_packaging_form_view'}"/>
</group>
</page>
+ <group name="description_internal">
+ <group string="Description for Internal">
+ <field name="description" nolabel="1" placeholder="This note is only for internal purposes."/>
+ </group>
+ </group>
</notebook>
</sheet>
<div class="oe_chatter">
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index c469614e1f0..2de6c14f47d 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -935,12 +935,12 @@ class Task(models.Model):
@api.multi
def message_get_suggested_recipients(self):
recipients = super(Task, self).message_get_suggested_recipients()
- for task in self.filtered('partner_id'):
- reason = _('Customer Email') if task.partner_id.email else _('Customer')
+ for task in self:
if task.partner_id:
+ reason = _('Customer Email') if task.partner_id.email else _('Customer')
task._message_add_suggested_recipient(recipients, partner=task.partner_id, reason=reason)
elif task.email_from:
- task._message_add_suggested_recipient(recipients, partner=task.email_from, reason=reason)
+ task._message_add_suggested_recipient(recipients, email=task.email_from, reason=_('Customer Email'))
return recipients
@api.multi
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
index 0b795300c3e..c61b4b3a518 100644
--- a/addons/purchase/models/purchase.py
+++ b/addons/purchase/models/purchase.py
@@ -324,6 +324,8 @@ class PurchaseOrder(models.Model):
for inv in order.invoice_ids:
if inv and inv.state not in ('cancel', 'draft'):
raise UserError(_("Unable to cancel this purchase order. You must first cancel the related vendor bills."))
+ order.order_line.write({'move_dest_ids':[(5,0,0)]})
+
self.write({'state': 'cancel'})
@api.multi
diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py
index 46071a56941..03196f5d9f8 100644
--- a/addons/purchase_requisition/models/purchase_requisition.py
+++ b/addons/purchase_requisition/models/purchase_requisition.py
@@ -313,8 +313,12 @@ class PurchaseOrder(models.Model):
self.payment_term_id = payment_term.id,
self.company_id = requisition.company_id.id
self.currency_id = requisition.currency_id.id
- self.origin = requisition.name
- self.partner_ref = requisition.name # to control vendor bill based on agreement reference
+ if not self.origin or requisition.name not in self.origin.split(', '):
+ if self.origin:
+ if requisition.name:
+ self.origin = self.origin + ', ' + requisition.name
+ else:
+ self.origin = requisition.name
self.notes = requisition.description
self.date_order = requisition.date_end or fields.Datetime.now()
self.picking_type_id = requisition.picking_type_id.id
diff --git a/addons/purchase_stock/models/procurement.py b/addons/purchase_stock/models/procurement.py
index 3d5381770fe..e4912067704 100644
--- a/addons/purchase_stock/models/procurement.py
+++ b/addons/purchase_stock/models/procurement.py
@@ -36,7 +36,8 @@ class ProcurementRule(models.Model):
cache[domain] = po
if not po:
vals = self._prepare_purchase_order(product_id, product_qty, product_uom, origin, values, partner)
- po = self.env['purchase.order'].sudo().create(vals)
+ company_id = values.get('company_id') and values['company_id'].id or self.env.user.company_id.id
+ po = self.env['purchase.order'].with_context(force_company=company_id).sudo().create(vals)
cache[domain] = po
elif not po.origin or origin not in po.origin.split(', '):
if po.origin:
diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py
index 63d996ffb6e..8b353c129c0 100644
--- a/addons/resource/models/resource.py
+++ b/addons/resource/models/resource.py
@@ -41,6 +41,8 @@ def datetime_to_string(dt):
def float_to_time(hours):
""" Convert a number of hours into a time object. """
+ if float_hour == 24.0:
+ return time.max
fractional, integral = math.modf(hours)
return time(int(integral), int(60 * fractional), 0)
@@ -400,7 +402,9 @@ class ResourceCalendarAttendance(models.Model):
], 'Day of Week', required=True, index=True, default='0')
date_from = fields.Date(string='Starting Date')
date_to = fields.Date(string='End Date')
- hour_from = fields.Float(string='Work from', required=True, index=True, help="Start and End time of working.")
+ hour_from = fields.Float(string='Work from', required=True, index=True,
+ help="Start and End time of working.\n"
+ "A specific value of 24:00 is interpreted as 23:59:59.999999.")
hour_to = fields.Float(string='Work to', required=True)
calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade')
day_period = fields.Selection([('morning', 'Morning'), ('afternoon', 'Afternoon')], required=True, default='morning')
diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py
index 3b164d69d4f..4184ea890a1 100644
--- a/addons/resource/tests/test_resource.py
+++ b/addons/resource/tests/test_resource.py
@@ -184,6 +184,21 @@ class TestCalendar(TestResourceCommon):
leave.unlink()
# leave of very small size
+ def test_calendar_working_hours_24(self):
+ self.att_4 = self.env['resource.calendar.attendance'].create({
+ 'name': 'Att4',
+ 'calendar_id': self.calendar.id,
+ 'dayofweek': '2',
+ 'hour_from': 0,
+ 'hour_to': 24
+ })
+ res = self.calendar.get_work_hours_count(
+ Datetime.from_string('2018-06-19 23:00:00'),
+ Datetime.from_string('2018-06-21 01:00:00'),
+ self.resource1_id,
+ compute_leaves=True)
+ self.assertAlmostEqual(res, 24.0)
+
leave = self.env['resource.calendar.leaves'].create({
'name': 'zero_length',
'calendar_id': self.calendar_patel.id,
diff --git a/addons/sale/data/mail_data.xml b/addons/sale/data/mail_data.xml
index ce8dcc651a1..134ebd7b7b7 100644
--- a/addons/sale/data/mail_data.xml
+++ b/addons/sale/data/mail_data.xml
@@ -69,7 +69,7 @@
% set is_online = access_action and access_action['type'] == 'ir.actions.act_url'
% set doc_name = 'quotation' if object.state in ('draft', 'sent') else 'order confirmation'
% set pay_sign_name = object.get_portal_confirmation_action()
-% set access_name = is_online and object.state in ('draft', 'sent') and pay_sign_name in ('pay', 'sign') and 'Accept and %s online' % pay_sign_name or 'View %s' % doc_name
+% set access_name = is_online and object.state in ('draft', 'sent') and pay_sign_name == 'pay' and 'Accept and pay online' or pay_sign_name == 'sign' and 'Accept and sign online' or 'View %s' % doc_name
% set access_url = is_online and object.get_mail_url() or ''
% if object.partner_id.parent_id:
diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py
index c11f7a9de19..f9c2d87afa9 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': # 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 != 'no' and result.amount <= 0: # allow to force a False value for so_line
result.sudo()._sale_determine_order_line()
return result
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 1f0eeb79071..5b959f4abe9 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -1272,3 +1272,8 @@ class SaleOrderLine(models.Model):
discount = (new_list_price - price) / new_list_price * 100
if discount > 0:
self.discount = discount
+
+
+ def _is_delivery(self):
+ self.ensure_one()
+ return False \ No newline at end of file
diff --git a/addons/sale/views/res_config_settings_views.xml b/addons/sale/views/res_config_settings_views.xml
index b2e4615e890..03dcf39a4ea 100644
--- a/addons/sale/views/res_config_settings_views.xml
+++ b/addons/sale/views/res_config_settings_views.xml
@@ -362,7 +362,7 @@
<h2>Invoicing</h2>
<div class="row mt16 o_settings_container">
<div id="sales_settings_invoicing_policy"
- class="col-xs-12 col-md-6 o_setting_box"
+ class="col-xs-12 col-md-6 o_setting_box hidden"
title="This default value is applied to any new product created. This can be changed in the product detail form.">
<div class="o_setting_right_pane">
<label for="default_invoice_policy"/>
diff --git a/addons/sale_management/static/src/js/tour.js b/addons/sale_management/static/src/js/tour.js
index 89c792e104a..b379a0526d3 100644
--- a/addons/sale_management/static/src/js/tour.js
+++ b/addons/sale_management/static/src/js/tour.js
@@ -48,6 +48,8 @@ tour.register('sale_tour', {
in_modal: false,
run: function (actions) {
actions.auto();
+ // There might be a modal because of the view:
+ // sale.order.form.editable.list, enabled by some groups
if ($(".modal-footer .btn-primary").length) {
actions.auto(".modal-footer .btn-primary");
}
diff --git a/addons/sale_mrp/models/sale_mrp.py b/addons/sale_mrp/models/sale_mrp.py
index 7e5b3cb6291..0561aa528df 100644
--- a/addons/sale_mrp/models/sale_mrp.py
+++ b/addons/sale_mrp/models/sale_mrp.py
@@ -63,8 +63,7 @@ class AccountInvoiceLine(models.Model):
qty_done = sum([x.uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in s_line.invoice_lines if x.invoice_id.state in ('open', 'paid')])
quantity = self.uom_id._compute_quantity(self.quantity, self.product_id.uom_id)
# Put moves in fixed order by date executed
- moves = s_line.move_ids
- moves.sorted(lambda x: x.date)
+ moves = s_line.move_ids.sorted(lambda x: x.date)
# Go through all the moves and do nothing until you get to qty_done
# Beyond qty_done we need to calculate the average of the price_unit
# on the moves we encounter.
diff --git a/addons/sale_mrp/tests/test_sale_mrp_flow.py b/addons/sale_mrp/tests/test_sale_mrp_flow.py
index 14a94657f83..3db65ff13c5 100644
--- a/addons/sale_mrp/tests/test_sale_mrp_flow.py
+++ b/addons/sale_mrp/tests/test_sale_mrp_flow.py
@@ -40,10 +40,6 @@ class TestSaleMrpFlow(common.TransactionCase):
self.uom_kg = self.env['uom.uom'].search([('category_id', '=', self.categ_kgm.id), ('uom_type', '=', 'reference')], limit=1)
self.uom_kg.write({
'name': 'Test-KG',
- 'category_id': self.categ_kgm.id,
- 'factor_inv': 1,
- 'factor': 1,
- 'uom_type': 'reference',
'rounding': 0.000001})
self.uom_gm = self.UoM.create({
'name': 'Test-G',
@@ -54,9 +50,6 @@ class TestSaleMrpFlow(common.TransactionCase):
self.uom_unit = self.env['uom.uom'].search([('category_id', '=', self.categ_unit.id), ('uom_type', '=', 'reference')], limit=1)
self.uom_unit.write({
'name': 'Test-Unit',
- 'category_id': self.categ_unit.id,
- 'factor': 1,
- 'uom_type': 'reference',
'rounding': 1.0})
self.uom_dozen = self.UoM.create({
'name': 'Test-DozenA',
diff --git a/addons/sale_order_dates/models/sale_order.py b/addons/sale_order_dates/models/sale_order.py
index 998d3465c8e..f02552adfdf 100644
--- a/addons/sale_order_dates/models/sale_order.py
+++ b/addons/sale_order_dates/models/sale_order.py
@@ -28,7 +28,7 @@ class SaleOrder(models.Model):
for order in self:
dates_list = []
confirm_date = fields.Datetime.from_string(order.confirmation_date if order.state == 'sale' else fields.Datetime.now())
- for line in order.order_line.filtered(lambda x: x.state != 'cancel'):
+ for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery()):
dt = confirm_date + timedelta(days=line.customer_lead or 0.0)
dates_list.append(dt)
if dates_list:
diff --git a/addons/sale_payment/controllers/payment.py b/addons/sale_payment/controllers/payment.py
index 3ebd7373ec7..13d92577ead 100644
--- a/addons/sale_payment/controllers/payment.py
+++ b/addons/sale_payment/controllers/payment.py
@@ -17,7 +17,6 @@ class PaymentPortal(http.Controller):
:return html: form containing all values related to the acquirer to
redirect customers to the acquirer website """
success_url = kwargs.get('success_url', '/my')
- callback_method = kwargs.get('callback_method', '')
order_sudo = request.env['sale.order'].sudo().browse(order_id)
if not order_sudo:
@@ -28,12 +27,12 @@ class PaymentPortal(http.Controller):
except:
return False
+ if request.env.user == request.env.ref('base.public_user'):
+ save_token = False
+
# Create transaction
vals = {
'acquirer_id': acquirer_id,
- 'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', order_sudo._name)], limit=1).id,
- 'callback_res_id': order_sudo.id,
- 'callback_method': callback_method,
}
if save_token:
@@ -56,7 +55,6 @@ class PaymentPortal(http.Controller):
""" Use a token to perform a s2s transaction """
error_url = kwargs.get('error_url', '/my')
success_url = kwargs.get('success_url', '/my')
- callback_method = kwargs.get('callback_method', '')
access_token = kwargs.get('access_token')
params = {}
if access_token:
@@ -71,23 +69,16 @@ class PaymentPortal(http.Controller):
token = request.env['payment.token'].sudo().browse(int(pm_id))
except (ValueError, TypeError):
token = False
- if not token:
- params['error'] = 'pay_sale_invalid_token'
- return request.redirect(_build_url_w_params(error_url, params))
- try:
- pm_id = int(pm_id)
- except (ValueError, TypeError):
+ token_owner = order_sudo.partner_id if request.env.user == request.env.ref('base.public_user') else request.env.user.partner_id
+ if not token or token.partner_id != token_owner:
params['error'] = 'pay_sale_invalid_token'
return request.redirect(_build_url_w_params(error_url, params))
- # Create transaction
+ # Create transaction
vals = {
- 'payment_token_id': pm_id,
+ 'payment_token_id': token.id,
'type': 'server2server',
- 'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', order_sudo._name)], limit=1).id,
- 'callback_res_id': order_sudo.id,
- 'callback_method': callback_method,
}
order_sudo._create_payment_transaction(vals)
diff --git a/addons/sale_payment/controllers/portal.py b/addons/sale_payment/controllers/portal.py
index 22bdaf9c880..9b554c19c34 100644
--- a/addons/sale_payment/controllers/portal.py
+++ b/addons/sale_payment/controllers/portal.py
@@ -10,5 +10,16 @@ class CustomerPortal(CustomerPortal):
def _order_get_page_view_values(self, order, access_token, **kwargs):
values = super(CustomerPortal, self)._order_get_page_view_values(order, access_token, **kwargs)
if values['portal_confirmation'] == 'pay':
- values.update(request.env['payment.acquirer']._get_available_payment_input(order.partner_id, order.company_id))
+ payment_inputs = request.env['payment.acquirer']._get_available_payment_input(order.partner_id, order.company_id)
+ # if not connected (using public user), the method _get_available_payment_input will return public user tokens
+ is_public_user = request.env.ref('base.public_user') == request.env.user
+ if is_public_user:
+ # we should not display payment tokens owned by the public user
+ payment_inputs.pop('pms', None)
+ token_count = request.env['payment.token'].sudo().search_count([('acquirer_id.company_id', '=', order.company_id.id),
+ ('partner_id', '=', order.partner_id.id),
+ ])
+ values['existing_token'] = token_count > 0
+ values.update(payment_inputs)
+ values['partner_id'] = order.partner_id if is_public_user else request.env.user.partner_id
return values
diff --git a/addons/sale_payment/views/sale_portal_templates.xml b/addons/sale_payment/views/sale_portal_templates.xml
index 11e07dae31d..f23c5e272ad 100644
--- a/addons/sale_payment/views/sale_portal_templates.xml
+++ b/addons/sale_payment/views/sale_portal_templates.xml
@@ -38,6 +38,11 @@
</t>
</div>
</div>
+ <div class="panel-body" t-if="existing_token">
+ <div class="col-md-offset-3 col-md-6">
+ <i class="fa fa-info"></i> You have credits card registered, you can log-in to be able to use them.
+ </div>
+ </div>
</div>
</xpath>
<xpath expr="//t[@t-if='invoices']" position="before">
diff --git a/addons/sale_stock/models/account_invoice.py b/addons/sale_stock/models/account_invoice.py
index 9d754195c23..6d7c99ff062 100644
--- a/addons/sale_stock/models/account_invoice.py
+++ b/addons/sale_stock/models/account_invoice.py
@@ -35,9 +35,7 @@ class AccountInvoiceLine(models.Model):
qty_done = sum([x.uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in s_line.invoice_lines if x.invoice_id.state in ('open', 'paid')])
quantity = self.uom_id._compute_quantity(self.quantity, self.product_id.uom_id)
# Put moves in fixed order by date executed
- moves = self.env['stock.move']
- moves |= s_line.move_ids
- moves.sorted(lambda x: x.date)
+ moves = s_line.move_ids.sorted(lambda x: x.date)
# Go through all the moves and do nothing until you get to qty_done
# Beyond qty_done we need to calculate the average of the price_unit
# on the moves we encounter.
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index 34410399f3c..6f81e8a7658 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -95,8 +95,9 @@ class ProjectTask(models.Model):
@api.model
def create(self, values):
# sub task has the same so line than their parent
- if 'parent_id' in values and values['parent_id']:
- values['sale_line_id'] = self.env['project.task'].browse(values['parent_id']).sudo().sale_line_id.id
+ parent_id = values['parent_id'] if 'parent_id' in values else self.env.context.get('default_parent_id')
+ if parent_id:
+ values['sale_line_id'] = self.env['project.task'].browse(parent_id).sudo().sale_line_id.id
return super(ProjectTask, self).create(values)
@api.multi
diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py
index ae8cc4d98fd..741923862bc 100644
--- a/addons/stock/models/product.py
+++ b/addons/stock/models/product.py
@@ -582,3 +582,27 @@ class ProductCategory(models.Model):
category = category.parent_id
routes |= category.route_ids
self.total_route_ids = routes
+
+
+class UoM(models.Model):
+ _inherit = 'uom.uom'
+
+ def write(self, values):
+ # Users can not update the factor if open stock moves are based on it
+ if 'factor' in values or 'factor_inv' in values or 'category_id' in values:
+ changed = self.filtered(
+ lambda u: any(u[f] != values[f] if f in values else False
+ for f in {'factor', 'factor_inv', 'category_id'}))
+ if changed:
+ stock_move_lines = self.env['stock.move.line'].search_count([
+ ('product_uom_id.category_id', 'in', changed.mapped('category_id.id')),
+ ('state', '!=', 'cancel'),
+ ])
+
+ if stock_move_lines:
+ raise UserError(_(
+ "You cannot change the ratio of this unit of mesure as some"
+ " products with this UoM have already been moved or are "
+ "currently reserved."
+ ))
+ return super(UoM, self).write(values)
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
index 9d8a4aeacd6..db3e1281830 100644
--- a/addons/stock/models/stock_location.py
+++ b/addons/stock/models/stock_location.py
@@ -81,6 +81,21 @@ class Location(models.Model):
if 'usage' in values and values['usage'] == 'view':
if self.mapped('quant_ids'):
raise UserError(_("This location's usage cannot be changed to view as it contains products."))
+ if 'usage' in values or 'scrap_location' in values:
+
+ modified_locations = self.filtered(
+ lambda l: any(l[f] != values[f] if f in values else False
+ for f in {'usage', 'scrap_location'}))
+ reserved_quantities = self.env['stock.move.line'].search_count([
+ ('location_id', 'in', modified_locations.ids),
+ ('product_qty', '>', 0),
+ ])
+ if reserved_quantities:
+ raise UserError(_(
+ "You cannot change the location type or its use as a scrap"
+ " location as there are products reserved in this location."
+ " Please unreserve the products first."
+ ))
return super(Location, self).write(values)
def name_get(self):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 0dccd0a29a6..bbf3196d4f2 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -796,7 +796,7 @@ class StockMove(models.Model):
group_id = False
return {
'company_id': self.company_id,
- 'date_planned': self.date,
+ 'date_planned': self.date_expected,
'move_dest_ids': self,
'group_id': group_id,
'route_ids': self.route_ids,
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index e8a80121153..95c665d20ff 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -627,6 +627,7 @@ class Picking(models.Model):
all_in = True
pack_move_lines = self.move_line_ids.filtered(lambda ml: ml.package_id == package)
keys = ['product_id', 'lot_id']
+ precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
grouped_quants = {}
for k, g in groupby(sorted(package.quant_ids, key=itemgetter(*keys)), key=itemgetter(*keys)):
@@ -635,8 +636,8 @@ class Picking(models.Model):
grouped_ops = {}
for k, g in groupby(sorted(pack_move_lines, key=itemgetter(*keys)), key=itemgetter(*keys)):
grouped_ops[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
- if any(grouped_quants.get(key, 0) - grouped_ops.get(key, 0) != 0 for key in grouped_quants) \
- or any(grouped_ops.get(key, 0) - grouped_quants.get(key, 0) != 0 for key in grouped_ops):
+ if any(not float_is_zero(grouped_quants.get(key, 0) - grouped_ops.get(key, 0), precision_digits=precision_digits) for key in grouped_quants) \
+ or any(not float_is_zero(grouped_ops.get(key, 0) - grouped_quants.get(key, 0), precision_digits=precision_digits) for key in grouped_ops):
all_in = False
return all_in
diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py
index 8541ae2f9da..4f42602c023 100644
--- a/addons/stock/models/stock_quant.py
+++ b/addons/stock/models/stock_quant.py
@@ -380,6 +380,12 @@ class QuantPackage(models.Model):
def unpack(self):
for package in self:
+ move_line_to_modify = self.env['stock.move.line'].search([
+ ('package_id', '=', package.id),
+ ('state', 'in', ('assigned', 'partially_available')),
+ ('product_qty', '!=', 0),
+ ])
+ move_line_to_modify.write({'package_id': False})
package.mapped('quant_ids').write({'package_id': False})
def action_view_picking(self):
diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py
index c565d73a7a6..b49c1df1218 100644
--- a/addons/stock/models/stock_warehouse.py
+++ b/addons/stock/models/stock_warehouse.py
@@ -397,7 +397,7 @@ class Warehouse(models.Model):
pull_rules_list = supplier_wh._get_supply_pull_rules_values(
[self.Routing(output_location, transit_location, supplier_wh.out_type_id)],
- values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': self.id})
+ values={'route_id': inter_wh_route.id})
pull_rules_list += self._get_supply_pull_rules_values(
[self.Routing(transit_location, input_location, self.in_type_id)],
values={'route_id': inter_wh_route.id, 'propagate_warehouse_id': supplier_wh.id})
diff --git a/addons/stock/tests/__init__.py b/addons/stock/tests/__init__.py
index 52f5e2c53b0..25ae6888c7c 100644
--- a/addons/stock/tests/__init__.py
+++ b/addons/stock/tests/__init__.py
@@ -9,6 +9,7 @@ from . import test_quant
from . import test_inventory
from . import test_move
from . import test_move2
+from . import test_robustness
from . import test_packing
from . import test_packing_neg
from . import test_proc_rule
diff --git a/addons/stock/tests/common.py b/addons/stock/tests/common.py
index 8c26b60e6f3..f0a1bd2d795 100644
--- a/addons/stock/tests/common.py
+++ b/addons/stock/tests/common.py
@@ -48,10 +48,6 @@ class TestStockCommon(common.TransactionCase):
self.uom_kg = self.env['uom.uom'].search([('category_id', '=', self.categ_kgm), ('uom_type', '=', 'reference')], limit=1)
self.uom_kg.write({
'name': 'Test-KG',
- 'category_id': self.categ_kgm,
- 'factor_inv': 1,
- 'factor': 1,
- 'uom_type': 'reference',
'rounding': 0.000001})
self.uom_tone = self.UomObj.create({
'name': 'Test-Tone',
@@ -75,9 +71,6 @@ class TestStockCommon(common.TransactionCase):
self.uom_unit = self.env['uom.uom'].search([('category_id', '=', self.categ_unit), ('uom_type', '=', 'reference')], limit=1)
self.uom_unit.write({
'name': 'Test-Unit',
- 'category_id': self.categ_unit,
- 'factor': 1,
- 'uom_type': 'reference',
'rounding': 1.0})
self.uom_dozen = self.UomObj.create({
'name': 'Test-DozenA',
diff --git a/addons/stock/tests/test_robustness.py b/addons/stock/tests/test_robustness.py
new file mode 100644
index 00000000000..5926a6e2487
--- /dev/null
+++ b/addons/stock/tests/test_robustness.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.exceptions import UserError
+from odoo.tests.common import TransactionCase
+
+
+class TestRobustness(TransactionCase):
+ def setUp(self):
+ super(TestRobustness, self).setUp()
+ self.stock_location = self.env.ref('stock.stock_location_stock')
+ self.customer_location = self.env.ref('stock.stock_location_customers')
+ self.uom_unit = self.env.ref('uom.product_uom_unit')
+ self.uom_dozen = self.env.ref('uom.product_uom_dozen')
+ self.product1 = self.env['product.product'].create({
+ 'name': 'Product A',
+ 'type': 'product',
+ 'categ_id': self.env.ref('product.product_category_all').id,
+ })
+
+ def test_uom_factor(self):
+ """ Changing the factor of a unit of measure shouldn't be allowed while
+ quantities are reserved, else the existing move lines won't be consistent
+ with the `reserved_quantity` on quants.
+ """
+ # make some stock
+ self.env['stock.quant']._update_available_quantity(
+ self.product1,
+ self.stock_location,
+ 12,
+ )
+
+ # reserve a dozen
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_uom_rounding',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_dozen.id,
+ 'product_uom_qty': 1,
+ })
+ move1._action_confirm()
+ move1._action_assign()
+ self.assertEqual(move1.state, 'assigned')
+ quant = self.env['stock.quant']._gather(
+ self.product1,
+ self.stock_location,
+ )
+
+ # assert the reservation
+ self.assertEqual(quant.reserved_quantity, 12)
+ self.assertEqual(move1.product_qty, 12)
+
+ # change the factor
+ with self.assertRaises(UserError):
+ with self.cr.savepoint():
+ move1.product_uom.factor = 0.05
+
+ # assert the reservation
+ self.assertEqual(quant.reserved_quantity, 12)
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(move1.product_qty, 12)
+
+ # unreserve
+ move1._do_unreserve()
+
+ def test_location_usage(self):
+ """ Changing the usage of a location shouldn't be allowed while
+ quantities are reserved, else the existing move lines won't be
+ consistent with the `reserved_quantity` on the quants.
+ """
+ # change stock usage
+ self.stock_location.scrap_location = True
+
+ # make some stock
+ self.env['stock.quant']._update_available_quantity(
+ self.product1,
+ self.stock_location,
+ 1,
+ )
+
+ # reserve a unit
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_location_archive',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1,
+ })
+ move1._action_confirm()
+ move1._action_assign()
+ self.assertEqual(move1.state, 'assigned')
+ quant = self.env['stock.quant']._gather(
+ self.product1,
+ self.stock_location,
+ )
+
+ # assert the reservation
+ self.assertEqual(quant.reserved_quantity, 0) # reservation is bypassed in scrap location
+ self.assertEqual(move1.product_qty, 1)
+
+ # change the stock usage
+ with self.assertRaises(UserError):
+ with self.cr.savepoint():
+ self.stock_location.scrap_location = False
+
+ # unreserve
+ move1._do_unreserve()
+
+ def test_package_unpack(self):
+ """ Unpack a package that contains quants with a reservation
+ should also remove the package on the reserved move lines.
+ """
+ package = self.env['stock.quant.package'].create({
+ 'name': 'Shell Helix HX7 10W30',
+ })
+
+ self.env['stock.quant']._update_available_quantity(
+ self.product1,
+ self.stock_location,
+ 10,
+ package_id=package
+ )
+
+ # reserve a dozen
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_uom_rounding',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 10,
+ })
+ move1._action_confirm()
+ move1._action_assign()
+
+ move1.result_package_id = False
+
+ package.unpack()
+
+ # unreserve
+ move1._do_unreserve()
+ self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1)
+ self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location, package_id=package)), 0)
+
+ self.assertEqual(self.env['stock.quant']._gather(self.product1, self.stock_location).reserved_quantity, 0)
diff --git a/addons/stock/tests/test_warehouse.py b/addons/stock/tests/test_warehouse.py
index 0704dc3dd3a..d32943d2312 100644
--- a/addons/stock/tests/test_warehouse.py
+++ b/addons/stock/tests/test_warehouse.py
@@ -226,6 +226,215 @@ class TestWarehouse(TestStockCommon):
quant = self.env['stock.quant'].search([('product_id', '=', productA.id), ('location_id', '=', location_loss.id)])
self.assertEqual(len(quant), 1)
+ def test_resupply_route(self):
+ """ Simulate a resupply chain between warehouses.
+ Stock -> transit -> Dist. -> transit -> Shop -> Customer
+ Create the move from Shop to Customer and ensure that all the pull
+ rules are triggered in order to complete the move chain to Stock.
+ """
+ warehouse_stock = self.env['stock.warehouse'].create({
+ 'name': 'Stock.',
+ 'code': 'STK',
+ })
+
+ warehouse_distribution = self.env['stock.warehouse'].create({
+ 'name': 'Dist.',
+ 'code': 'DIST',
+ 'default_resupply_wh_id': warehouse_stock.id,
+ 'resupply_wh_ids': [(6, 0, [warehouse_stock.id])]
+ })
+
+ warehouse_shop = self.env['stock.warehouse'].create({
+ 'name': 'Shop',
+ 'code': 'SHOP',
+ 'default_resupply_wh_id': warehouse_distribution.id,
+ 'resupply_wh_ids': [(6, 0, [warehouse_distribution.id])]
+ })
+
+ route_stock_to_dist = warehouse_distribution.resupply_route_ids
+ route_dist_to_shop = warehouse_shop.resupply_route_ids
+
+ # Change the procure_method on the pull rules between dist and shop
+ # warehouses. Since mto and resupply routes are both on product it will
+ # select one randomly between them and if it select the resupply it is
+ # 'make to stock' and it will not create the picking between stock and
+ # dist warehouses.
+ route_dist_to_shop.pull_ids.write({'procure_method': 'make_to_order'})
+
+ product = self.env['product.product'].create({
+ 'name': 'Fakir',
+ 'type': 'product',
+ 'route_ids': [(4, route_id) for route_id in [route_stock_to_dist.id, route_dist_to_shop.id, self.env.ref('stock.route_warehouse0_mto').id]],
+ })
+
+ picking_out = self.env['stock.picking'].create({
+ 'partner_id': self.env.ref('base.res_partner_2').id,
+ 'picking_type_id': self.env.ref('stock.picking_type_out').id,
+ 'location_id': warehouse_shop.lot_stock_id.id,
+ 'location_dest_id': self.env.ref('stock.stock_location_customers').id,
+ })
+ self.env['stock.move'].create({
+ 'name': product.name,
+ 'product_id': product.id,
+ 'product_uom_qty': 1,
+ 'product_uom': product.uom_id.id,
+ 'picking_id': picking_out.id,
+ 'location_id': warehouse_shop.lot_stock_id.id,
+ 'location_dest_id': self.env.ref('stock.stock_location_customers').id,
+ 'warehouse_id': warehouse_shop.id,
+ 'procure_method': 'make_to_order',
+ })
+ picking_out.action_confirm()
+
+ moves = self.env['stock.move'].search([('product_id', '=', product.id)])
+ # Shop/Stock -> Customer
+ # Transit -> Shop/Stock
+ # Dist/Stock -> Transit
+ # Transit -> Dist/Stock
+ # Stock/Stock -> Transit
+ self.assertEqual(len(moves), 5, 'Invalid moves number.')
+ self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_stock.lot_stock_id.id)]))
+ self.assertTrue(self.env['stock.move'].search([('location_dest_id', '=', warehouse_distribution.lot_stock_id.id)]))
+ self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_distribution.lot_stock_id.id)]))
+ self.assertTrue(self.env['stock.move'].search([('location_dest_id', '=', warehouse_shop.lot_stock_id.id)]))
+ self.assertTrue(self.env['stock.move'].search([('location_id', '=', warehouse_shop.lot_stock_id.id)]))
+
+ def test_mutiple_resupply_warehouse(self):
+ """ Simulate the following situation:
+ - 2 shops with stock are resupply by 2 distinct warehouses
+ - Shop Namur is resupply by the warehouse stock Namur
+ - Shop Wavre is resupply by the warehouse stock Wavre
+ - Simulate 2 moves for the same product but in different shop.
+ This test ensure that the move are supplied by the correct distribution
+ warehouse.
+ """
+ customer_location = self.env.ref('stock.stock_location_customers')
+
+ warehouse_distribution_wavre = self.env['stock.warehouse'].create({
+ 'name': 'Stock Wavre.',
+ 'code': 'WV',
+ })
+
+ warehouse_shop_wavre = self.env['stock.warehouse'].create({
+ 'name': 'Shop Wavre',
+ 'code': 'SHWV',
+ 'default_resupply_wh_id': warehouse_distribution_wavre.id,
+ 'resupply_wh_ids': [(6, 0, [warehouse_distribution_wavre.id])]
+ })
+
+ warehouse_distribution_namur = self.env['stock.warehouse'].create({
+ 'name': 'Stock Namur.',
+ 'code': 'NM',
+ })
+
+ warehouse_shop_namur = self.env['stock.warehouse'].create({
+ 'name': 'Shop Namur',
+ 'code': 'SHNM',
+ 'default_resupply_wh_id': warehouse_distribution_namur.id,
+ 'resupply_wh_ids': [(6, 0, [warehouse_distribution_namur.id])]
+ })
+
+ route_shop_namur = warehouse_shop_namur.resupply_route_ids
+ route_shop_wavre = warehouse_shop_wavre.resupply_route_ids
+
+ # The product contains the 2 resupply routes.
+ product = self.env['product.product'].create({
+ 'name': 'Fakir',
+ 'type': 'product',
+ 'route_ids': [(4, route_id) for route_id in [route_shop_namur.id, route_shop_wavre.id, self.env.ref('stock.route_warehouse0_mto').id]],
+ })
+
+ # Add 1 quant in each distribution warehouse.
+ self.env['stock.quant']._update_available_quantity(product, warehouse_distribution_wavre.lot_stock_id, 1.0)
+ self.env['stock.quant']._update_available_quantity(product, warehouse_distribution_namur.lot_stock_id, 1.0)
+
+ # Create the move for the shop Namur. Should create a resupply from
+ # distribution warehouse Namur.
+ picking_out_namur = self.env['stock.picking'].create({
+ 'partner_id': self.env.ref('base.res_partner_2').id,
+ 'picking_type_id': self.env.ref('stock.picking_type_out').id,
+ 'location_id': warehouse_shop_namur.lot_stock_id.id,
+ 'location_dest_id': customer_location.id,
+ })
+ self.env['stock.move'].create({
+ 'name': product.name,
+ 'product_id': product.id,
+ 'product_uom_qty': 1,
+ 'product_uom': product.uom_id.id,
+ 'picking_id': picking_out_namur.id,
+ 'location_id': warehouse_shop_namur.lot_stock_id.id,
+ 'location_dest_id': customer_location.id,
+ 'warehouse_id': warehouse_shop_namur.id,
+ 'procure_method': 'make_to_order',
+ })
+ picking_out_namur.action_confirm()
+
+ # Validate the picking
+ # Dist. warehouse Namur -> transit Location -> Shop Namur
+ picking_stock_transit = self.env['stock.picking'].search([('location_id', '=', warehouse_distribution_namur.lot_stock_id.id)])
+ self.assertTrue(picking_stock_transit)
+ picking_stock_transit.action_assign()
+ picking_stock_transit.move_lines[0].quantity_done = 1.0
+ picking_stock_transit.action_done()
+
+ picking_transit_shop_namur = self.env['stock.picking'].search([('location_dest_id', '=', warehouse_shop_namur.lot_stock_id.id)])
+ self.assertTrue(picking_transit_shop_namur)
+ picking_transit_shop_namur.action_assign()
+ picking_transit_shop_namur.move_lines[0].quantity_done = 1.0
+ picking_transit_shop_namur.action_done()
+
+ picking_out_namur.action_assign()
+ picking_out_namur.move_lines[0].quantity_done = 1.0
+ picking_out_namur.action_done()
+
+ # Check that the correct quantity has been provided to customer
+ self.assertEqual(self.env['stock.quant']._gather(product, customer_location).quantity, 1)
+ # Ensure there still no quants in distribution warehouse
+ self.assertEqual(len(self.env['stock.quant']._gather(product, warehouse_distribution_namur.lot_stock_id)), 0)
+
+ # Create the move for the shop Wavre. Should create a resupply from
+ # distribution warehouse Wavre.
+ picking_out_wavre = self.env['stock.picking'].create({
+ 'partner_id': self.env.ref('base.res_partner_2').id,
+ 'picking_type_id': self.env.ref('stock.picking_type_out').id,
+ 'location_id': warehouse_shop_wavre.lot_stock_id.id,
+ 'location_dest_id': customer_location.id,
+ })
+ self.env['stock.move'].create({
+ 'name': product.name,
+ 'product_id': product.id,
+ 'product_uom_qty': 1,
+ 'product_uom': product.uom_id.id,
+ 'picking_id': picking_out_wavre.id,
+ 'location_id': warehouse_shop_wavre.lot_stock_id.id,
+ 'location_dest_id': customer_location.id,
+ 'warehouse_id': warehouse_shop_wavre.id,
+ 'procure_method': 'make_to_order',
+ })
+ picking_out_wavre.action_confirm()
+
+ # Validate the picking
+ # Dist. warehouse Wavre -> transit Location -> Shop Wavre
+ picking_stock_transit = self.env['stock.picking'].search([('location_id', '=', warehouse_distribution_wavre.lot_stock_id.id)])
+ self.assertTrue(picking_stock_transit)
+ picking_stock_transit.action_assign()
+ picking_stock_transit.move_lines[0].quantity_done = 1.0
+ picking_stock_transit.action_done()
+
+ picking_transit_shop_wavre = self.env['stock.picking'].search([('location_dest_id', '=', warehouse_shop_wavre.lot_stock_id.id)])
+ self.assertTrue(picking_transit_shop_wavre)
+ picking_transit_shop_wavre.action_assign()
+ picking_transit_shop_wavre.move_lines[0].quantity_done = 1.0
+ picking_transit_shop_wavre.action_done()
+
+ picking_out_wavre.action_assign()
+ picking_out_wavre.move_lines[0].quantity_done = 1.0
+ picking_out_wavre.action_done()
+
+ # Check that the correct quantity has been provided to customer
+ self.assertEqual(self.env['stock.quant']._gather(product, customer_location).quantity, 2)
+ # Ensure there still no quants in distribution warehouse
+ self.assertEqual(len(self.env['stock.quant']._gather(product, warehouse_distribution_wavre.lot_stock_id)), 0)
class TestResupply(TestStockCommon):
def setUp(self):
diff --git a/addons/stock/views/stock_location_views.xml b/addons/stock/views/stock_location_views.xml
index 810fc4ba078..2d35bc13d65 100644
--- a/addons/stock/views/stock_location_views.xml
+++ b/addons/stock/views/stock_location_views.xml
@@ -283,6 +283,7 @@
<field name="model">stock.location.route</field>
<field name="arch" type="xml">
<search string="Route">
+ <field name="name"/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
</search>
</field>
diff --git a/addons/stock_account/models/account_chart_template.py b/addons/stock_account/models/account_chart_template.py
index fdf75e5978e..8561253b7fe 100644
--- a/addons/stock_account/models/account_chart_template.py
+++ b/addons/stock_account/models/account_chart_template.py
@@ -52,12 +52,12 @@ class AccountChartTemplate(models.Model):
'fields_id': field.id,
'value': value,
}
- properties = PropertyObj.search([('name', '=', record), ('company_id', '=', company.id)])
- if properties:
- # the property exist: modify it
- properties.write(vals)
- else:
+ properties = PropertyObj.search([('name', '=', record), ('company_id', '=', company.id)], limit=1)
+ if not properties:
# create the property
PropertyObj.create(vals)
+ elif not properties.value_reference:
+ # update the property if False
+ properties.write(vals)
return res
diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py
index 14807df4fc2..564160f85cb 100644
--- a/addons/stock_account/models/product.py
+++ b/addons/stock_account/models/product.py
@@ -201,45 +201,39 @@ class ProductProduct(models.Model):
fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4]))
for product in self:
- if to_date:
- price_used = product.get_history_price(
- self.env.user.company_id.id,
- date=to_date,
- ) if product.cost_method in ['standard', 'average'] else 0.0
- 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.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
- if product.cost_method == 'fifo':
+ if product.cost_method in ['standard', 'average']:
+ qty_available = product.with_context(company_owned=True, owner_id=False).qty_available
+ price_used = product.standard_price
+ if to_date:
+ price_used = product.get_history_price(
+ self.env.user.company_id.id,
+ date=to_date,
+ )
+ product.stock_value = price_used * qty_available
+ product.qty_at_date = qty_available
+ 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.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
product.stock_fifo_manual_move_ids = StockMove.browse(moves.ids)
- elif product.cost_method in ['standard', 'average']:
- product.stock_value = product.qty_at_date * price_used
- 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, [])
- product.qty_at_date = quantity
- if product.cost_method == 'fifo':
+ 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, [])
product.stock_value = value
+ product.qty_at_date = quantity
product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids)
- elif product.cost_method in ['standard', 'average']:
- product.stock_value = quantity * price_used
- else:
- if product.product_tmpl_id.valuation == 'manual_periodic':
+ else:
+ product.stock_value, moves = product._sum_remaining_values()
product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
- if product.cost_method == 'fifo':
- product.stock_value, product.stock_fifo_manual_move_ids = product._sum_remaining_values()
- elif product.cost_method in ['standard', 'average']:
- product.stock_value = product.qty_at_date * product.standard_price
- 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, [])
- product.qty_at_date = quantity
- if product.cost_method == 'fifo':
- product.stock_value = value
+ if product.product_tmpl_id.valuation == 'manual_periodic':
+ product.stock_fifo_manual_move_ids = moves
+ 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, [])
product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids)
- elif product.cost_method in ['standard', 'average']:
- product.stock_value = quantity * product.standard_price
def action_valuation_at_date_details(self):
""" Returns an action with either a list view of all the valued stock moves of `self` if the
diff --git a/addons/stock_dropshipping/tests/test_stockvaluation.py b/addons/stock_dropshipping/tests/test_stockvaluation.py
index 64bcbd0d77f..8f2e4acf2e7 100644
--- a/addons/stock_dropshipping/tests/test_stockvaluation.py
+++ b/addons/stock_dropshipping/tests/test_stockvaluation.py
@@ -12,13 +12,14 @@ class TestStockValuation(AccountingTestCase):
self.stock_location = self.env.ref('stock.stock_location_stock')
self.partner_id = self.env.ref('base.res_partner_1')
self.product1 = self.env.ref('product.product_product_8')
-
- self.acc_payable = self.env['account.account'].search([('name', '=', 'Account Payable')]).id
- self.acc_expense = self.env['account.account'].search([('name', '=', 'Expenses')]).id
- self.acc_receivable = self.env['account.account'].search([('name', '=', 'Account Receivable')]).id
- self.acc_sale = self.env['account.account'].search([('name', '=', 'Product Sales')]).id
- self.acc_stock_in = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Received)')]).id
- self.acc_stock_out = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Delivered)')]).id
+ self.categ_id = self.product1.categ_id
+
+ self.acc_payable = self.partner_id.property_account_payable_id.id
+ self.acc_expense = self.categ_id.property_account_expense_categ_id.id
+ self.acc_receivable = self.partner_id.property_account_receivable_id.id
+ self.acc_sale = self.categ_id.property_account_income_categ_id.id
+ self.acc_stock_in = self.categ_id.property_stock_account_input_categ_id.id
+ self.acc_stock_out = self.categ_id.property_stock_account_output_categ_id.id
def _dropship_product1(self):
# enable the dropship and MTO route on the product
diff --git a/addons/stock_landed_costs/tests/test_stock_landed_costs_rounding.py b/addons/stock_landed_costs/tests/test_stock_landed_costs_rounding.py
index f79d285bd23..2d7b820d512 100644
--- a/addons/stock_landed_costs/tests/test_stock_landed_costs_rounding.py
+++ b/addons/stock_landed_costs/tests/test_stock_landed_costs_rounding.py
@@ -17,9 +17,7 @@ class TestStockLandedCostsRounding(TestStockLandedCostsCommon):
# Define undivisible units
product_uom_unit_round_1 = self.env.ref('uom.product_uom_unit')
product_uom_unit_round_1.write({
- 'category_id': self.ref('uom.product_uom_categ_unit'),
'name': 'Undivisible Unit(s)',
- 'factor': 1.0,
'rounding': 1.0,
})
diff --git a/addons/web/models/ir_qweb.py b/addons/web/models/ir_qweb.py
index a7376e04b5d..29426b9d8a8 100644
--- a/addons/web/models/ir_qweb.py
+++ b/addons/web/models/ir_qweb.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
+from collections import OrderedDict
from odoo import api, models
from odoo.tools import pycompat
@@ -57,6 +58,24 @@ class Image(models.AbstractModel):
elif options.get('zoom'):
src_zoom = options['zoom']
- img = '<img class="%s" src="%s" style="%s"%s%s/>' % \
- (classes, src, options.get('style', ''), ' alt="%s"' % alt if alt else '', ' data-zoom="1" data-zoom-image="%s"' % src_zoom if src_zoom else '')
- return pycompat.to_text(img)
+ atts = OrderedDict()
+ atts["src"] = src
+ atts["class"] = classes
+ atts["style"] = options.get('style')
+ atts["alt"] = alt
+ atts["data-zoom"] = src_zoom and u'1' or None
+ atts["data-zoom-image"] = src_zoom
+
+ atts = self.env['ir.qweb']._post_processing_att('img', atts, options.get('template_options'))
+
+ img = ['<img']
+ for name, value in atts.items():
+ if value:
+ img.append(' ')
+ img.append(escape(pycompat.to_text(name)))
+ img.append('="')
+ img.append(escape(pycompat.to_text(value)))
+ img.append('"')
+ img.append('/>')
+
+ return u''.join(img)
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index 0fe45f011e8..f98447c8983 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -391,10 +391,13 @@ var ActionManager = Widget.extend({
}
return this._startController(controller).then(function (controller) {
+ var prevDialogOnClose;
if (self.currentDialogController) {
+ prevDialogOnClose = self.currentDialogController.onClose;
self._closeDialog(true);
}
+ controller.onClose = prevDialogOnClose || options.on_close;
var dialog = new Dialog(self, _.defaults({}, options, {
buttons: [],
dialogClass: controller.className,
@@ -405,7 +408,7 @@ var ActionManager = Widget.extend({
self._removeAction(action.jsID);
self.currentDialogController = null;
if (silent !== true) {
- options.on_close();
+ controller.onClose();
}
});
controller.dialog = dialog;
@@ -488,8 +491,9 @@ var ActionManager = Widget.extend({
* @returns {Deferred} resolved immediately
*/
_executeCloseAction: function (action, options) {
+ var result;
if (!this.currentDialogController) {
- options.on_close();
+ result = options.on_close();
}
this._closeDialog();
@@ -499,7 +503,7 @@ var ActionManager = Widget.extend({
this.trigger_up('show_effect', action.effect);
}
- return $.when();
+ return $.when(result);
},
/**
* Executes actions of type 'ir.actions.server'.
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index 43b4e349a66..d007305dc79 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -2516,6 +2516,21 @@ var AceEditor = DebouncedField.extend({
//--------------------------------------------------------------------------
/**
+ * Format value
+ *
+ * Note: We have to overwrite this method to always return a string.
+ * AceEditor works with string and not boolean value.
+ *
+ * @override
+ * @private
+ * @param {boolean|string} value
+ * @returns {string}
+ */
+ _formatValue: function (value) {
+ return this._super.apply(this, arguments) || '';
+ },
+
+ /**
* @override
* @private
*/
@@ -2537,6 +2552,7 @@ var AceEditor = DebouncedField.extend({
this.aceSession.setValue(newValue);
}
},
+
/**
* Starts the ace library on the given DOM element. This initializes the
* ace editor option according to the edit/readonly mode and binds ace
diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js
index 8d15efda686..f3bfa87341c 100644
--- a/addons/web/static/src/js/services/crash_manager.js
+++ b/addons/web/static/src/js/services/crash_manager.js
@@ -41,7 +41,8 @@ var CrashManager = core.Class.extend({
core.bus.trigger('connection_lost');
this.connection_lost = true;
var timeinterval = setInterval(function() {
- ajax.jsonRpc('/web/webclient/version_info').then(function() {
+ var options = {shadow: true};
+ ajax.jsonRpc('/web/webclient/version_info', 'call', {}, options).then(function () {
clearInterval(timeinterval);
core.bus.trigger('connection_restored');
self.connection_lost = false;
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 ffa93fc4d78..9d509dd703c 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -584,13 +584,16 @@ var BasicModel = AbstractModel.extend({
/**
* Returns true if a record can be abandoned.
*
- * A record can be abandonned if it is a new record, except if
- * this datapoint has been specifically tagged as "do not abandon".
+ * Case for not abandoning the record:
*
- * Example:
+ * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any
+ * `onchange` from a `default_get`)
+ * 2. registered in a list on addition
+ * 2.1. registered as non-new addition
+ * 2.2. registered as new additon on update
+ * 3. record is not new
*
- * - Discard record from "Add an item" => "New" record => abandon
- * - Discard record from `default_get`/`onchange` => do not abandon
+ * Otherwise, the record can be abandoned.
*
* This is useful when discarding changes on this record, as it means that
* we must keep the record even if some fields are invalids (e.g. required
@@ -600,7 +603,29 @@ var BasicModel = AbstractModel.extend({
* @returns {boolean}
*/
canBeAbandoned: function (id) {
- return !this.localData[id]._noAbandon && this.isNew(id);
+ // 1. no drop if flagged
+ if (this.localData[id]._noAbandon) {
+ return false;
+ }
+ // 2. no drop in a list on "ADD in some cases
+ var record = this.localData[id];
+ var parent = this.localData[record.parentID];
+ if (parent) {
+ var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id});
+ if (entry) {
+ // 2.1. no drop on non-new addition in list
+ if (!entry.isNew) {
+ return false;
+ }
+ // 2.2. no drop on new addition on "UPDATE"
+ var lastEntry = _.last(parent._savePoint);
+ if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) {
+ return false;
+ }
+ }
+ }
+ // 3. drop new records
+ return this.isNew(id);
},
/**
* Returns true if a record is dirty. A record is considered dirty if it has
@@ -2571,6 +2596,11 @@ var BasicModel = AbstractModel.extend({
relationField: field.relation_field,
viewType: view ? view.type : fieldInfo.viewType,
});
+ // set existing changes to the list
+ if (record._changes && record._changes[fieldName]) {
+ list._changes = self.localData[record._changes[fieldName]]._changes;
+ record._changes[fieldName] = list.id;
+ }
record.data[fieldName] = list.id;
if (!fieldInfo.__no_fetch) {
var def = self._readUngroupedList(list).then(function () {
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 be49c12e768..d0ca5c3d09f 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -634,7 +634,11 @@ var FormController = BasicController.extend({
*/
_onSave: function (ev) {
ev.stopPropagation(); // Prevent x2m lines to be auto-saved
- this.saveRecord();
+ var self = this;
+ this._disableButtons();
+ this.saveRecord().always(function () {
+ self._enableButtons();
+ });
},
/**
* This method is called when someone tries to sort a column, most likely
diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js
index b0a90173363..216e6cf6756 100644
--- a/addons/web/static/tests/chrome/action_manager_tests.js
+++ b/addons/web/static/tests/chrome/action_manager_tests.js
@@ -98,6 +98,9 @@ QUnit.module('ActionManager', {
name: 'A Client Action',
tag: 'ClientAction',
type: 'ir.actions.client',
+ }, {
+ id: 10,
+ type: 'ir.actions.act_window_close',
}];
this.archs = {
@@ -2416,6 +2419,51 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
+ QUnit.test('requests for execute_action of type object: disable buttons', function (assert) {
+ assert.expect(2);
+
+ var self = this;
+ var def;
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_button') {
+ return $.when(false);
+ } else if (args.method === 'read') {
+ // Block the 'read' call
+ var result = this._super.apply(this, arguments);
+ return $.when(def).then(_.constant(result));
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ actionManager.doAction(3);
+
+ // open a record in form view
+ actionManager.$('.o_list_view .o_data_row:first').click();
+
+ // click on 'Call method' button (should call an Object method)
+ def = $.Deferred();
+ actionManager.$('.o_form_view button:contains(Call method)').click();
+
+ // Buttons should be disabled
+ assert.strictEqual(
+ actionManager.$('.o_form_view button:contains(Call method)').attr('disabled'),
+ 'disabled', 'buttons should be disabled')
+
+ // Release the 'read' call
+ def.resolve();
+
+ // Buttons should be enabled after the reload
+ assert.strictEqual(
+ actionManager.$('.o_form_view button:contains(Call method)').attr('disabled'),
+ undefined, 'buttons should be disabled')
+
+ actionManager.destroy();
+ });
+
QUnit.test('can open different records from a multi record view', function (assert) {
assert.expect(11);
@@ -3015,6 +3063,31 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
+ QUnit.test('chained action on_close', function (assert) {
+ assert.expect(3);
+
+ function on_close() {
+ assert.step('Close Action');
+ };
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+ actionManager.doAction(5, {on_close: on_close});
+
+ // a target=new action shouldn't activate the on_close
+ actionManager.doAction(5);
+ assert.verifySteps([]);
+
+ // An act_window_close should trigger the on_close
+ actionManager.doAction(10);
+ assert.verifySteps(['Close Action']);
+
+ actionManager.destroy();
+ });
+
QUnit.test('footer buttons are moved to the dialog footer', function (assert) {
assert.expect(3);
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 1147c1398aa..0cf7c43618e 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -143,10 +143,12 @@ QUnit.module('relational_fields', {
user: {
fields: {
name: {string: "Name", type: "char"},
+ partner_ids: {string: "one2many partners field", type: "one2many", relation: 'partner', relation_field: 'user_id'},
},
records: [{
id: 17,
name: "Aline",
+ partner_ids: [1, 2],
}, {
id: 19,
name: "Christine",
@@ -1178,7 +1180,7 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('list in form: discard newly added element with empty required field (default_get)', function (assert) {
+ QUnit.test('item not dropped on discard with empty required field (default_get)', function (assert) {
// This test simulates discarding a record that has been created with
// one of its required field that is empty. When we discard the changes
// on this empty field, it should not assume that this record should be
@@ -1322,7 +1324,7 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('list in form: discard newly added element with empty required field (onchange in default_get)', function (assert) {
+ QUnit.test('list in form: item not dropped on discard with empty required field (onchange in default_get)', function (assert) {
// variant of the test "list in form: discard newly added element with
// empty required field (default_get)", in which the `default_get`
// performs an `onchange` at the same time. This `onchange` may create
@@ -1392,11 +1394,13 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('list in form: discard newly added element with empty required field (onchange after default_get)', function (assert) {
+ QUnit.test('list in form: item not dropped on discard with empty required field (onchange on list after default_get)', function (assert) {
// discarding a record from an `onchange` in a `default_get` should not
- // abandon the record. However, any `onchange` after `default_get`
- // should be abandoned on discard
- assert.expect(6);
+ // abandon the record. This should not be the case for following
+ // `onchange`, except if an onchange make some changes on the list:
+ // in particular, if an onchange make changes on the list such that
+ // a record is added, this record should not be dropped on discard
+ assert.expect(8);
var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
@@ -1448,10 +1452,127 @@ QUnit.module('relational_fields', {
// click off so that the required field still stay empty
$('body').click();
+ // record should not be dropped
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped record in the editable list");
+ assert.strictEqual($('td.o_data_cell').first().text(), "entry",
+ "should still have the correct displayed name");
+ assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "",
+ "should still have empty string in the required field");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ });
+
+ QUnit.test('item dropped on discard with empty required field with "Add an item" (invalid on "ADD")', function (assert) {
+ // when a record in a list is added with "Add an item", it should
+ // always be dropped on discard if some required field are empty
+ // at the record creation.
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ // Click on "Add an item"
+ form.$('.o_field_x2many_list_row_add a').click();
+ var charField = form.$('.o_field_widget.o_field_char[name="display_name"]');
+ var requiredField = form.$('.o_field_widget.o_required_modifier[name="trululu"]');
+ charField.val("some text");
+ assert.strictEqual(charField.length, 1,
+ "should have a char field 'display_name' on this record");
+ assert.notOk(charField.hasClass('o_required_modifier'),
+ "the char field should not be required on this record");
+ assert.strictEqual(charField.val(), "some text",
+ "should have entered text in the char field on this record");
+ assert.strictEqual(requiredField.length, 1,
+ "should have a required field 'trululu' on this record");
+ assert.strictEqual(requiredField.val().trim(), "",
+ "should have empty string in the required field on this record");
+
+ // click on empty required field in editable list record
+ requiredField.click();
+ // click off so that the required field still stay empty
+ $('body').click();
+
// record should be dropped
assert.strictEqual(form.$('.o_data_row').length, 0,
"should have dropped record in the editable list");
+ form.destroy();
+ });
+
+ QUnit.test('item not dropped on discard with empty required field with "Add an item" (invalid on "UPDATE")', function (assert) {
+ // when a record in a list is added with "Add an item", it should
+ // be temporarily added to the list when it is valid (e.g. required
+ // fields are non-empty). If the record is updated so that the required
+ // field is empty, and it is discarded, then the record should not be
+ // dropped.
+ assert.expect(8);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "should initially not have any record in the list");
+
+ // Click on "Add an item"
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should have a temporary record in the list");
+
+ var $inputEditMode = form.$('.o_field_widget.o_required_modifier[name="trululu"] input');
+ assert.strictEqual($inputEditMode.length, 1,
+ "should have a required field 'trululu' on this record");
+ assert.strictEqual($inputEditMode.val(), "",
+ "should have empty string in the required field on this record");
+
+ // add something to required field and leave edit mode of the record
+ $inputEditMode.click();
+ $('li.ui-menu-item').first().click();
+ $('body').click(); // leave edit mode on the line
+
+ var $inputReadonlyMode = form.$('.o_data_cell.o_required_modifier');
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped valid record when leaving edit mode");
+ assert.strictEqual($inputReadonlyMode.text(), "first record",
+ "should have put some content in the required field on this record");
+
+ // remove the required field and leave edit mode of the record
+ $('.o_data_row').click(); // enter edit mode on the line
+ $inputEditMode.click();
+ $inputEditMode.val("");
+ $('body').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped record in the list on discard (invalid on UPDATE)");
+ assert.strictEqual($inputReadonlyMode.text(), "first record",
+ "should keep previous valid required field content on this record");
+
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
form.destroy();
});
@@ -9418,6 +9539,94 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('onchange affecting inline unopened list view', function (assert) {
+ // when we got onchange result for fields of record that were not
+ // already available because they were in a inline view not already
+ // opened, in a given configuration the change were applied ignoring
+ // existing data, thus a line of a one2many field inside a one2many
+ // field could be duplicated unexplectedly
+ assert.expect(5);
+
+ var numUserOnchange = 0;
+
+ this.data.user.onchanges = {
+ partner_ids: function (obj) {
+ if (numUserOnchange === 0) {
+ // simulate proper server onchange after save of modal with new record
+ obj.partner_ids = [
+ [5],
+ [1, 1, {
+ display_name: 'first record',
+ turtles: [
+ [5],
+ [1, 2, {'display_name': 'donatello'}],
+ ],
+ }],
+ [1, 2, {
+ display_name: 'second record',
+ turtles: [
+ [5],
+ obj.partner_ids[1][2].turtles[0],
+ ],
+ }],
+ ];
+ }
+ numUserOnchange++;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'user',
+ data: this.data,
+ arch: '<form><sheet><group>' +
+ '<field name="partner_ids">' +
+ '<form>'+
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group></sheet></form>',
+ res_id: 17,
+ });
+
+ // add a turtle on second partner
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_data_row:eq(1)').click();
+ $('.modal .o_field_x2many_list_row_add a').click();
+ $('.modal input[name="display_name"]').val('michelangelo').change();
+ $('.modal .btn-primary').click();
+ // open first partner so changes from previous action are applied
+ form.$('.o_data_row:eq(0)').click();
+ $('.modal .btn-primary').click();
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(numUserOnchange, 2,
+ 'there should 2 and only 2 onchange from closing the partner modal');
+
+ form.$('.o_data_row:eq(0)').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ 'only 1 turtle for first partner');
+ assert.strictEqual($('.modal .o_data_row').text(), 'donatello',
+ 'first partner turtle is donatello');
+ $('.modal .o_form_button_cancel').click();
+
+ form.$('.o_data_row:eq(1)').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ 'only 1 turtle for second partner');
+ assert.strictEqual($('.modal .o_data_row').text(), 'michelangelo',
+ 'second partner turtle is michelangelo');
+ $('.modal .o_form_button_cancel').click();
+
+ form.destroy();
+ });
+
QUnit.module('FieldMany2Many');
QUnit.test('many2many kanban: edition', function (assert) {
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 18c82adb0a5..d8950ae44ab 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -1335,6 +1335,51 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('disable buttons until reload data from server', function (assert) {
+ assert.expect(2);
+ var def;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group><field name="foo"/></group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ args.args[1].foo = "apple";
+ } else if (args.method === 'read') {
+ // Block the 'read' call
+ var result = this._super.apply(this, arguments);
+ return $.when(def).then(_.constant(result));
+ }
+ return this._super(route, args);
+ },
+ res_id: 2,
+ });
+
+ var def = $.Deferred();
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('input').val("tralala").trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ // Save button should be disabled
+ assert.strictEqual(
+ form.$buttons.find('.o_form_button_save').attr('disabled'),
+ 'disabled', 'buttons should be disabled')
+
+ // Release the 'read' call
+ def.resolve();
+
+ // Edit button should be enabled after the reload
+ assert.strictEqual(
+ form.$buttons.find('.o_form_button_edit').attr('disabled'),
+ undefined, 'buttons should be enabled')
+
+ form.destroy();
+ });
+
QUnit.test('properly apply onchange in simple case', function (assert) {
assert.expect(2);
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index f573342570e..22a1e514d88 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -273,7 +273,7 @@ class Web_Editor(http.Controller):
## @param bundles - True if the bundles views must be fetched (default to False)
## @param bundles_restriction - Names of the bundle in which to look for scss files (if empty, search in all of them)
## @returns a dictionary with views info in the views key and style info in the scss key
- @http.route("/web_editor/get_assets_editor_resources", type="json", auth="user")
+ @http.route("/web_editor/get_assets_editor_resources", type="json", auth="user", website=True)
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)
@@ -390,16 +390,17 @@ class Web_Editor(http.Controller):
# Check if the file to save had already been modified
custom_attachment = IrAttachment.search([("url", "=", custom_url)])
+ datas = base64.b64encode((content or "\n").encode("utf-8"))
if custom_attachment:
# If it was already modified, simply override the corresponding attachment content
- custom_attachment.write({"datas": base64.b64encode(content.encode("utf-8"))})
+ custom_attachment.write({"datas": datas})
else:
# If not, create a new attachment to copy the original scss file content, with its modifications
IrAttachment.create(dict(
name = custom_url,
type = "binary",
mimetype = "text/scss",
- datas = base64.b64encode(content.encode("utf-8")),
+ datas = datas,
datas_fname = url.split("/")[-1],
url = custom_url, # Having an attachment of "binary" type with an non empty "url" field
# is quite of an hack. This allows to fetch the "datas" field by adding
diff --git a/addons/web_editor/models/ir_qweb.py b/addons/web_editor/models/ir_qweb.py
index a9492dac645..6926775ccbc 100644
--- a/addons/web_editor/models/ir_qweb.py
+++ b/addons/web_editor/models/ir_qweb.py
@@ -178,6 +178,7 @@ class Contact(models.AbstractModel):
@api.model
def attributes(self, record, field_name, options, values):
attrs = super(Contact, self).attributes(record, field_name, options, values)
+ options.pop('template_options') # remove options not specific to this widget
attrs['data-oe-contact-options'] = json.dumps(options)
return attrs
diff --git a/addons/web_editor/static/src/js/editor/transcoder.js b/addons/web_editor/static/src/js/editor/transcoder.js
index fb3e69eda29..84fe3cf8c93 100644
--- a/addons/web_editor/static/src/js/editor/transcoder.js
+++ b/addons/web_editor/static/src/js/editor/transcoder.js
@@ -108,9 +108,14 @@ function getMatchedCSSRules(a) {
// The css generates all the attributes separately and not in simplified form.
// In order to have a better compatibility (outlook for example) we simplify the css tags.
// e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none
- _.each([['margin'], ['padding'], ['border', 'style']], function (attr) {
- var p = attr[0];
- var e = attr[1] ? '-' + attr[1] : '';
+ _.each([
+ {property: 'margin'},
+ {property: 'padding'},
+ {property: 'border', propertyEnd: '-style', defaultValue: 'none'},
+ ], function (propertyInfo) {
+ var p = propertyInfo.property;
+ var e = propertyInfo.propertyEnd || '';
+ var defVal = propertyInfo.defaultValue || 0;
if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) {
if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) {
@@ -119,7 +124,7 @@ function getMatchedCSSRules(a) {
}
else {
// keep => property: [top value] [right value] [bottom value] [left value];
- style[p+e] = (style[p+'-top'+e] || 0) + ' ' + (style[p+'-right'+e] || 0) + ' ' + (style[p+'-bottom'+e] || 0) + ' ' + (style[p+'-left'+e] || 0);
+ style[p+e] = (style[p+'-top'+e] || defVal) + ' ' + (style[p+'-right'+e] || defVal) + ' ' + (style[p+'-bottom'+e] || defVal) + ' ' + (style[p+'-left'+e] || defVal);
if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) {
// keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value];
delete style[p+e];
@@ -277,11 +282,12 @@ function classToStyle($editable) {
}
// Outlook
- if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.children().length) {
- var $hack = $('<table class="o_outlook_hack"><tr><td></td></tr></table>');
+ if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) {
+ var $hack = $('<table class="o_outlook_hack" style="display: inline-table;"><tr><td></td></tr></table>');
$hack.find('td')
.attr('height', $target.outerHeight())
.css({
+ 'text-align': $target.parent().css('text-align'),
'margin': $target.css('padding'),
'border-radius': $target.css('border-radius'),
'background-color': $target.css('background-color'),
diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js
index 92ebf9ec4fe..e2dc7ea73a7 100644
--- a/addons/web_editor/static/src/js/widgets/widgets.js
+++ b/addons/web_editor/static/src/js/widgets/widgets.js
@@ -291,6 +291,10 @@ var ImageWidget = MediaWidget.extend({
}
var img = this.images[0];
+ if (!img) {
+ return this.media;
+ }
+
var def = $.when();
if (!img.access_token) {
def = this._rpc({
@@ -1223,7 +1227,7 @@ var MediaDialog = Dialog.extend({
}
return $.when(this.active.save()).then(function (media) {
- if (!self.media) {
+ if (!self.media && media) {
self.range.insertNode(media, true);
}
self.media = media;
@@ -1235,7 +1239,7 @@ var MediaDialog = Dialog.extend({
// Update editor bar after image edition (in case the image change to icon or other)
_.defer(function () {
- if (!self.media.parentNode) {
+ if (!self.media || !self.media.parentNode) {
return;
}
range.createFromNode(self.media).select();
diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py
index 943a603d211..0f73bc469f5 100644
--- a/addons/website/models/ir_qweb.py
+++ b/addons/website/models/ir_qweb.py
@@ -1,93 +1,51 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-import ast
+import re
+from collections import OrderedDict
from odoo import models
from odoo.http import request
+re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^)'\"]+)")
+
+
class QWeb(models.AbstractModel):
""" QWeb object for rendering stuff in the website context """
_inherit = 'ir.qweb'
URL_ATTRS = {
- 'form': 'action',
- 'a': 'href',
+ 'form': 'action',
+ 'a': 'href',
+ 'link': 'href',
+ 'script': 'src',
+ 'img': 'src',
}
- CDN_TRIGGERS = {
- 'link': 'href',
- 'script': 'src',
- 'img': 'src',
- }
-
- def _get_asset(self, xmlid, options, css=True, js=True, debug=False, async=False, values=None):
- website = getattr(request, 'website', None) if request else None
- if website and website.cdn_activated:
- values = dict(values or {}, url_for=website.get_cdn_url)
- return super(QWeb, self)._get_asset(xmlid, options, css, js, debug, async, values)
-
- def _website_build_attribute(self, tagName, name, value, options, values):
- """ Compute the value of an attribute while rendering the template. """
- if name == self.URL_ATTRS.get(tagName) and values.get('url_for'):
- return values.get('url_for')(value or '')
- elif request and getattr(request, 'website', None) and request.website.cdn_activated and (name == self.URL_ATTRS.get(tagName) or name == self.CDN_TRIGGERS.get(tagName)):
- return request.website.get_cdn_url(value or '')
- return value
-
- def _wrap_build_attributes(self, el, items, options):
- """ Map items corresponding to URL and CDN attributes to an ast expression. """
- if options.get('rendering_bundle'):
- return items
-
- url_att = self.URL_ATTRS.get(el.tag)
- cdn_att = self.CDN_TRIGGERS.get(el.tag)
+ def _post_processing_att(self, tagName, atts, options):
+ if atts.get('data-no-post-process'):
+ return atts
- def process(item):
- if isinstance(item, tuple) and (item[0] in (url_att, cdn_att)):
- return (item[0], ast.Call(
- func=ast.Attribute(
- value=ast.Name(id='self', ctx=ast.Load()),
- attr='_website_build_attribute',
- ctx=ast.Load()
- ),
- args=[
- ast.Str(el.tag),
- ast.Str(item[0]),
- item[1],
- ast.Name(id='options', ctx=ast.Load()),
- ast.Name(id='values', ctx=ast.Load()),
- ], keywords=[],
- starargs=None, kwargs=None
- ))
- else:
- return item
+ atts = super(QWeb, self)._post_processing_att(tagName, atts, options)
- return [process(it) for it in items]
+ if options.get('inherit_branding') or options.get('rendering_bundle') or \
+ options.get('edit_translations') or options.get('debug') or (request and request.debug):
+ return atts
- def _compile_static_attributes(self, el, options):
- items = super(QWeb, self)._compile_static_attributes(el, options)
- return self._wrap_build_attributes(el, items, options)
+ website = request and getattr(request, 'website', None)
+ if not website and options.get('website_id'):
+ website = self.env['website'].browse(options['website_id'])
- def _compile_dynamic_attributes(self, el, options):
- items = super(QWeb, self)._compile_dynamic_attributes(el, options)
- return self._wrap_build_attributes(el, items, options)
+ if not website or not website.cdn_activated:
+ return atts
- # method called by computing code
+ name = self.URL_ATTRS.get(tagName)
+ if name and name in atts:
+ atts = OrderedDict(atts)
+ atts[name] = website.get_cdn_url(atts[name])
+ if isinstance(atts.get('style'), str) and 'background-image' in atts['style']:
+ atts = OrderedDict(atts)
+ atts['style'] = re_background_image.sub(lambda m: '%s%s' % (m.group(1), website.get_cdn_url(m.group(2))), atts['style'])
- def _get_dynamic_att(self, tagName, atts, options, values):
- atts = super(QWeb, self)._get_dynamic_att(tagName, atts, options, values)
- if options.get('rendering_bundle'):
- return atts
- for name, value in atts.items():
- atts[name] = self._website_build_attribute(tagName, name, value, options, values)
return atts
-
- def _is_static_node(self, el):
- url_att = self.URL_ATTRS.get(el.tag)
- cdn_att = self.CDN_TRIGGERS.get(el.tag)
- return super(QWeb, self)._is_static_node(el) and \
- (not url_att or not el.get(url_att)) and \
- (not cdn_att or not el.get(cdn_att))
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index 106aee8b208..40c1ce0ae6e 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -85,7 +85,11 @@ class Website(models.Model):
@api.multi
def write(self, values):
self._get_languages.clear_cache(self)
- return super(Website, self).write(values)
+ result = super(Website, self).write(values)
+ if 'cdn_activated' in values or 'cdn_url' in values or 'cdn_filters' in values:
+ # invalidate the caches from static node at compile time
+ self.env['ir.qweb'].clear_caches()
+ return result
#----------------------------------------------------------
# Page Management
@@ -563,15 +567,15 @@ class Website(models.Model):
size = '' if size is None else '/%s' % size
return '/web/image/%s/%s/%s%s?unique=%s' % (record._name, record.id, field, size, sha)
- @api.model
def get_cdn_url(self, uri):
- # Currently only usable in a website_enable request context
- if request and request.website and not request.debug and request.website.user_id.id == request.uid:
- cdn_url = request.website.cdn_url
- cdn_filters = (request.website.cdn_filters or '').splitlines()
- for flt in cdn_filters:
- if flt and re.match(flt, uri):
- return urls.url_join(cdn_url, uri)
+ self.ensure_one()
+ if not uri:
+ return ''
+ cdn_url = self.cdn_url
+ cdn_filters = (self.cdn_filters or '').splitlines()
+ for flt in cdn_filters:
+ if flt and re.match(flt, uri):
+ return urls.url_join(cdn_url, uri)
return uri
@api.model
diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py
index 121eff4ff44..a4415acbb7d 100644
--- a/addons/website/tests/__init__.py
+++ b/addons/website/tests/__init__.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_converter
+from . import test_qweb
from . import test_crawl
from . import test_ui
from . import test_views
diff --git a/addons/website/tests/template_qweb_test.xml b/addons/website/tests/template_qweb_test.xml
new file mode 100644
index 00000000000..423c8d3212e
--- /dev/null
+++ b/addons/website/tests/template_qweb_test.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <template id="website.test_bundle">
+ <script type="text/javascript" src="/web/static/lib/qweb/qweb2.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/web/static/lib/jquery.ui/jquery-ui.css"/>
+ <link rel="stylesheet" href="http://test.external.link/style1.css"/>
+
+ <script type="text/javascript" src="/web/static/src/js/boot.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
+
+ <link rel="stylesheet" href="http://test.external.link/style2.css"/>
+ </template>
+
+ <template id="website.test_template" name="test template 2">
+&lt;!DOCTYPE html&gt;
+<html>
+ <head>
+ <t t-call-assets="website.test_bundle" t-js="False"/>
+ <meta/>
+ <t t-call-assets="website.test_bundle" t-css="False"/>
+ </head>
+ <body>
+ <img src="http://test.external.link/img.png"/>
+ <img src="/website/static/img.png"/>
+ <a href="http://test.external.link/link">x</a>
+ <a href="/web/content/local_link">x</a>
+ <span t-attf-style="background-image: url('/web/image/2')" t-att-empty="False">xxx</span>
+ <div widget="html" t-field="user.signature"/>
+ <div widget="image" t-field="user.image" t-options="{'widget': 'image'}"/>
+ </body>
+</html>
+ </template>
+
+</odoo> \ No newline at end of file
diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py
new file mode 100644
index 00000000000..e73083a66b2
--- /dev/null
+++ b/addons/website/tests/test_qweb.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import re
+
+from odoo import tools
+from odoo.modules.module import get_module_resource
+from odoo.tests.common import TransactionCase
+
+
+class TestQweb(TransactionCase):
+ def _load(self, module, *args):
+ tools.convert_file(self.cr, 'website',
+ get_module_resource(module, *args),
+ {}, 'init', False, 'test', self.registry._assertion_report)
+
+ def test_qweb_cdn(self):
+ self._load('website', 'tests', 'template_qweb_test.xml')
+
+ website = self.env['website'].browse(1)
+ website.write({
+ "cdn_activated": True,
+ "cdn_url": "http://test.cdn"
+ })
+
+ demo = self.env['res.users'].search([('login', '=', 'demo')])[0]
+ demo.write({"signature": '''<span class="toto">
+ span<span class="fa"></span><img src="/web/image/1"/>
+ </span>'''})
+
+ demo_env = self.env(user=demo)
+
+ html = demo_env['ir.qweb'].render('website.test_template', {"user": demo}, website_id= website.id)
+ html = html.strip().decode('utf8')
+ html = re.sub(r'\?unique=[^"]+', '', html).encode('utf8')
+
+ attachments = demo_env['ir.attachment'].search([('url', '=like', '/web/content/%-%/website.test_bundle.%')])
+ self.assertEqual(len(attachments), 2)
+ self.assertEqual(html, ("""<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="stylesheet" href="http://test.external.link/style1.css"/>
+ <link rel="stylesheet" href="http://test.external.link/style2.css"/>
+ <link type="text/css" rel="stylesheet" href="http://test.cdn%(css)s"/>
+ <meta/>
+ <script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
+ <script type="text/javascript" src="http://test.cdn%(js)s"></script>
+ </head>
+ <body>
+ <img src="http://test.external.link/img.png"/>
+ <img src="http://test.cdn/website/static/img.png"/>
+ <a href="http://test.external.link/link">x</a>
+ <a href="http://test.cdn/web/content/local_link">x</a>
+ <span style="background-image: url('http://test.cdn/web/image/2')">xxx</span>
+ <div widget="html"><span class="toto">
+ span<span class="fa"></span><img src="http://test.cdn/web/image/1">
+ </span></div>
+ <div widget="image"><img src="http://test.cdn/web/image/res.users/%(user_id)s/image" class="img img-responsive"/></div>
+ </body>
+</html>""" % {
+ "js": attachments[0].url,
+ "css": attachments[1].url,
+ "user_id": demo.id,
+ }).encode('utf8'))
diff --git a/addons/website_event/controllers/main.py b/addons/website_event/controllers/main.py
index bc1edfc5bb9..6284711732d 100644
--- a/addons/website_event/controllers/main.py
+++ b/addons/website_event/controllers/main.py
@@ -177,7 +177,7 @@ class WebsiteEventController(http.Controller):
'event': event,
'main_object': event,
'range': range,
- 'registrable': event._is_event_registrable()
+ 'registrable': event.sudo()._is_event_registrable()
}
return request.render("website_event.event_description_full", values)
diff --git a/addons/website_event/views/event_templates.xml b/addons/website_event/views/event_templates.xml
index 91ebbd69b8c..fce2d689a64 100644
--- a/addons/website_event/views/event_templates.xml
+++ b/addons/website_event/views/event_templates.xml
@@ -473,7 +473,7 @@
<t t-if="event.state != 'draft'" itemprop="availability" content="http://schema.org/Discontinued">
Event registration is closed.
</t>
- <t t-if="uid">
+ <t t-if="request.env.user.has_group('event.group_event_manager')">
<a t-attf-href="/web#id=#{event.id}&amp;view_type=form&amp;model=event.event">
<i class="fa fa-plus-circle"></i>
<em>Configure and Launch Event Registration</em>
diff --git a/addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js b/addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js
index 9daba4bf666..e48cae79df4 100644
--- a/addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js
+++ b/addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js
@@ -8,6 +8,7 @@ sAnimation.registry.follow_alias = sAnimation.Class.extend({
start: function () {
var self = this;
this.is_user = false;
+ var unsubscribePage = window.location.search.slice(1).split('&').indexOf("unsubscribe") >= 0;
this._rpc({
route: '/groups/is_member',
params: {
@@ -19,6 +20,9 @@ sAnimation.registry.follow_alias = sAnimation.Class.extend({
self.is_user = data.is_user;
self.email = data.email;
self.$target.find('.js_mg_link').attr('href', '/groups/' + self.$target.data('id'));
+ if (unsubscribePage && self.is_user) {
+ self.$target.find(".js_mg_follow_form").remove();
+ }
self.toggle_subscription(data.is_member ? 'on' : 'off', data.email);
self.$target.removeClass("hidden");
});
diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py
index 9de089dee86..287b21450ab 100644
--- a/addons/website_sale/models/res_config_settings.py
+++ b/addons/website_sale/models/res_config_settings.py
@@ -42,7 +42,7 @@ class ResConfigSettings(models.TransientModel):
order_mail_template = fields.Many2one('mail.template', string='Order Confirmation Email',
default=_default_order_mail_template, domain="[('model', '=', 'sale.order')]",
- help="Email sent to customer at the end of the checkout process")
+ help="Email sent to customer at the end of the checkout process", readonly=True)
automatic_invoice = fields.Boolean("Automatic Invoice",
help="The invoice is generated automatically and available in the customer portal "
diff --git a/addons/website_sale_options/static/src/js/website_sale.js b/addons/website_sale_options/static/src/js/website_sale.js
index da7025d65bb..27da88ae866 100644
--- a/addons/website_sale_options/static/src/js/website_sale.js
+++ b/addons/website_sale_options/static/src/js/website_sale.js
@@ -9,7 +9,7 @@ require('website_sale.website_sale');
$('.oe_website_sale #add_to_cart, .oe_website_sale #products_grid .a-submit')
.off('click')
.removeClass('a-submit')
- .click(function (event) {
+ .click(_.debounce(function (event) {
var $form = $(this).closest('form');
var quantity = parseFloat($form.find('input[name="add_qty"]').val() || 1);
var product_id = parseInt($form.find('input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked').first().val(),10);
@@ -96,6 +96,6 @@ $('.oe_website_sale #add_to_cart, .oe_website_sale #products_grid .a-submit')
});
});
return false;
- });
+ }, 200, true));
});
diff --git a/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js b/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js
index 3be63b14ad2..55040849635 100644
--- a/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js
+++ b/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js
@@ -28,7 +28,12 @@ var ProductWishlist = Widget.extend({
if ($('.wishlist-section').length) {
$('.wishlist-section a.o_wish_rm').on('click', function (e){ self.wishlist_rm(e, false); });
- $('.wishlist-section a.o_wish_add').on('click', function (e){ self.wishlist_add_or_mv(e); });
+ $('.wishlist-section a.o_wish_add').on('click', function (e){
+ $(e.currentTarget).addClass('disabled');
+ self.wishlist_add_or_mv(e).then(function(o) {
+ $(e.currentTarget).removeClass('disabled');
+ });
+ });
}
$('.oe_website_sale').on('change', 'input.js_variant_change, select.js_variant_change, ul[data-attribute_value_ids]', function(ev) {
@@ -133,7 +138,7 @@ var ProductWishlist = Widget.extend({
// can be hidden if empty
$('#my_cart').removeClass('hidden');
website_sale_utils.animate_clone($('#my_cart'), tr, 25, 40);
- this.add_to_cart(product, tr.find('qty').val() || 1);
+ return this.add_to_cart(product, tr.find('qty').val() || 1);
},
wishlist_mv: function(e){
var tr = $(e.currentTarget).parents('tr');
@@ -143,6 +148,7 @@ var ProductWishlist = Widget.extend({
website_sale_utils.animate_clone($('#my_cart'), tr, 25, 40);
var adding_deffered = this.add_to_cart(product, tr.find('qty').val() || 1);
this.wishlist_rm(e, adding_deffered);
+ return adding_deffered;
},
add_to_cart: function(product_id, qty_id) {
var add_to_cart = ajax.jsonRpc("/shop/cart/update_json", 'call', {
diff --git a/debian/postrm b/debian/postrm
index 22f7211203f..274a488c1bf 100644
--- a/debian/postrm
+++ b/debian/postrm
@@ -10,9 +10,6 @@ case "${1}" in
remove)
deluser --quiet --system $ODOO_USER || true
delgroup --quiet --system --only-if-empty $ODOO_GROUP || true
- if [ -d "$ODOO_LIB_DIR" ]; then
- rm -rf $ODOO_LIB_DIR
- fi
;;
purge)
diff --git a/doc/cla/corporate/accomodata.md b/doc/cla/corporate/accomodata.md
new file mode 100644
index 00000000000..2cbbfe82794
--- /dev/null
+++ b/doc/cla/corporate/accomodata.md
@@ -0,0 +1,20 @@
+Belgium, 2018-05-18
+
+Accomodata agrees to the terms of the Odoo Corporate Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Jos De Graeve Jos.DeGraeve@accomodata.be https://github.com/JosDeGraeve
+
+List of contributors:
+
+* James De Ridder james.de.ridder@accomodata.be https://github.com/Krynox
+* Manuel Dellisse manuel.dellisse@accomodata.be https://github.com/Manuel-Dellisse
+* Brecht Verhaeghe brecht.verhaeghe@accomodata.be https://github.com/brechtverhaeghe
+* Ronny Houben ronny.houben@accomodata.be https://github.com/ronnyhouben
+* Jean-Paul Robineau jean-paul.robineau@accomodata.be https://github.com/jeanpaulrobineau
+* Jos De Graeve jos.degraeve@accomodata.be https://github.com/JosDeGraeve
diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py
index 13634fa401f..393faab10a5 100644
--- a/odoo/addons/base/models/assetsbundle.py
+++ b/odoo/addons/base/models/assetsbundle.py
@@ -15,9 +15,12 @@ except ImportError:
libsass = None
from datetime import datetime
from subprocess import Popen, PIPE
+from collections import OrderedDict
from odoo import fields, tools
+from odoo.tools.pycompat import string_types, to_text
from odoo.http import request
from odoo.modules.module import get_resource_path
+from .qweb import escape
import psycopg2
from odoo.tools import func, misc
@@ -72,7 +75,6 @@ def rjsmin(script):
).strip()
return result
-
class AssetError(Exception):
pass
@@ -86,17 +88,16 @@ class AssetsBundle(object):
rx_preprocess_imports = re.compile("""(@import\s?['"]([^'"]+)['"](;?))""")
rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/")
- def __init__(self, name, files, remains, env=None):
+ # remains attribute is depreciated and will remove after v11
+ def __init__(self, name, files, remains=None, env=None):
self.name = name
self.env = request.env if env is None else env
self.max_css_rules = self.env.context.get('max_css_rules', MAX_CSS_RULES)
self.javascripts = []
self.stylesheets = []
self.css_errors = []
- self.remains = []
self._checksum = None
self.files = files
- self.remains = remains
for f in files:
if f['atype'] == 'text/sass':
self.stylesheets.append(SassStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media']))
@@ -109,10 +110,31 @@ class AssetsBundle(object):
elif f['atype'] == 'text/javascript':
self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
+ # depreciated and will remove after v11
def to_html(self, sep=None, css=True, js=True, debug=False, async=False, url_for=(lambda url: url)):
+ nodes = self.to_node(css=css, js=js, debug=debug, async=async)
+
if sep is None:
sep = u'\n '
response = []
+ for tagName, attributes, content in nodes:
+ html = u"<%s " % tagName
+ for name, value in attributes.items():
+ if value or isinstance(value, string_types):
+ html += u' %s="%s"' % (name, escape(to_text(value)))
+ if content is None:
+ html += u'/>'
+ else:
+ html += u'>%s</%s>' % (escape(to_text(content)), tagName)
+ response.append(html)
+
+ return sep + sep.join(response)
+
+ def to_node(self, css=True, js=True, debug=False, async=False):
+ """
+ :returns [(tagName, attributes, content)] if the tag is auto close
+ """
+ response = []
if debug == 'assets':
if css and self.stylesheets:
is_css_preprocessed, old_attachments = self.is_css_preprocessed()
@@ -120,28 +142,37 @@ class AssetsBundle(object):
self.preprocess_css(debug=debug, old_attachments=old_attachments)
if self.css_errors:
msg = '\n'.join(self.css_errors)
- response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_html())
- response.append(StylesheetAsset(self, url="/web/static/lib/bootstrap/css/bootstrap.css").to_html())
+ response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_node())
+ response.append(StylesheetAsset(self, url="/web/static/lib/bootstrap/css/bootstrap.css").to_node())
if not self.css_errors:
for style in self.stylesheets:
- response.append(style.to_html())
+ response.append(style.to_node())
if js:
for jscript in self.javascripts:
- response.append(jscript.to_html())
+ response.append(jscript.to_node())
else:
if css and self.stylesheets:
css_attachments = self.css() or []
for attachment in css_attachments:
- response.append(u'<link href="%s" rel="stylesheet"/>' % url_for(attachment.url))
+ attr = OrderedDict([
+ ["type", "text/css"],
+ ["rel", "stylesheet"],
+ ["href", attachment.url],
+ ])
+ response.append(("link", attr, None))
if self.css_errors:
msg = '\n'.join(self.css_errors)
- response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_html())
+ response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_node())
if js and self.javascripts:
- response.append(u'<script %s type="text/javascript" src="%s"></script>' % (async and u'async="async"' or '', url_for(self.js().url)))
- response.extend(self.remains)
+ attr = OrderedDict([
+ ["async", "async" if async else None],
+ ["type", "text/javascript"],
+ ["src", self.js().url],
+ ])
+ response.append(("script", attr, None))
- return sep + sep.join(response)
+ return response
@func.lazy_property
def last_modified(self):
@@ -161,7 +192,7 @@ class AssetsBundle(object):
Not really a full checksum.
We compute a SHA1 on the rendered bundle + max linked files last_modified date
"""
- check = u"%s%s%s" % (json.dumps(self.files, sort_keys=True), u",".join(self.remains), self.last_modified)
+ check = u"%s%s" % (json.dumps(self.files, sort_keys=True), self.last_modified)
return hashlib.sha1(check.encode('utf-8')).hexdigest()
def clean_attachments(self, type):
@@ -323,7 +354,7 @@ class AssetsBundle(object):
outdated = False
assets = dict((asset.html_url, asset) for asset in self.stylesheets if isinstance(asset, atype))
if assets:
- assets_domain = [('url', 'in', list(assets))]
+ assets_domain = [('url', 'in', list(assets.keys()))]
attachments = self.env['ir.attachment'].sudo().search(assets_domain)
for attachment in attachments:
asset = assets[attachment.url]
@@ -474,7 +505,20 @@ class WebAsset(object):
except Exception:
raise AssetNotFound("Could not find %s" % self.name)
+ # depreciated and will remove after v11
def to_html(self):
+ tagName, attributes, content = self.to_node()
+ html = u"<%s " % tagName
+ for name, value in attributes.items():
+ if value or isinstance(value, string_types):
+ html += u' %s="%s"' % (name, escape(to_text(value)))
+ if content is None:
+ html += u'/>'
+ else:
+ html += u'>%s</%s>' % (escape(to_text(content)), tagName)
+ return html
+
+ def to_node(self):
raise NotImplementedError()
@func.lazy_property
@@ -533,13 +577,19 @@ class JavascriptAsset(WebAsset):
try:
return super(JavascriptAsset, self)._fetch_content()
except AssetError as e:
- return "console.error(%s);" % json.dumps(str(e))
+ return u"console.error(%s);" % json.dumps(to_text(e))
- def to_html(self):
+ def to_node(self):
if self.url:
- return '<script type="text/javascript" src="%s"></script>' % (self.html_url)
+ return ("script", OrderedDict([
+ ["type", "text/javascript"],
+ ["src", self.html_url],
+ ]), None)
else:
- return '<script type="text/javascript" charset="utf-8">%s</script>' % self.with_header()
+ return ("script", OrderedDict([
+ ["type", "text/javascript"],
+ ["charset", "utf-8"],
+ ]), self.with_header())
class StylesheetAsset(WebAsset):
@@ -595,13 +645,21 @@ class StylesheetAsset(WebAsset):
content = re.sub(r' *([{}]) *', r'\1', content)
return self.with_header(content)
- def to_html(self):
- media = (' media="%s"' % misc.html_escape(self.media)) if self.media else ''
+ def to_node(self):
if self.url:
- href = self.html_url
- return '<link rel="stylesheet" href="%s" type="text/css"%s/>' % (href, media)
+ attr = OrderedDict([
+ ["type", "text/css"],
+ ["rel", "stylesheet"],
+ ["href", self.html_url],
+ ["media", escape(to_text(self.media)) if self.media else None]
+ ])
+ return ("link", attr, None)
else:
- return '<style type="text/css"%s>%s</style>' % (media, self.with_header())
+ attr = OrderedDict([
+ ["type", "text/css"],
+ ["media", escape(to_text(self.media)) if self.media else None]
+ ])
+ return ("style", attr, self.with_header())
class PreprocessedCSS(StylesheetAsset):
diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py
index f1d7c812fde..fa600211795 100644
--- a/odoo/addons/base/models/ir_actions_report.py
+++ b/odoo/addons/base/models/ir_actions_report.py
@@ -522,20 +522,24 @@ class IrActionsReport(models.Model):
# we look on the pdf structure using pypdf to compute the outlines_pages that is
# an array like [0, 3, 5] that means a new document start at page 0, 3 and 5.
reader = PdfFileReader(pdf_content_stream)
- outlines_pages = sorted(
- [outline.getObject()[0] for outline in reader.trailer['/Root']['/Dests'].values()])
- assert len(outlines_pages) == len(res_ids)
- for i, num in enumerate(outlines_pages):
- to = outlines_pages[i + 1] if i + 1 < len(outlines_pages) else reader.numPages
- attachment_writer = PdfFileWriter()
- for j in range(num, to):
- attachment_writer.addPage(reader.getPage(j))
- stream = io.BytesIO()
- attachment_writer.write(stream)
- if res_ids[i] and res_ids[i] not in save_in_attachment:
- self.postprocess_pdf_report(record_map[res_ids[i]], stream)
- streams.append(stream)
- close_streams([pdf_content_stream])
+ if reader.trailer['/Root'].get('/Dests'):
+ outlines_pages = sorted(
+ [outline.getObject()[0] for outline in reader.trailer['/Root']['/Dests'].values()])
+ assert len(outlines_pages) == len(res_ids)
+ for i, num in enumerate(outlines_pages):
+ to = outlines_pages[i + 1] if i + 1 < len(outlines_pages) else reader.numPages
+ attachment_writer = PdfFileWriter()
+ for j in range(num, to):
+ attachment_writer.addPage(reader.getPage(j))
+ stream = io.BytesIO()
+ attachment_writer.write(stream)
+ if res_ids[i] and res_ids[i] not in save_in_attachment:
+ self.postprocess_pdf_report(record_map[res_ids[i]], stream)
+ streams.append(stream)
+ close_streams([pdf_content_stream])
+ else:
+ # If no outlines available, do not save each record
+ streams.append(pdf_content_stream)
# If attachment_use is checked, the records already having an existing attachment
# are not been rendered by wkhtmltopdf. So, create a new stream for each of them.
diff --git a/odoo/addons/base/models/ir_property.py b/odoo/addons/base/models/ir_property.py
index 99b63a95887..63ca6d2d6d0 100644
--- a/odoo/addons/base/models/ir_property.py
+++ b/odoo/addons/base/models/ir_property.py
@@ -189,7 +189,7 @@ class Property(models.Model):
if not values:
return
- if not default_value:
+ if default_value is None:
domain = self._get_domain(name, model)
if domain is None:
raise Exception()
diff --git a/odoo/addons/base/models/ir_qweb.py b/odoo/addons/base/models/ir_qweb.py
index 0bf528c9270..a8cfaf951a5 100644
--- a/odoo/addons/base/models/ir_qweb.py
+++ b/odoo/addons/base/models/ir_qweb.py
@@ -125,35 +125,122 @@ class IrQWeb(models.AbstractModel, QWeb):
if len(el):
raise SyntaxError("t-call-assets cannot contain children nodes")
- # self._get_asset(xmlid, options, css=css, js=js, debug=values.get('debug'), async=async, values=values)
+ # nodes = self._get_asset(xmlid, options, css=css, js=js, debug=values.get('debug'), async=async, values=values)
+ #
+ # for index, (tagName, t_attrs, content) in enumerate(nodes):
+ # if index:
+ # append('\n ')
+ # append('<')
+ # append(tagName)
+ #
+ # self._post_processing_att(tagName, t_attrs, options)
+ # for name, value in t_attrs.items():
+ # if value or isinstance(value, string_types)):
+ # append(u' ')
+ # append(name)
+ # append(u'="')
+ # append(escape(pycompat.to_text((value)))
+ # append(u'"')
+ #
+ # if not content and tagName in self._void_elements:
+ # append('/>')
+ # else:
+ # append('>')
+ # if content:
+ # append(content)
+ # append('</')
+ # append(tagName)
+ # append('>')
+ #
+ space = el.getprevious() is not None and el.getprevious().tail or el.getparent().text
+ sep = u'\n' + space.rsplit('\n').pop()
return [
- self._append(ast.Call(
- func=ast.Attribute(
- value=ast.Name(id='self', ctx=ast.Load()),
- attr='_get_asset',
- ctx=ast.Load()
+ ast.Assign(
+ targets=[ast.Name(id='nodes', ctx=ast.Store())],
+ value=ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='self', ctx=ast.Load()),
+ attr='_get_asset_nodes',
+ ctx=ast.Load()
+ ),
+ args=[
+ ast.Str(el.get('t-call-assets')),
+ ast.Name(id='options', ctx=ast.Load()),
+ ],
+ keywords=[
+ ast.keyword('css', self._get_attr_bool(el.get('t-css', True))),
+ ast.keyword('js', self._get_attr_bool(el.get('t-js', True))),
+ ast.keyword('debug', ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='values', ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
+ ),
+ args=[ast.Str('debug')],
+ keywords=[], starargs=None, kwargs=None
+ )),
+ ast.keyword('async', self._get_attr_bool(el.get('async', False))),
+ ast.keyword('values', ast.Name(id='values', ctx=ast.Load())),
+ ],
+ starargs=None, kwargs=None
+ )
+ ),
+ ast.For(
+ target=ast.Tuple(elts=[
+ ast.Name(id='index', ctx=ast.Store()),
+ ast.Tuple(elts=[
+ ast.Name(id='tagName', ctx=ast.Store()),
+ ast.Name(id='t_attrs', ctx=ast.Store()),
+ ast.Name(id='content', ctx=ast.Store())
+ ], ctx=ast.Store())
+ ], ctx=ast.Store()),
+ iter=ast.Call(
+ func=ast.Name(id='enumerate', ctx=ast.Load()),
+ args=[ast.Name(id='nodes', ctx=ast.Load())],
+ keywords=[],
+ starargs=None, kwargs=None
),
- args=[
- ast.Str(el.get('t-call-assets')),
- ast.Name(id='options', ctx=ast.Load()),
- ],
- keywords=[
- ast.keyword('css', self._get_attr_bool(el.get('t-css', True))),
- ast.keyword('js', self._get_attr_bool(el.get('t-js', True))),
- ast.keyword('debug', ast.Call(
- func=ast.Attribute(
- value=ast.Name(id='values', ctx=ast.Load()),
- attr='get',
- ctx=ast.Load()
+ body=[
+ ast.If(
+ test=ast.Name(id='index', ctx=ast.Load()),
+ body=[self._append(ast.Str(sep))],
+ orelse=[]
+ ),
+ self._append(ast.Str(u'<')),
+ self._append(ast.Name(id='tagName', ctx=ast.Load())),
+ ] + self._append_attributes() + [
+ ast.If(
+ test=ast.BoolOp(
+ op=ast.And(),
+ values=[
+ ast.UnaryOp(ast.Not(), ast.Name(id='content', ctx=ast.Load()), lineno=0, col_offset=0),
+ ast.Compare(
+ left=ast.Name(id='tagName', ctx=ast.Load()),
+ ops=[ast.In()],
+ comparators=[ast.Attribute(
+ value=ast.Name(id='self', ctx=ast.Load()),
+ attr='_void_elements',
+ ctx=ast.Load()
+ )]
+ ),
+ ]
),
- args=[ast.Str('debug')],
- keywords=[], starargs=None, kwargs=None
- )),
- ast.keyword('async', self._get_attr_bool(el.get('async', False))),
- ast.keyword('values', ast.Name(id='values', ctx=ast.Load())),
+ body=[self._append(ast.Str(u'/>'))],
+ orelse=[
+ self._append(ast.Str(u'>')),
+ ast.If(
+ test=ast.Name(id='content', ctx=ast.Load()),
+ body=[self._append(ast.Name(id='content', ctx=ast.Load()))],
+ orelse=[]
+ ),
+ self._append(ast.Str(u'</')),
+ self._append(ast.Name(id='tagName', ctx=ast.Load())),
+ self._append(ast.Str(u'>')),
+ ]
+ )
],
- starargs=None, kwargs=None
- ))
+ orelse=[]
+ )
]
# for backward compatibility to remove after v10
@@ -185,17 +272,31 @@ class IrQWeb(models.AbstractModel, QWeb):
# method called by computing code
+ def get_asset_bundle(self, xmlid, files, remains=None, env=None):
+ return AssetsBundle(xmlid, files, remains=remains, env=env)
+
+ # compatibility to remove after v11 - DEPRECATED
@tools.conditional(
- # in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear
- # by restarting the server after updating the source code (or using the "Clear server cache" in debug tools)
'xml' not in tools.config['dev_mode'],
tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async', keys=("website_id",)),
)
def _get_asset(self, xmlid, options, css=True, js=True, debug=False, async=False, values=None):
files, remains = self._get_asset_content(xmlid, options)
- asset = AssetsBundle(xmlid, files, remains, env=self.env)
+ asset = self.get_asset_bundle(xmlid, files, remains, env=self.env)
return asset.to_html(css=css, js=js, debug=debug, async=async, url_for=(values or {}).get('url_for', lambda url: url))
+ @tools.conditional(
+ # in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear
+ # by restarting the server after updating the source code (or using the "Clear server cache" in debug tools)
+ 'xml' not in tools.config['dev_mode'],
+ tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async', keys=("website_id",)),
+ )
+ def _get_asset_nodes(self, xmlid, options, css=True, js=True, debug=False, async=False, values=None):
+ files, remains = self._get_asset_content(xmlid, options)
+ asset = self.get_asset_bundle(xmlid, files, env=self.env)
+ remains = [node for node in remains if (css and node[0] == 'link') or (js and node[0] != 'link')]
+ return remains + asset.to_node(css=css, js=js, debug=debug, async=async)
+
@tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', keys=("website_id",))
def _get_asset_content(self, xmlid, options):
options = dict(options,
@@ -205,6 +306,9 @@ class IrQWeb(models.AbstractModel, QWeb):
env = self.env(context=options)
+ def can_aggregate(url):
+ return not urls.url_parse(url).scheme and not urls.url_parse(url).netloc and not url.startswith('/web/content')
+
# TODO: This helper can be used by any template that wants to embedd the backend.
# It is currently necessary because the ir.ui.view bundle inheritance does not
# match the module dependency graph.
@@ -218,16 +322,13 @@ class IrQWeb(models.AbstractModel, QWeb):
files = []
remains = []
for el in html.fragments_fromstring(template):
- if isinstance(el, pycompat.string_types):
- remains.append(pycompat.to_text(el))
- elif isinstance(el, html.HtmlElement):
+ if isinstance(el, html.HtmlElement):
href = el.get('href', '')
src = el.get('src', '')
atype = el.get('type')
media = el.get('media')
- can_aggregate = not urls.url_parse(href).netloc and not href.startswith('/web/content')
- if el.tag == 'style' or (el.tag == 'link' and el.get('rel') == 'stylesheet' and can_aggregate):
+ if can_aggregate(href) and (el.tag == 'style' or (el.tag == 'link' and el.get('rel') == 'stylesheet')):
if href.endswith('.sass'):
atype = 'text/sass'
elif href.endswith('.scss'):
@@ -239,25 +340,26 @@ class IrQWeb(models.AbstractModel, QWeb):
path = [segment for segment in href.split('/') if segment]
filename = get_resource_path(*path) if path else None
files.append({'atype': atype, 'url': href, 'filename': filename, 'content': el.text, 'media': media})
- elif el.tag == 'script':
+ elif can_aggregate(src) and el.tag == 'script':
atype = 'text/javascript'
- path = [segment for segment in src.split('/') if segment]
+ path = [segment for segment in href.split('/') if segment]
filename = get_resource_path(*path) if path else None
files.append({'atype': atype, 'url': src, 'filename': filename, 'content': el.text, 'media': media})
else:
- remains.append(html.tostring(el, encoding='unicode'))
+ remains.append((el.tag, OrderedDict(el.attrib), el.text))
else:
- try:
- remains.append(html.tostring(el, encoding='unicode'))
- except Exception:
- # notYETimplementederror
- raise NotImplementedError
+ # the other cases are ignored
+ pass
return (files, remains)
def _get_field(self, record, field_name, expression, tagName, field_options, options, values):
field = record._fields[field_name]
+ # adds template compile options for rendering fields
+ field_options['template_options'] = options
+
+ # adds generic field options
field_options['tagName'] = tagName
field_options['expression'] = expression
field_options['type'] = field_options.get('widget', field.type)
@@ -277,6 +379,9 @@ class IrQWeb(models.AbstractModel, QWeb):
return (attributes, content, inherit_branding or translate)
def _get_widget(self, value, expression, tagName, field_options, options, values):
+ # adds template compile options for rendering fields
+ field_options['template_options'] = options
+
field_options['type'] = field_options['widget']
field_options['tagName'] = tagName
field_options['expression'] = expression
diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py
index 7e870ebef06..1d2a71ea202 100644
--- a/odoo/addons/base/models/ir_qweb_fields.py
+++ b/odoo/addons/base/models/ir_qweb_fields.py
@@ -6,6 +6,8 @@ from io import BytesIO
from odoo import api, fields, models, _
from PIL import Image
import babel
+from lxml import etree
+
from odoo.tools import html_escape as escape, posix_to_ldml, safe_eval, float_utils, format_date, pycompat
import logging
@@ -252,7 +254,17 @@ class HTMLConverter(models.AbstractModel):
@api.model
def value_to_html(self, value, options):
- return pycompat.to_text(value)
+ irQweb = self.env['ir.qweb']
+ # wrap value inside a body and parse it as HTML
+ body = etree.fromstring("<body>%s</body>" % value, etree.HTMLParser(encoding='utf-8'))[0]
+ # use pos processing for all nodes with attributes
+ for element in body.iter():
+ if element.attrib:
+ attrib = OrderedDict(element.attrib)
+ attrib = irQweb._post_processing_att(element.tag, attrib, options.get('template_options'))
+ element.attrib.clear()
+ element.attrib.update(attrib)
+ return etree.tostring(body, encoding='unicode', method='html')[6:-7]
class ImageConverter(models.AbstractModel):
@@ -484,7 +496,7 @@ class Contact(models.AbstractModel):
'object': value,
'options': options
}
- return self.env['ir.qweb'].render('base.contact', val)
+ return self.env['ir.qweb'].render('base.contact', val, **options.get('template_options'))
class QwebView(models.AbstractModel):
diff --git a/odoo/addons/base/models/qweb.py b/odoo/addons/base/models/qweb.py
index 692db1bf0f3..937134a6625 100644
--- a/odoo/addons/base/models/qweb.py
+++ b/odoo/addons/base/models/qweb.py
@@ -5,7 +5,7 @@ import os.path
import re
import traceback
-from collections import OrderedDict, Sized, Mapping, defaultdict
+from collections import OrderedDict, Sized, Mapping
from functools import reduce
from itertools import tee, count
from textwrap import dedent
@@ -695,6 +695,80 @@ class QWeb(object):
ctx=ctx
)
+ def _append_attributes(self):
+ # t_attrs = self._post_processing_att(tagName, t_attrs, options)
+ # for name, value in t_attrs.items():
+ # if value or isinstance(value, string_types)):
+ # append(u' ')
+ # append(name)
+ # append(u'="')
+ # append(escape(pycompat.to_text((value)))
+ # append(u'"')
+ return [
+ ast.Assign(
+ targets=[ast.Name(id='t_attrs', ctx=ast.Store())],
+ value=ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='self', ctx=ast.Load()),
+ attr='_post_processing_att',
+ ctx=ast.Load()
+ ),
+ args=[
+ ast.Name(id='tagName', ctx=ast.Load()),
+ ast.Name(id='t_attrs', ctx=ast.Load()),
+ ast.Name(id='options', ctx=ast.Load()),
+ ], keywords=[],
+ starargs=None, kwargs=None
+ )
+ ),
+ ast.For(
+ target=ast.Tuple(elts=[ast.Name(id='name', ctx=ast.Store()), ast.Name(id='value', ctx=ast.Store())], ctx=ast.Store()),
+ iter=ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='t_attrs', ctx=ast.Load()),
+ attr='items',
+ ctx=ast.Load()
+ ),
+ args=[], keywords=[],
+ starargs=None, kwargs=None
+ ),
+ body=[ast.If(
+ test=ast.BoolOp(
+ op=ast.Or(),
+ values=[
+ ast.Name(id='value', ctx=ast.Load()),
+ ast.Call(
+ func=ast.Name(id='isinstance', ctx=ast.Load()),
+ args=[
+ ast.Name(id='value', ctx=ast.Load()),
+ ast.Name(id='string_types', ctx=ast.Load())
+ ],
+ keywords=[],
+ starargs=None, kwargs=None
+ )
+ ]
+ ),
+ body=[
+ self._append(ast.Str(u' ')),
+ self._append(ast.Name(id='name', ctx=ast.Load())),
+ self._append(ast.Str(u'="')),
+ self._append(ast.Call(
+ func=ast.Name(id='escape', ctx=ast.Load()),
+ args=[ast.Call(
+ func=ast.Name(id='to_text', ctx=ast.Load()),
+ args=[ast.Name(id='value', ctx=ast.Load())], keywords=[],
+ starargs=None, kwargs=None
+ )], keywords=[],
+ starargs=None, kwargs=None
+ )),
+ self._append(ast.Str(u'"')),
+ ],
+ orelse=[]
+ )],
+ orelse=[]
+ )
+ ]
+
# order
def _directives_eval_order(self):
@@ -733,7 +807,7 @@ class QWeb(object):
if not el.nsmap:
unqualified_el_tag = el_tag = el.tag
content = self._compile_directive_content(el, options)
- attrib = el.attrib
+ attrib = self._post_processing_att(el.tag, el.attrib, options)
else:
# Etree will remove the ns prefixes indirection by inlining the corresponding
# nsmap definition into the tag attribute. Restore the tag and prefix here.
@@ -763,6 +837,8 @@ class QWeb(object):
else:
attrib[key] = value
+ attrib = self._post_processing_att(el.tag, attrib, options)
+
# Update the dict of inherited namespaces before continuing the recursion. Note:
# since `options['nsmap']` is a dict (and therefore mutable) and we do **not**
# want changes done in deeper recursion to bevisible in earlier ones, we'll pass
@@ -871,59 +947,12 @@ class QWeb(object):
)))
if attr_already_created:
- # for name, value in t_attrs.items():
- # if value or isinstance(value, basestring)):
- # append(u' ')
- # append(name)
- # append(u'="')
- # append(escape(to_text((value)))
- # append(u'"')
- body.append(ast.For(
- target=ast.Tuple(elts=[ast.Name(id='name', ctx=ast.Store()), ast.Name(id='value', ctx=ast.Store())], ctx=ast.Store()),
- iter=ast.Call(
- func=ast.Attribute(
- value=ast.Name(id='t_attrs', ctx=ast.Load()),
- attr='items',
- ctx=ast.Load()
- ),
- args=[], keywords=[],
- starargs=None, kwargs=None
- ),
- body=[ast.If(
- test=ast.BoolOp(
- op=ast.Or(),
- values=[
- ast.Name(id='value', ctx=ast.Load()),
- ast.Call(
- func=ast.Name(id='isinstance', ctx=ast.Load()),
- args=[
- ast.Name(id='value', ctx=ast.Load()),
- ast.Name(id='string_types', ctx=ast.Load())
- ],
- keywords=[],
- starargs=None, kwargs=None
- )
- ]
- ),
- body=[
- self._append(ast.Str(u' ')),
- self._append(ast.Name(id='name', ctx=ast.Load())),
- self._append(ast.Str(u'="')),
- self._append(ast.Call(
- func=ast.Name(id='escape', ctx=ast.Load()),
- args=[ast.Call(
- func=ast.Name(id='to_text', ctx=ast.Load()),
- args=[ast.Name(id='value', ctx=ast.Load())], keywords=[],
- starargs=None, kwargs=None
- )], keywords=[],
- starargs=None, kwargs=None
- )),
- self._append(ast.Str(u'"')),
- ],
- orelse=[]
- )],
- orelse=[]
- ))
+ # tagName = $el.tag
+ body.append(ast.Assign(
+ targets=[ast.Name(id='tagName', ctx=ast.Store())],
+ value=ast.Str(el.tag))
+ )
+ body.extend(self._append_attributes())
return body
@@ -1512,6 +1541,14 @@ class QWeb(object):
atts = OrderedDict(atts)
return atts
+ def _post_processing_att(self, tagName, atts, options):
+ """ Method called by the compiled code. This method may be overwrited
+ to filter or modify the attributes after they are compiled.
+
+ @returns OrderedDict
+ """
+ return atts
+
def _get_field(self, record, field_name, expression, tagName, field_options, options, values):
"""
:returns: tuple:
diff --git a/odoo/addons/base/static/src/js/res_config_settings.js b/odoo/addons/base/static/src/js/res_config_settings.js
index caae87cc183..69a54ff2df6 100644
--- a/odoo/addons/base/static/src/js/res_config_settings.js
+++ b/odoo/addons/base/static/src/js/res_config_settings.js
@@ -257,9 +257,7 @@ var BaseSettingRenderer = FormRenderer.extend({
_render: function () {
var res = this._super.apply(this, arguments);
- if (!this.modules) {
- this._initModules();
- }
+ this._initModules();
this._renderLeftPanel();
this._initSearch();
if (config.device.isMobile) {
diff --git a/odoo/addons/base/static/tests/base_settings_tests.js b/odoo/addons/base/static/tests/base_settings_tests.js
index e4e5dc0527d..2507a46c7c7 100644
--- a/odoo/addons/base/static/tests/base_settings_tests.js
+++ b/odoo/addons/base/static/tests/base_settings_tests.js
@@ -139,5 +139,44 @@ QUnit.module('base_settings_tests', {
actionManager.destroy();
});
+
+ QUnit.test('settings view does not display other settings after reload', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: BaseSettingsView,
+ model: 'project',
+ data: this.data,
+ arch: '<form string="Settings" class="oe_form_configuration o_base_settings">' +
+ '<div class="o_panel">' +
+ '<div class="setting_search">' +
+ '<input type="text" class="searchInput" placeholder="Search..."/>' +
+ '</div> ' +
+ '</div> ' +
+ '<header>' +
+ '<button string="Save" type="object" name="execute" class="oe_highlight" />' +
+ '<button string="Cancel" type="object" name="cancel" class="oe_link" />' +
+ '</header>' +
+ '<div class="o_setting_container">' +
+ '<div class="settings_tab"/>'+
+ '<div class="settings">' +
+ '<div class="notFound o_hidden">No Record Found</div>' +
+ '<div class="app_settings_block" string="CRM" data-key="crm">' +
+ 'crm tab' +
+ '</div>' +
+ '<div class="app_settings_block o_not_app" string="Other App" data-key="otherapp">' +
+ 'other app tab' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('.app_settings_block').text().replace(/\s/g,''), 'CRMcrmtab');
+ form.reload();
+ assert.strictEqual(form.$('.app_settings_block').text().replace(/\s/g,''), 'CRMcrmtab');
+ form.destroy();
+ });
+
});
});
diff --git a/odoo/addons/base/wizard/base_partner_merge.py b/odoo/addons/base/wizard/base_partner_merge.py
index b987e469c5d..47c9f83fe51 100644
--- a/odoo/addons/base/wizard/base_partner_merge.py
+++ b/odoo/addons/base/wizard/base_partner_merge.py
@@ -249,11 +249,16 @@ class MergePartnerAutomatic(models.TransientModel):
except ValidationError:
_logger.info('Skip recursive partner hierarchies for parent_id %s of partner: %s', parent_id, dst_partner.id)
- def _merge(self, partner_ids, dst_partner=None):
+ def _merge(self, partner_ids, dst_partner=None, extra_checks=True):
""" private implementation of merge partner
:param partner_ids : ids of partner to merge
:param dst_partner : record of destination res.partner
+ :param extra_checks: pass False to bypass extra sanity check (e.g. email address)
"""
+ # super-admin can be used to bypass extra checks
+ if self.env.uid == SUPERUSER_ID:
+ extra_checks = False
+
Partner = self.env['res.partner']
partner_ids = Partner.browse(partner_ids).exists()
if len(partner_ids) < 2:
@@ -269,8 +274,7 @@ class MergePartnerAutomatic(models.TransientModel):
if partner_ids & child_ids:
raise UserError(_("You cannot merge a contact with one of his parent."))
- # check only admin can merge partners with different emails
- if SUPERUSER_ID != self.env.uid and len(set(partner.email for partner in partner_ids)) > 1:
+ if extra_checks and len(set(partner.email for partner in partner_ids)) > 1:
raise UserError(_("All contacts must have the same email. Only the Administrator can merge contacts with different emails."))
# remove dst_partner from partners to merge
@@ -283,9 +287,14 @@ class MergePartnerAutomatic(models.TransientModel):
_logger.info("dst_partner: %s", dst_partner.id)
# FIXME: is it still required to make and exception for account.move.line since accounting v9.0 ?
- if SUPERUSER_ID != self.env.uid and 'account.move.line' in self.env and self.env['account.move.line'].sudo().search([('partner_id', 'in', [partner.id for partner in src_partners])]):
+ if extra_checks and 'account.move.line' in self.env and self.env['account.move.line'].sudo().search([('partner_id', 'in', [partner.id for partner in src_partners])]):
raise UserError(_("Only the destination contact may be linked to existing Journal Items. Please ask the Administrator if you need to merge several contacts linked to existing Journal Items."))
+ # Make the company of all related users consistent
+ for user in partner_ids.mapped('user_ids'):
+ user.sudo().write({'company_ids': [(6, 0, [dst_partner.company_id.id])],
+ 'company_id': dst_partner.company_id.id})
+
# call sub methods to do the merge
self._update_foreign_keys(src_partners, dst_partner)
self._update_reference_fields(src_partners, dst_partner)
diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
index fef390b6356..3f4e75b7b69 100644
--- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
+++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
@@ -38,7 +38,7 @@ class TestJavascriptAssetsBundle(TransactionCase):
def _get_asset(self, xmlid, env=None):
env = (env or self.env)
files, remains = env['ir.qweb']._get_asset_content(xmlid, env.context)
- return AssetsBundle(xmlid, files, remains, env=env)
+ return AssetsBundle(xmlid, files, env=env)
def _any_ira_for_bundle(self, type):
""" Returns all ir.attachments associated to a bundle, regardless of the verion.
@@ -118,7 +118,6 @@ class TestJavascriptAssetsBundle(TransactionCase):
bundle0 = self._get_asset(self.jsbundle_xmlid)
bundle0.js()
files0 = bundle0.files
- remains0 = bundle0.remains
version0 = bundle0.version
self.assertEquals(len(self._any_ira_for_bundle('js')), 1)
@@ -141,11 +140,9 @@ class TestJavascriptAssetsBundle(TransactionCase):
bundle1 = self._get_asset(self.jsbundle_xmlid, env=self.env(context={'check_view_ids': view.ids}))
bundle1.js()
files1 = bundle1.files
- remains1 = bundle1.remains
version1 = bundle1.version
self.assertNotEquals(files0, files1)
- self.assertEquals(remains0, remains1)
self.assertNotEquals(version0, version1)
# check if the previous attachment are correctly cleaned
@@ -247,7 +244,6 @@ class TestJavascriptAssetsBundle(TransactionCase):
bundle0 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
bundle0.css()
files0 = bundle0.files
- remains0 = bundle0.remains
version0 = bundle0.version
self.assertEquals(len(self._any_ira_for_bundle('css')), 3)
@@ -270,11 +266,9 @@ class TestJavascriptAssetsBundle(TransactionCase):
bundle1 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'check_view_ids': view.ids, 'max_css_rules': 1}))
bundle1.css()
files1 = bundle1.files
- remains1 = bundle1.remains
version1 = bundle1.version
self.assertNotEquals(files0, files1)
- self.assertEquals(remains0, remains1)
self.assertNotEquals(version0, version1)
# check if the previous attachment are correctly cleaned
@@ -318,6 +312,48 @@ class TestJavascriptAssetsBundle(TransactionCase):
content = bundle0.to_html()
self.assertEqual(content.count('test_assetsbundle.bundle2.0.css'), 1)
+ def test_15_exteral_lib_assets(self):
+ html = self.env['ir.ui.view'].render_template('test_assetsbundle.template2')
+ attachments = self.env['ir.attachment'].search([('url', '=like', '/web/content/%-%/test_assetsbundle.bundle4.%')])
+ self.assertEquals(len(attachments), 2)
+ self.assertEqual(html.strip(), ("""<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="stylesheet" href="http://test.external.link/style1.css"/>
+ <link rel="stylesheet" href="http://test.external.link/style2.css"/>
+ <link type="text/css" rel="stylesheet" href="%(css)s"/>
+ <meta/>
+ <script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
+ <script type="text/javascript" src="%(js)s"></script>
+ </head>
+ <body>
+ </body>
+</html>""" % {"js": attachments[0].url, "css": attachments[1].url}).encode('utf8'))
+
+ def test_16_exteral_lib_assets_debug_mode(self):
+ html = self.env['ir.ui.view'].render_template('test_assetsbundle.template2', {"debug": "assets"})
+ attachments = self.env['ir.attachment'].search([('url', '=like', '/web/content/%-%/test_assetsbundle.bundle4.%')])
+ self.assertEquals(len(attachments), 0)
+ self.assertEqual(html.strip(), ("""<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="stylesheet" href="http://test.external.link/style1.css"/>
+ <link rel="stylesheet" href="http://test.external.link/style2.css"/>
+ <link type="text/css" rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile1.css"/>
+ <link type="text/css" rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile2.css"/>
+ <meta/>
+ <script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
+ <script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile1.js"></script>
+ <script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile2.js"></script>
+ <script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile3.js"></script>
+ </head>
+ <body>
+ </body>
+</html>""").encode('utf8'))
+
+
class TestAssetsBundleInBrowser(HttpCase):
def test_01_js_interpretation(self):
diff --git a/odoo/addons/test_assetsbundle/views/views.xml b/odoo/addons/test_assetsbundle/views/views.xml
index f98652a5b38..5302ee6080f 100644
--- a/odoo/addons/test_assetsbundle/views/views.xml
+++ b/odoo/addons/test_assetsbundle/views/views.xml
@@ -23,4 +23,33 @@
<template id="test_assetsbundle.bundle3">
<link rel="stylesheet" type="text/scss" href="/test_assetsbundle/static/src/scss/test_file1.scss"/>
</template>
+
+ <template id="test_assetsbundle.bundle4">
+ <script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile1.js"></script>
+ <script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile2.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript1.js"></script>
+
+ <link rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile1.css"/>
+ <link rel="stylesheet" href="http://test.external.link/style1.css"/>
+ <link rel="stylesheet" href="/test_assetsbundle/static/src/css/test_cssfile2.css"/>
+
+ <script type="text/javascript" src="/test_assetsbundle/static/src/js/test_jsfile3.js"></script>
+ <script type="text/javascript" src="http://test.external.link/javascript2.js"></script>
+
+ <link rel="stylesheet" href="http://test.external.link/style2.css"/>
+ </template>
+
+ <template id="test_assetsbundle.template2" name="test template 2">
+&lt;!DOCTYPE html&gt;
+<html>
+ <head>
+ <t t-call-assets="test_assetsbundle.bundle4" t-js="False"/>
+ <meta/>
+ <t t-call-assets="test_assetsbundle.bundle4" t-css="False"/>
+ </head>
+ <body>
+ </body>
+</html>
+ </template>
+
</odoo>