diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-02-01 18:15:21 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-02-01 18:15:21 +0100 |
| commit | 7e469eb77aae1f7e05a3ce0f6575b5149fed3ca1 (patch) | |
| tree | 25023238fd6ccde1f3b8e805b03fcf228798a7b0 | |
| parent | c4895c2f2ba0fcd8fc5aef210003c20ba2154979 (diff) | |
| parent | f5e7b866860436782ade53308da1ff725be1f124 (diff) | |
[MERGE] forward port branch 11.0 up to f5e7b866860
59 files changed, 444 insertions, 199 deletions
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index b6bc2732231..8764d190991 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -332,7 +332,7 @@ class account_payment(models.Model): else: if self.payment_type == 'inbound': domain.append(('at_least_one_inbound', '=', True)) - else: + elif self.payment_type == 'outbound': domain.append(('at_least_one_outbound', '=', True)) return {'domain': domain, 'journal_types': set(journal_type)} @@ -341,9 +341,16 @@ class account_payment(models.Model): jrnl_filters = self._compute_journal_domain_and_types() journal_types = jrnl_filters['journal_types'] domain_on_types = [('type', 'in', list(journal_types))] - if self.journal_id.type not in journal_types: - self.journal_id = self.env['account.journal'].search(domain_on_types, limit=1) - return {'domain': {'journal_id': jrnl_filters['domain'] + domain_on_types}} + + journal_domain = jrnl_filters['domain'] + domain_on_types + default_journal_id = self.env.context.get('default_journal_id') + if not default_journal_id: + if self.journal_id.type not in journal_types: + self.journal_id = self.env['account.journal'].search(domain_on_types, limit=1) + else: + journal_domain = journal_domain.append(('id', '=', default_journal_id)) + + return {'domain': {'journal_id': journal_domain}} @api.one @api.depends('invoice_ids', 'payment_type', 'partner_type', 'partner_id') @@ -374,6 +381,8 @@ class account_payment(models.Model): self.partner_type = 'customer' elif self.payment_type == 'outbound': self.partner_type = 'supplier' + else: + self.partner_type = False # Set payment method domain res = self._onchange_journal() if not res.get('domain', {}): @@ -505,7 +514,7 @@ class account_payment(models.Model): @api.multi def action_draft(self): return self.write({'state': 'draft'}) - + def action_validate_invoice_payment(self): """ Posts a payment used to pay an invoice. This function only posts the payment by default but can be overridden to apply specific post or pre-processing. @@ -669,7 +678,7 @@ class account_payment(models.Model): for inv in invoice: if inv.move_id: name += inv.number + ', ' - name = name[:len(name)-2] + name = name[:len(name)-2] return { 'name': name, 'account_id': self.destination_account_id.id, diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index 7a878d265c7..22726578d1b 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -168,10 +168,6 @@ <label for="currency_exchange_journal_id" class="col-md-3 o_light_label"/> <field name="currency_exchange_journal_id"/> </div> - <div class="row mt16"> - <label for="currency_exchange_journal_id" class="col-md-3 o_light_label"/> - <field name="currency_exchange_journal_id"/> - </div> </div> </div> </div> diff --git a/addons/account_asset/models/account_invoice.py b/addons/account_asset/models/account_invoice.py index dd22ea851f3..1c8333f550a 100644 --- a/addons/account_asset/models/account_invoice.py +++ b/addons/account_asset/models/account_invoice.py @@ -3,7 +3,8 @@ from datetime import datetime from dateutil.relativedelta import relativedelta -from odoo import api, fields, models +from odoo import api, fields, models, _ +from odoo.exceptions import UserError from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF from odoo.addons import decimal_precision as dp diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py index aa49b0f1993..65d734fd11a 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -32,7 +32,7 @@ class AccountRegisterPayments(models.TransientModel): res = super(AccountRegisterPayments, self)._prepare_payment_vals(invoices) if self.payment_method_id == self.env.ref('account_check_printing.account_payment_method_check'): res.update({ - 'check_amount_in_words': self.check_amount_in_words, + 'check_amount_in_words': self.currency_id.amount_to_text(res['amount']) if self.multi else self.check_amount_in_words, 'check_manual_sequencing': self.check_manual_sequencing, }) return res diff --git a/addons/barcodes/models/__init__.py b/addons/barcodes/models/__init__.py index 05b9acf6f97..e3f49cb14ed 100644 --- a/addons/barcodes/models/__init__.py +++ b/addons/barcodes/models/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import barcodes from . import barcode_events_mixin +from . import ir_http diff --git a/addons/barcodes/models/ir_http.py b/addons/barcodes/models/ir_http.py new file mode 100644 index 00000000000..7ff6aa7c8f2 --- /dev/null +++ b/addons/barcodes/models/ir_http.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class IrHttp(models.AbstractModel): + _inherit = 'ir.http' + + def session_info(self): + res = super(IrHttp, self).session_info() + res['max_time_between_keys_in_ms'] = int( + self.env['ir.config_parameter'].sudo().get_param('barcode.max_time_between_keys_in_ms', default='55')) + return res diff --git a/addons/barcodes/static/src/js/barcode_events.js b/addons/barcodes/static/src/js/barcode_events.js index f26748ac7b3..e02d3aafdb7 100644 --- a/addons/barcodes/static/src/js/barcode_events.js +++ b/addons/barcodes/static/src/js/barcode_events.js @@ -3,6 +3,7 @@ odoo.define('barcodes.BarcodeEvents', function(require) { var core = require('web.core'); var mixins = require('web.mixins'); +var session = require('web.session'); // For IE >= 9, use this, new CustomEvent(), instead of new Event() @@ -26,7 +27,7 @@ var BarcodeEvents = core.Class.extend(mixins.PropertiesMixin, { suffix: /[\n\r\t]+/, // Keys from a barcode scanner are usually processed as quick as possible, // but some scanners can use an intercharacter delay (we support <= 50 ms) - max_time_between_keys_in_ms: 55, + max_time_between_keys_in_ms: session.max_time_between_keys_in_ms || 55, init: function() { mixins.PropertiesMixin.init.call(this); diff --git a/addons/event/models/event_mail.py b/addons/event/models/event_mail.py index d87c24dd663..1013f5a083b 100644 --- a/addons/event/models/event_mail.py +++ b/addons/event/models/event_mail.py @@ -96,6 +96,7 @@ class EventMailScheduler(models.Model): @api.one def execute(self): + now = fields.Datetime.now() if self.interval_type == 'after_sub': # update registration lines lines = [ @@ -105,9 +106,10 @@ class EventMailScheduler(models.Model): if lines: self.write({'mail_registration_ids': lines}) # execute scheduler on registrations - self.mail_registration_ids.filtered(lambda reg: reg.scheduled_date and reg.scheduled_date <= datetime.strftime(fields.datetime.now(), tools.DEFAULT_SERVER_DATETIME_FORMAT)).execute() + self.mail_registration_ids.filtered(lambda reg: reg.scheduled_date and reg.scheduled_date <= now).execute() else: - if not self.mail_sent: + # Do not send emails if the mailing was scheduled before the event but the event is over + if not self.mail_sent and (self.interval_type != 'before_event' or self.event_id.date_end > now): self.event_id.mail_attendees(self.template_id.id) self.write({'mail_sent': True}) return True diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index fc75dbe232e..d3351e1b5f2 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -290,12 +290,14 @@ </field> </record> + <!-- TODO remove me in master --> <record id="hr_employee_action_subordinate_hierachy" model="ir.actions.act_window"> <field name="name">Subordinate Hierarchy</field> <field name="type">ir.actions.act_window</field> <field name="res_model">hr.employee</field> - <field name="domain">[('id','in',active_ids)]</field> - <field name="view_type">tree</field> + <field name="domain">['|', ('id','in',active_ids), ('parent_id', 'in', active_ids)]</field> + <field name="view_type">form</field> + <field name="view_mode">tree,form</field> <field name="view_id" ref="view_partner_tree2"/> <field name="binding_model_id" ref="hr.model_hr_employee"/> </record> diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index af27e3094a4..237e280ed65 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -67,7 +67,7 @@ class Task(models.Model): if 'project_id' in values: project_id = values.get('project_id') # a timesheet must have an analytic account (and a project) - if not project_id: + if self and not project_id: raise UserError(_('This task must have a project since they are linked to timesheets.')) self.sudo().mapped('timesheet_ids').write({ 'project_id': project_id, diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index 34e100cea2e..4011f4987d7 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -418,6 +418,8 @@ class IrHttp(models.AbstractModel): try: _, path = rule.build(arguments) assert path is not None + except odoo.exceptions.MissingError: + return cls._handle_exception(werkzeug.exceptions.NotFound()) except Exception as e: return cls._handle_exception(e) diff --git a/addons/iap/static/src/xml/iap_templates.xml b/addons/iap/static/src/xml/iap_templates.xml index 9b52a4ec445..4995c4527e3 100644 --- a/addons/iap/static/src/xml/iap_templates.xml +++ b/addons/iap/static/src/xml/iap_templates.xml @@ -17,9 +17,11 @@ </div> <t t-extend="DashboardApps"> - <t t-jquery=".o_web_settings_dashboard_pills" t-operation="append"> - <a class="pull-right" t-att-href="widget.data.url" target="_blank"> - <i class="fa fa-money fa-2x text-muted"/> In-App Purchases</a> + <t t-jquery=".o_web_settings_dashboard_pills" t-operation="after"> + <div class="text-center" style="display: inline-block"> + <a t-att-href="widget.data.url" target="_blank"> + <i class="fa fa-money fa-2x text-muted"/> In-App Purchases</a> + </div> </t> </t> diff --git a/addons/l10n_in_hr_payroll/views/report_payslip_details_template.xml b/addons/l10n_in_hr_payroll/views/report_payslip_details_template.xml index 72bc16378ab..44137ab88f5 100644 --- a/addons/l10n_in_hr_payroll/views/report_payslip_details_template.xml +++ b/addons/l10n_in_hr_payroll/views/report_payslip_details_template.xml @@ -62,7 +62,8 @@ <strong><span t-esc="h['rule_category']"/></strong> </td> <td> - <strong><span t-esc="format(h['total'], 'monetary', o.company_id.currency_id)"/></strong> + <strong><span t-esc="h['total']" + t-esc-options='{"widget": "monetary", "display_currency": o.company_id.currency_id}'/></strong> </td> </tr> </t> @@ -75,7 +76,8 @@ <span t-esc="'..'*h['level']"/><span t-esc="h['rule_category']"/> </td> <td> - <span t-esc="format(h['total'], 'monetary', o.company_id.currency_id)"/> + <span t-esc="h['total']" + t-esc-options='{"widget": "monetary", "display_currency": o.company_id.currency_id}'/> </td> </tr> </t> diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index e0e16eeef7c..65ffe08e1e1 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -300,7 +300,7 @@ class MailTemplate(models.Model): def _process_link(url): new_url = urls.url_parse(url) - if new_url.scheme and new_url.netloc: + if new_url.scheme and (new_url.netloc or new_url.scheme == 'mailto'): return url return new_url.replace(scheme=base.scheme, netloc=base.netloc).to_url() diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js index 2b5b342787b..61650ceaa73 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/client_action.js @@ -890,7 +890,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { var def = window.Notification && window.Notification.requestPermission(); if (def) { def.then(function (value) { - if (value === 'denied') { + if (value !== 'granted') { utils.send_notification(self, _t('Permission denied'), _t('Odoo will not have the permission to send native notifications on this device.')); } else { diff --git a/addons/maintenance/models/maintenance.py b/addons/maintenance/models/maintenance.py index d46b688ca79..53b450baee7 100644 --- a/addons/maintenance/models/maintenance.py +++ b/addons/maintenance/models/maintenance.py @@ -368,7 +368,7 @@ class MaintenanceTeam(models.Model): _name = 'maintenance.team' _description = 'Maintenance Teams' - name = fields.Char(required=True) + name = fields.Char(required=True, translate=True) member_ids = fields.Many2many('res.users', 'maintenance_team_users_rel', string="Team Members") color = fields.Integer("Color Index", default=0) request_ids = fields.One2many('maintenance.request', 'maintenance_team_id', copy=False) 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 7705e62fa9e..c40f31c258f 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_editor.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_editor.js @@ -323,20 +323,20 @@ snippets_editor.Class.include({ $body.removeClass(all_classes).addClass(theme_params.className); var $old_layout = $editable_area.find(".o_layout"); - // This wrapper structure is the only way to have a responsive and - // centered fixed-width content column on all mail clients var $new_wrapper, $new_wrapper_content; if (theme_params.nowrap) { $new_wrapper = $new_wrapper_content = $("<div/>", {"class": "oe_structure"}); } else { + // This wrapper structure is the only way to have a responsive and + // centered fixed-width content column on all mail clients $new_wrapper = $('<table/>', {class: 'o_mail_wrapper'}); - $new_wrapper_content = $("<td/>", {class: 'o_mail_no_resize o_mail_wrapper_td oe_structure'}); + $new_wrapper_content = $("<td/>", {class: 'o_mail_no_resize o_mail_no_colorpicker o_mail_wrapper_td oe_structure'}); $new_wrapper.append($('<tr/>').append( - $("<td/>", {class: 'o_mail_no_resize'}), + $("<td/>", {class: 'o_mail_no_resize o_not_editable', contenteditable: 'false'}), $new_wrapper_content, - $("<td/>", {class: 'o_mail_no_resize'}) + $("<td/>", {class: 'o_mail_no_resize o_not_editable', contenteditable: 'false'}) )); } var $new_layout = $("<div/>", {"class": "o_layout " + theme_params.className}).append($new_wrapper); diff --git a/addons/mass_mailing/views/snippets_themes_options.xml b/addons/mass_mailing/views/snippets_themes_options.xml index 34d973369e8..88c3ea889e7 100644 --- a/addons/mass_mailing/views/snippets_themes_options.xml +++ b/addons/mass_mailing/views/snippets_themes_options.xml @@ -6,10 +6,12 @@ <t t-raw="0"/> <div data-js="width-x" - data-selector="img, .mv, .col_mv, td:not(.o_mail_no_resize), th"> - </div> + data-selector="img, .mv, .col_mv, td, th" + data-exclude=".o_mail_no_resize, .o_mail_wrapper_td"/> - <div data-js="table_item" data-selector="td, th"/> + <div data-js="table_item" + data-selector="td, th" + data-exclude=".o_mail_wrapper_td"/> <div data-js="table_row" data-selector="tr:has(> .row), tr:has(> .col_mv)" @@ -18,11 +20,13 @@ <div data-js="table_column" data-selector=".col>td, .col>th" + data-exclude=".o_mail_wrapper_td" data-drop-near=".col>td, .col>th"> </div> <div data-js="table_column_mv" data-selector=".col_mv, td, th" + data-exclude=".o_mail_wrapper_td" data-drop-near=".col_mv, td, th"> </div> @@ -33,19 +37,22 @@ </div> <div data-js="resize" - data-selector="[data-oe-field='body_html'] > div:not(.o_layout), [data-oe-field='body_html'] .oe_structure > div"> + data-selector="[data-oe-field='body_html'] > div:not(.o_layout), [data-oe-field='body_html'] .oe_structure > div" + data-exclude=".o_mail_no_resize, .o_mail_wrapper_td"> </div> <div data-js="margin-y" - data-selector="[data-oe-field='body_html'] > div:not(.o_layout), [data-oe-field='body_html'] .oe_structure > div, td:not(.o_mail_no_resize), th"> + data-selector="[data-oe-field='body_html'] > div:not(.o_layout), [data-oe-field='body_html'] .oe_structure > div, td, th" + data-exclude=".o_mail_no_resize, .o_mail_wrapper_td"> </div> <div data-js="colorpicker" data-selector=" - [data-oe-field='body_html'] > div:not(.o_layout):not(.o_mail_no_colorpicker), - [data-oe-field='body_html'] .oe_structure > div:not(.o_mail_no_colorpicker), + [data-oe-field='body_html'] > div:not(.o_layout), + [data-oe-field='body_html'] .oe_structure > div, td, th - "> + " + data-exclude=".o_mail_no_colorpicker"> <li class="dropdown-submenu"> <a tabindex="-1" href="#"><i class="fa fa-eyedropper"/>Background Color</a> <ul class="dropdown-menu"> diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 73f4c861727..ccba058f291 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -151,7 +151,7 @@ class StockMove(models.Model): # all grouped in the same picking. if not self.picking_type_id: return self - bom = self.env['mrp.bom'].sudo()._bom_find(product=self.product_id) + bom = self.env['mrp.bom'].sudo()._bom_find(product=self.product_id, company_id=self.company_id.id) if not bom or bom.type != 'phantom': return self phantom_moves = self.env['stock.move'] diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 1d6136d7e95..9de067caf0d 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -48,7 +48,7 @@ <button name="button_unreserve" type="object" string="Unreserve" attrs="{'invisible': [('unreserve_visible', '=', False)]}"/> <span class="label label-danger" attrs="{'invisible': ['|',('availability', 'in', ('assigned', 'none')), ('state', 'not in', ('confirmed','progress'))]}">Raw materials not available!</span> <field name="state" widget="statusbar" statusbar_visible="confirmed,progress,done"/> - <button name="action_toggle_is_locked" attrs="{'invisible': ['|', '|', ('state', '=', 'cancel'), ('id', '=', False), ('is_locked', '=', False)]}" string="Unlock" groups="mrp.group_mrp_manager" type="object" help="If the manufacturing order is unlocked you can add to the initial demand."/> + <button name="action_toggle_is_locked" attrs="{'invisible': ['|', '|', ('state', '=', 'cancel'), ('id', '=', False), ('is_locked', '=', False)]}" string="Unlock" groups="mrp.group_mrp_manager" type="object" help="Unlock the manufacturing order to correct what has been consumed or produced."/> <button name="action_toggle_is_locked" attrs="{'invisible': [('is_locked', '=', True)]}" string="Lock" class="oe_highlight" groups="mrp.group_mrp_manager" type="object"/> </header> <sheet> diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index 60eaa5fc2bb..aebcff24918 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -132,7 +132,7 @@ class PadCommon(models.AbstractModel): # Update the `pad_content_field` if the pad is modified for k, v in list(vals.items()): - field = self._fields[k] + field = self._fields.get(k) if hasattr(field, 'pad_content_field'): vals[field.pad_content_field] = self.pad_get_content(v) diff --git a/addons/payment_sips/models/payment.py b/addons/payment_sips/models/payment.py index f06b72461f9..46464b7d60c 100644 --- a/addons/payment_sips/models/payment.py +++ b/addons/payment_sips/models/payment.py @@ -76,7 +76,7 @@ class AcquirerSips(models.Model): if self.environment == 'prod': # For production environment, key version 2 is required merchant_id = getattr(self, 'sips_merchant_id') - key_version = '2' + key_version = self.env['ir.config_parameter'].sudo().get_param('sips.key_version', '2') else: # Test key provided by Atos Wordline works only with version 1 merchant_id = '002001000000001' diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index ba856c4cfab..080d7277657 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -1018,7 +1018,10 @@ class ReportSaleDetails(models.AbstractModel): for tax in line_taxes['taxes']: taxes.setdefault(tax['id'], {'name': tax['name'], 'tax_amount':0.0, 'base_amount':0.0}) taxes[tax['id']]['tax_amount'] += tax['amount'] - taxes[tax['id']]['base_amount'] += line.price_subtotal + taxes[tax['id']]['base_amount'] += tax['base'] + else: + taxes.setdefault(0, {'name': _('No Taxes'), 'tax_amount':0.0, 'base_amount':0.0}) + taxes[0]['base_amount'] += line.price_subtotal_incl st_line_ids = self.env["account.bank.statement.line"].search([('pos_statement_id', 'in', orders.ids)]).ids if st_line_ids: diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 14b9285f38d..99fdc1ec7db 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -331,7 +331,7 @@ exports.PosModel = Backbone.Model.extend({ ids: function(self){ return [self.config.currency_id[0]]; }, loaded: function(self, currencies){ self.currency = currencies[0]; - if (self.currency.rounding > 0) { + if (self.currency.rounding > 0 && self.currency.rounding < 1) { self.currency.decimals = Math.ceil(Math.log(1.0 / self.currency.rounding) / Math.log(10)); } else { self.currency.decimals = 0; @@ -528,7 +528,7 @@ exports.PosModel = Backbone.Model.extend({ var fields = typeof model.fields === 'function' ? model.fields(self,tmp) : model.fields; var domain = typeof model.domain === 'function' ? model.domain(self,tmp) : model.domain; - var context = typeof model.context === 'function' ? model.context(self,tmp) : model.context; + var context = typeof model.context === 'function' ? model.context(self,tmp) : model.context || {}; var ids = typeof model.ids === 'function' ? model.ids(self,tmp) : model.ids; var order = typeof model.order === 'function' ? model.order(self,tmp): model.order; progress += progress_step; @@ -536,7 +536,7 @@ exports.PosModel = Backbone.Model.extend({ if( model.model ){ var params = { model: model.model, - context: context, + context: _.extend(context, session.user_context || {}), }; if (model.ids) { diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index 02d02844a82..ef23444c32c 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -94,13 +94,11 @@ class AccountInvoice(models.Model): self.purchase_id = False return {} - @api.onchange('currency_id', 'date_invoice') + @api.onchange('currency_id') def _onchange_currency_id(self): if self.currency_id: for line in self.invoice_line_ids.filtered(lambda r: r.purchase_line_id): - if line.purchase_id.currency_id != self.currency_id: - line.price_unit = line.purchase_id.currency_id.with_context(date=self.date_invoice)\ - .compute(line.purchase_line_id.price_unit, self.currency_id, round=False) + line.price_unit = line.purchase_id.currency_id.with_context(date=self.date_invoice).compute(line.purchase_line_id.price_unit, self.currency_id, round=False) @api.onchange('invoice_line_ids') def _onchange_origin(self): diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index bcf7799f161..50cb684699f 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -128,6 +128,8 @@ class ResourceCalendar(models.Model): ) def _interval_and(self, interval, interval_dst): + if interval.start_datetime > interval_dst.end_datetime or interval.end_datetime < interval_dst.start_datetime: + return None return self._interval_obj( interval.start_datetime > interval_dst.start_datetime and interval.start_datetime or interval_dst.start_datetime, interval.end_datetime < interval_dst.end_datetime and interval.end_datetime or interval_dst.end_datetime, @@ -392,10 +394,10 @@ class ResourceCalendar(models.Model): start_datetime=datetime.datetime.combine(day_date, start_time), end_datetime=datetime.datetime.combine(day_date, end_time)) - final_intervals = [ - self._interval_and(leave_interval, work_interval) - for leave_interval in leaves_intervals - for work_interval in working_intervals] + final_intervals = [i for i in + [self._interval_and(leave_interval, work_interval) + for leave_interval in leaves_intervals + for work_interval in working_intervals] if i] # adapt tz return [self._interval_new( diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py index 617d6f9b79e..f31a89399dd 100644 --- a/addons/resource/tests/test_resource.py +++ b/addons/resource/tests/test_resource.py @@ -55,6 +55,12 @@ class TestIntervals(TestResourceCommon): # Third interval: 04, 17-21, 18-19 being inside 17-21 self.assertEqual(cleaned_intervals[2][:2], (Datetime.from_string('2013-02-04 17:00:00'), Datetime.from_string('2013-02-04 21:00:00'))) + def test_interval_and(self): + self.assertEqual(self.env['resource.calendar']._interval_and(self.intervals[0], self.intervals[1]), + self.calendar._interval_new(Datetime.from_string('2013-02-04 09:00:00'), Datetime.from_string('2013-02-04 11:00:00'))) + self.assertEqual(self.env['resource.calendar']._interval_and(self.intervals[2], self.intervals[3]), + None) + def test_interval_remove(self): working_interval = self.calendar._interval_new(Datetime.from_string('2013-02-04 08:00:00'), Datetime.from_string('2013-02-04 18:00:00')) result = self.env['resource.calendar']._interval_remove_leaves(working_interval, self.intervals) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 9f07d83cbe5..1c460150555 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -830,8 +830,9 @@ class SaleOrderLine(models.Model): # Prevent writing on a locked SO. protected_fields = self._get_protected_fields() if 'done' in self.mapped('order_id.state') and any(f in values.keys() for f in protected_fields): + protected_fields_modified = list(set(protected_fields) & set(values.keys())) fields = self.env['ir.model.fields'].search([ - ('name', 'in', protected_fields), ('model', '=', self._name) + ('name', 'in', protected_fields_modified), ('model', '=', self._name) ]) raise UserError( _('It is forbidden to modify the following fields in a locked order:\n%s') diff --git a/addons/sms/wizard/send_sms.py b/addons/sms/wizard/send_sms.py index 844933d092a..139f542e597 100644 --- a/addons/sms/wizard/send_sms.py +++ b/addons/sms/wizard/send_sms.py @@ -35,7 +35,7 @@ class SendSMS(models.TransientModel): def _sms_sanitization(self, partner, field_name): number = partner[field_name] - if _sms_phonenumbers_lib_imported: + if number and _sms_phonenumbers_lib_imported: country = self._phone_get_country(partner) country_code = country.code if country else None try: diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 126da0a781d..167f1089c8f 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -246,7 +246,7 @@ <field name="backorder_id" readonly="1" attrs="{'invisible': [('backorder_id','=',False)]}"/> </group> <group> - <field name="scheduled_date" attrs="{'readonly': [('id', '=', False)]}"/> + <field name="scheduled_date" attrs="{'readonly': [('id', '=', False)], 'required': [('id', '!=', False)]}"/> <field name="origin" placeholder="e.g. PO0032"/> <field name="owner_id" groups="stock.group_tracking_owner"/> <div groups="stock.group_tracking_owner" colspan="2" col="2"> diff --git a/addons/stock_account/views/stock_account_views.xml b/addons/stock_account/views/stock_account_views.xml index e169a2b7a8d..bd9f7f523f8 100644 --- a/addons/stock_account/views/stock_account_views.xml +++ b/addons/stock_account/views/stock_account_views.xml @@ -43,9 +43,9 @@ <field name="inherit_id" ref="stock.view_stock_return_picking_form"/> <field name="model">stock.return.picking</field> <field name="arch" type="xml"> - <field name="uom_id" position="after"> + <xpath expr="//field[@name='product_return_moves']/tree" position="inside"> <field name="to_refund"/> - </field> + </xpath> </field> </record> diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 94facd80ceb..276c9e12544 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -1082,6 +1082,7 @@ var UrlWidget = InputField.extend({ _renderReadonly: function () { this.$el.text(this.attrs.text || this.value) .addClass('o_form_uri o_text_overflow') + .attr('target', '_blank') .attr('href', this.value); } }); diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index bdafcee04da..7006ef03471 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -1104,6 +1104,7 @@ var FieldX2Many = AbstractField.extend({ * @param {OdooEvent} event */ _onResequence: function (event) { + event.stopPropagation(); var self = this; this.trigger_up('freeze_order', {id: this.value.id}); var rowIDs = event.data.rowIDs.slice(); diff --git a/addons/web/static/src/js/fields/special_fields.js b/addons/web/static/src/js/fields/special_fields.js index 19db97fe985..6bd5d03ac69 100644 --- a/addons/web/static/src/js/fields/special_fields.js +++ b/addons/web/static/src/js/fields/special_fields.js @@ -20,7 +20,8 @@ var FieldTimezoneMismatch = FieldSelection.extend({ * @override */ start: function () { - this._datetime = setInterval(this._renderDateTimeTimezone.bind(this), 1000); + var interval = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? 60000 : 1000; + this._datetime = setInterval(this._renderDateTimeTimezone.bind(this), interval); return this._super.apply(this, arguments); }, /** diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js index d3553d8db12..e3ea8a82343 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column.js +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -103,7 +103,7 @@ var KanbanColumn = Widget.extend({ // should find a way to use the touch events to make sortable work. this.$el.sortable({ connectWith: '.o_kanban_group', - containment: this.draggable ? '.o_kanban_view' : 'parent', + containment: this.draggable ? false : 'parent', revert: 0, delay: 0, items: '> .o_kanban_record:not(.o_updating)', diff --git a/addons/web/static/src/less/webclient.less b/addons/web/static/src/less/webclient.less index dfc26b11f5a..215b86cef83 100644 --- a/addons/web/static/src/less/webclient.less +++ b/addons/web/static/src/less/webclient.less @@ -86,39 +86,37 @@ div.o_boolean_toggle { width: 32px !important; height: 18px; - & > label { - & > input { + > input { display: none; } - & > .slider { + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #a0a0a0; + border-radius: 24px; + + &:before { position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #a0a0a0; - border-radius: 24px; - - &:before { - position: absolute; - content: ""; - height: 12px; - width: 12px; - left: 3px; - bottom: 3px; - background-color: white; - border-radius: 50%; - } + content: ""; + height: 12px; + width: 12px; + left: 3px; + bottom: 3px; + background-color: white; + border-radius: 50%; } + } - & > input:checked + .slider { - background-color: @odoo-brand-optional; + > input:checked + .slider { + background-color: @odoo-brand-optional; - &:before { - .translate(14px; 0px); - } + &:before { + .translate(14px; 0px); } } } diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 4fd9d6ee84e..4f3f4b413df 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -1110,7 +1110,7 @@ QUnit.module('basic_fields', { QUnit.module('UrlWidget'); QUnit.test('url widget in form view', function (assert) { - assert.expect(8); + assert.expect(9); var form = createView({ View: FormView, @@ -1130,6 +1130,8 @@ QUnit.module('basic_fields', { "should have a anchor with correct classes"); assert.strictEqual(form.$('a.o_form_uri.o_field_widget.o_text_overflow').attr('href'), 'yop', "should have proper href link"); + assert.strictEqual(form.$('a.o_form_uri.o_field_widget.o_text_overflow').attr('target'), '_blank', + "should have target attribute set to _blank"); assert.strictEqual(form.$('a.o_form_uri.o_field_widget.o_text_overflow').text(), 'yop', "the value should be displayed properly"); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 8938755a8f3..0782d667690 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1321,6 +1321,93 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('X2Many sequence list in modal', function (assert) { + assert.expect(5); + + this.data.partner.fields.sequence = {string: 'Sequence', type: 'integer'}; + this.data.partner.records[0].sequence = 1; + this.data.partner.records[1].sequence = 2; + this.data.partner.onchanges = { + sequence: function (obj) { + if (obj.id === 2) { + obj.sequence = 1; + assert.step('onchange sequence'); + } + }, + }; + + this.data.product.fields.turtle_ids = {string: 'Turtles', type: 'one2many', relation: 'turtle'}; + this.data.product.records[0].turtle_ids = [1]; + + this.data.turtle.fields.partner_types_ids = {string: "Partner", type: "one2many", relation: 'partner'}; + this.data.turtle.fields.type_id = {string: "Partner Type", type: "many2one", relation: 'partner_type'}; + + this.data.partner_type.fields.partner_ids = {string: "Partner", type: "one2many", relation: 'partner'}; + this.data.partner_type.records[0].partner_ids = [1,2]; + + testUtils.createAsyncView({ + View: FormView, + model: 'product', + data: this.data, + arch: '<form>' + + '<field name="name"/>' + + '<field name="turtle_ids" widget="one2many_list">' + + '<tree string="Turtles" editable="bottom">' + + '<field name="type_id"/>' + + '</tree>' + + '</field>' + + '</form>', + archs: { + 'partner_type,false,form': '<form><field name="partner_ids"/></form>', + 'partner,false,list': '<tree string="Vendors">' + + '<field name="display_name"/>' + + '<field name="sequence" widget="handle"/>' + + '</tree>', + }, + res_id: 37, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/product/read') { + return $.when([{id: 37, name: 'xphone', display_name: 'leonardo', turtle_ids: [1]}]); + } + if (route === '/web/dataset/call_kw/turtle/read') { + return $.when([{id: 1, type_id: [12, 'gold']}]); + } + if (route === '/web/dataset/call_kw/partner_type/get_formview_id') { + return $.when(false) + } + if (route === '/web/dataset/call_kw/partner_type/read') { + return $.when([{id: 12, partner_ids: [1,2], display_name: 'gold'}]) + } + if (route === '/web/dataset/call_kw/partner_type/write') { + assert.step('partner_type write'); + } + return this._super.apply(this, arguments); + }, + }).then(function(form) { + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_data_cell').click(); + form.$('.o_external_button').click(); + + var $modal = $('.modal-dialog'); + assert.equal($modal.length, 1, + 'There should be 1 modal opened'); + + var $handles = $modal.find('.ui-sortable-handle'); + assert.equal($handles.length, 2, + 'There should be 2 sequence handlers'); + + testUtils.dragAndDrop($handles.eq(1), $modal.find('tbody tr').first()); + + // Saving the modal and then the original model + $modal.find('.modal-footer .btn-primary').click(); + form.$buttons.find('.o_form_button_save').click(); + + assert.verifySteps(['onchange sequence', 'partner_type write']); + + form.destroy(); + }); + }); + QUnit.test('autocompletion in a many2one, in form view with a domain', function (assert) { assert.expect(1); diff --git a/addons/web_editor/static/src/js/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js index cbdb6cbb88b..b84fcd21c91 100644 --- a/addons/web_editor/static/src/js/backend/fields.js +++ b/addons/web_editor/static/src/js/backend/fields.js @@ -409,7 +409,19 @@ var FieldTextHtml = AbstractField.extend({ var layoutInfo = self.editor.rte.editable().data('layoutInfo'); $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); } + var $ancestors = self.$iframe.filter(':not(:visible)').parentsUntil(':visible').addBack(); + var ancestorsStyle = []; + // temporarily force displaying iframe (needed for firefox) + _.each($ancestors, function (el) { + var $el = $(el); + ancestorsStyle.unshift($el.attr('style') || null); + $el.css({display: 'initial', visibility: 'hidden', height: 1}); + }); self.editor.snippetsMenu && self.editor.snippetsMenu.cleanForSave(); + _.each($ancestors, function (el) { + var $el = $(el); + $el.attr('style', ancestorsStyle.pop()); + }); self._setValue(self.$content.html()); }); }, diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js index d2bcd29e5ce..ee0ec7932c0 100644 --- a/addons/web_editor/static/src/js/editor/rte.js +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -281,8 +281,10 @@ var RTEWidget = Widget.extend({ .each(function () { var $node = $(this); + // fallback for firefox iframe display:none see https://github.com/odoo/odoo/pull/22610 + var computedStyles = window.getComputedStyle(this) || window.parent.getComputedStyle(this); // add class to display inline-block for empty t-field - if (window.getComputedStyle(this).display === 'inline' && $node.data('oe-type') !== 'image') { + if (computedStyles.display === 'inline' && $node.data('oe-type') !== 'image') { $node.addClass('o_is_inline_editable'); } }); @@ -651,26 +653,16 @@ var RTEWidget = Widget.extend({ self.historyRecordUndo($target, 'activate', true); }); - // 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) { - _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(); + // Browsers select different content from one to another after a + // triple click (especially: if triple-clicking on a paragraph on + // Chrome, blank characters of the element following the paragraph are + // selected too) + // + // The triple click behavior is reimplemented for all browsers here + if (ev.originalEvent.detail === 3) { + // Select the whole content inside the deepest DOM element that was + // triple-clicked + range.create(ev.target, 0, ev.target, ev.target.childNodes.length).select(); } }, }); 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 fb1ce3e12b4..2ac9df2d693 100644 --- a/addons/web_editor/static/src/js/editor/rte.summernote.js +++ b/addons/web_editor/static/src/js/editor/rte.summernote.js @@ -417,6 +417,9 @@ dom.isImg = function (node) { }; var fn_is_forbidden_node = dom.isForbiddenNode || function () {}; dom.isForbiddenNode = function (node) { + if (node.tagName === "BR") { + return false; + } return fn_is_forbidden_node(node) || $(node).is(".media_iframe_video"); }; var fn_is_img_font = dom.isImgFont || function () {}; diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js index 8ce2ed692da..59609eab8a5 100644 --- a/addons/web_editor/static/src/js/editor/summernote.js +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -694,10 +694,16 @@ dom.isRemovableEmptyNode = function (node) { dom.isForbiddenNode = function (node) { return node.tagName === "BR" || $(node).is(".fa, img"); }; -dom.listBetween = function (sc, ec) { +/** + * @todo 'so' and 'eo' were added as a bugfix and are not given everytime. They + * however should be as the function may be wrong without them (for example, + * when asking the list between an element and its parent, as there is no path + * from the beginning of the former to the beginning of the later). + */ +dom.listBetween = function (sc, ec, so, eo) { var nodes = []; var ancestor = dom.commonAncestor(sc, ec); - dom.walkPoint({'node': sc, 'offset': 0}, {'node': ec, 'offset': 0}, function (point) { + dom.walkPoint({'node': sc, 'offset': so || 0}, {'node': ec, 'offset': eo || 0}, function (point) { if (ancestor !== point.node || ancestor === sc || ancestor === ec) { nodes.push(point.node); } @@ -1869,7 +1875,7 @@ $.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTa } // fix by odoo because if you select a style in a li with no p tag all the ul is wrapped by the style tag - var nodes = dom.listBetween(r.sc, r.ec); + var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo); for (var i=0; i<nodes.length; i++) { if (dom.isBR(nodes[i]) || (dom.isText(nodes[i]) && dom.isVisibleText(nodes[i])) || dom.isB(nodes[i]) || dom.isU(nodes[i]) || dom.isS(nodes[i]) || dom.isI(nodes[i]) || dom.isFont(nodes[i])) { var ancestor = dom.ancestor(nodes[i], isFormatNode); @@ -1984,6 +1990,14 @@ eventHandler.modules.editor.currentStyle = function (target) { if (r) styleInfo.image = r.isOnImg(); } + // Fix when the target is a link: the text-align buttons state should + // indicate the alignment of the link in the parent, not the text inside + // the link (which is not possible to customize with summernote). Summernote fixed + // this in their newest version... by just not showing the active button + // for alignments. + if (styleInfo.anchor) { + styleInfo['text-align'] = $(styleInfo.anchor).parent().css('text-align'); + } return styleInfo; }; diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js index 80bb28a4c01..6c28c1e2332 100644 --- a/addons/web_editor/static/src/js/widgets/ace.js +++ b/addons/web_editor/static/src/js/widgets/ace.js @@ -742,7 +742,7 @@ var ViewEditor = Widget.extend({ var text = data.text || ''; if (!isSelected) { - text = Array($elem.data('level')).join('-') + ' ' + text; + text = Array($elem.data('level') + 1).join('-') + ' ' + text; } var $div = $('<div/>', { text: text, diff --git a/addons/web_editor/static/tests/web_editor_tests.js b/addons/web_editor/static/tests/web_editor_tests.js index 3f46edde64c..2ea6078c44b 100644 --- a/addons/web_editor/static/tests/web_editor_tests.js +++ b/addons/web_editor/static/tests/web_editor_tests.js @@ -136,12 +136,12 @@ QUnit.test('field html_frame widget', function (assert) { model: 'mass.mailing', data: this.data, arch: '<form string="Partners">' + - '<field name="body" widget="html_frame" options="{\'editor_url\': \'/test\'}"/>' + + '<field name="body" widget="html_frame" options="{\'editor_url\': \'/logo\'}"/>' + '</form>', res_id: 1, session: {user_context: {lang: "en_us"}}, mockRPC: function (route) { - if (_.str.startsWith(route, '/test')) { + if (_.str.startsWith(route, '/logo')) { // those tests will be executed twice, once in readonly and once in edit assert.ok(route.search('model=mass.mailing') > 0, "the route should specify the correct model"); @@ -200,7 +200,7 @@ QUnit.test('html_frame does not crash when saving in readonly', function (assert data: this.data, arch: '<form string="Partners">' + '<sheet>' + - '<field name="body" widget="html_frame" options="{\'editor_url\': \'/test\'}"/>' + + '<field name="body" widget="html_frame" options="{\'editor_url\': \'/logo\'}"/>' + '</sheet>' + '</form>', res_id: 1, @@ -208,7 +208,7 @@ QUnit.test('html_frame does not crash when saving in readonly', function (assert if (args.method) { assert.step(args.method); } - if (_.str.startsWith(route, '/test')) { + if (_.str.startsWith(route, '/logo')) { // manually call the callback to simulate that the iframe has // been loaded (note: just the content, not the editor) window.odoo[$.deparam(route).callback + '_content'].call(); @@ -237,7 +237,7 @@ QUnit.test('html_frame does not crash when saving in edit mode (editor not loade arch: '<form string="Partners">' + '<sheet>' + '<field name="display_name"/>' + - '<field name="body" widget="html_frame" options="{\'editor_url\': \'/test\'}"/>' + + '<field name="body" widget="html_frame" options="{\'editor_url\': \'/logo\'}"/>' + '</sheet>' + '</form>', res_id: 1, @@ -245,7 +245,7 @@ QUnit.test('html_frame does not crash when saving in edit mode (editor not loade if (args.method) { assert.step(args.method); } - if (_.str.startsWith(route, '/test')) { + if (_.str.startsWith(route, '/logo')) { // manually call the callback to simulate that the iframe has // been partially loaded (just the content, not the editor) window.odoo[$.deparam(route).callback + '_content'](); @@ -279,7 +279,7 @@ QUnit.test('html_frame saving in edit mode (editor and content fully loaded)', f arch: '<form string="Partners">' + '<sheet>' + '<field name="display_name"/>' + - '<field name="body" widget="html_frame" options="{\'editor_url\': \'/test\'}"/>' + + '<field name="body" widget="html_frame" options="{\'editor_url\': \'/logo\'}"/>' + '</sheet>' + '</form>', res_id: 1, @@ -290,7 +290,7 @@ QUnit.test('html_frame saving in edit mode (editor and content fully loaded)', f writeDeferred.resolve(); } } - if (_.str.startsWith(route, '/test')) { + if (_.str.startsWith(route, '/logo')) { // manually call the callback to simulate that the iframe has // been fully loaded (content + editor) var callback = $.deparam(route).callback; diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index 84813d7eca5..c87d7226893 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -124,12 +124,6 @@ class Http(models.AbstractModel): return False @classmethod - def _serve_404(cls): - req_page = request.httprequest.path - page404 = 'website.%s' % (request.website.is_publisher() and 'page_404' or '404') - return request.render(page404, {'path': req_page[1:]}) - - @classmethod def _serve_redirect(cls): req_page = request.httprequest.path domain = [ @@ -153,7 +147,7 @@ class Http(models.AbstractModel): if redirect: return request.redirect(redirect.url_to, code=redirect.type) - return cls._serve_404() + return False @classmethod def _handle_exception(cls, exception): @@ -212,11 +206,16 @@ class Http(models.AbstractModel): status_code=code, ) + view_id = code + if request.website.is_publisher() and isinstance(exception, werkzeug.exceptions.NotFound): + view_id = 'page_404' + values['path'] = request.httprequest.path[1:] + if not request.uid: cls._auth_method_public() try: - html = request.env['ir.ui.view'].render_template('website.%s' % code, values) + html = request.env['ir.ui.view'].render_template('website.%s' % view_id, values) except Exception: html = request.env['ir.ui.view'].render_template('website.http_error', values) return werkzeug.wrappers.Response(html, status=code, content_type='text/html;charset=utf-8') diff --git a/addons/website_blog/models/website_blog.py b/addons/website_blog/models/website_blog.py index 64928682533..87e2ccd5999 100644 --- a/addons/website_blog/models/website_blog.py +++ b/addons/website_blog/models/website_blog.py @@ -209,7 +209,7 @@ class BlogPost(models.Model): def write(self, vals): result = True for post in self: - copy_vals = vals + copy_vals = dict(vals) if 'website_published' in vals and 'published_date' not in vals and (post.published_date or '') <= fields.Datetime.now(): copy_vals['published_date'] = vals['website_published'] and fields.Datetime.now() or False result &= super(BlogPost, self).write(copy_vals) diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 6e9781da5f3..f0393cfb834 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -417,8 +417,8 @@ <div id="o-carousel-product" class="carousel slide" data-ride="carousel" data-interval="0"> <div class="carousel-outer"> <div class="carousel-inner"> - <div class="item active" itemprop="image" t-field="product.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update']}"/> - <div t-if="variant_img" class="item" itemprop="image" t-field="product.product_variant_id.image" t-options="{'widget': 'image', 'class': 'product_detail_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update'] + (product.product_variant_id['__last_update'] or '')}"/> + <div t-if="variant_img" class="item active" itemprop="image" t-field="product.product_variant_id.image" t-options="{'widget': 'image', 'class': 'product_detail_img js_variant_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update'] + (product.product_variant_id['__last_update'] or '')}"/> + <div t-attf-class="item#{'' if variant_img else ' active'}" itemprop="image" t-field="product.image" t-options="{'widget': 'image', 'class': 'product_detail_img', 'alt-field': 'name', 'zoom': 'image', 'unique': product['__last_update']}"/> <t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg"> <div class="item" t-field="pimg.image" t-options='{"widget": "image", "class": "product_detail_img", "alt-field": "name", "zoom": "image" }'/> </t> @@ -437,11 +437,11 @@ </div> <ol class="carousel-indicators" t-if="len(image_ids) or variant_img"> - <li data-target="#o-carousel-product" data-slide-to="0" class="active"> - <img class="img img-responsive js_variant_img_small" t-attf-src="/website/image/product.template/{{product.id}}/image/90x90" t-att-alt="product.name"/> + <li t-if="variant_img" data-target="#o-carousel-product" data-slide-to="0" class="active"> + <img class="img img-responsive js_variant_img_small" t-attf-src="/website/image/product.product/{{product.product_variant_id.id}}/image/90x90" t-att-alt="product.name"/> </li> - <li t-if="variant_img" data-target="#o-carousel-product" data-slide-to="1" class=""> - <img class="img img-responsive" t-attf-src="/website/image/product.product/{{product.product_variant_id.id}}/image/90x90" t-att-alt="product.name"/> + <li data-target="#o-carousel-product" t-att-data-slide-to="1 if variant_img else 0" t-att-class="'' if variant_img else 'active'"> + <img class="img img-responsive" t-attf-src="/website/image/product.template/{{product.id}}/image/90x90" t-att-alt="product.name"/> </li> <t t-if="len(image_ids)" t-foreach="image_ids" t-as="pimg"> <li data-target="#o-carousel-product" t-att-data-slide-to="pimg_index + (variant_img and 2 or 1)"> @@ -518,7 +518,7 @@ <div t-field="alt_product.image_small" t-options="{'widget': 'image', 'class': 'img-rounded shadow o_alternative_product' }" /> <h5> <a t-attf-href="/shop/product/#{ slug(alt_product) }" style="display: block"> - <span t-att-title="alt_product.name" t-field="alt_product.name" style="display: block;" /> + <span t-att-title="alt_product.name" t-field="alt_product.name" class="o_text_overflow" style="display: block;" /> </a> </h5> </div> diff --git a/doc/cla/corporate/vauxoo.md b/doc/cla/corporate/vauxoo.md index f250546a6ef..0d8c8b623e9 100644 --- a/doc/cla/corporate/vauxoo.md +++ b/doc/cla/corporate/vauxoo.md @@ -42,3 +42,4 @@ Edgar Rivero edgar@vauxoo.com https://github.com/egrivero Alexander Olivares alexander@vauxoo.com https://github.com/alxolivares Jose Manuel Robles josemanuel@vauxoo.com https://github.com/keylor2906 Erick Birbe erick@vauxoo.com https://github.com/ebirbe +Tomas Alvarez tomas@vauxoo.com https://github.com/tomeyro diff --git a/doc/cla/individual/kaberer.md b/doc/cla/individual/kaberer.md new file mode 100644 index 00000000000..e17852fe338 --- /dev/null +++ b/doc/cla/individual/kaberer.md @@ -0,0 +1,11 @@ +Switzerland, 2018-01-30 + +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, + +Kumar Aberer office@aberer-solutions.ch https://github.com/kaberer diff --git a/doc/cla/individual/moutio.md b/doc/cla/individual/moutio.md new file mode 100644 index 00000000000..4a1b5b4b955 --- /dev/null +++ b/doc/cla/individual/moutio.md @@ -0,0 +1,9 @@ +Spain, 2018-01-29 + +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, + +Nacho Muñoz nacmuro@gmail.com https://github.com/moutio diff --git a/doc/cla/individual/phucngta.md b/doc/cla/individual/phucngta.md new file mode 100644 index 00000000000..c53fe2b7136 --- /dev/null +++ b/doc/cla/individual/phucngta.md @@ -0,0 +1,11 @@ +Vietnam, 2018.01.17 + +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, + +Nguyen Phuc <phucngta@gmail.com> https://github.com/phucngta
\ No newline at end of file diff --git a/doc/reference/module.rst b/doc/reference/module.rst index aecb8de8a8f..9ec0ef39c93 100644 --- a/doc/reference/module.rst +++ b/doc/reference/module.rst @@ -1,5 +1,3 @@ -:banner: banners/module.jpg - ======= Modules ======= @@ -84,6 +82,49 @@ Available manifest fields are: to ``auto_install``. When both ``sale`` and ``crm`` are installed, it automatically adds CRM campaigns tracking to sale orders without either ``sale`` or ``crm`` being aware of one another +``external_dependencies`` (``dict(key=list(str))``) + A dictionary containing python and/or binary dependencies. + + For python dependencies, the ``python`` key must be defined for this + dictionary and a list of python modules to be imported should be assigned + to it. + + For binary dependencies, the ``bin`` key must be defined for this + dictionary and a list of binary executable names should be assigned to it. + + The module won't be installed if either the python module is not installed + in the host machine or the binary executable is not found within the + host machine's PATH environment variable. +``application`` (``bool``, default: ``False``) + Whether the module should be considered as a fully-fledged application + (``True``) or is just a technical module (``False``) that provides some + extra functionality to an existing application module. +``css`` (``list(str)``) + Specify css files with custom rules to be imported, these files should be + located in ``static/src/css`` inside the module. +``images`` (``list(str)``) + Specify image files to be used by the module. +``installable`` (``bool`` default: ``False``) + Whether a user should be able to install the module from the Web UI or not. +``maintainer`` (``str``) + Person or entity in charge of the maintenance of this module, by default + it is assumed that the author is the maintainer. +``{pre_init, post_init, uninstall}_hook`` (``str``) + Hooks for module installation/uninstallation, their value should be a + string representing the name of a function defined inside the module's + ``__init__.py``. + + ``pre_init_hook`` takes a cursor as its only argument, this function is + executed prior to the module's installation. + + ``post_init_hook`` takes a cursor and a registry as its arguments, this + function is executed right after the module's installation. + + ``uninstall_hook`` takes a cursor and a registry as its arguments, this + function is executed after the module's uninstallation. + + These hooks should only be used when setup/cleanup required for this module + is either extremely difficult or impossible through the api. .. _semantic versioning: http://semver.org .. _existing categories: diff --git a/doc/setup/deploy.rst b/doc/setup/deploy.rst index 0d7303a795a..b0a5cf3224e 100644 --- a/doc/setup/deploy.rst +++ b/doc/setup/deploy.rst @@ -44,7 +44,7 @@ correctly. Configuration samples --------------------- -* show only databases with names beginning with 'mycompany' +* Show only databases with names beginning with 'mycompany' in ``/etc/odoo.conf`` set: @@ -87,18 +87,18 @@ machine, and is the default when no host is provided, but if you want Odoo and PostgreSQL to execute on different machines [#different-machines]_ it will need to `listen to network interfaces`_ [#remote-socket]_, either: -* only accept loopback connections and `use an SSH tunnel`_ between the +* Only accept loopback connections and `use an SSH tunnel`_ between the machine on which Odoo runs and the one on which PostgreSQL runs, then configure Odoo to connect to its end of the tunnel -* accept connections to the machine on which Odoo is installed, possibly +* Accept connections to the machine on which Odoo is installed, possibly over ssl (see `PostgreSQL connection settings`_ for details), then configure Odoo to connect over the network Configuration sample -------------------- -* allow tcp connection on localhost -* allow tcp connection from 192.168.1.x network +* Allow tcp connection on localhost +* Allow tcp connection from 192.168.1.x network in ``/etc/postgresql/9.5/main/pg_hba.conf`` set: @@ -134,11 +134,11 @@ create a new user (``odoo``) and set it as the database user. simply checked before performing database alterations. It should be set to a randomly generated value to ensure third parties can not use this interface. -* all database operations use the :ref:`database options +* All database operations use the :ref:`database options <reference/cmdline/server/database>`, including the database management screen. For the database management screen to work requires that the PostgreSQL user have ``createdb`` right. -* users can always drop databases they own. For the database management screen +* Users can always drop databases they own. For the database management screen to be completely non-functional, the PostgreSQL user needs to be created with ``no-createdb`` and the database must be owned by a different PostgreSQL user. @@ -248,10 +248,10 @@ authentication information in cleartext. This means a secure deployment of Odoo must use HTTPS\ [#switching]_. SSL termination can be implemented via just about any SSL termination proxy, but requires the following setup: -* enable Odoo's :option:`proxy mode <odoo-bin --proxy-mode>`. This should only be enabled when Odoo is behind a reverse proxy -* set up the SSL termination proxy (`Nginx termination example`_) -* set up the proxying itself (`Nginx proxying example`_) -* your SSL termination proxy should also automatically redirect non-secure +* Enable Odoo's :option:`proxy mode <odoo-bin --proxy-mode>`. This should only be enabled when Odoo is behind a reverse proxy +* Set up the SSL termination proxy (`Nginx termination example`_) +* Set up the proxying itself (`Nginx proxying example`_) +* Your SSL termination proxy should also automatically redirect non-secure connections to the secure port .. warning:: @@ -263,8 +263,8 @@ just about any SSL termination proxy, but requires the following setup: Configuration sample -------------------- -* redirect http requests to https -* proxy requests to odoo +* Redirect http requests to https +* Proxy requests to odoo in ``/etc/odoo.conf`` set: @@ -349,10 +349,10 @@ Cron Workers To run cron jobs for an Odoo deployment as a WSGI application requires -* a classical Odoo (run via ``odoo-bin``) -* connected to the database in which cron jobs have to be run (via +* A classical Odoo (run via ``odoo-bin``) +* Connected to the database in which cron jobs have to be run (via :option:`odoo-bin -d`) -* which should not be exposed to the network. To ensure cron runners are not +* Which should not be exposed to the network. To ensure cron runners are not network-accessible, it is possible to disable the built-in HTTP server entirely with :option:`odoo-bin --no-http` or setting ``http_enable = False`` in the configuration file @@ -372,11 +372,11 @@ notifications. The solutions to support livechat/motifications in a WSGI application are: -* deploy a threaded version of Odoo (instread of a process-based preforking +* Deploy a threaded version of Odoo (instread of a process-based preforking one) and redirect only requests to URLs starting with ``/longpolling/`` to that Odoo, this is the simplest and the longpolling URL can double up as the cron instance. -* deploy an evented Odoo via ``odoo-gevent`` and proxy requests starting +* Deploy an evented Odoo via ``odoo-gevent`` and proxy requests starting with ``/longpolling/`` to :option:`the longpolling port <odoo-bin --longpolling-port>`. diff --git a/doc/webservices/localization.rst b/doc/webservices/localization.rst index 17559b2a01a..39e1c98181c 100644 --- a/doc/webservices/localization.rst +++ b/doc/webservices/localization.rst @@ -1,12 +1,13 @@ :banner: banners/localization.jpg -========================== -Making my Own Localization -========================== +======================= +Creating a Localization +======================= .. warning:: - This tutorial requires knowledges about how to build a module in Odoo. + This tutorial requires knowledges about how to build a module in Odoo (see + :doc:`../../howtos/backend`). Building a localization module ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index d857c14593b..646615cb593 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -8,6 +8,7 @@ from odoo.tools.safe_eval import safe_eval, test_python_expr from odoo.tools import pycompat from odoo.http import request +import base64 from collections import defaultdict import datetime import dateutil @@ -76,6 +77,8 @@ class IrActions(models.Model): 'datetime': datetime, 'dateutil': dateutil, 'timezone': timezone, + 'b64encode': base64.b64encode, + 'b64decode': base64.b64decode, } @api.model @@ -253,7 +256,7 @@ class IrActionsActWindowView(models.Model): _name = 'ir.actions.act_window.view' _table = 'ir_act_window_view' _rec_name = 'view_id' - _order = 'sequence' + _order = 'sequence,id' sequence = fields.Integer() view_id = fields.Many2one('ir.ui.view', string='View') diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index 2915d9a0086..53a3438b21a 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -15,7 +15,6 @@ from odoo.exceptions import AccessDenied, AccessError, UserError, ValidationErro from odoo.osv import expression from odoo.service.db import check_super from odoo.tools import partition, pycompat -from odoo.http import root _logger = logging.getLogger(__name__) @@ -373,8 +372,6 @@ class Users(models.Model): if any(key.startswith('context_') or key in ('lang', 'tz') for key in values): self.context_get.clear_cache(self) if any(key in values for key in ['active'] + USER_PRIVATE_FIELDS): - # force deletion of all sessions for these users - root.session_store.delete_sessions_for_uids(self.ids) db = self._cr.dbname for id in self.ids: self.__uid_cache[db].pop(id, None) @@ -388,10 +385,7 @@ class Users(models.Model): db = self._cr.dbname for id in self.ids: self.__uid_cache[db].pop(id, None) - res = super(Users, self).unlink() - # force deletion of all sessions for these users - root.session_store.delete_sessions_for_uids(self.ids) - return res + return super(Users, self).unlink() @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): diff --git a/odoo/addons/base/tests/test_float.py b/odoo/addons/base/tests/test_float.py index 1c2fcc3e91f..6e42f8adf1d 100644 --- a/odoo/addons/base/tests/test_float.py +++ b/odoo/addons/base/tests/test_float.py @@ -92,6 +92,18 @@ class TestFloatPrecision(TransactionCase): try_round(1.8, '2', 0, method='UP') try_round(-1.8, '-2', 0, method='UP') + # Try some rounding value with rounding method DOWN instead of HALF-UP + # We use 2.425 because when normalizing 2.425 with precision_digits=3 it gives + # us 2424.9999999999995 as value, and if not handle correctly the rounding DOWN + # value will be incorrect (should be 2.425 and not 2.424) + try_round(2.425, '2.425', method='DOWN') + try_round(2.4249, '2.424', method='DOWN') + try_round(-2.425, '-2.425', method='DOWN') + try_round(-2.4249, '-2.424', method='DOWN') + try_round(-2.500, '-2.500', method='DOWN') + try_round(1.8, '1', 0, method='DOWN') + try_round(-1.8, '-1', 0, method='DOWN') + # Extended float range test, inspired by Cloves Almeida's test on bug #882036. fractions = [.0, .015, .01499, .675, .67499, .4555, .4555, .45555] expecteds = ['.00', '.02', '.01', '.68', '.67', '.46', '.456', '.4556'] @@ -135,8 +147,8 @@ class TestFloatPrecision(TransactionCase): try_compare(-657.4444, -657.445, 1) # Rounding to unusual rounding units (e.g. coin values) - def try_round(amount, expected, precision_rounding=None): - value = float_round(amount, precision_rounding=precision_rounding) + def try_round(amount, expected, precision_rounding=None, method='HALF-UP'): + value = float_round(amount, precision_rounding=precision_rounding, rounding_method=method) result = float_repr(value, precision_digits=2) self.assertEqual(result, expected, 'Rounding error: got %s, expected %s' % (result, expected)) @@ -145,6 +157,8 @@ class TestFloatPrecision(TransactionCase): try_round(457.3, '455.00', precision_rounding=5) try_round(457.5, '460.00', precision_rounding=5) try_round(457.1, '456.00', precision_rounding=3) + try_round(2.5, '2.50', precision_rounding=0.05, method='DOWN') + try_round(-2.5, '-2.50', precision_rounding=0.05, method='DOWN') def test_rounding_04(self): """ check that proper rounding is performed for float persistence """ diff --git a/odoo/http.py b/odoo/http.py index 63d7ece05d5..4cfe76a76a7 100644 --- a/odoo/http.py +++ b/odoo/http.py @@ -635,8 +635,9 @@ class JsonRequest(WebRequest): body = json.dumps(response, default=ustr) return Response( - body, headers=[('Content-Type', mime), - ('Content-Length', len(body))]) + body, status=error and error.pop('http_status', 200) or 200, + headers=[('Content-Type', mime), ('Content-Length', len(body))] + ) def _handle_exception(self, exception): """Called within an except block to allow converting exceptions @@ -645,13 +646,18 @@ class JsonRequest(WebRequest): try: return super(JsonRequest, self)._handle_exception(exception) except Exception: - if not isinstance(exception, (odoo.exceptions.Warning, SessionExpiredException, odoo.exceptions.except_orm)): + if not isinstance(exception, (odoo.exceptions.Warning, SessionExpiredException, + odoo.exceptions.except_orm, werkzeug.exceptions.NotFound)): _logger.exception("Exception during JSON request handling.") error = { 'code': 200, 'message': "Odoo Server Error", 'data': serialize_exception(exception) } + if isinstance(exception, werkzeug.exceptions.NotFound): + error['http_status'] = 404 + error['code'] = 404 + error['message'] = "404: Not Found" if isinstance(exception, AuthenticationError): error['code'] = 100 error['message'] = "Odoo Session Invalid" @@ -1029,6 +1035,7 @@ class OpenERPSession(werkzeug.contrib.sessions.Session): self.db = db self.uid = uid self.login = login + self.password = password request.uid = uid request.disable_db = False @@ -1043,6 +1050,7 @@ class OpenERPSession(werkzeug.contrib.sessions.Session): """ if not self.db or not self.uid: raise SessionExpiredException("Session expired") + security.check(self.db, self.uid, self.password) def logout(self, keep_db=False): for k in list(self): @@ -1055,6 +1063,7 @@ class OpenERPSession(werkzeug.contrib.sessions.Session): self.setdefault("db", None) self.setdefault("uid", None) self.setdefault("login", None) + self.setdefault("password", None) self.setdefault("context", {}) def get_context(self): @@ -1268,17 +1277,6 @@ class DisableCacheMiddleware(object): start_response(status, new_headers) return self.app(environ, start_wrapped) -class OdooSessionStore(werkzeug.contrib.sessions.FilesystemSessionStore): - def delete_sessions_for_uids(self, uids): - # pretty expensive on large session stores, especially non-local! - uids = set(uids) - _logger.info('Deleting all HTTP sessions for UIDs %s', uids) - for sid in self.list(): - s = self.get(sid) - if s.uid and s.uid in uids: - _logger.debug('Deleting session %s', sid) - self.delete(s) - class Root(object): """Root WSGI application for the OpenERP Web Client. """ @@ -1290,7 +1288,7 @@ class Root(object): # Setup http sessions path = odoo.tools.config.session_dir _logger.debug('HTTP sessions stored in: %s', path) - return OdooSessionStore(path, session_class=OpenERPSession) + return werkzeug.contrib.sessions.FilesystemSessionStore(path, session_class=OpenERPSession) @lazy_property def nodb_routing_map(self): diff --git a/odoo/tools/float_utils.py b/odoo/tools/float_utils.py index 33044d21f93..041a87cabbe 100644 --- a/odoo/tools/float_utils.py +++ b/odoo/tools/float_utils.py @@ -61,9 +61,6 @@ def float_round(value, precision_digits=None, precision_rounding=None, rounding_ # In order to easily support rounding to arbitrary 'steps' (e.g. coin values), # we normalize the value before rounding it as an integer, and de-normalize # after rounding: e.g. float_round(1.3, precision_rounding=.5) == 1.5 - - # TIE-BREAKING: HALF-UP (for normal rounding) - # We want to apply HALF-UP tie-breaking rules, i.e. 0.5 rounds away from 0. # Due to IEE754 float/double representation limits, the approximation of the # real value may be slightly below the tie limit, resulting in an error of # 1 unit in the last place (ulp) after rounding. @@ -74,25 +71,31 @@ def float_round(value, precision_digits=None, precision_rounding=None, rounding_ # Credit: discussion with OpenERP community members on bug 882036 normalized_value = value / rounding_factor # normalize + sign = math.copysign(1.0, normalized_value) epsilon_magnitude = math.log(abs(normalized_value), 2) epsilon = 2**(epsilon_magnitude-53) - if rounding_method == 'HALF-UP': - normalized_value += math.copysign(epsilon, normalized_value) - rounded_value = round(normalized_value) # round to integer - # TIE-BREAKING: UP (for ceiling[resp. flooring] operations) - # When rounding the value up[resp. down], we instead subtract the epsilon value + # TIE-BREAKING: UP/DOWN (for ceiling[resp. flooring] operations) + # When rounding the value up[resp. down], we instead subtract[resp. add] the epsilon value # as the the approximation of the real value may be slightly *above* the # tie limit, this would result in incorrectly rounding up[resp. down] to the next number # The math.ceil[resp. math.floor] operation is applied on the absolute value in order to # round "away from zero" and not "towards infinity", then the sign is # restored. - else: - func = math.floor if rounding_method == 'DOWN' else math.ceil - sign = math.copysign(1.0, normalized_value) + if rounding_method == 'UP': normalized_value -= sign*epsilon - rounded_value = func(abs(normalized_value)) * sign + rounded_value = math.ceil(abs(normalized_value)) * sign + + elif rounding_method == 'DOWN': + normalized_value += sign*epsilon + rounded_value = math.floor(abs(normalized_value)) * sign + + # TIE-BREAKING: HALF-UP (for normal rounding) + # We want to apply HALF-UP tie-breaking rules, i.e. 0.5 rounds away from 0. + else: + normalized_value += math.copysign(epsilon, normalized_value) + rounded_value = round(normalized_value) # round to integer result = rounded_value * rounding_factor # de-normalize return result |
