summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2017-10-21 15:22:36 +0200
committerChristophe Simonis <chs@odoo.com>2017-10-21 15:22:36 +0200
commitde987831da237ed5f9c96d50a9e51d083e072aa0 (patch)
treeef714d2d98693eae3446620d28bb48314573018a
parentea2a976f31b2611b6b4ff69f4a5cdc2ffc7798fe (diff)
parentd70518d04f8f8061c8c683ea4ba8c95ae631ae45 (diff)
[MERGE] forward port branch 11.0 up to d70518d04f8
-rw-r--r--addons/account/data/mail_template_data.xml8
-rw-r--r--addons/account/models/account.py169
-rw-r--r--addons/account/models/account_bank_statement.py5
-rw-r--r--addons/account/models/account_invoice.py3
-rw-r--r--addons/account/models/account_journal_dashboard.py2
-rw-r--r--addons/account/models/account_move.py54
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_action.js32
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_model.js107
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_renderer.js23
-rw-r--r--addons/account/static/src/xml/account_reconciliation.xml6
-rw-r--r--addons/account/static/tests/reconciliation_tests.js7
-rw-r--r--addons/account/tests/test_tax.py234
-rw-r--r--addons/account/views/account_view.xml4
-rw-r--r--addons/account_asset/models/account_invoice.py10
-rw-r--r--addons/auth_oauth/controllers/main.py7
-rw-r--r--addons/base_import/models/base_import.py17
-rw-r--r--addons/base_import/tests/test_base_import.py26
-rw-r--r--addons/base_vat/models/res_partner.py20
-rw-r--r--addons/base_vat_autocomplete/models/res_partner.py15
-rw-r--r--addons/calendar/models/calendar.py24
-rw-r--r--addons/crm/controllers/main.py10
-rw-r--r--addons/crm/models/crm_lead.py8
-rw-r--r--addons/crm/report/crm_opportunity_report_views.xml7
-rw-r--r--addons/crm/static/description/index.html2
-rw-r--r--addons/crm/views/crm_lead_views.xml6
-rw-r--r--addons/document/static/src/js/document.js6
-rw-r--r--addons/hr_expense/models/hr_expense.py11
-rw-r--r--addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py1
-rw-r--r--addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml1
-rw-r--r--addons/hw_proxy/controllers/main.py6
-rw-r--r--addons/iap/models/iap.py7
-rw-r--r--addons/mail/data/mail_data.xml3
-rw-r--r--addons/mail/data/mail_shortcode_data.xml4
-rw-r--r--addons/mail/models/mail_channel.py2
-rw-r--r--addons/mail/models/mail_thread.py17
-rw-r--r--addons/mail/models/res_partner.py7
-rw-r--r--addons/mail/wizard/email_template_preview_view.xml3
-rw-r--r--addons/mass_mailing/controllers/main.py4
-rw-r--r--addons/mass_mailing/models/mass_mailing.py12
-rw-r--r--addons/mass_mailing/static/src/js/mass_mailing_editor.js11
-rw-r--r--addons/mass_mailing/views/mass_mailing_views.xml2
-rw-r--r--addons/membership/models/account_invoice.py8
-rw-r--r--addons/membership/models/partner.py4
-rw-r--r--addons/mrp/i18n/mrp.pot42
-rw-r--r--addons/mrp/models/mrp_bom.py5
-rw-r--r--addons/mrp/models/mrp_production.py2
-rw-r--r--addons/mrp/views/mrp_bom_views.xml17
-rw-r--r--addons/mrp/views/mrp_production_views.xml2
-rw-r--r--addons/mrp/views/mrp_workorder_views.xml6
-rw-r--r--addons/mrp/views/stock_move_views.xml22
-rw-r--r--addons/mrp/wizard/change_production_qty.py2
-rw-r--r--addons/mrp_byproduct/models/mrp_production.py38
-rw-r--r--addons/payment_stripe/controllers/main.py13
-rw-r--r--addons/payment_stripe/models/payment.py41
-rw-r--r--addons/payment_stripe/static/src/js/stripe.js201
-rw-r--r--addons/point_of_sale/models/pos_order.py4
-rw-r--r--addons/point_of_sale/static/src/js/models.js155
-rw-r--r--addons/portal/static/src/less/portal_frontend.less4
-rw-r--r--addons/portal/views/portal_templates.xml2
-rw-r--r--addons/product/models/product.py3
-rw-r--r--addons/purchase/models/stock.py15
-rw-r--r--addons/purchase/tests/__init__.py1
-rw-r--r--addons/purchase/tests/test_stockvaluation.py200
-rw-r--r--addons/resource/models/resource.py2
-rw-r--r--addons/sale/__init__.py4
-rw-r--r--addons/sale/data/mail_template_data.xml8
-rw-r--r--addons/sale/models/sale.py12
-rw-r--r--addons/sale/tests/test_sale_order.py8
-rw-r--r--addons/sale/views/sale_views.xml6
-rw-r--r--addons/sale_stock/models/account_invoice.py2
-rw-r--r--addons/sale_stock/models/sale_order.py22
-rw-r--r--addons/sale_stock/models/stock.py13
-rw-r--r--addons/sale_stock/tests/test_sale_stock.py4
-rw-r--r--addons/sale_timesheet/__manifest__.py1
-rw-r--r--addons/sale_timesheet/controllers/main.py4
-rw-r--r--addons/sale_timesheet/models/product.py9
-rw-r--r--addons/sale_timesheet/models/sale_order.py8
-rw-r--r--addons/sale_timesheet/security/sale_timesheet_security.xml19
-rw-r--r--addons/sale_timesheet/tests/test_sale_service.py4
-rw-r--r--addons/sms/wizard/send_sms.py2
-rw-r--r--addons/stock/models/product.py2
-rw-r--r--addons/stock/models/stock_move.py26
-rw-r--r--addons/stock/models/stock_picking.py4
-rw-r--r--addons/stock_account/models/stock.py2
-rw-r--r--addons/stock_landed_costs/__manifest__.py8
-rw-r--r--addons/stock_landed_costs/models/stock_landed_cost.py17
-rw-r--r--addons/stock_landed_costs/test/stock_landed_costs.yml22
-rw-r--r--addons/web/controllers/main.py36
-rw-r--r--addons/web/static/src/js/core/ajax.js11
-rw-r--r--addons/web/static/src/js/core/misc.js17
-rw-r--r--addons/web/static/src/js/core/session.js2
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js34
-rw-r--r--addons/web/static/src/js/report/qwebactionmanager.js7
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js36
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js21
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js8
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js14
-rw-r--r--addons/web/static/src/less/modal.less2
-rw-r--r--addons/web/static/src/xml/base.xml8
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js106
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js168
-rw-r--r--addons/web/static/tests/helpers/mock_server.js8
-rw-r--r--addons/web/static/tests/views/calendar_tests.js14
-rw-r--r--addons/web/static/tests/views/form_tests.js38
-rw-r--r--addons/web/views/webclient_templates.xml2
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js12
-rw-r--r--addons/web_editor/static/src/js/backend/fields.js2
-rw-r--r--addons/web_editor/static/src/js/editor/rte.js22
-rw-r--r--addons/web_editor/static/src/js/editor/rte.summernote.js4
-rw-r--r--addons/web_editor/static/src/less/web_editor.common.less4
-rw-r--r--addons/website/models/ir_http.py7
-rw-r--r--addons/website/static/src/js/content/website_root.js11
-rw-r--r--addons/website/static/src/less/website.backendDashboard.less5
-rw-r--r--addons/website/views/res_config_settings_views.xml2
-rw-r--r--addons/website/views/website_templates.xml6
-rw-r--r--addons/website_forum/controllers/main.py2
-rw-r--r--addons/website_forum/i18n/website_forum.pot6
-rw-r--r--addons/website_forum/models/forum.py6
-rw-r--r--addons/website_forum/views/forum.xml1
-rw-r--r--addons/website_quote/models/__init__.py1
-rw-r--r--addons/website_quote/models/ir_model_fields.py35
-rw-r--r--addons/website_sale/controllers/main.py5
-rw-r--r--addons/website_sale/models/__init__.py1
-rw-r--r--addons/website_sale/models/ir_model_fields.py35
-rw-r--r--addons/website_sale/models/product.py6
-rw-r--r--addons/website_sale/static/src/css/website_sale.css5
-rw-r--r--addons/website_sale/static/src/css/website_sale.sass4
-rw-r--r--addons/website_sale/static/src/js/website_sale_payment.js6
-rw-r--r--addons/website_sale/views/templates.xml6
-rw-r--r--addons/website_sale_stock/models/sale_order.py3
-rw-r--r--addons/website_sale_stock_options/views/website_sale_stock_templates.xml2
-rw-r--r--addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js9
-rw-r--r--addons/website_slides/models/slides.py2
-rw-r--r--addons/website_slides/static/src/js/slides_upload.js2
-rw-r--r--addons/website_theme_install/views/views.xml1
-rw-r--r--doc/cla/corporate/acsone.md1
-rw-r--r--doc/cla/corporate/savoirfairelinux.md17
-rw-r--r--doc/cla/individual/felixonmars.md11
-rw-r--r--odoo/addons/base/ir/ir_actions.py9
-rw-r--r--odoo/addons/base/ir/ir_http.py2
-rw-r--r--odoo/addons/base/ir/ir_sequence.py6
-rw-r--r--odoo/addons/base/module/wizard/base_import_language.py2
-rw-r--r--odoo/addons/base/static/src/css/description.css39
-rw-r--r--odoo/addons/base/static/src/css/description.sass36
-rw-r--r--odoo/addons/base/static/src/js/res_config_settings.js2
-rw-r--r--odoo/addons/test_new_api/tests/test_new_fields.py24
-rw-r--r--odoo/addons/test_pylint/tests/test_pylint.py2
-rw-r--r--odoo/loglevels.py2
-rw-r--r--odoo/tools/translate.py7
-rw-r--r--requirements.txt2
-rwxr-xr-xsetup/win32/setup.nsi20
151 files changed, 2215 insertions, 611 deletions
diff --git a/addons/account/data/mail_template_data.xml b/addons/account/data/mail_template_data.xml
index 6507259b89c..d0365929333 100644
--- a/addons/account/data/mail_template_data.xml
+++ b/addons/account/data/mail_template_data.xml
@@ -72,12 +72,12 @@ from ${object.company_id.name}.
<div>
% set record = ctx.get('record')
% set company = record and record.company_id or user.company_id
-<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed" summary="o_mail_notification">
+<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed; border-collapse:separate;" summary="o_mail_notification">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
- <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
+ <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle">
<span style="font-size:20px; color:white; font-weight: bold;">
@@ -94,7 +94,7 @@ from ${object.company_id.name}.
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
- <table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px;">
+ <table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px; border-collapse:separate;">
<tbody>
<td valign="top" style="font-family:Arial,Helvetica,sans-serif; color: #555; font-size: 14px;">
${object.body | safe}
@@ -106,7 +106,7 @@ from ${object.company_id.name}.
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
- <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
+ <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle" align="left" style="color: #fff; padding-top: 10px; padding-bottom: 10px; font-size: 12px;">
${company.name}<br/>
diff --git a/addons/account/models/account.py b/addons/account/models/account.py
index e1b20487b71..7d1344f7555 100644
--- a/addons/account/models/account.py
+++ b/addons/account/models/account.py
@@ -848,6 +848,7 @@ class AccountTax(models.Model):
price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include)
"""
self.ensure_one()
+ price_include = self._context.get('force_price_include', self.price_include)
if self.amount_type == 'fixed':
# Use copysign to take into account the sign of the base amount which includes the sign
# of the quantity and the sign of the price_unit
@@ -861,11 +862,11 @@ class AccountTax(models.Model):
return math.copysign(quantity, base_amount) * self.amount
else:
return quantity * self.amount
- if (self.amount_type == 'percent' and not self.price_include) or (self.amount_type == 'division' and self.price_include):
+ if (self.amount_type == 'percent' and not price_include) or (self.amount_type == 'division' and self.price_include):
return base_amount * self.amount / 100
- if self.amount_type == 'percent' and self.price_include:
+ if self.amount_type == 'percent' and price_include:
return base_amount - (base_amount / (1 + self.amount / 100))
- if self.amount_type == 'division' and not self.price_include:
+ if self.amount_type == 'division' and not price_include:
return base_amount / (1 - self.amount / 100) - base_amount
@api.multi
@@ -899,13 +900,58 @@ class AccountTax(models.Model):
'analytic': boolean,
}]
} """
+
+ # 1) Flatten the taxes.
+
+ def collect_taxes(self, all_taxes=None):
+ # Collect all the taxes recursively ordered by the sequence.
+ # Example:
+ # group | seq | sub-group |
+ # ------------|-----------|
+ # | 1 | |
+ # ------------|-----------|
+ # t | 2 | | seq | |
+ # | | | 4 | |
+ # | | | 5 | |
+ # | | | 6 | |
+ # | | |
+ # ------------|-----------|
+ # | 3 | |
+ # ------------|-----------|
+ # Result: 1-4-5-6-3
+ if not all_taxes:
+ all_taxes = self.env['account.tax']
+ for tax in self.sorted(key=lambda r: r.sequence):
+ if tax.amount_type == 'group':
+ all_taxes = collect_taxes(tax.children_tax_ids, all_taxes)
+ else:
+ all_taxes += tax
+ return all_taxes
+
+ taxes = collect_taxes(self)
+
+ # 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True
+ # with price_include=True
+
+ base_excluded_flag = False # price_include=False && include_base_amount=True
+ included_flag = False # price_include=True
+ for tax in taxes:
+ if tax.price_include:
+ included_flag = True
+ elif tax.include_base_amount:
+ base_excluded_flag = True
+ if base_excluded_flag and included_flag:
+ raise UserError(_('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.'))
+
+ # 3) Deal with the rounding methods
+
if len(self) == 0:
company_id = self.env.user.company_id
else:
company_id = self[0].company_id
if not currency:
currency = company_id.currency_id
- taxes = []
+
# By default, for each tax, tax amount will first be computed
# and rounded at the 'Account' decimal precision for each
# PO/SO/invoice line and then these rounded amounts will be
@@ -930,50 +976,93 @@ class AccountTax(models.Model):
if not round_tax:
prec += 5
- base_values = self.env.context.get('base_values')
- if not base_values:
- total_excluded = total_included = base = round(price_unit * quantity, prec)
+ # 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation.
+ # tax | base | amount |
+ # /\ ----------------------------
+ # || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded
+ # || tax_2 | | |
+ # || tax_3 | | |
+ # || ... | .. | .. |
+ # ----------------------------
+
+ def recompute_base(base_amount, fixed_amount, percent_amount):
+ # Recompute the new base amount based on included fixed/percent amount and the current base amount.
+ # Example:
+ # tax | amount |
+ # ------------------
+ # tax_1 | 10% |
+ # tax_2 | 15 |
+ # tax_3 | 20% |
+ # ------------------
+ # if base_amount = 145, the new base is computed as:
+ # (145 - 15) / (1.0 + ((10 + 20) / 100.0)) = 130 / 1.3 = 100
+ if fixed_amount == 0.0 and percent_amount == 0.0:
+ return base_amount
+ return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0)
+
+ base = round(price_unit * quantity, prec)
+
+ # For the computation of move lines, we could have a negative base value.
+ # In this case, compute all with positive values and negative them at the end.
+ if base < 0:
+ base = -base
+ sign = -1
else:
- total_excluded, total_included, base = base_values
-
- # Sorting key is mandatory in this case. When no key is provided, sorted() will perform a
- # search. However, the search method is overridden in account.tax in order to add a domain
- # depending on the context. This domain might filter out some taxes from self, e.g. in the
- # case of group taxes.
- for tax in self.sorted(key=lambda r: r.sequence):
- if tax.amount_type == 'group':
- children = tax.children_tax_ids.with_context(base_values=(total_excluded, total_included, base))
- ret = children.compute_all(price_unit, currency, quantity, product, partner)
- total_excluded = ret['total_excluded']
- base = ret['base'] if tax.include_base_amount else base
- total_included = ret['total_included']
- tax_amount = total_included - total_excluded
- taxes += ret['taxes']
- continue
-
- tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner)
+ sign = 1
+
+ # Keep track of the accumulated included fixed/percent amount.
+ incl_fixed_amount = incl_percent_amount = 0
+ for tax in reversed(taxes):
+ if tax.include_base_amount:
+ base = recompute_base(base, incl_fixed_amount, incl_percent_amount)
+ incl_fixed_amount = incl_percent_amount = 0
+ if tax.price_include:
+ if tax.amount_type == 'fixed':
+ incl_fixed_amount += tax.amount
+ elif tax.amount_type == 'percent':
+ incl_percent_amount += tax.amount
+ # Start the computation of accumulated amounts at the total_excluded value.
+ total_excluded = total_included = base = recompute_base(base, incl_fixed_amount, incl_percent_amount)
+
+ # 5) Iterate the taxes in the sequence order to fill missing base/amount values.
+ # tax | base | amount |
+ # || ----------------------------
+ # || tax_1 | OK | XXXX |
+ # || tax_2 | XXXX | XXXX |
+ # || tax_3 | XXXX | XXXX |
+ # \/ ... | .. | .. |
+ # ----------------------------
+ taxes_vals = []
+ for tax in taxes:
+ # Compute the amount of the tax but don't deal with the price_include because it's already
+ # took into account on the base amount except for 'division' tax:
+ # (tax.amount_type == 'percent' && not tax.price_include)
+ # == (tax.amount_type == 'division' && tax.price_include)
+ tax_amount = tax.with_context(force_price_include=False)._compute_amount(base, price_unit, quantity, product, partner)
if not round_tax:
tax_amount = round(tax_amount, prec)
else:
tax_amount = currency.round(tax_amount)
- if tax.price_include:
- total_excluded -= tax_amount
- base -= tax_amount
- else:
- total_included += tax_amount
-
- # Keep base amount used for the current tax
+ # Suppose:
+ # seq | amount | incl | incl_base | base | amount
+ # -----------------------------------------------
+ # 1 | 10 % | t | t | 100.0 | 10.0
+ # -----------------------------------------------
+ # ... the next computation must be done using 100.0 + 10.0 = 110.0 as base but
+ # the tax base of this tax will be 100.0.
tax_base = base
-
if tax.include_base_amount:
base += tax_amount
- taxes.append({
+ # The total_included amount is computed as the sum of total_excluded with all tax_amount
+ total_included += tax_amount
+
+ taxes_vals.append({
'id': tax.id,
'name': tax.with_context(**{'lang': partner.lang} if partner else {}).name,
- 'amount': tax_amount,
- 'base': tax_base,
+ 'amount': sign * tax_amount,
+ 'base': round(sign * tax_base, prec),
'sequence': tax.sequence,
'account_id': tax.account_id.id,
'refund_account_id': tax.refund_account_id.id,
@@ -981,10 +1070,10 @@ class AccountTax(models.Model):
})
return {
- 'taxes': sorted(taxes, key=lambda k: k['sequence']),
- 'total_excluded': currency.round(total_excluded) if round_total else total_excluded,
- 'total_included': currency.round(total_included) if round_total else total_included,
- 'base': base,
+ 'taxes': taxes_vals,
+ 'total_excluded': sign * (currency.round(total_excluded) if round_total else total_excluded),
+ 'total_included': sign * (currency.round(total_included) if round_total else total_included),
+ 'base': round(sign * base, prec),
}
@api.model
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index 715941ce02f..964b000cfc0 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -500,10 +500,10 @@ class AccountBankStatementLine(models.Model):
if self.amount_currency and self.currency_id:
amount = self.amount_currency
amount_currency = self.amount
- amount_currency_str = amount_currency > 0 and amount_currency or -amount_currency
- amount_currency_str = formatLang(self.env, amount_currency_str, currency_obj=statement_currency)
+ amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=statement_currency)
else:
amount = self.amount
+ amount_currency = amount
amount_currency_str = ""
amount_str = formatLang(self.env, abs(amount), currency_obj=self.currency_id or statement_currency)
@@ -525,6 +525,7 @@ class AccountBankStatementLine(models.Model):
'partner_name': self.partner_id.name,
'communication_partner_name': self.partner_name,
'amount_currency_str': amount_currency_str, # Amount in the statement currency
+ 'amount_currency': amount_currency, # Amount in the statement currency
'has_no_partner': not self.partner_id.id,
}
if self.partner_id:
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index 1fae487036a..ffc572a2cab 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -1588,6 +1588,9 @@ class AccountInvoiceLine(models.Model):
self.price_unit = 0.0
domain['uom_id'] = []
else:
+ # Use the purchase uom by default
+ self.uom_id = self.product_id.uom_po_id
+
if part.lang:
product = self.product_id.with_context(lang=part.lang)
else:
diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py
index 83b8ec1f824..66c8bb9f4a4 100644
--- a/addons/account/models/account_journal_dashboard.py
+++ b/addons/account/models/account_journal_dashboard.py
@@ -175,7 +175,7 @@ class account_journal(models.Model):
# optimization to read sum of balance from account_move_line
account_ids = tuple(ac for ac in [self.default_debit_account_id.id, self.default_credit_account_id.id] if ac)
if account_ids:
- amount_field = 'balance' if not self.currency_id else 'amount_currency'
+ amount_field = 'balance' if (not self.currency_id or self.currency_id == self.company_id.currency_id) else 'amount_currency'
query = """SELECT sum(%s) FROM account_move_line WHERE account_id in %%s AND date <= %%s;""" % (amount_field,)
self.env.cr.execute(query, (account_ids, fields.Date.today(),))
query_results = self.env.cr.dictfetchall()
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index ef60c1897a7..6a72ba478ea 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -698,19 +698,36 @@ class AccountMoveLine(models.Model):
'|', ('date_maturity', 'like', str),
'&', ('name', '!=', '/'), ('name', 'ilike', str)
]
- try:
- amount = float(str)
- amount_domain = [
- '|', ('amount_residual', '=', amount),
- '|', ('amount_residual_currency', '=', amount),
- '|', ('amount_residual', '=', -amount),
- '|', ('amount_residual_currency', '=', -amount),
- '&', ('account_id.internal_type', '=', 'liquidity'),
- '|', '|', ('debit', '=', amount), ('credit', '=', amount), ('amount_currency', '=', amount),
- ]
- str_domain = expression.OR([str_domain, amount_domain])
- except:
- pass
+ if str[0] in ['-', '+']:
+ try:
+ amounts_str = str.split('|')
+ for amount_str in amounts_str:
+ amount = amount_str[0] == '-' and float(amount_str) or float(amount_str[1:])
+ amount_domain = [
+ '|', ('amount_residual', '=', amount),
+ '|', ('amount_residual_currency', '=', amount),
+ '|', (amount_str[0] == '-' and 'credit' or 'debit', '=', float(amount_str[1:])),
+ ('amount_currency', '=', amount),
+ ]
+ str_domain = expression.OR([str_domain, amount_domain])
+ except:
+ pass
+ else:
+ try:
+ amount = float(str)
+ amount_domain = [
+ '|', ('amount_residual', '=', amount),
+ '|', ('amount_residual_currency', '=', amount),
+ '|', ('amount_residual', '=', -amount),
+ '|', ('amount_residual_currency', '=', -amount),
+ '&', ('account_id.internal_type', '=', 'liquidity'),
+ '|', '|', '|', ('debit', '=', amount), ('credit', '=', amount),
+ ('amount_currency', '=', amount),
+ ('amount_currency', '=', -amount),
+ ]
+ str_domain = expression.OR([str_domain, amount_domain])
+ except:
+ pass
return str_domain
def _domain_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, str=False):
@@ -753,6 +770,7 @@ class AccountMoveLine(models.Model):
for line in self:
company_currency = line.account_id.company_id.currency_id
+ line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency
ret_line = {
'id': line.id,
'name': line.name and line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name,
@@ -769,7 +787,7 @@ class AccountMoveLine(models.Model):
'journal_id': [line.journal_id.id, line.journal_id.display_name],
'partner_id': line.partner_id.id,
'partner_name': line.partner_id.name,
- 'currency_id': (line.currency_id and line.amount_currency) and line.currency_id.id or False,
+ 'currency_id': line_currency.id,
}
debit = line.debit
@@ -784,7 +802,7 @@ class AccountMoveLine(models.Model):
# Get right debit / credit:
target_currency = target_currency or company_currency
- line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency
+ amount_currency = debit - credit
amount_currency_str = ""
total_amount_currency_str = ""
if line_currency != company_currency and target_currency == line_currency:
@@ -809,9 +827,10 @@ class AccountMoveLine(models.Model):
actual_debit = debit > 0 and amount or 0.0
actual_credit = credit > 0 and -amount or 0.0
currency = company_currency
+
if line_currency != target_currency:
- target_currency.compute(total_amount, line_currency)
- amount_currency_str = formatLang(self.env, target_currency.compute(abs(actual_debit or actual_credit), line_currency), currency_obj=line_currency)
+ amount_currency = target_currency.compute(total_amount, line_currency)
+ amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=line_currency)
total_amount_currency_str = formatLang(self.env, target_currency.compute(total_amount, line_currency), currency_obj=line_currency)
if currency != target_currency:
ctx = context.copy()
@@ -827,6 +846,7 @@ class AccountMoveLine(models.Model):
ret_line['amount_str'] = amount_str
ret_line['total_amount_str'] = total_amount_str
ret_line['amount_currency_str'] = amount_currency_str
+ ret_line['amount_currency'] = amount_currency
ret_line['total_amount_currency_str'] = total_amount_currency_str
ret.append(ret_line)
return ret
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_action.js b/addons/account/static/src/js/reconciliation/reconciliation_action.js
index fabfee1ad00..bfd2e48972e 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_action.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_action.js
@@ -20,6 +20,7 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
change_offset: '_onAction',
change_partner: '_onAction',
add_proposition: '_onAction',
+ search_balance_amount: '_onAction',
remove_proposition: '_onAction',
update_proposition: '_onAction',
create_proposition: '_onAction',
@@ -38,7 +39,11 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
// used to instanciate the action interface
ActionRenderer: ReconciliationRenderer.StatementRenderer,
// used to instanciate each widget line
- LineRenderer: ReconciliationRenderer.LineRenderer
+ LineRenderer: ReconciliationRenderer.LineRenderer,
+ // used context params
+ params: ['statement_ids'],
+ // number of moves lines displayed in 'match' mode
+ limitMoveLines: 5,
},
/**
@@ -51,11 +56,25 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
this._super.apply(this, arguments);
this.action_manager = parent;
this.params = params;
- this.model = new this.config.Model(this, {modelName: "account.bank.statement.line"});
+ this.model = new this.config.Model(this, {
+ modelName: "account.bank.statement.line",
+ limitMoveLines: params.params && params.params.limitMoveLines || this.config.limitMoveLines,
+ });
this.widgets = [];
if (!this.action_manager) {
this.set_cp_bus(new Widget());
}
+ // Adding values from the context is necessary to put this information in the url via the action manager so that
+ // you can retrieve it if the person shares his url or presses f5
+ _.each(params.params, function (value, name) {
+ params.context[name] = name.indexOf('_ids') !== -1 ? _.map((value+'').split(), parseFloat) : value;
+ });
+ params.params = {};
+ _.each(this.config.params, function (name) {
+ if (params.context[name]) {
+ params.params[name] = name.indexOf('_ids') !== -1 && _.isArray(params.context[name]) ? params.context[name].join() : params.context[name];
+ }
+ });
},
/**
@@ -305,8 +324,15 @@ var ManualAction = StatementAction.extend({
config: {
Model: ReconciliationModel.ManualModel,
ActionRenderer: ReconciliationRenderer.ManualRenderer,
- LineRenderer: ReconciliationRenderer.ManualLineRenderer
+ LineRenderer: ReconciliationRenderer.ManualLineRenderer,
+ params: ['company_ids', 'mode', 'partner_ids', 'account_ids'],
+ limitMoveLines: 10,
},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
/**
* call 'validate' or 'autoReconciliation' model method then destroy the
* reconcilied lines, update the not reconcilied and update the action
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js
index 880619411fe..41fb2d32003 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_model.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js
@@ -66,6 +66,7 @@ var _t = core._t;
* }
* mv_lines: object - idem than reconciliation_proposition
* offset: integer
+ * limitMoveLines: integer
* filter: string
* [createForm]: {
* account_id: {
@@ -95,8 +96,11 @@ var StatementModel = BasicModel.extend({
/**
* @override
+ *
+ * @param {Widget} parent
+ * @param {object} options
*/
- init: function () {
+ init: function (parent, options) {
this._super.apply(this, arguments);
this.reconcileModels = [];
this.lines = {};
@@ -104,6 +108,7 @@ var StatementModel = BasicModel.extend({
this.valuemax = 0;
this.alreadyDisplayed = [];
this.defaultDisplayQty = 10;
+ this.limitMoveLines = options && options.limitMoveLines || 5;
},
//--------------------------------------------------------------------------
@@ -162,7 +167,9 @@ var StatementModel = BasicModel.extend({
* @returns {Deferred}
*/
changeFilter: function (handle, filter) {
- this.getLine(handle).filter = filter;
+ var line = this.getLine(handle);
+ line.filter = filter;
+ line.offset = 0;
return this._performMoveLine(handle);
},
/**
@@ -219,7 +226,7 @@ var StatementModel = BasicModel.extend({
* @returns {Deferred}
*/
changeOffset: function (handle, offset) {
- this.getLine(handle).offset += offset;
+ this.getLine(handle).offset += (offset > 0 ? 1 : -1) * this.limitMoveLines;
return this._performMoveLine(handle);
},
/**
@@ -360,14 +367,14 @@ var StatementModel = BasicModel.extend({
self.context.journal_id = statement.journal_id;
_.each(statement.st_lines_ids, function (id) {
self.lines[_.uniqueId('rline')] = {
- 'id': id,
- 'reconciled': false,
- 'mode': 'inactive',
- 'mv_lines': [],
- 'offset': 0,
- 'filter': "",
- 'reconciliation_proposition': [],
- 'reconcileModels': []
+ id: id,
+ reconciled: false,
+ mode: 'inactive',
+ mv_lines: [],
+ offset: 0,
+ filter: "",
+ reconciliation_proposition: [],
+ reconcileModels: [],
};
});
});
@@ -489,6 +496,25 @@ var StatementModel = BasicModel.extend({
}
return def;
},
+ searchBalanceAmount: function (handle) {
+ var line = this.getLine(handle);
+ var amount = line.balance.amount;
+ var amount_str = _.str.sprintf('%.2f', Math.abs(amount));
+ amount_str = (amount > '0' ? '-' : '+') + amount_str;
+ if (line.balance.amount_currency) {
+ var amount_currency = line.balance.amount_currency;
+ var amount_currency_str = _.str.sprintf('%.2f', Math.abs(amount_currency));
+ amount_str += '|' + (amount_currency > '0' ? '-' : '+') + amount_currency_str;
+ }
+ if (amount_str === line.filter) {
+ line.filter = '';
+ line.offset = 0;
+ return this.changeMode(handle, 'create');
+ }
+ line.filter = amount_str;
+ line.offset = 0;
+ return this.changeMode(handle, 'match');
+ },
/**
* Force the partial reconciliation to display the reconciliate button.
* This method should only be called when there is onely one proposition.
@@ -728,7 +754,7 @@ var StatementModel = BasicModel.extend({
return !p.is_tax || p.link !== prop.id;
});
- var args = [[prop.tax_id.id], prop.base_amount, line.st_line.currency_id];
+ var args = [[prop.tax_id.id], prop.base_amount, formatOptions.currency_id];
tax_defs.push(self._rpc({
model: 'account.tax',
method: 'json_friendly_compute_all',
@@ -758,11 +784,7 @@ var StatementModel = BasicModel.extend({
});
}));
} else {
- var currencyData = {
- currency_id: line.st_line.currency_id
- };
-
- prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, currencyData);
+ prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, formatOptions);
prop.display = self._isDisplayedProposition(prop);
prop.invalid = !self._isValid(prop);
}
@@ -774,19 +796,32 @@ var StatementModel = BasicModel.extend({
});
line.reconciliation_proposition = reconciliation_proposition;
+ var amount_currency = 0;
var total = line.st_line.amount || 0;
- _.each(line.reconciliation_proposition, function (prop) {
+ var isOtherCurrencyId = _.uniq(_.pluck(_.reject(reconciliation_proposition, 'invalid'), 'currency_id'));
+ isOtherCurrencyId = isOtherCurrencyId.length === 1 && !total && isOtherCurrencyId[0] !== formatOptions.currency_id ? isOtherCurrencyId[0] : false;
+
+ _.each(reconciliation_proposition, function (prop) {
if (!prop.invalid) {
total -= prop.amount;
+ if (isOtherCurrencyId) {
+ amount_currency -= prop.amount_currency;
+ }
}
});
total = Math.round(total*1000)/1000 || 0;
line.balance = {
- 'amount': total,
- 'amount_str': field_utils.format.monetary(Math.abs(total), {}, formatOptions),
- 'account_code': self.accounts[line.st_line.open_balance_account_id],
+ amount: total,
+ amount_str: field_utils.format.monetary(Math.abs(total), {}, formatOptions),
+ currency_id: isOtherCurrencyId,
+ amount_currency: isOtherCurrencyId ? amount_currency : false,
+ amount_currency_str: isOtherCurrencyId ? field_utils.format.monetary(Math.abs(amount_currency), {}, {
+ currency_id: isOtherCurrencyId
+ }) : false,
+ account_code: self.accounts[line.st_line.open_balance_account_id],
};
- line.balance.type = line.balance.amount ? (line.balance.amount > 0 && line.st_line.partner_id ? 0 : -1) : 1;
+ line.balance.type = (isOtherCurrencyId && amount_currency || line.balance.amount) ?
+ (line.balance.amount > 0 && line.st_line.partner_id ? 0 : -1) : 1;
});
},
/**
@@ -851,6 +886,7 @@ var StatementModel = BasicModel.extend({
return l.id === data.st_line.id;
});
line.visible = true;
+ line.limitMoveLines = self.limitMoveLines;
_.extend(line, data);
self._formatLineProposition(line, line.reconciliation_proposition);
if (!line.reconciliation_proposition.length) {
@@ -989,7 +1025,7 @@ var StatementModel = BasicModel.extend({
})));
var filter = line.filter || "";
var offset = line.offset;
- var limit = 6;
+ var limit = this.limitMoveLines+1;
return this._rpc({
model: 'account.bank.statement.line',
method: 'get_move_lines_for_reconciliation_widget',
@@ -1196,6 +1232,8 @@ var ManualModel = StatementModel.extend({
if (line.reconciled) {
return;
}
+ line.filter = "";
+ line.offset = 0;
defs.push(self._performMoveLine(handle).then(function () {
if(!line.mv_lines.length) {
self.valuenow++;
@@ -1243,7 +1281,7 @@ var ManualModel = StatementModel.extend({
*/
_computeLine: function (line) {
return this._super(line).then(function () {
- var props = line.reconciliation_proposition;
+ var props = _.reject(line.reconciliation_proposition, 'invalid');
line.balance.type = -1;
if (!line.balance.amount && props.length) {
line.balance.type = 1;
@@ -1265,15 +1303,16 @@ var ManualModel = StatementModel.extend({
*/
_formatLine: function (type, data) {
var line = this.lines[_.uniqueId('rline')] = _.extend(data, {
- 'type': type,
- 'reconciled': false,
- 'mode': 'inactive',
- 'offset': 0,
- 'filter': "",
- 'reconcileModels': [],
- 'account_id': this._formatNameGet([data.account_id, data.account_name]),
- 'st_line': data,
- 'visible': true
+ type: type,
+ reconciled: false,
+ mode: 'inactive',
+ offset: 0,
+ limitMoveLines: this.limitMoveLines,
+ filter: "",
+ reconcileModels: [],
+ account_id: this._formatNameGet([data.account_id, data.account_name]),
+ st_line: data,
+ visible: true
});
this._formatLineProposition(line, line.reconciliation_proposition);
if (!line.reconciliation_proposition.length) {
@@ -1337,7 +1376,7 @@ var ManualModel = StatementModel.extend({
})));
var filter = line.filter || "";
var offset = line.offset;
- var limit = 6;
+ var limit = this.limitMoveLines+1;
var args = [line.account_id.id, line.partner_id, excluded_ids, filter, offset, limit];
return this._rpc({
model: 'account.move.line',
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
index f71f3d09011..9e72fdeb44f 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
@@ -250,7 +250,8 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
events: {
'click .accounting_view caption .o_buttons button': '_onValidate',
'click .accounting_view thead td': '_onTogglePanel',
- 'click .accounting_view tfoot td': '_onShowPanel',
+ 'click .accounting_view tfoot td:not(.cell_left,.cell_right)': '_onShowPanel',
+ 'click tfoot .cell_left, tfoot .cell_right': '_onSearchBalanceAmount',
'input input.filter': '_onFilterChange',
'click .match_controls .fa-chevron-left:not(.disabled)': '_onPrevious',
'click .match_controls .fa-chevron-right:not(.disabled)': '_onNext',
@@ -386,7 +387,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
// mv_lines
var $mv_lines = this.$('.match table tbody').empty();
- _.each(state.mv_lines.slice(0,5), function (line) {
+ _.each(state.mv_lines.slice(0, state.limitMoveLines), function (line) {
var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state}));
if (!isNaN(line.id)) {
$('<span class="line_info_button fa fa-info-circle"/>')
@@ -395,13 +396,19 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
}
$mv_lines.append($line);
});
- this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= 5);
+ this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= state.limitMoveLines);
this.$('.match .fa-chevron-left').toggleClass('disabled', !state.offset);
this.$('.match').css('max-height', !state.mv_lines.length && !state.filter.length ? '0px' : '');
// balance
+ this.$('.popover').remove();
this.$('table tfoot').html(qweb.render("reconciliation.line.balance", {'state': state}));
+ // filter
+ if (_.str.strip(this.$('input.filter').val()) !== state.filter) {
+ this.$('input.filter').val(state.filter);
+ }
+
// create form
if (state.createForm) {
if (!this.fields.account_id) {
@@ -593,6 +600,12 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
/**
* @private
*/
+ _onSearchBalanceAmount: function () {
+ this.trigger_up('search_balance_amount');
+ },
+ /**
+ * @private
+ */
_onShowPanel: function () {
var mode = (this.$el.data('mode') === 'inactive' || this.$el.data('mode') === 'match') ? 'create' : 'match';
this.trigger_up('change_mode', {'data': mode});
@@ -637,13 +650,13 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
* @private
*/
_onPrevious: function () {
- this.trigger_up('change_offset', {'data': -5});
+ this.trigger_up('change_offset', {'data': -1});
},
/**
* @private
*/
_onNext: function () {
- this.trigger_up('change_offset', {'data': 5});
+ this.trigger_up('change_offset', {'data': 1});
},
/**
* @private
diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml
index 7b50cff9ebb..20ef80c45c8 100644
--- a/addons/account/static/src/xml/account_reconciliation.xml
+++ b/addons/account/static/src/xml/account_reconciliation.xml
@@ -25,7 +25,7 @@
<div class="notification_area"/>
</div>
<div class="o_reconciliation_lines"/>
- <div t-if="widget._initialState.valuemax > widget._initialState.defaultDisplayQty">
+ <div t-if="widget._initialState.valuemax &gt; widget._initialState.defaultDisplayQty">
<button class="btn btn-default js_load_more">Load more</button>
</div>
<div class="pull-right text-muted">Tip: Hit CTRL-Enter to reconcile all the balanced items in the sheet.</div>
@@ -129,8 +129,8 @@
<td class="cell_account_code"><t t-esc="state.balance.account_code"/></td>
<td class="cell_due_date"></td>
<td class="cell_label"><t t-if="state.balance.amount &lt; 0">Create Write-off</t><t t-elif="state.st_line.partner_id">Open balance</t><t t-else="">Choose counterpart</t></td>
- <td class="cell_left"><t t-if="state.balance.amount &lt; 0"><t t-raw="state.balance.amount_str"/></t></td>
- <td class="cell_right"><t t-if="state.balance.amount &gt; 0"><t t-raw="state.balance.amount_str"/></t></td>
+ <td class="cell_left"><t t-if="state.balance.amount &lt; 0"><span t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td>
+ <td class="cell_right"><t t-if="state.balance.amount &gt; 0"><span t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_info_popover"></td>
</tr>
</t>
diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js
index a8fa2c6f233..dda6ffa0664 100644
--- a/addons/account/static/tests/reconciliation_tests.js
+++ b/addons/account/static/tests/reconciliation_tests.js
@@ -475,7 +475,10 @@ var session = {
var options = {
context: {
statement_ids: [4]
- }
+ },
+ params: {
+ limitMoveLines: 5,
+ },
};
Datas.params = {
@@ -1051,7 +1054,7 @@ QUnit.module('account', {
var clientAction = new ReconciliationClientAction.ManualAction(null, this.params.options);
testUtils.addMockEnvironment(clientAction, {
- 'data': this.params.data,
+ data: this.params.data,
session: this.params.session,
});
clientAction.appendTo($('#qunit-fixture'));
diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py
index 84aa784fad3..b39d36f7bf3 100644
--- a/addons/account/tests/test_tax.py
+++ b/addons/account/tests/test_tax.py
@@ -1,6 +1,7 @@
+# -*- coding: utf-8 -*-
+
from odoo.addons.account.tests.account_test_users import AccountTestUsers
-from odoo.tests.common import TransactionCase
-from odoo.tools import float_compare
+
import time
@@ -27,6 +28,12 @@ class TestTax(AccountTestUsers):
'amount': 10,
'sequence': 3,
})
+ self.percent_tax_bis = self.tax_model.create({
+ 'name': "Percent tax bis",
+ 'amount_type': 'percent',
+ 'amount': 21,
+ 'sequence': 3,
+ })
self.division_tax = self.tax_model.create({
'name': "Division tax",
'amount_type': 'division',
@@ -67,47 +74,125 @@ class TestTax(AccountTestUsers):
self.bank_account = self.bank_journal.default_debit_account_id
self.expense_account = self.env['account.account'].search([('user_type_id.type', '=', 'payable')], limit=1) #Should be done by onchange later
+ def _check_compute_all_results(self, base, total_included, total_excluded, taxes, res):
+ self.assertAlmostEqual(res['base'], base)
+ self.assertAlmostEqual(res['total_included'], total_included)
+ self.assertAlmostEqual(res['total_excluded'], total_excluded)
+ for i in range(0, len(taxes)):
+ self.assertAlmostEqual(res['taxes'][i]['base'], taxes[i][0])
+ self.assertAlmostEqual(res['taxes'][i]['amount'], taxes[i][1])
+
def test_tax_group_of_group_tax(self):
self.fixed_tax.include_base_amount = True
- self.group_tax.include_base_amount = True
- self.group_of_group_tax.include_base_amount = True
res = self.group_of_group_tax.compute_all(200.0)
- self.assertEquals(res['total_excluded'], 200.0)
- # After calculation of first group
- # base = 210
- # total_included = 231
- # Base of the first grouped is passed
- # Base after the second group (220) is dropped.
- # Base of the group of groups is passed out,
- # so we obtain base as after first group
- self.assertEquals(res['base'], 210.0)
- self.assertEquals(res['total_included'], 263.0)
+ self._check_compute_all_results(
+ 220, # 'base'
+ 263, # 'total_included'
+ 200, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (200.0, 10.0), # | 1 | 10 | | t
+ (210.0, 21.0), # | 3 | 10% | |
+ (210.0, 10.0), # | 1 | 10 | | t
+ (220.0, 22.0), # | 3 | 10% | |
+ # ---------------------------------------------------
+ ],
+ res
+ )
def test_tax_group(self):
res = self.group_tax.compute_all(200.0)
- self.assertEquals(res['total_excluded'], 200.0)
- self.assertEquals(res['total_included'], 230.0)
- self.assertEquals(len(res['taxes']), 2)
- self.assertEquals(res['taxes'][0]['amount'], 10.0)
- self.assertEquals(res['taxes'][1]['amount'], 20.0)
+ self._check_compute_all_results(
+ 200, # 'base'
+ 230, # 'total_included'
+ 200, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (200.0, 10.0), # | 1 | 10 | |
+ (200.0, 20.0), # | 3 | 10% | |
+ # ---------------------------------------------------
+ ],
+ res
+ )
def test_tax_percent_division(self):
self.division_tax.price_include = True
self.division_tax.include_base_amount = True
+ res_division = self.division_tax.compute_all(200.0)
+ self._check_compute_all_results(
+ 220, # 'base'
+ 220, # 'total_included'
+ 200, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (200.0, 20.0), # | 4 | 10/ | t | t
+ # ---------------------------------------------------
+ ],
+ res_division
+ )
self.percent_tax.price_include = False
self.percent_tax.include_base_amount = False
- res_division = self.division_tax.compute_all(200.0)
res_percent = self.percent_tax.compute_all(200.0)
- self.assertEquals(res_division['taxes'][0]['amount'], 20.0)
- self.assertEquals(res_percent['taxes'][0]['amount'], 20.0)
+ self._check_compute_all_results(
+ 200, # 'base'
+ 220, # 'total_included'
+ 200, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (200.0, 20.0), # | 3 | 10% | |
+ # ---------------------------------------------------
+ ],
+ res_percent
+ )
self.division_tax.price_include = False
self.division_tax.include_base_amount = False
+ res_division = self.division_tax.compute_all(200.0)
+ self._check_compute_all_results(
+ 200, # 'base'
+ 222.22, # 'total_included'
+ 200, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (200.0, 22.22), # | 4 | 10/ | |
+ # ---------------------------------------------------
+ ],
+ res_division
+ )
self.percent_tax.price_include = True
self.percent_tax.include_base_amount = True
- res_division = self.division_tax.compute_all(200.0)
res_percent = self.percent_tax.compute_all(200.0)
- self.assertEquals(res_division['taxes'][0]['amount'], 22.22)
- self.assertEquals(res_percent['taxes'][0]['amount'], 18.18)
+ self._check_compute_all_results(
+ 200, # 'base'
+ 200, # 'total_included'
+ 181.82, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (181.82, 18.18), # | 3 | 10% | t | t
+ # ---------------------------------------------------
+ ],
+ res_percent
+ )
+ self.percent_tax_bis.price_include = True
+ self.percent_tax_bis.include_base_amount = True
+ res_percent = self.percent_tax_bis.compute_all(7.0)
+ self._check_compute_all_results(
+ 7.0, # 'base'
+ 7.0, # 'total_included'
+ 5.79, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (5.79, 1.21), # | 3 | 21% | t | t
+ # ---------------------------------------------------
+ ],
+ res_percent
+ )
def test_tax_sequence_normalized_set(self):
self.division_tax.sequence = 1
@@ -115,19 +200,53 @@ class TestTax(AccountTestUsers):
self.percent_tax.sequence = 3
taxes_set = (self.group_tax | self.division_tax)
res = taxes_set.compute_all(200.0)
- self.assertEquals(res['taxes'][0]['amount'], 22.22)
- self.assertEquals(res['taxes'][1]['amount'], 10.0)
- self.assertEquals(res['taxes'][2]['amount'], 20.0)
+ self._check_compute_all_results(
+ 200, # 'base'
+ 252.22, # 'total_included'
+ 200, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (200.0, 22.22), # | 1 | 10/ | |
+ (200.0, 10.0), # | 2 | 10 | |
+ (200.0, 20.0), # | 3 | 10% | |
+ # ---------------------------------------------------
+ ],
+ res
+ )
def test_tax_include_base_amount(self):
self.fixed_tax.include_base_amount = True
res = self.group_tax.compute_all(200.0)
- self.assertEquals(res['total_included'], 231.0)
+ self._check_compute_all_results(
+ 210, # 'base'
+ 231, # 'total_included'
+ 200, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (200.0, 10.0), # | 1 | 10 | | t
+ (210.0, 21.0), # | 3 | 10% | |
+ # ---------------------------------------------------
+ ],
+ res
+ )
def test_tax_currency(self):
self.division_tax.amount = 15.0
res = self.division_tax.compute_all(200.0, currency=self.env.ref('base.VEF'))
- self.assertAlmostEqual(res['total_included'], 235.2941)
+ self._check_compute_all_results(
+ 200, # 'base'
+ 235.2941, # 'total_included'
+ 200, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (200.0, 35.2941), # | 4 | 15/ | |
+ # ---------------------------------------------------
+ ],
+ res
+ )
def test_tax_move_lines_creation(self):
""" Test that creating a move.line with tax_ids generates the tax move lines and adjust line amount when a tax is price_include """
@@ -172,3 +291,58 @@ class TestTax(AccountTestUsers):
aml_with_taxes = move.line_ids.filtered(lambda l: set(l.tax_ids.ids) == set([self.group_tax.id, self.fixed_tax_bis.id]))
self.assertEquals(len(aml_with_taxes), 1)
self.assertEquals(aml_with_taxes.credit, 190)
+
+ def test_advanced_taxes_computation_0(self):
+ '''Test more advanced taxes computation (see issue 34471).'''
+ tax_1 = self.env['account.tax'].create({
+ 'name': 'test_advanced_taxes_computation_0_1',
+ 'amount_type': 'percent',
+ 'amount': 10,
+ 'price_include': True,
+ 'include_base_amount': True,
+ 'sequence': 1,
+ })
+ tax_2 = self.env['account.tax'].create({
+ 'name': 'test_advanced_taxes_computation_0_2',
+ 'amount_type': 'percent',
+ 'amount': 10,
+ 'sequence': 2,
+ })
+ tax_3 = self.env['account.tax'].create({
+ 'name': 'test_advanced_taxes_computation_0_3',
+ 'amount_type': 'percent',
+ 'amount': 10,
+ 'price_include': True,
+ 'sequence': 3,
+ })
+ tax_4 = self.env['account.tax'].create({
+ 'name': 'test_advanced_taxes_computation_0_4',
+ 'amount_type': 'percent',
+ 'amount': 10,
+ 'sequence': 4,
+ })
+ tax_5 = self.env['account.tax'].create({
+ 'name': 'test_advanced_taxes_computation_0_5',
+ 'amount_type': 'percent',
+ 'amount': 10,
+ 'price_include': True,
+ 'sequence': 5,
+ })
+ taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5
+ res = taxes.compute_all(132.0)
+ self._check_compute_all_results(
+ 110, # 'base'
+ 154, # 'total_included'
+ 100, # 'total_excluded'
+ [
+ # base , amount | seq | amount | incl | incl_base
+ # ---------------------------------------------------
+ (100.0, 10.0), # | 1 | 10% | t | t
+ (110.0, 11.0), # | 3 | 10% | |
+ (110.0, 11.0), # | 3 | 10% | t |
+ (110.0, 11.0), # | 3 | 10% | |
+ (110.0, 11.0), # | 3 | 10% | t |
+ # ---------------------------------------------------
+ ],
+ res
+ )
diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml
index 20c63983ec4..db8accc8808 100644
--- a/addons/account/views/account_view.xml
+++ b/addons/account/views/account_view.xml
@@ -322,10 +322,12 @@
<field name="type_control_ids" widget="many2many_tags"/>
<field name="account_control_ids" widget="many2many_tags"/>
</group>
- <group string="Miscellaneous" groups="account.group_account_user">
+ <group string="Miscellaneous">
<field name="inbound_payment_method_ids" widget="many2many_checkboxes" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/>
<field name="outbound_payment_method_ids" widget="many2many_checkboxes" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/>
<field name="group_invoice_lines" attrs="{'invisible': [('type', 'not in', ['sale', 'purchase'])]}"/>
+ </group>
+ <group string="Accounting App Options" groups="account.group_account_user">
<field name="profit_account_id" attrs="{'invisible': [('type', '!=', 'cash')]}"/>
<field name="loss_account_id" attrs="{'invisible': [('type', '!=', 'cash')]}"/>
<field name="show_on_dashboard" groups="base.group_no_one"/>
diff --git a/addons/account_asset/models/account_invoice.py b/addons/account_asset/models/account_invoice.py
index fc4789d0706..dd22ea851f3 100644
--- a/addons/account_asset/models/account_invoice.py
+++ b/addons/account_asset/models/account_invoice.py
@@ -12,6 +12,16 @@ from odoo.addons import decimal_precision as dp
class AccountInvoice(models.Model):
_inherit = 'account.invoice'
+ @api.model
+ def _refund_cleanup_lines(self, lines):
+ result = super(AccountInvoice, self)._refund_cleanup_lines(lines)
+ for i, line in enumerate(lines):
+ for name, field in line._fields.items():
+ if name == 'asset_category_id':
+ result[i][2][name] = False
+ break
+ return result
+
@api.multi
def action_cancel(self):
res = super(AccountInvoice, self).action_cancel()
diff --git a/addons/auth_oauth/controllers/main.py b/addons/auth_oauth/controllers/main.py
index 0d09f1e68aa..e231cd0f6d0 100644
--- a/addons/auth_oauth/controllers/main.py
+++ b/addons/auth_oauth/controllers/main.py
@@ -6,6 +6,7 @@ import logging
import json
+import werkzeug.urls
import werkzeug.utils
from werkzeug.exceptions import BadRequest
@@ -148,7 +149,11 @@ class OAuthController(http.Controller):
url = '/web#action=%s' % action
elif menu:
url = '/web#menu_id=%s' % menu
- return login_and_redirect(*credentials, redirect_url=url)
+ resp = login_and_redirect(*credentials, redirect_url=url)
+ # Since /web is hardcoded, verify user has right to land on it
+ if werkzeug.urls.url_parse(resp.location).path == '/web' and not request.env.user.has_group('base.group_user'):
+ resp.location = '/'
+ return resp
except AttributeError:
# auth_signup is not installed
_logger.error("auth_signup not installed on database %s: oauth sign up cancelled." % (dbname,))
diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py
index 720cdc1e71a..542283903f1 100644
--- a/addons/base_import/models/base_import.py
+++ b/addons/base_import/models/base_import.py
@@ -597,9 +597,18 @@ class Import(models.TransientModel):
@api.multi
def _parse_import_data(self, data, import_fields, options):
+ """ Lauch first call to _parse_import_data_recursive with an
+ empty prefix. _parse_import_data_recursive will be run
+ recursively for each relational field.
+ """
+ return self._parse_import_data_recursive(self.res_model, '', data, import_fields, options)
+
+ @api.multi
+ def _parse_import_data_recursive(self, model, prefix, data, import_fields, options):
# Get fields of type date/datetime
- all_fields = self.env[self.res_model].fields_get()
+ all_fields = self.env[model].fields_get()
for name, field in all_fields.items():
+ name = prefix + name
if field['type'] in ('date', 'datetime') and name in import_fields:
# Parse date
index = import_fields.index(name)
@@ -618,7 +627,11 @@ class Import(models.TransientModel):
raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e))
except Exception as e:
raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, e))
-
+ # Check if the field is in import_field and is a relational (followed by /)
+ # Also verify that the field name exactly match the import_field at the correct level.
+ elif any(name + '/' in import_field and name == import_field.split('/')[prefix.count('/')] for import_field in import_fields):
+ # Recursive call with the relational as new model and add the field name to the prefix
+ self._parse_import_data_recursive(field['relation'], name + '/', data, import_fields, options)
elif field['type'] in ('float', 'monetary') and name in import_fields:
# Parse float, sometimes float values from file have currency symbol or () to denote a negative value
# We should be able to manage both case
diff --git a/addons/base_import/tests/test_base_import.py b/addons/base_import/tests/test_base_import.py
index a59cfa9205c..55f3300da91 100644
--- a/addons/base_import/tests/test_base_import.py
+++ b/addons/base_import/tests/test_base_import.py
@@ -400,7 +400,33 @@ class test_convert_import_data(TransactionCase):
# if results empty, no errors
self.assertItemsEqual(results, [])
+ def test_parse_relational_fields(self):
+ """ Ensure that relational fields float and date are correctly
+ parsed during the import call.
+ """
+ import_wizard = self.env['base_import.import'].create({
+ 'res_model': 'res.partner',
+ 'file': u'name,parent_id/id,parent_id/date,parent_id/credit_limit\n'
+ u'"foo","__export__.res_partner_1","2017年10月12日","5,69"\n'.encode('utf-8'),
+ 'file_type': 'text/csv'
+ })
+ options = {
+ 'date_format': '%Y年%m月%d日',
+ 'quoting': '"',
+ 'separator': ',',
+ 'float_decimal_separator': ',',
+ 'float_thousand_separator': '.',
+ 'headers': True
+ }
+ data, import_fields = import_wizard._convert_import_data(
+ ['name', 'parent_id/.id', 'parent_id/date', 'parent_id/credit_limit'],
+ options
+ )
+ result = import_wizard._parse_import_data(data, import_fields, options)
+ # Check if the data 5,69 as been correctly parsed.
+ self.assertEqual(float(result[0][-1]), 5.69)
+ self.assertEqual(str(result[0][-2]), '2017-10-12')
def test_filtered(self):
""" If ``False`` is provided as field mapping for a column,
diff --git a/addons/base_vat/models/res_partner.py b/addons/base_vat/models/res_partner.py
index dc547b2c422..2d2b37c939b 100644
--- a/addons/base_vat/models/res_partner.py
+++ b/addons/base_vat/models/res_partner.py
@@ -18,6 +18,12 @@ from odoo import api, models, _
from odoo.tools.misc import ustr
from odoo.exceptions import ValidationError
+_eu_country_vat = {
+ 'GR': 'EL'
+}
+
+_eu_country_vat_inverse = {v: k for k, v in _eu_country_vat.items()}
+
_ref_vat = {
'at': 'ATU12345675',
'be': 'BE0477472701',
@@ -79,6 +85,7 @@ class ResPartner(models.Model):
# Foreign companies that trade with non-enterprises in the EU
# may have a VATIN starting with "EU" instead of a country code.
return True
+ country_code = _eu_country_vat_inverse.get(country_code, country_code)
return bool(self.env['res.country'].search([('code', '=ilike', country_code)]))
return check_func(vat_number)
@@ -96,6 +103,19 @@ class ResPartner(models.Model):
# country code or empty VAT number), so we fall back to the simple check.
return self.simple_vat_check(country_code, vat_number)
+ @api.model
+ def fix_eu_vat_number(self, country_id, vat):
+ europe = self.env.ref('base.europe')
+ country = self.env["res.country"].browse(country_id)
+ if not europe:
+ europe = self.env["res.country.group"].search([('name', '=', 'Europe')], limit=1)
+ if europe and country and country.id in europe.country_ids.ids:
+ vat = re.sub('[^A-Za-z0-9]', '', vat).upper()
+ country_code = _eu_country_vat.get(country.code, country.code).upper()
+ if vat[:2] != country_code:
+ vat = country_code + vat
+ return vat
+
@api.constrains('vat', 'commercial_partner_country_id')
def check_vat(self):
if self.env.context.get('company_id'):
diff --git a/addons/base_vat_autocomplete/models/res_partner.py b/addons/base_vat_autocomplete/models/res_partner.py
index 3b25f99a8f1..41b40ec9c1d 100644
--- a/addons/base_vat_autocomplete/models/res_partner.py
+++ b/addons/base_vat_autocomplete/models/res_partner.py
@@ -6,6 +6,8 @@ import re
from odoo import api, models
+from suds.client import Client
+
_logger = logging.getLogger(__name__)
try:
@@ -14,6 +16,7 @@ except ImportError:
_logger.warning('Python `stdnum` library not found, unable to call VIES service to detect address based on VAT number.')
stdnum_vat = None
+
class ResPartner(models.Model):
_inherit = 'res.partner'
@@ -40,7 +43,17 @@ class ResPartner(models.Model):
if not partner.vat:
return {}
if len(partner.vat) > 5 and partner.vat[:2].lower() in stdnum_vat.country_codes:
- result = stdnum_vat.check_vies(partner.vat)
+ # Equivalent to stdnum_vat.check_vies(partner.vat).
+ # However, we want to add a custom timeout to the suds.client
+ # because by default, it's 120 seconds and this is to long.
+ try:
+ client = Client(stdnum_vat.vies_wsdl, timeout=5)
+ partner_vat = stdnum_vat.compact(partner.vat)
+ result = client.service.checkVat(partner_vat[:2], partner_vat[2:])
+ except:
+ # Avoid blocking the client when the service is unreachable/unavailable
+ return {}
+
if not result['valid']:
return {}
diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py
index 9c0ddc5c13f..37bd8e3e3ff 100644
--- a/addons/calendar/models/calendar.py
+++ b/addons/calendar/models/calendar.py
@@ -670,27 +670,27 @@ class Meeting(models.Model):
1) if user add duration for 2 hours, return : August-23-2013 at (04-30 To 06-30) (Europe/Brussels)
2) if event all day ,return : AllDay, July-31-2013
"""
- timezone = self._context.get('tz')
- if not timezone:
- timezone = self.env.user.partner_id.tz or 'UTC'
- timezone = tools.ustr(timezone).encode('utf-8') # make safe for str{p,f}time()
+ timezone = self._context.get('tz') or self.env.user.partner_id.tz or 'UTC'
+ timezone = pycompat.to_native(timezone) # make safe for str{p,f}time()
# get date/time format according to context
- format_date, format_time = self.with_context(tz=timezone)._get_date_formats()
+ format_date, format_time = self._get_date_formats()
# convert date and time into user timezone
- date = fields.Datetime.context_timestamp(self.with_context(tz=timezone), fields.Datetime.from_string(start))
- date_deadline = fields.Datetime.context_timestamp(self.with_context(tz=timezone), fields.Datetime.from_string(stop))
+ self_tz = self.with_context(tz=timezone)
+ date = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(start))
+ date_deadline = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(stop))
# convert into string the date and time, using user formats
- date_str = date.strftime(format_date).decode('utf-8')
- time_str = date.strftime(format_time).decode('utf-8')
+ to_text = pycompat.to_text
+ date_str = to_text(date.strftime(format_date))
+ time_str = to_text(date.strftime(format_time))
if zallday:
display_time = _("AllDay , %s") % (date_str)
elif zduration < 24:
duration = date + timedelta(hours=zduration)
- duration_time = duration.strftime(format_time).decode('utf-8')
+ duration_time = to_text(duration.strftime(format_time))
display_time = _(u"%s at (%s To %s) (%s)") % (
date_str,
time_str,
@@ -698,8 +698,8 @@ class Meeting(models.Model):
timezone,
)
else:
- dd_date = date_deadline.strftime(format_date).decode('utf-8')
- dd_time = date_deadline.strftime(format_time).decode('utf-8')
+ dd_date = to_text(date_deadline.strftime(format_date))
+ dd_time = to_text(date_deadline.strftime(format_time))
display_time = _(u"%s at %s To\n %s at %s (%s)") % (
date_str,
time_str,
diff --git a/addons/crm/controllers/main.py b/addons/crm/controllers/main.py
index d92758e961b..4f1a6960645 100644
--- a/addons/crm/controllers/main.py
+++ b/addons/crm/controllers/main.py
@@ -1,9 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
from odoo.addons.mail.controllers.main import MailController
from odoo import http
+_logger = logging.getLogger(__name__)
+
class CrmController(http.Controller):
@@ -12,8 +15,9 @@ class CrmController(http.Controller):
comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token)
if comparison and record:
try:
- record.case_mark_won()
+ record.action_set_won()
except Exception:
+ _logger.exception("Could not mark crm.lead as won")
return MailController._redirect_to_messaging()
return redirect
@@ -22,8 +26,9 @@ class CrmController(http.Controller):
comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token)
if comparison and record:
try:
- record.case_mark_lost()
+ record.action_set_lost()
except Exception:
+ _logger.exception("Could not mark crm.lead as lost")
return MailController._redirect_to_messaging()
return redirect
@@ -34,5 +39,6 @@ class CrmController(http.Controller):
try:
record.convert_opportunity(record.partner_id.id)
except Exception:
+ _logger.exception("Could not convert crm.lead to opportunity")
return MailController._redirect_to_messaging()
return redirect
diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py
index 334b52be890..31a91c08885 100644
--- a/addons/crm/models/crm_lead.py
+++ b/addons/crm/models/crm_lead.py
@@ -268,6 +268,14 @@ class Lead(models.Model):
# ----------------------------------------
@api.model
+ def name_create(self, name):
+ res = super(Lead, self).name_create(name)
+
+ # update the probability of the lead if the stage is set to update it automatically
+ self.browse(res[0])._onchange_stage_id()
+ return res
+
+ @api.model
def create(self, vals):
# set up context used to find the lead's sales channel which is needed
# to correctly set the default stage_id
diff --git a/addons/crm/report/crm_opportunity_report_views.xml b/addons/crm/report/crm_opportunity_report_views.xml
index 6728604b8d8..24625320191 100644
--- a/addons/crm/report/crm_opportunity_report_views.xml
+++ b/addons/crm/report/crm_opportunity_report_views.xml
@@ -104,17 +104,10 @@
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
<group expand="0" string="Extended Filters">
<field name="partner_id" filter_domain="[('partner_id','child_of',self)]"/>
- <field name="stage_id" domain="['|', ('team_id', '=', False), ('team_id', '=', 'team_id')]"/>
- <field name="campaign_id"/>
- <field name="medium_id"/>
- <field name="source_id"/>
- <field name="company_id" groups="base.group_multi_company"/>
- <separator orientation="vertical"/>
<field name="stage_id" widget="selection" domain="['|', ('team_id', '=', False), ('team_id', '=', 'team_id')]"/>
<field name="campaign_id" widget="selection"/>
<field name="medium_id" widget="selection"/>
<field name="source_id" widget="selection"/>
- <separator orientation="vertical"/>
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
<newline/>
<field name="create_date"/>
diff --git a/addons/crm/static/description/index.html b/addons/crm/static/description/index.html
index df8b9a447f8..6f4ab397d69 100644
--- a/addons/crm/static/description/index.html
+++ b/addons/crm/static/description/index.html
@@ -43,7 +43,7 @@
<p class="oe_mt32 oe_mb32 text-justify">
Automate activities based on your sales scripts: calls, meetings, mails and quotations. Get all information you need, right on the opportunity: page viewed, mails received, etc.
</p>
- <img src="crm_sc_02b.png">
+ <img class='img-responsive' src="crm_sc_02b.png">
</div>
</div>
</section>
diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml
index 60e7c350c70..a48a6dda3b0 100644
--- a/addons/crm/views/crm_lead_views.xml
+++ b/addons/crm/views/crm_lead_views.xml
@@ -385,7 +385,7 @@
domain="[('activity_ids.user_id', '=', uid)]"/>
<separator/>
<filter string="Late Activities" name="activities_overdue"
- domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
+ domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all records which has next action date is before today"/>
<filter string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
@@ -630,7 +630,7 @@
domain="[('activity_ids.user_id', '=', uid)]"/>
<separator/>
<filter string="Late Activities" name="activities_overdue"
- domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
+ domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all opportunities for which the next action date is before today"/>
<filter string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
@@ -640,7 +640,7 @@
<separator/>
<filter string="Overdue Opportunities" name="overdue_opp"
- domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
+ domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all opportunities with a deadline for which the next action date is before today"/>
<filter string="To Close Today" name="close_today"
diff --git a/addons/document/static/src/js/document.js b/addons/document/static/src/js/document.js
index 5fc69141583..0af85179b49 100644
--- a/addons/document/static/src/js/document.js
+++ b/addons/document/static/src/js/document.js
@@ -5,6 +5,7 @@ var core = require('web.core');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
var Sidebar = require('web.Sidebar');
+var field_utils = require('web.field_utils');
var _t = core._t;
@@ -61,6 +62,7 @@ Sidebar.include({
*/
_processAttachments: function (attachments) {
//to display number in name if more then one attachment which has same name.
+ var self = this;
_.chain(attachments)
.groupBy(function (attachment) { return attachment.name; })
.each(function (attachment) {
@@ -75,6 +77,10 @@ Sidebar.include({
if (a.type === "binary") {
a.url = '/web/content/' + a.id + '?download=true';
}
+ a.create_date = field_utils.parse.datetime(a.create_date, 'create_date', {isUTC: true});
+ a.create_date_string = field_utils.format.datetime(a.create_date, 'create_date', self.env.context.params);
+ a.write_date = field_utils.parse.datetime(a.write_date, 'write_date', {isUTC: true});
+ a.write_date_string = field_utils.format.datetime(a.write_date, 'write_date', self.env.context.params);
});
this.items.files = attachments;
},
diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py
index cfbd6e77967..17cbc2b14bf 100644
--- a/addons/hr_expense/models/hr_expense.py
+++ b/addons/hr_expense/models/hr_expense.py
@@ -109,17 +109,16 @@ class HrExpense(models.Model):
raise UserError(_("You cannot report twice the same line!"))
if len(self.mapped('employee_id')) != 1:
raise UserError(_("You cannot report expenses for different employees in the same report!"))
- expense_sheet = self.env['hr.expense.sheet'].create({
- 'expense_line_ids': [(4, line.id) for line in self],
- 'name': self[0].name if len(self.ids) == 1 else '',
- 'employee_id': self[0].employee_id.id,
- })
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'hr.expense.sheet',
'target': 'current',
- 'res_id': expense_sheet.id,
+ 'context': {
+ 'default_expense_line_ids': [line.id for line in self],
+ 'default_employee_id': self[0].employee_id.id,
+ 'default_name': self[0].name if len(self.ids) == 1 else ''
+ }
}
def _prepare_move_line(self, line):
diff --git a/addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py b/addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py
index 582926ab89a..a0765b0d5cf 100644
--- a/addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py
+++ b/addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py
@@ -42,6 +42,7 @@ class TimesheetAttendance(models.Model):
ts.unit_amount AS timesheet,
date_trunc('day', ts.date) AS date
FROM account_analytic_line AS ts
+ WHERE ts.project_id IS NOT NULL
) AS t
GROUP BY t.user_id, t.date
ORDER BY t.date
diff --git a/addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml b/addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml
index 046632c515d..3aee6568457 100644
--- a/addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml
+++ b/addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml
@@ -29,6 +29,7 @@
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'tree', 'view_id': ref('hr_timesheet.hr_timesheet_line_tree')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('hr_timesheet.hr_timesheet_line_form')})]"/>
+ <field name="domain">[('project_id','!=',False)]</field>
</record>
<record id="hr_timesheet_sheet_form" model="ir.ui.view">
diff --git a/addons/hw_proxy/controllers/main.py b/addons/hw_proxy/controllers/main.py
index 02f083110e7..3d42918c2ad 100644
--- a/addons/hw_proxy/controllers/main.py
+++ b/addons/hw_proxy/controllers/main.py
@@ -18,9 +18,9 @@ _logger = logging.getLogger(__name__)
# Those are the builtin raspberry pi USB modules, they should
# not appear in the list of connected devices.
BANNED_DEVICES = {
- "0424:9514", # Standard Microsystem Corp. Builtin Ethernet module
- "1d6b:0002", # Linux Foundation 2.0 root hub
- "0424:ec00", # Standard Microsystem Corp. Other Builtin Ethernet module
+ "0424:9514", # Standard Microsystem Corp. Builtin Ethernet module
+ "1d6b:0002", # Linux Foundation 2.0 root hub
+ "0424:ec00", # Standard Microsystem Corp. Other Builtin Ethernet module
}
diff --git a/addons/iap/models/iap.py b/addons/iap/models/iap.py
index 659ea1974c5..5e264dc6715 100644
--- a/addons/iap/models/iap.py
+++ b/addons/iap/models/iap.py
@@ -8,6 +8,7 @@ import werkzeug.urls
import requests
from odoo import api, fields, models, exceptions
+from odoo.tools import pycompat
_logger = logging.getLogger(__name__)
@@ -57,8 +58,10 @@ def jsonrpc(url, method='call', params=None):
e_class = InsufficientCreditError
elif name == 'AccessError':
e_class = exceptions.AccessError
- else:
+ elif name == 'UserError':
e_class = exceptions.UserError
+ else:
+ raise requests.exceptions.ConnectionError()
e = e_class(message)
e.data = response['error']['data']
raise e
@@ -93,7 +96,7 @@ def charge(env, key, account_token, credit, description=None, credit_template=No
except InsufficientCreditError as e:
if credit_template:
arguments = json.loads(e.args[0])
- arguments['body'] = env['ir.qweb'].render(credit_template)
+ arguments['body'] = pycompat.to_text(env['ir.qweb'].render(credit_template))
e.args = (json.dumps(arguments),)
raise e
try:
diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml
index 36c26f56eb4..34789916a48 100644
--- a/addons/mail/data/mail_data.xml
+++ b/addons/mail/data/mail_data.xml
@@ -71,6 +71,7 @@
<field name="model_id" ref="mail.model_mail_message"/>
<field name="auto_delete" eval="True"/>
<field name="body_html"><![CDATA[
+% set company = ctx.get('company', user.company_id)
<div>
% if ctx.get('has_button_access'):
<div itemscope itemtype="http://schema.org/EmailMessage">
@@ -110,7 +111,7 @@
% endif
</td>
<td valign="center" align="right">
- <img src="/logo.png" style="padding: 0px; margin: 0px; height: auto; max-width: 80px; max-height: 40px;" alt="${user.company_id.name}">
+ <img src="/logo.png?company=${company.id}" style="padding: 0px; margin: 0px; height: auto; max-width: 80px; max-height: 40px;" alt="${company.name}">
</td>
</tr>
<tr>
diff --git a/addons/mail/data/mail_shortcode_data.xml b/addons/mail/data/mail_shortcode_data.xml
index 64f1aa7129f..afd0cf91146 100644
--- a/addons/mail/data/mail_shortcode_data.xml
+++ b/addons/mail/data/mail_shortcode_data.xml
@@ -103,8 +103,8 @@
</record>
<record id="mail_smiley_angry" model="mail.shortcode">
<field name="source">3:(</field>
- <field name="unicode_source">😈</field>
- <field name="substitution">&lt;img src='/mail/static/src/img/smiley/angry.png' alt='😈'/&gt;</field>
+ <field name="unicode_source">😠</field>
+ <field name="substitution">&lt;img src='/mail/static/src/img/smiley/angry.png' alt='😠'/&gt;</field>
<field name="description">Angry</field>
<field name="shortcode_type">image</field>
</record>
diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py
index 25b01ce80b5..a47a6ffac6b 100644
--- a/addons/mail/models/mail_channel.py
+++ b/addons/mail/models/mail_channel.py
@@ -251,7 +251,7 @@ class Channel(models.Model):
author = self.env['res.partner'].browse(message_dict.get('author_id', False))
if not author or author not in self.channel_partner_ids:
return {
- 'error_mesage': _('restricted to channel members'),
+ 'error_message': _('restricted to channel members'),
}
return True
return super(Channel, self)._alias_check_contact(message, message_dict, alias)
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index fd736269968..53ed1da740c 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -829,11 +829,13 @@ class MailThread(models.AbstractModel):
def _routing_warn(self, error_message, warn_suffix, message_id, route, raise_exception):
""" Tools method used in message_route_verify: whether to log a warning or raise an error """
- full_message = _('Routing mail with Message-Id %s: route %s: %s') % (message_id, route, error_message)
+ short_message = _("Mailbox unavailable - %s") % error_message
+ full_message = ('Routing mail with Message-Id %s: route %s: %s' %
+ (message_id, route, error_message))
+ _logger.info(full_message + (warn_suffix and '; %s' % warn_suffix or ''))
if raise_exception:
- raise ValueError(full_message)
- else:
- _logger.info(full_message + warn_suffix and '; %s' % warn_suffix or '')
+ # sender should not see private diagnostics info, just the error
+ raise ValueError(short_message)
def _routing_create_bounce_email(self, email_from, body_html, message):
bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from
@@ -1387,6 +1389,7 @@ class MailThread(models.AbstractModel):
node.getparent().remove(node)
if postprocessed:
body = etree.tostring(root, pretty_print=False, encoding='UTF-8')
+ body = pycompat.to_native(body)
return body, attachments
def _message_extract_payload(self, message, save_original=False):
@@ -1495,10 +1498,8 @@ class MailThread(models.AbstractModel):
'message_type': 'email',
}
if not isinstance(message, Message):
- # message_from_string works on a native str, so on py2 we need to
- # encode incoming unicode strings
- if pycompat.PY2 and not isinstance(message, str):
- message = message.encode('utf-8')
+ # message_from_string works on a native str
+ message = pycompat.to_native(message)
message = email.message_from_string(message)
message_id = message['message-id']
diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py
index 0b7533c853d..3a81423a9b2 100644
--- a/addons/mail/models/res_partner.py
+++ b/addons/mail/models/res_partner.py
@@ -54,7 +54,6 @@ class Partner(models.Model):
website_url = 'http://%s' % user.company_id.website if not user.company_id.website.lower().startswith(('http:', 'https:')) else user.company_id.website
else:
website_url = False
- company_name = user.company_id.name
model_name = False
if message.model:
@@ -74,9 +73,15 @@ class Partner(models.Model):
if message.res_id and message.model in self.env:
record = self.env[message.model].browse(message.res_id)
+ company = user.company_id;
+ if record and hasattr(record, 'company_id'):
+ company = record.company_id;
+ company_name = company.name;
+
return {
'signature': signature,
'website_url': website_url,
+ 'company': company,
'company_name': company_name,
'model_name': model_name,
'record': record,
diff --git a/addons/mail/wizard/email_template_preview_view.xml b/addons/mail/wizard/email_template_preview_view.xml
index 7d5cd1cb6d6..67068f5dc84 100644
--- a/addons/mail/wizard/email_template_preview_view.xml
+++ b/addons/mail/wizard/email_template_preview_view.xml
@@ -28,6 +28,9 @@
<field name="body_html" widget="html" readonly="1"
nolabel="1" options='{"safe": True}'/>
<field name="attachment_ids" widget="many2many_binary" readonly="1"/>
+ <footer>
+ <button string="Discard" class="btn-default" special="cancel"/>
+ </footer>
</form>
</field>
</record>
diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py
index bcee631aad6..ab902ee7d75 100644
--- a/addons/mass_mailing/controllers/main.py
+++ b/addons/mass_mailing/controllers/main.py
@@ -17,10 +17,10 @@ class MassMailController(http.Controller):
if mailing.exists():
res_id = res_id and int(res_id)
res_ids = []
- if mailing.mailing_model_name == 'mail.mass_mailing.contact':
+ if mailing.mailing_model_name == 'mail.mass_mailing.list':
contacts = request.env['mail.mass_mailing.contact'].sudo().search([
('email', '=', email),
- ('list_id', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids])
+ ('list_ids', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids])
])
res_ids = contacts.ids
else:
diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py
index ca2e739c35c..2f3666efc77 100644
--- a/addons/mass_mailing/models/mass_mailing.py
+++ b/addons/mass_mailing/models/mass_mailing.py
@@ -15,6 +15,16 @@ from odoo.tools.translate import html_translate
_logger = logging.getLogger(__name__)
+MASS_MAILING_BUSINESS_MODELS = [
+ 'crm.lead',
+ 'event.registration',
+ 'hr.applicant',
+ 'res.partner',
+ 'event.track',
+ 'sale.order',
+ 'mail.mass_mailing.list',
+]
+
class MassMailingTag(models.Model):
"""Model of categories of mass mailing, i.e. marketing, newsletter, ... """
_name = 'mail.mass_mailing.tag'
@@ -320,7 +330,7 @@ class MassMailing(models.Model):
default=lambda self: self.env['mail.message']._get_default_from())
# recipients
mailing_model_real = fields.Char(compute='_compute_model', string='Recipients Real Model', default='mail.mass_mailing.contact', required=True)
- mailing_model_id = fields.Many2one('ir.model', string='Recipients Model', domain="[('is_mail_thread', '=', True)]",
+ mailing_model_id = fields.Many2one('ir.model', string='Recipients Model', domain=[('model', 'in', MASS_MAILING_BUSINESS_MODELS)],
default=lambda self: self.env.ref('mass_mailing.model_mail_mass_mailing_list').id)
mailing_model_name = fields.Char(related='mailing_model_id.model', string='Recipients Model Name')
mailing_domain = fields.Char(string='Domain', oldname='domain', default=[])
diff --git a/addons/mass_mailing/static/src/js/mass_mailing_editor.js b/addons/mass_mailing/static/src/js/mass_mailing_editor.js
index a97f055f6c9..5190cd00838 100644
--- a/addons/mass_mailing/static/src/js/mass_mailing_editor.js
+++ b/addons/mass_mailing/static/src/js/mass_mailing_editor.js
@@ -404,4 +404,15 @@ odoo_top[callback+"_updown"] = function (value, fields_values, field_name) {
if ($editable_area.html().indexOf('on_change_model_and_list') !== -1) {
$editable_area.empty();
}
+// Adding compatibility for the outlook compliance of mailings.
+// Commit of such compatibility : a14f89c8663c9cafecb1cc26918055e023ecbe42
+options.registry.background.include({
+ start: function() {
+ this._super();
+ var $table_target = this.$target.find('table:first');
+ if ($table_target) {
+ this.$target = $table_target;
+ }
+ }
+});
});
diff --git a/addons/mass_mailing/views/mass_mailing_views.xml b/addons/mass_mailing/views/mass_mailing_views.xml
index 31f4664f552..4875421e812 100644
--- a/addons/mass_mailing/views/mass_mailing_views.xml
+++ b/addons/mass_mailing/views/mass_mailing_views.xml
@@ -415,7 +415,7 @@
</div>
<label for="mailing_model_id" string="Recipients"/>
<div>
- <field name="mailing_model_id"/>
+ <field name="mailing_model_id" widget="selection"/>
<field name="mailing_model_name" invisible="1"/>
<field name="mailing_model_real" invisible="1"/>
<field name="mailing_domain" widget="domain"
diff --git a/addons/membership/models/account_invoice.py b/addons/membership/models/account_invoice.py
index 73622ce0fb2..85b158e4959 100644
--- a/addons/membership/models/account_invoice.py
+++ b/addons/membership/models/account_invoice.py
@@ -36,7 +36,7 @@ class AccountInvoiceLine(models.Model):
# Product line has changed to a membership product
date_from = line.product_id.membership_date_from
date_to = line.product_id.membership_date_to
- if line.invoice_id.date_invoice > date_from and line.invoice_id.date_invoice < date_to:
+ if line.invoice_id.date_invoice > (date_from or '0000-00-00') and line.invoice_id.date_invoice < (date_to or '0000-00-00'):
date_from = line.invoice_id.date_invoice
MemberLine.create({
'partner': line.invoice_id.partner_id.id,
@@ -60,9 +60,9 @@ class AccountInvoiceLine(models.Model):
invoice_line.product_id.membership and \
not MemberLine.search([('account_invoice_line', '=', invoice_line.id)]):
# Product line is a membership product
- date_from = invoice_line.product_id.membership_date_from or '0000-00-00'
- date_to = invoice_line.product_id.membership_date_to or '0000-00-00'
- if date_from < (invoice_line.invoice_id.date_invoice or '0000-00-00') < date_to:
+ date_from = invoice_line.product_id.membership_date_from
+ date_to = invoice_line.product_id.membership_date_to
+ if date_from and date_from < (invoice_line.invoice_id.date_invoice or '0000-00-00') < (date_to or '0000-00-00'):
date_from = invoice_line.invoice_id.date_invoice
MemberLine.create({
'partner': invoice_line.invoice_id.partner_id.id,
diff --git a/addons/membership/models/partner.py b/addons/membership/models/partner.py
index 89da36bc273..348d4d04dfa 100644
--- a/addons/membership/models/partner.py
+++ b/addons/membership/models/partner.py
@@ -108,7 +108,7 @@ class Partner(models.Model):
s = 4
if partner.member_lines:
for mline in partner.member_lines:
- if mline.date_to >= today and mline.date_from <= today:
+ if (mline.date_to or '0000-00-00') >= today and (mline.date_from or '0000-00-00') <= today:
if mline.account_invoice_line.invoice_id:
mstate = mline.account_invoice_line.invoice_id.state
if mstate == 'paid':
@@ -126,7 +126,7 @@ class Partner(models.Model):
s = 3
if s == 4:
for mline in partner.member_lines:
- if mline.date_from < today and mline.date_to < today and mline.date_from <= mline.date_to and mline.account_invoice_line and mline.account_invoice_line.invoice_id.state == 'paid':
+ if (mline.date_from or '0000-00-00') < today and (mline.date_to or '0000-00-00') < today and (mline.date_from or '0000-00-00') <= (mline.date_to or '0000-00-00') and mline.account_invoice_line and mline.account_invoice_line.invoice_id.state == 'paid':
s = 5
else:
s = 6
diff --git a/addons/mrp/i18n/mrp.pot b/addons/mrp/i18n/mrp.pot
index 617117deea1..0b2fac329be 100644
--- a/addons/mrp/i18n/mrp.pot
+++ b/addons/mrp/i18n/mrp.pot
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 11.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-10-10 11:34+0000\n"
-"PO-Revision-Date: 2017-10-10 11:34+0000\n"
+"POT-Creation-Date: 2017-10-16 11:13+0000\n"
+"PO-Revision-Date: 2017-10-16 11:13+0000\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -423,7 +423,6 @@ msgstr ""
#: model:ir.actions.act_window,name:mrp.template_open_bom
#: model:ir.model.fields,field_description:mrp.field_product_product_bom_ids
#: model:ir.model.fields,field_description:mrp.field_product_template_bom_ids
-#: model:ir.ui.view,arch_db:mrp.mrp_bom_line_tree_view
#: model:ir.ui.view,arch_db:mrp.mrp_bom_tree_view
#: model:ir.ui.view,arch_db:mrp.product_product_form_view_bom_button
#: model:ir.ui.view,arch_db:mrp.product_template_form_view_bom_button
@@ -431,11 +430,6 @@ msgid "Bill of Materials"
msgstr ""
#. module: mrp
-#: model:ir.actions.act_window,name:mrp.mrp_bom_line_action_from_bom
-msgid "Bill of Materials Structure"
-msgstr ""
-
-#. module: mrp
#: model:ir.model.fields,help:mrp.field_mrp_production_bom_id
msgid "Bill of Materials allow you to define the list of required raw materials to make a finished product."
msgstr ""
@@ -1203,6 +1197,21 @@ msgid "If the manufacturing order is unlocked you can add to the initial demand.
msgstr ""
#. module: mrp
+#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view
+msgid "If the product is a finished product: When processing a sales\n"
+" order for this product, the delivery order will contain the raw\n"
+" materials, instead of the finished product."
+msgstr ""
+
+#. module: mrp
+#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view
+msgid "If the product is a semi-finished product: When processing a\n"
+" manufacturing order that contains that product as component,\n"
+" the raw materials of that product will be added to the\n"
+" manufacturing order of the final product."
+msgstr ""
+
+#. module: mrp
#: model:ir.ui.view,arch_db:mrp.mrp_workcenter_kanban
#: model:ir.ui.view,arch_db:mrp.stock_production_type_kanban
#: model:ir.ui.view,arch_db:mrp.view_mrp_production_filter
@@ -1301,15 +1310,11 @@ msgstr ""
#. module: mrp
#: model:ir.ui.view,arch_db:mrp.view_mrp_bom_filter
+#: selection:mrp.bom,type:0
msgid "Kit"
msgstr ""
#. module: mrp
-#: model:ir.model.fields,help:mrp.field_mrp_bom_type
-msgid "Kit (Phantom): When processing a sales order for this product, the delivery order will contain the raw materials, instead of the finished product."
-msgstr ""
-
-#. module: mrp
#: model:product.product,name:mrp.product_product_laptop_charger
#: model:product.template,name:mrp.product_product_laptop_charger_product_template
#: model:stock.inventory.line,product_name:mrp.stock_inventory_line_product_laptop_charger
@@ -2602,11 +2607,6 @@ msgid "Settings"
msgstr ""
#. module: mrp
-#: selection:mrp.bom,type:0
-msgid "Ship this product as a set of components (kit)"
-msgstr ""
-
-#. module: mrp
#: code:addons/mrp/models/mrp_unbuild.py:89
#, python-format
msgid "Should have a lot for the finished product"
@@ -3207,12 +3207,6 @@ msgid "When a procurement has a ‘produce’ route with a operation type set, i
msgstr ""
#. module: mrp
-#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view
-msgid "When processing a sales order for this product, the delivery order\n"
-" will contain the raw materials, instead of the finished product."
-msgstr ""
-
-#. module: mrp
#: model:ir.model.fields,help:mrp.field_stock_warehouse_manufacture_to_resupply
msgid "When products are manufactured, they can be manufactured in this warehouse."
msgstr ""
diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py
index f41e4a5a111..4d9a0e5bcbe 100644
--- a/addons/mrp/models/mrp_bom.py
+++ b/addons/mrp/models/mrp_bom.py
@@ -24,9 +24,8 @@ class MrpBom(models.Model):
help="If the active field is set to False, it will allow you to hide the bills of material without removing it.")
type = fields.Selection([
('normal', 'Manufacture this product'),
- ('phantom', 'Ship this product as a set of components (kit)')], 'BoM Type',
- default='normal', required=True,
- help="Kit (Phantom): When processing a sales order for this product, the delivery order will contain the raw materials, instead of the finished product.")
+ ('phantom', 'Kit')], 'BoM Type',
+ default='normal', required=True)
product_tmpl_id = fields.Many2one(
'product.template', 'Product',
domain="[('type', 'in', ['product', 'consu'])]", required=True)
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index 59bafc27487..4a56ef217bc 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -107,7 +107,7 @@ class MrpProduction(models.Model):
copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
domain=[('scrapped', '=', False)])
finished_move_line_ids = fields.One2many(
- 'stock.move.line', compute='_compute_lines', inverse='_inverse_lines',
+ 'stock.move.line', compute='_compute_lines', inverse='_inverse_lines', string="Finished Product"
)
workorder_ids = fields.One2many(
'mrp.workorder', 'production_id', 'Work Orders',
diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml
index 19b56e17c2e..299f96c1b61 100644
--- a/addons/mrp/views/mrp_bom_views.xml
+++ b/addons/mrp/views/mrp_bom_views.xml
@@ -29,9 +29,20 @@
<group>
<field name="code"/>
<field name="type" widget="radio"/>
- <p colspan="2" class="oe_grey" attrs="{'invisible': [('type','=','normal')]}">
- When processing a sales order for this product, the delivery order
- will contain the raw materials, instead of the finished product.
+ <p colspan="2" class="oe_grey oe_edit_only" attrs="{'invisible': [('type','=','normal')]}">
+ <ul>
+ <li>
+ If the product is a finished product: When processing a sales
+ order for this product, the delivery order will contain the raw
+ materials, instead of the finished product.
+ </li>
+ <li>
+ If the product is a semi-finished product: When processing a
+ manufacturing order that contains that product as component,
+ the raw materials of that product will be added to the
+ manufacturing order of the final product.
+ </li>
+ </ul>
</p>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
</group>
diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml
index 42a3806dc0e..3fdf010c2af 100644
--- a/addons/mrp/views/mrp_production_views.xml
+++ b/addons/mrp/views/mrp_production_views.xml
@@ -131,7 +131,7 @@
</field>
</page>
<page string="Finished Products">
- <field name="finished_move_line_ids" attrs="{'readonly': [('is_locked', '=', True)], 'invisible': [('finished_move_line_ids', '=', [])]}">
+ <field name="finished_move_line_ids" context="{'form_view_ref': 'mrp.view_finisehd_move_line'}" attrs="{'readonly': [('is_locked', '=', True)], 'invisible': [('finished_move_line_ids', '=', [])]}">
<tree default_order="done_move" editable="bottom" create="0" delete="0" decoration-muted="state in ('done', 'cancel')">
<field name="product_id" readonly="1"/>
<field name="lot_id" groups="stock.group_production_lot" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}" attrs="{'invisible': [('lots_visible', '=', False)]}"/>
diff --git a/addons/mrp/views/mrp_workorder_views.xml b/addons/mrp/views/mrp_workorder_views.xml
index 994b4e1dd68..a3522f85999 100644
--- a/addons/mrp/views/mrp_workorder_views.xml
+++ b/addons/mrp/views/mrp_workorder_views.xml
@@ -140,13 +140,13 @@
<page string="Current Production">
<group>
<group>
- <field name="qty_producing" string="Current Qty" attrs="{'readonly': ['|', ('product_tracking', '=', 'serial'), ('state', 'in', ('done', 'cancel'))]}" readonly="1"/>
- <field name="final_lot_id" context="{'default_product_id': product_id}" domain="['|', '&amp;', ('product_id', '=', product_id), ('product_id.tracking', '!=', 'serial'), ('use_next_on_work_order_id', '=', id)]" attrs="{'invisible': [('product_tracking', '=', 'none')]}" groups="stock.group_production_lot" readonly="1"/>
+ <field name="qty_producing" string="Current Qty" attrs="{'readonly': ['|', ('product_tracking', '=', 'serial'), ('state', 'in', ('done', 'cancel'))]}"/>
+ <field name="final_lot_id" context="{'default_product_id': product_id}" domain="['|', '&amp;', ('product_id', '=', product_id), ('product_id.tracking', '!=', 'serial'), ('use_next_on_work_order_id', '=', id)]" attrs="{'invisible': [('product_tracking', '=', 'none')]}" groups="stock.group_production_lot"/>
<field name="product_tracking" invisible="1"/>
</group>
</group>
<field name="move_raw_ids" invisible="1"/>
- <field name="active_move_line_ids" attrs="{'invisible': [('active_move_line_ids', '=', [])]}" readonly="1">
+ <field name="active_move_line_ids" attrs="{'invisible': [('active_move_line_ids', '=', [])]}">
<tree editable="bottom" create="0" delete="0">
<field name="product_id"/>
<field name="product_uom_qty" readonly="1" string="To Consume"/>
diff --git a/addons/mrp/views/stock_move_views.xml b/addons/mrp/views/stock_move_views.xml
index f1007b8d26b..a6c2a07417a 100644
--- a/addons/mrp/views/stock_move_views.xml
+++ b/addons/mrp/views/stock_move_views.xml
@@ -63,6 +63,28 @@
</field>
</record>
+ <record id="view_finisehd_move_line" model="ir.ui.view">
+ <field name="name">mrp.finished.move.line.form</field>
+ <field name="model">stock.move.line</field>
+ <field name="arch" type="xml">
+ <form string="Finished Product">
+ <group>
+ <group>
+ <field name="product_id" readonly="1"/>
+ <label for="qty_done" string ="Quantity"/>
+ <div class="o_row">
+ <span><field name="qty_done" readonly="1" nolabel="1"/></span>
+ <span>/</span>
+ <span><field name="product_uom_qty" readonly="1" nolabel="1"/></span>
+ <span><field name="product_uom_id" attrs="{'readonly': [('id', '!=', False)]}" nolabel="1"/></span>
+ </div>
+ <field name="lot_id" string="Lot/Serial number" groups="stock.group_production_lot" readonly="1" attrs="{'invisible': [('lots_visible', '=', False)]}"/>
+ </group>
+ </group>
+ </form>
+ </field>
+ </record>
+
<menuitem id="menu_mrp_traceability"
name="Traceability: Lots"
parent="menu_mrp_reporting"
diff --git a/addons/mrp/wizard/change_production_qty.py b/addons/mrp/wizard/change_production_qty.py
index 526a12d8ffc..bf447feee61 100644
--- a/addons/mrp/wizard/change_production_qty.py
+++ b/addons/mrp/wizard/change_production_qty.py
@@ -39,7 +39,7 @@ class ChangeProductionQty(models.TransientModel):
def change_prod_qty(self):
for wizard in self:
production = wizard.mo_id
- produced = sum(production.move_finished_ids.mapped('quantity_done'))
+ produced = sum(production.move_finished_ids.filtered(lambda m: m.product_id == production.product_id).mapped('quantity_done'))
if wizard.product_qty < produced:
raise UserError(_("You have already processed %d. Please input a quantity higher than %d ")%(produced, produced))
production.write({'product_qty': wizard.product_qty})
diff --git a/addons/mrp_byproduct/models/mrp_production.py b/addons/mrp_byproduct/models/mrp_production.py
index 91917da03cc..05584d0d019 100644
--- a/addons/mrp_byproduct/models/mrp_production.py
+++ b/addons/mrp_byproduct/models/mrp_production.py
@@ -3,6 +3,8 @@
from odoo import api, models
+from odoo.tools import float_round
+
class MrpProduction(models.Model):
_description = 'Production'
@@ -42,3 +44,39 @@ class MrpProduction(models.Model):
for sub_product in production.bom_id.sub_products:
production._create_byproduct_move(sub_product)
return res
+
+
+class MrpProductProduce(models.TransientModel):
+ _name = "mrp.product.produce"
+ _description = "Record Production"
+ _inherit = "mrp.product.produce"
+
+ @api.multi
+ def check_finished_move_lots(self):
+ """ Handle by product tracked """
+ by_product_moves = self.production_id.move_finished_ids.filtered(lambda m: m.product_id != self.product_id and m.product_id.tracking != 'none' and m.state not in ('done', 'cancel'))
+ for by_product_move in by_product_moves:
+ rounding = by_product_move.product_uom.rounding
+ quantity = float_round(self.product_qty * by_product_move.unit_factor, precision_rounding=rounding)
+ values = {
+ 'move_id': by_product_move.id,
+ 'product_id': by_product_move.product_id.id,
+ 'production_id': self.production_id.id,
+ 'product_uom_id': by_product_move.product_uom.id,
+ 'location_id': by_product_move.location_id.id,
+ 'location_dest_id': by_product_move.location_dest_id.id,
+ }
+ if by_product_move.product_id.tracking == 'lot':
+ values.update({
+ 'product_uom_qty': quantity,
+ 'qty_done': quantity,
+ })
+ self.env['stock.move.line'].create(values)
+ else:
+ values.update({
+ 'product_uom_qty': 1.0,
+ 'qty_done': 1.0,
+ })
+ for i in range(0, int(quantity)):
+ self.env['stock.move.line'].create(values)
+ return super(MrpProductProduce, self).check_finished_move_lots()
diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py
index 55cad84fd9d..27b36a902bf 100644
--- a/addons/payment_stripe/controllers/main.py
+++ b/addons/payment_stripe/controllers/main.py
@@ -53,9 +53,16 @@ class StripeController(http.Controller):
""" Create a payment transaction
Expects the result from the user input from checkout.js popup"""
- tx = request.env['payment.transaction'].sudo().browse(
- int(request.session.get('sale_transaction_id') or request.session.get('website_payment_tx_id', False))
- )
+ TX = request.env['payment.transaction']
+ tx = None
+ if post.get('tx_ref'):
+ tx = TX.sudo().search([('reference', '=', post['tx_ref'])])
+ if not tx:
+ tx_id = (post.get('tx_id') or request.session.get('sale_transaction_id') or
+ request.session.get('website_payment_tx_id'))
+ tx = TX.sudo().browse(int(tx_id))
+ if not tx:
+ raise werkzeug.exceptions.NotFound()
response = tx._create_stripe_charge(tokenid=post['tokenid'], email=post['email'])
_logger.info('Stripe: entering form_feedback with post data %s', pprint.pformat(response))
if response:
diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py
index e53901b71b4..232ec6d9d43 100644
--- a/addons/payment_stripe/models/payment.py
+++ b/addons/payment_stripe/models/payment.py
@@ -43,13 +43,13 @@ class PaymentAcquirerStripe(models.Model):
'amount': tx_values.get('amount'),
'currency': tx_values.get('currency') and tx_values.get('currency').name or '',
'currency_id': tx_values.get('currency') and tx_values.get('currency').id or '',
- 'address_line1': tx_values['partner_address'],
- 'address_city': tx_values['partner_city'],
- 'address_country': tx_values['partner_country'] and tx_values['partner_country'].name or '',
- 'email': tx_values['partner_email'],
- 'address_zip': tx_values['partner_zip'],
- 'name': tx_values['partner_name'],
- 'phone': tx_values['partner_phone'],
+ 'address_line1': tx_values.get('partner_address'),
+ 'address_city': tx_values.get('partner_city'),
+ 'address_country': tx_values.get('partner_country') and tx_values['partner_country'].name or '',
+ 'email': tx_values.get('partner_email'),
+ 'address_zip': tx_values.get('partner_zip'),
+ 'name': tx_values.get('partner_name'),
+ 'phone': tx_values.get('partner_phone'),
}
temp_stripe_tx_values['returndata'] = stripe_tx_values.pop('return_url', '')
@@ -107,14 +107,15 @@ class PaymentTransactionStripe(models.Model):
charge_params = {
'amount': int(self.amount if self.currency_id.name in INT_CURRENCIES else self.amount*100),
'currency': self.currency_id.name,
- 'metadata[reference]': self.reference
+ 'metadata[reference]': self.reference,
+ 'description': self.reference,
}
if acquirer_ref:
charge_params['customer'] = acquirer_ref
if tokenid:
charge_params['card'] = str(tokenid)
if email:
- charge_params['receipt_email'] = email
+ charge_params['receipt_email'] = email.strip()
r = requests.post(api_url_charge,
auth=(self.acquirer_id.stripe_secret_key, ''),
params=charge_params,
@@ -156,12 +157,17 @@ class PaymentTransactionStripe(models.Model):
transaction record. """
reference = data.get('metadata', {}).get('reference')
if not reference:
- error_msg = _(
- 'Stripe: invalid reply received from provider, missing reference. Additional message: %s'
- % data.get('error', {}).get('message', '')
- )
- _logger.error(error_msg)
+ stripe_error = data.get('error', {}).get('message', '')
+ _logger.error('Stripe: invalid reply received from stripe API, looks like '
+ 'the transaction failed. (error: %s)', stripe_error or 'n/a')
+ error_msg = _("We're sorry to report that the transaction has failed.")
+ if stripe_error:
+ error_msg += " " + (_("Stripe gave us the following info about the problem: '%s'") %
+ stripe_error)
+ error_msg += " " + _("Perhaps the problem can be solved by double-checking your "
+ "credit card details, or contacting your bank?")
raise ValidationError(error_msg)
+
tx = self.search([('reference', '=', reference)])
if not tx:
error_msg = (_('Stripe: no order found for reference %s') % reference)
@@ -231,6 +237,7 @@ class PaymentTokenStripe(models.Model):
'card[exp_month]': str(values['cc_expiry'][:2]),
'card[exp_year]': str(values['cc_expiry'][-2:]),
'card[cvc]': values['cvc'],
+ 'card[name]': values['cc_holder_name'],
}
r = requests.post(url_token,
auth=(payment_acquirer.stripe_secret_key, ''),
@@ -239,8 +246,12 @@ class PaymentTokenStripe(models.Model):
token = r.json()
if token.get('id'):
customer_params = {
- 'source': token['id']
+ 'source': token['id'],
+ 'description': values['cc_holder_name']
}
+ if values.get('partner_id'):
+ partner = self.env['res.partner'].browse(values['partner_id'])
+ customer_params['email'] = partner.email and partner.email.strip()
r = requests.post(url_customer,
auth=(payment_acquirer.stripe_secret_key, ''),
params=customer_params,
diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js
index c0b9ef3d5a8..759ef95b422 100644
--- a/addons/payment_stripe/static/src/js/stripe.js
+++ b/addons/payment_stripe/static/src/js/stripe.js
@@ -14,120 +14,111 @@ odoo.define('payment_stripe.stripe', function(require) {
'RWF', 'KRW', 'VUV', 'VND', 'XOF'
];
- function getStripeHandler(parentElement)
- {
- var handler = StripeCheckout.configure({
- key: $("input[name='stripe_key']", parentElement).val(),
- image: $("input[name='stripe_image']", parentElement).val(),
- locale: 'auto',
- token: function(token, args) {
- handler.isTokenGenerate = true;
- ajax.jsonRpc("/payment/stripe/create_charge", 'call', {
- tokenid: token.id,
- email: token.email,
- amount: $("input[name='amount']", parentElement).val(),
- acquirer_id: $("#acquirer_stripe", parentElement).val(),
- currency: $("input[name='currency']", parentElement).val(),
- invoice_num: $("input[name='invoice_num']", parentElement).val(),
- return_url: $("input[name='return_url']", parentElement).val()
- }).done(function(data){
- handler.isTokenGenerate = false;
- window.location.href = data;
- }).fail(function(){
- var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.message;
- var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
- wizard.appendTo($('body')).modal({'keyboard': true});
- });
- },
- });
- return handler;
+ if ($.blockUI) {
+ // our message needs to appear above the modal dialog
+ $.blockUI.defaults.baseZ = 2147483647; //same z-index as StripeCheckout
+ $.blockUI.defaults.css.border = '0';
+ $.blockUI.defaults.css["background-color"] = '';
+ $.blockUI.defaults.overlayCSS["opacity"] = '0.9';
}
- $(document).ready(function (){
- if (!$('.o_payment_form').length) {
- return $.Deferred().reject("DOM doesn't contain '.o_payment_form'");
- }
-
- $('#o_payment_form_pay').on('click', function(ev){
- // we retrieve the payment form
- var parent_form = ev.target.form;
- // then the checked radio
- var checked_radio = $('input[type="radio"]:checked', parent_form);
- // check if there's one checked radio
- if(checked_radio.length != 1) {
- return;
- }
- // if there's a checked radio, we retrieve the usefull data
- var acquirer_id = checked_radio.data('acquirerId');
- var provider = checked_radio.data('provider');
- var is_form_payment = checked_radio.data('form-payment') === "True";
- // now we check if the user has clicked on stripe radio button and wants to pay via the checkout form
- if(provider != "stripe" || is_form_payment !== true) {
- return;
+ var handler = StripeCheckout.configure({
+ key: $("input[name='stripe_key']").val(),
+ image: $("input[name='stripe_image']").val(),
+ locale: 'auto',
+ token: function(token, args) {
+ handler.isTokenGenerate = true;
+ if ($.blockUI) {
+ var msg = _t("Just one more second, confirming your payment...");
+ $.blockUI({
+ 'message': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' +
+ ' <br />' + msg +
+ '</h2>'
+ });
}
- // from here we retrieve the inputs that contains the data we need
- var provider_form = $("#o_payment_form_acq_" + acquirer_id, parent_form);
+ ajax.jsonRpc("/payment/stripe/create_charge", 'call', {
+ tokenid: token.id,
+ email: token.email,
+ amount: $("input[name='amount']").val(),
+ acquirer_id: $("#acquirer_stripe").val(),
+ currency: $("input[name='currency']").val(),
+ invoice_num: $("input[name='invoice_num']").val(),
+ tx_ref: $("input[name='invoice_num']").val(),
+ return_url: $("input[name='return_url']").val()
+ }).always(function(){
+ if ($.blockUI) {
+ $.unblockUI();
+ }
+ }).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];
+ var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
+ wizard.appendTo($('body')).modal({'keyboard': true});
+ });
+ },
+ });
- var so_id = $("input[name='return_url']", provider_form).val().match(/quote\/([0-9]+)/) || undefined;
- var access_token = $("input[name='return_url']", provider_form).val().match(/quote\/([0-9]+)\/([0-9a-zA-Z\-]+)/) || undefined;
- if (so_id) {
- so_id = parseInt(so_id[1]);
- }
- if(access_token) {
- access_token = access_token[2];
- }
+ $('#pay_stripe').on('click', function(e) {
+ // Open Checkout with further options
+ if(!$(this).find('i').length)
+ $(this).append('<i class="fa fa-spinner fa-spin"/>');
+ $(this).attr('disabled','disabled');
- if ($('.o_website_payment').length !== 0) {
- var currency = $("input[name='currency']", provider_form).val();
- var amount = parseFloat($("input[name='amount']", provider_form).val() || '0.0');
- if (!_.contains(int_currencies, currency)) {
- amount = amount*100;
- }
+ var $form = $(e.currentTarget).parents('form');
+ var acquirer_id = $(e.currentTarget).closest('div.oe_sale_acquirer_button,div.oe_quote_acquirer_button,div.o_website_payment_new_payment');
+ acquirer_id = acquirer_id.data('id') || acquirer_id.data('acquirer_id');
+ if (! acquirer_id) {
+ return false;
+ }
- ajax.jsonRpc('/website_payment/transaction', 'call', {
- reference: $("input[name='invoice_num']", provider_form).val(),
- amount: amount,
- currency_id: currency,
- acquirer_id: acquirer_id
- })
- var handler = getStripeHandler(provider_form);
- handler.open({
- name: $("input[name='merchant']", provider_form).val(),
- description: $("input[name='invoice_num']", provider_form).val(),
- currency: currency,
- amount: amount,
- });
- } else {
- var currency = $("input[name='currency']", provider_form).val();
- var amount = parseFloat($("input[name='amount']", provider_form).val() || '0.0');
- if (!_.contains(int_currencies, currency)) {
- amount = amount*100;
- }
+ var so_token = $("input[name='token']").val();
+ var so_id = $("input[name='return_url']").val().match(/quote\/([0-9]+)/) || undefined;
+ if (so_id) {
+ so_id = parseInt(so_id[1]);
+ }
- var url = '/shop/payment/transaction/';
- if(so_id) {
- url += so_id + '/';
- if(access_token) {
- url += access_token;
- }
- }
+ e.preventDefault();
- ajax.jsonRpc(url, 'call', {
- acquirer_id: acquirer_id
- }, {'async': false}).then(function (data) {
- try {
- provider_form.html(data);
- }
- catch(err) { } // here it will catch an error saying that payment_stripe.stripe is already started
- var handler = getStripeHandler(provider_form);
- handler.open({
- name: $("input[name='merchant']", provider_form).val(),
- description: $("input[name='invoice_num']", provider_form).val(),
- currency: currency,
- amount: amount,
- });
- });
+ var currency = $("input[name='currency']").val();
+ var currency_id = $("input[name='currency_id']").val();
+ var amount = parseFloat($("input[name='amount']").val() || '0.0');
+
+
+ if ($('.o_website_payment').length !== 0) {
+ var create_tx = ajax.jsonRpc('/website_payment/transaction', 'call', {
+ reference: $("input[name='invoice_num']").val(),
+ amount: amount, // exact amount, not stripe cents
+ currency_id: currency_id,
+ acquirer_id: acquirer_id
+ });
+ }
+ else if ($('.o_website_quote').length !== 0) {
+ var url = _.str.sprintf("/quote/%s/transaction/%s/%s", so_id, acquirer_id, so_token);
+ var create_tx = ajax.jsonRpc(url, 'call', {}).then(function (data) {
+ try { $form.html(data); } catch (e) {};
+ });
+ }
+ else {
+ var create_tx = ajax.jsonRpc('/shop/payment/transaction/' + acquirer_id, 'call', {
+ so_id: so_id,
+ so_token: so_token
+ }).then(function (data) {
+ try { $form.html(data); } catch (e) {};
+ });
+ }
+ create_tx.done(function () {
+ if (!_.contains(int_currencies, currency)) {
+ amount = amount*100;
}
+ handler.open({
+ name: $("input[name='merchant']").val(),
+ description: $("input[name='invoice_num']").val(),
+ email: $("input[name='email']").val(),
+ currency: currency,
+ amount: amount,
+ });
});
});
});
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index 6d5b1649dc3..fd150d96a61 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -223,7 +223,7 @@ class PosOrder(models.Model):
# We convert a new id object back to a dictionary to write to
# bridge between old and new api
inv_line = invoice_line._convert_to_write({name: invoice_line[name] for name in invoice_line._cache})
- inv_line.update(price_unit=line.price_unit, discount=line.discount)
+ inv_line.update(price_unit=line.price_unit, discount=line.discount, name=inv_name)
return InvoiceLine.sudo().create(inv_line)
def _create_account_move_line(self, session=None, move=None):
@@ -650,7 +650,7 @@ class PosOrder(models.Model):
return_picking = Picking.create(return_vals)
return_picking.message_post(body=message)
- for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_digits=l.product_id.uom_id.rounding)):
+ for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_rounding=l.product_id.uom_id.rounding)):
moves |= Move.create({
'name': line.name,
'product_uom': line.product_id.uom_id.id,
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 611cc9146de..fffd394244f 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -206,7 +206,7 @@ exports.PosModel = Backbone.Model.extend({
},
},{
model: 'account.tax',
- fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'],
+ fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids', 'sequence'],
domain: null,
loaded: function(self, taxes){
self.taxes = taxes;
@@ -1646,72 +1646,137 @@ exports.Orderline = Backbone.Model.extend({
return tax;
},
- _compute_all: function(tax, base_amount, quantity) {
+ /**
+ * Mirror JS method of:
+ * _compute_amount in addons/account/models/account.py
+ */
+ _compute_all: function(tax, base_amount, quantity, price_include) {
+ if(price_include === undefined)
+ price_include = tax.price_include;
if (tax.amount_type === 'fixed') {
var sign_base_amount = base_amount >= 0 ? 1 : -1;
return (Math.abs(tax.amount) * sign_base_amount) * quantity;
}
- if ((tax.amount_type === 'percent' && !tax.price_include) || (tax.amount_type === 'division' && tax.price_include)){
+ if ((tax.amount_type === 'percent' && !price_include) || (tax.amount_type === 'division' && tax.price_include)){
return base_amount * tax.amount / 100;
}
- if (tax.amount_type === 'percent' && tax.price_include){
+ if (tax.amount_type === 'percent' && price_include){
return base_amount - (base_amount / (1 + tax.amount / 100));
}
- if (tax.amount_type === 'division' && !tax.price_include) {
+ if (tax.amount_type === 'division' && !price_include) {
return base_amount / (1 - tax.amount / 100) - base_amount;
}
return false;
},
+ /**
+ * Mirror JS method of:
+ * compute_all in addons/account/models/account.py
+ *
+ * Read comments in the python side method for more details about each sub-methods.
+ */
compute_all: function(taxes, price_unit, quantity, currency_rounding, no_map_tax) {
var self = this;
- var list_taxes = [];
+
+ // 1) Flatten the taxes.
+
+ var _collect_taxes = function(taxes, all_taxes){
+ taxes.sort(function (tax1, tax2) {
+ return tax1.sequence - tax2.sequence;
+ });
+ _(taxes).each(function(tax){
+ if(tax.amount_type === 'group')
+ all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes);
+ else
+ all_taxes.push(tax);
+ });
+ return all_taxes;
+ }
+ var collect_taxes = function(taxes){
+ return _collect_taxes(taxes, []);
+ }
+
+ taxes = collect_taxes(taxes);
+
+ // 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True
+ // with price_include=True
+
+ var base_excluded_flag = false; // price_include=False && include_base_amount=True
+ var included_flag = false; // price_include=True
+ _(taxes).each(function(tax){
+ if(tax.price_include)
+ included_flag = true;
+ else if(tax.include_base_amount)
+ base_excluded_flag = true
+ if(base_excluded_flag && included_flag)
+ throw new Error('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.');
+ });
+
+ // 3) Deal with the rounding methods
+
var currency_rounding_bak = currency_rounding;
if (this.pos.company.tax_calculation_rounding_method == "round_globally"){
currency_rounding = currency_rounding * 0.00001;
}
- var total_excluded = round_pr(price_unit * quantity, currency_rounding);
- var total_included = total_excluded;
- var base = total_excluded;
- _(taxes).each(function(tax) {
- if (!no_map_tax){
- tax = self._map_tax_fiscal_position(tax);
- }
- if (!tax){
- return;
- }
- if (tax.amount_type === 'group'){
- var ret = self.compute_all(tax.children_tax_ids, price_unit, quantity, currency_rounding);
- total_excluded = ret.total_excluded;
- base = ret.total_excluded;
- total_included = ret.total_included;
- list_taxes = list_taxes.concat(ret.taxes);
+
+ var recompute_base = function(base_amount, fixed_amount, percent_amount){
+ if(fixed_amount === 0.0 && percent_amount === 0.0)
+ return base_amount;
+ return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0);
+ }
+
+ // 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation.
+
+ var base = round_pr(price_unit * quantity, currency_rounding);
+ var sign = 1;
+ if(base < 0){
+ base = -base;
+ var sign = -1;
+ }
+
+ var incl_fixed_amount = 0.0;
+ var incl_percent_amount = 0.0;
+ _(taxes.reverse()).each(function(tax){
+ if(tax.include_base_amount){
+ base = recompute_base(base, incl_fixed_amount, incl_percent_amount);
+ incl_fixed_amount = 0.0;
+ incl_percent_amount = 0.0;
}
- else {
- var tax_amount = self._compute_all(tax, base, quantity);
- tax_amount = round_pr(tax_amount, currency_rounding);
-
- if (tax_amount){
- if (tax.price_include) {
- total_excluded -= tax_amount;
- base -= tax_amount;
- }
- else {
- total_included += tax_amount;
- }
- if (tax.include_base_amount) {
- base += tax_amount;
- }
- var data = {
- id: tax.id,
- amount: tax_amount,
- name: tax.name,
- };
- list_taxes.push(data);
- }
+ if(tax.price_include){
+ if(tax.amount_type === 'fixed')
+ incl_fixed_amount += tax.amount;
+ else if(tax.amount_type === 'percent')
+ incl_percent_amount += tax.amount;
}
});
+
+ base = recompute_base(base, incl_fixed_amount, incl_percent_amount);
+ var total_excluded = base;
+ var total_included = base;
+
+ // 5) Iterate the taxes in the sequence order to fill missing base/amount values.
+
+ var taxes_vals = [];
+ _(taxes.reverse()).each(function(tax){
+ var tax_amount = self._compute_all(tax, base, quantity, false);
+ tax_amount = round_pr(tax_amount, currency_rounding);
+
+ var tax_base = base;
+
+ if(tax.include_base_amount)
+ base += tax_amount;
+
+ total_included += tax_amount;
+
+ var tax_vals = {
+ id: tax.id,
+ amount: sign * tax_amount,
+ name: tax.name,
+ };
+
+ taxes_vals.push(tax_vals);
+ });
return {
- taxes: list_taxes,
+ taxes: taxes_vals,
total_excluded: round_pr(total_excluded, currency_rounding_bak),
total_included: round_pr(total_included, currency_rounding_bak)
};
diff --git a/addons/portal/static/src/less/portal_frontend.less b/addons/portal/static/src/less/portal_frontend.less
index 33c2bf0969b..ebfc117b9dc 100644
--- a/addons/portal/static/src/less/portal_frontend.less
+++ b/addons/portal/static/src/less/portal_frontend.less
@@ -72,3 +72,7 @@
text-align: center;
}
}
+
+.label-optional {
+ font-weight: normal;
+}
diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml
index 7cb351818fc..3a8a64aba48 100644
--- a/addons/portal/views/portal_templates.xml
+++ b/addons/portal/views/portal_templates.xml
@@ -261,7 +261,7 @@
<div class="clearfix" />
<div t-attf-class="form-group #{error.get('street') and 'has-error' or ''} col-lg-6">
- <label class="control-label label-optional" for="street">Street</label>
+ <label class="control-label" for="street">Street</label>
<input type="text" name="street" class="form-control" t-att-value="street or partner.street"/>
</div>
<div t-attf-class="form-group #{error.get('city') and 'has-error' or ''} col-lg-6">
diff --git a/addons/product/models/product.py b/addons/product/models/product.py
index b854925701e..61704ad44f8 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -283,7 +283,8 @@ class ProductProduct(models.Model):
for value in product.attribute_value_ids:
if value.attribute_id in attributes:
raise ValidationError(_('Error! It is not allowed to choose more than one value for a given attribute.'))
- attributes |= value.attribute_id
+ if value.attribute_id.create_variant:
+ attributes |= value.attribute_id
return True
@api.onchange('uom_id', 'uom_po_id')
diff --git a/addons/purchase/models/stock.py b/addons/purchase/models/stock.py
index 64aa739daaa..4edd90aafbb 100644
--- a/addons/purchase/models/stock.py
+++ b/addons/purchase/models/stock.py
@@ -31,6 +31,19 @@ class StockMove(models.Model):
created_purchase_line_id = fields.Many2one('purchase.order.line',
'Created Purchase Order Line', ondelete='set null', readonly=True, copy=False)
+ @api.model
+ def _prepare_merge_moves_distinct_fields(self):
+ distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields()
+ distinct_fields.append('purchase_line_id')
+ return distinct_fields
+
+ @api.model
+ def _prepare_merge_move_sort_method(self, move):
+ move.ensure_one()
+ keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move)
+ keys_sorted.append(move.purchase_line_id.id)
+ return keys_sorted
+
@api.multi
def _get_price_unit(self):
""" Returns the unit price for the move"""
@@ -148,4 +161,4 @@ class Orderpoint(models.Model):
res = super(Orderpoint, self)._quantity_in_progress()
for poline in self.env['purchase.order.line'].search([('state','in',('draft','sent','to approve')),('orderpoint_id','in',self.ids)]):
res[poline.orderpoint_id.id] += poline.product_uom._compute_quantity(poline.product_qty, poline.orderpoint_id.product_uom, round=False)
- return res \ No newline at end of file
+ return res
diff --git a/addons/purchase/tests/__init__.py b/addons/purchase/tests/__init__.py
index 924cfc8c823..fd93f8e2b61 100644
--- a/addons/purchase/tests/__init__.py
+++ b/addons/purchase/tests/__init__.py
@@ -5,3 +5,4 @@ from . import test_onchange_product_id
from . import test_purchase_order
from . import test_create_picking
from . import test_purchase_lead_time
+from . import test_stockvaluation
diff --git a/addons/purchase/tests/test_stockvaluation.py b/addons/purchase/tests/test_stockvaluation.py
new file mode 100644
index 00000000000..09359417738
--- /dev/null
+++ b/addons/purchase/tests/test_stockvaluation.py
@@ -0,0 +1,200 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import time
+from datetime import datetime
+
+from odoo.tests.common import TransactionCase
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
+
+
+class TestStockValuation(TransactionCase):
+ def setUp(self):
+ super(TestStockValuation, self).setUp()
+ self.supplier_location = self.env.ref('stock.stock_location_suppliers')
+ 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')
+ Account = self.env['account.account']
+ self.stock_input_account = Account.create({
+ 'name': 'Stock Input',
+ 'code': 'StockIn',
+ 'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
+ })
+ self.stock_output_account = Account.create({
+ 'name': 'Stock Output',
+ 'code': 'StockOut',
+ 'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
+ })
+ self.stock_valuation_account = Account.create({
+ 'name': 'Stock Valuation',
+ 'code': 'Stock Valuation',
+ 'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
+ })
+ self.stock_journal = self.env['account.journal'].create({
+ 'name': 'Stock Journal',
+ 'code': 'STJTEST',
+ 'type': 'general',
+ })
+ self.product1.categ_id.write({
+ 'property_stock_account_input_categ_id': self.stock_input_account.id,
+ 'property_stock_account_output_categ_id': self.stock_output_account.id,
+ 'property_stock_valuation_account_id': self.stock_valuation_account.id,
+ 'property_stock_journal': self.stock_journal.id,
+ })
+
+ def test_change_unit_cost_average_1(self):
+ """ Confirm a purchase order and create the associated receipt, change the unit cost of the
+ purchase order before validating the receipt, the value of the received goods should be set
+ according to the last unit cost.
+ """
+ self.product1.product_tmpl_id.cost_method = 'average'
+ po1 = self.env['purchase.order'].create({
+ 'partner_id': self.partner_id.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.product1.name,
+ 'product_id': self.product1.id,
+ 'product_qty': 10.0,
+ 'product_uom': self.product1.uom_po_id.id,
+ 'price_unit': 100.0,
+ 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ }),
+ ],
+ })
+ po1.button_confirm()
+
+ picking1 = po1.picking_ids[0]
+ move1 = picking1.move_lines[0]
+
+ # the unit price of the purchase order line is copied to the in move
+ self.assertEquals(move1.price_unit, 100)
+
+ # update the unit price on the purchase order line
+ po1.order_line.price_unit = 200
+
+ # the unit price on the stock move is not directly updated
+ self.assertEquals(move1.price_unit, 100)
+
+ # validate the receipt
+ res_dict = picking1.button_validate()
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ wizard.process()
+
+ # the unit price of the stock move has been updated to the latest value
+ self.assertEquals(move1.price_unit, 200)
+
+ self.assertEquals(self.product1.stock_value, 2000)
+
+ def test_standard_price_change_1(self):
+ """ Confirm a purchase order and create the associated receipt, change the unit cost of the
+ purchase order and the standard price of the product before validating the receipt, the
+ value of the received goods should be set according to the last standard price.
+ """
+ self.product1.product_tmpl_id.cost_method = 'standard'
+
+ # set a standard price
+ self.product1.product_tmpl_id.standard_price = 10
+
+ po1 = self.env['purchase.order'].create({
+ 'partner_id': self.partner_id.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.product1.name,
+ 'product_id': self.product1.id,
+ 'product_qty': 10.0,
+ 'product_uom': self.product1.uom_po_id.id,
+ 'price_unit': 11.0,
+ 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ }),
+ ],
+ })
+ po1.button_confirm()
+
+ picking1 = po1.picking_ids[0]
+ move1 = picking1.move_lines[0]
+
+ # the move's unit price reflects the purchase order line's cost even if it's useless when
+ # the product's cost method is standard
+ self.assertEquals(move1.price_unit, 11)
+
+ # set a new standard price
+ self.product1.product_tmpl_id.standard_price = 12
+
+ # the unit price on the stock move is not directly updated
+ self.assertEquals(move1.price_unit, 11)
+
+ # validate the receipt
+ res_dict = picking1.button_validate()
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ wizard.process()
+
+ # the unit price of the stock move has been updated to the latest value
+ self.assertEquals(move1.price_unit, 12)
+
+ self.assertEquals(self.product1.stock_value, 120)
+
+ def test_change_currency_rate_average_1(self):
+ """ Confirm a purchase order in another currency and create the associated receipt, change
+ the currency rate, validate the receipt and then check that the value of the received goods
+ is set according to the last currency rate.
+ """
+ usd_currency = self.env.ref('base.USD')
+ self.env.user.company_id.currency_id = usd_currency.id
+
+ eur_currency = self.env.ref('base.EUR')
+
+ self.product1.product_tmpl_id.cost_method = 'average'
+
+ # default currency is USD, create a purchase order in EUR
+ po1 = self.env['purchase.order'].create({
+ 'partner_id': self.partner_id.id,
+ 'currency_id': eur_currency.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.product1.name,
+ 'product_id': self.product1.id,
+ 'product_qty': 10.0,
+ 'product_uom': self.product1.uom_po_id.id,
+ 'price_unit': 100.0,
+ 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ }),
+ ],
+ })
+ po1.button_confirm()
+
+ picking1 = po1.picking_ids[0]
+ move1 = picking1.move_lines[0]
+
+ # convert the price unit in the company currency
+ price_unit_usd = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=False)
+
+ # the unit price of the move is the unit price of the purchase order line converted in
+ # the company's currency
+ self.assertEqual(move1.price_unit, price_unit_usd)
+
+ # change the rate of the currency
+ self.env['res.currency.rate'].create({
+ 'name': time.strftime('%Y-%m-%d'),
+ 'rate': 2.0,
+ 'currency_id': eur_currency.id,
+ 'company_id': po1.company_id.id,
+ })
+ eur_currency._compute_current_rate()
+ price_unit_usd_new_rate = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=False)
+
+ # the new price_unit is lower than th initial because of the rate's change
+ self.assertLess(price_unit_usd_new_rate, price_unit_usd)
+
+ # the unit price on the stock move is not directly updated
+ self.assertEquals(move1.price_unit, price_unit_usd)
+
+ # validate the receipt
+ res_dict = picking1.button_validate()
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ wizard.process()
+
+ # the unit price of the stock move has been updated to the latest value
+ self.assertEquals(move1.price_unit, price_unit_usd_new_rate)
+
+ self.assertAlmostEqual(self.product1.stock_value, price_unit_usd_new_rate * 10, delta=0.1)
diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py
index b50faa855fc..6d215d7160a 100644
--- a/addons/resource/models/resource.py
+++ b/addons/resource/models/resource.py
@@ -721,7 +721,7 @@ class ResourceCalendarLeaves(models.Model):
date_from = fields.Datetime('Start Date', required=True)
date_to = fields.Datetime('End Date', required=True)
tz = fields.Selection(
- _tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz),
+ _tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz or 'UTC'),
help="Timezone used when encoding the leave. It is used to correctly"
"localize leave hours when computing time intervals.")
resource_id = fields.Many2one(
diff --git a/addons/sale/__init__.py b/addons/sale/__init__.py
index 59347877995..ba3e6977f3c 100644
--- a/addons/sale/__init__.py
+++ b/addons/sale/__init__.py
@@ -20,6 +20,6 @@ def uninstall_hook(cr, registry):
if 'crm.team' in env:
recs = env['crm.team'].search([])
for rec in recs:
- rec._onchange_team_type()
+ rec._onchange_team_type()
- cr.after("commit", partial(update_dashboard_graph_model, cr.dbname)) \ No newline at end of file
+ cr.after("commit", partial(update_dashboard_graph_model, cr.dbname))
diff --git a/addons/sale/data/mail_template_data.xml b/addons/sale/data/mail_template_data.xml
index effcd48d714..e83ed891611 100644
--- a/addons/sale/data/mail_template_data.xml
+++ b/addons/sale/data/mail_template_data.xml
@@ -72,13 +72,13 @@ from ${object.company_id.name}.
% set record = ctx.get('record')
% set company = record and record.company_id or user.company_id
<body style="margin: 0; padding: 0;">
- <table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed" summary="o_mail_notification">
+ <table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed; border-collapse:separate;" summary="o_mail_notification">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
- <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
+ <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle">
<span style="font-size:20px; color:white; font-weight: bold;">
@@ -96,7 +96,7 @@ from ${object.company_id.name}.
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
- <table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px;">
+ <table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px; border-collapse:separate;">
<tbody>
<td valign="top" style="font-family:Arial,Helvetica,sans-serif; color: #555; font-size: 14px;">
${object.body | safe}
@@ -109,7 +109,7 @@ from ${object.company_id.name}.
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
- <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
+ <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle" align="left" style="color: #fff; padding-top: 10px; padding-bottom: 10px; font-size: 12px;">
${company.name}<br/>
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 177deb30c50..2fd6a90797e 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -693,6 +693,14 @@ class SaleOrderLine(models.Model):
'price_subtotal': taxes['total_excluded'],
})
+ @api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered')
+ def _compute_product_updatable(self):
+ for line in self:
+ if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)):
+ line.product_updatable = False
+ else:
+ line.product_updatable = True
+
@api.depends('product_id.invoice_policy', 'order_id.state')
def _compute_qty_delivered_updateable(self):
for line in self:
@@ -852,6 +860,7 @@ class SaleOrderLine(models.Model):
discount = fields.Float(string='Discount (%)', digits=dp.get_precision('Discount'), default=0.0)
product_id = fields.Many2one('product.product', string='Product', domain=[('sale_ok', '=', True)], change_default=True, ondelete='restrict', required=True)
+ product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True)
product_uom_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0)
product_uom = fields.Many2one('product.uom', string='Unit of Measure', required=True)
# Non-stored related field to allow portal user to see the image of the product he has ordered
@@ -964,7 +973,8 @@ class SaleOrderLine(models.Model):
return price
else:
from_currency = self.order_id.company_id.currency_id
- return from_currency.compute(product.lst_price, self.order_id.pricelist_id.currency_id)
+ product_price = product[pricelist_item.base] if (pricelist_item and pricelist_item.base != 'pricelist') else product.lst_price
+ return from_currency.compute(product_price, self.order_id.pricelist_id.currency_id)
@api.multi
@api.onchange('product_id')
diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py
index e0892afed10..eee80eedc17 100644
--- a/addons/sale/tests/test_sale_order.py
+++ b/addons/sale/tests/test_sale_order.py
@@ -21,10 +21,13 @@ class TestSaleOrder(TestSale):
'pricelist_id': self.env.ref('product.list0').id,
})
self.assertEqual(so.amount_total, sum([2 * p.list_price for p in self.products.values()]), 'Sale: total amount is wrong')
-
+ so.order_line._compute_product_updatable()
+ self.assertTrue(so.order_line[0].product_updatable)
# send quotation
so.force_quotation_send()
self.assertTrue(so.state == 'sent', 'Sale: state after sending is wrong')
+ so.order_line._compute_product_updatable()
+ self.assertTrue(so.order_line[0].product_updatable)
# confirm quotation
so.action_confirm()
@@ -38,7 +41,8 @@ class TestSaleOrder(TestSale):
self.assertEqual(inv.amount_total, sum([2 * p.list_price if p.invoice_policy == 'order' else 0 for p in self.products.values()]), 'Sale: invoice total amount is wrong')
self.assertTrue(so.invoice_status == 'no', 'Sale: SO status after invoicing should be "nothing to invoice"')
self.assertTrue(len(so.invoice_ids) == 1, 'Sale: invoice is missing')
-
+ so.order_line._compute_product_updatable()
+ self.assertFalse(so.order_line[0].product_updatable)
# deliver lines except 'time and material' then invoice again
for line in so.order_line:
line.qty_delivered = 2 if line.product_id.expense_policy=='no' else 0
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index 1367f5344ff..dce70b92f63 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -239,9 +239,10 @@
<form string="Sales Order Lines">
<group>
<group>
+ <field name="product_updatable" invisible="1"/>
<field name="product_id"
context="{'partner_id':parent.partner_id, 'quantity':product_uom_qty, 'pricelist':parent.pricelist_id, 'uom':product_uom, 'company_id': parent.company_id}"
- attrs="{'readonly': [('qty_invoiced', '&gt;', 0)]}"
+ attrs="{'readonly': [('product_updatable', '=', False)]}"
force_save="1"
/>
<field name="layout_category_id" groups="sale.group_sale_layout"/>
@@ -294,8 +295,9 @@
</form>
<tree string="Sales Order Lines" editable="bottom" decoration-info="invoice_status=='to invoice'">
<field name="sequence" widget="handle"/>
+ <field name="product_updatable" invisible="1"/>
<field name="product_id"
- attrs="{'readonly': [('qty_invoiced', '&gt;', 0)]}"
+ attrs="{'readonly': [('product_updatable', '=', False)]}"
context="{'partner_id':parent.partner_id, 'quantity':product_uom_qty, 'pricelist':parent.pricelist_id, 'uom':product_uom, 'company_id': parent.company_id}"
/>
<field name="layout_category_id" groups="sale.group_sale_layout"/>
diff --git a/addons/sale_stock/models/account_invoice.py b/addons/sale_stock/models/account_invoice.py
index 930e5cf0bb2..4b9212beb8f 100644
--- a/addons/sale_stock/models/account_invoice.py
+++ b/addons/sale_stock/models/account_invoice.py
@@ -51,7 +51,7 @@ class AccountInvoiceLine(models.Model):
qty_to_consider = invoiced_qty - qty_done
qty_to_consider = min(qty_to_consider, quantity - qty_delivered)
qty_delivered += qty_to_consider
- average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + move.price_unit * qty_to_consider) / qty_delivered
+ average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + (-1 * move.price_unit) * qty_to_consider) / qty_delivered
if qty_delivered == quantity:
break
return average_price_unit
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index aa55b213294..38a01917cf4 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -127,14 +127,24 @@ class SaleOrderLine(models.Model):
and all(move.state in ['done', 'cancel'] for move in line.move_ids):
line.invoice_status = 'invoiced'
+ @api.depends('move_ids')
+ def _compute_product_updatable(self):
+ for line in self:
+ if not line.move_ids:
+ super(SaleOrderLine, line)._compute_product_updatable()
+ else:
+ line.product_updatable = False
+
@api.multi
@api.depends('product_id')
def _compute_qty_delivered_updateable(self):
- for line in self:
- if line.product_id.type not in ('consu', 'product'):
- super(SaleOrderLine, line)._compute_qty_delivered_updateable()
- else:
- line.qty_delivered_updateable = False
+ # prefetch field before filtering
+ self.mapped('product_id')
+ # on consumable or stockable products, qty_delivered_updateable defaults
+ # to False; on other lines use the original computation
+ lines = self.filtered(lambda line: line.product_id.type not in ('consu', 'product'))
+ lines = lines.with_prefetch(self._prefetch)
+ super(SaleOrderLine, lines)._compute_qty_delivered_updateable()
@api.onchange('product_id')
def _onchange_product_id_set_customer_lead(self):
@@ -225,7 +235,7 @@ class SaleOrderLine(models.Model):
if line.state != 'sale' or not line.product_id.type in ('consu','product'):
continue
qty = 0.0
- for move in line.move_ids:
+ for move in line.move_ids.filtered(lambda r: r.state != 'cancel'):
qty += move.product_qty
if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0:
continue
diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py
index aed1a510c9e..c29193ce541 100644
--- a/addons/sale_stock/models/stock.py
+++ b/addons/sale_stock/models/stock.py
@@ -13,6 +13,19 @@ class StockMove(models.Model):
_inherit = "stock.move"
sale_line_id = fields.Many2one('sale.order.line', 'Sale Line')
+ @api.model
+ def _prepare_merge_moves_distinct_fields(self):
+ distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields()
+ distinct_fields.append('sale_line_id')
+ return distinct_fields
+
+ @api.model
+ def _prepare_merge_move_sort_method(self, move):
+ move.ensure_one()
+ keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move)
+ keys_sorted.append(move.sale_line_id.id)
+ return keys_sorted
+
def _action_done(self):
result = super(StockMove, self)._action_done()
for line in self.mapped('sale_line_id'):
diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py
index 4880c8fdd12..3655cf61807 100644
--- a/addons/sale_stock/tests/test_sale_stock.py
+++ b/addons/sale_stock/tests/test_sale_stock.py
@@ -80,7 +80,11 @@ class TestSaleStock(TestSale):
for sol in self.so.order_line:
sol.product_id.invoice_policy = 'order'
# confirm our standard so, check the picking
+ self.so.order_line._compute_product_updatable()
+ self.assertTrue(self.so.order_line[0].product_updatable)
self.so.action_confirm()
+ self.so.order_line._compute_product_updatable()
+ self.assertFalse(self.so.order_line[0].product_updatable)
self.assertTrue(self.so.picking_ids, 'Sale Stock: no picking created for "invoice on order" stockable products')
# let's do an invoice for a deposit of 5%
adv_wiz = self.env['sale.advance.payment.inv'].with_context(active_ids=[self.so.id]).create({
diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py
index e65f7580af7..0cb9db63bae 100644
--- a/addons/sale_timesheet/__manifest__.py
+++ b/addons/sale_timesheet/__manifest__.py
@@ -16,6 +16,7 @@ have real delivered quantities in sales orders.
'website': 'https://www.odoo.com/page/warehouse',
'depends': ['sale_management', 'hr_timesheet'],
'data': [
+ 'security/sale_timesheet_security.xml',
'views/account_invoice_views.xml',
'views/sale_order_views.xml',
'views/product_views.xml',
diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py
index 2095abee936..1f3e9b01103 100644
--- a/addons/sale_timesheet/controllers/main.py
+++ b/addons/sale_timesheet/controllers/main.py
@@ -55,8 +55,8 @@ class SaleTimesheetController(http.Controller):
dashboard_values['hours'][billable_type] = float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
dashboard_values['hours']['total'] += float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
# rates
- dashboard_values['rates'][billable_type] = float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding)
- dashboard_values['rates']['total'] += float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding)
+ dashboard_values['rates'][billable_type] = round(data.get('unit_amount') / dashboard_total_hours * 100, 2)
+ dashboard_values['rates']['total'] += round(data.get('unit_amount') / dashboard_total_hours * 100, 2)
# money_amount
so_lines = values['timesheet_lines'].mapped('so_line')
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
index 1eda2b9aa7a..3fe382701d3 100644
--- a/addons/sale_timesheet/models/product.py
+++ b/addons/sale_timesheet/models/product.py
@@ -52,3 +52,12 @@ class ProductTemplate(models.Model):
def _onchange_service_tracking(self):
if self.service_tracking != 'task_global_project':
self.project_id = False
+
+ @api.onchange('type')
+ def _onchange_type(self):
+ if self.type == 'service':
+ self.invoice_policy = 'order'
+ self.service_type = 'timesheet'
+ elif self.type == 'consu':
+ self.invoice_policy = 'order'
+ self.service_type = 'manual'
diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py
index cddb6f6abd9..40dc9e2dd2d 100644
--- a/addons/sale_timesheet/models/sale_order.py
+++ b/addons/sale_timesheet/models/sale_order.py
@@ -144,6 +144,14 @@ class SaleOrderLine(models.Model):
for so_line in self:
so_line.is_service = so_line.product_id.type == 'service'
+ @api.depends('product_id.type')
+ def _compute_product_updatable(self):
+ for line in self:
+ if line.product_id.type == 'service' and line.state == 'sale':
+ line.product_updatable = False
+ else:
+ super(SaleOrderLine, line)._compute_product_updatable()
+
@api.model
def create(self, values):
line = super(SaleOrderLine, self).create(values)
diff --git a/addons/sale_timesheet/security/sale_timesheet_security.xml b/addons/sale_timesheet/security/sale_timesheet_security.xml
new file mode 100644
index 00000000000..f038e964ad8
--- /dev/null
+++ b/addons/sale_timesheet/security/sale_timesheet_security.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+
+ <data noupdate="1">
+
+ <record id="account_analytic_line_rule_billing_user" model="ir.rule">
+ <field name="name">account.analytic.line.billing.user</field>
+ <field name="model_id" ref="analytic.model_account_analytic_line"/>
+ <field name="domain_force">[('project_id', '=', False)]</field>
+ <field name="groups" eval="[(4, ref('account.group_account_invoice'))]"/>
+ <field name="perm_create" eval="1"/>
+ <field name="perm_write" eval="1"/>
+ <field name="perm_unlink" eval="1"/>
+ <field name="perm_read" eval="0"/>
+ </record>
+
+ </data>
+
+</odoo>
diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py
index 388a3830122..81d9aaecc10 100644
--- a/addons/sale_timesheet/tests/test_sale_service.py
+++ b/addons/sale_timesheet/tests/test_sale_service.py
@@ -25,7 +25,11 @@ class TestSaleService(CommonTest):
'pricelist_id': self.pricelist_usd.id,
}
sale_order = self.env['sale.order'].create(sale_order_vals)
+ sale_order.order_line._compute_product_updatable()
+ self.assertTrue(sale_order.order_line[0].product_updatable)
sale_order.action_confirm()
+ sale_order.order_line._compute_product_updatable()
+ self.assertFalse(sale_order.order_line[0].product_updatable)
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
# check task creation
diff --git a/addons/sms/wizard/send_sms.py b/addons/sms/wizard/send_sms.py
index 25fa0828934..7aa76205a9f 100644
--- a/addons/sms/wizard/send_sms.py
+++ b/addons/sms/wizard/send_sms.py
@@ -3,7 +3,7 @@
import logging
-from odoo import api, fields, models
+from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.addons.iap.models import iap
diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py
index 00ecb6d6118..2aa444223b1 100644
--- a/addons/stock/models/product.py
+++ b/addons/stock/models/product.py
@@ -500,7 +500,7 @@ class ProductTemplate(models.Model):
done_moves = self.env['stock.move'].search([('product_id', 'in', updated.mapped('product_variant_ids').ids)], limit=1)
if done_moves:
raise UserError(_("You can not change the unit of measure of a product that has already been used in a done stock move. If you need to change the unit of measure, you may deactivate this product."))
- if 'type' in vals:
+ if any('type' in vals and vals['type'] != prod_tmpl.type for prod_tmpl in self):
existing_move_lines = self.env['stock.move.line'].search([
('product_id', 'in', self.mapped('product_variant_ids').ids),
('state', 'in', ['partially_available', 'assigned']),
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 7bfa320bb50..f37554cf583 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -514,25 +514,35 @@ class StockMove(models.Model):
'origin': origin,
}
+ @api.model
+ def _prepare_merge_moves_distinct_fields(self):
+ return [
+ 'product_id', 'price_unit', 'product_packaging',
+ 'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id'
+ ]
+
+ @api.model
+ def _prepare_merge_move_sort_method(self, move):
+ move.ensure_one()
+ return [
+ move.product_id.id, move.price_unit, move.product_packaging.id, move.product_uom.id,
+ move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id
+ ]
+
def _merge_moves(self):
""" This method will, for each move in `self`, go up in their linked picking and try to
find in their existing moves a candidate into which we can merge the move.
:return: Recordset of moves passed to this method. If some of the passed moves were merged
into another existing one, return this one and not the (now unlinked) original.
"""
- distinct_fields = ['product_id', 'price_unit', 'product_packaging',
- 'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id']
-
- def _keys_sorted(move):
- return move.product_id.id, move.price_unit, move.product_packaging.id, move.product_uom.id,\
- move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id
+ distinct_fields = self._prepare_merge_moves_distinct_fields()
# Move removed after merge
moves_to_unlink = self.env['stock.move']
moves_to_merge = []
for picking in self.mapped('picking_id'):
# First step find move to merge.
- for k, g in groupby(sorted(picking.move_lines, key=_keys_sorted), key=itemgetter(*distinct_fields)):
+ for k, g in groupby(sorted(picking.move_lines, key=self._prepare_merge_move_sort_method), key=itemgetter(*distinct_fields)):
moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft'))
# If we have multiple records we will merge then in a single one.
if len(moves) > 1:
@@ -912,6 +922,8 @@ class StockMove(models.Model):
if any(move.state == 'done' for move in self):
raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'.'))
for move in self:
+ if move.state == 'cancel':
+ continue
move._do_unreserve()
siblings_states = (move.move_dest_ids.mapped('move_orig_ids') - move).mapped('state')
if move.propagate:
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index 33faf25e3b7..7a67e07b1cb 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -788,11 +788,11 @@ class Picking(models.Model):
if operations:
package = self.env['stock.quant.package'].create({})
for operation in operations:
- if float_compare(operation.qty_done, operation.product_qty, precision_rounding=operation.product_uom_id.rounding) >= 0:
+ if float_compare(operation.qty_done, operation.product_uom_qty, precision_rounding=operation.product_uom_id.rounding) >= 0:
operation_ids |= operation
else:
quantity_left_todo = float_round(
- operation.product_qty - operation.qty_done,
+ operation.product_uom_qty - operation.qty_done,
precision_rounding=operation.product_uom_id.rounding,
rounding_method='UP')
new_operation = operation.copy(
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index 5d5631c4069..5ab89e8fca1 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -244,7 +244,7 @@ class StockMove(models.Model):
self.ensure_one()
if self._is_in():
if self.product_id.cost_method in ['fifo', 'average']:
- price_unit = self.price_unit or self._get_price_unit()
+ price_unit = self._get_price_unit()
value = price_unit * (quantity or self.product_qty)
vals = {
'price_unit': price_unit,
diff --git a/addons/stock_landed_costs/__manifest__.py b/addons/stock_landed_costs/__manifest__.py
index 767c538c8bb..436a601b9bc 100644
--- a/addons/stock_landed_costs/__manifest__.py
+++ b/addons/stock_landed_costs/__manifest__.py
@@ -24,10 +24,10 @@ This module allows you to easily add extra costs on pickings and decide the spli
'views/res_config_settings_views.xml',
],
'test': [
- # '../account/test/account_minimal_test.xml',
- # '../stock_account/test/stock_valuation_account.xml',
- # 'test/stock_landed_costs.yml',
- #'test/stock_landed_costs_rounding.yml',
+ '../account/test/account_minimal_test.xml',
+ '../stock_account/test/stock_valuation_account.xml',
+ 'test/stock_landed_costs.yml',
+ 'test/stock_landed_costs_rounding.yml',
],
'installable': True,
'auto_install': False,
diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py
index a95b9ac73c6..f61014caf0c 100644
--- a/addons/stock_landed_costs/models/stock_landed_cost.py
+++ b/addons/stock_landed_costs/models/stock_landed_cost.py
@@ -93,10 +93,21 @@ class LandedCost(models.Model):
for line in cost.valuation_adjustment_lines.filtered(lambda line: line.move_id):
# Prorate the value at what's still in stock
cost_to_add = (line.move_id.remaining_qty / line.move_id.product_qty) * line.additional_landed_cost
- line.move_id.landed_cost_value += cost_to_add
- line.move_id.remaining_value += cost_to_add
- line._create_accounting_entries(move, line.move_id.product_qty - line.move_id.remaining_qty)
+ new_landed_cost_value = line.move_id.landed_cost_value + line.additional_landed_cost
+ line.move_id.write({
+ 'landed_cost_value': new_landed_cost_value,
+ 'remaining_value': line.move_id.remaining_value + cost_to_add,
+ 'price_unit': (line.move_id.value + new_landed_cost_value) / line.move_id.product_qty,
+ })
+ # `remaining_qty` is negative if the move is out and delivered proudcts that were not
+ # in stock.
+ qty_out = 0
+ if line.move_id._is_in():
+ qty_out = line.move_id.product_qty - line.move_id.remaining_qty
+ elif line.move_id._is_out():
+ qty_out = line.move_id.product_qty
+ line._create_accounting_entries(move, qty_out)
move.assert_balanced()
cost.write({'state': 'done', 'account_move_id': move.id})
diff --git a/addons/stock_landed_costs/test/stock_landed_costs.yml b/addons/stock_landed_costs/test/stock_landed_costs.yml
index cecf5a23668..f98ca67a312 100644
--- a/addons/stock_landed_costs/test/stock_landed_costs.yml
+++ b/addons/stock_landed_costs/test/stock_landed_costs.yml
@@ -1,7 +1,7 @@
-
In order to test the landed costs feature of stock, I create a landed cost, confirm it and check its account move created
-
- I create 2 products with different volume and gross weight and configure them for real_time valuation and real price costing method
+ I create 2 products with different volume and gross weight and configure them for real_time valuation and fifo costing method
-
!record {model: product.product, id: product_landed_cost_1}:
name: "LC product 1"
@@ -34,6 +34,15 @@
location_id: stock.stock_location_stock
location_dest_id: stock.stock_location_customers
-
+ Confirm and assign picking
+-
+ !python {model: stock.picking, id: picking_landed_cost_1}: |
+ self.env.user.company_id.anglo_saxon_accounting = True
+ self.action_confirm()
+ self.action_assign()
+ self.move_lines.quantity_done = 5
+ self.button_validate()
+-
!record {model: stock.picking, id: picking_landed_cost_2}:
name: 'LC_pick_2'
picking_type_id: stock.picking_type_out
@@ -45,6 +54,14 @@
location_id: stock.stock_location_stock
location_dest_id: stock.stock_location_customers
-
+ Confirm and assign picking
+-
+ !python {model: stock.picking, id: picking_landed_cost_2}: |
+ self.action_confirm()
+ self.action_assign()
+ self.move_lines.quantity_done = 10
+ self.button_validate()
+-
I create a landed cost for those 2 pickings
-
!record {model: stock.landed.cost, id: stock_landed_cost_1}:
@@ -73,7 +90,6 @@
-
!python {model: stock.landed.cost, id: stock_landed_cost_1}: |
self.compute_landed_cost()
-
-
I check the valuation adjustment lines
-
@@ -106,4 +122,4 @@
!assert {model: stock.landed.cost, id: stock_landed_cost_1}:
- state == 'done'
- account_move_id
- - len(account_move_id.line_ids) == 16
+ - len(account_move_id.line_ids) == 48
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index 17ca031a48f..67db357ec05 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -18,13 +18,14 @@ import os
import re
import sys
import time
+import zlib
+
import werkzeug
import werkzeug.utils
import werkzeug.wrappers
-import zlib
+import werkzeug.wsgi
+from werkzeug.urls import url_decode, iri_to_uri
from xml.etree import ElementTree
-from werkzeug import url_decode
-from werkzeug import iri_to_uri
import odoo
@@ -33,11 +34,11 @@ from odoo.api import call_kw, Environment
from odoo.modules import get_resource_path
from odoo.tools import crop_image, topological_sort, html_escape, pycompat
from odoo.tools.translate import _
-from odoo.tools.misc import str2bool, xlwt
+from odoo.tools.misc import str2bool, xlwt, file_open
from odoo.tools.safe_eval import safe_eval
from odoo import http
from odoo.http import content_disposition, dispatch_rpc, request, \
- serialize_exception as _serialize_exception
+ serialize_exception as _serialize_exception, Response
from odoo.exceptions import AccessError, UserError
from odoo.models import check_method_name
@@ -513,21 +514,24 @@ class WebClient(http.Controller):
@http.route('/web/webclient/locale/<string:lang>', type='http', auth="none")
def load_locale(self, lang):
magic_file_finding = [lang.replace("_", '-').lower(), lang.split('_')[0]]
- addons_path = http.addons_manifest['web']['addons_path']
- # load momentjs locale
- momentjs_locale = ""
for code in magic_file_finding:
try:
- with open(os.path.join(addons_path, 'web', 'static', 'lib', 'moment', 'locale', code + '.js'), 'r') as f:
- momentjs_locale = f.read()
- # we found a locale matching so we can exit
- break
+ return http.Response(
+ werkzeug.wsgi.wrap_file(
+ request.httprequest.environ,
+ file_open('web/static/lib/moment/locale/%s.js' % code, 'rb')
+ ),
+ content_type='application/javascript; charset=utf-8',
+ headers=[('Cache-Control', 'max-age=36000')],
+ direct_passthrough=True,
+ )
except IOError:
- continue
+ _logger.debug("No moment locale for code %s", code)
- # return the content of the locale
- headers = [('Content-Type', 'application/javascript'), ('Cache-Control', 'max-age=%s' % (36000))]
- return request.make_response(momentjs_locale, headers)
+ return request.make_response("", headers=[
+ ('Content-Type', 'application/javascript'),
+ ('Cache-Control', 'max-age=36000'),
+ ])
@http.route('/web/webclient/qweb', type='http', auth="none", cors="*")
def qweb(self, mods=None, db=None):
diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js
index d69574d2e10..0ee6d2ad1ed 100644
--- a/addons/web/static/src/js/core/ajax.js
+++ b/addons/web/static/src/js/core/ajax.js
@@ -212,6 +212,9 @@ var loadJS = (function () {
* @param {Function} [options.success] callback in case of download success
* @param {Function} [options.error] callback in case of request error, provided with the error body
* @param {Function} [options.complete] called after both ``success`` and ``error`` callbacks have executed
+ * @returns {boolean} a false value means that a popup window was blocked. This
+ * mean that we probably need to inform the user that something needs to be
+ * changed to make it work.
*/
function get_file(options) {
// need to detect when the file is done downloading (not used
@@ -232,7 +235,12 @@ function get_file(options) {
var url = options.session.url(options.url, params);
if (options.complete) { options.complete(); }
- return window.open(url);
+ var w = window.open(url);
+ if (!w || w.closed || typeof w.closed === 'undefined') {
+ // popup was blocked
+ return false;
+ }
+ return true;
}
var $form, $form_data = $('<div>');
@@ -309,6 +317,7 @@ function get_file(options) {
}
};
timer = setTimeout(waitLoop, CHECK_INTERVAL);
+ return true;
};
function post (controller_url, data) {
diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js
index 64ebeeb1794..983b26d3606 100644
--- a/addons/web/static/src/js/core/misc.js
+++ b/addons/web/static/src/js/core/misc.js
@@ -173,6 +173,23 @@ function ReloadContext (parent, action) {
}
core.action_registry.add("reload_context", ReloadContext);
+// In Internet Explorer, document doesn't have the contains method, so we make a
+// polyfill for the method in order to be compatible.
+if (!document.contains) {
+ document.contains = function contains (node) {
+ if (!(0 in arguments)) {
+ throw new TypeError('1 argument is required');
+ }
+
+ do {
+ if (this === node) {
+ return true;
+ }
+ } while (node = node && node.parentNode);
+
+ return false;
+ };
+}
return {
blockUI: blockUI,
diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js
index 0ecea92fb71..c3181a7abe6 100644
--- a/addons/web/static/src/js/core/session.js
+++ b/addons/web/static/src/js/core/session.js
@@ -278,7 +278,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
options.data.session_id = this.session_id;
}
options.session = this;
- ajax.get_file(options);
+ return ajax.get_file(options);
},
/**
* (re)loads the content of a session: db name, username, user id, session
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index 6392870dc90..8eae8f5b8f1 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -394,7 +394,7 @@ var LinkButton = AbstractField.extend({
var FieldDate = InputField.extend({
className: "o_field_date",
tagName: "span",
- supportedFieldTypes: ['date'],
+ supportedFieldTypes: ['date', 'datetime'],
/**
* @override
@@ -453,12 +453,13 @@ var FieldDate = InputField.extend({
* @override
* @private
* @param {Moment|false} value
+ * @returns {boolean}
*/
_isSameValue: function (value) {
if (value === false) {
return this.value === false;
}
- return value.isSame(this.value);
+ return value.isSame(this.value, 'day');
},
/**
* Instantiates a new DateWidget datepicker.
@@ -497,7 +498,16 @@ var FieldDateTime = FieldDate.extend({
var value = this.datewidget.getValue();
return value && value.add(-this.getSession().getTZOffset(value), 'minutes');
},
-
+ /**
+ * @override
+ * @private
+ */
+ _isSameValue: function (value) {
+ if (value === false) {
+ return this.value === false;
+ }
+ return value.isSame(this.value);
+ },
/**
* Instantiates a new DateTimeWidget datepicker rather than DateWidget.
*
@@ -966,7 +976,7 @@ var FieldPhone = FieldEmail.extend({
* @override
*/
getFocusableElement: function () {
- if (this._canCall()) {
+ if (this.mode !== 'readonly' || this._canCall()) {
return this._super.apply(this, arguments);
}
return $();
@@ -1171,7 +1181,6 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
supportedFieldTypes: ['binary'],
_render: function () {
var self = this;
- var attrs = this.attrs;
var url = this.placeholder;
if (this.value) {
if (!utils.is_bin_size(this.value)) {
@@ -1186,11 +1195,7 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
});
}
}
- var $img = $('<img>').attr('src', url);
- $img.css({
- width: this.nodeOptions.size ? this.nodeOptions.size[0] : attrs.img_width || attrs.width,
- height: this.nodeOptions.size ? this.nodeOptions.size[1] : attrs.img_height || attrs.height,
- });
+ var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url}));
this.$('> img').remove();
this.$el.prepend($img);
$img.on('error', function () {
@@ -2175,6 +2180,15 @@ var FieldDomain = AbstractField.extend({
//--------------------------------------------------------------------------
/**
+ * A domain field is always set since the false value is considered to be
+ * equal to "[]" (match all records).
+ *
+ * @override
+ */
+ isSet: function () {
+ return true;
+ },
+ /**
* @override isValid from AbstractField.isValid
* Parsing the char value is not enough for this field. It is considered
* valid if the internal domain selector was built correctly and that the
diff --git a/addons/web/static/src/js/report/qwebactionmanager.js b/addons/web/static/src/js/report/qwebactionmanager.js
index 3c8fee859e3..cc875882ce2 100644
--- a/addons/web/static/src/js/report/qwebactionmanager.js
+++ b/addons/web/static/src/js/report/qwebactionmanager.js
@@ -21,7 +21,7 @@ var WKHTMLTOPDF_MESSAGES = {
};
var trigger_download = function (session, response, c, action, options) {
- session.get_file({
+ return session.get_file({
url: '/report/download',
data: {data: JSON.stringify(response)},
complete: framework.unblockUI,
@@ -107,7 +107,10 @@ ActionManager.include({
report_urls['qweb-pdf'],
action.report_type, //The 'root' report is considered the maine one, so we use its type for all the others.
];
- trigger_download(self.getSession(), response, c, current_action, options);
+ var success = trigger_download(self.getSession(), response, c, current_action, options);
+ if (!success) {
+ self.do_warn(_t('Warning'), _t('A popup window with your report was blocked. You may need to change your browser settings to allow popup windows for this page.'), true);
+ }
treated_actions.push(current_action);
current_action = current_action.next_report_to_generate;
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 6884442039e..1df118ecfd9 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -875,7 +875,10 @@ var BasicModel = AbstractModel.extend({
// id never changes, and should not be written
delete record._changes.id;
}
- var changes = self._generateChanges(record, {viewType: options.viewType});
+ var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'});
+
+ // id field should never be written/changed
+ delete changes.id;
if (method === 'create') {
var fieldNames = record.getFieldNames();
@@ -1551,6 +1554,9 @@ var BasicModel = AbstractModel.extend({
removedDef = this._applyX2ManyChange(record, fieldName, {
operation: 'DELETE',
ids: _.map(removedIds, function (resID) {
+ if (resID in list._cache) {
+ return list._cache[resID];
+ }
return _.findWhere(listData, {res_id: resID}).id;
}),
});
@@ -2455,7 +2461,7 @@ var BasicModel = AbstractModel.extend({
changes = _.extend({}, record.data, record._changes);
} else {
changes = _.extend({}, record._changes);
- };
+ }
var withReadonly = options.withReadonly || false;
var commands = this._generateX2ManyCommands(record, {
changesOnly: 'changesOnly' in options ? options.changesOnly : true,
@@ -2492,6 +2498,7 @@ var BasicModel = AbstractModel.extend({
changes[fieldName] = false;
}
}
+
return changes;
},
/**
@@ -2608,6 +2615,7 @@ var BasicModel = AbstractModel.extend({
_.each(relRecordUpdated, function (relRecord) {
var changes = self._generateChanges(relRecord, options);
if (!_.isEmpty(changes)) {
+ delete changes.id;
var command = x2ManyCommands.update(relRecord.res_id, changes);
commands[fieldName].push(command);
}
@@ -2638,7 +2646,12 @@ var BasicModel = AbstractModel.extend({
// this is a new id
relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]});
changes = this._generateChanges(relRecord, options);
- commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes));
+ if ('id' in changes) {
+ delete changes.id;
+ commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes));
+ } else {
+ commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes));
+ }
}
}
if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) {
@@ -3294,11 +3307,17 @@ var BasicModel = AbstractModel.extend({
* This method is quite important: it is supposed to perform the /onchange
* rpc and apply the result.
*
+ * The changes that triggered the onchange are assumed to have already been
+ * applied to the record.
+ *
* @param {Object} record
* @param {string[]} fields changed fields
* @param {string} [viewType] current viewType. If not set, we will assume
* main viewType from the record
- * @returns {Deferred}
+ * @returns {Deferred} Note: this deferred cannot fail. It is either in the
+ * 'success' or 'pending' state. If the onchange rpc fails, it will be
+ * resolved with an empty dictionary. Note that the initial change was
+ * already applied.
*/
_performOnChange: function (record, fields, viewType) {
var self = this;
@@ -3318,7 +3337,9 @@ var BasicModel = AbstractModel.extend({
var context = this._getContext(record, options);
var currentData = this._generateOnChangeData(record, {changesOnly: false});
- return self._rpc({
+ var def = $.Deferred();
+
+ self._rpc({
model: record.model,
method: 'onchange',
args: [idList, currentData, fields, onchangeSpec, context],
@@ -3343,9 +3364,12 @@ var BasicModel = AbstractModel.extend({
record._domains = _.extend(record._domains, result.domain);
}
return self._applyOnChange(result.value, record).then(function () {
- return result;
+ def.resolve(result);
});
+ }).fail(function () {
+ def.resolve({});
});
+ return def;
},
/**
* Once a record is created and some data has been fetched, we need to do
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
index 4168d901065..08d03c037f0 100644
--- a/addons/web/static/src/js/views/calendar/calendar_controller.js
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -257,13 +257,20 @@ var CalendarController = AbstractController.extend({
id = id && parseInt(id).toString() === id ? parseInt(id) : id;
if (!this.eventOpenPopup) {
- this.do_action({
- type: 'ir.actions.act_window',
- res_id: id,
- res_model: this.modelName,
- views: [[this.formViewId || false, 'form']],
- target: 'current',
- context: event.context || self.context,
+ this._rpc({
+ model: self.modelName,
+ method: 'get_formview_id',
+ //The event can be called by a view that can have another context than the default one.
+ args: [[id], event.context || self.context],
+ }).then(function (viewId) {
+ self.do_action({
+ type:'ir.actions.act_window',
+ res_id: id,
+ res_model: self.modelName,
+ views: [[viewId || false, 'form']],
+ target: 'current',
+ context: event.context || self.context,
+ });
});
return;
}
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index b9711d1d392..0ddf2f0a129 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -807,7 +807,15 @@ var FormRenderer = BasicRenderer.extend({
// Attach the tooltips on the fields' label
_.each(this.allFieldWidgets[this.state.id], function (widget) {
var idForLabel = self.idsForLabels[widget.name];
+ // We usually don't support multiple widgets for the same field on the
+ // same view but it is the case with the new settings view on V11.0.
+ // Therefore, we need to retrieve the correct label since it could be
+ // displayed multiple times on the view, otherwise, for example the
+ // enterprise label will be displayed as many times as the field
+ // exists on settings.
+ var $widgets = self.$('.o_field_widget[name=' + widget.name + ']');
var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $();
+ $label = $label.eq($widgets.index(widget.$el));
if (config.debug || widget.attrs.help || widget.field.help) {
self._addFieldTooltip(widget, $label);
}
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index 9027fc3c984..b0ddbdcd385 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -136,7 +136,9 @@ ListRenderer.include({
currentRowID = this.state.data[this.currentRow].id;
currentWidget = this.allFieldWidgets[currentRowID][this.currentFieldIndex];
focusedElement = currentWidget.getFocusableElement().get(0);
- selectionRange = dom.getSelectionRange(focusedElement);
+ if (currentWidget.formatType !== 'boolean') {
+ selectionRange = dom.getSelectionRange(focusedElement);
+ }
}
var oldData = this.state.data;
@@ -179,7 +181,9 @@ ListRenderer.include({
currentRowID = self.state.data[newRowIndex].id;
currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
focusedElement = currentWidget.getFocusableElement().get(0);
- dom.setSelectionRange(focusedElement, selectionRange);
+ if (selectionRange) {
+ dom.setSelectionRange(focusedElement, selectionRange);
+ }
});
}
});
@@ -585,12 +589,18 @@ ListRenderer.include({
if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) {
return $.Deferred().reject();
}
+ // _activateFieldWidget might trigger an onchange,
+ // which requires currentFieldIndex to be set
+ // so that the cursor can be restored
+ var oldFieldIndex = self.currentFieldIndex;
+ self.currentFieldIndex = fieldIndex;
fieldIndex = self._activateFieldWidget(record, fieldIndex, {
inc: 1,
wrap: wrap,
event: options && options.event,
});
if (fieldIndex < 0) {
+ self.currentFieldIndex = oldFieldIndex;
return $.Deferred().reject();
}
self.currentFieldIndex = fieldIndex;
diff --git a/addons/web/static/src/less/modal.less b/addons/web/static/src/less/modal.less
index 1d95ddc219b..95a14420773 100644
--- a/addons/web/static/src/less/modal.less
+++ b/addons/web/static/src/less/modal.less
@@ -52,6 +52,8 @@
.modal-body {
overflow: auto;
+ // fix iOS issue https://github.com/scottjehl/Device-Bugs/issues/8
+ -webkit-transform: translate3d(0, 0, 0);
min-height: 0;
&.o_dialog_error {
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index e1f955370d3..7da3f448378 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -335,12 +335,12 @@
<t t-if="item.create_uid and item.create_uid[0]" t-set="item.title">
<t t-raw="item.title"/><br/>
<b>Created by : </b><br/>
- <t t-raw="item.create_uid[1] + ' ' + item.create_date"/>
+ <t t-raw="item.create_uid[1]"/> <t t-esc="item.create_date_string" />
</t>
<t t-if="item.create_uid and item.write_uid and item.create_uid[0] != item.write_uid[0]" t-set="item.title">
<t t-raw="item.title"/><br/>
<b>Modified by : </b><br/>
- <t t-raw="item.write_uid[1] + ' ' + item.write_date"/>
+ <t t-raw="item.write_uid[1]"/> <t t-esc="item.write_date_string"/>
</t>
</t>
<a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url">
@@ -861,8 +861,8 @@
t-att-src='url'
t-att-border="widget.readonly ? 0 : 1"
t-att-name="widget.name"
- t-att-width="widget.node.attrs.img_width || widget.node.attrs.width"
- t-att-height="widget.node.attrs.img_height || widget.node.attrs.height"/>
+ t-att-width="widget.nodeOptions.size ? widget.nodeOptions.size[0] : widget.attrs.img_width || widget.attrs.width"
+ t-att-height="widget.nodeOptions.size ? widget.nodeOptions.size[1] : widget.attrs.img_height || widget.attrs.height"/>
</t>
<t t-name="FieldBinaryFile">
<a t-if="widget.mode === 'readonly'" href="javascript:void(0)" class="o_form_uri"/>
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index f01f2d8b589..1e623e54d9a 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -1462,6 +1462,42 @@ QUnit.module('basic_fields', {
}
});
+ QUnit.module('FieldImage');
+
+ QUnit.test('image fields are correctly rendered', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records[0].document = 'myimage';
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="document" widget="image" options="{\'size\': [90, 90]}"/> ' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route) {
+ if (route === 'data:image/png;base64,myimage') {
+ assert.ok(true, "should called the correct route");
+ return $.when('wow');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.ok(form.$('div[name="document"]').hasClass('o_field_image'),
+ "the widget should have the correct class");
+ assert.strictEqual(form.$('div[name="document"] > img').length, 1,
+ "the widget should contain an image");
+ assert.ok(form.$('div[name="document"] > img').hasClass('img-responsive'),
+ "the image should have the correct class");
+ assert.strictEqual(form.$('div[name="document"] > img').attr('width'), "90",
+ "the image should correctly set its attributes");
+ form.destroy();
+
+ });
+
QUnit.module('JournalDashboardGraph');
QUnit.test('graph dashboard widget is rendered correctly', function (assert) {
@@ -1844,6 +1880,39 @@ QUnit.module('basic_fields', {
form.destroy();
});
+ QUnit.test('do not trigger a field_changed for datetime field with date widget', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners"><field name="datetime" widget="date"/></form>',
+ translateParameters: { // Avoid issues due to localization formats
+ date_format: '%m/%d/%Y',
+ time_format: '%H:%M:%S',
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_datepicker_input').val(), '02/08/2017',
+ 'the date should be correct');
+
+ form.$('input[name="datetime"]').val('02/08/2017').trigger('input').trigger('change');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.verifySteps(['read']); // should not have save as nothing changed
+
+ form.destroy();
+ });
+
QUnit.module('FieldDatetime');
QUnit.test('datetime field in form view', function (assert) {
@@ -2828,6 +2897,33 @@ QUnit.module('basic_fields', {
form.destroy();
});
+ QUnit.test('use TAB to navigate to a phone field', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="display_name"/>' +
+ '<field name="foo" widget="phone"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ form.$('input[name=display_name]').click();
+ assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement,
+ "display_name should be focused");
+ form.$('input[name="display_name"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
+ assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement,
+ "foo should be focused");
+
+ form.destroy();
+ });
+
QUnit.module('PriorityWidget');
QUnit.test('priority widget when not set', function (assert) {
@@ -3939,7 +4035,7 @@ QUnit.module('basic_fields', {
});
QUnit.test('domain field: handle false domain as []', function (assert) {
- assert.expect(2);
+ assert.expect(3);
this.data.partner.records[0].foo = false;
this.data.partner.fields.bar.type = "char";
@@ -3965,11 +4061,13 @@ QUnit.module('basic_fields', {
return this._super.apply(this, arguments);
},
res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
});
+ assert.strictEqual(form.$('.o_field_widget[name=foo]:not(.o_field_empty)').length, 1,
+ "there should be a domain field, not considered empty");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
var $warning = form.$('.o_field_widget[name=foo] .text-warning');
assert.strictEqual($warning.length, 0, "should not display that the domain is invalid");
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 641546b3fc1..2d328f0b7ff 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1064,14 +1064,14 @@ QUnit.module('relational_fields', {
});
QUnit.test('list in form: default_get with x2many create and onchange', function (assert) {
- assert.expect(1);
+ assert.expect(2);
this.data.partner.onchanges.turtles = function (obj) {
assert.deepEqual(
obj.turtles,
[
- [0, 2, {turtle_foo: 'blip', id: 2}],
- [0, 3, {turtle_foo: 'kawa', id: 3}]
+ [1, 2, {turtle_foo: 'blip'}],
+ [1, 3, {turtle_foo: 'kawa'}]
],
"should have properly created the x2many command list");
};
@@ -1094,10 +1094,21 @@ QUnit.module('relational_fields', {
if (args.method === 'default_get') {
return $.when({turtles: [[6, 0, [2,3]]]});
}
+ if (args.method === 'create') {
+ // it would be even better if we did not send the current
+ // unchanged state with the command 1, but this seems more
+ // difficult.
+ assert.deepEqual(args.args[0].turtles, [
+ [1, 2, {turtle_foo: 'blip'}],
+ [1, 3, {turtle_foo: 'kawa'}]
+ ], 'should send proper commands to create method');
+ }
return this._super.apply(this, arguments);
},
});
+ form.$buttons.find('.o_form_button_save').click();
+
form.destroy();
});
@@ -6248,6 +6259,35 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('checkbox in an x2many that triggers an onchange', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.onchanges = {
+ bar: function () {}
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ form.$('.o_field_widget[name=bar] input').click();
+ assert.notOk(form.$('.o_field_widget[name=bar] input').prop('checked'),
+ "the checkbox should be unticked");
+
+ form.destroy();
+ });
+
QUnit.test('one2many with default value: edit line to make it invalid', function (assert) {
assert.expect(3);
@@ -6284,6 +6324,66 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('display correct value after validation error', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.onchanges.turtles = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ // we simulate a validation error. In the 'real' web client,
+ // the server error will be used by the session to display
+ // an error dialog. From the point of view of the basic
+ // model, the deferred is just rejected.
+ return $.Deferred().reject();
+ }
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].turtles[0], [1,2,{turtle_foo: 'foo'}],
+ 'should send the "bad" value');
+ // we simulate a validation error
+ return $.Deferred().reject();
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {mode: 'edit'},
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'blip',
+ "initial text should be correct");
+ form.$('.o_data_row .o_data_cell:nth(0)').click();
+ form.$('.o_field_widget[name=turtle_foo]').val('foo').trigger('input');
+
+ // we try to validate the line. This triggers an onchange which will be
+ // rejected. The line will be returned to readonly mode, but with the
+ // new invalid value.
+ form.$el.click();
+
+ assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'foo',
+ "turtle_foo text should now be foo (invalid value)");
+
+ // we make sure here that when we save, the values are the current
+ // values displayed in the field.
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.mode, 'edit', "form view should still be in edit mode");
+
+ form.destroy();
+ });
+
+
QUnit.module('FieldMany2Many');
QUnit.test('many2many kanban: edition', function (assert) {
@@ -7128,6 +7228,42 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('default_get, onchange, onchange on m2m', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.onchanges.int_field = function (obj) {
+ if (obj.int_field === 2) {
+ assert.deepEqual(obj.timmy, [
+ [6, false, [12]],
+ [1, 12, {display_name: 'gold'}]
+ ]);
+ }
+ obj.timmy = [
+ [5],
+ [1, 12, {display_name: 'gold'}]
+ ];
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="timmy">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '<field name="int_field"/>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ form.$('.o_field_widget[name=int_field]').val(2).trigger('input');
+ form.destroy();
+ });
+
QUnit.module('FieldStatus');
QUnit.test('static statusbar widget on many2one field', function (assert) {
@@ -8288,6 +8424,32 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('widget many2many_checkboxes: start non empty, then remove twice', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].timmy = [12,14];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<group><field name="timmy" widget="many2many_checkboxes"/></group>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {mode: 'edit'},
+ });
+
+ form.$('div.o_field_widget div.o_checkbox input').eq(0).click();
+ form.$('div.o_field_widget div.o_checkbox input').eq(1).click();
+ form.$buttons.find('.o_form_button_save').click();
+ assert.notOk(form.$('div.o_field_widget div.o_checkbox input').eq(0).prop('checked'),
+ "first checkbox should not be checked");
+ assert.notOk(form.$('div.o_field_widget div.o_checkbox input').eq(1).prop('checked'),
+ "second checkbox should not be checked");
+
+ form.destroy();
+ });
+
QUnit.module('FieldMany2ManyBinaryMultiFiles');
QUnit.test('widget many2many_binary', function (assert) {
diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js
index 8cb30291f24..dfcbdba5985 100644
--- a/addons/web/static/tests/helpers/mock_server.js
+++ b/addons/web/static/tests/helpers/mock_server.js
@@ -110,6 +110,14 @@ var MockServer = Class.extend({
console.log('%c[rpc] response' + route, 'color: blue; font-weight: bold;', JSON.parse(resultString));
}
return JSON.parse(resultString);
+ }).fail(function (result) {
+ var errorString = JSON.stringify(result || false);
+ if (logLevel === 1) {
+ console.log('Mock: (ERROR)' + route, JSON.parse(errorString));
+ } else if (logLevel === 2) {
+ console.log('%c[rpc] response (error) ' + route, 'color: orange; font-weight: bold;', JSON.parse(errorString));
+ }
+ return JSON.parse(errorString);
});
},
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index ecf02be9c4a..ed9e416a302 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -975,6 +975,12 @@ QUnit.module('Views', {
viewOptions: {
initialDate: initialDate,
},
+ mockRPC: function (route, args) {
+ if (args.method === "get_formview_id") {
+ return $.Deferred().resolve('A view');
+ }
+ return this._super(route, args);
+ },
});
// click on an existing event to open the form view
@@ -985,7 +991,7 @@ QUnit.module('Views', {
type: "ir.actions.act_window",
res_id: 4,
res_model: "event",
- views: [[false, "form"]],
+ views: [['A view', "form"]],
target: "current",
context: {}
},
@@ -1103,6 +1109,12 @@ QUnit.module('Views', {
viewOptions: {
initialDate: initialDate,
},
+ mockRPC: function (route, args) {
+ if (args.method === "get_formview_id") {
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
});
assert.strictEqual(calendar.$('.fc-resizer').length, 0, "should not have resize button");
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index ac45b6de256..989c67e78dd 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -2649,6 +2649,44 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('default_get, onchange which fails, should still work', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.onchanges.foo = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ // we simulate a validation error. In the 'real' web client,
+ // the server error will be used by the session to display
+ // an error dialog. From the point of view of the basic
+ // model, the deferred is just rejected.
+ return $.Deferred().reject();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // this test checks that a form view is still rendered when the server
+ // onchange fails (for example, because of a validation error, or, more
+ // likely, a bug in the onchange code). This is quite rare, but if the
+ // onchange fails, we still want to display the form view (with the error
+ // dialog from the session/crashmanager). Otherwise, we could be in the
+ // situation where a user clicks on a button, it should open a wizard,
+ // but something fails and the wizard is not even rendered. In that
+ // case, there is nothing that the user could do.
+ assert.strictEqual(form.$('.o_field_widget[name="foo"]').val(), 'My little Foo Value',
+ "should display proper default value");
+
+ form.destroy();
+ });
+
QUnit.test('attrs are properly transmitted to new records', function (assert) {
assert.expect(2);
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index cdc29635cd1..9b222f8563d 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -252,7 +252,7 @@
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
- <meta name="viewport" content="width=device-width, initial-scale=1"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
<title t-esc="title or 'Odoo'"/>
<link type="image/x-icon" rel="shortcut icon" t-att-href="x_icon or '/web/static/src/img/favicon.ico'"/>
diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js
index 8b42726ff7a..955b49437a8 100644
--- a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js
+++ b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js
@@ -206,6 +206,18 @@ define([
r.select();
}
+ // If only pasting a <p/> element in an unique <p/> element, only paste
+ // the <p/> element text
+ var $p = $arch.children('p');
+ var onlyAP = ($p.length === 1 && $arch.children().length === 1);
+ if (onlyAP) {
+ var $p1 = $(r.sc).closest('p');
+ var $p2 = $(r.ec).closest('p');
+ if ($p1.length && $p2.length && $p1[0] === $p2[0]) {
+ $arch.html($p.text());
+ }
+ }
+
/*
insert content
*/
diff --git a/addons/web_editor/static/src/js/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js
index 2486fea9b81..bf83e5b8dec 100644
--- a/addons/web_editor/static/src/js/backend/fields.js
+++ b/addons/web_editor/static/src/js/backend/fields.js
@@ -399,7 +399,7 @@ var FieldTextHtml = AbstractField.extend({
var layoutInfo = this.editor.rte.editable().data('layoutInfo');
$.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false);
}
- this.editor.snippetsMenu.cleanForSave();
+ this.editor.snippetsMenu && this.editor.snippetsMenu.cleanForSave();
this._setValue(this.$content.html());
return this._super.apply(this, arguments);
},
diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js
index f97a97b8b46..4d2f6fe977a 100644
--- a/addons/web_editor/static/src/js/editor/rte.js
+++ b/addons/web_editor/static/src/js/editor/rte.js
@@ -654,6 +654,28 @@ var RTEWidget = Widget.extend({
// whitespace characters (so Tripleclicking then typing text will remove
// the whole paragraph instead of its content).
// http://stackoverflow.com/questions/38467334/why-does-google-chrome-always-add-space-after-selected-text
+ if ($.browser.chrome === true && event.originalEvent.detail === 3) {
+ var currentSelection = range.create();
+ if (currentSelection.sc.parentNode === currentSelection.ec) {
+ _selectSC(currentSelection);
+ } else if (currentSelection.eo === 0) {
+ var $hasNext = $(currentSelection.sc).parent();
+ while (!$hasNext.next().length && !$hasNext.is('body')) {
+ $hasNext = $hasNext.parent();
+ }
+ if ($hasNext.next()[0] === currentSelection.ec) {
+ _selectSC(currentSelection);
+ }
+ }
+ }
+ function _selectSC(selection) {
+ range.create(selection.sc, selection.so, selection.sc, selection.sc.length).select();
+ }
+
+ // To Fix Google Chrome Tripleclick Issue, which selects the ending
+ // whitespace characters (so Tripleclicking then typing text will remove
+ // the whole paragraph instead of its content).
+ // http://stackoverflow.com/questions/38467334/why-does-google-chrome-always-add-space-after-selected-text
if ($.browser.chrome === true && ev.originalEvent.detail === 3) {
var currentSelection = range.create();
if (currentSelection.sc.parentNode === currentSelection.ec) {
diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js
index 141fb6ea229..7f82df2cb28 100644
--- a/addons/web_editor/static/src/js/editor/rte.summernote.js
+++ b/addons/web_editor/static/src/js/editor/rte.summernote.js
@@ -446,7 +446,7 @@ $.summernote.pluginEvents.visible = function (event, editor, layoutInfo) {
if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) &&
$node.hasClass("o_editable") &&
!$node[0].children.length &&
- "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span".toUpperCase().indexOf($node[0].nodeName) === -1) {
+ "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span label".toUpperCase().indexOf($node[0].nodeName) === -1) {
var p = $('<p><br/></p>')[0];
$node.append( p );
range.createFromNode(p.firstChild).select();
@@ -761,7 +761,7 @@ eventHandler.attach = function (oLayoutInfo, options) {
* Open Link Dialog on double click on a link/button.
* Shows a tooltip on click to say to the user he can double click.
*/
- create_dblclick_feature("a[href], .btn", function () {
+ create_dblclick_feature("a[href], a.btn, button.btn", function () {
eventHandler.modules.linkDialog.show(oLayoutInfo);
});
diff --git a/addons/web_editor/static/src/less/web_editor.common.less b/addons/web_editor/static/src/less/web_editor.common.less
index 57c06c6f513..cfa9f683198 100644
--- a/addons/web_editor/static/src/less/web_editor.common.less
+++ b/addons/web_editor/static/src/less/web_editor.common.less
@@ -23,7 +23,9 @@ html, body {
/* ----- GENERIC LAYOUTING HELPERS ---- */
/* table */
#wrapwrap, .o_editable {
- table.table {
+ // Only style editor-made tables (shop/portal/... tables are not supposed to
+ // use table-bordered...)
+ table.table.table-bordered {
table-layout: fixed;
td {
min-width: 20px;
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index 433fc5e297a..c737f064e1a 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -105,11 +105,12 @@ class Http(models.AbstractModel):
req_page = request.httprequest.path
domain = [('url', '=', req_page), '|', ('website_ids', 'in', request.website.id), ('website_ids', '=', False)]
+ pages = request.env['website.page'].search(domain)
- if not request.website.is_publisher:
- domain += [('is_visible', '=', True)]
+ if not request.website.is_publisher():
+ pages = pages.filtered('is_visible')
- mypage = request.env['website.page'].search(domain, limit=1)
+ mypage = pages[0] if pages else False
_, ext = os.path.splitext(req_page)
if mypage:
return request.render(mypage.view_id.id, {
diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js
index cf837b9df7c..7104a323040 100644
--- a/addons/website/static/src/js/content/website_root.js
+++ b/addons/website/static/src/js/content/website_root.js
@@ -39,6 +39,7 @@ var WebsiteRoot = BodyManager.extend({
'click .js_change_lang': '_onLangChangeClick',
'click .js_publish_management .js_publish_btn': '_onPublishBtnClick',
'submit .js_website_submit_form': '_onWebsiteFormSubmit',
+ 'click .js_disable_on_click': '_onDisableOnClick',
}),
custom_events: _.extend({}, BodyManager.prototype.custom_events || {}, {
animation_start_demand: '_onAnimationStartDemand',
@@ -224,6 +225,16 @@ var WebsiteRoot = BodyManager.extend({
$btn.button('loading');
});
},
+ /**
+ * Called when the root is notified that the button should be
+ * disabled after the first click.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onDisableOnClick: function (ev) {
+ $(ev.currentTarget).addClass('disabled');
+ },
});
return {
diff --git a/addons/website/static/src/less/website.backendDashboard.less b/addons/website/static/src/less/website.backendDashboard.less
index 21d90c81d24..f920418e9fc 100644
--- a/addons/website/static/src/less/website.backendDashboard.less
+++ b/addons/website/static/src/less/website.backendDashboard.less
@@ -15,13 +15,12 @@
h2, h4 {
text-transform: uppercase;
margin-top: 0;
+ color: @odoo-brand-primary;
}
h4 { margin: 0 0 0 8px; }
- h2, h4 { color: @odoo-brand-primary; }
-
- .col-md-5, .col-md-7 {
+ .col-md-5, .col-md-7, .col-md-12 {
padding: 15px;
.js_field_selection {
diff --git a/addons/website/views/res_config_settings_views.xml b/addons/website/views/res_config_settings_views.xml
index 624c65dd04f..8ef61e3db6c 100644
--- a/addons/website/views/res_config_settings_views.xml
+++ b/addons/website/views/res_config_settings_views.xml
@@ -128,7 +128,7 @@
</div>
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
- <field name="module_website_version"/>
+ <field name="module_website_version" widget="upgrade_boolean"/>
</div>
<div class="o_setting_right_pane">
<label for="module_website_version"/>
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index bb097eb0ef9..38fde186043 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -233,6 +233,10 @@
</xpath>
</template>
+<record id="portal.portal_show_sign_in" model="ir.ui.view">
+ <field name="customize_show" eval="True"/>
+</record>
+
<!-- Features template -->
<template id="login_layout" inherit_id="web.login_layout" name="Website Login Layout">
<xpath expr="t" position="replace">
@@ -574,7 +578,7 @@
<div class="container">
<div class="well mt32">
<p>This page does not exists, but you can create it as you are administrator of this site.</p>
- <a class="btn btn-primary" t-attf-href="/website/add/#{ path }#{ from_template and '?template=%s' % from_template }">Create Page</a>
+ <a class="btn btn-primary js_disable_on_click" t-attf-href="/website/add/#{ path }#{ from_template and '?template=%s' % from_template }">Create Page</a>
</div>
<div class="text-center text-muted">Edit the content below this line to adapt the default "page not found" page.</div>
</div>
diff --git a/addons/website_forum/controllers/main.py b/addons/website_forum/controllers/main.py
index 5f315491074..94043cdb0b0 100644
--- a/addons/website_forum/controllers/main.py
+++ b/addons/website_forum/controllers/main.py
@@ -332,7 +332,7 @@ class WebsiteForum(http.Controller):
if post_type == 'question' and not post.get('post_name', ''):
return request.render('website.http_error', {'status_code': _('Bad Request'), 'status_message': _('Title should not be empty.')})
if post.get('content', '') == '<p><br></p>':
- return werkzeug.utils.redirect("/forum/%s/question/%s?nocontent=1" % (slug(forum), post_parent and slug(post_parent)))
+ return request.render('website.http_error', {'status_code': _('Bad Request'), 'status_message': _('Question should not be empty.')})
post_tag_ids = forum._tag_to_write_vals(post.get('post_tags', ''))
diff --git a/addons/website_forum/i18n/website_forum.pot b/addons/website_forum/i18n/website_forum.pot
index ba9d4eef535..99a8e934959 100644
--- a/addons/website_forum/i18n/website_forum.pot
+++ b/addons/website_forum/i18n/website_forum.pot
@@ -2450,6 +2450,12 @@ msgid "Title must not be empty"
msgstr ""
#. module: website_forum
+#: code:addons/website_forum/controllers/main.py:325
+#, python-format
+msgid "Question should not be empty."
+msgstr ""
+
+#. module: website_forum
#: code:addons/website_forum/controllers/main.py:324
#: code:addons/website_forum/controllers/main.py:395
#, python-format
diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py
index e8349e0cb9e..b9da18ffee3 100644
--- a/addons/website_forum/models/forum.py
+++ b/addons/website_forum/models/forum.py
@@ -12,7 +12,7 @@ from werkzeug.exceptions import Forbidden
from odoo import api, fields, models, modules, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, ValidationError
-from odoo.tools import pycompat
+from odoo.tools import pycompat, misc
_logger = logging.getLogger(__name__)
@@ -38,10 +38,8 @@ class Forum(models.Model):
@api.model
def _get_default_faq(self):
- fname = modules.get_module_resource('website_forum', 'data', 'forum_default_faq.html')
- with open(fname, 'r') as f:
+ with misc.file_open('website_forum/data/forum_default_faq.html', 'r') as f:
return f.read()
- return False
# description and use
name = fields.Char('Forum Name', required=True, translate=True)
diff --git a/addons/website_forum/views/forum.xml b/addons/website_forum/views/forum.xml
index b78560c01b9..490c9f9f9bb 100644
--- a/addons/website_forum/views/forum.xml
+++ b/addons/website_forum/views/forum.xml
@@ -69,6 +69,7 @@
<group>
<group>
<field name="karma_ask"/>
+ <field name="karma_answer"/>
<field name="karma_upvote"/>
<field name="karma_downvote"/>
<field name="karma_edit_own"/>
diff --git a/addons/website_quote/models/__init__.py b/addons/website_quote/models/__init__.py
index 08bb5a18917..43d640bca5f 100644
--- a/addons/website_quote/models/__init__.py
+++ b/addons/website_quote/models/__init__.py
@@ -4,4 +4,5 @@
from .import sale_order
from .import sale_quote
from .import product_template
+from . import ir_model_fields
from .import res_config_settings
diff --git a/addons/website_quote/models/ir_model_fields.py b/addons/website_quote/models/ir_model_fields.py
new file mode 100644
index 00000000000..55643edfe0d
--- /dev/null
+++ b/addons/website_quote/models/ir_model_fields.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+# !! Attention, this is a hack of a hack, do not try this at home !!
+# This is done because website_quote defines the website_description
+# field which is also defined by website_sale, but these two modules
+# are independent of each other, the part of the ORM that generates
+# xmlids does not support this case therefore it only creates a single
+# pair of xmlids for whichever module is installed first, therefore
+# when uninstalling any of the two modules the website_descripion field
+# will be deleted from the db and the other module won't be able to
+# use this field, resulting in a crash and data-loss.
+# See opw-776464 for more details.
+#
+# This is hotfixed by overriding the unlink method so as to not delete
+# the website_description field if website_quote is uninstalled, it's
+# an ugly hack but it works and big changes can't be done to the ORM
+# in stable versions, Odoo v12+ will properly fix this
+
+
+class IrModelFields(models.Model):
+ _inherit = "ir.model.fields"
+
+ @api.multi
+ def unlink(self):
+ # Prevent the deletion of the field "website_description"
+ self = self.filtered(
+ lambda rec: not (
+ rec.model in ('product.product', 'product.template') and
+ rec.name == 'website_description'
+ )
+ )
+ return super(IrModelFields, self).unlink()
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index e23da2bcf89..9180e67d061 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -400,9 +400,10 @@ class WebsiteSale(http.Controller):
return {}
value = order._cart_update(product_id=product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty)
+
if not order.cart_quantity:
request.website.sale_reset()
- return {}
+ return value
order = request.website.sale_get_order()
value['cart_quantity'] = order.cart_quantity
@@ -503,6 +504,8 @@ class WebsiteSale(http.Controller):
# vat validation
Partner = request.env['res.partner']
if data.get("vat") and hasattr(Partner, "check_vat"):
+ if data.get("country_id"):
+ data["vat"] = Partner.fix_eu_vat_number(data.get("country_id"), data.get("vat"))
partner_dummy = Partner.new({
'vat': data['vat'],
'country_id': (int(data['country_id'])
diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py
index 1f9c38d727f..8cc73414b40 100644
--- a/addons/website_sale/models/__init__.py
+++ b/addons/website_sale/models/__init__.py
@@ -8,5 +8,6 @@ from . import product
from . import res_country
from . import res_partner
from . import sale_order
+from . import ir_model_fields
from . import website
from . import res_config_settings
diff --git a/addons/website_sale/models/ir_model_fields.py b/addons/website_sale/models/ir_model_fields.py
new file mode 100644
index 00000000000..e109ab809b5
--- /dev/null
+++ b/addons/website_sale/models/ir_model_fields.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+# !! Attention, this is a hack of a hack, do not try this at home !!
+# This is done because website_sale defines the website_description
+# field which is also defined by website_quote, but these two modules
+# are independent of each other, the part of the ORM that generates
+# xmlids does not support this case therefore it only creates a single
+# pair of xmlids for whichever module is installed first, therefore
+# when uninstalling any of the two modules the website_descripion field
+# will be deleted from the db and the other module won't be able to
+# use this field, resulting in a crash and data-loss.
+# See opw-776464 for more details.
+#
+# This is hotfixed by overriding the unlink method so as to not delete
+# the website_description field if website_sale is uninstalled, it's
+# an ugly hack but it works and big changes can't be done to the ORM
+# in stable versions, Odoo v12+ will properly fix this
+
+
+class IrModelFields(models.Model):
+ _inherit = "ir.model.fields"
+
+ @api.multi
+ def unlink(self):
+ # Prevent the deletion of the field "website_description"
+ self = self.filtered(
+ lambda rec: not (
+ rec.model in ('product.product', 'product.template') and
+ rec.name == 'website_description'
+ )
+ )
+ return super(IrModelFields, self).unlink()
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
index 45154eae1ef..a93f9d6ce81 100644
--- a/addons/website_sale/models/product.py
+++ b/addons/website_sale/models/product.py
@@ -5,6 +5,7 @@ from odoo.addons import decimal_precision as dp
from odoo.tools import pycompat
from odoo.tools.translate import html_translate
+from odoo.tools import float_is_zero
class ProductStyle(models.Model):
@@ -130,6 +131,7 @@ class ProductTemplate(models.Model):
website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price'))
website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price'))
+ website_price_difference = fields.Boolean('Website price difference', compute='_website_price')
def _website_price(self):
# First filter out the ones that have no variant:
@@ -139,6 +141,7 @@ class ProductTemplate(models.Model):
for template, product in pycompat.izip(self, self.mapped('product_variant_id')):
template.website_price = product.website_price
template.website_public_price = product.website_public_price
+ template.website_price_difference = product.website_price_difference
def _default_website_sequence(self):
self._cr.execute("SELECT MIN(website_sequence) FROM %s" % self._table)
@@ -179,6 +182,7 @@ class Product(models.Model):
website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price'))
website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price'))
+ website_price_difference = fields.Boolean('Website price difference', compute='_website_price')
def _website_price(self):
qty = self._context.get('quantity', 1.0)
@@ -195,6 +199,8 @@ class Product(models.Model):
for p, p2 in pycompat.izip(self, self2):
taxes = partner.property_account_position_id.map_tax(p.taxes_id.filtered(lambda x: x.company_id == company_id))
p.website_price = taxes.compute_all(p2.price, pricelist.currency_id, quantity=qty, product=p2, partner=partner)[ret]
+ price_without_pricelist = taxes.compute_all(p.list_price, pricelist.currency_id)[ret]
+ p.website_price_difference = False if float_is_zero(price_without_pricelist - p.website_price, precision_rounding=pricelist.currency_id.rounding) else True
p.website_public_price = taxes.compute_all(p2.lst_price, quantity=qty, product=p2, partner=partner)[ret]
@api.multi
diff --git a/addons/website_sale/static/src/css/website_sale.css b/addons/website_sale/static/src/css/website_sale.css
index 66735871e06..2fd9a68563a 100644
--- a/addons/website_sale/static/src/css/website_sale.css
+++ b/addons/website_sale/static/src/css/website_sale.css
@@ -555,11 +555,6 @@ ul.wizard li:last-child .chevron {
display: none;
}
-/* Shipping & Billing */
-.label-optional {
- font-weight: normal;
-}
-
/* Collapse category */
#o_shop_collapse_category li a {
display: inline-block;
diff --git a/addons/website_sale/static/src/css/website_sale.sass b/addons/website_sale/static/src/css/website_sale.sass
index 3b9ceb7fdd9..6b8c5a155e6 100644
--- a/addons/website_sale/static/src/css/website_sale.sass
+++ b/addons/website_sale/static/src/css/website_sale.sass
@@ -488,10 +488,6 @@ ul.wizard
.chevron
display: none
-/* Shipping & Billing */
-.label-optional
- font-weight: normal
-
/* Collapse category */
#o_shop_collapse_category li
a
diff --git a/addons/website_sale/static/src/js/website_sale_payment.js b/addons/website_sale/static/src/js/website_sale_payment.js
index 8ac46658da9..a3c2816da1c 100644
--- a/addons/website_sale/static/src/js/website_sale_payment.js
+++ b/addons/website_sale/static/src/js/website_sale_payment.js
@@ -4,10 +4,10 @@ odoo.define('website_sale.payment', function (require) {
$(document).ready(function () {
// If option is enable
if ($("#checkbox_cgv").length) {
- var enabling_button = function() {
+ $("#checkbox_cgv").change(function() {
$("button#o_payment_form_pay").prop("disabled", !this.checked);
- };
- $("#checkbox_cgv").click(enabling_button).each(enabling_button);
+ });
+ $('#checkbox_cgv').trigger('change');
}
});
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 0df77aaecf5..f2a2f6c4f83 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -108,7 +108,7 @@
</h5>
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price" t-if="product.product_variant_ids">
<b>
- <t t-if="(compute_currency(product.lst_price) - product.website_price ) &gt; 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
+ <t t-if="product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'">
<del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" />
</t>
@@ -581,7 +581,7 @@
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price mt16">
<h4 class="oe_price_h4 css_editable_mode_hidden">
<span class="text-danger oe_default_price" style="text-decoration: line-through; white-space: nowrap;"
- t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" t-att-style="'text-decoration: line-through; white-space: nowrap; ' + '' if (compute_currency(product.lst_price) - product.website_price) &gt; 0.01 and website.get_current_pricelist().discount_policy == 'without_discount' else 'display: none;'"
+ t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" t-att-style="'text-decoration: line-through; white-space: nowrap; ' + '' if product.website_price_difference and website.get_current_pricelist().discount_policy == 'without_discount' else 'display: none;'"
/>
<b class="oe_price" style="white-space: nowrap;" t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="product.website_price"/>
@@ -965,7 +965,7 @@
<div class="text-muted hidden-xs" t-field="product.description_sale" />
</td>
<td class='td-price'>
- <t t-if="(compute_currency(product.lst_price) - product.website_price) &gt; 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
+ <t t-if="product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'">
<del class="text-danger mr8" style="white-space: nowrap;" t-field="product.lst_price" t-options="{'widget': 'monetary','from_currency': product.currency_id, 'display_currency': website.currency_id}" />
</t>
<span t-field="product.website_price" style="white-space: nowrap;" t-options="{'widget': 'monetary','display_currency': website.currency_id}" />
diff --git a/addons/website_sale_stock/models/sale_order.py b/addons/website_sale_stock/models/sale_order.py
index ecc653f98d1..4ac8a67a871 100644
--- a/addons/website_sale_stock/models/sale_order.py
+++ b/addons/website_sale_stock/models/sale_order.py
@@ -25,9 +25,10 @@ class SaleOrder(models.Model):
# Make sure line still exists, it may have been deleted in super()_cartupdate because qty can be <= 0
if line.exists() and new_val['quantity']:
line.warning_stock = _('You ask for %s products but only %s is available') % (cart_qty, new_val['quantity'])
+ values['warning'] = line.warning_stock
else:
self.warning_stock = _("Some products became unavailable and your cart has been updated. We're sorry for the inconvenience.")
-
+ values['warning'] = self.warning_stock
return values
@api.multi
diff --git a/addons/website_sale_stock_options/views/website_sale_stock_templates.xml b/addons/website_sale_stock_options/views/website_sale_stock_templates.xml
index 8f34365277f..d5709c8556c 100644
--- a/addons/website_sale_stock_options/views/website_sale_stock_templates.xml
+++ b/addons/website_sale_stock_options/views/website_sale_stock_templates.xml
@@ -3,7 +3,7 @@
<!-- Shopping Cart Lines -->
<template id="website_sale_stock_modal" inherit_id="website_sale_options.modal" name="Stocks Modal">
<xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes">
- <attribute name='t-att-data-max'>max(product.sudo().virtual_available - product.cart_qty, 1)</attribute>
+ <attribute name='t-att-data-max'>max(product.sudo().virtual_available - product.cart_qty, 1) if product.type == "product" and product.inventory_availability in ["always", "threshold"] else None</attribute>
</xpath>
<xpath expr="//div[hasclass('css_quantity')]" position="after">
<div class='availability_messages'/>
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 c1e9a001034..8f0c3ec0359 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
@@ -134,7 +134,14 @@ var ProductWishlist = Widget.extend({
});
add_to_cart.then(function(resp) {
- $('.my_cart_quantity').text(resp.cart_quantity || '* 1');
+ if (resp.warning) {
+ if (! $('#data_warning').length) {
+ $('.wishlist-section').prepend('<div class="mt16 alert alert-danger alert-dismissable" role="alert" id="data_warning"></div>');
+ }
+ var cart_alert = $('.wishlist-section').parent().find('#data_warning');
+ cart_alert.html('<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button> ' + resp.warning);
+ }
+ $('.my_cart_quantity').html(resp.cart_quantity || '<i class="fa fa-warning" /> ');
});
return add_to_cart;
},
diff --git a/addons/website_slides/models/slides.py b/addons/website_slides/models/slides.py
index b7632e96527..c598af5104d 100644
--- a/addons/website_slides/models/slides.py
+++ b/addons/website_slides/models/slides.py
@@ -133,7 +133,7 @@ class Channel(models.Model):
"""
op = operator == "=" and "inselect" or "not inselect"
# don't use param named because orm will add other param (test_active, ...)
- return [('id', op, (req, (self._uid)))]
+ return [('id', op, (req, (self._uid, )))]
@api.one
@api.depends('visibility', 'group_ids', 'upload_group_ids')
diff --git a/addons/website_slides/static/src/js/slides_upload.js b/addons/website_slides/static/src/js/slides_upload.js
index 7145558ecfc..b3953f12571 100644
--- a/addons/website_slides/static/src/js/slides_upload.js
+++ b/addons/website_slides/static/src/js/slides_upload.js
@@ -149,6 +149,8 @@ var SlideDialog = Widget.extend({
_.each(data.items, function (obj) {
page_content = page_content + obj.str + " ";
});
+ // page_content may contain null characters
+ page_content = page_content.replace(/\0/g, "");
self.index_content = self.index_content + page_number + ". " + page_content + '\n';
if (maxPages === page_number) {
if (loaded) {
diff --git a/addons/website_theme_install/views/views.xml b/addons/website_theme_install/views/views.xml
index 19593023695..656c8f731ca 100644
--- a/addons/website_theme_install/views/views.xml
+++ b/addons/website_theme_install/views/views.xml
@@ -86,6 +86,7 @@ action = {
</record>
<record id="theme_install_todo" model="ir.actions.todo">
<field name="action_id" ref="theme_install_todo_action"/>
+ <field name="sequence">0</field>
</record>
<!-- Add link to theme install in default customize modal -->
diff --git a/doc/cla/corporate/acsone.md b/doc/cla/corporate/acsone.md
index cb97dc6b20d..1060d7ccc02 100644
--- a/doc/cla/corporate/acsone.md
+++ b/doc/cla/corporate/acsone.md
@@ -25,3 +25,4 @@ Philippe Schmidt philippe.schmidt@acsone.eu https://github.com/phschmidt
Stéphane Bidoul stephane.bidoul@acsone.eu https://github.com/sbidoul
Thomas Binsfeld thomas.binsfeld@acsone.eu https://github.com/ThomasBinsfeld
Benjamin Willig benjamin.willig@acsone.eu https://github.com/benwillig
+François Honoré francois.honore@acsone.eu https://github.com/acsonefho
diff --git a/doc/cla/corporate/savoirfairelinux.md b/doc/cla/corporate/savoirfairelinux.md
index 784518b093e..6dca085c4ce 100644
--- a/doc/cla/corporate/savoirfairelinux.md
+++ b/doc/cla/corporate/savoirfairelinux.md
@@ -1,4 +1,4 @@
-Canada, France, 2017-05-12
+Canada, France, 2017-10-17
Savoir-faire Linux agrees to the terms of the Odoo Corporate Contributor License
Agreement v1.0.
@@ -8,17 +8,22 @@ declaration.
Signed,
-Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard
+João Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf
List of contributors:
Agathe Mollé agathe.molle@savoirfairelinux.com https://github.com/Ehtaga
Bruno Joliveau bruno.joliveau@savoirfairelinux.com https://github.com/bjoliveau
David Dufresne david.dufresne@savoirfairelinux.com https://github.com/dufresnedavid
-Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard
-Joao Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf
-Julie Moussu julie.mousse@savoirfairelinux.com https://github.com/JulieSFL
-Pierre Lamarche pierre.lamarche@savoirfairelinux.com https://github.com/plamarche
+Istvan SZALAÏ istvan.szalai@savoirfairelinux.com https://github.com/ventilooo
+João Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf
+Julien Jezequel-Breard julien.jezequel-breard@savoirfairelinux.com https://github.com/jjbreard
+Quentin Lavallée-Bourdeau quentin.lavallee@savoirfairelinux.com https://github.com/qtiplb
+Rim Ben Dhaou rim.bendhaou@savoirfairelinux.com https://github.com/rimbendhaou
+Yasmine El Mrini yasmine.elmrini@savoirfairelinux.com https://github.com/yasmineelmrini
+Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard (up to 2017-10-17)
+Julie Moussu julie.mousse@savoirfairelinux.com https://github.com/JulieSFL (up to 2017-10-17)
+Pierre Lamarche pierre.lamarche@savoirfairelinux.com https://github.com/plamarche (up to 2017-10-17)
David Cormier david.cormier@savoirfairelinux.com https://github.com/cormier (up to 2017-05-12)
Davin Baragiotta davin.baragiotta@savoirfairelinux.com https://github.com/giotta (up to 2017-05-12)
El Hadji Dem elhadji.dem@savoirfairelinux.com https://github.com/ehdem (up to 2017-05-12)
diff --git a/doc/cla/individual/felixonmars.md b/doc/cla/individual/felixonmars.md
new file mode 100644
index 00000000000..dc45cf3f625
--- /dev/null
+++ b/doc/cla/individual/felixonmars.md
@@ -0,0 +1,11 @@
+China, 2017-10-19
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Felix Yan felixonmars@archlinux.org https://github.com/felixonmars
diff --git a/odoo/addons/base/ir/ir_actions.py b/odoo/addons/base/ir/ir_actions.py
index e24ebedba1b..982a0364089 100644
--- a/odoo/addons/base/ir/ir_actions.py
+++ b/odoo/addons/base/ir/ir_actions.py
@@ -602,18 +602,19 @@ class IrActionsTodo(models.Model):
def create(self, vals):
todo = super(IrActionsTodo, self).create(vals)
if todo.state == "open":
- todo.ensure_one_open_todo()
+ self.ensure_one_open_todo()
return todo
+ @api.multi
def write(self, vals):
res = super(IrActionsTodo, self).write(vals)
if vals.get('state', '') == 'open':
- for todo in self:
- todo.ensure_one_open_todo()
+ self.ensure_one_open_todo()
return res
+ @api.model
def ensure_one_open_todo(self):
- open_todo = self.search(['&', ('state', '=', 'open'), ('id', '!=', self.id)])
+ open_todo = self.search([('state', '=', 'open')], order='sequence asc, id desc', offset=1)
if open_todo:
open_todo.write({'state': 'done'})
diff --git a/odoo/addons/base/ir/ir_http.py b/odoo/addons/base/ir/ir_http.py
index 85af389b0fb..27d759e5461 100644
--- a/odoo/addons/base/ir/ir_http.py
+++ b/odoo/addons/base/ir/ir_http.py
@@ -342,7 +342,7 @@ class IrHttp(models.AbstractModel):
# cache
etag = bool(request) and request.httprequest.headers.get('If-None-Match')
- retag = '"%s"' % hashlib.md5(last_update.encode('utf-8')).hexdigest()
+ retag = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
status = status or (304 if etag == retag else 200)
headers.append(('ETag', retag))
headers.append(('Cache-Control', 'max-age=%s' % (STATIC_CACHE if unique else 0)))
diff --git a/odoo/addons/base/ir/ir_sequence.py b/odoo/addons/base/ir/ir_sequence.py
index 105f0f9bbf4..f1005cc6c44 100644
--- a/odoo/addons/base/ir/ir_sequence.py
+++ b/odoo/addons/base/ir/ir_sequence.py
@@ -324,6 +324,12 @@ class IrSequenceDateRange(models.Model):
for seq in self:
seq.write({'number_next': seq.number_next_actual or 0})
+ @api.model
+ def default_get(self, fields):
+ result = super(IrSequenceDateRange, self).default_get(fields)
+ result['number_next_actual'] = 1
+ return result
+
date_from = fields.Date(string='From', required=True)
date_to = fields.Date(string='To', required=True)
sequence_id = fields.Many2one("ir.sequence", string='Main Sequence', required=True, ondelete='cascade')
diff --git a/odoo/addons/base/module/wizard/base_import_language.py b/odoo/addons/base/module/wizard/base_import_language.py
index c0aca1669bd..9f7338ed592 100644
--- a/odoo/addons/base/module/wizard/base_import_language.py
+++ b/odoo/addons/base/module/wizard/base_import_language.py
@@ -29,7 +29,7 @@ class BaseLanguageImport(models.TransientModel):
def import_lang(self):
this = self[0]
this = this.with_context(overwrite=this.overwrite)
- with TemporaryFile('w+') as buf:
+ with TemporaryFile('w+b') as buf:
try:
buf.write(base64.decodestring(this.data))
diff --git a/odoo/addons/base/static/src/css/description.css b/odoo/addons/base/static/src/css/description.css
index cbfa3d5e968..b1f436b872f 100644
--- a/odoo/addons/base/static/src/css/description.css
+++ b/odoo/addons/base/static/src/css/description.css
@@ -1,5 +1,4 @@
-@charset "utf-8";
-/*
+/**
* This CSS is for the html description of modules
* TODO clean
*/
@@ -78,11 +77,11 @@
/* ------ BASE GRID CONSTRUCTS ----- */
.oe_page {
margin: 0px auto 64px auto;
- max-width: 992px;
+ max-width: 100%;
}
.oe_row {
- width: 928px;
+ width: 100%;
margin-top: 16px;
margin-bottom: 16px;
margin-left: auto;
@@ -111,35 +110,35 @@
}
.oe_span12 {
- width: 928px;
+ width: 100%;
}
.oe_span10 {
- width: 773px;
+ width: 83.33333%;
}
.oe_span9 {
- width: 696px;
+ width: 75%;
}
.oe_span8 {
- width: 618px;
+ width: 66.66667%;
}
.oe_span6 {
- width: 464px;
+ width: 50%;
}
.oe_span4 {
- width: 309px;
+ width: 33.33333%;
}
.oe_span3 {
- width: 232px;
+ width: 25%;
}
.oe_span2 {
- width: 154px;
+ width: 16.66667%;
}
[class*='oe_span'].oe_fit {
@@ -162,28 +161,28 @@
width: auto;
}
.oe_row.oe_flex .oe_span12 {
- max-width: 928px;
+ max-width: 100%;
}
.oe_row.oe_flex .oe_span10 {
- max-width: 769px;
+ max-width: 83.33333%;
}
.oe_row.oe_flex .oe_span9 {
- max-width: 692px;
+ max-width: 75%;
}
.oe_row.oe_flex .oe_span8 {
- max-width: 614px;
+ max-width: 66.66667%;
}
.oe_row.oe_flex .oe_span6 {
- max-width: 460px;
+ max-width: 50%;
}
.oe_row.oe_flex .oe_span4 {
- max-width: 305px;
+ max-width: 33.33333%;
}
.oe_row.oe_flex .oe_span3 {
- max-width: 228px;
+ max-width: 25%;
}
.oe_row.oe_flex .oe_span2 {
- max-width: 150px;
+ max-width: 16.66667%;
}
.oe_mb0 {
diff --git a/odoo/addons/base/static/src/css/description.sass b/odoo/addons/base/static/src/css/description.sass
index 4fa2ed100d8..49be6054652 100644
--- a/odoo/addons/base/static/src/css/description.sass
+++ b/odoo/addons/base/static/src/css/description.sass
@@ -86,11 +86,11 @@ $v8_anchor_visited_color: rgb(91, 40, 79)
.oe_page
margin: 0px auto 64px auto
- max-width: 992px
+ max-width: 100%
.oe_row
- width: 928px
+ width: 100%
margin-top: 16px
margin-bottom: 16px
margin-left: auto
@@ -107,14 +107,14 @@ $v8_anchor_visited_color: rgb(91, 40, 79)
line-height: 0
height: 0
-$oe_span12_width: 928px
-$oe_span10_width: 773px
-$oe_span9_width: 696px
-$oe_span8_width: 618px
-$oe_span6_width: 464px
-$oe_span4_width: 309px
-$oe_span3_width: 232px
-$oe_span2_width: 154px
+$oe_span12_width: 100%
+$oe_span10_width: 83.33333333%
+$oe_span9_width: 75%
+$oe_span8_width: 66.66666667%
+$oe_span6_width: 50%
+$oe_span4_width: 33.33333333%
+$oe_span3_width: 25%
+$oe_span2_width: 16.66666667%
[class*='oe_span']
float: left
@@ -155,19 +155,19 @@ $oe_span2_width: 154px
.oe_span12
max-width: $oe_span12_width
.oe_span10
- max-width: ($oe_span10_width +-4px)
+ max-width: $oe_span10_width
.oe_span9
- max-width: ($oe_span9_width +-4px)
- .oe_span8
- max-width: ($oe_span8_width +-4px)
+ max-width: $oe_span9_width
+ .oe_span8
+ max-width: $oe_span8_width
.oe_span6
- max-width: ($oe_span6_width +-4px)
+ max-width: $oe_span6_width
.oe_span4
- max-width: ($oe_span4_width +-4px)
+ max-width: $oe_span4_width
.oe_span3
- max-width: ($oe_span3_width +-4px)
+ max-width: $oe_span3_width
.oe_span2
- max-width: ($oe_span2_width +-4px)
+ max-width: $oe_span2_width
.oe_mb0
margin-bottom: 0px !important
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 47b4bf3b8c7..9af3e9c782b 100644
--- a/odoo/addons/base/static/src/js/res_config_settings.js
+++ b/odoo/addons/base/static/src/js/res_config_settings.js
@@ -294,7 +294,7 @@ var BaseSettingRenderer = FormRenderer.extend({
var settingBox = $(this).closest('.o_setting_box');
if (!settingBox.hasClass('o_invisible_modifier')) {
settingBox.removeClass('o_hidden');
- $(this).html(self._wordHighlighter($(this).text(), self.searchText));
+ $(this).html(self._wordHighlighter($(this).html(), self.searchText));
} else {
self.inVisibleCount++;
}
diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py
index 73ba36f4813..72aac0bc6d5 100644
--- a/odoo/addons/test_new_api/tests/test_new_fields.py
+++ b/odoo/addons/test_new_api/tests/test_new_fields.py
@@ -736,6 +736,30 @@ class TestFields(common.TransactionCase):
self.assertEqual(len(discussion.important_messages), 2)
self.assertEqual(len(discussion.very_important_messages), 2)
+ def test_70_x2many_write(self):
+ discussion = self.env.ref('test_new_api.discussion_0')
+ Message = self.env['test_new_api.message']
+ # There must be 3 messages, 0 important
+ self.assertEqual(len(discussion.messages), 3)
+ self.assertEqual(len(discussion.important_messages), 0)
+ self.assertEqual(len(discussion.very_important_messages), 0)
+ discussion.important_messages = [(0, 0, {
+ 'body': 'What is the answer?',
+ 'important': True,
+ })]
+ # There must be 4 messages, 1 important
+ self.assertEqual(len(discussion.messages), 4)
+ self.assertEqual(len(discussion.important_messages), 1)
+ self.assertEqual(len(discussion.very_important_messages), 1)
+ discussion.very_important_messages |= Message.new({
+ 'body': '42',
+ 'important': True,
+ })
+ # There must be 5 messages, 2 important
+ self.assertEqual(len(discussion.messages), 5)
+ self.assertEqual(len(discussion.important_messages), 2)
+ self.assertEqual(len(discussion.very_important_messages), 2)
+
class TestX2many(common.TransactionCase):
def test_search_many2many(self):
diff --git a/odoo/addons/test_pylint/tests/test_pylint.py b/odoo/addons/test_pylint/tests/test_pylint.py
index 86d193b161d..f5ac83ffb24 100644
--- a/odoo/addons/test_pylint/tests/test_pylint.py
+++ b/odoo/addons/test_pylint/tests/test_pylint.py
@@ -27,6 +27,8 @@ class TestPyLint(TransactionCase):
'W0123', # eval used
'W0101', # unreachable code
+ 'mixed-indentation',
+
# py3k checks
'print-statement',
'backtick',
diff --git a/odoo/loglevels.py b/odoo/loglevels.py
index 4380ff29427..bbf7204607b 100644
--- a/odoo/loglevels.py
+++ b/odoo/loglevels.py
@@ -95,7 +95,7 @@ def ustr(value, hint_encoding='utf-8', errors='strict'):
def exception_to_unicode(e):
- if hasattr(e, 'args'):
+ if getattr(e, 'args', ()):
return "\n".join((ustr(a) for a in e.args))
try:
return text_type(e)
diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py
index dddd7c936cb..714d0734f56 100644
--- a/odoo/tools/translate.py
+++ b/odoo/tools/translate.py
@@ -932,7 +932,8 @@ def trans_generate(lang, modules, cr):
def get_module_from_path(path):
for (mp, rec) in path_list:
mp = os.path.join(mp, '')
- if rec and path.startswith(mp) and os.path.dirname(path) != mp:
+ dirname = os.path.join(os.path.dirname(path), '')
+ if rec and path.startswith(mp) and dirname != mp:
path = path[len(mp):]
return path.split(os.path.sep)[0]
return 'base' # files that are not in a module are considered as being in 'base' module
@@ -1043,7 +1044,9 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
# Make a reader for the POT file and be somewhat defensive for the
# stable branch.
- if fileobj.name.endswith('.po'):
+
+ # when fileobj is a TemporaryFile, its name is an interget in P3, a string in P2
+ if isinstance(fileobj.name, str) and fileobj.name.endswith('.po'):
try:
# Normally the path looks like /path/to/xxx/i18n/lang.po
# and we try to find the corresponding
diff --git a/requirements.txt b/requirements.txt
index ee6c39b586d..f003d714858 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
Babel==2.3.4
decorator==4.0.10
docutils==0.12
-ebaysdk==2.1.4
+ebaysdk==2.1.5
feedparser==5.2.1
gevent==1.1.2
greenlet==0.4.10
diff --git a/setup/win32/setup.nsi b/setup/win32/setup.nsi
index 10dbc9e52d8..948c0486905 100755
--- a/setup/win32/setup.nsi
+++ b/setup/win32/setup.nsi
@@ -8,7 +8,7 @@
!include 'FileFunc.nsh'
!include 'LogicLib.nsh'
!include 'Sections.nsh'
-!include 'LogicLib.nsh'
+!include 'x64.nsh'
!macro IfKeyExists ROOT MAIN_KEY KEY
# This macro comes from http://nsis.sourceforge.net/Check_for_a_Registry_Key
@@ -216,9 +216,16 @@ Section $(TITLE_OpenERP_Server) SectionOpenERP_Server
SetOutPath "$INSTDIR\server"
File /r /x "${POSTGRESQL_EXE_FILENAME}" /x "wkhtmltopdf" "..\..\*"
+ SetOutPath "$INSTDIR\vcredist"
+ File /r "..\..\..\vcredist\*.exe"
+
# Install Visual C redistribuable files
DetailPrint "Installing Visual C++ redistributable files"
- nsExec::Exec '"$INSTDIR\service\vcredist\vcredist_x86.exe" /q'
+ ${If} ${RunningX64}
+ nsExec::Exec '"$INSTDIR\vcredist\vc_redist.x64.exe" /q'
+ ${Else}
+ nsExec::Exec '"$INSTDIR\vcredist\vc_redist.x86.exe" /q'
+ ${EndIf}
SetOutPath "$INSTDIR\thirdparty"
File /r "${STATIC_PATH}\wkhtmltopdf\*"
@@ -241,8 +248,13 @@ Section $(TITLE_OpenERP_Server) SectionOpenERP_Server
DetailPrint "Installing Windows service"
nsExec::ExecTOLog '"$INSTDIR\python\python.exe" "$INSTDIR\server\odoo-bin" --stop-after-init --logfile "$INSTDIR\server\odoo.log" -s'
- nsExec::ExecToLog '"$INSTDIR\nssm\win32\nssm.exe" install ${SERVICENAME} "$INSTDIR\python\python.exe" "\"$INSTDIR\server\odoo-bin\""'
- nsExec::ExecToLog '"$INSTDIR\nssm\win32\nssm.exe" set AppDirectory "$\"$INSTDIR\server$\""'
+ ${If} ${RunningX64}
+ nsExec::ExecToLog '"$INSTDIR\nssm\win64\nssm.exe" install ${SERVICENAME} "$INSTDIR\python\python.exe" "\"$INSTDIR\server\odoo-bin\""'
+ nsExec::ExecToLog '"$INSTDIR\nssm\win64\nssm.exe" set ${SERVICENAME} AppDirectory "$\"$INSTDIR\server$\""'
+ ${Else}
+ nsExec::ExecToLog '"$INSTDIR\nssm\win32\nssm.exe" install ${SERVICENAME} "$INSTDIR\python\python.exe" "\"$INSTDIR\server\odoo-bin\""'
+ nsExec::ExecToLog '"$INSTDIR\nssm\win32\nssm.exe" set ${SERVICENAME} AppDirectory "$\"$INSTDIR\server$\""'
+ ${EndIf}
nsExec::Exec "net stop ${SERVICENAME}"
sleep 2