diff options
321 files changed, 4025 insertions, 2210 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 21bc3ff9a43..16ee2744892 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -5,7 +5,7 @@ import math from odoo.osv import expression from odoo.tools.float_utils import float_round as round -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, pycompat +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.exceptions import UserError, ValidationError from odoo import api, fields, models, _ diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index d565b07e54c..3ba6dcb1399 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -305,9 +305,9 @@ class AccountBankStatement(models.Model): st_lines_left = self.env['account.bank.statement.line'].browse([line.get('id') for line in self.env.cr.dictfetchall()]) #try to assign partner to bank_statement_line - stl_to_assign_partner = [stl.id for stl in st_lines_left if not stl.partner_id] - refs = list(set([st.name for st in st_lines_left if not stl.partner_id])) - if st_lines_left and stl_to_assign_partner and refs\ + stl_to_assign = st_lines_left.filtered(lambda stl: not stl.partner_id) + refs = {stl_to_assign.mapped('name')} + if stl_to_assign and refs\ and st_lines_left[0].journal_id.default_credit_account_id\ and st_lines_left[0].journal_id.default_debit_account_id: @@ -327,7 +327,7 @@ class AccountBankStatement(models.Model): params = (self.env.user.company_id.id, (st_lines_left[0].journal_id.default_credit_account_id.id, st_lines_left[0].journal_id.default_debit_account_id.id), tuple(refs)) if statements: sql_query += 'AND stl.id IN %s' - params += (tuple(stl_to_assign_partner),) + params += (tuple(stl_to_assign.ids),) self.env.cr.execute(sql_query, params) results = self.env.cr.dictfetchall() st_line = self.env['account.bank.statement.line'] @@ -499,7 +499,6 @@ class AccountBankStatementLine(models.Model): 'st_line': st_line.get_statement_line_for_reconciliation_widget(), 'reconciliation_proposition': rp }) - return ret def get_statement_line_for_reconciliation_widget(self): diff --git a/addons/account/models/account_config_settings.py b/addons/account/models/account_config_settings.py index c70bb30bf4e..4eb6fc1c559 100644 --- a/addons/account/models/account_config_settings.py +++ b/addons/account/models/account_config_settings.py @@ -82,10 +82,8 @@ class AccountConfigSettings(models.TransientModel): self.env.ref('base.group_user').write({'implied_ids': [(4, self.env.ref('product.group_sale_pricelist').id)]}) """ Set the product taxes if they have changed """ ir_values_obj = self.env['ir.values'] - if self.default_sale_tax_id: - ir_values_obj.sudo().set_default('product.template', "taxes_id", [self.default_sale_tax_id.id], for_all_users=True, company_id=self.company_id.id) - if self.default_purchase_tax_id: - ir_values_obj.sudo().set_default('product.template', "supplier_taxes_id", [self.default_purchase_tax_id.id], for_all_users=True, company_id=self.company_id.id) + ir_values_obj.sudo().set_default('product.template', "taxes_id", [self.default_sale_tax_id.id] if self.default_sale_tax_id else False, for_all_users=True, company_id=self.company_id.id) + ir_values_obj.sudo().set_default('product.template', "supplier_taxes_id", [self.default_purchase_tax_id.id] if self.default_purchase_tax_id else False, for_all_users=True, company_id=self.company_id.id) """ install a chart of accounts for the given company (if required) """ if self.chart_template_id and self.chart_template_id != self.company_id.chart_template_id: wizard = self.env['wizard.multi.charts.accounts'].create({ diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 3d7924bab47..cc96c01bbd5 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -341,9 +341,9 @@ class AccountInvoice(models.Model): related='partner_id.commercial_partner_id', store=True, readonly=True, help="The commercial entity that will be used on Journal Entries for this invoice") - outstanding_credits_debits_widget = fields.Text(compute='_get_outstanding_info_JSON') - payments_widget = fields.Text(compute='_get_payment_info_JSON') - has_outstanding = fields.Boolean(compute='_get_outstanding_info_JSON') + outstanding_credits_debits_widget = fields.Text(compute='_get_outstanding_info_JSON', groups="account.group_account_invoice") + payments_widget = fields.Text(compute='_get_payment_info_JSON', groups="account.group_account_invoice") + has_outstanding = fields.Boolean(compute='_get_outstanding_info_JSON', groups="account.group_account_invoice") #fields use to set the sequence, on the first invoice of the journal sequence_number_next = fields.Char(string='Next Number', compute="_get_sequence_prefix", inverse="_set_sequence_next") @@ -411,7 +411,7 @@ class AccountInvoice(models.Model): '_onchange_partner_id': ['account_id', 'payment_term_id', 'fiscal_position_id', 'partner_bank_id'], '_onchange_journal_id': ['currency_id'], } - for onchange_method, changed_fields in pycompat.items(onchanges): + for onchange_method, changed_fields in onchanges.items(): if any(f not in vals for f in changed_fields): invoice = self.new(vals) getattr(invoice, onchange_method)() @@ -532,7 +532,7 @@ class AccountInvoice(models.Model): tax_grouped = invoice.get_taxes_values() # Create new tax lines - for tax in pycompat.values(tax_grouped): + for tax in tax_grouped.values(): account_invoice_tax.create(tax) # dummy write on self to trigger recomputations @@ -551,7 +551,7 @@ class AccountInvoice(models.Model): def _onchange_invoice_line_ids(self): taxes_grouped = self.get_taxes_values() tax_lines = self.tax_line_ids.filtered('manual') - for tax in pycompat.values(taxes_grouped): + for tax in taxes_grouped.values(): tax_lines += tax_lines.new(tax) self.tax_line_ids = tax_lines return @@ -799,7 +799,7 @@ class AccountInvoice(models.Model): self.ensure_one() credit_aml = self.env['account.move.line'].browse(credit_aml_id) if not credit_aml.currency_id and self.currency_id != self.company_id.currency_id: - credit_aml.with_context(allow_amount_currency=True).write({ + credit_aml.with_context(allow_amount_currency=True, check_move_validity=False).write({ 'amount_currency': self.company_id.currency_id.with_context(date=credit_aml.date).compute(credit_aml.balance, self.currency_id), 'currency_id': self.currency_id.id}) if credit_aml.payment_id: @@ -944,7 +944,7 @@ class AccountInvoice(models.Model): else: line2[tmp] = l line = [] - for key, val in pycompat.items(line2): + for key, val in line2.items(): line.append((0, 0, val)) return line @@ -1137,7 +1137,7 @@ class AccountInvoice(models.Model): result = [] for line in lines: values = {} - for name, field in pycompat.items(line._fields): + for name, field in line._fields.items(): if name in MAGIC_COLUMNS: continue elif field.type == 'many2one': @@ -1307,7 +1307,7 @@ class AccountInvoice(models.Model): for line in self.tax_line_ids: res.setdefault(line.tax_id.tax_group_id, 0.0) res[line.tax_id.tax_group_id] += line.amount - res = sorted(pycompat.items(res), key=lambda l: l[0].sequence) + res = sorted(res.items(), key=lambda l: l[0].sequence) res = [(l[0].name, l[1]) for l in res] return res @@ -1408,7 +1408,7 @@ class AccountInvoiceLine(models.Model): node.set('domain', "[('purchase_ok', '=', True)]") else: node.set('domain', "[('sale_ok', '=', True)]") - res['arch'] = etree.tostring(doc) + res['arch'] = etree.tostring(doc, encoding='unicode') return res @api.v8 diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 311cc20f93b..db70e42b8f9 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -987,26 +987,10 @@ class AccountMoveLine(models.Model): vals['amount_currency'] = sign * abs(sum([r.amount_residual_currency for r in self])) # Writeoff line in the account of self - first_line_dict = vals.copy() - first_line_dict['account_id'] = self[0].account_id.id - if 'analytic_account_id' in first_line_dict: - del first_line_dict['analytic_account_id'] - if 'tax_ids' in first_line_dict: - tax_ids = [] - #vals['tax_ids'] is a list of commands [[4, tax_id, None], ...] - for tax_id in vals['tax_ids']: - tax_ids.append(tax_id[1]) - amount = first_line_dict['credit'] - first_line_dict['debit'] - amount_tax = self.env['account.tax'].browse(tax_ids).compute_all(amount)['total_included'] - first_line_dict['credit'] = amount_tax > 0 and amount_tax or 0.0 - first_line_dict['debit'] = amount_tax < 0 and abs(amount_tax) or 0.0 - del first_line_dict['tax_ids'] + first_line_dict = self._prepare_writeoff_first_line_values(vals) # Writeoff line in specified writeoff account - second_line_dict = vals.copy() - second_line_dict['debit'], second_line_dict['credit'] = second_line_dict['credit'], second_line_dict['debit'] - if 'amount_currency' in vals: - second_line_dict['amount_currency'] = -second_line_dict['amount_currency'] + second_line_dict = self._prepare_writeoff_second_line_values(vals) # Create the move writeoff_move = self.env['account.move'].with_context(apply_taxes=True).create({ @@ -1020,6 +1004,32 @@ class AccountMoveLine(models.Model): # Return the writeoff move.line which is to be reconciled return writeoff_move.line_ids.filtered(lambda r: r.account_id == self[0].account_id) + @api.multi + def _prepare_writeoff_first_line_values(self, values): + line_values = values.copy() + line_values['account_id'] = self[0].account_id.id + if 'analytic_account_id' in line_values: + del line_values['analytic_account_id'] + if 'tax_ids' in line_values: + tax_ids = [] + # vals['tax_ids'] is a list of commands [[4, tax_id, None], ...] + for tax_id in values['tax_ids']: + tax_ids.append(tax_id[1]) + amount = line_values['credit'] - line_values['debit'] + amount_tax = self.env['account.tax'].browse(tax_ids).compute_all(amount)['total_included'] + line_values['credit'] = amount_tax > 0 and amount_tax or 0.0 + line_values['debit'] = amount_tax < 0 and abs(amount_tax) or 0.0 + del line_values['tax_ids'] + return line_values + + @api.multi + def _prepare_writeoff_second_line_values(self, values): + line_values = values.copy() + line_values['debit'], line_values['credit'] = line_values['credit'], line_values['debit'] + if 'amount_currency' in values: + line_values['amount_currency'] = -line_values['amount_currency'] + return line_values + def force_full_reconcile(self): """ After running the manual reconciliation wizard and making full reconciliation, we need to run this method to create potentially exchange rate entries that will balance the remaining amount_residual_currency (possibly several aml in @@ -1031,7 +1041,7 @@ class AccountMoveLine(models.Model): partial_rec_set = self.env['account.partial.reconcile'] maxdate = '0000-00-00' - #gather the max date for the move creation, and all aml that are unbalanced + # gather the max date for the move creation, and all aml that are unbalanced for aml in self: maxdate = max(aml.date, maxdate) if aml.amount_residual_currency: @@ -1771,6 +1781,6 @@ class AccountFullReconcile(models.Model): # The move date should be the maximum date between payment and invoice # (in case of payment in advance). However, we should make sure the # move date is not recorded after the end of year closing. - if move_date > company.fiscalyear_lock_date: + if move_date > (company.fiscalyear_lock_date or '0000-00-00'): res['date'] = move_date return res diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 8c59f15fad8..19989561f86 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -7,8 +7,6 @@ from odoo.exceptions import UserError import logging -from odoo.tools import pycompat - _logger = logging.getLogger(__name__) def migrate_set_tags_and_taxes_updatable(cr, registry, module): @@ -327,7 +325,7 @@ class AccountChartTemplate(models.Model): # writing account values after creation of accounts company.transfer_account_id = account_template_ref[transfer_account_id.id] - for key, value in pycompat.items(generated_tax_res['account_dict']): + for key, value in generated_tax_res['account_dict'].items(): if value['refund_account_id'] or value['account_id'] or value['cash_basis_account']: AccountTaxObj.browse(key).write({ 'refund_account_id': account_ref.get(value['refund_account_id'], False), diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py index f2f0f85f7c0..de32aaa5f3d 100644 --- a/addons/account/models/partner.py +++ b/addons/account/models/partner.py @@ -5,7 +5,7 @@ from operator import itemgetter import time from odoo import api, fields, models, _ -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, pycompat +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.exceptions import ValidationError from odoo.addons.base.res.res_partner import WARNING_MESSAGE, WARNING_HELP @@ -73,7 +73,7 @@ class AccountFiscalPosition(models.Model): ref_dict = {} for line in self.account_ids: ref_dict[line.account_src_id] = line.account_dest_id - for key, acc in list(pycompat.items(accounts)): + for key, acc in accounts.items(): if acc in ref_dict: accounts[key] = ref_dict[acc] return accounts @@ -293,7 +293,7 @@ class ResPartner(models.Model): """ % where_clause self.env.cr.execute(query, where_clause_params) price_totals = self.env.cr.dictfetchall() - for partner, child_ids in pycompat.items(all_partners_and_children): + for partner, child_ids in all_partners_and_children.items(): partner.total_invoiced = sum(price['total'] for price in price_totals if price['partner_id'] in child_ids) @api.multi diff --git a/addons/account/report/account_report_financial.py b/addons/account/report/account_report_financial.py index 01e2080307f..65560736fff 100644 --- a/addons/account/report/account_report_financial.py +++ b/addons/account/report/account_report_financial.py @@ -3,7 +3,6 @@ import time from odoo import api, models, _ from odoo.exceptions import UserError -from odoo.tools import pycompat class ReportFinancial(models.AbstractModel): @@ -28,7 +27,7 @@ class ReportFinancial(models.AbstractModel): if where_clause.strip(): wheres.append(where_clause.strip()) filters = " AND ".join(wheres) - request = "SELECT account_id as id, " + ', '.join(pycompat.values(mapping)) + \ + request = "SELECT account_id as id, " + ', '.join(mapping.values()) + \ " FROM " + tables + \ " WHERE account_id IN %s " \ + filters + \ @@ -55,26 +54,26 @@ class ReportFinancial(models.AbstractModel): if report.type == 'accounts': # it's the sum of the linked accounts res[report.id]['account'] = self._compute_account_balance(report.account_ids) - for value in pycompat.values(res[report.id]['account']): + for value in res[report.id]['account'].values(): for field in fields: res[report.id][field] += value.get(field) elif report.type == 'account_type': # it's the sum the leaf accounts with such an account type accounts = self.env['account.account'].search([('user_type_id', 'in', report.account_type_ids.ids)]) res[report.id]['account'] = self._compute_account_balance(accounts) - for value in pycompat.values(res[report.id]['account']): + for value in res[report.id]['account'].values(): for field in fields: res[report.id][field] += value.get(field) elif report.type == 'account_report' and report.account_report_id: # it's the amount of the linked report res2 = self._compute_report_balance(report.account_report_id) - for key, value in pycompat.items(res2): + for key, value in res2.items(): for field in fields: res[report.id][field] += value[field] elif report.type == 'sum': # it's the sum of the children of this account.report res2 = self._compute_report_balance(report.children_ids) - for key, value in pycompat.items(res2): + for key, value in res2.items(): for field in fields: res[report.id][field] += value[field] return res @@ -86,11 +85,11 @@ class ReportFinancial(models.AbstractModel): res = self.with_context(data.get('used_context'))._compute_report_balance(child_reports) if data['enable_filter']: comparison_res = self.with_context(data.get('comparison_context'))._compute_report_balance(child_reports) - for report_id, value in pycompat.items(comparison_res): + for report_id, value in comparison_res.items(): res[report_id]['comp_bal'] = value['balance'] report_acc = res[report_id].get('account') if report_acc: - for account_id, val in pycompat.items(comparison_res[report_id].get('account')): + for account_id, val in comparison_res[report_id].get('account').items(): report_acc[account_id]['comp_bal'] = val['balance'] for report in child_reports: @@ -115,7 +114,7 @@ class ReportFinancial(models.AbstractModel): if res[report.id].get('account'): sub_lines = [] - for account_id, value in pycompat.items(res[report.id]['account']): + for account_id, value in res[report.id]['account'].items(): #if there are accounts to display, we add them to the lines with a level equals to their level in #the COA + 1 (to avoid having them with a too low level that would conflicts with the level of data #financial reports for Assets, liabilities...) diff --git a/addons/account/static/src/js/account_payment_field.js b/addons/account/static/src/js/account_payment_field.js index 4745f23e648..1e5171e1f44 100644 --- a/addons/account/static/src/js/account_payment_field.js +++ b/addons/account/static/src/js/account_payment_field.js @@ -46,7 +46,7 @@ var ShowPaymentLineWidget = AbstractField.extend({ k.index = v; k.amount = field_utils.format.float(k.amount, {digits: k.digits}); if (k.date){ - k.date = field_utils.format.date(field_utils.parse.date(k.date, {isUTC: true})); + k.date = field_utils.format.date(field_utils.parse.date(k.date, {}, {isUTC: true})); } }); this.$el.html(QWeb.render('ShowPaymentInfo', { diff --git a/addons/account/static/src/js/reconciliation/reconciliation_action.js b/addons/account/static/src/js/reconciliation/reconciliation_action.js index 51175be7794..8763d5acaa1 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_action.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_action.js @@ -30,6 +30,7 @@ var StatementAction = Widget.extend(ControlPanelMixin, { validate_all_balanced: '_onValidate', change_name: '_onChangeName', close_statement: '_onCloseStatement', + load_more: '_onLoadMore', }, config: { // used to instanciate the model @@ -71,6 +72,7 @@ var StatementAction = Widget.extend(ControlPanelMixin, { 'bank_statement_id': self.model.bank_statement_id, 'valuenow': self.model.valuenow, 'valuemax': self.model.valuemax, + 'defaultDisplayQty': self.model.defaultDisplayQty, 'title': self.title, }); }); @@ -89,12 +91,7 @@ var StatementAction = Widget.extend(ControlPanelMixin, { this.update_control_panel({breadcrumbs: breadcrumbs, search_view_hidden: true}, {clear: true}); this.renderer.prependTo(self.$('.o_form_sheet')); - _.each(this.model.lines, function (line, handle) { - var widget = new self.config.LineRenderer(self, self.model, line); - widget.handle = handle; - self.widgets.push(widget); - widget.appendTo(self.$('.o_reconciliation_lines')); - }); + this._renderLines(); this._openFirstLine(); }, @@ -135,6 +132,15 @@ var StatementAction = Widget.extend(ControlPanelMixin, { }, /** + * + */ + _loadMore: function(qty) { + var self = this; + return this.model.loadMore(qty).then(function () { + self._renderLines(); + }); + }, + /** * sitch to 'match' the first available line * * @private @@ -153,6 +159,24 @@ var StatementAction = Widget.extend(ControlPanelMixin, { } return handle; }, + /** + * render line widget and append to view + * + * @private + */ + _renderLines: function () { + var self = this; + var linesToDisplay = this.model.getStatementLines(); + _.each(linesToDisplay, function (line, handle) { + var widget = new self.config.LineRenderer(self, self.model, line); + widget.handle = handle; + self.widgets.push(widget); + widget.appendTo(self.$('.o_reconciliation_lines')); + }); + if (this.model.hasMoreLines() === false) { + this.renderer.hideLoadMoreButton(); + } + }, //-------------------------------------------------------------------------- // Handlers @@ -224,6 +248,14 @@ var StatementAction = Widget.extend(ControlPanelMixin, { }); }, /** + * Load more statement and render them + * + * @param {OdooEvent} event + */ + _onLoadMore: function (event) { + return this._loadMore(this.model.defaultDisplayQty); + }, + /** * call 'validate' or 'autoReconciliation' model method then destroy the * validated lines and update the action renderer with the new status bar * values and notifications then open the first available line @@ -246,7 +278,15 @@ var StatementAction = Widget.extend(ControlPanelMixin, { }); _.each(result.handles, function (handle) { self._getWidget(handle).destroy(); + var index = _.findIndex(self.widgets, function (widget) {return widget.handle===handle;}); + self.widgets.splice(index, 1); }); + // Get number of widget and if less than constant and if there are more to laod, load until constant + if (self.widgets.length < self.model.defaultDisplayQty + && self.model.valuemax - self.model.valuenow >= self.model.defaultDisplayQty) { + var toLoad = self.model.defaultDisplayQty - self.widgets.length; + self._loadMore(toLoad); + } self._openFirstLine(); }); }, diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index e12f8f21009..019e761f3d3 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -100,6 +100,8 @@ var StatementModel = BasicModel.extend({ this.lines = {}; this.valuenow = 0; this.valuemax = 0; + this.alreadyDisplayed = []; + this.defaultDisplayQty = 10; }, //-------------------------------------------------------------------------- @@ -230,10 +232,10 @@ var StatementModel = BasicModel.extend({ var line = this.getLine(handle); line.st_line.partner_id = partner && partner.id; line.st_line.partner_name = partner && partner.display_name || ''; - return this._changePartner(handle, partner.id) + return $.when(partner && this._changePartner(handle, partner.id)) .then(function() { line.reconciliation_proposition = []; - return self._performMoveLine(handle) + return self.changeMode(handle, 'match'); }) .then(function () { if (line.mode === 'create') { @@ -253,7 +255,7 @@ var StatementModel = BasicModel.extend({ args: [self.bank_statement_id.id], }) .then(function () { - return self.bank_statement_id.id + return self.bank_statement_id.id; }); }, /** @@ -284,6 +286,34 @@ var StatementModel = BasicModel.extend({ return this.context; }, /** + * Return the lines that needs to be displayed by the widget + * + * @returns {Object} lines that are loaded and not yet displayed + */ + getStatementLines: function () { + var self = this; + var linesToDisplay = _.pick(this.lines, function(value, key, object) { + if (value.visible === true && self.alreadyDisplayed.indexOf(key) === -1) { + self.alreadyDisplayed.push(key); + return object; + } + }); + return linesToDisplay; + }, + /** + * Return a boolean telling if load button needs to be displayed or not + * + * @returns {boolean} true if load more button needs to be displayed + */ + hasMoreLines: function () { + var self = this; + var notDisplayed = _.filter(this.lines, function(line) { return !line.visible; }); + if (notDisplayed.length > 0) { + return true; + } + return false; + }, + /** * get the line data for this handle * * @param {OdooEvent} event @@ -355,15 +385,43 @@ var StatementModel = BasicModel.extend({ line.reconcileModels = self.reconcileModels; }); var ids = _.pluck(self.lines, 'id'); - return self._rpc({ - model: 'account.bank.statement.line', - method: 'get_data_for_reconciliation_widget', - args: [ids], - }) - .then(self._formatLine.bind(self)); + ids = ids.splice(0, self.defaultDisplayQty); + self.pagerIndex = ids.length; + return self.loadData(ids, []); }); }, /** + * Load more bank statement line + * + * @param {integer} quantity to load + * @returns {Deferred} + */ + loadMore: function(qty) { + if (qty === undefined) { + qty = this.defaultDisplayQty; + } + var ids = _.pluck(this.lines, 'id'); + ids = ids.splice(this.pagerIndex, qty); + this.pagerIndex += qty; + return this.loadData(ids, this._getExcludedIds()); + }, + /** + * RPC method to load informations on lines + * + * @param {Array} ids of bank statement line passed to rpc call + * @param {Array} list of move_line ids that needs to be excluded from search + * @returns {Deferred} + */ + loadData: function(ids, excluded_ids) { + var self = this; + return self._rpc({ + model: 'account.bank.statement.line', + method: 'get_data_for_reconciliation_widget', + args: [ids, excluded_ids], + }) + .then(self._formatLine.bind(self)); + }, + /** * Add lines into the propositions from the reconcile model * Can add 2 lines, and each with its taxes. The second line become editable * in the create mode. @@ -770,6 +828,7 @@ var StatementModel = BasicModel.extend({ var line = _.find(self.lines, function (l) { return l.id === data.st_line.id; }); + line.visible = true; _.extend(line, data); self._formatLineProposition(line, line.reconciliation_proposition); if (!line.reconciliation_proposition.length) { @@ -851,6 +910,26 @@ var StatementModel = BasicModel.extend({ return prop; }, /** + * Return list of account_move_line that has been selected and needs to be removed + * from other calls. + * + * @private + * @returns {Array} list of excluded ids + */ + _getExcludedIds: function () { + var excludedIds = []; + _.each(this.lines, function(line) { + if (line.reconciliation_proposition) { + _.each(line.reconciliation_proposition, function(prop) { + if (parseInt(prop['id'])) { + excludedIds.push(prop['id']); + } + }) + } + }); + return excludedIds; + }, + /** * Defined whether the line is to be displayed or not. Here, we only display * the line if it comes from the server or if an account is defined when it * is created @@ -1168,7 +1247,8 @@ var ManualModel = StatementModel.extend({ 'filter': "", 'reconcileModels': [], 'account_id': this._formatNameGet([data.account_id, data.account_name]), - 'st_line': data + 'st_line': data, + 'visible': true }); this._formatLineProposition(line, line.reconciliation_proposition); if (!line.reconciliation_proposition.length) { diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index e0ecf61d354..2530a6043c2 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -24,6 +24,7 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, { "click *[rel='do_action']": "_onDoAction", 'click button.button_back_to_statement': '_onGoToBankStatement', 'click button.button_close_statement': '_onCloseBankStatement', + 'click button.js_load_more': '_onLoadMore', }, /** * @override @@ -87,6 +88,12 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, { //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- + /* + * hide the button to load more statement line + */ + hideLoadMoreButton: function () { + this.$('.js_load_more').hide(); + }, /** * update the statement rendering * @@ -215,6 +222,14 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, { }); }, /** + * Load more statement lines for reconciliation + * @private + * @param {MouseEvent} event + */ + _onLoadMore: function (e) { + this.trigger_up('load_more'); + }, + /** * @private */ _onValidateName: function () { diff --git a/addons/account/static/src/less/account_reconciliation.less b/addons/account/static/src/less/account_reconciliation.less index 4ecf02c8416..66b39301fbb 100644 --- a/addons/account/static/src/less/account_reconciliation.less +++ b/addons/account/static/src/less/account_reconciliation.less @@ -166,7 +166,6 @@ vertical-align: top; padding-right: 10px; font-weight: bold; - white-space: nowrap; } } @@ -209,7 +208,7 @@ .o-transition(max-height, 250ms); } &[data-mode="match"] > .match { - max-height: 130px; + max-height: none; .o-transition(max-height, 400ms); } &[data-mode="create"] > .create { diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml index e73a42c785b..e41bb97cc3d 100644 --- a/addons/account/static/src/xml/account_reconciliation.xml +++ b/addons/account/static/src/xml/account_reconciliation.xml @@ -25,6 +25,9 @@ <div class="notification_area"/> </div> <div class="o_reconciliation_lines"/> + <div t-if="widget._initialState.valuemax > 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> </div> <div t-else="" class="o_view_nocontent"> @@ -203,7 +206,16 @@ <td class="cell_action"><span class="fa fa-add-remove"/></td> <td class="cell_account_code"><t t-esc="line.account_code"/></td> <td class="cell_due_date"><t t-esc="line.date_maturity === false ? line.date : line.date_maturity"/></td> - <td class="cell_label"><t t-if="line.partner_id && line.partner_id !== state.st_line.partner_id"><t t-if="line.partner_name.length"><t t-esc="line.partner_name"/>: </t><t t-esc="line.label || line.name"/><t t-if="line.ref && line.ref.length"> : </t><t t-esc="line.ref"/></t><t t-else=""><t t-esc="line.ref && line.ref.length ? line.ref : line.label || line.name"/></t></td> + <td class="cell_label"> + <t t-if="line.partner_id && line.partner_id !== state.st_line.partner_id"> + <t t-if="line.partner_name.length"> + <t t-esc="line.partner_name"/>: + </t> + </t> + <t t-esc="line.label || line.name"/> + <t t-if="line.ref && line.ref.length"> : </t> + <t t-esc="line.ref"/> + </td> <td class="cell_left"><t t-if="line.amount < 0"><span t-if="line.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{line.currency_id%8} line_info_button fa fa-money" t-att-data-content="line.amount_currency_str"/><t t-raw="line.amount_str"/></t></td> <td class="cell_right"><t t-if="line.amount > 0"><span t-if="line.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{line.currency_id%8} line_info_button fa fa-money" t-att-data-content="line.amount_currency_str"/><t t-raw="line.amount_str"/></t></td> <td class="cell_info_popover"></td> diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 0eeb805112c..511656906d9 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -1,7 +1,9 @@ odoo.define('account.reconciliation_tests.data', function () { "use strict"; -var data = { +var Datas = {}; + +var db = { 'res.partner': { fields: { id: {string: "ID", type: 'integer'}, @@ -58,7 +60,7 @@ var data = { {id: 7, display_name: "Tax 10.00% include", amount: 10, price_include: true, account_id: 288}, ], json_friendly_compute_all: function (args) { - var tax = _.find(data['account.tax'].records, {'id': args[0][0]}); + var tax = _.find(db['account.tax'].records, {'id': args[0][0]}); var amount = args[1]; var tax_base = tax.price_include ? amount*100/(100+tax.amount) : amount; return $.when({ @@ -103,7 +105,7 @@ var data = { 'account.bank.statement': { fields: {}, reconciliation_widget_preprocess: function () { - return $.when(data_preprocess); + return $.when(Datas.used.data_preprocess); }, }, 'account.bank.statement.line': { @@ -122,19 +124,19 @@ var data = { var partner_id = args.splice(1, 1)[0]; var excluded_ids = args.splice(1, 1)[0]; var key = JSON.stringify(args); - if (!mv_lines[key]) { + if (!Datas.used.mv_lines[key]) { throw new Error("Unknown parameters for get_move_lines_for_reconciliation_widget: '"+ key + "'"); } - return $.when(_.filter(mv_lines[key], function (line) { + return $.when(_.filter(Datas.used.mv_lines[key], function (line) { return excluded_ids.indexOf(line.id) === -1 && (!partner_id || partner_id === line.partner_id); })); }, get_data_for_reconciliation_widget: function (args) { var ids = args[0]; - return $.when(_.filter(data_widget, function (w) {return _.contains(ids, w.st_line.id);})); + return $.when(_.filter(Datas.used.data_widget, function (w) {return _.contains(ids, w.st_line.id);})); }, reconciliation_widget_auto_reconcile: function () { - return $.when(auto_reconciliation); + return $.when(Datas.used.auto_reconciliation); }, process_reconciliations: function (args) { var datas = args[1]; @@ -142,8 +144,8 @@ var data = { ids = ids.concat(_.flatten(_.pluck(datas, 'payment_aml_ids'))); ids = _.compact(ids); - for (var key in move_lines_for_manual_reconciliation) { - move_lines_for_manual_reconciliation[key] = _.filter(move_lines_for_manual_reconciliation[key], function (mv_line) { + for (var key in Datas.used.move_lines_for_manual_reconciliation) { + Datas.used.move_lines_for_manual_reconciliation[key] = _.filter(Datas.used.move_lines_for_manual_reconciliation[key], function (mv_line) { return ids.indexOf(mv_line.id) === -1; }); } @@ -154,18 +156,18 @@ var data = { fields: {}, get_data_for_manual_reconciliation_widget: function (args) { var key = JSON.stringify(args); - if (!data_for_manual_reconciliation_widget[key]) { + if (!Datas.used.data_for_manual_reconciliation_widget[key]) { throw new Error("Unknown parameters for get_data_for_manual_reconciliation_widget: '"+ key + "'"); } - return $.when(data_for_manual_reconciliation_widget[key]); + return $.when(Datas.used.data_for_manual_reconciliation_widget[key]); }, get_move_lines_for_manual_reconciliation: function (args) { var excluded_ids = args.splice(2, 1)[0]; var key = JSON.stringify(args); - if (!move_lines_for_manual_reconciliation[key]) { + if (!Datas.used.move_lines_for_manual_reconciliation[key]) { throw new Error("Unknown parameters for get_move_lines_for_manual_reconciliation: '"+ key + "'"); } - return $.when(_.filter(move_lines_for_manual_reconciliation[key], function (line) { + return $.when(_.filter(Datas.used.move_lines_for_manual_reconciliation[key], function (line) { return excluded_ids.indexOf(line.id) === -1; })); }, @@ -174,8 +176,8 @@ var data = { var datas = args[0]; for (var i in datas) { var data = datas[i]; - for (var key in move_lines_for_manual_reconciliation) { - move_lines_for_manual_reconciliation[key] = _.filter(move_lines_for_manual_reconciliation[key], function (mv_line) { + for (var key in Datas.used.move_lines_for_manual_reconciliation) { + Datas.used.move_lines_for_manual_reconciliation[key] = _.filter(Datas.used.move_lines_for_manual_reconciliation[key], function (mv_line) { return data.mv_line_ids.indexOf(mv_line.id) === -1; }); } @@ -367,7 +369,12 @@ var mv_lines = { '[7,"",0,6]': [ {'account_type': "receivable", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-07", 'date': "2017-01-08", 'total_amount_str': "$ 650.00", 'partner_id': 12, 'account_name': "101200 Account Receivable", 'name': "INV/2017/0012", 'partner_name': "Camptocamp", 'total_amount_currency_str': "", 'id': 133, 'credit': 0.0, 'journal_id': [1, "Customer Invoices"], 'amount_str': "$ 650.00", 'debit': 650.0, 'account_id': [287, "101200 Account Receivable"], 'account_code': "101200", 'ref': "", 'already_paid': false}, {'account_type': "receivable", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-28", 'date': "2017-01-01", 'total_amount_str': "$ 4,610.00", 'partner_id': 12, 'account_name': "101200 Account Receivable", 'name': "INV/2017/0001", 'partner_name': "Camptocamp", 'total_amount_currency_str': "", 'id': 106, 'credit': 0.0, 'journal_id': [1, "Customer Invoices"], 'amount_str': "$ 4,610.00", 'debit': 4610.0, 'account_id': [287, "101200 Account Receivable"], 'account_code': "101200", 'ref': "", 'already_paid': false}, - {'account_type': "payable", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-28", 'date': "2017-01-01", 'total_amount_str': "$ 10,000.00", 'partner_id': 12, 'account_name': "Account Payable", 'name': "BILL/2017/0001", 'partner_name': "Camptocamp", 'total_amount_currency_str': "", 'id': 114, 'credit': 10000.0, 'journal_id': [2, "Vendor Bills"], 'amount_str': "$ 10,000.00", 'debit': 0.0, 'account_id': [284, "101110 Stock Valuation Account"], 'account_code': "111100", 'ref': "", 'already_paid': false} + {'account_type': "payable", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-28", 'date': "2017-01-01", 'total_amount_str': "$ 10,000.00", 'partner_id': 12, 'account_name': "Account Payable", 'name': "BILL/2017/0001", 'partner_name': "Camptocamp", 'total_amount_currency_str': "", 'id': 114, 'credit': 10000.0, 'journal_id': [2, "Vendor Bills"], 'amount_str': "$ 10,000.00", 'debit': 0.0, 'account_id': [284, "101110 Stock Valuation Account"], 'account_code': "111100", 'ref': "", 'already_paid': false}, + {'account_type': "liquidity", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-01-23", 'date': "2017-01-23", 'total_amount_str': "$ 376.00", 'partner_id': 7, 'account_name': "Bank", 'name': "BNK1/2017/0002: SUPP.OUT/2017/0002", 'partner_name': "ASUSTeK", 'total_amount_currency_str': "", 'id': 392, 'credit': 376.0, 'journal_id': "Bank", 'amount_str': "$ 376.00", 'debit': 0.0, 'account_code': "101401", 'ref': "BILL/2017/0003", 'already_paid': true}, + {'account_type': "liquidity", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-01-23", 'date': "2017-01-23", 'total_amount_str': "$ 100.00", 'partner_id': 8, 'account_name': "Bank", 'name': "BNK1/2017/0003: CUST.IN/2017/0001", 'partner_name': "Agrolait", 'total_amount_currency_str': "", 'id': 394, 'credit': 0.0, 'journal_id': "Bank", 'amount_str': "$ 100.00", 'debit': 100.0, 'account_code': "101401", 'ref': "", 'already_paid': true}, + {'account_type': "liquidity", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-01-23", 'date': "2017-01-23", 'total_amount_str': "$ 525.50", 'partner_id': 8, 'account_name': "Bank", 'name': "BNK1/2017/0004: CUST.IN/2017/0002", 'partner_name': "Agrolait", 'total_amount_currency_str': "", 'id': 396, 'credit': 0.0, 'journal_id': "Bank", 'amount_str': "$ 525.50", 'debit': 525.5, 'account_code': "101401", 'ref': "INV/2017/0003", 'already_paid': true}, + {'account_type': "receivable", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-07", 'date': "2017-01-08", 'total_amount_str': "$ 650.00", 'partner_id': 8, 'account_name': "101200 Account Receivable", 'name': "INV/2017/0002", 'partner_name': "Agrolait", 'total_amount_currency_str': "", 'id': 109, 'credit': 0.0, 'journal_id': [1, "Customer Invoices"], 'amount_str': "$ 650.00", 'debit': 650.0, 'account_code': "101200", 'ref': "", 'already_paid': false}, + {'account_type': "receivable", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-22", 'date': "2017-01-23", 'total_amount_str': "$ 525.00", 'partner_id': 8, 'account_name': "101200 Account Receivable", 'name': "INV/2017/0004", 'partner_name': "Agrolait", 'total_amount_currency_str': "", 'id': 399, 'credit': 0.0, 'journal_id': [1, "Customer Invoices"], 'amount_str': "$ 525.00", 'debit': 525.0, 'account_code': "101200", 'ref': "", 'already_paid': false}, ], '[8,"",0,6]': [], }; @@ -468,23 +475,22 @@ var options = { } }; -return { - params: { - data: data, - data_preprocess: data_preprocess, - data_widget: data_widget, - mv_lines: mv_lines, - auto_reconciliation: auto_reconciliation, - data_for_manual_reconciliation_widget: data_for_manual_reconciliation_widget, - move_lines_for_manual_reconciliation: move_lines_for_manual_reconciliation, - session: session, - options: options, - }, +Datas.params = { + data: db, + data_preprocess: data_preprocess, + data_widget: data_widget, + mv_lines: mv_lines, + auto_reconciliation: auto_reconciliation, + data_for_manual_reconciliation_widget: data_for_manual_reconciliation_widget, + move_lines_for_manual_reconciliation: move_lines_for_manual_reconciliation, + session: session, + options: options, +}; // this is the main function for this module. Its job is to export (and clone) all data for a test. - getParams: function () { - return $.extend(true, {}, this.params); - } +Datas.getParams = function () { + return (this.used = $.extend(true, {}, this.params)); }; +return Datas; }); odoo.define('account.reconciliation_tests', function (require) { @@ -534,7 +540,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation fields', function (assert) { assert.expect(6); @@ -561,7 +566,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation basic data', function (assert) { assert.expect(14); @@ -630,7 +634,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation partial', function (assert) { assert.expect(10); @@ -701,7 +704,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation currencies', function (assert) { assert.expect(2); @@ -736,7 +738,7 @@ QUnit.module('account', { }); QUnit.test('Reconciliation change partner', function (assert) { - assert.expect(4); + assert.expect(10); var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); @@ -754,7 +756,21 @@ QUnit.module('account', { $('.ui-autocomplete .ui-menu-item a:contains(Camptocamp)').trigger('mouseenter').trigger('click'); clientAction._onAction({target: widget, name: 'change_partner', data: {data: {display_name: 'Camptocamp', id: 12}}, stopped: false}); assert.strictEqual(widget.$('.o_input_dropdown input').val(), "Camptocamp", "the partner many2one should display Camptocamp"); - assert.strictEqual(widget.$('.match table tr').length, 3, "camptocamp should have 3 propositions for reconciliation"); + assert.strictEqual(widget.$('.match table tr.mv_line').length, 3, "camptocamp should have 3 propositions for reconciliation"); + + widget = clientAction.widgets[2]; + widget.$('.accounting_view thead td:first').trigger('click'); + widget.$('.accounting_view .mv_line .cell_label').trigger('click'); + assert.strictEqual(widget.$('.o_input_dropdown input').val(), "Camptocamp", "the partner many2one should display agrolait"); + assert.strictEqual(widget.$('.match table tr').length, 3, "Camptocamp should have 3 propositions for reconciliation"); + assert.strictEqual(widget.$('.match .match_controls .fa:not(.disabled)').length, 0, "should not display the right arrow"); + + // Simulate remove partner + widget.$('.o_input_dropdown input').trigger('click'); + widget.$('.o_input_dropdown input').val('').trigger('keyup').trigger('blur'); + assert.strictEqual(widget.$('.o_input_dropdown input').val(), "", "the partner many2one should be empty"); + assert.strictEqual(widget.$('.match table tr.mv_line').length, 5, "should have 5 propositions for reconciliation if partner is false"); + assert.strictEqual(widget.$('.match .match_controls .fa:not(.disabled)').length, 1, "should not display the right arrow"); clientAction.destroy(); }); @@ -839,7 +855,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation create line with taxes', function (assert) { assert.expect(13); @@ -883,7 +898,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation create line from reconciliation model', function (assert) { assert.expect(6); @@ -900,7 +914,7 @@ QUnit.module('account', { widget.$('.create .quick_add button:contains(ATOS)').trigger('click'); assert.strictEqual(widget.$('.accounting_view tbody .cell_label, .accounting_view tbody .cell_right').text().replace(/[\n\r\s$,]+/g, ' '), - "ATOS Banque1145.62Tax 20.00%229.12ATOS Frais26.71Tax 10.00% include2.67", "should display 4 lines"); + " ATOS Banque 1145.62 Tax 20.00% 229.12 ATOS Frais 26.71 Tax 10.00% include 2.67", "should display 4 lines"); assert.strictEqual(widget.$('.accounting_view tfoot .cell_label, .accounting_view tfoot .cell_left').text().replace(/[\n\r\s$,]+/g, ' '), "Create Write-off229.12", "should display the 'Create Write-off' line with value in left column"); @@ -926,7 +940,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation auto reconciliation', function (assert) { assert.expect(6); @@ -958,7 +971,6 @@ QUnit.module('account', { clientAction.destroy(); }); - QUnit.test('Reconciliation manual', function (assert) { assert.expect(8); @@ -992,5 +1004,229 @@ QUnit.module('account', { clientAction.destroy(); }); + + + QUnit.test('Reconciliation pager', function (assert) { + assert.expect(6); + + this.params.data['account.bank.statement.line'].records.push( + {id: 9, display_name: "add 9"}, + {id: 10, display_name: "add 10"}, + {id: 11, display_name: "add 11"}, + {id: 12, display_name: "add 12"}, + {id: 13, display_name: "add 13"}, + {id: 14, display_name: "add 14"}, + {id: 15, display_name: "add 15"}, + {id: 16, display_name: "add 16"} + ); + + var data_preprocess = this.params.data_preprocess; + data_preprocess.st_lines_ids.push(9, 10, 11, 12, 13, 14, 15, 16); + + this.params.data_widget.push( + { + 'st_line': { + 'currency_id': 3, + 'communication_partner_name': false, + 'open_balance_account_id': 287, + 'name': "add 9", + 'partner_name': "Camptocamp", + 'partner_id': 12, + 'has_no_partner': false, + 'journal_id': 84, + 'account_name': "Bank", + 'note': "", + 'amount': 650.0, + 'amount_str': "$ 650.00", + 'amount_currency_str': "", + 'date': "2017-01-01", + 'account_code': "101401", + 'ref': "", + 'id': 9, + 'statement_id': 2 + }, + 'reconciliation_proposition': [] + }, + { + 'st_line': { + 'currency_id': 3, + 'communication_partner_name': false, + 'open_balance_account_id': 287, + 'name': "add 10", + 'partner_name': "Camptocamp", + 'partner_id': 12, + 'has_no_partner': false, + 'journal_id': 84, + 'account_name': "Bank", + 'note': "", + 'amount': 650.0, + 'amount_str': "$ 650.00", + 'amount_currency_str': "", + 'date': "2017-01-01", + 'account_code': "101401", + 'ref': "", + 'id': 10, + 'statement_id': 2 + }, + 'reconciliation_proposition': [] + }, + { + 'st_line': { + 'currency_id': 3, + 'communication_partner_name': false, + 'open_balance_account_id': 287, + 'name': "add 11", + 'partner_name': "Camptocamp", + 'partner_id': 12, + 'has_no_partner': false, + 'journal_id': 84, + 'account_name': "Bank", + 'note': "", + 'amount': 650.0, + 'amount_str': "$ 650.00", + 'amount_currency_str': "", + 'date': "2017-01-01", + 'account_code': "101401", + 'ref': "", + 'id': 11, + 'statement_id': 2 + }, + 'reconciliation_proposition': [] + }, + { + 'st_line': { + 'currency_id': 3, + 'communication_partner_name': false, + 'open_balance_account_id': 287, + 'name': "add 12", + 'partner_name': "Camptocamp", + 'partner_id': 12, + 'has_no_partner': false, + 'journal_id': 84, + 'account_name': "Bank", + 'note': "", + 'amount': 650.0, + 'amount_str': "$ 650.00", + 'amount_currency_str': "", + 'date': "2017-01-01", + 'account_code': "101401", + 'ref': "", + 'id': 12, + 'statement_id': 2 + }, + 'reconciliation_proposition': [] + }, + { + 'st_line': { + 'currency_id': 3, + 'communication_partner_name': false, + 'open_balance_account_id': 287, + 'name': "add 13", + 'partner_name': "Camptocamp", + 'partner_id': 12, + 'has_no_partner': false, + 'journal_id': 84, + 'account_name': "Bank", + 'note': "", + 'amount': 650.0, + 'amount_str': "$ 650.00", + 'amount_currency_str': "", + 'date': "2017-01-01", + 'account_code': "101401", + 'ref': "", + 'id': 13, + 'statement_id': 2 + }, + 'reconciliation_proposition': [] + }, + { + 'st_line': { + 'currency_id': 3, + 'communication_partner_name': false, + 'open_balance_account_id': 287, + 'name': "add 14", + 'partner_name': "Camptocamp", + 'partner_id': 12, + 'has_no_partner': false, + 'journal_id': 84, + 'account_name': "Bank", + 'note': "", + 'amount': 650.0, + 'amount_str': "$ 650.00", + 'amount_currency_str': "", + 'date': "2017-01-01", + 'account_code': "101401", + 'ref': "", + 'id': 14, + 'statement_id': 2 + }, + 'reconciliation_proposition': [] + }, + { + 'st_line': { + 'currency_id': 3, + 'communication_partner_name': false, + 'open_balance_account_id': 287, + 'name': "add 15", + 'partner_name': "Camptocamp", + 'partner_id': 12, + 'has_no_partner': false, + 'journal_id': 84, + 'account_name': "Bank", + 'note': "", + 'amount': 650.0, + 'amount_str': "$ 650.00", + 'amount_currency_str': "", + 'date': "2017-01-01", + 'account_code': "101401", + 'ref': "", + 'id': 15, + 'statement_id': 2 + }, + 'reconciliation_proposition': [] + }, + { + 'st_line': { + 'currency_id': 3, + 'communication_partner_name': false, + 'open_balance_account_id': 287, + 'name': "add 16", + 'partner_name': "Camptocamp", + 'partner_id': 12, + 'has_no_partner': false, + 'journal_id': 84, + 'account_name': "Bank", + 'note': "", + 'amount': 650.0, + 'amount_str': "$ 650.00", + 'amount_currency_str': "", + 'date': "2017-01-01", + 'account_code': "101401", + 'ref': "", + 'id': 16, + 'statement_id': 2 + }, + 'reconciliation_proposition': [] + } + ); + + var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); + testUtils.addMockEnvironment(clientAction, { + 'data': this.params.data, + }); + + clientAction.appendTo($('#qunit-fixture')); + + assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 10, "should display 10 lines"); + assert.strictEqual(clientAction.$('.js_load_more:visible').length, 1, "should display the load more button"); + clientAction.$('.o_reconcile:visible').click(); + assert.strictEqual(clientAction.$('.o_reconcile:visible').length, 0, "should remove the reconciled line"); + assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 10, "should load one line to complete the 10"); + clientAction.$('.js_load_more').click(); + assert.strictEqual(clientAction.$('.o_reconciliation_line').length, 11, "should load the last record"); + assert.strictEqual(clientAction.$('.js_load_more:visible').length, 0, "should hide the load more button"); + + clientAction.destroy(); + }); }); }); diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 0093915eee5..ac2aca16951 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -2,8 +2,6 @@ from odoo.addons.account.tests.account_test_classes import AccountingTestCase import time import unittest -from odoo.tools import pycompat - class TestReconciliation(AccountingTestCase): @@ -339,8 +337,8 @@ class TestReconciliation(AccountingTestCase): self.assertTrue(exchange_loss_line, 'There should be one move line of 0.01 EUR in credit') # The journal items of the reconciliation should have their debit and credit total equal # Besides, the total debit and total credit should be 60.61 EUR (2.00 USD) - self.assertEquals(sum(res['debit'] for res in pycompat.values(result)), 60.61) - self.assertEquals(sum(res['credit'] for res in pycompat.items(result)), 60.61) + self.assertEquals(sum(res['debit'] for res in result.values()), 60.61) + self.assertEquals(sum(res['credit'] for res in result.items()), 60.61) counterpart_exchange_loss_line = None for line in exchange_loss_line.move_id.line_id: if line.account_id.id == self.account_fx_expense_id: diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index e1a0c6a3334..d73af20294d 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -223,7 +223,7 @@ <button name="action_invoice_draft" states="cancel" string="Set to Draft" type="object" groups="account.group_account_invoice"/> <field name="state" widget="statusbar" statusbar_visible="draft,open,paid" /> </header> - <div class="alert alert-info" role="alert" style="margin-bottom:0px;" attrs="{'invisible': [('has_outstanding','=',False)]}"> + <div groups="account.group_account_invoice" class="alert alert-info" role="alert" style="margin-bottom:0px;" attrs="{'invisible': [('has_outstanding','=',False)]}"> You have <bold><a class="alert-link" href="#outstanding" role="button">outstanding debits</a></bold> for this supplier. You can allocate them to mark this bill as paid. </div> <field name="has_outstanding" invisible="1"/> @@ -363,7 +363,7 @@ <button name="action_invoice_draft" states="cancel" string="Reset to Draft" type="object" groups="account.group_account_invoice"/> <field name="state" widget="statusbar" nolabel="1" statusbar_visible="draft,open,paid"/> </header> - <div class="alert alert-info" role="alert" style="margin-bottom:0px;" attrs="{'invisible': [('has_outstanding','=',False)]}"> + <div groups="account.group_account_invoice" class="alert alert-info" role="alert" style="margin-bottom:0px;" attrs="{'invisible': [('has_outstanding','=',False)]}"> You have <bold><a class="alert-link" href="#outstanding" role="button">outstanding payments</a></bold> for this customer. You can allocate them to mark this invoice as paid. </div> <field name="has_outstanding" invisible="1"/> diff --git a/addons/account/wizard/account_invoice_refund.py b/addons/account/wizard/account_invoice_refund.py index f0840aa7856..4fdd998f215 100644 --- a/addons/account/wizard/account_invoice_refund.py +++ b/addons/account/wizard/account_invoice_refund.py @@ -74,7 +74,7 @@ class AccountInvoiceRefund(models.TransientModel): for tmpline in refund.move_id.line_ids: if tmpline.account_id.id == inv.account_id.id: to_reconcile_lines += tmpline - to_reconcile_lines.filtered(lambda l: l.reconciled == False).reconcile() + to_reconcile_lines.filtered(lambda l: l.reconciled == False).reconcile() if mode == 'modify': invoice = inv.read(inv_obj._get_refund_modify_read_fields()) invoice = invoice[0] diff --git a/addons/account_asset/models/account_asset.py b/addons/account_asset/models/account_asset.py index 4c2855d216e..2a4432ace9e 100644 --- a/addons/account_asset/models/account_asset.py +++ b/addons/account_asset/models/account_asset.py @@ -7,7 +7,7 @@ from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF, pycompat +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF from odoo.tools import float_compare, float_is_zero @@ -374,7 +374,7 @@ class AccountAssetAsset(models.Model): vals = self.onchange_category_id_values(self.category_id.id) # We cannot use 'write' on an object that doesn't exist yet if vals: - for k, v in pycompat.items(vals['value']): + for k, v in vals['value'].items(): setattr(self, k, v) def onchange_category_id_values(self, category_id): @@ -583,7 +583,7 @@ class AccountAssetDepreciationLine(models.Model): message = '' if message_description: message = '<span>%s</span>' % message_description - for name, values in pycompat.items(tracked_values): + for name, values in tracked_values.items(): message += '<div> • <b>%s</b>: ' % name message += '%s</div>' % values return message diff --git a/addons/account_asset/wizard/asset_modify.py b/addons/account_asset/wizard/asset_modify.py index 9f8ce7cd497..d31df656455 100644 --- a/addons/account_asset/wizard/asset_modify.py +++ b/addons/account_asset/wizard/asset_modify.py @@ -39,7 +39,7 @@ class AssetModify(models.TransientModel): node = doc.xpath("//field[@name='method_number']")[0] node.set('invisible', '1') setup_modifiers(node, result['fields']['method_number']) - result['arch'] = etree.tostring(doc) + result['arch'] = etree.tostring(doc, encoding='unicode') return result @api.model diff --git a/addons/account_voucher/models/account_voucher.py b/addons/account_voucher/models/account_voucher.py index e394af36296..02256abaa22 100644 --- a/addons/account_voucher/models/account_voucher.py +++ b/addons/account_voucher/models/account_voucher.py @@ -5,7 +5,6 @@ from odoo import fields, models, api, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError -from odoo.tools import pycompat class AccountVoucher(models.Model): @@ -358,7 +357,7 @@ class AccountVoucherLine(models.Model): self.company_id.id, self.voucher_id.currency_id.id, self.voucher_id.voucher_type) - for fname, fvalue in pycompat.items(onchange_res['value']): + for fname, fvalue in onchange_res['value'].items(): setattr(self, fname, fvalue) def _get_account(self, product, fpos, type): @@ -394,7 +393,7 @@ class AccountVoucherLine(models.Model): if product.description_purchase: values['name'] += '\n' + product.description_purchase else: - values['price_unit'] = product.lst_price + values['price_unit'] = price_unit or product.lst_price taxes = product.taxes_id or account.tax_ids if product.description_sale: values['name'] += '\n' + product.description_sale @@ -404,7 +403,7 @@ class AccountVoucherLine(models.Model): if company and currency: if company.currency_id != currency: if type == 'purchase': - values['price_unit'] = product.standard_price + values['price_unit'] = price_unit or product.standard_price values['price_unit'] = values['price_unit'] * currency.rate return {'value': values, 'domain': {}} diff --git a/addons/anonymization/models/anonymization.py b/addons/anonymization/models/anonymization.py index 2d4fe1b17d2..a0c90382938 100644 --- a/addons/anonymization/models/anonymization.py +++ b/addons/anonymization/models/anonymization.py @@ -6,6 +6,7 @@ from operator import itemgetter from odoo import api, fields, models, _ from odoo.exceptions import UserError +from odoo.tools import pycompat FIELD_STATES = [('clear', 'Clear'), ('anonymized', 'Anonymized'), ('not_existing', 'Not Existing'), ('new', 'New')] ANONYMIZATION_HISTORY_STATE = [('started', 'Started'), ('done', 'Done'), ('in_exception', 'Exception occured')] @@ -13,7 +14,7 @@ ANONYMIZATION_DIRECTION = [('clear -> anonymized', 'clear -> anonymized'), ('ano def group(lst, cols): - if isinstance(cols, basestring): + if isinstance(cols, pycompat.string_types): cols = [cols] return dict((k, [v for v in itr]) for k, itr in groupby(sorted(lst, key=itemgetter(*cols)), itemgetter(*cols))) diff --git a/addons/anonymization/wizard/anonymize_wizard.py b/addons/anonymization/wizard/anonymize_wizard.py index fb514fe778e..a319347f918 100644 --- a/addons/anonymization/wizard/anonymize_wizard.py +++ b/addons/anonymization/wizard/anonymize_wizard.py @@ -120,7 +120,7 @@ class IrModelFieldsAnonymizeWizard(models.TransientModel): else: raise UserError(_("The database anonymization is currently in an unstable state. Some fields are anonymized," " while some fields are not anonymized. You should try to solve this problem before trying to do anything else.")) - res['arch'] = etree.tostring(eview) + res['arch'] = etree.tostring(eview, encoding='unicode') return res @api.multi diff --git a/addons/auth_signup/controllers/main.py b/addons/auth_signup/controllers/main.py index 8f8db0fab0d..262dfdad86d 100644 --- a/addons/auth_signup/controllers/main.py +++ b/addons/auth_signup/controllers/main.py @@ -8,7 +8,6 @@ from odoo.addons.auth_signup.models.res_users import SignupError from odoo.addons.web.controllers.main import ensure_db, Home from odoo.exceptions import UserError from odoo.http import request -from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -106,7 +105,7 @@ class AuthSignupHome(Home): try: # retrieve the user info (name, login or email) corresponding to a signup token token_infos = request.env['res.partner'].sudo().signup_retrieve_info(qcontext.get('token')) - for k, v in pycompat.items(token_infos): + for k, v in token_infos.items(): qcontext.setdefault(k, v) except: qcontext['error'] = _("Invalid signup token") diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py index 0cf5c25b7ff..ecf63246580 100644 --- a/addons/auth_signup/models/res_partner.py +++ b/addons/auth_signup/models/res_partner.py @@ -8,8 +8,6 @@ from collections import defaultdict from datetime import datetime, timedelta from odoo import api, exceptions, fields, models, _ -from odoo.tools import pycompat - class SignupError(Exception): pass diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index f6fcbf61d42..e3e3ad574e0 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -35,5 +35,5 @@ class Partner(models.Model): city_id_node = etree.fromstring(replacement_xml) city_node.getparent().replace(city_node, city_id_node) - arch = etree.tostring(doc) + arch = etree.tostring(doc, encoding='unicode') return arch diff --git a/addons/base_address_extended/models/base_address_extended.py b/addons/base_address_extended/models/base_address_extended.py index 1d49c1c94c4..9a210dde769 100644 --- a/addons/base_address_extended/models/base_address_extended.py +++ b/addons/base_address_extended/models/base_address_extended.py @@ -5,7 +5,6 @@ import re from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.tools import pycompat STREET_FIELDS = ('street_name', 'street_number', 'street_number2') @@ -135,7 +134,7 @@ class Partner(models.Model): vals[field_name] = street_raw # assign the values to the fields # /!\ Note that a write(vals) would cause a recursion since it would bypass the cache - for k, v in pycompat.items(vals): + for k, v in vals.items(): partner[k] = v diff --git a/addons/base_automation/models/base_automation.py b/addons/base_automation/models/base_automation.py index 061828c161e..9896a9706d8 100644 --- a/addons/base_automation/models/base_automation.py +++ b/addons/base_automation/models/base_automation.py @@ -12,7 +12,6 @@ from dateutil.relativedelta import relativedelta from odoo import api, fields, models, SUPERUSER_ID from odoo.modules.registry import Registry -from odoo.tools import pycompat from odoo.tools.safe_eval import safe_eval _logger = logging.getLogger(__name__) @@ -264,7 +263,7 @@ class BaseAutomation(models.Model): if res: if 'value' in res: res['value'].pop('id', None) - self.update({key: val for key, val in pycompat.items(res['value']) if key in self._fields}) + self.update({key: val for key, val in res['value'].items() if key in self._fields}) if 'domain' in res: result.setdefault('domain', {}).update(res['domain']) if 'warning' in res: diff --git a/addons/base_gengo/wizard/base_gengo_translations.py b/addons/base_gengo/wizard/base_gengo_translations.py index 98df9356576..d6e5ce9176a 100644 --- a/addons/base_gengo/wizard/base_gengo_translations.py +++ b/addons/base_gengo/wizard/base_gengo_translations.py @@ -8,7 +8,6 @@ import uuid from odoo import api, fields, models, tools, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -194,7 +193,7 @@ class BaseGengoTranslations(models.TransientModel): term_ids.write(vals) jobs = response.get('jobs', []) if jobs: - for t_id, job in pycompat.items(jobs): + for t_id, job in jobs.items(): self._update_terms_job(job) return diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 85fb49924f6..c86db44e26c 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import csv import datetime import io import itertools @@ -17,12 +16,6 @@ from odoo.tools.mimetypes import guess_mimetype from odoo.tools.misc import ustr from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT, pycompat -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - - FIELDS_RECURSION_LIMIT = 2 ERROR_PREVIEW_BYTES = 200 _logger = logging.getLogger(__name__) @@ -49,7 +42,7 @@ FILE_TYPE_DICT = { } EXTENSIONS = { '.' + ext: handler - for mime, (ext, handler, req) in pycompat.items(FILE_TYPE_DICT) + for mime, (ext, handler, req) in FILE_TYPE_DICT.items() } @@ -121,7 +114,7 @@ class Import(models.TransientModel): }] model_fields = Model.fields_get() blacklist = models.MAGIC_COLUMNS + [Model.CONCURRENCY_CHECK_FIELD] - for name, field in pycompat.items(model_fields): + for name, field in model_fields.items(): if name in blacklist: continue # an empty string means the field is deprecated, @deprecated must @@ -134,7 +127,7 @@ class Import(models.TransientModel): continue # states = {state: [(attr, value), (attr2, value2)], state2:...} if not any(attr == 'readonly' and value is False - for attr, value in itertools.chain.from_iterable(pycompat.values(states))): + for attr, value in itertools.chain.from_iterable(states.values())): continue field_value = { 'id': name, @@ -214,9 +207,9 @@ class Import(models.TransientModel): if cell.ctype is xlrd.XL_CELL_NUMBER: is_float = cell.value % 1 != 0.0 values.append( - unicode(cell.value) + pycompat.text_type(cell.value) if is_float - else unicode(int(cell.value)) + else pycompat.text_type(int(cell.value)) ) elif cell.ctype is xlrd.XL_CELL_DATE: is_datetime = cell.value % 1 != 0.0 @@ -268,14 +261,13 @@ class Import(models.TransientModel): # csv module expect utf-8, see http://docs.python.org/2/library/csv.html csv_data = csv_data.decode(encoding).encode('utf-8') - csv_iterator = csv.reader( - StringIO(csv_data), + csv_iterator = pycompat.csv_reader( + io.BytesIO(csv_data), quotechar=str(options['quoting']), delimiter=str(options['separator'])) return ( - [item.decode('utf-8') for item in row] - for row in csv_iterator + row for row in csv_iterator if any(x for x in row if x.strip()) ) @@ -603,7 +595,7 @@ class Import(models.TransientModel): def _parse_import_data(self, data, import_fields, options): # Get fields of type date/datetime all_fields = self.env[self.res_model].fields_get() - for name, field in pycompat.items(all_fields): + for name, field in all_fields.items(): if field['type'] in ('date', 'datetime') and name in import_fields: # Parse date index = import_fields.index(name) @@ -611,11 +603,13 @@ class Import(models.TransientModel): server_format = DEFAULT_SERVER_DATE_FORMAT if field['type'] == 'date' else DEFAULT_SERVER_DATETIME_FORMAT if options.get('%s_format' % field['type'], server_format) != server_format: - user_format = ustr(options.get('%s_format' % field['type'])).encode('utf-8') + # datetime.str[fp]time takes *native strings* in both + # versions, for both data and pattern + user_format = pycompat.to_native(options.get('%s_format' % field['type'])) for num, line in enumerate(data): if line[index]: try: - line[index] = dt.strftime(dt.strptime(ustr(line[index].strip()).encode('utf-8'), user_format), server_format) + line[index] = dt.strftime(dt.strptime(pycompat.to_native(line[index].strip()), user_format), server_format) except ValueError as e: raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e)) except Exception as e: @@ -659,12 +653,18 @@ class Import(models.TransientModel): except ValueError as error: return [{ 'type': 'error', - 'message': unicode(error), + 'message': pycompat.text_type(error), 'record': False, }] _logger.info('importing %d rows...', len(data)) - import_result = self.env[self.res_model].with_context(import_file=True).load(import_fields, data) + + model = self.env[self.res_model].with_context(import_file=True) + defer_parent_store = self.env.context.get('defer_parent_store_computation', True) + if defer_parent_store and model._parent_store: + model = model.with_context(defer_parent_store_computation=True) + + import_result = model.load(import_fields, data) _logger.info('done') # If transaction aborted, RELEASE SAVEPOINT is going to raise diff --git a/addons/base_import/models/odf_ods_reader.py b/addons/base_import/models/odf_ods_reader.py index c2613c6baa5..daa7d2b16e2 100644 --- a/addons/base_import/models/odf_ods_reader.py +++ b/addons/base_import/models/odf_ods_reader.py @@ -19,8 +19,6 @@ from odf import opendocument from odf.table import Table, TableRow, TableCell from odf.text import P -from odoo.tools import pycompat - class ODSReader(object): @@ -97,4 +95,4 @@ class ODSReader(object): return self.SHEETS[name] def getFirstSheet(self): - return next(iter(pycompat.values(self.SHEETS))) + return next(iter(self.SHEETS.values())) diff --git a/addons/base_import/tests/test_base_import.py b/addons/base_import/tests/test_base_import.py index 2d1d850d3a9..cf324b44abd 100644 --- a/addons/base_import/tests/test_base_import.py +++ b/addons/base_import/tests/test_base_import.py @@ -1,13 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - -import csv +import base64 import io import unittest from odoo.tests.common import TransactionCase, can_import from odoo.modules.module import get_module_resource -from odoo.tools import mute_logger +from odoo.tools import mute_logger, pycompat ID_FIELD = { 'id': 'id', @@ -231,10 +230,10 @@ class TestPreview(TransactionCase): def test_csv_success(self): import_wizard = self.env['base_import.import'].create({ 'res_model': 'base_import.tests.models.preview', - 'file': 'name,Some Value,Counter\n' - 'foo,1,2\n' - 'bar,3,4\n' - 'qux,5,6\n', + 'file': b'name,Some Value,Counter\n' + b'foo,1,2\n' + b'bar,3,4\n' + b'qux,5,6\n', 'file_type': 'text/csv' }) @@ -359,10 +358,10 @@ class test_convert_import_data(TransactionCase): def test_all(self): import_wizard = self.env['base_import.import'].create({ 'res_model': 'base_import.tests.models.preview', - 'file': 'name,Some Value,Counter\n' - 'foo,1,2\n' - 'bar,3,4\n' - 'qux,5,6\n', + 'file': b'name,Some Value,Counter\n' + b'foo,1,2\n' + b'bar,3,4\n' + b'qux,5,6\n', 'file_type': 'text/csv' }) @@ -381,8 +380,8 @@ class test_convert_import_data(TransactionCase): def test_date_fields(self): import_wizard = self.env['base_import.import'].create({ 'res_model': 'res.partner', - 'file': 'name,date,create_date\n' - '"foo","2013ๅนด07ๆ18ๆฅ","2016-10-12 06:06"\n', + 'file': u'name,date,create_date\n' + u'"foo","2013ๅนด07ๆ18ๆฅ","2016-10-12 06:06"\n'.encode('utf-8'), 'file_type': 'text/csv' }) @@ -409,10 +408,10 @@ class test_convert_import_data(TransactionCase): """ import_wizard = self.env['base_import.import'].create({ 'res_model': 'base_import.tests.models.preview', - 'file': 'name,Some Value,Counter\n' - 'foo,1,2\n' - 'bar,3,4\n' - 'qux,5,6\n', + 'file': b'name,Some Value,Counter\n' + b'foo,1,2\n' + b'bar,3,4\n' + b'qux,5,6\n', 'file_type': 'text/csv' }) data, fields = import_wizard._convert_import_data( @@ -433,10 +432,10 @@ class test_convert_import_data(TransactionCase): """ import_wizard = self.env['base_import.import'].create({ 'res_model': 'base_import.tests.models.preview', - 'file': 'name,Some Value,Counter\n' - 'foo,1,2\n' - ',3,\n' - ',5,6\n', + 'file': b'name,Some Value,Counter\n' + b'foo,1,2\n' + b',3,\n' + b',5,6\n', 'file_type': 'text/csv' }) data, fields = import_wizard._convert_import_data( @@ -453,12 +452,12 @@ class test_convert_import_data(TransactionCase): def test_empty_rows(self): import_wizard = self.env['base_import.import'].create({ 'res_model': 'base_import.tests.models.preview', - 'file': 'name,Some Value\n' - 'foo,1\n' - '\n' - 'bar,2\n' - ' \n' - '\t \n', + 'file': b'name,Some Value\n' + b'foo,1\n' + b'\n' + b'bar,2\n' + b' \n' + b'\t \n', 'file_type': 'text/csv' }) data, fields = import_wizard._convert_import_data( @@ -475,8 +474,8 @@ class test_convert_import_data(TransactionCase): def test_nofield(self): import_wizard = self.env['base_import.import'].create({ 'res_model': 'base_import.tests.models.preview', - 'file': 'name,Some Value,Counter\n' - 'foo,1,2\n', + 'file': b'name,Some Value,Counter\n' + b'foo,1,2\n', 'file_type': 'text/csv' }) @@ -485,8 +484,8 @@ class test_convert_import_data(TransactionCase): def test_falsefields(self): import_wizard = self.env['base_import.import'].create({ 'res_model': 'base_import.tests.models.preview', - 'file': 'name,Some Value,Counter\n' - 'foo,1,2\n', + 'file': b'name,Some Value,Counter\n' + b'foo,1,2\n', 'file_type': 'text/csv' }) @@ -501,11 +500,11 @@ class test_convert_import_data(TransactionCase): Ensure importing keep newlines """ output = io.BytesIO() - writer = csv.writer(output, quoting=csv.QUOTE_ALL) + writer = pycompat.csv_writer(output, quoting=1) - data_row = ["\tfoo\n\tbar", " \"hello\" \n\n 'world' "] + data_row = [u"\tfoo\n\tbar", u" \"hello\" \n\n 'world' "] - writer.writerow(["name", "Some Value"]) + writer.writerow([u"name", u"Some Value"]) writer.writerow(data_row) import_wizard = self.env['base_import.import'].create({ @@ -527,16 +526,15 @@ class test_failures(TransactionCase): Ensure big fields (e.g. b64-encoded image data) can be imported and we're not hitting limits of the default CSV parser config """ - import csv, cStringIO from PIL import Image im = Image.new('RGB', (1920, 1080)) - fout = cStringIO.StringIO() + fout = io.BytesIO() - writer = csv.writer(fout, dialect=None) + writer = pycompat.csv_writer(fout, dialect=None) writer.writerows([ - ['name', 'db_datas'], - ['foo', im.tobytes().encode('base64')] + [u'name', u'db_datas'], + [u'foo', base64.b64encode(im.tobytes()).decode('ascii')] ]) import_wizard = self.env['base_import.import'].create({ diff --git a/addons/base_import_module/models/ir_module.py b/addons/base_import_module/models/ir_module.py index ec94bc22d27..5bad5ea53eb 100644 --- a/addons/base_import_module/models/ir_module.py +++ b/addons/base_import_module/models/ir_module.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import base64 import logging import os import sys @@ -8,7 +9,7 @@ from os.path import join as opj from odoo import api, fields, models, _ from odoo.exceptions import UserError from odoo.modules import load_information_from_description_file -from odoo.tools import convert_file, exception_to_unicode, pycompat +from odoo.tools import convert_file, exception_to_unicode from odoo.tools.osutil import tempdir _logger = logging.getLogger(__name__) @@ -63,8 +64,8 @@ class IrModule(models.Model): for root, dirs, files in os.walk(path_static): for static_file in files: full_path = opj(root, static_file) - with open(full_path, 'r') as fp: - data = fp.read().encode('base64') + with open(full_path, 'rb') as fp: + data = base64.b64encode(fp.read()) url_path = '/%s%s' % (module, full_path.split(path)[1].replace(os.path.sep, '/')) url_path = url_path.decode(sys.getfilesystemencoding()) filename = os.path.split(url_path)[1] @@ -118,6 +119,6 @@ class IrModule(models.Model): finally: addons.module.ad_paths.remove(module_dir) r = ["Successfully imported module '%s'" % mod for mod in success] - for mod, error in pycompat.items(errors): + for mod, error in errors.items(): r.append("Error while importing module '%s': %r" % (mod, error)) return '\n'.join(r), module_names diff --git a/addons/board/controllers/main.py b/addons/board/controllers/main.py index 2ba236ea109..ebd9b568583 100644 --- a/addons/board/controllers/main.py +++ b/addons/board/controllers/main.py @@ -29,7 +29,7 @@ class Board(Controller): 'domain': str(domain) }) column.insert(0, new_action) - arch = ElementTree.tostring(xml, 'utf-8') + arch = ElementTree.tostring(xml, encoding='unicode') request.env['ir.ui.view.custom'].create({ 'user_id': request.session.uid, 'ref_id': view_id, diff --git a/addons/board/models/board.py b/addons/board/models/board.py index 3acd277826a..ebbbec74e2c 100644 --- a/addons/board/models/board.py +++ b/addons/board/models/board.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, models +from odoo.tools import pycompat class Board(models.AbstractModel): @@ -41,13 +42,8 @@ class Board(models.AbstractModel): if child.tag == 'action' and child.get('invisible'): node.remove(child) else: - child = remove_unauthorized_children(child) + remove_unauthorized_children(child) return node - def encode(s): - if isinstance(s, unicode): - return s.encode('utf8') - return s - - archnode = etree.fromstring(encode(arch)) - return etree.tostring(remove_unauthorized_children(archnode), pretty_print=True) + archnode = etree.fromstring(arch) + return etree.tostring(remove_unauthorized_children(archnode), pretty_print=True, encoding='unicode') diff --git a/addons/bus/controllers/main.py b/addons/bus/controllers/main.py index 40118764751..4e82116e7c7 100644 --- a/addons/bus/controllers/main.py +++ b/addons/bus/controllers/main.py @@ -4,6 +4,8 @@ from odoo import exceptions from odoo.http import Controller, request, route from odoo.addons.bus.models.bus import dispatch +from odoo.tools import pycompat + class BusController(Controller): """ Examples: @@ -14,7 +16,7 @@ class BusController(Controller): @route('/longpolling/send', type="json", auth="public") def send(self, channel, message): - if not isinstance(channel, basestring): + if not isinstance(channel, pycompat.string_types): raise Exception("bus.Bus only string channels are allowed.") return request.env['bus.bus'].sendone(channel, message) @@ -33,7 +35,7 @@ class BusController(Controller): options = {} if not dispatch: raise Exception("bus.Bus unavailable") - if [c for c in channels if not isinstance(c, basestring)]: + if [c for c in channels if not isinstance(c, pycompat.string_types)]: raise Exception("bus.Bus only string channels are allowed.") if request.registry.in_test_mode(): raise exceptions.UserError("bus.Bus not available in test mode") diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 9c8b088aeb1..e80a2727282 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 import babel.dates import collections @@ -33,7 +34,7 @@ def calendar_id2real_id(calendar_id=None, with_date=False): :param with_date: if a value is passed to this param it will return dates based on value of withdate + calendar_id :return: real event id """ - if calendar_id and isinstance(calendar_id, (basestring)): + if calendar_id and isinstance(calendar_id, pycompat.string_types): res = [bit for bit in calendar_id.split('-') if bit] if len(res) == 2: real_id = res[0] @@ -47,7 +48,7 @@ def calendar_id2real_id(calendar_id=None, with_date=False): def get_real_ids(ids): - if isinstance(ids, (basestring, pycompat.integer_types)): + if isinstance(ids, (pycompat.string_types, pycompat.integer_types)): return calendar_id2real_id(ids) if isinstance(ids, (list, tuple)): @@ -173,6 +174,7 @@ class Attendee(models.Model): mails_to_send = self.env['mail.mail'] for attendee in self: if attendee.email or attendee.partner_id.email: + # FIXME: is ics_file text or bytes? ics_file = ics_files.get(attendee.event_id.id) mail_id = invitation_template.send_mail(attendee.id) @@ -181,7 +183,7 @@ class Attendee(models.Model): vals['attachment_ids'] = [(0, 0, {'name': 'invitation.ics', 'mimetype': 'text/calendar', 'datas_fname': 'invitation.ics', - 'datas': str(ics_file).encode('base64')})] + 'datas': base64.b64encode(ics_file)})] vals['model'] = None # We don't want to have the mail in the tchatter while in queue! vals['res_id'] = False current_mail = self.env['mail.mail'].browse(mail_id) @@ -468,7 +470,7 @@ class Alarm(models.Model): name = fields.Char('Name', required=True) type = fields.Selection([('notification', 'Notification'), ('email', 'Email')], 'Type', required=True, default='email') duration = fields.Integer('Remind Before', required=True, default=1) - interval = fields.Selection(list(pycompat.items(_interval_selection)), 'Unit', required=True, default='hours') + interval = fields.Selection(list(_interval_selection.items()), 'Unit', required=True, default='hours') duration_minutes = fields.Integer('Duration in minutes', compute='_compute_duration_minutes', store=True, help="Duration in minutes") @api.onchange('duration', 'interval') @@ -630,8 +632,8 @@ class Meeting(models.Model): } # formats will be used for str{f,p}time() which do not support unicode in Python 2, coerce to str - format_date = lang_params.get("date_format", '%B-%d-%Y').encode('utf-8') - format_time = lang_params.get("time_format", '%I-%M %p').encode('utf-8') + format_date = pycompat.to_native(lang_params.get("date_format", '%B-%d-%Y')) + format_time = pycompat.to_native(lang_params.get("time_format", '%I-%M %p')) return (format_date, format_time) @api.model @@ -917,11 +919,11 @@ class Meeting(models.Model): elif interval == 'minutes': delta = timedelta(minutes=duration) trigger.value = delta - valarm.add('DESCRIPTION').value = alarm.name or 'Odoo' + valarm.add('DESCRIPTION').value = alarm.name or u'Odoo' for attendee in meeting.attendee_ids: attendee_add = event.add('attendee') - attendee_add.value = 'MAILTO:' + (attendee.email or '') - result[meeting.id] = cal.serialize() + attendee_add.value = u'MAILTO:' + (attendee.email or u'') + result[meeting.id] = cal.serialize().encode('utf-8') return result @@ -1058,7 +1060,7 @@ class Meeting(models.Model): pile.reverse() new_pile = [] for item in pile: - if not isinstance(item, basestring): + if not isinstance(item, pycompat.string_types): res = item elif str(item) == str('&'): first = new_pile.pop() @@ -1207,7 +1209,7 @@ class Meeting(models.Model): if interval == 'day': # Day number (1-31) - result = unicode(date.day) + result = pycompat.text_type(date.day) elif interval == 'month': # Localized month name and year @@ -1219,6 +1221,7 @@ class Meeting(models.Model): elif interval == 'time': # Localized time + # FIXME: formats are specifically encoded to bytes, maybe use babel? dummy, format_time = self._get_date_formats() result = tools.ustr(date.strftime(format_time + " %Z")) @@ -1289,7 +1292,7 @@ class Meeting(models.Model): @api.multi def _get_message_unread(self): id_map = {x: calendar_id2real_id(x) for x in self.ids} - real = self.browse(set(pycompat.values(id_map))) + real = self.browse(set(id_map.values())) super(Meeting, real)._get_message_unread() for event in self: if event.id == id_map[event.id]: @@ -1301,7 +1304,7 @@ class Meeting(models.Model): @api.multi def _get_message_needaction(self): id_map = {x: calendar_id2real_id(x) for x in self.ids} - real = self.browse(set(pycompat.values(id_map))) + real = self.browse(set(id_map.values())) super(Meeting, real)._get_message_needaction() for event in self: if event.id == id_map[event.id]: @@ -1314,7 +1317,7 @@ class Meeting(models.Model): @api.returns('self', lambda value: value.id) def message_post(self, **kwargs): thread_id = self.id - if isinstance(self.id, basestring): + if isinstance(self.id, pycompat.string_types): thread_id = get_real_ids(self.id) if self.env.context.get('default_date'): context = dict(self.env.context) @@ -1350,7 +1353,7 @@ class Meeting(models.Model): for arg in args: if arg[0] == 'id': for n, calendar_id in enumerate(arg[2]): - if isinstance(calendar_id, basestring): + if isinstance(calendar_id, pycompat.string_types): arg[2][n] = calendar_id.split('-')[0] return super(Meeting, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) @@ -1388,7 +1391,7 @@ class Meeting(models.Model): # set end_date for calendar searching if any(field in values for field in ['recurrency', 'end_type', 'count', 'rrule_type', 'start', 'stop']): for real_meeting in real_meetings: - if real_meeting.recurrency and real_meeting.end_type in ('count', unicode('count')): + if real_meeting.recurrency and real_meeting.end_type == u'count': final_date = real_meeting._get_recurrency_end_date() super(Meeting, real_meeting).write({'final_date': final_date}) @@ -1472,7 +1475,7 @@ class Meeting(models.Model): for calendar_id, real_id in select: res = real_data[real_id].copy() ls = calendar_id2real_id(calendar_id, with_date=res and res.get('duration', 0) > 0 and res.get('duration') or 1) - if not isinstance(ls, (basestring, pycompat.integer_types)) and len(ls) >= 2: + if not isinstance(ls, (pycompat.string_types, pycompat.integer_types)) and len(ls) >= 2: res['start'] = ls[1] res['stop'] = ls[2] diff --git a/addons/calendar/models/ir_attachment.py b/addons/calendar/models/ir_attachment.py index c98242db7e7..ae734ec59f4 100644 --- a/addons/calendar/models/ir_attachment.py +++ b/addons/calendar/models/ir_attachment.py @@ -5,6 +5,8 @@ from odoo import api, models from odoo.addons.calendar.models.calendar import get_real_ids +from odoo.tools import pycompat + class Attachment(models.Model): @@ -16,13 +18,13 @@ class Attachment(models.Model): args = list(args) if any([leaf for leaf in args if leaf[0] == "res_model" and leaf[2] == 'calendar.event']): for index in range(len(args)): - if args[index][0] == "res_id" and isinstance(args[index][2], basestring): + if args[index][0] == "res_id" and isinstance(args[index][2], pycompat.string_types): args[index] = (args[index][0], args[index][1], get_real_ids(args[index][2])) return super(Attachment, self).search(args, offset=offset, limit=limit, order=order, count=count) @api.multi def write(self, vals): """ When posting an attachment (new or not), convert the virtual ids in real ids. """ - if isinstance(vals.get('res_id'), basestring): + if isinstance(vals.get('res_id'), pycompat.string_types): vals['res_id'] = get_real_ids(vals.get('res_id')) return super(Attachment, self).write(vals) diff --git a/addons/calendar/models/mail_message.py b/addons/calendar/models/mail_message.py index 0dc4d24e364..26be5080ab5 100644 --- a/addons/calendar/models/mail_message.py +++ b/addons/calendar/models/mail_message.py @@ -5,6 +5,8 @@ from odoo import api, models from odoo.addons.calendar.models.calendar import get_real_ids +from odoo.tools import pycompat + class Message(models.Model): @@ -16,7 +18,7 @@ class Message(models.Model): args = list(args) for index in range(len(args)): if args[index][0] == "res_id": - if isinstance(args[index][2], basestring): + if isinstance(args[index][2], pycompat.string_types): args[index] = (args[index][0], args[index][1], get_real_ids(args[index][2])) elif isinstance(args[index][2], list): args[index] = (args[index][0], args[index][1], [get_real_ids(x) for x in args[index][2]]) diff --git a/addons/crm/wizard/base_partner_merge.py b/addons/crm/wizard/base_partner_merge.py index 7751e0106ed..a91b9ec3dc1 100644 --- a/addons/crm/wizard/base_partner_merge.py +++ b/addons/crm/wizard/base_partner_merge.py @@ -2,56 +2,18 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from ast import literal_eval -from email.utils import parseaddr import functools -try: - from html.entities import entitydefs -except ImportError: - from htmlentitydefs import entitydefs # pylint: disable=deprecated-module import itertools import logging -import operator import psycopg2 -import re - -# Validation Library https://pypi.python.org/pypi/validate_email/1.1 -from .validate_email import validate_email from odoo import api, fields, models from odoo import SUPERUSER_ID, _ from odoo.exceptions import ValidationError, UserError -from odoo.tools import mute_logger, pycompat +from odoo.tools import mute_logger _logger = logging.getLogger('base.partner.merge') - -# http://www.php2python.com/wiki/function.html-entity-decode/ -def html_entity_decode_char(m, defs=entitydefs): - try: - return defs[m.group(1)] - except KeyError: - return m.group(0) - - -def html_entity_decode(string): - pattern = re.compile("&(\w+?);") - return pattern.sub(html_entity_decode_char, string) - - -def sanitize_email(email): - assert isinstance(email, basestring) and email - - result = re.subn(r';|/|:', ',', html_entity_decode(email or ''))[0].split(',') - - emails = [parseaddr(email)[1] - for item in result - for email in item.split()] - - return [email.lower() - for email in emails - if validate_email(email)] - - class MergePartnerLine(models.TransientModel): _name = 'base.partner.merge.line' @@ -271,7 +233,7 @@ class MergePartnerAutomatic(models.TransientModel): return item # get all fields that are not computed or x2many values = dict() - for column, field in pycompat.items(model_fields): + for column, field in model_fields.items(): if field.type not in ('many2many', 'one2many') and field.compute is None: for item in itertools.chain(src_partners, [dst_partner]): if item[column]: @@ -409,7 +371,7 @@ class MergePartnerAutomatic(models.TransientModel): """ return any( self.env[model].search_count([(field, 'in', aggr_ids)]) - for model, field in pycompat.items(models) + for model, field in models.items() ) @api.model diff --git a/addons/crm/wizard/validate_email.py b/addons/crm/wizard/validate_email.py deleted file mode 100644 index 38de5743fca..00000000000 --- a/addons/crm/wizard/validate_email.py +++ /dev/null @@ -1,123 +0,0 @@ -# RFC 2822 - style email validation for Python -# (c) 2012 Syrus Akbary <me@syrusakbary.com> -# Extended from (c) 2011 Noel Bush <noel@aitools.org> -# for support of mx and user check -# This code is made available to you under the GNU LGPL v3. -# -# This module provides a single method, valid_email_address(), -# which returns True or False to indicate whether a given address -# is valid according to the 'addr-spec' part of the specification -# given in RFC 2822. Ideally, we would like to find this -# in some other library, already thoroughly tested and well- -# maintained. The standard Python library email.utils -# contains a parse_addr() function, but it is not sufficient -# to detect many malformed addresses. -# -# This implementation aims to be faithful to the RFC, with the -# exception of a circular definition (see comments below), and -# with the omission of the pattern components marked as "obsolete". - -import re -import smtplib -import socket - -try: - import DNS - ServerError = DNS.ServerError -except: - DNS = None - class ServerError(Exception): pass -# All we are really doing is comparing the input string to one -# gigantic regular expression. But building that regexp, and -# ensuring its correctness, is made much easier by assembling it -# from the "tokens" defined by the RFC. Each of these tokens is -# tested in the accompanying unit test file. -# -# The section of RFC 2822 from which each pattern component is -# derived is given in an accompanying comment. -# -# (To make things simple, every string below is given as 'raw', -# even when it's not strictly necessary. This way we don't forget -# when it is necessary.) -# -WSP = r'[ \t]' # see 2.2.2. Structured Header Field Bodies -CRLF = r'(?:\r\n)' # see 2.2.3. Long Header Fields -NO_WS_CTL = r'\x01-\x08\x0b\x0c\x0f-\x1f\x7f' # see 3.2.1. Primitive Tokens -QUOTED_PAIR = r'(?:\\.)' # see 3.2.2. Quoted characters -FWS = r'(?:(?:' + WSP + r'*' + CRLF + r')?' + \ - WSP + r'+)' # see 3.2.3. Folding white space and comments -CTEXT = r'[' + NO_WS_CTL + \ - r'\x21-\x27\x2a-\x5b\x5d-\x7e]' # see 3.2.3 -CCONTENT = r'(?:' + CTEXT + r'|' + \ - QUOTED_PAIR + r')' # see 3.2.3 (NB: The RFC includes COMMENT here - # as well, but that would be circular.) -COMMENT = r'\((?:' + FWS + r'?' + CCONTENT + \ - r')*' + FWS + r'?\)' # see 3.2.3 -CFWS = r'(?:' + FWS + r'?' + COMMENT + ')*(?:' + \ - FWS + '?' + COMMENT + '|' + FWS + ')' # see 3.2.3 -ATEXT = r'[\w!#$%&\'\*\+\-/=\?\^`\{\|\}~]' # see 3.2.4. Atom -ATOM = CFWS + r'?' + ATEXT + r'+' + CFWS + r'?' # see 3.2.4 -DOT_ATOM_TEXT = ATEXT + r'+(?:\.' + ATEXT + r'+)*' # see 3.2.4 -DOT_ATOM = CFWS + r'?' + DOT_ATOM_TEXT + CFWS + r'?' # see 3.2.4 -QTEXT = r'[' + NO_WS_CTL + \ - r'\x21\x23-\x5b\x5d-\x7e]' # see 3.2.5. Quoted strings -QCONTENT = r'(?:' + QTEXT + r'|' + \ - QUOTED_PAIR + r')' # see 3.2.5 -QUOTED_STRING = CFWS + r'?' + r'"(?:' + FWS + \ - r'?' + QCONTENT + r')*' + FWS + \ - r'?' + r'"' + CFWS + r'?' -LOCAL_PART = r'(?:' + DOT_ATOM + r'|' + \ - QUOTED_STRING + r')' # see 3.4.1. Addr-spec specification -DTEXT = r'[' + NO_WS_CTL + r'\x21-\x5a\x5e-\x7e]' # see 3.4.1 -DCONTENT = r'(?:' + DTEXT + r'|' + \ - QUOTED_PAIR + r')' # see 3.4.1 -DOMAIN_LITERAL = CFWS + r'?' + r'\[' + \ - r'(?:' + FWS + r'?' + DCONTENT + \ - r')*' + FWS + r'?\]' + CFWS + r'?' # see 3.4.1 -DOMAIN = r'(?:' + DOT_ATOM + r'|' + \ - DOMAIN_LITERAL + r')' # see 3.4.1 -ADDR_SPEC = LOCAL_PART + r'@' + DOMAIN # see 3.4.1 - -# A valid address will match exactly the 3.4.1 addr-spec. -VALID_ADDRESS_REGEXP = '^' + ADDR_SPEC + '$' - -def validate_email(email, check_mx=False,verify=False): - - """Indicate whether the given string is a valid email address - according to the 'addr-spec' portion of RFC 2822 (see section - 3.4.1). Parts of the spec that are marked obsolete are *not* - included in this test, and certain arcane constructions that - depend on circular definitions in the spec may not pass, but in - general this should correctly identify any email address likely - to be in use as of 2011.""" - try: - assert re.match(VALID_ADDRESS_REGEXP, email) is not None - check_mx |= verify - if check_mx: - if not DNS: raise Exception('For check the mx records or check if the email exists you must have installed pyDNS python package') - DNS.DiscoverNameServers() - hostname = email[email.find('@')+1:] - mx_hosts = DNS.mxlookup(hostname) - for mx in mx_hosts: - try: - smtp = smtplib.SMTP() - smtp.connect(mx[1]) - if not verify: return True - status, _ = smtp.helo() - if status != 250: continue - smtp.mail('') - status, _ = smtp.rcpt(email) - if status != 250: return False - break - except smtplib.SMTPServerDisconnected: #Server not permits verify user - break - except smtplib.SMTPConnectError: - continue - except (AssertionError, ServerError): - return False - return True - -# import sys - -# sys.modules[__name__],sys.modules['validate_email_module'] = validate_email,sys.modules[__name__] -# from validate_email_module import * diff --git a/addons/document/models/ir_attachment.py b/addons/document/models/ir_attachment.py index 53ae1f1f463..2bc6aee234d 100644 --- a/addons/document/models/ir_attachment.py +++ b/addons/document/models/ir_attachment.py @@ -1,30 +1,16 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import io import logging import PyPDF2 import xml.dom.minidom import zipfile -from StringIO import StringIO - from odoo import api, models _logger = logging.getLogger(__name__) FTYPES = ['docx', 'pptx', 'xlsx', 'opendoc', 'pdf'] -# Keep function in case it is necessary to do toUnicode(buf.encode('ascii', 'replace')) -def toUnicode(s): - try: - return s.decode('utf-8') - except UnicodeError: - try: - return s.decode('latin') - except UnicodeError: - try: - return s.encode('ascii') - except UnicodeError: - return s - def textToString(element): buff = u"" for node in element.childNodes: @@ -41,7 +27,7 @@ class IrAttachment(models.Model): def _index_docx(self, bin_data): '''Index Microsoft .docx documents''' buf = u"" - f = StringIO(bin_data) + f = io.BytesIO(bin_data) if zipfile.is_zipfile(f): try: zf = zipfile.ZipFile(f) @@ -57,7 +43,7 @@ class IrAttachment(models.Model): '''Index Microsoft .pptx documents''' buf = u"" - f = StringIO(bin_data) + f = io.BytesIO(bin_data) if zipfile.is_zipfile(f): try: zf = zipfile.ZipFile(f) @@ -75,7 +61,7 @@ class IrAttachment(models.Model): '''Index Microsoft .xlsx documents''' buf = u"" - f = StringIO(bin_data) + f = io.BytesIO(bin_data) if zipfile.is_zipfile(f): try: zf = zipfile.ZipFile(f) @@ -91,7 +77,7 @@ class IrAttachment(models.Model): '''Index OpenDocument documents (.odt, .ods...)''' buf = u"" - f = StringIO(bin_data) + f = io.BytesIO(bin_data) if zipfile.is_zipfile(f): try: zf = zipfile.ZipFile(f) @@ -107,8 +93,8 @@ class IrAttachment(models.Model): '''Index PDF documents''' buf = u"" - if bin_data.startswith('%PDF-'): - f = StringIO(bin_data) + if bin_data.startswith(b'%PDF-'): + f = io.BytesIO(bin_data) try: pdf = PyPDF2.PdfFileReader(f, overwriteWarnings=False) for page in pdf.pages: diff --git a/addons/event/models/event.py b/addons/event/models/event.py index 95310a67122..c5f7f43915a 100644 --- a/addons/event/models/event.py +++ b/addons/event/models/event.py @@ -5,7 +5,6 @@ import pytz from odoo import _, api, fields, models from odoo.addons.mail.models.mail_template import format_tz from odoo.exceptions import AccessError, UserError, ValidationError -from odoo.tools import pycompat from odoo.tools.translate import html_translate from dateutil.relativedelta import relativedelta @@ -389,7 +388,7 @@ class EventRegistration(models.Model): 'partner_id': partner_id.id, 'event_id': event_id and event_id.id or False, } - data.update({key: value for key, value in pycompat.items(registration) if key in self._fields}) + data.update({key: value for key, value in registration.items() if key in self._fields}) return data @api.one diff --git a/addons/fleet/models/fleet_vehicle_cost.py b/addons/fleet/models/fleet_vehicle_cost.py index 2c347c09783..7ca677d137b 100644 --- a/addons/fleet/models/fleet_vehicle_cost.py +++ b/addons/fleet/models/fleet_vehicle_cost.py @@ -6,8 +6,6 @@ from odoo.exceptions import UserError from dateutil.relativedelta import relativedelta -from odoo.tools import pycompat - class FleetVehicleCost(models.Model): _name = 'fleet.vehicle.cost' @@ -272,7 +270,7 @@ class FleetVehicleLogContract(models.Model): res[contract.vehicle_id.id] = 1 Vehicle = self.env['fleet.vehicle'] - for vehicle, value in pycompat.items(res): + for vehicle, value in res.items(): Vehicle.browse(vehicle).message_post(body=_('%s contract(s) will expire soon and should be renewed and/or closed!') % value) nearly_expired_contracts.write({'state': 'diesoon'}) diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py index 8a1b02d65fe..140576da889 100644 --- a/addons/gamification/models/challenge.py +++ b/addons/gamification/models/challenge.py @@ -503,8 +503,10 @@ class Challenge(models.Model): line_data['own_goal_id'] = False, line_data['goals'] = [] - - goals = Goals.search(domain, order="completeness desc, current desc") + if line.condition=='higher': + goals = Goals.search(domain, order="completeness desc, current desc") + else: + goals = Goals.search(domain, order="completeness desc, current asc") for ranking, goal in enumerate(goals): if user and goal.user_id == user: line_data['own_goal_id'] = goal.id diff --git a/addons/gamification/models/goal.py b/addons/gamification/models/goal.py index 56c447dd9d4..3c062401662 100644 --- a/addons/gamification/models/goal.py +++ b/addons/gamification/models/goal.py @@ -262,7 +262,7 @@ class Goal(models.Model): for goal in self: goals_by_definition.setdefault(goal.definition_id, []).append(goal) - for definition, goals in pycompat.items(goals_by_definition): + for definition, goals in goals_by_definition.items(): goals_to_write = {} if definition.computation_mode == 'manually': for goal in goals: @@ -307,9 +307,9 @@ class Goal(models.Model): subqueries.setdefault((start_date, end_date), {}).update({goal.id:safe_eval(definition.batch_user_expression, {'user': goal.user_id})}) # the global query should be split by time periods (especially for recurrent goals) - for (start_date, end_date), query_goals in pycompat.items(subqueries): + for (start_date, end_date), query_goals in subqueries.items(): subquery_domain = list(general_domain) - subquery_domain.append((field_name, 'in', list(set(pycompat.values(query_goals))))) + subquery_domain.append((field_name, 'in', list(set(query_goals.values())))) if start_date: subquery_domain.append((field_date_name, '>=', start_date)) if end_date: @@ -353,7 +353,7 @@ class Goal(models.Model): goals_to_write.update(goal._get_write_values(new_value)) - for goal, values in pycompat.items(goals_to_write): + for goal, values in goals_to_write.items(): if not values: continue goal.write(values) diff --git a/addons/google_account/models/google_service.py b/addons/google_account/models/google_service.py index 44972435da5..040521a08bd 100644 --- a/addons/google_account/models/google_service.py +++ b/addons/google_account/models/google_service.py @@ -9,6 +9,7 @@ import requests from werkzeug import urls from odoo import api, fields, models, registry, _ +from odoo.exceptions import UserError from odoo.http import request @@ -125,6 +126,9 @@ class GoogleService(models.TransientModel): client_id = get_param('google_%s_client_id' % (service,), default=False) client_secret = get_param('google_%s_client_secret' % (service,), default=False) + if not client_id or not client_secret: + raise UserError(_("The account for the Google service '%s' is not configured") % service) + headers = {"content-type": "application/x-www-form-urlencoded"} data = { 'refresh_token': refresh_token, diff --git a/addons/google_calendar/models/google_calendar.py b/addons/google_calendar/models/google_calendar.py index 0303dbe5154..ac91835f530 100644 --- a/addons/google_calendar/models/google_calendar.py +++ b/addons/google_calendar/models/google_calendar.py @@ -12,7 +12,7 @@ import pytz from werkzeug import urls from odoo import api, fields, models, tools, _ -from odoo.tools import exception_to_unicode, pycompat +from odoo.tools import exception_to_unicode _logger = logging.getLogger(__name__) @@ -25,15 +25,13 @@ class Meta(type): """ This Meta class allow to define class as a structure, and so instancied variable in __init__ to avoid to have side effect alike 'static' variable """ def __new__(typ, name, parents, attrs): - methods = {k: v for k, v in pycompat.items(attrs) - if callable(v)} - attrs = {k: v for k, v in pycompat.items(attrs) - if not callable(v)} + methods = {k: v for k, v in attrs.items() if callable(v)} + attrs = {k: v for k, v in attrs.items() if not callable(v)} def init(self, **kw): - for key, val in pycompat.items(attrs): + for key, val in attrs.items(): setattr(self, key, val) - for key, val in pycompat.items(kw): + for key, val in kw.items(): assert key in attrs setattr(self, key, val) @@ -161,7 +159,7 @@ class SyncOperation(object): def __init__(self, src, info, **kw): self.src = src self.info = info - for key, val in pycompat.items(kw): + for key, val in kw.items(): setattr(self, key, val) def __str__(self): @@ -689,7 +687,7 @@ class GoogleCalendar(models.AbstractModel): my_google_attendees = CalendarAttendee.with_context(context_novirtual).search([ ('partner_id', '=', my_partner_id), - ('google_internal_event_id', 'in', pycompat.keys(all_event_from_google)) + ('google_internal_event_id', 'in', all_event_from_google.keys()) ]) my_google_att_ids = my_google_attendees.ids @@ -753,7 +751,7 @@ class GoogleCalendar(models.AbstractModel): ev_to_sync.OE.status = event.active ev_to_sync.OE.synchro = att.oe_synchro_date - for event in pycompat.values(all_event_from_google): + for event in all_event_from_google.values(): event_id = event.get('id') base_event_id = event_id.rsplit('_', 1)[0] @@ -788,7 +786,7 @@ class GoogleCalendar(models.AbstractModel): # DO ACTION # ###################### for base_event in event_to_synchronize: - event_to_synchronize[base_event] = sorted(pycompat.items(event_to_synchronize[base_event]), key=operator.itemgetter(0)) + event_to_synchronize[base_event] = sorted(event_to_synchronize[base_event].items(), key=operator.itemgetter(0)) for current_event in event_to_synchronize[base_event]: self.env.cr.commit() event = current_event[1] # event is an Sync Event ! diff --git a/addons/hr/models/hr.py b/addons/hr/models/hr.py index dc49f6f2022..654cc3543a1 100644 --- a/addons/hr/models/hr.py +++ b/addons/hr/models/hr.py @@ -1,13 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import logging from odoo import api, fields, models from odoo import tools, _ from odoo.exceptions import ValidationError from odoo.modules.module import get_module_resource -from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -103,7 +102,7 @@ class Employee(models.Model): @api.model def _default_image(self): image_path = get_module_resource('hr', 'static/src/img', 'default_image.png') - return tools.image_resize_image_big(open(image_path, 'rb').read().encode('base64')) + return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read())) # resource and user name = fields.Char(related='resource_id.name', store=True, oldname='name_related') @@ -254,7 +253,7 @@ class Employee(models.Model): if auto_follow_fields is None: auto_follow_fields = ['user_id'] user_field_lst = [] - for name, field in pycompat.items(self._fields): + for name, field in self._fields.items(): if name in auto_follow_fields and name in updated_fields and field.comodel_name == 'res.users': user_field_lst.append(name) return user_field_lst diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 6b23dc91b1e..dca8da92fb2 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -5,7 +5,7 @@ import re from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.tools import email_split, float_is_zero, pycompat +from odoo.tools import email_split, float_is_zero from odoo.addons import decimal_precision as dp @@ -243,7 +243,7 @@ class HrExpense(models.Model): expense.sheet_id.write({'account_move_id': move.id}) if expense.payment_mode == 'company_account': expense.sheet_id.paid_expense_sheets() - for move in pycompat.values(move_group_by_sheet): + for move in move_group_by_sheet.values(): move.post() return True diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index fd30b7a1b23..2f310c3456e 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -11,8 +11,6 @@ import babel from odoo import api, fields, models, tools, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError, ValidationError -from odoo.tools import pycompat - class HrPayslip(models.Model): _name = 'hr.payslip' @@ -205,7 +203,7 @@ class HrPayslip(models.Model): } res.append(attendances) - res.extend(pycompat.values(leaves)) + res.extend(leaves.values()) return res @api.model @@ -369,7 +367,7 @@ class HrPayslip(models.Model): #blacklist this rule and its children blacklist += [id for id, seq in rule._recursive_search_of_rules()] - return [value for code, value in pycompat.items(result_dict)] + return list(result_dict.values()) # YTI TODO To rename. This method is not really an onchange, as it is not in any view # employee_id and contract_id could be browse records diff --git a/addons/hr_payroll/report/report_payslip_details.py b/addons/hr_payroll/report/report_payslip_details.py index 23043d2edb9..ffd5b9d913d 100644 --- a/addons/hr_payroll/report/report_payslip_details.py +++ b/addons/hr_payroll/report/report_payslip_details.py @@ -2,8 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, models -from odoo.tools import pycompat - class PayslipDetailsReport(models.AbstractModel): _name = 'report.hr_payroll.report_payslipdetails' @@ -38,9 +36,9 @@ class PayslipDetailsReport(models.AbstractModel): result.setdefault(x[2], {}) result[x[2]].setdefault(x[1], []) result[x[2]][x[1]].append(x[0]) - for payslip_id, lines_dict in pycompat.items(result): + for payslip_id, lines_dict in result.items(): res.setdefault(payslip_id, []) - for rule_categ_id, line_ids in pycompat.items(lines_dict): + for rule_categ_id, line_ids in lines_dict.items(): rule_categories = RuleCateg.browse(rule_categ_id) lines = PayslipLine.browse(line_ids) level = 0 @@ -70,9 +68,9 @@ class PayslipDetailsReport(models.AbstractModel): result.setdefault(line.slip_id.id, {}) result[line.slip_id.id].setdefault(line.register_id, line) result[line.slip_id.id][line.register_id] |= line - for payslip_id, lines_dict in pycompat.items(result): + for payslip_id, lines_dict in result.items(): res.setdefault(payslip_id, []) - for register, lines in pycompat.items(lines_dict): + for register, lines in lines_dict.items(): res[payslip_id].append({ 'register_name': register.name, 'total': sum(lines.mapped('total')), diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py index bf6a1fb8242..3a237e9f283 100644 --- a/addons/hr_recruitment/models/hr_recruitment.py +++ b/addons/hr_recruitment/models/hr_recruitment.py @@ -4,7 +4,6 @@ from datetime import datetime from odoo import api, fields, models, tools, SUPERUSER_ID -from odoo.tools import pycompat from odoo.tools.translate import _ from odoo.exceptions import UserError @@ -251,7 +250,7 @@ class Applicant(models.Model): self = self.with_context(default_department_id=vals.get('department_id')) if vals.get('job_id') or self._context.get('default_job_id'): job_id = vals.get('job_id') or self._context.get('default_job_id') - for key, value in pycompat.items(self._onchange_job_id_internal(job_id)['value']): + for key, value in self._onchange_job_id_internal(job_id)['value'].items(): if key not in vals: vals[key] = value if vals.get('user_id'): diff --git a/addons/hw_escpos/escpos/escpos.py b/addons/hw_escpos/escpos/escpos.py index e50537e0eeb..7159ac3b31e 100644 --- a/addons/hw_escpos/escpos/escpos.py +++ b/addons/hw_escpos/escpos/escpos.py @@ -12,6 +12,8 @@ from hashlib import md5 from PIL import Image from xml.etree import ElementTree as ET +from odoo.tools import pycompat + try: import jcconv except ImportError: @@ -27,7 +29,7 @@ from .exceptions import * def utfstr(stuff): """ converts stuff to string and does without failing if stuff is a utf8 string """ - if isinstance(stuff,basestring): + if isinstance(stuff,pycompat.string_types): return stuff else: return str(stuff) @@ -431,7 +433,7 @@ class Escpos: print('print_b64_img') - id = md5(img).digest() + id = md5(img.encode('utf-8')).digest() if id not in self.img_cache: print('not in cache') diff --git a/addons/hw_proxy/controllers/main.py b/addons/hw_proxy/controllers/main.py index 0521b72c89b..02f083110e7 100644 --- a/addons/hw_proxy/controllers/main.py +++ b/addons/hw_proxy/controllers/main.py @@ -5,6 +5,7 @@ from __future__ import print_function import logging import subprocess import time +import subprocess from threading import Lock @@ -23,7 +24,7 @@ BANNED_DEVICES = { } -# drivers modules must add to drivers an object with a get_status() method +# drivers modules must add to drivers an object with a get_status() method # so that 'status' can return the status of all active drivers drivers = {} @@ -108,7 +109,7 @@ class Proxy(http.Controller): if not (device_id in BANNED_DEVICES): resp += "<div class='device' data-device='"+device+"'>"+device_name+"</div>\n" count += 1 - + if count == 0: resp += "<div class='device'>No USB Device Found</div>" @@ -126,7 +127,7 @@ class Proxy(http.Controller): """ % subprocess.check_output('lsusb -v', shell=True) return request.make_response(resp,{ - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET', @@ -167,7 +168,7 @@ class Proxy(http.Controller): @http.route('/hw_proxy/payment_request', type='json', auth='none', cors='*') def payment_request(self, price): """ - The PoS will activate the method payment + The PoS will activate the method payment """ print("payment_request: price:%s" % price) return 'ok' @@ -175,7 +176,7 @@ class Proxy(http.Controller): @http.route('/hw_proxy/payment_status', type='json', auth='none', cors='*') def payment_status(self): print("payment_status") - return { 'status':'waiting' } + return { 'status':'waiting' } @http.route('/hw_proxy/payment_cancel', type='json', auth='none', cors='*') def payment_cancel(self): diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py index 1c72ba8725d..c2741fcef92 100644 --- a/addons/im_livechat/models/im_livechat_channel.py +++ b/addons/im_livechat/models/im_livechat_channel.py @@ -1,13 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import random import re from datetime import datetime, timedelta from odoo import api, fields, models, modules, tools -from odoo.tools import pycompat - class ImLivechatChannel(models.Model): """ Livechat Channel @@ -21,7 +19,7 @@ class ImLivechatChannel(models.Model): def _default_image(self): image_path = modules.get_module_resource('im_livechat', 'static/src/img', 'default.png') - return tools.image_resize_image_big(open(image_path, 'rb').read().encode('base64')) + return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read())) def _default_user_ids(self): return [(6, 0, [self._uid])] @@ -96,7 +94,7 @@ class ImLivechatChannel(models.Model): for record in self: dt = fields.Datetime.to_string(datetime.utcnow() - timedelta(days=7)) repartition = record.channel_ids.rating_get_grades([('create_date', '>=', dt)]) - total = sum(pycompat.values(repartition)) + total = sum(repartition.values()) if total > 0: happy = repartition['great'] record.rating_percentage_satisfaction = ((happy*100) / total) if happy > 0 else 0 diff --git a/addons/im_livechat/models/mail_channel.py b/addons/im_livechat/models/mail_channel.py index 32d66a0154c..c64bcd7108c 100644 --- a/addons/im_livechat/models/mail_channel.py +++ b/addons/im_livechat/models/mail_channel.py @@ -2,8 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ -from odoo.tools import pycompat - class ChannelPartner(models.Model): _inherit = 'mail.channel.partner' @@ -78,7 +76,7 @@ class MailChannel(models.Model): last_msg = self.env['mail.message'].search([("channel_ids", "in", [channel.id])], limit=1) if last_msg: channel_infos_dict[channel.id]['last_message_date'] = last_msg.date - return list(pycompat.values(channel_infos_dict)) + return list(channel_infos_dict.values()) @api.model def channel_fetch_slot(self): diff --git a/addons/l10n_be_intrastat/wizard/xml_decl.py b/addons/l10n_be_intrastat/wizard/xml_decl.py index f7d01db30d1..b5486d963c9 100644 --- a/addons/l10n_be_intrastat/wizard/xml_decl.py +++ b/addons/l10n_be_intrastat/wizard/xml_decl.py @@ -6,6 +6,7 @@ from xml.etree import ElementTree as ET from collections import namedtuple from odoo import api, exceptions, fields, models, _ +from odoo.tools.pycompat import text_type INTRASTAT_XMLNS = 'http://www.onegate.eu/2010-01-01' @@ -264,18 +265,18 @@ class XmlDeclaration(models.TransientModel): continue numlgn += 1 item = ET.SubElement(datas, 'Item') - self._set_Dim(item, 'EXSEQCODE', unicode(numlgn)) - self._set_Dim(item, 'EXTRF', unicode(linekey.EXTRF)) - self._set_Dim(item, 'EXCNT', unicode(linekey.EXCNT)) - self._set_Dim(item, 'EXTTA', unicode(linekey.EXTTA)) - self._set_Dim(item, 'EXREG', unicode(linekey.EXREG)) - self._set_Dim(item, 'EXTGO', unicode(linekey.EXGO)) + self._set_Dim(item, 'EXSEQCODE', text_type(numlgn)) + self._set_Dim(item, 'EXTRF', text_type(linekey.EXTRF)) + self._set_Dim(item, 'EXCNT', text_type(linekey.EXCNT)) + self._set_Dim(item, 'EXTTA', text_type(linekey.EXTTA)) + self._set_Dim(item, 'EXREG', text_type(linekey.EXREG)) + self._set_Dim(item, 'EXTGO', text_type(linekey.EXGO)) if extendedmode: - self._set_Dim(item, 'EXTPC', unicode(linekey.EXTPC)) - self._set_Dim(item, 'EXDELTRM', unicode(linekey.EXDELTRM)) - self._set_Dim(item, 'EXTXVAL', unicode(round(amounts[0], 0)).replace(".", ",")) - self._set_Dim(item, 'EXWEIGHT', unicode(round(amounts[1], 0)).replace(".", ",")) - self._set_Dim(item, 'EXUNITS', unicode(round(amounts[2], 0)).replace(".", ",")) + self._set_Dim(item, 'EXTPC', text_type(linekey.EXTPC)) + self._set_Dim(item, 'EXDELTRM', text_type(linekey.EXDELTRM)) + self._set_Dim(item, 'EXTXVAL', text_type(round(amounts[0], 0)).replace(".", ",")) + self._set_Dim(item, 'EXWEIGHT', text_type(round(amounts[1], 0)).replace(".", ",")) + self._set_Dim(item, 'EXUNITS', text_type(round(amounts[2], 0)).replace(".", ",")) if numlgn == 0: #no datas diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py index de968b64374..d78d57e6c66 100644 --- a/addons/l10n_fr_fec/wizard/account_fr_fec.py +++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py @@ -4,11 +4,11 @@ # Copyright (C) 2013-2015 Akretion (http://www.akretion.com) import base64 -import csv -import StringIO +import io from odoo import api, fields, models, _ from odoo.exceptions import Warning +from odoo.tools import pycompat class AccountFrFec(models.TransientModel): @@ -86,24 +86,24 @@ class AccountFrFec(models.TransientModel): # So it will be easier for the accountant to check the file before # sending it to the fiscal administration header = [ - 'JournalCode', # 0 - 'JournalLib', # 1 - 'EcritureNum', # 2 - 'EcritureDate', # 3 - 'CompteNum', # 4 - 'CompteLib', # 5 - 'CompAuxNum', # 6 We use partner.id - 'CompAuxLib', # 7 - 'PieceRef', # 8 - 'PieceDate', # 9 - 'EcritureLib', # 10 - 'Debit', # 11 - 'Credit', # 12 - 'EcritureLet', # 13 - 'DateLet', # 14 - 'ValidDate', # 15 - 'Montantdevise', # 16 - 'Idevise', # 17 + u'JournalCode', # 0 + u'JournalLib', # 1 + u'EcritureNum', # 2 + u'EcritureDate', # 3 + u'CompteNum', # 4 + u'CompteLib', # 5 + u'CompAuxNum', # 6 We use partner.id + u'CompAuxLib', # 7 + u'PieceRef', # 8 + u'PieceDate', # 9 + u'EcritureLib', # 10 + u'Debit', # 11 + u'Credit', # 12 + u'EcritureLet', # 13 + u'DateLet', # 14 + u'ValidDate', # 15 + u'Montantdevise', # 16 + u'Idevise', # 17 ] company = self.env.user.company_id @@ -114,8 +114,8 @@ class AccountFrFec(models.TransientModel): raise Warning( _("FEC is for French companies only !")) - fecfile = StringIO.StringIO() - w = csv.writer(fecfile, delimiter='|') + fecfile = io.BytesIO() + w = pycompat.csv_writer(fecfile, delimiter='|') w.writerow(header) # INITIAL BALANCE @@ -190,7 +190,7 @@ class AccountFrFec(models.TransientModel): else: listrow[11] = '0,00' listrow[12] = str(-listrow_amount).replace('.', ',') - w.writerow([s.encode("utf-8") for s in listrow]) + w.writerow(listrow) #if the unaffected earnings account wasn't in the selection yet: add it manually if (not unaffected_earnings_line and unaffected_earnings_results @@ -201,7 +201,7 @@ class AccountFrFec(models.TransientModel): if unaffected_earnings_account: unaffected_earnings_results[4] = unaffected_earnings_account.code unaffected_earnings_results[5] = unaffected_earnings_account.name - w.writerow([s.encode("utf-8") for s in unaffected_earnings_results]) + w.writerow(unaffected_earnings_results) # LINES sql_query = ''' @@ -266,8 +266,7 @@ class AccountFrFec(models.TransientModel): sql_query, (self.date_from, self.date_to, company.id)) for row in self._cr.fetchall(): - listrow = list(row) - w.writerow([s.encode("utf-8") for s in listrow]) + w.writerow(list(row)) siren = company.vat[4:13] end_date = self.date_to.replace('-', '') diff --git a/addons/l10n_in_hr_payroll/report/report_hr_yearly_salary_detail.py b/addons/l10n_in_hr_payroll/report/report_hr_yearly_salary_detail.py index 001a5a782f8..0d26efe9bf5 100644 --- a/addons/l10n_in_hr_payroll/report/report_hr_yearly_salary_detail.py +++ b/addons/l10n_in_hr_payroll/report/report_hr_yearly_salary_detail.py @@ -5,8 +5,6 @@ from datetime import date from odoo import api, models, _ from odoo.exceptions import UserError -from odoo.tools import pycompat - class EmployeesYearlySalaryReport(models.AbstractModel): _name = 'report.l10n_in_hr_payroll.report_hryearlysalary' @@ -99,7 +97,7 @@ class EmployeesYearlySalaryReport(models.AbstractModel): def salary_list(self, salaries): cat_salary_all = [] - for category_name, amount in pycompat.items(salaries): + for category_name, amount in salaries.items(): cat_salary = [] total = 0.0 cat_salary.append(category_name) diff --git a/addons/l10n_lu/scripts/tax2csv.py b/addons/l10n_lu/scripts/tax2csv.py index 9993272573f..23fec3c734c 100644 --- a/addons/l10n_lu/scripts/tax2csv.py +++ b/addons/l10n_lu/scripts/tax2csv.py @@ -1,18 +1,8 @@ from collections import OrderedDict -import csv import xlrd from odoo.tools import pycompat -def _e(s): - if type(s) is unicode: - return s.encode('utf8') - elif s is None: - return '' - else: - return str(s) - - def _is_true(s): return s not in ('F', 'False', 0, '', None, False) @@ -56,9 +46,9 @@ class LuxTaxGenerator: yield OrderedDict(pycompat.izip(keys, row)) def tax_codes_to_csv(self): - writer = csv.writer(open('account.tax.code.template-%s.csv' % + writer = pycompat.csv_writer(open('account.tax.code.template-%s.csv' % self.suffix, 'wb')) - tax_codes_iterator = self.iter_tax_codes + tax_codes_iterator = self.iter_tax_codes() keys = next(tax_codes_iterator) writer.writerow(keys) @@ -69,7 +59,7 @@ class LuxTaxGenerator: if tax_code in tax_codes: raise RuntimeError('duplicate tax code %s' % tax_code) tax_codes[tax_code] = row['id'] - writer.writerow(pycompat.imap(_e, pycompat.values(row))) + writer.writerow([pycompat.to_text(v) for v in row.values()]) # read taxes and add leaf tax codes new_tax_codes = {} # id: parent_code @@ -145,17 +135,19 @@ class LuxTaxGenerator: for tax_code_id in sorted(new_tax_codes): name, parent_code = new_tax_codes[tax_code_id] - writer.writerow((tax_code_id, - 'lu_tct_m' + parent_code, - tax_code_id.replace('lu_tax_code_template_', ''), - '1', - '', - _e(name), - '')) + writer.writerow([ + tax_code_id, + u'lu_tct_m' + parent_code, + tax_code_id.replace('lu_tax_code_template_', u''), + u'1', + u'', + pycompat.to_text(name), + u'' + ]) def taxes_to_csv(self): - writer = csv.writer(open('account.tax.template-%s.csv' % - self.suffix, 'wb')) + writer = pycompat.csv_writer(open('account.tax.template-%s.csv' % + self.suffix, 'wb')) taxes_iterator = self.iter_taxes() keys = next(taxes_iterator) writer.writerow(keys[3:] + ['sequence']) @@ -168,17 +160,20 @@ class LuxTaxGenerator: cur_seq = seq + 1000 else: cur_seq = seq - writer.writerow(list(pycompat.imap(_e, list(pycompat.values(row))[3:])) + [cur_seq]) + writer.writerow([ + pycompat.to_text(v) + for v in list(row.values())[3:] + ] + [cur_seq]) def fiscal_pos_map_to_csv(self): - writer = csv.writer(open('account.fiscal.' - 'position.tax.template-%s.csv' % - self.suffix, 'wb')) + writer = pycompat.csv_writer(open('account.fiscal.' + 'position.tax.template-%s.csv' % + self.suffix, 'wb')) fiscal_pos_map_iterator = self.iter_fiscal_pos_map() keys = next(fiscal_pos_map_iterator) writer.writerow(keys) for row in fiscal_pos_map_iterator: - writer.writerow(pycompat.imap(_e, pycompat.values(row))) + writer.writerow([pycompat.to_text(s) for s in row.values()]) if __name__ == '__main__': diff --git a/addons/link_tracker/models/link_tracker.py b/addons/link_tracker/models/link_tracker.py index 1d9a7cdf561..0ffaad046a0 100644 --- a/addons/link_tracker/models/link_tracker.py +++ b/addons/link_tracker/models/link_tracker.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import datetime import random import re @@ -12,9 +12,9 @@ from werkzeug import urls, utils from odoo import models, fields, api, _ -from odoo.tools import ustr, pycompat +from odoo.tools import ustr -URL_REGEX = r'(\bhref=[\'"](?!mailto:)([^\'"]+)[\'"])' +URL_REGEX = r'(\bhref=[\'"](?!mailto:|tel:|sms:)([^\'"]+)[\'"])' def VALIDATE_URL(url): if urls.url_parse(url).scheme not in ('http', 'https', 'ftp', 'ftps'): @@ -90,7 +90,7 @@ class link_tracker(models.Model): @api.one @api.depends('favicon') def _compute_icon_src(self): - self.icon_src = 'data:image/png;base64,' + self.favicon + self.icon_src = b'data:image/png;base64,' + self.favicon @api.one @api.depends('url') @@ -123,7 +123,7 @@ class link_tracker(models.Model): def _compute_favicon(self): try: icon = requests.get('http://www.google.com/s2/favicons', params={'domain': self.url}, timeout=5).content - icon_base64 = icon.encode('base64').replace("\n", "") + icon_base64 = base64.b64encode(icon).replace(b"\n", b"").decode('ascii') except: icon_base64 = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAACiElEQVQ4EaVTzU8TURCf2tJuS7tQtlRb6UKBIkQwkRRSEzkQgyEc6lkOKgcOph78Y+CgjXjDs2i44FXY9AMTlQRUELZapVlouy3d7kKtb0Zr0MSLTvL2zb75eL838xtTvV6H/xELBptMJojeXLCXyobnyog4YhzXYvmCFi6qVSfaeRdXdrfaU1areV5KykmX06rcvzumjY/1ggkR3Jh+bNf1mr8v1D5bLuvR3qDgFbvbBJYIrE1mCIoCrKxsHuzK+Rzvsi29+6DEbTZz9unijEYI8ObBgXOzlcrx9OAlXyDYKUCzwwrDQx1wVDGg089Dt+gR3mxmhcUnaWeoxwMbm/vzDFzmDEKMMNhquRqduT1KwXiGt0vre6iSeAUHNDE0d26NBtAXY9BACQyjFusKuL2Ry+IPb/Y9ZglwuVscdHaknUChqLF/O4jn3V5dP4mhgRJgwSYm+gV0Oi3XrvYB30yvhGa7BS70eGFHPoTJyQHhMK+F0ZesRVVznvXw5Ixv7/C10moEo6OZXbWvlFAF9FVZDOqEABUMRIkMd8GnLwVWg9/RkJF9sA4oDfYQAuzzjqzwvnaRUFxn/X2ZlmGLXAE7AL52B4xHgqAUqrC1nSNuoJkQtLkdqReszz/9aRvq90NOKdOS1nch8TpL555WDp49f3uAMXhACRjD5j4ykuCtf5PP7Fm1b0DIsl/VHGezzP1KwOiZQobFF9YyjSRYQETRENSlVzI8iK9mWlzckpSSCQHVALmN9Az1euDho9Xo8vKGd2rqooA8yBcrwHgCqYR0kMkWci08t/R+W4ljDCanWTg9TJGwGNaNk3vYZ7VUdeKsYJGFNkfSzjXNrSX20s4/h6kB81/271ghG17l+rPTAAAAAElFTkSuQmCC' @@ -165,7 +165,7 @@ class link_tracker(models.Model): create_vals['url'] = VALIDATE_URL(vals['url']) search_domain = [] - for fname, value in pycompat.items(create_vals): + for fname, value in create_vals.items(): search_domain.append((fname, '=', value)) result = self.search(search_domain, limit=1) diff --git a/addons/lunch/models/lunch.py b/addons/lunch/models/lunch.py index ee8bba3492e..e9c13060fc3 100644 --- a/addons/lunch/models/lunch.py +++ b/addons/lunch/models/lunch.py @@ -10,8 +10,6 @@ from odoo import api, fields, models, _ from odoo.exceptions import AccessError, ValidationError from odoo.addons import decimal_precision as dp -from odoo.tools import pycompat - class LunchOrder(models.Model): """ @@ -26,10 +24,10 @@ class LunchOrder(models.Model): prev_order = self.env['lunch.order.line'].search([('user_id', '=', self.env.uid), ('product_id.active', '!=', False)], limit=20, order='id desc') # If we return return prev_order.ids, we will have duplicates (identical orders). # Therefore, this following part removes duplicates based on product_id and note. - return list(pycompat.values({ + return list({ (order.product_id, order.note): order.id for order in prev_order - })) + }.values()) user_id = fields.Many2one('res.users', 'User', readonly=True, states={'new': [('readonly', False)]}, @@ -88,10 +86,10 @@ class LunchOrder(models.Model): prev_order = self.env['lunch.order.line'].search([('user_id', '=', self.env.uid), ('product_id.active', '!=', False)], limit=20, order='date desc, id desc') # If we use prev_order.ids, we will have duplicates (identical orders). # Therefore, this following part removes duplicates based on product_id and note. - self.previous_order_ids = list(pycompat.values({ + self.previous_order_ids = list({ (order.product_id, order.note): order.id for order in prev_order - })) + }.values()) if self.previous_order_ids: lunch_data = {} diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py index cfa5654b0cd..c1b4433e74a 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -13,7 +13,7 @@ from odoo import api, http, registry, SUPERUSER_ID, _ from odoo.addons.web.controllers.main import binary_content from odoo.exceptions import AccessError from odoo.http import request -from odoo.tools import consteq +from odoo.tools import consteq, pycompat _logger = logging.getLogger(__name__) @@ -101,7 +101,7 @@ class MailController(http.Controller): """ End-point to receive mail from an external SMTP server. """ dbs = req.jsonrequest.get('databases') for db in dbs: - message = dbs[db].decode('base64') + message = base64.b64decode(dbs[db]) try: db_registry = registry(db) with db_registry.cursor() as cr: @@ -119,7 +119,7 @@ class MailController(http.Controller): follower_id = None follower_recs = request.env['mail.followers'].sudo().browse(follower_ids) res_ids = follower_recs.mapped('res_id') - request.env[res_model].browse(res_ids).check_access_rule("write") + request.env[res_model].browse(res_ids).check_access_rule("read") for follower in follower_recs: is_uid = partner_id == follower.partner_id follower_id = follower.id if is_uid else follower_id @@ -182,7 +182,7 @@ class MailController(http.Controller): else: # either a wrong message_id, either someone trying ids -> just go to messaging return self._redirect_to_messaging() - elif res_id and isinstance(res_id, basestring): + elif res_id and isinstance(res_id, pycompat.string_types): res_id = int(res_id) return self._redirect_to_record(model, res_id, access_token) diff --git a/addons/mail/models/ir_model.py b/addons/mail/models/ir_model.py index e2231e38f5d..01862352e96 100644 --- a/addons/mail/models/ir_model.py +++ b/addons/mail/models/ir_model.py @@ -3,6 +3,7 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError +from odoo.tools import pycompat class IrModel(models.Model): @@ -41,7 +42,7 @@ class IrModel(models.Model): model_class = super(IrModel, self)._instanciate(model_data) if model_data.get('is_mail_thread') and model_class._name != 'mail.thread': parents = model_class._inherit or [] - parents = [parents] if isinstance(parents, basestring) else parents + parents = [parents] if isinstance(parents, pycompat.string_types) else parents model_class._inherit = parents + ['mail.thread'] return model_class diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index fac6edfff98..31e03b99081 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 from email.utils import formataddr import re @@ -9,7 +9,7 @@ import uuid from odoo import _, api, fields, models, modules, tools from odoo.exceptions import UserError from odoo.osv import expression -from odoo.tools import ormcache, pycompat +from odoo.tools import ormcache from odoo.tools.safe_eval import safe_eval @@ -41,7 +41,7 @@ class Channel(models.Model): def _get_default_image(self): image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png') - return tools.image_resize_image_big(open(image_path, 'rb').read().encode('base64')) + return tools.image_resize_image_big(base64.b64encode(open(image_path, 'rb').read())) @api.model def default_get(self, fields): @@ -619,7 +619,7 @@ class Channel(models.Model): channel = channels_preview[message['id']] del(channel['message_id']) channel['last_message'] = message - return list(pycompat.values(channels_preview)) + return list(channels_preview.values()) #------------------------------------------------------ # Commands diff --git a/addons/mail/models/mail_followers.py b/addons/mail/models/mail_followers.py index a6bd60f82c9..32d2e66b028 100644 --- a/addons/mail/models/mail_followers.py +++ b/addons/mail/models/mail_followers.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models -from odoo.tools import pycompat class Followers(models.Model): @@ -24,7 +23,7 @@ class Followers(models.Model): index=True, required=True, ondelete='cascade', help='Model of the followed resource') res_model = fields.Char( - 'Related Document Model Name', index=True, readonly=True, related='res_model_id.model', store=True) + 'Related Document Model Name', index=True, readonly=True, related='res_model_id.model', compute_sudo=True, store=True) res_id = fields.Integer( 'Related Document ID', index=True, help='Id of the followed resource') partner_id = fields.Many2one( @@ -42,7 +41,7 @@ class Followers(models.Model): using the subtypes given in the parameters """ res_model_id = self.env['ir.model']._get(res_model).id - force_mode = force or (all(data for data in pycompat.values(partner_data)) and all(data for data in pycompat.values(channel_data))) + force_mode = force or (all(partner_data.values()) and all(channel_data.values())) generic = [] specific = {} existing = {} # {res_id: follower_ids} @@ -71,13 +70,13 @@ class Followers(models.Model): if force_mode: employee_pids = self.env['res.users'].sudo().search([('partner_id', 'in', list(partner_data)), ('share', '=', False)]).mapped('partner_id').ids - for pid, data in pycompat.items(partner_data): + for pid, data in partner_data.items(): if not data: if pid not in employee_pids: partner_data[pid] = external_default_subtypes.ids else: partner_data[pid] = default_subtypes.ids - for cid, data in pycompat.items(channel_data): + for cid, data in channel_data.items(): if not data: channel_data[cid] = default_subtypes.ids diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 3f206424e07..e723f71bdc6 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -13,7 +13,6 @@ from email.utils import formataddr from odoo import _, api, fields, models from odoo import tools from odoo.addons.base.ir.ir_mail_server import MailDeliveryException -from odoo.tools import pycompat from odoo.tools.safe_eval import safe_eval _logger = logging.getLogger(__name__) @@ -169,7 +168,7 @@ class MailMail(models.Model): - else fallback on mail.email_to splitting """ self.ensure_one() if partner: - email_to = [formataddr((partner.name, partner.email))] + email_to = [formataddr((partner.name or 'False', partner.email or 'False'))] else: email_to = tools.email_split_and_format(self.email_to) return email_to @@ -205,7 +204,7 @@ class MailMail(models.Model): groups[mail.mail_server_id.id].append(mail.id) sys_params = self.env['ir.config_parameter'].sudo() batch_size = int(sys_params.get_param('mail.session.batch.size', 1000)) - for server_id, record_ids in pycompat.items(groups): + for server_id, record_ids in groups.items(): for mail_batch in tools.split_every(batch_size, record_ids): yield server_id, mail_batch diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 583f0320a40..425745142f0 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -9,7 +9,6 @@ from email.utils import formataddr from odoo import _, api, fields, models, modules, SUPERUSER_ID, tools from odoo.exceptions import UserError, AccessError from odoo.osv import expression -from odoo.tools import pycompat _logger = logging.getLogger(__name__) _image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/]{3,}=*)([\'"])', re.I) @@ -282,7 +281,7 @@ class Message(models.Model): partners = self.env['res.partner'].sudo() attachments = self.env['ir.attachment'] trackings = self.env['mail.tracking.value'] - for key, message in pycompat.items(message_tree): + for key, message in message_tree.items(): if message.author_id: partners |= message.author_id if message.subtype_id and message.partner_ids: # take notified people of message with a subtype @@ -446,7 +445,7 @@ class Message(models.Model): def _find_allowed_doc_ids(self, model_ids): IrModelAccess = self.env['ir.model.access'] allowed_ids = set() - for doc_model, doc_dict in pycompat.items(model_ids): + for doc_model, doc_dict in model_ids.items(): if not IrModelAccess.check(doc_model, 'read', False): continue allowed_ids |= self._find_allowed_model_wise(doc_model, doc_dict) @@ -607,17 +606,17 @@ class Message(models.Model): # Author condition (READ, WRITE, CREATE (private)) author_ids = [] if operation == 'read' or operation == 'write': - author_ids = [mid for mid, message in pycompat.items(message_values) + author_ids = [mid for mid, message in message_values.items() if message.get('author_id') and message.get('author_id') == self.env.user.partner_id.id] elif operation == 'create': - author_ids = [mid for mid, message in pycompat.items(message_values) + author_ids = [mid for mid, message in message_values.items() if not message.get('model') and not message.get('res_id')] # Parent condition, for create (check for received notifications for the created message parent) notified_ids = [] if operation == 'create': # TDE: probably clean me - parent_ids = [message.get('parent_id') for mid, message in pycompat.items(message_values) + parent_ids = [message.get('parent_id') for message in message_values.values() if message.get('parent_id')] self._cr.execute("""SELECT DISTINCT m.id, partner_rel.res_partner_id, channel_partner.partner_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel @@ -630,37 +629,37 @@ class Message(models.Model): ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s) WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.partner_id.id, self.env.user.partner_id.id, parent_ids,)) not_parent_ids = [mid[0] for mid in self._cr.fetchall() if any([mid[1], mid[2]])] - notified_ids += [mid for mid, message in pycompat.items(message_values) + notified_ids += [mid for mid, message in message_values.items() if message.get('parent_id') in not_parent_ids] # Recipients condition, for read and write (partner_ids) and create (message_follower_ids) other_ids = set(self.ids).difference(set(author_ids), set(notified_ids)) model_record_ids = _generate_model_record_ids(message_values, other_ids) if operation in ['read', 'write']: - notified_ids = [mid for mid, message in pycompat.items(message_values) if message.get('notified')] + notified_ids = [mid for mid, message in message_values.items() if message.get('notified')] elif operation == 'create': - for doc_model, doc_ids in pycompat.items(model_record_ids): + for doc_model, doc_ids in model_record_ids.items(): followers = self.env['mail.followers'].sudo().search([ ('res_model', '=', doc_model), ('res_id', 'in', list(doc_ids)), ('partner_id', '=', self.env.user.partner_id.id), ]) fol_mids = [follower.res_id for follower in followers] - notified_ids += [mid for mid, message in pycompat.items(message_values) + notified_ids += [mid for mid, message in message_values.items() if message.get('model') == doc_model and message.get('res_id') in fol_mids] # CRUD: Access rights related to the document other_ids = other_ids.difference(set(notified_ids)) model_record_ids = _generate_model_record_ids(message_values, other_ids) document_related_ids = [] - for model, doc_ids in pycompat.items(model_record_ids): + for model, doc_ids in model_record_ids.items(): DocumentModel = self.env[model] mids = DocumentModel.browse(doc_ids).exists() if hasattr(DocumentModel, 'check_mail_message_access'): DocumentModel.check_mail_message_access(mids.ids, operation) # ?? mids ? else: self.env['mail.thread'].check_mail_message_access(mids.ids, operation, model_name=model) - document_related_ids += [mid for mid, message in pycompat.items(message_values) + document_related_ids += [mid for mid, message in message_values.items() if message.get('model') == model and message.get('res_id') in mids.ids] # Calculate remaining ids: if not void, raise an error @@ -745,7 +744,7 @@ class Message(models.Model): values['attachment_ids'].append((4, attachment.id)) data_to_url[key] = '/web/image/%s' % attachment.id return '%s%s alt="%s"' % (data_to_url[key], match.group(3), name) - values['body'] = _image_dataurl.sub(base64_to_boundary, values['body']) + values['body'] = _image_dataurl.sub(base64_to_boundary, tools.ustr(values['body'])) message = super(Message, self).create(values) message._invalidate_documents() diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index e9bf605c382..1bd92aff430 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -45,7 +45,7 @@ def format_tz(env, dt, tz=False, format=False): return format_datetime(ts, format or 'medium', locale=env.context.get("lang") or 'en_US') if format: - return ts.strftime(format) + return pycompat.text_type(ts.strftime(format)) else: lang = env.context.get("lang") langs = env['res.lang'] @@ -54,9 +54,9 @@ def format_tz(env, dt, tz=False, format=False): format_date = langs.date_format or '%B-%d-%Y' format_time = langs.time_format or '%I-%M %p' - fdate = ts.strftime(format_date).decode('utf-8') - ftime = ts.strftime(format_time).decode('utf-8') - return "%s %s%s" % (fdate, ftime, (' (%s)' % tz) if tz else '') + fdate = pycompat.text_type(ts.strftime(format_date)) + ftime = pycompat.text_type(ts.strftime(format_time)) + return u"%s %s%s" % (fdate, ftime, (u' (%s)' % tz) if tz else u'') def format_amount(env, amount, currency): fmt = "%.{0}f".format(currency.decimal_places) @@ -306,8 +306,8 @@ class MailTemplate(models.Model): # form a tree root = lxml.html.fromstring(html) if not len(root) and root.text is None and root.tail is None: - html = '<div>%s</div>' % html - root = lxml.html.fromstring(html) + html = u'<div>%s</div>' % html + root = lxml.html.fromstring(html, encoding='unicode') base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') base = urls.url_parse(base_url) @@ -324,12 +324,12 @@ class MailTemplate(models.Model): for node in root.iter(): if node.tag == 'a' and node.get('href'): node.set('href', _process_link(node.get('href'))) - elif node.tag == 'img' and not node.get('src', 'data').startswith('data'): + elif node.tag == 'img' and not node.get('src', 'data').startswith(u'data'): node.set('src', _process_link(node.get('src'))) - html = lxml.html.tostring(root, pretty_print=False, method='html') + html = lxml.html.tostring(root, pretty_print=False, method='html', encoding='unicode') # this is ugly, but lxml/etree tostring want to put everything in a 'div' that breaks the editor -> remove that - if html.startswith('<div>') and html.endswith('</div>'): + if html.startswith(u'<div>') and html.endswith(u'</div>'): html = html[5:-6] return html @@ -379,7 +379,7 @@ class MailTemplate(models.Model): 'user': self.env.user, 'ctx': self._context, # context kw would clash with mako internals } - for res_id, record in pycompat.items(res_to_rec): + for res_id, record in res_to_rec.items(): variables['object'] = record try: render_result = template.render(variables) @@ -391,7 +391,7 @@ class MailTemplate(models.Model): results[res_id] = render_result if post_process: - for res_id, result in pycompat.items(results): + for res_id, result in results.items(): results[res_id] = self.render_post_process(result) return multi_mode and results or results[res_ids[0]] @@ -412,7 +412,7 @@ class MailTemplate(models.Model): self.ensure_one() langs = self.render_template(self.lang, self.model, res_ids) - for res_id, lang in pycompat.items(langs): + for res_id, lang in langs.items(): if lang: template = self.with_context(lang=lang) else: @@ -431,11 +431,11 @@ class MailTemplate(models.Model): if self.use_default_to or self._context.get('tpl_force_default_to'): default_recipients = self.env['mail.thread'].message_get_default_recipients(res_model=self.model, res_ids=res_ids) - for res_id, recipients in pycompat.items(default_recipients): + for res_id, recipients in default_recipients.items(): results[res_id].pop('partner_to', None) results[res_id].update(recipients) - for res_id, values in pycompat.items(results): + for res_id, values in results.items(): partner_ids = values.get('partner_ids', list()) if self._context.get('tpl_partners_only'): mails = tools.email_split(values.pop('email_to', '')) + tools.email_split(values.pop('email_cc', '')) @@ -474,11 +474,11 @@ class MailTemplate(models.Model): # templates: res_id -> template; template -> res_ids templates_to_res_ids = {} - for res_id, template in pycompat.items(res_ids_to_templates): + for res_id, template in res_ids_to_templates.items(): templates_to_res_ids.setdefault(template, []).append(res_id) results = dict() - for template, template_res_ids in pycompat.items(templates_to_res_ids): + for template, template_res_ids in templates_to_res_ids.items(): Template = self.env['mail.template'] # generate fields value for all res_ids linked to the current template if template.lang: @@ -488,7 +488,7 @@ class MailTemplate(models.Model): generated_field_values = Template.render_template( getattr(template, field), template.model, template_res_ids, post_process=(field == 'body_html')) - for res_id, field_value in pycompat.items(generated_field_values): + for res_id, field_value in generated_field_values.items(): results.setdefault(res_id, dict())[field] = field_value # compute recipients if any(field in fields for field in ['email_to', 'partner_to', 'email_cc']): diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 60c40a69aa9..6e097196a1b 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -238,7 +238,7 @@ class MailThread(models.AbstractModel): # auto_subscribe: take values and defaults into account create_values = dict(values) - for key, val in pycompat.items(self._context): + for key, val in self._context.items(): if key.startswith('default_') and key[8:] not in create_values: create_values[key[8:]] = val thread.message_auto_subscribe(list(create_values), values=create_values) @@ -373,7 +373,7 @@ class MailThread(models.AbstractModel): options['display_log_button'] = is_employee # save options on the node node.set('options', repr(options)) - res['arch'] = etree.tostring(doc) + res['arch'] = etree.tostring(doc, encoding='unicode') return res # ------------------------------------------------------ @@ -388,7 +388,7 @@ class MailThread(models.AbstractModel): always tracked fields and modified on_change fields """ tracked_fields = [] - for name, field in pycompat.items(self._fields): + for name, field in self._fields.items(): if getattr(field, 'track_visibility', False): tracked_fields.append(name) @@ -416,13 +416,13 @@ class MailThread(models.AbstractModel): @api.multi def _message_track_post_template(self, tracking): - if not any(change for rec_id, (change, tracking_value_ids) in pycompat.items(tracking)): + if not any(change for rec_id, (change, tracking_value_ids) in tracking.items()): return True templates = self._track_template(tracking) - for field_name, (template, post_kwargs) in pycompat.items(templates): + for field_name, (template, post_kwargs) in templates.items(): if not template: continue - if isinstance(template, basestring): + if isinstance(template, pycompat.string_types): self.message_post_with_view(template, **post_kwargs) else: self.message_post_with_template(template.id, **post_kwargs) @@ -450,7 +450,7 @@ class MailThread(models.AbstractModel): display_values_ids = [] # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}} - for col_name, col_info in pycompat.items(tracked_fields): + for col_name, col_info in tracked_fields.items(): track_visibility = getattr(self._fields[col_name], 'track_visibility', 'onchange') initial_value = initial[col_name] new_value = getattr(self, col_name) @@ -569,7 +569,7 @@ class MailThread(models.AbstractModel): def _generate_notification_token(self, base_link, params): secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') token = '%s?%s' % (base_link, ' '.join('%s=%s' % (key, params[key]) for key in sorted(params))) - hm = hmac.new(str(secret), token, hashlib.sha1).hexdigest() + hm = hmac.new(secret.encode('utf-8'), token.encode('utf-8'), hashlib.sha1).hexdigest() return hm @api.multi @@ -816,7 +816,7 @@ class MailThread(models.AbstractModel): def message_capable_models(self): """ Used by the plugin addon, based for plugin_outlook and others. """ ret_dict = {} - for model_name, model in pycompat.items(self.env): + for model_name, model in self.env.items(): if hasattr(model, "message_process") and hasattr(model, "message_post"): ret_dict[model_name] = model._description return ret_dict @@ -1266,12 +1266,16 @@ class MailThread(models.AbstractModel): # we don't know its encoding until we parse its headers and hence can't # convert it to utf-8 for transport between the mailgate script and here. if isinstance(message, xmlrpclib.Binary): - message = str(message.data) - # Warning: message_from_string doesn't always work correctly on unicode, - # we must use utf-8 strings here :-( - if isinstance(message, unicode): + message = bytes(message.data) + # message_from_string parses from a *native string*, except apparently + # sometimes message is ISO-8859-1 binary data or some shit and the + # straightforward version (pycompat.to_native) won't work right -> + # always encode message to bytes then use the relevant method + # depending on ~python version + if isinstance(message, pycompat.text_type): message = message.encode('utf-8') - msg_txt = email.message_from_string(message) + extract = getattr(email, 'message_from_bytes', email.message_from_string) + msg_txt = extract(message) # parse the message, verify we are not in a loop by checking message_id is not duplicated msg = self.message_parse(msg_txt, save_original=save_original) @@ -1492,9 +1496,9 @@ class MailThread(models.AbstractModel): 'message_type': 'email', } if not isinstance(message, Message): - if isinstance(message, unicode): - # Warning: message_from_string doesn't always work correctly on unicode, - # we must use utf-8 strings here :-( + # 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 = email.message_from_string(message) @@ -1723,11 +1727,11 @@ class MailThread(models.AbstractModel): cid = info and info.get('cid') else: continue - if isinstance(content, unicode): + if isinstance(content, pycompat.text_type): content = content.encode('utf-8') data_attach = { 'name': name, - 'datas': base64.b64encode(str(content)), + 'datas': base64.b64encode(content), 'type': 'binary', 'datas_fname': name, 'description': name, @@ -1920,7 +1924,7 @@ class MailThread(models.AbstractModel): values['slug'] = slug except ImportError: values['slug'] = lambda self: self.id - if isinstance(views_or_xmlid, basestring): + if isinstance(views_or_xmlid, pycompat.string_types): views = self.env.ref(views_or_xmlid, raise_if_not_found=False) else: views = views_or_xmlid @@ -2052,7 +2056,7 @@ class MailThread(models.AbstractModel): if auto_follow_fields is None: auto_follow_fields = ['user_id'] user_field_lst = [] - for name, field in pycompat.items(self._fields): + for name, field in self._fields.items(): if name in auto_follow_fields and name in updated_fields and getattr(field, 'track_visibility', False) and field.comodel_name == 'res.users': user_field_lst.append(name) return user_field_lst @@ -2148,10 +2152,10 @@ class MailThread(models.AbstractModel): for partner in to_add_users.mapped('partner_id'): new_partners.setdefault(partner.id, None) - for pid, subtypes in pycompat.items(new_partners): + for pid, subtypes in new_partners.items(): subtypes = list(subtypes) if subtypes is not None else None self.message_subscribe(partner_ids=[pid], subtype_ids=subtypes, force=(subtypes != None)) - for cid, subtypes in pycompat.items(new_channels): + for cid, subtypes in new_channels.items(): subtypes = list(subtypes) if subtypes is not None else None self.message_subscribe(channel_ids=[cid], subtype_ids=subtypes, force=(subtypes != None)) diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 6379ddb8c42..9140959f89e 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -4,7 +4,6 @@ import logging import threading -from odoo.tools import pycompat from odoo.tools.misc import split_every from odoo import _, api, fields, models, registry, SUPERUSER_ID @@ -175,7 +174,7 @@ class Partner(models.Model): emails = self.env['mail.mail'] recipients_nbr, recipients_max = 0, 50 - for email_type, recipient_template_values in pycompat.items(recipients): + for email_type, recipient_template_values in recipients.items(): if recipient_template_values['followers']: # generate notification email content template_fol_values = dict(base_template_ctx, **recipient_template_values) # fixme: set button_unfollow to none diff --git a/addons/mail/static/src/xml/client_action.xml b/addons/mail/static/src/xml/client_action.xml index 936f8a91be9..952b93af1a0 100644 --- a/addons/mail/static/src/xml/client_action.xml +++ b/addons/mail/static/src/xml/client_action.xml @@ -84,7 +84,7 @@ <t t-name="mail.chat.SidebarItems"> <div t-if="!disable_add_channel" class="o_mail_add_channel" t-attf-data-type="#{channel_type}"> <span t-if="display_hash">#</span> - <input type="text" t-attf-placeholder="#{input_placeholder}"/> + <input type="text" class="o_input" t-attf-placeholder="#{input_placeholder}"/> </div> <t t-foreach="channels" t-as="channel"> <t t-set="counter" t-value="channel.is_chat ? channel.unread_counter : channel.needaction_counter"/> @@ -134,6 +134,6 @@ <!-- Invite dialog's content --> <div t-name="mail.PartnerInviteDialog"> - <input type="text" class="o_mail_chat_partner_invite_input" id="mail_search_partners"/> + <input type="text" class="o_input o_mail_chat_partner_invite_input" id="mail_search_partners"/> </div> </templates> diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml index 93a1ea51e46..032a71edbcd 100644 --- a/addons/mail/static/src/xml/composer.xml +++ b/addons/mail/static/src/xml/composer.xml @@ -5,7 +5,7 @@ <div class="o_composer_attachments_list"/> <t t-if="widget.extended"> <div class="o_composer_subject"> - <input tabindex="1" type="text" placeholder="Subject"></input> + <input tabindex="1" type="text" class="o_input" placeholder="Subject"></input> <div class="btn-group o_composer_buttons"> <button tabindex="3" class="btn btn-sm btn-primary o_composer_button_send" type="button"><t t-esc="widget.options.send_text"/></button> <button tabindex="4" class="btn btn-sm btn-icon fa fa-smile-o o_composer_button_emoji" type="button" data-toggle="popover"/> @@ -15,7 +15,7 @@ </t> <div t-attf-class="o_composer #{widget.extended ? 'o_extended_composer' : ''}"> <div class="o_composer_input"> - <textarea class="o_composer_text_field" tabindex="2" placeholder="Write something..."/> + <textarea class="o_input o_composer_text_field" tabindex="2" placeholder="Write something..."/> </div> <t t-if="!widget.extended"> <div class="btn-group o_composer_buttons"> diff --git a/addons/mail/tests/test_mail_features.py b/addons/mail/tests/test_mail_features.py index 92613f55e51..8e2264ad6d3 100644 --- a/addons/mail/tests/test_mail_features.py +++ b/addons/mail/tests/test_mail_features.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 from odoo.addons.mail.tests.common import TestMail from odoo.addons.mail.tests.test_mail_gateway import MAIL_TEMPLATE_PLAINTEXT @@ -89,8 +90,8 @@ class TestMessagePost(TestMail): _body, _body_alt = '<p>Test Body</p>', 'Test Body' _subject = 'Test Subject' _attachments = [ - ('List1', 'My first attachment'), - ('List2', 'My second attachment') + ('List1', b'My first attachment'), + ('List2', b'My second attachment') ] _attach_1 = self.env['ir.attachment'].sudo(self.user_employee).create({ 'name': 'Attach1', 'datas_fname': 'Attach1', @@ -129,8 +130,8 @@ class TestMessagePost(TestMail): 'message_post: all atttachments should be linked to the mail.test model') self.assertEqual(set(msg.attachment_ids.mapped('res_id')), set([self.test_pigs.id]), 'message_post: all atttachments should be linked to the pigs group') - self.assertEqual(set([x.decode('base64') for x in msg.attachment_ids.mapped('datas')]), - set(['migration test', _attachments[0][1], _attachments[1][1]])) + self.assertEqual(set([base64.b64decode(x) for x in msg.attachment_ids.mapped('datas')]), + set([b'migration test', _attachments[0][1], _attachments[1][1]])) self.assertTrue(set([_attach_1.id, _attach_2.id]).issubset(msg.attachment_ids.ids), 'message_post: mail.message attachments duplicated') # notifications diff --git a/addons/mail/tests/test_mail_message.py b/addons/mail/tests/test_mail_message.py index db6dc9457a5..566a038d9ef 100644 --- a/addons/mail/tests/test_mail_message.py +++ b/addons/mail/tests/test_mail_message.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import itertools from odoo.addons.mail.tests.common import TestMail @@ -215,7 +215,7 @@ class TestMailMessageAccess(TestMail): def test_mail_message_access_read_notification(self): attachment = self.env['ir.attachment'].create({ - 'datas': 'My attachment'.encode('base64'), + 'datas': base64.b64encode(b'My attachment'), 'name': 'doc.txt', 'datas_fname': 'doc.txt'}) # attach the attachment to the message diff --git a/addons/mail/tests/test_mail_template.py b/addons/mail/tests/test_mail_template.py index 60df04f0654..e5da1c628ae 100644 --- a/addons/mail/tests/test_mail_template.py +++ b/addons/mail/tests/test_mail_template.py @@ -17,13 +17,13 @@ class TestMailTemplate(TestMail): 'name': '_Test_First', 'datas_fname': 'first.txt', - 'datas': base64.b64encode('My first attachment'), + 'datas': base64.b64encode(b'My first attachment'), 'res_model': 'res.partner', 'res_id': self.user_admin.partner_id.id }, { 'name': '_Test_Second', 'datas_fname': 'second.txt', - 'datas': base64.b64encode('My second attachment'), + 'datas': base64.b64encode(b'My second attachment'), 'res_model': 'res.partner', 'res_id': self.user_admin.partner_id.id }] diff --git a/addons/mail/views/mail_message_views.xml b/addons/mail/views/mail_message_views.xml index 75ffea35a5e..19ad37d44a5 100644 --- a/addons/mail/views/mail_message_views.xml +++ b/addons/mail/views/mail_message_views.xml @@ -148,11 +148,11 @@ <field name="name"/> </div> <div t-if="record.type.raw_value == 'binary'"> - <a t-attf-href="/web/content/#{record.id.value}?download=1"> + <a t-attf-href="/web/content/#{record.id.raw_value}?download=1"> <t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png)').test(record.mimetype.value)"/> <div t-if="!webimage" class="o_image" t-att-data-mimetype="record.mimetype.value"/> <div t-if="webimage" class="o_attachment_image"> - <img t-attf-src="/web/image/#{record.id.value}?download=1"/> + <img t-attf-src="/web/image/#{record.id.raw_value}?download=1"/> </div> </a> </div> diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 76412ce041c..0d91879224e 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -140,7 +140,7 @@ class MailComposer(models.TransientModel): for mid, rmod, rid in self._cr.fetchall(): message_values[mid] = {'model': rmod, 'res_id': rid} # remove from the set to check the ids that mail_compose_message accepts - author_ids = [mid for mid, message in pycompat.items(message_values) + author_ids = [mid for mid, message in message_values.items() if message.get('model') and not message.get('res_id')] self = self.browse(list(set(self.ids) - set(author_ids))) # not sure slef = ... @@ -248,7 +248,7 @@ class MailComposer(models.TransientModel): for res_ids in sliced_res_ids: batch_mails = Mail all_mail_values = wizard.get_mail_values(res_ids) - for res_id, mail_values in pycompat.items(all_mail_values): + for res_id, mail_values in all_mail_values.items(): if wizard.composition_mode == 'mass_mail': batch_mails |= Mail.create(mail_values) else: @@ -295,8 +295,11 @@ class MailComposer(models.TransientModel): 'mail_server_id': self.mail_server_id.id, 'mail_activity_type_id': self.mail_activity_type_id.id, } + # mass mailing: rendering override wizard static values if mass_mail_mode and self.model: + if self.model in self.env and hasattr(self.env[self.model], 'message_get_email_values'): + mail_values.update(self.env[self.model].browse(res_id).message_get_email_values()) # keep a copy unless specifically requested, reset record name (avoid browsing records) mail_values.update(notification=not self.auto_delete_message, model=self.model, res_id=res_id, record_name=False) # auto deletion of mail_mail @@ -338,7 +341,7 @@ class MailComposer(models.TransientModel): def onchange_template_id_wrapper(self): self.ensure_one() values = self.onchange_template_id(self.template_id.id, self.composition_mode, self.model, self.res_id)['value'] - for fname, value in pycompat.items(values): + for fname, value in values.items(): setattr(self, fname, value) @api.multi diff --git a/addons/marketing_campaign/models/marketing_campaign.py b/addons/marketing_campaign/models/marketing_campaign.py index 7370633114e..6fea98870d8 100644 --- a/addons/marketing_campaign/models/marketing_campaign.py +++ b/addons/marketing_campaign/models/marketing_campaign.py @@ -9,7 +9,6 @@ import re from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import pycompat from odoo.tools.safe_eval import safe_eval from odoo.addons import decimal_precision as dp @@ -466,7 +465,7 @@ class MarketingCampaignWorkitem(models.Model): matching_workitems = [] for id, res_id, model in res: workitem_map.setdefault(model, {}).setdefault(res_id, set()).add(id) - for model, id_map in pycompat.items(workitem_map): + for model, id_map in workitem_map.items(): Model = self.env[model] condition_name[0] = Model._rec_name condition = [('id', 'in', list(id_map)), condition_name] diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py index 276056311b8..b2e77c2875e 100644 --- a/addons/mass_mailing/controllers/main.py +++ b/addons/mass_mailing/controllers/main.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 import werkzeug @@ -37,7 +38,7 @@ class MassMailController(http.Controller): request.env['mail.mail.statistics'].sudo().set_opened(mail_mail_ids=[mail_id]) response = werkzeug.wrappers.Response() response.mimetype = 'image/gif' - response.data = 'R0lGODlhAQABAIAAANvf7wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='.decode('base64') + response.data = base64.b64decode(b'R0lGODlhAQABAIAAANvf7wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==') return response diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index 44993bdc907..a10e6143c89 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -430,7 +430,7 @@ class MassMailing(models.Model): secret = self.env["ir.config_parameter"].sudo().get_param( "database.secret") token = (self.env.cr.dbname, self.id, int(res_id), tools.ustr(email)) - return hmac.new(str(secret), repr(token), hashlib.sha512).hexdigest() + return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha512).hexdigest() def _compute_next_departure(self): cron_next_call = self.env.ref('mass_mailing.ir_cron_mass_mailing_queue').sudo().nextcall diff --git a/addons/mass_mailing_event_track/views/event_views.xml b/addons/mass_mailing_event_track/views/event_views.xml index cf057244f95..6e3e60fc781 100644 --- a/addons/mass_mailing_event_track/views/event_views.xml +++ b/addons/mass_mailing_event_track/views/event_views.xml @@ -11,7 +11,6 @@ string="Mail Track Speakers" icon="fa-envelope-o" attrs="{'invisible': [('track_count', '=', 0)]}"> - Mail Track Speakers </button> </button> </field> diff --git a/addons/mrp/models/mrp_workcenter.py b/addons/mrp/models/mrp_workcenter.py index 58226b2e9e8..cc12ec0060b 100644 --- a/addons/mrp/models/mrp_workcenter.py +++ b/addons/mrp/models/mrp_workcenter.py @@ -5,7 +5,6 @@ from dateutil import relativedelta import datetime from odoo import api, exceptions, fields, models, _ -from odoo.tools import pycompat class MrpWorkcenter(models.Model): @@ -74,7 +73,7 @@ class MrpWorkcenter(models.Model): if res_group['state'] in ('pending', 'ready', 'progress'): result_duration_expected[res_group['workcenter_id'][0]] += res_group['duration_expected'] for workcenter in self: - workcenter.workorder_count = sum(count for state, count in pycompat.items(result[workcenter.id]) if state not in ('done', 'cancel')) + workcenter.workorder_count = sum(count for state, count in result[workcenter.id].items() if state not in ('done', 'cancel')) workcenter.workorder_pending_count = result[workcenter.id].get('pending', 0) workcenter.workcenter_load = result_duration_expected[workcenter.id] workcenter.workorder_ready_count = result[workcenter.id].get('ready', 0) diff --git a/addons/mrp/views/ir_attachment_view.xml b/addons/mrp/views/ir_attachment_view.xml index 49fdf2c6037..7d3e5d47daa 100644 --- a/addons/mrp/views/ir_attachment_view.xml +++ b/addons/mrp/views/ir_attachment_view.xml @@ -19,11 +19,11 @@ <div class="oe_attachment"> <div class="row"> <div class="col-xs-4 o_kanban_primary_left" t-if="record.type.raw_value == 'binary'"> - <a t-attf-href="/web/content/#{record.id.value}?download=1"> + <a t-attf-href="/web/content/#{record.id.raw_value}?download=1"> <t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png)').test(record.mimetype.value)"/> <div t-if="!webimage" class="o_image" t-att-data-mimetype="record.mimetype.value"/> <div t-if="webimage" class="o_attachment_image"> - <img t-attf-src="/web/image/#{record.id.value}?download=1"/> + <img t-attf-src="/web/image/#{record.id.raw_value}?download=1"/> </div> </a> </div> diff --git a/addons/mrp/wizard/mrp_product_produce_views.xml b/addons/mrp/wizard/mrp_product_produce_views.xml index 9a48db5300e..4675092417a 100644 --- a/addons/mrp/wizard/mrp_product_produce_views.xml +++ b/addons/mrp/wizard/mrp_product_produce_views.xml @@ -26,7 +26,7 @@ <field name="product_id" readonly="1"/> <field name="product_qty" readonly="1"/> <field name="qty_done"/> - <field name="lot_id" context="{'default_product_id': product_id}"/> + <field name="lot_id" context="{'default_product_id': product_id}" domain="[('product_id', '=', product_id)]"/> <field name="move_id" invisible="1"/> </tree> </field> diff --git a/addons/mrp_repair/wizard/mrp_repair_make_invoice.py b/addons/mrp_repair/wizard/mrp_repair_make_invoice.py index a659e45e962..03e89da5cd5 100644 --- a/addons/mrp_repair/wizard/mrp_repair_make_invoice.py +++ b/addons/mrp_repair/wizard/mrp_repair_make_invoice.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models -from odoo.tools import pycompat class MakeInvoice(models.TransientModel): @@ -25,7 +24,7 @@ class MakeInvoice(models.TransientModel): # but that second call will not do anything, since the repairs are already invoiced. repairs.action_repair_invoice_create() return { - 'domain': [('id', 'in', list(pycompat.values(new_invoice)))], + 'domain': [('id', 'in', list(new_invoice.values()))], 'name': 'Invoices', 'view_type': 'form', 'view_mode': 'tree,form', diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index 945e24f4308..d0c2055c739 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -10,7 +10,7 @@ import requests from odoo import api, models, _ from odoo.exceptions import UserError -from odoo.tools import html2plaintext, pycompat +from odoo.tools import html2plaintext from ..py_etherpad import EtherpadLiteClient @@ -106,7 +106,7 @@ class PadCommon(models.AbstractModel): # In case the pad is created programmatically, the content is not filled in yet since it is # normally initialized by the JS layer - for k, field in pycompat.items(self._fields): + for k, field in self._fields.items(): if hasattr(field, 'pad_content_field') and k not in vals: ctx = { 'model': self._name, @@ -121,7 +121,7 @@ class PadCommon(models.AbstractModel): def _set_pad_value(self, vals): # Update the pad if the `pad_content_field` is modified - for k, field in pycompat.items(self._fields): + for k, field in self._fields.items(): if hasattr(field, 'pad_content_field') and vals.get(field.pad_content_field) and self[k]: company = self.env.user.sudo().company_id myPad = EtherpadLiteClient(company.pad_key, company.pad_server + '/api') @@ -129,7 +129,7 @@ class PadCommon(models.AbstractModel): myPad.setText(path, (html2plaintext(vals[field.pad_content_field]).encode('utf-8'))) # Update the `pad_content_field` if the pad is modified - for k, v in list(pycompat.items(vals)): + for k, v in vals.items(): field = self._fields[k] if hasattr(field, 'pad_content_field'): vals[field.pad_content_field] = self.pad_get_content(v) @@ -139,7 +139,7 @@ class PadCommon(models.AbstractModel): self.ensure_one() if not default: default = {} - for k, field in pycompat.items(self._fields): + for k, field in self._fields.items(): if hasattr(field, 'pad_content_field'): pad = self.pad_generate_url() default[k] = pad.get('url') diff --git a/addons/pad/static/tests/pad_tests.js b/addons/pad/static/tests/pad_tests.js index dedf799db66..f2ab3a42dfa 100644 --- a/addons/pad/static/tests/pad_tests.js +++ b/addons/pad/static/tests/pad_tests.js @@ -230,4 +230,37 @@ QUnit.module('pad widget', { delete FieldPad.prototype.isPadConfigured; }); + QUnit.test('record should be discarded properly even if only pad has changed', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'task', + data: this.data, + arch:'<form>' + + '<sheet>' + + '<group>' + + '<field name="description" widget="pad"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 2, + mockRPC: function (route, args) { + if (!args.method) { + return $.when(true); + } + return this._super.apply(this, arguments); + }, + session: { + userName: "batman", + }, + }); + form.$buttons.find('.o_form_button_edit').click(); + form.$buttons.find('.o_form_button_cancel').click(); + assert.strictEqual(form.$('.oe_pad_readonly').text(), this.data.task.pad_get_content(), + "pad content should not have changed"); + form.destroy(); + delete FieldPad.prototype.isPadConfigured; + }); + }); diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 623676c7a9c..2e36d891344 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -5,7 +5,7 @@ import logging import datetime from odoo import api, exceptions, fields, models, _ -from odoo.tools import consteq, float_round, image_resize_images, ustr, pycompat +from odoo.tools import consteq, float_round, image_resize_images, ustr from odoo.addons.base.module import module from odoo.exceptions import ValidationError @@ -169,7 +169,7 @@ class PaymentAcquirer(models.Model): """ If the field has 'required_if_provider="<provider>"' attribute, then it required if record.provider is <provider>. """ for acquirer in self: - if any(getattr(f, 'required_if_provider', None) == acquirer.provider and not acquirer[k] for k, f in pycompat.items(self._fields)): + if any(getattr(f, 'required_if_provider', None) == acquirer.provider and not acquirer[k] for k, f in self._fields.items()): return False return True @@ -583,7 +583,7 @@ class PaymentTransaction(models.Model): token = '%s%s%s' % (self.callback_model_id.model, self.callback_res_id, self.sudo().callback_method) - return hmac.new(str(secret), token, hashlib.sha256).hexdigest() + return hmac.new(secret.encode('utf-8'), token.encode('utf-8'), hashlib.sha256).hexdigest() # -------------------------------------------------- # FORM RELATED METHODS diff --git a/addons/payment_adyen/models/payment.py b/addons/payment_adyen/models/payment.py index 87e51e619cb..dbc24ced0c0 100644 --- a/addons/payment_adyen/models/payment.py +++ b/addons/payment_adyen/models/payment.py @@ -15,8 +15,6 @@ from odoo import api, fields, models, tools, _ from odoo.addons.payment.models.payment_acquirer import ValidationError from odoo.addons.payment_adyen.controllers.main import AdyenController -from odoo.tools import pycompat - _logger = logging.getLogger(__name__) @@ -52,9 +50,9 @@ class AcquirerAdyen(models.Model): def signParams(parms): signing_string = ':'.join( escapeVal(v) - for v in chain(pycompat.keys(parms), pycompat.values(parms)) + for v in chain(parms.keys(), parms.values()) ) - hm = hmac.new(hmac_key, signing_string, hashlib.sha256) + hm = hmac.new(hmac_key, signing_string.encode('utf-8'), hashlib.sha256) return base64.b64encode(hm.digest()) assert inout in ('in', 'out') @@ -81,7 +79,7 @@ class AcquirerAdyen(models.Model): hmac_key = binascii.a2b_hex(self.adyen_skin_hmac_key.encode('ascii')) raw_values = {k: values.get(k.encode('ascii'), '') for k in keys if k in values} - raw_values_ordered = OrderedDict(sorted(pycompat.items(raw_values), key=lambda t: t[0])) + raw_values_ordered = OrderedDict(sorted(raw_values.items(), key=lambda t: t[0])) return signParams(raw_values_ordered) diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index fcfab1e3ac2..7388a1a4329 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- +import io import requests from lxml import etree, objectify -from StringIO import StringIO from xml.etree import ElementTree as ET from uuid import uuid4 @@ -21,7 +21,7 @@ def strip_ns(xml, ns): :rtype: etree._Element :return: the parsed xml string with the namespace prefix removed """ - it = ET.iterparse(StringIO(xml)) + it = ET.iterparse(io.BytesIO(xml)) ns_prefix = '{%s}' % XMLNS for _, el in it: if el.tag.startswith(ns_prefix): @@ -58,7 +58,7 @@ class AuthorizeAPI(): :param etree._Element data: etree data to process """ - data = etree.tostring(data, xml_declaration=True, encoding='utf-8') + data = etree.tostring(data, encoding='utf-8') r = requests.post(self.url, data=data, headers={'Content-Type': 'text/xml'}) r.raise_for_status() response = strip_ns(r.content, XMLNS) diff --git a/addons/payment_authorize/models/payment.py b/addons/payment_authorize/models/payment.py index 7d787a54caf..ad792a08987 100644 --- a/addons/payment_authorize/models/payment.py +++ b/addons/payment_authorize/models/payment.py @@ -54,7 +54,7 @@ class PaymentAcquirerAuthorize(models.Model): values['x_fp_timestamp'], values['x_amount'], values['x_currency_code']]) - return hmac.new(str(values['x_trans_key']), data, hashlib.md5).hexdigest() + return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest() @api.multi def authorize_form_generate_values(self, values): diff --git a/addons/payment_authorize/tests/test_authorize.py b/addons/payment_authorize/tests/test_authorize.py index b02d3d81209..59fb401b091 100644 --- a/addons/payment_authorize/tests/test_authorize.py +++ b/addons/payment_authorize/tests/test_authorize.py @@ -11,7 +11,7 @@ import odoo from odoo.addons.payment.models.payment_acquirer import ValidationError from odoo.addons.payment.tests.common import PaymentAcquirerCommon from odoo.addons.payment_authorize.controllers.main import AuthorizeController -from odoo.tools import mute_logger, pycompat +from odoo.tools import mute_logger @odoo.tests.common.at_install(True) @@ -39,7 +39,7 @@ class AuthorizeForm(AuthorizeCommon): values['x_fp_timestamp'], values['x_amount'], ]) + '^' - return hmac.new(str(values['x_trans_key']), data, hashlib.md5).hexdigest() + return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest() def test_10_Authorize_form_render(self): self.assertEqual(self.authorize.environment, 'test', 'test without test environment') @@ -91,11 +91,11 @@ class AuthorizeForm(AuthorizeCommon): tree = objectify.fromstring(res) self.assertEqual(tree.get('action'), 'https://test.authorize.net/gateway/transact.dll', 'Authorize: wrong form POST url') for el in tree.iterfind('input'): - values = list(pycompat.values(el.attrib)) + values = list(el.attrib.values()) if values[1] in ['submit', 'x_fp_hash', 'return_url', 'x_state', 'x_ship_to_state']: continue self.assertEqual( - unicode(values[2], "utf-8"), + values[2], form_values[values[1]], 'Authorize: wrong value for input %s: received %s instead of %s' % (values[1], values[2], form_values[values[1]]) ) diff --git a/addons/payment_buckaroo/controllers/main.py b/addons/payment_buckaroo/controllers/main.py index 5af6a454b47..40e4ae539e4 100644 --- a/addons/payment_buckaroo/controllers/main.py +++ b/addons/payment_buckaroo/controllers/main.py @@ -6,7 +6,6 @@ import werkzeug from odoo import http from odoo.http import request -from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -27,6 +26,6 @@ class BuckarooController(http.Controller): """ Buckaroo.""" _logger.info('Buckaroo: entering form_feedback with post data %s', pprint.pformat(post)) # debug request.env['payment.transaction'].sudo().form_feedback(post, 'buckaroo') - post = {key.upper(): value for key, value in pycompat.items(post)} + post = {key.upper(): value for key, value in post.items()} return_url = post.get('ADD_RETURNDATA') or '/' return werkzeug.utils.redirect(return_url) diff --git a/addons/payment_buckaroo/models/payment.py b/addons/payment_buckaroo/models/payment.py index a07ca83318d..ec0cadfab01 100644 --- a/addons/payment_buckaroo/models/payment.py +++ b/addons/payment_buckaroo/models/payment.py @@ -8,7 +8,6 @@ from odoo import api, fields, models, _ from odoo.addons.payment.models.payment_acquirer import ValidationError from odoo.addons.payment_buckaroo.controllers.main import BuckarooController -from odoo.tools import pycompat from odoo.tools.float_utils import float_compare _logger = logging.getLogger(__name__) @@ -21,7 +20,7 @@ def normalize_keys_upper(data): convert everything to upper case to be able to easily detected the presence of a parameter by checking the uppercase key only """ - return {key.upper(): val for key, val in pycompat.items(data)} + return {key.upper(): val for key, val in data.items()} class AcquirerBuckaroo(models.Model): @@ -73,7 +72,7 @@ class AcquirerBuckaroo(models.Model): del values[key] break - items = sorted(pycompat.items(values), key=lambda pair: pair[0].lower()) + items = sorted(values.items(), key=lambda pair: pair[0].lower()) sign = ''.join('%s=%s' % (k, urls.url_unquote_plus(v)) for k, v in items) else: sign = ''.join('%s=%s' % (k, get_value(k)) for k in keys) diff --git a/addons/payment_ogone/models/payment.py b/addons/payment_ogone/models/payment.py index 9ac90f9478f..7a2adf37d45 100644 --- a/addons/payment_ogone/models/payment.py +++ b/addons/payment_ogone/models/payment.py @@ -1,4 +1,5 @@ # coding: utf-8 +import base64 import datetime import logging import time @@ -14,7 +15,7 @@ from odoo import api, fields, models, _ from odoo.addons.payment.models.payment_acquirer import ValidationError from odoo.addons.payment_ogone.controllers.main import OgoneController from odoo.addons.payment_ogone.data import ogone -from odoo.tools import float_round, DEFAULT_SERVER_DATE_FORMAT, pycompat +from odoo.tools import float_round, DEFAULT_SERVER_DATE_FORMAT from odoo.tools.float_utils import float_compare, float_repr from odoo.tools.safe_eval import safe_eval @@ -141,7 +142,7 @@ class PaymentAcquirerOgone(models.Model): ] return key.upper() in keys - items = sorted((k.upper(), v) for k, v in pycompat.items(values)) + items = sorted((k.upper(), v) for k, v in values.items()) sign = ''.join('%s=%s%s' % (k, v, key) for k, v in items if v and filter_key(k)) sign = sign.encode("utf-8") shasign = sha1(sign).hexdigest() @@ -469,7 +470,7 @@ class PaymentTxOgone(models.Model): self.write({ 'state': new_state, 'acquirer_reference': tree.get('PAYID'), - 'html_3ds': str(tree.HTML_ANSWER).decode('base64') + 'html_3ds': base64.b64decode(tree.HTML_ANSWER.decode('ascii')), }) elif status in self._ogone_wait_tx_status and tries > 0: time.sleep(0.5) diff --git a/addons/payment_payumoney/models/payment.py b/addons/payment_payumoney/models/payment.py index 243ef5cef53..cbda9e64527 100644 --- a/addons/payment_payumoney/models/payment.py +++ b/addons/payment_payumoney/models/payment.py @@ -49,7 +49,7 @@ class PaymentAcquirerPayumoney(models.Model): sign = ''.join('%s|' % (values.get(k) or '') for k in keys) sign = self.payumoney_merchant_salt + sign + self.payumoney_merchant_key - shasign = hashlib.sha512(sign).hexdigest() + shasign = hashlib.sha512(sign.encode('utf-8')).hexdigest() return shasign @api.multi diff --git a/addons/payment_sips/models/payment.py b/addons/payment_sips/models/payment.py index f01c59d1880..a6c9b390eae 100644 --- a/addons/payment_sips/models/payment.py +++ b/addons/payment_sips/models/payment.py @@ -66,7 +66,7 @@ class AcquirerSips(models.Model): if self.environment == 'prod': key = getattr(self, 'sips_secret') - shasign = sha256(data + key) + shasign = sha256((data + key).encode('utf-8')) return shasign.hexdigest() @api.multi diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py index 1bb4ea9aad8..55cd3d690bb 100644 --- a/addons/payment_stripe/models/payment.py +++ b/addons/payment_stripe/models/payment.py @@ -17,8 +17,8 @@ STRIPE_HEADERS = {'Stripe-Version': '2016-03-07'} # The following currencies are integer only, see https://stripe.com/docs/currencies#zero-decimal INT_CURRENCIES = [ - 'BIF', 'XAF', 'XPF', 'CLP', 'KMF', 'DJF', 'GNF', 'JPY', 'MGA', 'PYGรญ', 'RWF', 'KRW', 'VUV', - 'VND', 'XOF' + u'BIF', u'XAF', u'XPF', u'CLP', u'KMF', u'DJF', u'GNF', u'JPY', u'MGA', u'PYGรญ', u'RWF', u'KRW', + u'VUV', u'VND', u'XOF' ]; @@ -154,10 +154,13 @@ class PaymentTransactionStripe(models.Model): def _stripe_form_get_tx_from_data(self, data): """ Given a data dict coming from stripe, verify it and find the related transaction record. """ - reference = data['metadata']['reference'] + reference = data.get('metadata', {}).get('reference') if not reference: - error_msg = _('Stripe: invalid reply received from provider, missing reference') - _logger.error(error_msg, data['metadata']) + error_msg = _( + 'Stripe: invalid reply received from provider, missing reference. Additional message: %s' + % data.get('error', {}).get('message', '') + ) + _logger.error(error_msg) raise ValidationError(error_msg) tx = self.search([('reference', '=', reference)]) if not tx: diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index 3a736330657..93dcc606d38 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -2,6 +2,11 @@ odoo.define('payment_stripe.stripe', function(require) { "use strict"; var ajax = require('web.ajax'); + var core = require('web.core'); + var _t = core._t; + var qweb = core.qweb; + ajax.loadXML('/payment_stripe/static/src/xml/stripe_templates.xml', qweb); + // The following currencies are integer only, see // https://stripe.com/docs/currencies#zero-decimal var int_currencies = [ @@ -33,6 +38,10 @@ odoo.define('payment_stripe.stripe', function(require) { }).done(function(data){ handler.isTokenGenerate = false; window.location.href = data; + }).fail(function(){ + var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.message; + var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')})); + wizard.appendTo($('body')).modal({'keyboard': true}); }); }, }); diff --git a/addons/payment_stripe/static/src/xml/stripe_templates.xml b/addons/payment_stripe/static/src/xml/stripe_templates.xml new file mode 100644 index 00000000000..c5966fe6274 --- /dev/null +++ b/addons/payment_stripe/static/src/xml/stripe_templates.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> +<t t-name="stripe.error"> + <div class="modal fade"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button> + <h4 class="modal-title">Error</h4> + </div> + <div class="modal-body"> + <t t-esc="msg"></t> + </div> + <div class="modal-footer"> + <a href="#" class="btn btn-link btn-xs" data-dismiss="modal">Close</a> + </div> + </div> + </div> + </div> +</t> +</templates> diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index c7281942cf5..a0097b7ccd3 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -8,7 +8,7 @@ import psycopg2 import pytz from odoo import api, fields, models, tools, _ -from odoo.tools import float_is_zero, pycompat +from odoo.tools import float_is_zero from odoo.exceptions import UserError from odoo.http import request from odoo.addons import decimal_precision as dp @@ -334,7 +334,7 @@ class PosOrder(models.Model): # round tax lines per order if rounding_method == 'round_globally': - for group_key, group_value in pycompat.items(grouped_data): + for group_key, group_value in grouped_data.items(): if group_key[0] == 'tax': for line in group_value: line['credit'] = cur.round(line['credit']) @@ -352,7 +352,7 @@ class PosOrder(models.Model): order.write({'state': 'done', 'account_move': move.id}) all_lines = [] - for group_key, group_data in pycompat.items(grouped_data): + for group_key, group_data in grouped_data.items(): for value in group_data: all_lines.append((0, 0, value),) if move: # In case no order was changed @@ -1033,7 +1033,7 @@ class ReportSaleDetails(models.AbstractModel): 'total_paid': user_currency.round(total), 'payments': payments, 'company_name': self.env.user.company_id.name, - 'taxes': list(pycompat.values(taxes)), + 'taxes': list(taxes.values()), 'products': sorted([{ 'product_id': product.id, 'product_name': product.name, @@ -1042,7 +1042,7 @@ class ReportSaleDetails(models.AbstractModel): 'price_unit': price_unit, 'discount': discount, 'uom': product.uom_id.name - } for (product, price_unit, discount), qty in pycompat.items(products_sold)], key=lambda l: l['product_name']) + } for (product, price_unit, discount), qty in products_sold.items()], key=lambda l: l['product_name']) } @api.multi diff --git a/addons/point_of_sale/static/src/js/tests.js b/addons/point_of_sale/static/src/js/tests.js index ec6ed65e3fe..36fd7a49f8a 100644 --- a/addons/point_of_sale/static/src/js/tests.js +++ b/addons/point_of_sale/static/src/js/tests.js @@ -315,7 +315,8 @@ odoo.define('point_of_sale.tour.acceptance', function (require) { trigger: ".header-button", }, { content: "confirm closing the frontend", - trigger: ".header-button", + trigger: ".header-button.confirm", + run: function() {}, //it's a check, }]); Tour.register('pos_basic_order', { test: true, url: '/pos/web' }, steps); diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py index 1f5019a9810..b8e01be96c3 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -129,11 +129,13 @@ class CustomerPortal(Controller): @route(['/my/account'], type='http', auth='user', website=True) def account(self, redirect=None, **post): + values = self._prepare_portal_layout_values() partner = request.env.user.partner_id - values = { + values.update({ 'error': {}, - 'error_message': [] - } + 'error_message': [], + 'hide_current_user': True, + }) if post: error, error_message = self.details_form_validate(post) @@ -192,7 +194,7 @@ class CustomerPortal(Controller): error["vat"] = 'error' # error message for empty required fields - if [err for err in tools.pycompat.values(error) if err == 'missing']: + if [err for err in error.values() if err == 'missing']: error_message.append(_('Some required fields are empty.')) unknown = [k for k in data if k not in self.MANDATORY_BILLING_FIELDS + self.OPTIONAL_BILLING_FIELDS] diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 87e566b874e..8c4e99809c5 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -85,7 +85,7 @@ <div class="o_my_contact" t-if="sales_user"> <t t-call="portal.portal_contact"/> </div> - <div class="o_portal_my_details"> + <div class="o_portal_my_details" t-if="not hide_current_user and user"> <h3 class="page-header">Your Details <a href="/my/account" class="btn btn-default btn-xs">Change</a></h3> <div t-field="user_id.partner_id" t-options='{"widget": "contact", "fields": ["email", "phone", "address", "name"]}'/> </div> diff --git a/addons/portal/wizard/portal_wizard_views.xml b/addons/portal/wizard/portal_wizard_views.xml index b1cb31e63ba..909b822cf8d 100644 --- a/addons/portal/wizard/portal_wizard_views.xml +++ b/addons/portal/wizard/portal_wizard_views.xml @@ -25,7 +25,7 @@ </div> <field name="user_ids"> <tree string="Contacts" editable="bottom" create="false" delete="false"> - <field name="partner_id"/> + <field name="partner_id" force_save="1"/> <field name="email"/> <field name="in_portal"/> </tree> diff --git a/addons/pos_restaurant/static/src/js/multiprint.js b/addons/pos_restaurant/static/src/js/multiprint.js index 3f2d482c1e9..dac0a906850 100644 --- a/addons/pos_restaurant/static/src/js/multiprint.js +++ b/addons/pos_restaurant/static/src/js/multiprint.js @@ -191,7 +191,12 @@ models.Order = models.Order.extend({ var product_id = line.get_product().id; if (typeof resume[line_hash] === 'undefined') { - resume[line_hash] = { qty: qty, note: note, product_id: product_id }; + resume[line_hash] = { + qty: qty, + note: note, + product_id: product_id, + product_name_wrapped: line.generate_wrapped_product_name(), + }; } else { resume[line_hash].qty += qty; } @@ -222,6 +227,7 @@ models.Order = models.Order.extend({ add.push({ 'id': curr.product_id, 'name': this.pos.db.get_product_by_id(curr.product_id).display_name, + 'name_wrapped': curr.product_name_wrapped, 'note': curr.note, 'qty': curr.qty, }); @@ -229,6 +235,7 @@ models.Order = models.Order.extend({ add.push({ 'id': curr.product_id, 'name': this.pos.db.get_product_by_id(curr.product_id).display_name, + 'name_wrapped': curr.product_name_wrapped, 'note': curr.note, 'qty': curr.qty - old.qty, }); @@ -236,6 +243,7 @@ models.Order = models.Order.extend({ rem.push({ 'id': curr.product_id, 'name': this.pos.db.get_product_by_id(curr.product_id).display_name, + 'name_wrapped': curr.product_name_wrapped, 'note': curr.note, 'qty': old.qty - curr.qty, }); @@ -248,6 +256,7 @@ models.Order = models.Order.extend({ rem.push({ 'id': old.product_id, 'name': this.pos.db.get_product_by_id(old.product_id).display_name, + 'name_wrapped': old.product_name_wrapped, 'note': old.note, 'qty': old.qty, }); diff --git a/addons/pos_restaurant/static/src/xml/multiprint.xml b/addons/pos_restaurant/static/src/xml/multiprint.xml index 4f3d0cf7379..282d07421c2 100644 --- a/addons/pos_restaurant/static/src/xml/multiprint.xml +++ b/addons/pos_restaurant/static/src/xml/multiprint.xml @@ -8,6 +8,15 @@ </span> </t> + <t t-name="NameWrapped"> + <t t-foreach="change.name_wrapped.slice(1)" t-as="wrapped_line"> + <line> + <left></left> + <right><t t-esc="wrapped_line"/></right> + </line> + </t> + </t> + <t t-name="OrderChangeReceipt"> <receipt align='center' @@ -33,8 +42,9 @@ <t t-foreach="changes.cancelled" t-as="change"> <line> <left><value><t t-esc="change.qty" /></value></left> - <right><t t-esc="change.name" /></right> + <right><t t-esc="change.name_wrapped[0]" /></right> </line> + <t t-call="NameWrapped"/> <t t-if="change.note"> <line> <left>NOTE</left> @@ -55,8 +65,9 @@ <t t-foreach="changes.new" t-as="change"> <line> <left><value><t t-esc="change.qty" /></value></left> - <right><t t-esc="change.name" /></right> + <right><t t-esc="change.name_wrapped[0]" /></right> </line> + <t t-call="NameWrapped"/> <t t-if="change.note"> <line> <left>NOTE</left> diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 3466edc33ed..8437bf16d74 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -154,7 +154,7 @@ class ProductProduct(models.Model): quantity = self._context.get('quantity', 1.0) # Support context pricelists specified as display_name or ID for compatibility - if isinstance(pricelist_id_or_name, basestring): + if isinstance(pricelist_id_or_name, pycompat.string_types): pricelist_name_search = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1) if pricelist_name_search: pricelist = self.env['product.pricelist'].browse([pricelist_name_search[0][0]]) diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py index a0457f021ba..392447809f4 100644 --- a/addons/product/models/product_pricelist.py +++ b/addons/product/models/product_pricelist.py @@ -90,7 +90,7 @@ class Pricelist(models.Model): results = {} for pricelist in pricelists: subres = pricelist._compute_price_rule(products_qty_partner, date=date, uom_id=uom_id) - for product_id, price in pycompat.items(subres): + for product_id, price in subres.items(): results.setdefault(product_id, {}) results[product_id][pricelist.id] = price return results @@ -254,11 +254,11 @@ class Pricelist(models.Model): self.ensure_one() return { product_id: res_tuple[0] - for product_id, res_tuple in pycompat.items(self._compute_price_rule( + for product_id, res_tuple in self._compute_price_rule( list(pycompat.izip(products, quantities, partners)), date=date, uom_id=uom_id - )) + ).items() } def get_product_price(self, product, quantity, partner, date=False, uom_id=False): @@ -280,7 +280,7 @@ class Pricelist(models.Model): @api.multi def price_get(self, prod_id, qty, partner=None): """ Multi pricelist, mono product - returns price per pricelist """ - return {key: price[0] for key, price in pycompat.items(self.price_rule_get(prod_id, qty, partner=partner))} + return {key: price[0] for key, price in self.price_rule_get(prod_id, qty, partner=partner).items()} @api.multi def price_rule_get_multi(self, products_by_qty_by_partner): diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 646a813c63c..38fb9d4d1e1 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -170,7 +170,7 @@ class ProductTemplate(models.Model): quantity = self._context.get('quantity', 1.0) # Support context pricelists specified as display_name or ID for compatibility - if isinstance(pricelist_id_or_name, basestring): + if isinstance(pricelist_id_or_name, pycompat.string_types): pricelist_data = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1) if pricelist_data: pricelist = self.env['product.pricelist'].browse(pricelist_data[0][0]) @@ -417,9 +417,9 @@ class ProductTemplate(models.Model): variants_to_activate = self.env['product.product'] variants_to_unlink = self.env['product.product'] for product_id in tmpl_id.product_variant_ids: - if not product_id.active and product_id.attribute_value_ids in variant_matrix: + if not product_id.active and product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant) in variant_matrix: variants_to_activate |= product_id - elif product_id.attribute_value_ids not in variant_matrix: + elif product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant) not in variant_matrix: variants_to_unlink |= product_id if variants_to_activate: variants_to_activate.write({'active': True}) diff --git a/addons/product_expiry/models/production_lot.py b/addons/product_expiry/models/production_lot.py index 89d2a7569ff..6be9a2865dd 100644 --- a/addons/product_expiry/models/production_lot.py +++ b/addons/product_expiry/models/production_lot.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import datetime from odoo import api, fields, models -from odoo.tools import pycompat class StockProductionLot(models.Model): @@ -54,5 +53,5 @@ class StockProductionLot(models.Model): @api.onchange('product_id') def _onchange_product(self): dates_dict = self._get_dates() - for field, value in pycompat.items(dates_dict): + for field, value in dates_dict.items(): setattr(self, field, value) diff --git a/addons/product_extended/views/product_views.xml b/addons/product_extended/views/product_views.xml index 2867a33f868..4e065e19604 100644 --- a/addons/product_extended/views/product_views.xml +++ b/addons/product_extended/views/product_views.xml @@ -23,6 +23,7 @@ <field name="model">product.product</field> <field name="priority">4</field> <field name="inherit_id" ref="product.product_normal_form_view"/> + <field name="groups_id" eval="[(4, ref('mrp.group_mrp_user'))]"/> <field name="arch" type="xml"> <div name="standard_price_uom" position="after"> <button name="compute_price" diff --git a/addons/product_margin/models/product_product.py b/addons/product_margin/models/product_product.py index f203a9e146b..3dd735b26a4 100644 --- a/addons/product_margin/models/product_product.py +++ b/addons/product_margin/models/product_product.py @@ -4,7 +4,6 @@ import time from odoo import api, fields, models -from odoo.tools import pycompat class ProductProduct(models.Model): @@ -139,6 +138,6 @@ class ProductProduct(models.Model): res[val.id]['expected_margin'] = res[val.id]['sale_expected'] - res[val.id]['normal_cost'] res[val.id]['total_margin_rate'] = res[val.id]['turnover'] and res[val.id]['total_margin'] * 100 / res[val.id]['turnover'] or 0.0 res[val.id]['expected_margin_rate'] = res[val.id]['sale_expected'] and res[val.id]['expected_margin'] * 100 / res[val.id]['sale_expected'] or 0.0 - for k, v in pycompat.items(res[val.id]): + for k, v in res[val.id].items(): setattr(val, k, v) return res diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index 76079d9bdff..621113a385b 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -8,7 +8,6 @@ from odoo.http import request from odoo.addons.portal.controllers.portal import get_records_pager, CustomerPortal, pager as portal_pager from odoo.osv.expression import OR -from odoo.tools import pycompat class CustomerPortal(CustomerPortal): @@ -157,7 +156,7 @@ class CustomerPortal(CustomerPortal): 'searchbar_inputs': searchbar_inputs, 'search_in': search_in, 'sortby': sortby, - 'searchbar_filters': OrderedDict(sorted(pycompat.items(searchbar_filters))), + 'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())), 'filterby': filterby, }) return request.render("project.portal_my_tasks", values) diff --git a/addons/project/models/project.py b/addons/project/models/project.py index b2b9fccab1c..756283820d9 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -583,7 +583,7 @@ class Task(models.Model): _check_rec(eview) - res['arch'] = etree.tostring(eview) + res['arch'] = etree.tostring(eview, encoding='unicode') # replace reference of 'Hours' to 'Day(s)' for f in res['fields']: diff --git a/addons/purchase/controllers/portal.py b/addons/purchase/controllers/portal.py index 2e8c77bbfa3..4a169daba44 100644 --- a/addons/purchase/controllers/portal.py +++ b/addons/purchase/controllers/portal.py @@ -6,7 +6,6 @@ from collections import OrderedDict from odoo import http from odoo.exceptions import AccessError from odoo.http import request -from odoo.tools import pycompat from odoo.tools.translate import _ from odoo.addons.portal.controllers.portal import get_records_pager, pager as portal_pager, CustomerPortal @@ -88,7 +87,7 @@ class CustomerPortal(CustomerPortal): 'archive_groups': archive_groups, 'searchbar_sortings': searchbar_sortings, 'sortby': sortby, - 'searchbar_filters': OrderedDict(sorted(pycompat.items(searchbar_filters))), + 'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())), 'filterby': filterby, 'default_url': '/my/purchase', }) diff --git a/addons/rating/models/rating.py b/addons/rating/models/rating.py index 78369403d2d..f4bd17b48f3 100644 --- a/addons/rating/models/rating.py +++ b/addons/rating/models/rating.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import uuid from odoo import api, fields, models, tools, _ from odoo.modules.module import get_resource_path -from odoo.tools import pycompat RATING_LIMIT_SATISFIED = 7 RATING_LIMIT_OK = 3 @@ -70,7 +69,7 @@ class Rating(models.Model): for rating in self: try: image_path = get_resource_path('rating', 'static/src/img', 'rating_%s.png' % (int(rating.rating),)) - rating.rating_image = open(image_path, 'rb').read().encode('base64') + rating.rating_image = base64.b64decode(open(image_path, 'rb').read()) except (IOError, OSError): rating.rating_image = False @@ -275,7 +274,7 @@ class RatingMixin(models.AbstractModel): values.update((d['rating'], d['rating_count']) for d in data) # add other stats if add_stats: - rating_number = sum(pycompat.values(values)) + rating_number = sum(values.values()) result = { 'repartition': values, 'avg': sum(float(key * values[key]) for key in values) / rating_number if rating_number > 0 else 0, diff --git a/addons/rating_project/models/project.py b/addons/rating_project/models/project.py index 265e72b9179..36ad3a2cfba 100644 --- a/addons/rating_project/models/project.py +++ b/addons/rating_project/models/project.py @@ -3,7 +3,6 @@ from datetime import timedelta -from odoo.tools import pycompat from odoo import api, fields, models, _ from odoo.tools.safe_eval import safe_eval @@ -73,13 +72,13 @@ class Project(models.Model): domain = [('create_date', '>=', fields.Datetime.to_string(fields.datetime.now() - timedelta(days=30)))] for project in self: activity = project.tasks.rating_get_grades(domain) - project.percentage_satisfaction_project = activity['great'] * 100 / sum(pycompat.values(activity)) if sum(pycompat.values(activity)) else -1 + project.percentage_satisfaction_project = activity['great'] * 100 / sum(activity.values()) if sum(activity.values()) else -1 @api.one @api.depends('tasks.rating_ids.rating') def _compute_percentage_satisfaction_task(self): activity = self.tasks.rating_get_grades() - self.percentage_satisfaction_task = activity['great'] * 100 / sum(pycompat.values(activity)) if sum(pycompat.values(activity)) else -1 + self.percentage_satisfaction_task = activity['great'] * 100 / sum(activity.values()) if sum(activity.values()) else -1 percentage_satisfaction_task = fields.Integer( compute='_compute_percentage_satisfaction_task', string="Happy % on Task", store=True, default=-1) diff --git a/addons/resource/models/resource_mixin.py b/addons/resource/models/resource_mixin.py index 0d45ec646a1..f1d20cc6772 100644 --- a/addons/resource/models/resource_mixin.py +++ b/addons/resource/models/resource_mixin.py @@ -4,7 +4,7 @@ from datetime import timedelta from odoo import api, fields, models -from odoo.tools import pycompat +from odoo.tools import float_utils class ResourceMixin(models.AbstractModel): _name = "resource.mixin" @@ -47,7 +47,7 @@ class ResourceMixin(models.AbstractModel): theoric_hours = self.get_day_work_hours_count(day_intervals[0][0].date(), calendar=calendar) work_time = sum((interval[1] - interval[0] for interval in day_intervals), timedelta()) total_work_time += work_time - days_count += pycompat.round((work_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 + days_count += float_utils.round((work_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 return { 'days': days_count, 'hours': total_work_time.total_seconds() / 3600, @@ -70,7 +70,7 @@ class ResourceMixin(models.AbstractModel): for day_intervals in calendar._iter_leave_intervals(from_datetime, to_datetime, self.resource_id.id): theoric_hours = self.get_day_work_hours_count(day_intervals[0][0].date(), calendar=calendar) leave_time = sum((interval[1] - interval[0] for interval in day_intervals), timedelta()) - days_count += pycompat.round((leave_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 + days_count += float_utils.round((leave_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 return days_count def iter_leaves(self, from_datetime, to_datetime, calendar=None): diff --git a/addons/sale/models/account_invoice.py b/addons/sale/models/account_invoice.py index b10d8d407fd..1cf260da62e 100644 --- a/addons/sale/models/account_invoice.py +++ b/addons/sale/models/account_invoice.py @@ -3,7 +3,6 @@ from itertools import groupby from odoo import api, fields, models, _ -from odoo.tools import pycompat class AccountInvoice(models.Model): @@ -58,7 +57,7 @@ class AccountInvoice(models.Model): result = super(AccountInvoice, self)._refund_cleanup_lines(lines) if self.env.context.get('mode') == 'modify': for i, line in enumerate(lines): - for name, field in pycompat.items(line._fields): + for name, field in line._fields.items(): if name == 'sale_line_ids': result[i][2][name] = [(6, 0, line[name].ids)] line[name] = False diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index fbfe45bcdb1..02f8710da6f 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -9,7 +9,7 @@ from werkzeug.urls import url_encode from odoo import api, fields, models, _ from odoo.exceptions import UserError, AccessError -from odoo.tools import float_is_zero, float_compare, DEFAULT_SERVER_DATETIME_FORMAT, pycompat +from odoo.tools import float_is_zero, float_compare, DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.misc import formatLang from odoo.addons import decimal_precision as dp @@ -404,7 +404,7 @@ class SaleOrder(models.Model): if not invoices: raise UserError(_('There is no invoicable line.')) - for invoice in pycompat.values(invoices): + for invoice in invoices.values(): if not invoice.invoice_line_ids: raise UserError(_('There is no invoicable line.')) # If invoice is negative, do a refund invoice instead @@ -421,7 +421,7 @@ class SaleOrder(models.Model): invoice.message_post_with_view('mail.message_origin_link', values={'self': invoice, 'origin': references[invoice]}, subtype_id=self.env.ref('mail.mt_note').id) - return [inv.id for inv in pycompat.values(invoices)] + return [inv.id for inv in invoices.values()] @api.multi def action_draft(self): @@ -561,7 +561,7 @@ class SaleOrder(models.Model): if tax.include_base_amount: base_tax += tax.compute_all(price_reduce + base_tax, quantity=1, product=line.product_id, partner=self.partner_shipping_id)['taxes'][0]['amount'] - res = sorted(pycompat.items(res), key=lambda l: l[0].sequence) + res = sorted(res.items(), key=lambda l: l[0].sequence) res = [(l[0].name, l[1]) for l in res] return res diff --git a/addons/sale/models/sale_analytic.py b/addons/sale/models/sale_analytic.py index 733bd74bc2a..96f2f896f44 100644 --- a/addons/sale/models/sale_analytic.py +++ b/addons/sale/models/sale_analytic.py @@ -3,7 +3,6 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.tools import pycompat class SaleOrderLine(models.Model): @@ -41,7 +40,7 @@ class SaleOrderLine(models.Model): qty = d['unit_amount'] lines[line] += qty - for line, qty in pycompat.items(lines): + for line, qty in lines.items(): line.qty_delivered = qty return True diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index 84dbee6a3fd..2b6c082b150 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.exceptions import UserError, AccessError -from odoo.tools import pycompat from .test_sale_common import TestSale @@ -18,10 +17,10 @@ class TestSaleOrder(TestSale): 'partner_id': self.partner.id, 'partner_invoice_id': self.partner.id, 'partner_shipping_id': self.partner.id, - 'order_line': [(0, 0, {'name': p.name, 'product_id': p.id, 'product_uom_qty': 2, 'product_uom': p.uom_id.id, 'price_unit': p.list_price}) for (_, p) in pycompat.items(self.products)], + 'order_line': [(0, 0, {'name': p.name, 'product_id': p.id, 'product_uom_qty': 2, 'product_uom': p.uom_id.id, 'price_unit': p.list_price}) for p in self.products.values()], 'pricelist_id': self.env.ref('product.list0').id, }) - self.assertEqual(so.amount_total, sum([2 * p.list_price for (k, p) in pycompat.items(self.products)]), 'Sale: total amount is wrong') + self.assertEqual(so.amount_total, sum([2 * p.list_price for p in self.products.values()]), 'Sale: total amount is wrong') # send quotation so.force_quotation_send() @@ -36,7 +35,7 @@ class TestSaleOrder(TestSale): inv_id = so.action_invoice_create() inv = inv_obj.browse(inv_id) self.assertEqual(len(inv.invoice_line_ids), 2, 'Sale: invoice is missing lines') - self.assertEqual(inv.amount_total, sum([2 * p.list_price if p.invoice_policy == 'order' else 0 for (k, p) in pycompat.items(self.products)]), 'Sale: invoice total amount is wrong') + 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') @@ -47,7 +46,7 @@ class TestSaleOrder(TestSale): inv_id = so.action_invoice_create() inv = inv_obj.browse(inv_id) self.assertEqual(len(inv.invoice_line_ids), 2, 'Sale: second invoice is missing lines') - self.assertEqual(inv.amount_total, sum([2 * p.list_price if p.invoice_policy == 'delivery' else 0 for (k, p) in pycompat.items(self.products)]), 'Sale: second invoice total amount is wrong') + self.assertEqual(inv.amount_total, sum([2 * p.list_price if p.invoice_policy == 'delivery' else 0 for p in self.products.values()]), 'Sale: second invoice total amount is wrong') self.assertTrue(so.invoice_status == 'invoiced', 'Sale: SO status after invoicing everything should be "invoiced"') self.assertTrue(len(so.invoice_ids) == 2, 'Sale: invoice is missing') # go over the sold quantity @@ -72,7 +71,7 @@ class TestSaleOrder(TestSale): 'partner_id': self.partner.id, 'partner_invoice_id': self.partner.id, 'partner_shipping_id': self.partner.id, - 'order_line': [(0, 0, {'name': p.name, 'product_id': p.id, 'product_uom_qty': 2, 'product_uom': p.uom_id.id, 'price_unit': p.list_price}) for (_, p) in pycompat.items(self.products)], + 'order_line': [(0, 0, {'name': p.name, 'product_id': p.id, 'product_uom_qty': 2, 'product_uom': p.uom_id.id, 'price_unit': p.list_price}) for p in self.products.values()], 'pricelist_id': self.env.ref('product.list0').id, }) diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 7cdcfbdb229..eafe4222161 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -289,9 +289,9 @@ <div> <field name="customer_lead" class="oe_inline"/> days </div> - <label for="analytic_tag_ids"/> + <label for="analytic_tag_ids" groups="analytic.group_analytic_accounting"/> <div> - <field name="analytic_tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> + <field name="analytic_tag_ids" widget="many2many_tags" groups="analytic.group_analytic_accounting" options="{'color_field': 'color'}"/> </div> </group> </group> diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index 5c0c8091dbe..16c122cb9af 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -63,7 +63,7 @@ class SaleOrder(models.Model): @api.multi def action_cancel(self): - self.order_line.mapped('procurement_ids').cancel() + self.mapped('order_line').mapped('procurement_ids').cancel() return super(SaleOrder, self).action_cancel() @api.multi diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py index 5a4755d2c16..3dd6d5103ca 100644 --- a/addons/sale_stock/tests/test_sale_stock.py +++ b/addons/sale_stock/tests/test_sale_stock.py @@ -3,7 +3,6 @@ from odoo.addons.sale.tests.test_sale_common import TestSale from odoo.exceptions import UserError -from odoo.tools import pycompat class TestSaleStock(TestSale): @@ -17,7 +16,7 @@ class TestSaleStock(TestSale): 'partner_id': self.partner.id, 'partner_invoice_id': self.partner.id, 'partner_shipping_id': self.partner.id, - 'order_line': [(0, 0, {'name': p.name, 'product_id': p.id, 'product_uom_qty': 2, 'product_uom': p.uom_id.id, 'price_unit': p.list_price}) for (_, p) in pycompat.items(self.products)], + 'order_line': [(0, 0, {'name': p.name, 'product_id': p.id, 'product_uom_qty': 2, 'product_uom': p.uom_id.id, 'price_unit': p.list_price}) for p in self.products.values()], 'pricelist_id': self.env.ref('product.list0').id, 'picking_policy': 'direct', }) @@ -74,7 +73,7 @@ class TestSaleStock(TestSale): 'partner_id': self.partner.id, 'partner_invoice_id': self.partner.id, 'partner_shipping_id': self.partner.id, - 'order_line': [(0, 0, {'name': p.name, 'product_id': p.id, 'product_uom_qty': 2, 'product_uom': p.uom_id.id, 'price_unit': p.list_price}) for (_, p) in pycompat.items(self.products)], + 'order_line': [(0, 0, {'name': p.name, 'product_id': p.id, 'product_uom_qty': 2, 'product_uom': p.uom_id.id, 'price_unit': p.list_price}) for p in self.products.values()], 'pricelist_id': self.env.ref('product.list0').id, 'picking_policy': 'direct', }) diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 7aff42ff99d..975b79523a9 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -59,7 +59,7 @@ class SaleTimesheetController(http.Controller): values['dashboard'] = dashboard_values # -- Time Repartition (per employee) - repartition_domain = domain + [('timesheet_invoice_type', '!=', False)] # force billable type + repartition_domain = domain + [('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type repartition_data = request.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False) # set repartition per type per employee diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 155246b1c0c..eec7637be31 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -27,9 +27,9 @@ class AccountAnalyticLine(models.Model): @api.multi def write(self, values): # prevent to update invoiced timesheets - if self.filtered(lambda timesheet: timesheet.timesheet_invoice_id): - if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'task_id', 'timesheet_revenue', 'so_line', 'amount', 'date']]): - raise UserError(_('You can not modify already invoiced timesheets.')) + #if self.filtered(lambda timesheet: timesheet.timesheet_invoice_id): + # if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'task_id', 'timesheet_revenue', 'so_line', 'amount', 'date']]): + # raise UserError(_('You can not modify already invoiced timesheets.')) so_lines = self.mapped('so_line') if values.get('task_id'): @@ -97,26 +97,35 @@ class AccountAnalyticLine(models.Model): if self.env.context.get('create'): # avoid bad loop return result + # find the timesheet UoM + timesheet_uom = self.product_uom_id + if values.get('product_uom_id'): + timesheet_uom = self.env['product.uom'].browse(values['product_uom_id']) + if not timesheet_uom: # fallback on default company timesheet UoM + timesheet_uom = self.env.user.company_id.project_time_mode_id + unit_amount = values.get('unit_amount', 0.0) or self.unit_amount billable_type = 'non_billable' revenue = 0.0 # set the revenue and billable type according to the product and the SO line so_line_id = values.get('so_line') or self.so_line.id - so_line = self.env['sale.order.line'].browse(so_line_id) if so_line_id else self.env['sale.order.line'].browse() + so_line = self.env['sale.order.line'].browse(so_line_id).sudo() if so_line_id else self.env['sale.order.line'].browse().sudo() if so_line.product_id.type == 'service': # find the analytic account to convert revenue into its currency account_id = values.get('account_id') or self.account_id.id analytic_account = self.env['account.analytic.account'].browse(account_id) + # convert the unit of mesure into hours + sale_price_hour = so_line.product_uom._compute_price(so_line.price_unit, timesheet_uom) + sale_price = so_line.currency_id.compute(sale_price_hour, analytic_account.currency_id) # amount from SO should be convert into analytic account currency # calculate the revenue on the timesheet if so_line.product_id.invoice_policy == 'delivery': - sale_price_unit = so_line.currency_id.compute(so_line.price_unit, analytic_account.currency_id) # amount from SO should be convert into analytic account currency - revenue = analytic_account.currency_id.round(unit_amount * sale_price_unit * (1-so_line.discount)) + revenue = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100))) billable_type = 'billable_time' elif so_line.product_id.invoice_policy == 'order' and so_line.product_id.track_service == 'task': + quantity_hour = so_line.product_uom._compute_quantity(so_line.product_uom_qty, timesheet_uom) # compute the total revenue the SO since we are in fixed price - sale_price_unit = so_line.currency_id.compute(so_line.price_unit, analytic_account.currency_id) - total_revenue_so = analytic_account.currency_id.round(so_line.product_uom_qty * sale_price_unit * (1-so_line.discount)) + total_revenue_so = analytic_account.currency_id.round(quantity_hour * sale_price * (1-(so_line.discount/100))) # compute the total revenue already existing (without the current timesheet line) domain = [('so_line', '=', so_line.id)] if self.ids: diff --git a/addons/sale_timesheet/tests/test_timesheet_revenue.py b/addons/sale_timesheet/tests/test_timesheet_revenue.py index af7d1f6dbda..9c5ceb79bac 100644 --- a/addons/sale_timesheet/tests/test_timesheet_revenue.py +++ b/addons/sale_timesheet/tests/test_timesheet_revenue.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.sale.tests.test_sale_common import TestSale -from odoo.tools import float_compare +from odoo.tools import float_compare, float_repr class TestSaleTimesheet(TestSale): @@ -283,10 +283,10 @@ class TestSaleTimesheet(TestSale): invoice.action_invoice_open() # check concrete revenue - self.assertEquals(float_compare(timesheet1.timesheet_revenue, 385.85, precision_digits=2), 0, "Revenue computation on invoice validation does not return the correct revenue !") - self.assertEquals(float_compare(timesheet2.timesheet_revenue, 154.35, precision_digits=2), 0, "Revenue computation on invoice validation does not return the correct revenue !") - self.assertEquals(float_compare(timesheet3.timesheet_revenue, 114.5, precision_digits=2), 0, "Revenue computation on invoice validation does not return the correct revenue !") - self.assertEquals(float_compare(timesheet4.timesheet_revenue, 152.68, precision_digits=2), 0, "Revenue computation on invoice validation does not return the correct revenue !") + self.assertEquals(float_repr(timesheet1.timesheet_revenue, precision_digits=2), '385.85', "Revenue computation on invoice validation does not return the correct revenue !") + self.assertEquals(float_repr(timesheet2.timesheet_revenue, precision_digits=2), '154.35', "Revenue computation on invoice validation does not return the correct revenue !") + self.assertEquals(float_repr(timesheet3.timesheet_revenue, precision_digits=2), '114.50', "Revenue computation on invoice validation does not return the correct revenue !") + self.assertEquals(float_repr(timesheet4.timesheet_revenue, precision_digits=2), '152.68', "Revenue computation on invoice validation does not return the correct revenue !") # check the invoice is well set self.assertEquals(timesheet1.timesheet_invoice_id, invoice) diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index b8b02461c44..56a88c7a28d 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -32,4 +32,18 @@ </field> </field> </record> + + <record id="project_task_view_form_inherit_sale_timesheet" model="ir.ui.view"> + <field name="name">project.task.form.inherit.timesheet</field> + <field name="model">project.task</field> + <field name="inherit_id" ref="hr_timesheet.view_task_form2_inherited"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='timesheet_ids']/tree" position="attributes"> + <attribute name="decoration-muted">timesheet_invoice_id != False</attribute> + </xpath> + <xpath expr="//field[@name='timesheet_ids']/tree" position="inside"> + <field name="timesheet_invoice_id" invisible="1"/> + </xpath> + </field> + </record> </odoo> diff --git a/addons/sales_team/models/crm_team.py b/addons/sales_team/models/crm_team.py index 5aeaba6aeec..7a17403933b 100644 --- a/addons/sales_team/models/crm_team.py +++ b/addons/sales_team/models/crm_team.py @@ -239,7 +239,7 @@ class CrmTeam(models.Model): for data_item in graph_data: index = int((data_item.get('x_value') - start_date.isocalendar()[1]) % weeks_in_start_year) - values[index][y_field] = int(data_item.get('y_value')) + values[index][y_field] = data_item.get('y_value') elif self.dashboard_graph_group == 'month': for month in range(0, (end_date.month - start_date.month) % 12 + 1): diff --git a/addons/stock/models/procurement.py b/addons/stock/models/procurement.py index 8ac7fc638d7..8d5e419fb90 100644 --- a/addons/stock/models/procurement.py +++ b/addons/stock/models/procurement.py @@ -9,8 +9,7 @@ from psycopg2 import OperationalError from odoo import api, fields, models, registry, _ from odoo.osv import expression -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare, \ - float_round, pycompat +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare, float_round import logging @@ -311,7 +310,7 @@ class ProcurementOrder(models.Model): location_data[key]['orderpoints'] += orderpoint location_data[key]['groups'] = self._procurement_from_orderpoint_get_groups([orderpoint.id]) - for location_id, location_data in pycompat.items(location_data): + for location_id, location_data in location_data.items(): location_orderpoints = location_data['orderpoints'] product_context = dict(self._context, location=location_orderpoints[0].location_id.id) substract_quantity = location_orderpoints.subtract_procurements_from_orderpoints() diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 43cbe1e2e4d..693f5c0119e 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -161,7 +161,7 @@ class Product(models.Model): if self.env.context.get('location', False): if isinstance(self.env.context['location'], pycompat.integer_types): location_ids = [self.env.context['location']] - elif isinstance(self.env.context['location'], basestring): + elif isinstance(self.env.context['location'], pycompat.string_types): domain = [('complete_name', 'ilike', self.env.context['location'])] if self.env.context.get('force_company', False): domain += [('company_id', '=', self.env.context['force_company'])] @@ -172,7 +172,7 @@ class Product(models.Model): if self.env.context.get('warehouse', False): if isinstance(self.env.context['warehouse'], pycompat.integer_types): wids = [self.env.context['warehouse']] - elif isinstance(self.env.context['warehouse'], basestring): + elif isinstance(self.env.context['warehouse'], pycompat.string_types): domain = [('name', 'ilike', self.env.context['warehouse'])] if self.env.context.get('force_company', False): domain += [('company_id', '=', self.env.context['force_company'])] diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index 866f3e2104c..55e63bb4f9f 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -4,7 +4,7 @@ from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError -from odoo.tools import float_utils, pycompat +from odoo.tools import float_utils class Inventory(models.Model): @@ -202,6 +202,19 @@ class Inventory(models.Model): prepare_inventory = action_start @api.multi + def action_inventory_line_tree(self): + action = self.env.ref('stock.action_inventory_line_tree').read()[0] + action['context'] = { + 'default_location_id': self.location_id.id, + 'default_product_id': self.product_id.id, + 'default_prod_lot_id': self.lot_id.id, + 'default_package_id': self.package_id.id, + 'default_partner_id': self.partner_id.id, + 'default_inventory_id': self.id, + } + return action + + @api.multi def _get_inventory_lines_values(self): # TDE CLEANME: is sql really necessary ? I don't think so locations = self.env['stock.location'].search([('id', 'child_of', [self.location_id.id])]) @@ -251,7 +264,7 @@ class Inventory(models.Model): for product_data in self.env.cr.dictfetchall(): # replace the None the dictionary by False, because falsy values are tested later on - for void_field in [item[0] for item in pycompat.items(product_data) if item[1] is None]: + for void_field in [item[0] for item in product_data.items() if item[1] is None]: product_data[void_field] = False product_data['theoretical_qty'] = product_data['product_qty'] if product_data['product_id']: @@ -334,6 +347,8 @@ class InventoryLine(models.Model): theoretical_qty = fields.Float( 'Theoretical Quantity', compute='_compute_theoretical_qty', digits=dp.get_precision('Product Unit of Measure'), readonly=True, store=True) + inventory_location_id = fields.Many2one( + 'stock.location', 'Location', related='inventory_id.location_id', related_sudo=False) @api.one @api.depends('location_id', 'product_id', 'package_id', 'product_uom_id', 'company_id', 'prod_lot_id', 'partner_id') diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 3d09ff08949..6379289bfe6 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -10,7 +10,7 @@ from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.addons.procurement.models import procurement from odoo.exceptions import UserError -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, pycompat +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.float_utils import float_compare, float_round, float_is_zero @@ -211,7 +211,8 @@ class StockMove(models.Model): @api.one @api.depends('product_id', 'product_uom', 'product_uom_qty') def _compute_product_qty(self): - self.product_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id) + rounding_method = self._context.get('rounding_method', 'UP') + self.product_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id, rounding_method=rounding_method) def _get_move_lines(self): """ This will return the move lines to consider when applying _quantity_done_compute on a stock.move. @@ -595,7 +596,7 @@ class StockMove(models.Model): (move_waiting | move_create_proc).write({'state': 'waiting'}) # assign picking in batch for all confirmed move that share the same details - for key, moves in pycompat.items(to_assign): + for moves in to_assign.values(): moves.assign_picking() self._push_apply() return self @@ -767,7 +768,7 @@ class StockMove(models.Model): grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty')) available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in.keys()} # pop key if the quantity available amount to 0 - available_move_lines = {k: v for k, v in pycompat.items(available_move_lines) if v} + available_move_lines = dict((k, v) for k, v in available_move_lines.items() if v) if not available_move_lines: continue @@ -954,13 +955,13 @@ class StockMove(models.Model): # TDE CLEANME: remove context key + add as parameter if self.env.context.get('source_location_id'): defaults['location_id'] = self.env.context['source_location_id'] - new_move = self.copy(defaults) + new_move = self.with_context(rounding_method='HALF-UP').copy(defaults) # ctx = context.copy() # TDE CLEANME: used only in write in this file, to clean # ctx['do_not_propagate'] = True # FIXME: pim fix your crap - self.with_context(do_not_propagate=True, do_not_unreserve=True).write({'product_uom_qty': self.product_uom_qty - uom_qty}) + self.with_context(do_not_propagate=True, do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': self.product_uom_qty - uom_qty}) # if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'): # new_move_prop = self.move_dest_id.split(qty) diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index fd2e084f969..124f67ef6e2 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -96,6 +96,11 @@ class StockMoveLine(models.Model): res = {'domain': {'product_uom_id': []}} return res + @api.onchange('lot_name', 'lot_id') + def onchange_serial_number(self): + if self.product_id.tracking == 'serial': + self.qty_done = 1 + @api.model def create(self, vals): vals['ordered_qty'] = vals.get('product_uom_qty') diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 536ac2a5e6d..de736ef0bcf 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -7,7 +7,7 @@ import time from itertools import groupby from odoo import api, fields, models, _ -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, pycompat +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.float_utils import float_compare, float_round from odoo.addons.procurement.models import procurement from odoo.exceptions import UserError @@ -665,7 +665,7 @@ class Picking(models.Model): self.ensure_one() moves = self.env['stock.move'] for move_line in self.move_line_ids: - for product, remaining_qty in pycompat.items(move_line._get_remaining_prod_quantities()): + for product, remaining_qty in move_line._get_remaining_prod_quantities().items(): if float_compare(remaining_qty, 0, precision_rounding=product.uom_id.rounding) > 0: vals = self._prepare_values_extra_move(move_line, product, remaining_qty) moves |= moves.create(vals) diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 785cff1db58..582d24d1033 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -5,7 +5,6 @@ from psycopg2 import OperationalError from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import pycompat from odoo.osv import expression @@ -268,13 +267,15 @@ class QuantPackage(models.Model): def _compute_package_info(self): for package in self: values = {'location_id': False, 'company_id': self.env.user.company_id.id, 'owner_id': False} + if self.quant_ids: + values['location_id'] = self.quant_ids[0].location_id package.location_id = values['location_id'] package.company_id = values['company_id'] package.owner_id = values['owner_id'] @api.multi def name_get(self): - return list(pycompat.items(self._compute_complete_name())) + return list(self._compute_complete_name().items()) def _compute_complete_name(self): """ Forms complete name of location from parent location to child location. """ diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 1f7e44f23ea..d8bd64253c6 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -8,7 +8,7 @@ from dateutil import relativedelta from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError, ValidationError -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, pycompat +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT import logging @@ -99,7 +99,7 @@ class Warehouse(models.Model): 'wh_output_stock_loc_id': {'name': _('Output'), 'active': delivery_steps != 'ship_only', 'usage': 'internal'}, 'wh_pack_stock_loc_id': {'name': _('Packing Zone'), 'active': delivery_steps == 'pick_pack_ship', 'usage': 'internal'}, } - for field_name, values in pycompat.items(sub_locations): + for field_name, values in sub_locations.items(): values['location_id'] = vals['view_location_id'] if vals.get('company_id'): values['company_id'] = vals.get('company_id') @@ -240,10 +240,10 @@ class Warehouse(models.Model): }, } data = self._get_picking_type_values(self.reception_steps, self.delivery_steps, self.wh_pack_stock_loc_id) - for field_name, values in pycompat.items(data): + for field_name in data: data[field_name].update(create_data[field_name]) - for picking_type, values in pycompat.items(data): + for picking_type, values in data.items(): sequence = IrSequenceSudo.create(sequence_data[picking_type]) values.update(warehouse_id=self.id, color=color, sequence_id=sequence.id) warehouse_data[picking_type] = PickingType.create(values).id @@ -598,8 +598,8 @@ class Warehouse(models.Model): @api.one def _update_picking_type(self): picking_type_values = self._get_picking_type_values(self.reception_steps, self.delivery_steps, self.wh_pack_stock_loc_id) - for field_name, values in pycompat.items(picking_type_values): - getattr(self, field_name).write(values) + for field_name, values in picking_type_values.items(): + self[field_name].write(values) @api.multi def _update_name_and_code(self, new_name=False, new_code=False): diff --git a/addons/stock/views/stock_inventory_views.xml b/addons/stock/views/stock_inventory_views.xml index 9348886f2e1..a7a63adaea2 100644 --- a/addons/stock/views/stock_inventory_views.xml +++ b/addons/stock/views/stock_inventory_views.xml @@ -14,6 +14,40 @@ </field> </record> + <record id="stock_inventory_line_tree2" model="ir.ui.view"> + <field name="name">stock.inventory.line.tree2</field> + <field name="model">stock.inventory.line</field> + <field name="arch" type="xml"> + <tree editable="top" string="Inventory Details" decoration-info="product_qty != theoretical_qty" decoration-danger="theoretical_qty < 0"> + <field name="product_id" domain="[('type','=','product')]"/> + <field name="product_uom_id" string="UoM" groups="product.group_uom"/> + <field name="location_id" domain="[('id', 'child_of', inventory_location_id)]" groups="stock.group_stock_multi_locations"/> + <field name="prod_lot_id" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}" groups="stock.group_production_lot"/> + <field name="package_id" domain="['|', ('location_id','=', False), ('location_id', '=', location_id)]" groups="stock.group_tracking_lot"/> + <field name="partner_id" groups="stock.group_tracking_owner"/> + <field name="theoretical_qty" readonly="1"/> + <field name="product_qty" string="Real Quantity"/> + <field name="state" invisible="1"/> + <field name="inventory_id" invisible="1"/> + <field name="inventory_location_id" invisible="1"/> + </tree> + </field> + </record> + + <record id="stock_inventory_line_search" model="ir.ui.view"> + <field name="name">stock.inventory.line.search</field> + <field name="model">stock.inventory.line</field> + <field name="arch" type="xml"> + <search string="Search Inventory Lines"> + <field name="product_id"/> + <field name="location_id" groups="stock.group_stock_multi_locations"/> + <field name="prod_lot_id" groups="stock.group_production_lot"/> + <field name="package_id" groups="stock.group_tracking_lot"/> + <field name="partner_id" groups="stock.group_tracking_owner"/> + </search> + </field> + </record> + <record id="view_inventory_filter" model="ir.ui.view"> <field name="name">stock.inventory.filter</field> <field name="model">stock.inventory</field> @@ -87,6 +121,19 @@ <field name="state" widget="statusbar" statusbar_visible="draft,confirm,done"/> </header> <sheet> + <div class="oe_button_box" name="button_box"> + <button name="action_inventory_line_tree" + class="oe_stat_button" + icon="fa-building-o" + type="object" + help="List view of lines" + groups="base.group_no_one" + states="confirm"> + <div class="o_form_field o_stat_info"> + <span class="o_stat_text">Details</span> + </div> + </button> + </div> <div class="oe_title"> <label for="name" class="oe_edit_only"/> <h1><field name="name" placeholder="e.g. Annual inventory"/></h1> @@ -213,5 +260,16 @@ </p> </field> </record> + + <record id="action_inventory_line_tree" model="ir.actions.act_window"> + <field name="name">Inventory Lines</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">stock.inventory.line</field> + <field name="view_type">form</field> + <field name="view_mode">tree</field> + <field name="view_id" ref="stock_inventory_line_tree2"/> + <field name="domain">[('inventory_id', '=', active_id)]</field> + </record> + <menuitem action="action_inventory_form" id="menu_action_inventory_form" parent="menu_stock_inventory_control" sequence="30"/> -</odoo>
\ No newline at end of file +</odoo> diff --git a/addons/stock/wizard/make_procurement.py b/addons/stock/wizard/make_procurement.py index 369995e7a15..2fa3db75b5d 100644 --- a/addons/stock/wizard/make_procurement.py +++ b/addons/stock/wizard/make_procurement.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models -from odoo.tools import pycompat class MakeProcurement(models.TransientModel): @@ -51,8 +50,8 @@ class MakeProcurement(models.TransientModel): @api.onchange('product_id') def onchange_product_id(self): if self.product_id: - for key, value in pycompat.items(self.onchange_product_id_dict(self.product_id.id)): - setattr(self, key, value) + for key, value in self.onchange_product_id_dict(self.product_id.id).items(): + self[key] = value @api.model def create(self, values): diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py index aeb6e42366f..5fd5ce8f63b 100644 --- a/addons/stock_landed_costs/models/stock_landed_cost.py +++ b/addons/stock_landed_costs/models/stock_landed_cost.py @@ -7,7 +7,6 @@ from odoo import api, fields, models, tools, _ from odoo.addons import decimal_precision as dp from odoo.addons.stock_landed_costs.models import product from odoo.exceptions import UserError -from odoo.tools import pycompat class StockMove(models.Model): @@ -123,7 +122,7 @@ class LandedCost(models.Model): for val_line in landed_cost.valuation_adjustment_lines: val_to_cost_lines[val_line.cost_line_id] += val_line.additional_landed_cost if any(tools.float_compare(cost_line.price_unit, val_amount, precision_digits=prec_digits) != 0 - for cost_line, val_amount in pycompat.items(val_to_cost_lines)): + for cost_line, val_amount in val_to_cost_lines.items()): return False return True @@ -204,9 +203,8 @@ class LandedCost(models.Model): towrite_dict[valuation.id] = value else: towrite_dict[valuation.id] += value - if towrite_dict: - for key, value in pycompat.items(towrite_dict): - AdjustementLines.browse(key).write({'additional_landed_cost': value}) + for key, value in towrite_dict.items(): + AdjustementLines.browse(key).write({'additional_landed_cost': value}) return True diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 553fc5ad4b2..150503f07da 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -174,7 +174,7 @@ class WebsiteSurvey(http.Controller): answer_tag = "%s_%s" % (answer_tag, 'comment') answer_value = answer.value_text elif answer.answer_type == 'number': - answer_value = answer.value_number.__str__() + answer_value = str(answer.value_number) elif answer.answer_type == 'date': answer_value = answer.value_date elif answer.answer_type == 'suggestion' and not answer.value_suggested_row: diff --git a/addons/survey/models/survey.py b/addons/survey/models/survey.py index 77eff220e0d..99faaaa5b8e 100644 --- a/addons/survey/models/survey.py +++ b/addons/survey/models/survey.py @@ -12,8 +12,6 @@ from werkzeug import urls from odoo import api, fields, models, tools, SUPERUSER_ID, _ from odoo.addons.http_routing.models.ir_http import slug from odoo.exceptions import UserError, ValidationError -from odoo.tools import pycompat - email_validator = re.compile(r"[^@]+@[^@]+\.[^@]+") _logger = logging.getLogger(__name__) @@ -23,7 +21,7 @@ def dict_keys_startswith(dictionary, string): .. note:: This function uses dictionary comprehensions (Python >= 2.7) """ - return {k: v for k, v in pycompat.items(dictionary) if k.startswith(string)} + return {k: v for k, v in dictionary.items() if k.startswith(string)} class SurveyStage(models.Model): @@ -237,7 +235,7 @@ class Survey(models.Model): answers[input_line.value_suggested.id]['count'] += 1 if input_line.answer_type == 'text' and (not(current_filters) or input_line.user_input_id.id in current_filters): comments.append(input_line) - result_summary = {'answers': list(pycompat.values(answers)), 'comments': comments} + result_summary = {'answers': list(answers.values()), 'comments': comments} # Calculate and return statistics for matrix if question.type == 'matrix': @@ -637,7 +635,7 @@ class SurveyQuestion(models.Model): if self.comments_allowed: comment_answer = answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip() # Preventing answers with blank value - if all(not answer.strip() for answer in pycompat.values(answer_candidates)) and answer_candidates: + if all(not answer.strip() for answer in answer_candidates.values()) and answer_candidates: errors.update({answer_tag: self.constr_error_msg}) # There is no answer neither comments (if comments count as answer) if not answer_candidates and self.comment_count_as_answer and (not comment_flag or not comment_answer): diff --git a/addons/survey/tests/test_survey.py b/addons/survey/tests/test_survey.py index a6c0443f160..c767b2ece4d 100644 --- a/addons/survey/tests/test_survey.py +++ b/addons/survey/tests/test_survey.py @@ -12,8 +12,6 @@ from odoo import _ from odoo.addons.http_routing.models.ir_http import slug from odoo.exceptions import UserError from odoo.tests.common import TransactionCase -from odoo.tools import pycompat - class TestSurvey(TransactionCase): @@ -178,7 +176,7 @@ class TestSurvey(TransactionCase): base_url = self.env['ir.config_parameter'].get_param('web.base.url') urltypes = {'public': 'start', 'print': 'print', 'result': 'results'} - for urltype, urltxt in pycompat.items(urltypes): + for urltype, urltxt in urltypes.items(): survey_url = getattr(self.survey1, urltype + '_url') survey_url_relative = getattr(self.survey1.with_context({'relative_url': True}), urltype + '_url') self.assertTrue(validate_url(survey_url)) @@ -210,8 +208,8 @@ class TestSurvey(TransactionCase): answers = [input_portal.user_input_line_ids[0], input_public.user_input_line_ids[0]] expected_values = {'answer_type': 'free_text', 'value_free_text': "Test Answer"} for answer in answers: - for field, value in pycompat.items(expected_values): - self.assertEqual(getattr(answer, field), value, msg="Unable to answer the survey. Expected behaviour of %s is not proper." % (field)) + for field, value in expected_values.items(): + self.assertEqual(answer[field], value, msg="Unable to answer the survey. Expected behaviour of %s is not proper." % (field)) def test_10_survey_result_simple_multiple_choice(self): question = self.env['survey.question'].sudo(self.survey_manager).create({ @@ -264,7 +262,7 @@ class TestSurvey(TransactionCase): 'print': {'method': 'print', 'token': '/test', 'text': 'Print'}, 'result': {'method': 'result', 'token': '', 'text': 'Results of the'}, 'test': {'method': 'public', 'token': '/phantom', 'text': 'Results of the'}} - for action, val in pycompat.items(actions): + for action, val in actions.items(): result = getattr(self.survey1.with_context({'survey_token': val['token'][1:]}), 'action_' + action + '_survey')() url = getattr(self.survey1.with_context({'relative_url': True}), val['method'] + '_url') + val['token'] self.assertEqual(result['url'], url) diff --git a/addons/survey/wizard/survey_email_compose_message.py b/addons/survey/wizard/survey_email_compose_message.py index fb36d839c57..f23d836bc30 100644 --- a/addons/survey/wizard/survey_email_compose_message.py +++ b/addons/survey/wizard/survey_email_compose_message.py @@ -8,6 +8,7 @@ from werkzeug import urls from odoo import api, fields, models, _ from odoo.exceptions import UserError +from odoo.tools import pycompat emails_split = re.compile(r"[;,\n\r]+") email_validator = re.compile(r"[^@]+@[^@]+\.[^@]+") @@ -119,7 +120,7 @@ class SurveyMailComposeMessage(models.TransientModel): if wizard.public != 'email_private': return None else: - token = uuid.uuid4().__str__() + token = pycompat.text_type(uuid.uuid4()) # create response with token survey_user_input = SurveyUserInput.create({ 'survey_id': wizard.survey_id.id, diff --git a/addons/utm/models/utm.py b/addons/utm/models/utm.py index 5cb04d51425..b73329c1011 100644 --- a/addons/utm/models/utm.py +++ b/addons/utm/models/utm.py @@ -3,6 +3,7 @@ from odoo import api, fields, models, SUPERUSER_ID from odoo.http import request +from odoo.tools import pycompat class UtmMedium(models.Model): @@ -72,7 +73,7 @@ class UtmMixin(models.AbstractModel): # ir_http dispatch saves the url params in a cookie value = request.httprequest.cookies.get(cookie_name) # if we receive a string for a many2one, we search/create the id - if field.type == 'many2one' and isinstance(value, basestring) and value: + if field.type == 'many2one' and isinstance(value, pycompat.string_types) and value: Model = self.env[field.comodel_name] records = Model.search([('name', '=', value)], limit=1) if not records: diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 4e37cb482fd..f0a6650f3a6 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + import babel.messages.pofile import base64 -import csv import datetime import functools import glob import hashlib import imghdr +import io import itertools import jinja2 import json @@ -22,8 +23,8 @@ import werkzeug.utils import werkzeug.wrappers import zlib from xml.etree import ElementTree -from cStringIO import StringIO from werkzeug import url_decode +from werkzeug import iri_to_uri import odoo @@ -117,12 +118,11 @@ def ensure_db(redirect='/web/database/selector'): # Thus, we redirect the user to the same page but with the session cookie set. # This will force using the database route dispatcher... r = request.httprequest - url_redirect = r.base_url + url_redirect = werkzeug.urls.url_parse(r.base_url) if r.query_string: - # Can't use werkzeug.wrappers.BaseRequest.url with encoded hashes: - # https://github.com/amigrave/werkzeug/commit/b4a62433f2f7678c234cdcac6247a869f90a7eb7 - url_redirect += '?' + r.query_string - response = werkzeug.utils.redirect(url_redirect, 302) + # in P3, request.query_string is bytes, the rest is text, can't mix them + query_string = iri_to_uri(r.query_string) + url_redirect = url_redirect.replace(query=query_string) request.session.db = db abort_and_redirect(url_redirect) @@ -964,7 +964,7 @@ class Binary(http.Controller): def force_contenttype(self, headers, contenttype='image/png'): dictheaders = dict(headers) dictheaders['Content-Type'] = contenttype - return list(pycompat.items(dictheaders)) + return list(dictheaders.items()) @http.route(['/web/content', '/web/content/<string:xmlid>', @@ -1137,8 +1137,8 @@ class Binary(http.Controller): """, (uid,)) row = cr.fetchone() if row and row[0]: - image_base64 = str(row[0]).decode('base64') - image_data = StringIO(image_base64) + image_base64 = base64.b64decode(row[0]) + image_data = io.BytesIO(image_base64) imgext = '.' + (imghdr.what(None, h=image_base64) or 'png') response = http.send_file(image_data, filename=imgname + imgext, mtime=row[1]) else: @@ -1217,7 +1217,7 @@ class Export(http.Controller): else: fields['.id'] = fields.pop('id', {'string': 'ID'}) - fields_sequence = sorted(pycompat.items(fields), + fields_sequence = sorted(fields.items(), key=lambda field: odoo.tools.ustr(field[1].get('string', ''))) records = [] @@ -1228,7 +1228,7 @@ class Export(http.Controller): if field.get('readonly'): # If none of the field's states unsets readonly, skip the field if all(dict(attrs).get('readonly', True) - for attrs in pycompat.values(field.get('states', {}))): + for attrs in field.get('states', {}).values()): continue if not field.get('exportable', True): continue @@ -1320,7 +1320,7 @@ class Export(http.Controller): export_fields = [field.split('/', 1)[1] for field in fields] return ( (prefix + '/' + k, prefix_string + '/' + v) - for k, v in pycompat.items(self.fields_info(model, export_fields))) + for k, v in self.fields_info(model, export_fields).items()) class ExportFormat(object): raw_data = False @@ -1388,32 +1388,22 @@ class CSVExport(ExportFormat, http.Controller): return base + '.csv' def from_data(self, fields, rows): - fp = StringIO() - writer = csv.writer(fp, quoting=csv.QUOTE_ALL) + fp = io.BytesIO() + writer = pycompat.csv_writer(fp, quoting=1) - writer.writerow([name.encode('utf-8') for name in fields]) + writer.writerow(fields) for data in rows: row = [] for d in data: - if isinstance(d, unicode): - try: - d = d.encode('utf-8') - except UnicodeError: - pass - if d is False: d = None - # Spreadsheet apps tend to detect formulas on leading =, + and - - if type(d) is str and d.startswith(('=', '-', '+')): + if isinstance(d, pycompat.string_types) and d.startswith(('=', '-', '+')): d = "'" + d - row.append(d) + row.append(pycompat.to_text(d)) writer.writerow(row) - fp.seek(0) - data = fp.read() - fp.close() - return data + return fp.getvalue() class ExcelExport(ExportFormat, http.Controller): # Excel needs raw data to correctly handle numbers and date values @@ -1446,7 +1436,7 @@ class ExcelExport(ExportFormat, http.Controller): for row_index, row in enumerate(rows): for cell_index, cell_value in enumerate(row): cell_style = base_style - if isinstance(cell_value, basestring): + if isinstance(cell_value, pycompat.string_types): cell_value = re.sub("\r", " ", cell_value) elif isinstance(cell_value, datetime.datetime): cell_style = datetime_style @@ -1454,7 +1444,7 @@ class ExcelExport(ExportFormat, http.Controller): cell_style = date_style worksheet.write(row_index + 1, cell_index, cell_value, cell_style) - fp = StringIO() + fp = io.BytesIO() workbook.save(fp) fp.seek(0) data = fp.read() diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 36ffe593acb..23a87f4cd02 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -5,8 +5,6 @@ import json from odoo import models from odoo.http import request -import pytz -import datetime import odoo @@ -26,7 +24,6 @@ class Http(models.AbstractModel): version_info = odoo.service.common.exp_version() return { "session_id": request.session.sid, - "tzOffset": self._context.get('tz') and datetime.datetime.now(pytz.timezone(self._context['tz'])).utcoffset().total_seconds() / 60 or False, "uid": request.session.uid, "is_system": request.env.user._is_system(), "is_superuser": request.env.user._is_superuser(), diff --git a/addons/web/static/src/js/chrome/search_inputs.js b/addons/web/static/src/js/chrome/search_inputs.js index 2a265ff0fdd..2dba4c0dd99 100644 --- a/addons/web/static/src/js/chrome/search_inputs.js +++ b/addons/web/static/src/js/chrome/search_inputs.js @@ -232,7 +232,7 @@ var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerFiel error_message: _t("not a valid integer"), parse: function (value) { try { - return field_utils.parse.interger(value); + return field_utils.parse.integer(value); } catch (e) { return NaN; } diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js index 5962bb28acb..2dae906c6db 100644 --- a/addons/web/static/src/js/chrome/view_manager.js +++ b/addons/web/static/src/js/chrome/view_manager.js @@ -26,7 +26,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, { search: function(event) { var d = event.data; _.extend(this.env, this._process_search_data(d.domains, d.contexts, d.groupbys)); - this.active_view.controller.reload(this.env); + this.active_view.controller.reload(_.extend({}, this.env)); }, switch_view: function(event) { if ('res_id' in event.data) { diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js index c21a855bab5..065b3b360e5 100644 --- a/addons/web/static/src/js/core/session.js +++ b/addons/web/static/src/js/core/session.js @@ -290,10 +290,6 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { var result = _.extend({}, window.odoo.session_info); delete result.session_id; _.extend(this, result); - if (this.tzOffset === false) { - // send by server to have the user timezone and not the browser timezone - this.tzOffset = -new Date().getTimezoneOffset(); - } return $.when(); }, check_session_id: function () { @@ -415,6 +411,17 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { }) ? '' : this.prefix; return prefix + path + qs; }, + /** + * Returns the time zone difference (in minutes) from the current locale + * (host system settings) to UTC, for a given date. The offset is positive + * if the local timezone is behind UTC, and negative if it is ahead. + * + * @param {string || moment} date a valid string date or moment instance + * @returns {integer} + */ + getTZOffset: function (date) { + return -new Date(date).getTimezoneOffset(); + }, }); return Session; diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 2d18a0ddb56..a1f87a38fd2 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -341,6 +341,9 @@ var FieldChar = InputField.extend(TranslatableFieldMixin, { */ _renderEdit: function () { var def = this._super.apply(this, arguments); + if (this.field.size && this.field.size > 0) { + this.$el.attr('maxlength', this.field.size); + } this.$el = this.$el.add(this._renderTranslateButton()); return def; }, @@ -450,7 +453,7 @@ var FieldDateTime = FieldDate.extend({ */ _getValue: function () { var value = this.datewidget.getValue(); - return value && value.add(-this.getSession().tzOffset, 'minutes'); + return value && value.add(-this.getSession().getTZOffset(value), 'minutes'); }, /** @@ -460,7 +463,7 @@ var FieldDateTime = FieldDate.extend({ * @private */ _makeDatePicker: function () { - var value = this.value && this.value.clone().add(this.getSession().tzOffset, 'minutes'); + var value = this.value && this.value.clone().add(this.getSession().getTZOffset(this.value), 'minutes'); return new datepicker.DateTimeWidget(this, {defaultDate: value}); }, @@ -471,7 +474,7 @@ var FieldDateTime = FieldDate.extend({ * @private */ _renderEdit: function () { - var value = this.value && this.value.clone().add(this.getSession().tzOffset, 'minutes'); + var value = this.value && this.value.clone().add(this.getSession().getTZOffset(this.value), 'minutes'); this.datewidget.setValue(value); this.$input = this.datewidget.$input; }, @@ -791,6 +794,7 @@ var FieldFloatTime = FieldFloat.extend({ var FieldText = InputField.extend(TranslatableFieldMixin, { className: 'o_field_text', supportedFieldTypes: ['text'], + tagName: 'span', /** * @constructor diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js index 9d3ae75af92..cbb2a856337 100644 --- a/addons/web/static/src/js/fields/field_utils.js +++ b/addons/web/static/src/js/fields/field_utils.js @@ -92,8 +92,10 @@ function formatDate(value, field, options) { if (value === false) { return ""; } - if (!options || !('timezone' in options) || options.timezone) { - value = value.clone().add(session.tzOffset < 0 ? -1 : 0, 'days'); + if (field && field.type === 'datetime') { + if (!options || !('timezone' in options) || options.timezone) { + value = value.clone().add(session.getTZOffset(value), 'minutes'); + } } var l10n = core._t.database.parameters; var date_format = time.strftime_to_moment_format(l10n.date_format); @@ -118,7 +120,7 @@ function formatDateTime(value, field, options) { return ""; } if (!options || !('timezone' in options) || options.timezone) { - value = value.clone().add(session.tzOffset, 'minutes'); + value = value.clone().add(session.getTZOffset(value), 'minutes'); } var l10n = core._t.database.parameters; var date_format = time.strftime_to_moment_format(l10n.date_format); @@ -354,11 +356,8 @@ function parseDate(value, field, options) { date = moment.utc(value); } else { date = moment.utc(value, [datePattern, datePatternWoZero, moment.ISO_8601], true); - if (options && options.timezone) { - date.add(session.tzOffset > 0 ? -1 : 0, 'days'); - } } - if (date.isValid() && date.year() >= 1900) { + if (date.isValid()) { if (date.year() === 0) { date.year(moment.utc().year()); } @@ -402,7 +401,7 @@ function parseDateTime(value, field, options) { } else { datetime = moment.utc(value, [pattern1, pattern2, moment.ISO_8601], true); if (options && options.timezone) { - datetime.add(-session.tzOffset, 'minutes'); + datetime.add(-session.getTZOffset(datetime), 'minutes'); } } if (datetime.isValid()) { diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 0dce6ec2bb4..e05a53194c3 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -21,7 +21,6 @@ var dialogs = require('web.view_dialogs'); var core = require('web.core'); var data = require('web.data'); var Dialog = require('web.Dialog'); -var field_utils = require('web.field_utils'); var KanbanRenderer = require('web.KanbanRenderer'); var ListRenderer = require('web.ListRenderer'); var Pager = require('web.Pager'); @@ -226,7 +225,7 @@ var FieldMany2One = AbstractField.extend({ var self = this; var def = $.Deferred(); var slowCreate = function () { - var dialog = self._searchCreatePopup.bind(self, "form", false, self._createContext(name)); + var dialog = self._searchCreatePopup("form", false, self._createContext(name)); dialog.on('closed', self, def.resolve.bind(def)); }; if (this.nodeOptions.quick_create) { @@ -290,7 +289,7 @@ var FieldMany2One = AbstractField.extend({ _reset: function () { this._super.apply(this, arguments); this.floating = false; - this.m2o_value = field_utils.format.many2one(this.value); + this.m2o_value = this._formatValue(this.value); }, /** * @private @@ -394,7 +393,7 @@ var FieldMany2One = AbstractField.extend({ */ _searchCreatePopup: function (view, ids, context) { var self = this; - new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, { + return new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, { res_model: this.field.relation, domain: this.record.getDomain({fieldName: this.name}), context: _.extend({}, this.record.getContext(this.recordParams), context || {}), @@ -567,7 +566,7 @@ var KanbanFieldMany2One = AbstractField.extend({ tagName: 'span', init: function () { this._super.apply(this, arguments); - this.m2o_value = field_utils.format.many2one(this.value); + this.m2o_value = this._formatValue(this.value); }, //-------------------------------------------------------------------------- @@ -2023,7 +2022,8 @@ var FieldRadio = FieldSelection.extend({ * `name_get` as data). */ var FieldReference = FieldMany2One.extend({ - supportedFieldTypes: ['reference'], + specialData: "_fetchSpecialReference", + supportedFieldTypes: ['char', 'reference'], template: 'FieldReference', events: _.extend({}, FieldMany2One.prototype.events, { 'change select': '_onSelectionChange', @@ -2036,22 +2036,8 @@ var FieldReference = FieldMany2One.extend({ // needs to be copied as it is an unmutable object this.field = _.extend({}, this.field); - if (this.value) { - var model = null; - // The value of a Reference field can either be a string in the form - // of model,record_id or an object - if (typeof this.value === "string") { - var value = this.value.split(','); - model = value[0]; - this.value = { - res_id: parseInt(value[1]), - }; - } else { - model = this.value.model; - } - this._setRelation(model); - } + this._setState(); }, /** * @override @@ -2071,7 +2057,13 @@ var FieldReference = FieldMany2One.extend({ * @override */ _formatValue: function () { - return this.recordData.display_name; + var value; + if (this.field.type === 'char') { + value = this.record.specialData[this.name]; + } else { + value = this.value; + } + return value && value.data && value.data.display_name || ''; }, /** @@ -2097,8 +2089,10 @@ var FieldReference = FieldMany2One.extend({ * @private */ _reset: function () { + var value = this.$('select').val(); + this._setState(); this._super.apply(this, arguments); - this._setRelation(this.value && this.value.model); + this.$('select').val(this.value && this.value.model || value); }, /** * Set `relation` key in field properties. @@ -2111,6 +2105,19 @@ var FieldReference = FieldMany2One.extend({ this.field.relation = model; }, /** + * @private + */ + _setState: function () { + if (this.field.type === 'char') { + // in this case, the value is stored in specialData instead + this.value = this.record.specialData[this.name]; + } + + if (this.value) { + this._setRelation(this.value.model); + } + }, + /** * @override * @private */ diff --git a/addons/web/static/src/js/fields/special_fields.js b/addons/web/static/src/js/fields/special_fields.js index 69647902afe..7a0bb9ed092 100644 --- a/addons/web/static/src/js/fields/special_fields.js +++ b/addons/web/static/src/js/fields/special_fields.js @@ -54,7 +54,7 @@ var FieldTimezoneMismatch = FieldSelection.extend({ } var offset = this.recordData.tz_offset.match(/([+-])([0-9]{2})([0-9]{2})/); offset = (offset[1] === '-' ? -1 : 1) * (parseInt(offset[2])*60 + parseInt(offset[3])); - var datetime = field_utils.format.datetime(moment.utc().add(offset, 'minutes'), {timezone: false}); + var datetime = field_utils.format.datetime(moment.utc().add(offset, 'minutes'), this.field, {timezone: false}); var content = this.$option.html().split(' ')[0]; content += ' ('+ datetime + ')'; this.$option.html(content); diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 6ba9dbea477..cfdee6793dd 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -329,9 +329,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { if (options && options.readonlyIfRealDiscard && !needDiscard) { return; } - if (needDiscard) { // Just some optimization - self.model.discardChanges(recordID); - } + self.model.discardChanges(recordID); if (self.model.isNew(recordID)) { self._abandonRecord(recordID); return; 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 8755805f993..0d95c292070 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -186,6 +186,29 @@ var BasicModel = AbstractModel.extend({ }); }, /** + * Onchange RPCs may return values for fields that are not in the current + * view. Those fields might even be unknown when the onchange returns (e.g. + * in x2manys, we only know the fields that are used in the inner view, but + * not those used in the potential form view opened in a dialog when a sub- + * record is clicked). When this happens, we can't infer their type, so the + * given value can't be processed. It is instead stored in the '_rawChanges' + * key of the record, without any processing. Later on, if this record is + * displayed in another view (e.g. the user clicked on it in the x2many + * list, and the record opens in a dialog), those changes that were left + * behind must be applied. This function applies changes stored in + * '_rawChanges' for a given viewType. + * + * @param {string} id local resource id of a record + * @param {string} viewType the current viewType + * @returns {Deferred -> string} resolves to the id of the record + */ + applyRawChanges: function (recordID, viewType) { + var record = this.localData[recordID]; + return this._applyOnChange(record._rawChanges, record, viewType).then(function () { + return record.id; + }); + }, + /** * Delete a list of records, then, if the records have a parent, reload it. * * @todo we should remove the deleted records from the localData @@ -754,14 +777,15 @@ var BasicModel = AbstractModel.extend({ if (newValue instanceof Array) { rec._savePoint = newValue.slice(0); } else { - // save the viewType of edition, so that the correct readonly modifiers - // can be evaluated when the record will be saved - for (var fieldName in (rec._changes || {})) { - rec._editionViewType[fieldName] = options.viewType; - } rec._savePoint = _.extend({}, newValue); } }); + + // save the viewType of edition, so that the correct readonly modifiers + // can be evaluated when the record will be saved + _.each((record._changes || {}), function (value, fieldName) { + record._editionViewType[fieldName] = options.viewType; + }); } var shouldReload = 'reload' in options ? options.reload : true; var method = self.isNew(record_id) ? 'create' : 'write'; @@ -896,18 +920,23 @@ var BasicModel = AbstractModel.extend({ * @returns {Deferred -> string} resolves to the group id */ toggleGroup: function (groupId) { + var self = this; var group = this.localData[groupId]; if (group.isOpen) { group.isOpen = false; group.data = []; + group.res_ids = []; group.offset = 0; + this._updateParentResIDs(group); return $.when(groupId); } if (!group.isOpen) { group.isOpen = true; var def; if (group.count > 0) { - def = this._load(group); + def = this._load(group).then(function () { + self._updateParentResIDs(group); + }); } return $.when(def).then(function () { return groupId; @@ -1093,21 +1122,33 @@ var BasicModel = AbstractModel.extend({ * @param {Object} values the result of the onchange RPC (a mapping of * fieldnames to their value) * @param {Object} record + * @param {string} [viewType] current viewType. If not set, we will assume + * main viewType from the record * @returns {Deferred} */ - _applyOnChange: function (values, record) { + _applyOnChange: function (values, record, viewType) { var self = this; var defs = []; var rec; + viewType = viewType || record.viewType; record._changes = record._changes || {}; _.each(values, function (val, name) { var field = record.fields[name]; if (!field) { - return; // ignore changes of unknown fields + // this field is unknown so we can't process it for now (it is not + // in the current view anyway, otherwise it wouldn't be unknown. + // we store its value without processing it, so that if we later + // on switch to another view in which this field is displayed, + // we could process it as we would know its type then. + // use case: an onchange sends a create command for a one2many, + // in the dict of values, there is a value for a field that is + // not in the one2many list, but that is in the one2many form. + record._rawChanges[name] = val; + return; } - - if (field.type === 'many2one' ) { - var id = false; + var id; + if (field.type === 'many2one') { + id = false; // in some case, the value returned by the onchange can // be false (no value), so we need to avoid creating a // local record for that. @@ -1126,13 +1167,27 @@ var BasicModel = AbstractModel.extend({ id = rec.id; } record._changes[name] = id; + } else if (field.type === 'reference') { + id = false; + if (val) { + var ref = val.split(','); + rec = self._makeDataPoint({ + context: record.context, + data: {id: parseInt(ref[1])}, + modelName: ref[0], + parentID: record.id, + }); + defs.push(self._fetchNameGet(rec)); + id = rec.id; + } + record._changes[name] = id; } else if (field.type === 'one2many' || field.type === 'many2many') { var listId = record._changes[name] || record.data[name]; var list; if (listId) { list = self.localData[listId]; } else { - var fieldInfo = record.fieldsInfo[record.viewType][name]; + var fieldInfo = record.fieldsInfo[viewType][name]; if (!fieldInfo) { return; //ignore changes of x2many not in view } @@ -1142,7 +1197,7 @@ var BasicModel = AbstractModel.extend({ parentID: record.id, static: true, type: 'list', - viewtype: fieldInfo.viewType, + viewType: fieldInfo.viewType, }); } record._changes[name] = list.id; @@ -1535,15 +1590,17 @@ var BasicModel = AbstractModel.extend({ var self = this; var model; var records = []; + var ids = []; list = this._applyX2ManyOperations(list); - var ids = _.map(list.data, function (localId) { + _.each(list.data, function (localId) { var record = self.localData[localId]; var data = record._changes || record.data; var many2oneId = data[fieldName]; + if (!many2oneId) { return; } var many2oneRecord = self.localData[many2oneId]; records.push(many2oneRecord); + ids.push(many2oneRecord.res_id); model = many2oneRecord.model; - return many2oneRecord.res_id; }); return this._rpc({ model: model, @@ -1600,6 +1657,39 @@ var BasicModel = AbstractModel.extend({ }); }, /** + * Fetch the `name_get` for a reference field. + * + * @private + * @param {Object} record + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchReference: function (record, fieldName) { + var self = this; + var def; + var value = record._changes && record._changes[fieldName] || record.data[fieldName]; + var model = value && value.split(',')[0]; + var resID = value && parseInt(value.split(',')[1]); + if (model && model !== 'False' && resID) { + def = self._rpc({ + model: model, + method: 'name_get', + args: [resID], + context: record.getContext({fieldName: fieldName}), + }).then(function (result) { + return self._makeDataPoint({ + data: { + id: result[0][0], + display_name: result[0][1], + }, + modelName: model, + parentID: record.id, + }); + }); + } + return $.when(def); + }, + /** * Fetch the extra data (`name_get`) for the reference fields of the record * model. * @@ -1614,28 +1704,12 @@ var BasicModel = AbstractModel.extend({ _.each(fieldNames, function (fieldName) { var field = record.fields[fieldName]; if (field.type === 'reference') { - var value = record.data[fieldName]; - if (value) { - var model = value.split(',')[0]; - var resID = parseInt(value.split(',')[1]); - var def = self._rpc({ - model: model, - method: 'name_get', - args: [resID], - context: record.getContext({fieldName: fieldName}), - }).then(function (result) { - var referenceDp = self._makeDataPoint({ - data: { - id: result[0][0], - display_name: result[0][1], - }, - modelName: model, - parentID: record.id, - }); - record.data[fieldName] = referenceDp.id; - }); - defs.push(def); - } + var def = self._fetchReference(record, fieldName).then(function (dataPoint) { + if (dataPoint) { + record.data[fieldName] = dataPoint.id; + } + }); + defs.push(def); } }); return $.when.apply($, defs); @@ -1906,6 +1980,25 @@ var BasicModel = AbstractModel.extend({ }); }, /** + * Fetches the `name_get` associated to the reference widget if the field is + * a `char` (which is a supported case). + * + * @private + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @returns {Deferred} + */ + _fetchSpecialReference: function (record, fieldName) { + var def; + var field = record.fields[fieldName]; + if (field.type === 'char') { + // if the widget reference is set on a char field, the name_get + // needs to be fetched a posteriori + def = this._fetchReference(record, fieldName); + } + return $.when(def); + }, + /** * Fetches all the m2o records associated to the given fieldName. If the * given fieldName is not a m2o field, nothing is done. The difference with * _fetchSpecialMany2ones is that the field given by options.fold_field is @@ -2712,6 +2805,7 @@ var BasicModel = AbstractModel.extend({ _cache: type === 'list' ? {} : undefined, _changes: null, _domains: {}, + _rawChanges: {}, aggregateValues: params.aggregateValues || {}, context: params.context || {}, count: params.count || res_ids.length, @@ -2826,14 +2920,25 @@ var BasicModel = AbstractModel.extend({ var field = params.fields[name]; data[name] = null; record._changes = record._changes || {}; + var dp; if (field.type === 'many2one' && result[name]) { - var rec = self._makeDataPoint({ + dp = self._makeDataPoint({ context: record.context, data: {id: result[name]}, modelName: field.relation, parentID: record.id, }); - record._changes[name] = rec.id; + record._changes[name] = dp.id; + } else if (field.type === 'reference' && result[name]) { + var ref = result[name].split(','); + dp = self._makeDataPoint({ + context: record.context, + data: {id: parseInt(ref[1])}, + modelName: ref[0], + parentID: record.id, + }); + defs.push(self._fetchNameGet(dp)); + record._changes[name] = dp.id; } else if (field.type === 'one2many' || field.type === 'many2many') { var fieldInfo = record.fieldsInfo[record.viewType][name]; var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; @@ -2859,6 +2964,7 @@ var BasicModel = AbstractModel.extend({ x2manyList._changes = []; var many2ones = {}; var r; + result[name] = result[name] || []; // handle false value var isCommandList = result[name].length && _.isArray(result[name][0]); if (!isCommandList) { result[name] = [[6, false, result[name]]]; @@ -2885,7 +2991,7 @@ var BasicModel = AbstractModel.extend({ r._changes = _.defaults(value[2], r.data); for (var name in r._changes) { - if (r._changes[name] === null) { + if (!r._changes[name]) { continue; } var isFieldInView = name in r.fields; @@ -3318,6 +3424,7 @@ var BasicModel = AbstractModel.extend({ list.data = data; list.res_ids = ids; } + self._updateParentResIDs(list); return list; }); }, @@ -3364,6 +3471,26 @@ var BasicModel = AbstractModel.extend({ } }, /** + * Updates the res_ids of the parent of a given element of type list. + * + * After some operations (e.g. loading more records, folding/unfolding a + * group), the res_ids list of an element may be updated. When this happens, + * the res_ids of its ancestors need to be updated as well. This is the + * purpose of this function. + * + * @param {Object} element + */ + _updateParentResIDs: function (element) { + var self = this; + if (element.parentID) { + var parent = this.localData[element.parentID]; + parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) { + return self.localData[dataPointID].res_ids; + })); + this._updateParentResIDs(parent); + } + }, + /** * Helper method. Recursively traverses the data, starting from the element * record (or list), then following all relations. This is useful when one * want to determine a property for the current record. diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index 736416dea27..bdc250011f0 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -63,6 +63,7 @@ var BasicView = AbstractView.extend({ */ _loadData: function () { if (this.recordID) { + var self = this; var record = this.model.get(this.recordID); var viewType = this.viewType; var viewFields = Object.keys(record.fieldsInfo[viewType]); @@ -107,15 +108,27 @@ var BasicView = AbstractView.extend({ } } }); - if (fieldNames.length && !this.model.isNew(record.id)) { - return this.model.reload(this.recordID, { - fieldNames: fieldNames, - keepChanges: true, - viewType: viewType, + + var def; + if (fieldNames.length) { + // Some fields in the new view weren't in the previous one, so + // we might have stored changes for them (e.g. coming from + // onchange RPCs), that we haven't been able to process earlier + // (because those fields were unknow at that time). So we ask + // the model to process them. + def = this.model.applyRawChanges(record.id, viewType).then(function () { + if (!self.model.isNew(record.id)) { + return self.model.reload(record.id, { + fieldNames: fieldNames, + keepChanges: true, + viewType: viewType, + }); + } }); - } else { - return $.when(this.recordID); } + return $.when(def).then(function () { + return record.id; + }); } return this._super.apply(this, arguments); }, diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index d2b0c02c524..c8b9fd574b2 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -38,39 +38,47 @@ return AbstractModel.extend({ var start = event.start.clone(); var end = event.end && event.end.clone(); - if (!end || end.diff(start) < 0) { - end = start.clone().add(1, 'h'); + // Detects allDay events (86400000 = 1 day in ms) + if (event.allDay || (end && end.diff(start) % 86400000 === 0)) { + event.allDay = true; } - if (event.allDay || end.diff(start) % 86400000 === 0) { - event.allDay = true; + // Set end date if not existing + if (!end || end.diff(start) < 0) { // undefined or invalid end date + if (event.allDay) { + end = start.clone(); + } else { + // in week mode or day mode, convert allday event to event + end = start.clone().add(2, 'h'); + } + } else if (event.allDay) { + // For an "allDay", FullCalendar gives the end day as the + // next day at midnight (instead of 23h59). + end.add(-1, 'days'); + } - if (this.scale === 'month') { + // An "allDay" event without the "all_day" option is not considered + // as a 24h day. It's just a part of the day (by default: 7h-19h). + if (event.allDay) { + if (!this.mapping.all_day) { if (event.r_start) { start.hours(event.r_start.hours()) .minutes(event.r_start.minutes()) .seconds(event.r_start.seconds()) .utc(); - end.add(-1, 'days') - .hours(event.r_end.hours()) + end.hours(event.r_end.hours()) .minutes(event.r_end.minutes()) .seconds(event.r_end.seconds()) .utc(); } else { - start.utc(); - end.utc(); + // default hours in the user's timezone + start.hours(7).add(-this.getSession().getTZOffset(start), 'minutes'); + end.hours(19).add(-this.getSession().getTZOffset(end), 'minutes'); } - } else if (this.mapping.all_day) { - start.startOf('day'); - end.startOf('day').add(-1, 'days'); - } else { - // default hours in the user's timezone - start.hours(7).add(-this.getSession().tzOffset, 'minutes'); - end.hours(19).add(-this.getSession().tzOffset, 'minutes'); } } else { - start.add(-this.getSession().tzOffset, 'minutes'); - end.add(-this.getSession().tzOffset, 'minutes'); + start.add(-this.getSession().getTZOffset(start), 'minutes'); + end.add(-this.getSession().getTZOffset(end), 'minutes'); } if (this.mapping.all_day) { @@ -365,6 +373,7 @@ return AbstractModel.extend({ monthNamesShort: moment.monthsShort(), dayNames: moment.weekdays(), dayNamesShort: moment.weekdaysShort(), + firstDay: moment().startOf('week').isoWeekday(), }; }, _getRangeDomain: function () { @@ -599,8 +608,8 @@ return AbstractModel.extend({ date_stop = date_start.clone().add(date_delay,'hours'); } - date_start.add(this.getSession().tzOffset, 'minutes'); - date_stop.add(this.getSession().tzOffset, 'minutes'); + date_start.add(this.getSession().getTZOffset(date_start), 'minutes'); + date_stop.add(this.getSession().getTZOffset(date_stop), 'minutes'); if (this.mapping.all_day && evt[this.mapping.all_day]) { date_stop.add(1, 'days'); @@ -622,6 +631,10 @@ return AbstractModel.extend({ // r.start = date_start.format('YYYY-MM-DD'); // r.end = date_stop.format('YYYY-MM-DD'); } else if (this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') { + // In month, FullCalendar gives the end day as the + // next day at midnight (instead of 23h59). + date_stop.add(1, 'days'); + // allow to resize in month mode r.reset_allday = r.allDay; r.allDay = true; diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js index 5fb79cfa3a0..422258b4035 100644 --- a/addons/web/static/src/js/views/calendar/calendar_renderer.js +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -331,12 +331,15 @@ return AbstractRenderer.extend({ } element.find('.fc-content .fc-time').text(display_hour); }, + // Dirty hack to ensure a correct first render + eventAfterAllRender: function (view) { + window.dispatchEvent(new Event('resize')); + }, height: 'parent', unselectAuto: false, }); this.$calendar.fullCalendar(fc_options); - window.dispatchEvent(new Event('resize')); }, /** * Initialize the mini calendar in the sidebar diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js index 459f8768c04..3fa3b76d601 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -107,7 +107,10 @@ var KanbanController = BasicController.extend({ * @returns {Deferred} */ _resequenceRecords: function (column_id, ids) { - return this.model.resequence(this.modelName, ids, column_id); + var self = this; + return this.model.resequence(this.modelName, ids, column_id).then(function () { + self._updateEnv(); + }); }, /** * In grouped mode, set 'Create' button as btn-default if there is no column @@ -181,6 +184,7 @@ var KanbanController = BasicController.extend({ .then(function (db_id) { var data = self.model.get(db_id); self.renderer.updateColumn(db_id, data); + self._updateEnv(); }); } }, @@ -218,6 +222,7 @@ var KanbanController = BasicController.extend({ this.model.loadMore(column.db_id).then(function (db_id) { var data = self.model.get(db_id); self.renderer.updateColumn(db_id, data); + self._updateEnv(); }); }, /** @@ -328,9 +333,12 @@ var KanbanController = BasicController.extend({ * @param {OdooEvent} event */ _onResequenceColumn: function (event) { + var self = this; var state = this.model.get(this.handle, {raw: true}); var model = state.fields[state.groupedBy[0]].relation; - this.model.resequence(model, event.data.ids, this.handle); + this.model.resequence(model, event.data.ids, this.handle).then(function () { + self._updateEnv(); + }); }, /** * @param {OdooEvent} event @@ -341,6 +349,7 @@ var KanbanController = BasicController.extend({ this.model.toggleGroup(column.db_id).then(function (db_id) { var data = self.model.get(db_id); self.renderer.updateColumn(db_id, data); + self._updateEnv(); }); }, /** diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index 71402fc92ec..e21a1376e51 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -35,12 +35,8 @@ var KanbanModel = BasicModel.extend({ group.count++; // update the res_ids and count of the parent - var self = this; - var parent = this.localData[group.parentID]; - parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) { - return self.localData[dataPointID].res_ids; - })); - parent.count++; + this.localData[group.parentID].count++; + this._updateParentResIDs(group); return this._fetchRecord(new_record).then(function (result) { return result.id; @@ -151,6 +147,7 @@ var KanbanModel = BasicModel.extend({ return this.notifyChanges(recordID, changes).then(function () { return self.save(recordID); }).then(function () { + var resID = self.localData[recordID].res_id; // Remove record from its current group var old_group; for (var i = 0; i < parent.data.length; i++) { @@ -159,11 +156,14 @@ var KanbanModel = BasicModel.extend({ if (index >= 0) { old_group.data.splice(index, 1); old_group.count--; + old_group.res_ids = _.without(old_group.res_ids, resID); + self._updateParentResIDs(old_group); break; } } // Add record to its new group new_group.data.push(recordID); + new_group.res_ids.push(resID); new_group.count++; return [old_group.id, new_group.id]; }); @@ -201,6 +201,16 @@ var KanbanModel = BasicModel.extend({ data.data = _.sortBy(data.data, function (d) { return _.indexOf(resIDs, self.localData[d].res_id); }); + data.res_ids = []; + _.each(data.data, function (d) { + var dataPoint = self.localData[d]; + if (dataPoint.type === 'record') { + data.res_ids.push(dataPoint.res_id); + } else { + data.res_ids = data.res_ids.concat(dataPoint.res_ids); + } + }); + self._updateParentResIDs(data); return parentID; }); }, diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 3ef48792f06..3327b1bb3e5 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -388,15 +388,15 @@ var ListRenderer = BasicRenderer.extend({ var $th = $('<th>') .addClass('o_group_name') .text(name + ' (' + group.count + ')'); - if (group.count > 0) { - var $arrow = $('<span>') + var $arrow = $('<span>') .css('padding-left', (groupLevel * 20) + 'px') .css('padding-right', '5px') - .addClass('fa') - .toggleClass('fa-caret-right', !group.isOpen) - .toggleClass('fa-caret-down', group.isOpen); - $th.prepend($arrow); + .addClass('fa'); + if (group.count > 0) { + $arrow.toggleClass('fa-caret-right', !group.isOpen) + .toggleClass('fa-caret-down', group.isOpen); } + $th.prepend($arrow); if (group.isOpen && !group.groupedBy.length && (group.count > group.data.length)) { var $pager = this._renderGroupPager(group); var $lastCell = $cells[$cells.length-1]; diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index 97b17f00413..99ffd1e1aa3 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -350,6 +350,7 @@ var SelectCreateDialog = ViewDialog.extend({ groupBy: searchResult.groupBy, modelName: self.dataset.model, hasSelectors: !self.options.disable_multiple_selection, + readonly: true, }, self.options.list_view_options)); listView.setController(SelectCreateListController); return listView.getController(self); @@ -408,6 +409,7 @@ var SelectCreateDialog = ViewDialog.extend({ }, })).open(); dialog.on('closed', this, this.close); + return dialog; }, }); diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js index fde9001f860..4b9f6a29a47 100644 --- a/addons/web/static/src/js/widgets/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -179,7 +179,7 @@ var DataExport = Dialog.extend({ var self = this; this.exports.create({ name: value, - resource: this.dataset.model, + resource: this.record.model, export_fields: _.map(fields, function (field) { return [0, 0, {name: field}]; }), diff --git a/addons/web/static/src/js/widgets/debug_manager.js b/addons/web/static/src/js/widgets/debug_manager.js index baa147d9a1f..b2c175fc028 100644 --- a/addons/web/static/src/js/widgets/debug_manager.js +++ b/addons/web/static/src/js/widgets/debug_manager.js @@ -348,7 +348,117 @@ DebugManager.include({ }); }, set_defaults: function() { - this._active_view.controller.open_defaults_dialog(); + var self = this; + + var display = function (fieldInfo, value) { + var displayed = value; + if (value && fieldInfo.type === 'many2one') { + displayed = value.data.display_name; + value = value.data.id; + } else if (value && fieldInfo.type === 'selection') { + displayed = _.find(fieldInfo.selection, function (option) { + return option[0] === value; + })[1]; + } + return [value, displayed]; + }; + + var renderer = this._active_view.controller.renderer; + var fields = self._active_view.fields_view.fields; + var fieldsInfo = self._active_view.fields_view.fieldsInfo.form; + var fieldNamesInView = renderer.state.getFieldNames(); + var fieldsValues = renderer.state.data; + var modifierDatas = {}; + _.each(fieldNamesInView, function (fieldName) { + modifierDatas[fieldName] = _.find(renderer.allModifiersData, function (modifierdata) { + return modifierdata.node.attrs.name === fieldName; + }); + }); + this.fields = _.chain(fieldNamesInView) + .map(function (fieldName) { + var modifierData = modifierDatas[fieldName]; + var invisibleOrReadOnly; + if (modifierData) { + var evaluatedModifiers = modifierData.evaluatedModifiers[renderer.state.id]; + invisibleOrReadOnly = evaluatedModifiers.invisible || evaluatedModifiers.readonly; + } + var fieldInfo = fields[fieldName]; + var valueDisplayed = display(fieldInfo, fieldsValues[fieldName]); + var value = valueDisplayed[0]; + var displayed = valueDisplayed[1]; + // ignore fields which are empty, invisible, readonly, o2m + // or m2m + if (!value || invisibleOrReadOnly || fieldInfo.type === 'one2many' || + fieldInfo.type === 'many2many' || fieldInfo.type === 'binary' || + fieldsInfo[fieldName].options.isPassword) { + return false; + } + return { + name: fieldName, + string: fieldInfo.string, + value: value, + displayed: displayed, + }; + }) + .compact() + .sortBy(function (field) { return field.string; }) + .value(); + + var conditions = _.chain(fieldNamesInView) + .filter(function (fieldName) { + var fieldInfo = fields[fieldName]; + return fieldInfo.change_default; + }) + .map(function (fieldName) { + var fieldInfo = fields[fieldName]; + var valueDisplayed = display(fieldInfo, fieldsValues[fieldName]); + var value = valueDisplayed[0]; + var displayed = valueDisplayed[1]; + return { + name: fieldName, + string: fieldInfo.string, + value: value, + displayed: displayed, + }; + }) + .value(); + var d = new Dialog(this, { + title: _t("Set Default"), + buttons: [ + {text: _t("Close"), close: true}, + {text: _t("Save default"), click: function () { + var $defaults = d.$el.find('#formview_default_fields'); + var fieldToSet = $defaults.val(); + if (!fieldToSet) { + $defaults.parent().addClass('o_form_invalid'); + return; + } + var allUsers = d.$el.find('#formview_default_all').is(':checked'); + var condition = d.$el.find('#formview_default_conditions').val(); + var value = _.find(self.fields, function (field) { + return field.name === fieldToSet; + }).value; + self._rpc({ + model: 'ir.values', + method: 'set_default', + args: [ + self._active_view.fields_view.model, + fieldToSet, + value, + allUsers, + true, + condition || false, + ], + }).done(function () { d.close(); }); + }} + ] + }); + d.args = { + fields: this.fields, + conditions: conditions, + }; + d.template = 'FormView.set_default'; + d.open(); }, fvg: function() { var self = this; diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index f0a08b5dc62..b986c08ff4d 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -448,7 +448,7 @@ </label> </td> <td class="oe_form_required"> - <select id="formview_default_fields"> + <select id="formview_default_fields" class="o_input"> <option value=""/> <option t-foreach="args.fields" t-as="field" t-att-value="field.name"> @@ -465,7 +465,7 @@ </label> </td> <td> - <select id="formview_default_conditions"> + <select id="formview_default_conditions" class="o_input"> <option value=""/> <option t-foreach="args.conditions" t-as="cond" t-att-value="cond.name + '=' + cond.value"> diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml index 5206393960f..8def5642a8b 100644 --- a/addons/web/static/src/xml/kanban.xml +++ b/addons/web/static/src/xml/kanban.xml @@ -48,7 +48,7 @@ <span class="o_kanban_title">Add new Column</span> </div> <div class="o_kanban_quick_create"> - <input type="text" placeholder="Add"/> + <input type="text" class="o_input" placeholder="Add"/> <button class="btn btn-sm btn-primary o_kanban_add">Add</button> <button class="btn btn-sm btn-default o_kanban_cancel">Discard</button> </div> @@ -57,7 +57,7 @@ <t t-name="KanbanView.QuickCreate"> <div class="o_kanban_quick_create"> - <input type="text" placeholder="Add"/> + <input type="text" class="o_input" placeholder="Add"/> <button class="btn btn-sm btn-primary o_kanban_add">Add</button> <button class="btn btn-sm btn-primary o_kanban_edit">Edit</button> <button class="btn btn-sm btn-default o_kanban_cancel">Discard</button> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index c2ab8121c16..b533f5056d2 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -736,6 +736,31 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('char field with size attribute', function (assert) { + assert.expect(1); + + this.data.partner.fields.foo.size = 5; // max length + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<group><field name="foo"/></group>' + + '</sheet>' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('input.o_field_widget').attr('maxlength'), '5', + "maxlength attribute should have been set correctly on the input"); + + form.destroy(); + }); + QUnit.test('char field in editable list view', function (assert) { assert.expect(6); @@ -1072,8 +1097,8 @@ QUnit.module('basic_fields', { res_id: 1, }); - assert.ok(form.$('div.o_field_text').length, "should have a text area"); - assert.strictEqual(form.$('div.o_field_text').text(), 'yop', 'should be "yop" in readonly'); + assert.ok(form.$('.o_field_text').length, "should have a text area"); + assert.strictEqual(form.$('.o_field_text').text(), 'yop', 'should be "yop" in readonly'); form.$buttons.find('.o_form_button_edit').click(); @@ -1089,7 +1114,7 @@ QUnit.module('basic_fields', { form.$buttons.find('.o_form_button_save').click(); - assert.strictEqual(form.$('div.o_field_text').text(), 'hello world', + assert.strictEqual(form.$('.o_field_text').text(), 'hello world', 'should be "hello world" after save'); form.destroy(); }); @@ -1109,7 +1134,7 @@ QUnit.module('basic_fields', { res_id: 1, }); - var $field = form.$('div.o_field_text'); + var $field = form.$('.o_field_text'); assert.strictEqual($field.outerHeight(), $field[0].scrollHeight, "text field should not have a scroll bar"); @@ -1211,6 +1236,31 @@ QUnit.module('basic_fields', { list.destroy(); }); + // Firefox-specific + // Copying from <div style="white-space:pre-wrap"> does not keep line breaks + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1390115 + QUnit.test('copying text fields in RO mode should preserve line breaks', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="txt"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + // Copying from a div tag with white-space:pre-wrap doesn't work in Firefox + assert.strictEqual(form.$('[name="txt"]').prop("tagName").toLowerCase(), 'span', + "the field contents should be surrounded by a span tag"); + }); + QUnit.module('FieldBinary'); QUnit.test('binary fields are correctly rendered', function (assert) { @@ -1563,7 +1613,7 @@ QUnit.module('basic_fields', { form.destroy(); }); - QUnit.test('date field in form view', function (assert) { + QUnit.test('date field in form view (with positive time zone offset)', function (assert) { assert.expect(8); var form = createView({ @@ -1582,7 +1632,9 @@ QUnit.module('basic_fields', { date_format: '%m/%d/%Y', }, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; // Should be ignored by date fields + }, }, }); @@ -1614,6 +1666,36 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('date field in form view (with negative time zone offset)', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="date"/></form>', + res_id: 1, + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + }, + session: { + getTZOffset: function () { + return -120; // Should be ignored by date fields + }, + }, + }); + + assert.strictEqual(form.$('.o_field_date').text(), '02/03/2017', + 'the date should be correctly displayed in readonly'); + + // switch to edit mode + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('.o_datepicker_input').val(), '02/03/2017', + 'the date should be correct in edit mode'); + + form.destroy(); + }); + QUnit.test('date field in editable list view', function (assert) { assert.expect(8); @@ -1628,7 +1710,9 @@ QUnit.module('basic_fields', { date_format: '%m/%d/%Y', }, session: { - tzOffset: 0 + getTZOffset: function () { + return 0; + }, }, }); @@ -1717,7 +1801,9 @@ QUnit.module('basic_fields', { time_format: '%H:%M:%S', }, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, }); @@ -1774,7 +1860,9 @@ QUnit.module('basic_fields', { time_format: '%H:%M:%S', }, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, }); @@ -1844,7 +1932,9 @@ QUnit.module('basic_fields', { time_format: '%H:%M:%S', }, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, }); @@ -1863,6 +1953,93 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('datetime field with date/datetime widget (with day change)', function (assert) { + assert.expect(2); + + this.data.partner.records[0].p = [2]; + this.data.partner.records[1].datetime = "2017-02-08 02:00:00"; // UTC + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="datetime"/>' + + '</tree>' + + '<form>' + + '<field name="datetime" widget="date"/>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M:%S', + }, + session: { + getTZOffset: function () { + return -240; + }, + }, + }); + + var expectedDateString = "02/07/2017 22:00:00"; // local time zone + assert.strictEqual(form.$('.o_field_widget[name=p] .o_data_cell').text(), expectedDateString, + 'the datetime (datetime widget) should be correctly displayed in tree view'); + + // switch to form view + form.$('.o_field_widget[name=p] .o_data_row').click(); + assert.strictEqual($('.modal .o_field_date[name=datetime]').text(), '02/07/2017', + 'the datetime (date widget) should be correctly displayed in form view'); + + form.destroy(); + }); + + QUnit.test('datetime field with date/datetime widget (without day change)', function (assert) { + assert.expect(2); + + this.data.partner.records[0].p = [2]; + this.data.partner.records[1].datetime = "2017-02-08 10:00:00"; // without timezone + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="datetime"/>' + + '</tree>' + + '<form>' + + '<field name="datetime" widget="date"/>' + + '</form>' + + '</field>' + + '</form>', + res_id: 1, + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M:%S', + }, + session: { + getTZOffset: function () { + return -240; + }, + }, + }); + + var expectedDateString = "02/08/2017 06:00:00"; // with timezone + assert.strictEqual(form.$('.o_field_widget[name=p] .o_data_cell').text(), expectedDateString, + 'the datetime (datetime widget) should be correctly displayed in tree view'); + + // switch to form view + form.$('.o_field_widget[name=p] .o_data_row').click(); + assert.strictEqual($('.modal .o_field_date[name=datetime]').text(), '02/08/2017', + 'the datetime (date widget) should be correctly displayed in form view'); + + form.destroy(); + }); QUnit.module('FieldMonetary'); diff --git a/addons/web/static/tests/fields/field_utils_tests.js b/addons/web/static/tests/fields/field_utils_tests.js index a49197f6e65..a0284798553 100644 --- a/addons/web/static/tests/fields/field_utils_tests.js +++ b/addons/web/static/tests/fields/field_utils_tests.js @@ -4,6 +4,7 @@ odoo.define('web.field_utils_tests', function (require) { var core = require('web.core'); var session = require('web.session'); var fieldUtils = require('web.field_utils'); +var testUtils = require('web.test_utils'); QUnit.module('fields', {}, function () { @@ -59,10 +60,35 @@ QUnit.test("format_datetime", function (assert) { var date_string = "2009-05-04 12:34:23"; var date = fieldUtils.parse.datetime(date_string, {}, {timezone: false}); - var str = fieldUtils.format.datetime(date, {timezone: false}); + var str = fieldUtils.format.datetime(date, {}, {timezone: false}); assert.strictEqual(str, moment(date).format("MM/DD/YYYY HH:mm:ss")); }); +QUnit.test("format_datetime (with different timezone offset)", function (assert) { + assert.expect(2); + + // mock the date format to avoid issues due to localisation + var dateFormat = core._t.database.parameters.date_format; + core._t.database.parameters.date_format = '%m/%d/%Y'; + session.getTZOffset = function (date) { + // simulate daylight saving time + var startDate = new Date(2017, 2, 26); + var endDate = new Date(2017, 9, 29); + if (startDate < date && date < endDate) { + return 120; // UTC+2 + } else { + return 60; // UTC+1 + } + }; + + var str = fieldUtils.format.datetime(moment.utc('2017-01-01T10:00:00Z')); + assert.strictEqual(str, '01/01/2017 11:00:00'); + str = fieldUtils.format.datetime(moment.utc('2017-06-01T10:00:00Z')); + assert.strictEqual(str, '06/01/2017 12:00:00'); + + core._t.database.parameters.date_format = dateFormat; +}); + QUnit.test("format_many2one", function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 29f8666455a..89f4601efc4 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -958,6 +958,45 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('list in form: create with one2many with many2one (version 2)', function (assert) { + // This test simulates the exact same scenario as the previous one, + // except that the value for the many2one is explicitely set to false, + // which is stupid, but this happens, so we have to handle it + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="display_name"/>' + + '<field name="trululu"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({p: [[0, 0, {display_name: 'new record', trululu: false}]]}); + } else if (args.method === 'name_get') { + // This should not be called at all and thus is not accounted for + // in the assert.expect. If this is called, you broke this test. + assert.notOk(_.str.startsWith(args.args[0][0], 'virtual_'), + "should not call name_get for the m2o inside o2m which has no value"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual($('td.o_data_cell:first').text(), 'new record', + "should have created the new record in the o2m with the correct name"); + + form.destroy(); + }); + QUnit.test('list in form: default_get with x2many create', function (assert) { assert.expect(3); @@ -1250,6 +1289,38 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('slow create on a many2one', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<sheet>' + + '<field name="product_id" options="{\'quick_create\': False}"/>' + + '</sheet>' + + '</form>', + archs: { + 'product,false,form': + '<form>' + + '<field name="name"/>' + + '</form>', + }, + }); + + form.$('.o_field_many2one input').focus(); + form.$('.o_field_many2one input').val('new partner').trigger('keyup').trigger('focusout'); + + $('.modal .modal-footer .btn-primary').first().click(); + + assert.strictEqual($('.modal:visible:last .o_form_view').length, 1, + 'a new modal should be opened and contain a form view'); + + form.destroy(); + }); + QUnit.test('no_create option on a many2one', function (assert) { assert.expect(1); @@ -1732,7 +1803,9 @@ QUnit.module('relational_fields', { '</form>', res_id: 1, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, }); assert.strictEqual(form.$('td:eq(0)').text(), "01/25/2017", @@ -5227,6 +5300,59 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many: onchange that returns unknow field in list, but not in form', function (assert) { + assert.expect(5); + + this.data.partner.onchanges = { + name: function () {}, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="name"/>' + + '<field name="p">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + '<field name="timmy" widget="many2many_tags"/>' + + '</form>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return $.when({ + value: { + p: [[5], [0, 0, {display_name: 'new', timmy: [[5], [4, 12]]}]], + }, + }); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "the one2many should contain one row"); + assert.strictEqual(form.$('.o_field_widget[name="timmy"]').length, 0, + "timmy should not be displayed in the list view"); + + form.$('.o_data_row td:first').click(); // open the record + + assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"]').length, 1, + "timmy should be displayed in the form view"); + assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"] .badge').length, 1, + "m2mtags should contain one tag"); + assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"] .o_badge_text').text(), + 'gold', "tag name should have been correctly loaded"); + + form.destroy(); + }); + + QUnit.module('FieldMany2Many'); QUnit.test('many2many kanban: edition', function (assert) { @@ -7214,7 +7340,7 @@ QUnit.module('relational_fields', { "should do a do_action with correct parameters"); }); - assert.strictEqual(form.$('a.o_form_uri:contains(first record)').length, 1, + assert.strictEqual(form.$('a.o_form_uri:contains(xphone)').length, 1, "should contain a link"); form.$('a.o_form_uri').click(); // click on the link in readonly mode (should trigger do_action) @@ -7226,7 +7352,7 @@ QUnit.module('relational_fields', { "should contain one many2one"); assert.strictEqual(form.$('.o_field_widget select').val(), "product", "widget should contain one select with the model"); - assert.strictEqual(form.$('.o_field_widget input').val(), "first record", + assert.strictEqual(form.$('.o_field_widget input').val(), "xphone", "widget should contain one input with the record"); var options = _.map(form.$('.o_field_widget select > option'), function (el) { @@ -7251,12 +7377,114 @@ QUnit.module('relational_fields', { $dropdown.find('li:first()').click(); form.$buttons.find('.o_form_button_save').click(); - assert.strictEqual(form.$('a.o_form_uri').length, 1, + assert.strictEqual(form.$('a.o_form_uri:contains(gold)').length, 1, "should contain a link with the new value"); form.destroy(); }); + QUnit.test('default_get and onchange with a reference field', function (assert) { + assert.expect(8); + + this.data.partner.fields.reference.default = 'product,37'; + this.data.partner.onchanges = { + int_field: function (obj) { + if (obj.int_field !== 0) { + obj.reference = 'partner_type,' + obj.int_field; + } + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="int_field"/>' + + '<field name="reference"/>' + + '</group>' + + '</sheet>' + + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'name_get') { + assert.step(args.model); + } + return this._super(route, args); + }, + }); + + assert.verifySteps(['product'], "the first name_get should have been done"); + assert.strictEqual(form.$('.o_field_widget[name="reference"] select').val(), "product", + "reference field model should be correctly set"); + assert.strictEqual(form.$('.o_field_widget[name="reference"] input').val(), "xphone", + "reference field value should be correctly set"); + + // trigger onchange + form.$('.o_field_widget[name=int_field]').val(12).trigger('input'); + + assert.verifySteps(['product', 'partner_type'], "the second name_get should have been done"); + assert.strictEqual(form.$('.o_field_widget[name="reference"] select').val(), "partner_type", + "reference field model should be correctly set"); + assert.strictEqual(form.$('.o_field_widget[name="reference"] input').val(), "gold", + "reference field value should be correctly set"); + form.destroy(); + }); + + QUnit.test('widget reference on char field, reset by onchange', function (assert) { + assert.expect(4); + + this.data.partner.records[0].foo = 'product,37'; + this.data.partner.onchanges = { + int_field: function (obj) { + obj.foo = 'product,' + obj.int_field; + }, + }; + + var nbNameGet = 0; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="int_field"/>' + + '<field name="foo" widget="reference" readonly="1"/>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.model === 'product' && args.method === 'name_get') { + nbNameGet++; + } + return this._super(route, args); + }, + }); + + assert.strictEqual(nbNameGet, 1, + "the first name_get should have been done"); + assert.strictEqual(form.$('a[name="foo"]').text(), "xphone", + "foo field should be correctly set"); + + // trigger onchange + form.$('.o_field_widget[name=int_field]').val(41).trigger('input'); + + assert.strictEqual(nbNameGet, 2, + "the second name_get should have been done"); + assert.strictEqual(form.$('a[name="foo"]').text(), "xpad", + "foo field should have been updated"); + form.destroy(); + }); + }); }); }); diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index fbfdb315fa4..343f04d9fad 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -257,8 +257,11 @@ function addMockEnvironment(widget, params) { var initialDebounce = DebouncedField.prototype.DEBOUNCE; DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0; var initialSession, initialConfig, initialParameters; + initialSession = _.extend({}, session); + session.getTZOffset = function () { + return 0; // by default, but may be overriden in specific tests + }; if ('session' in params) { - initialSession = _.extend({}, session); _.extend(session, params.session); } if ('config' in params) { @@ -283,8 +286,8 @@ function addMockEnvironment(widget, params) { for (key in session) { delete session[key]; } - _.extend(session, initialSession); } + _.extend(session, initialSession); if ('config' in params) { for (key in config) { delete config[key]; diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 8c23fc4f97c..318b715a504 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -1323,6 +1323,64 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('default_get: fetch many2one with default (empty & not) inside x2manys', function (assert) { + assert.expect(4); + + this.data.partner.fields.o2m = { + string: "O2M", type: 'one2many', relation: 'partner', default: [ + [6, 0, []], + [0, 0, {category: false}], + [0, 0, {category: 12}], + ], + }; + this.data.partner.fields.category.type = 'many2one'; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'name_get' && args.model === 'partner_type') { + assert.deepEqual(args.args, [[12]], "should name_get on category 12"); + } + return this._super(route, args); + }, + }); + + var params = { + fieldNames: ['o2m'], + fields: this.data.partner.fields, + fieldsInfo: { + form: { + o2m: { + relatedFields: this.data.partner.fields, + fieldsInfo: { + list: { + category: { + relatedFields: { display_name: {} }, + }, + }, + }, + viewType: 'list', + }, + }, + }, + modelName: 'partner', + type: 'record', + viewType: 'form', + }; + + model.load(params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.o2m.count, 2, "o2m field should contain 2 records"); + assert.strictEqual(record.data.o2m.data[0].data.category, false, + "first category field should be empty"); + assert.strictEqual(record.data.o2m.data[1].data.category.data.display_name, "gold", + "second category field should have been correctly fetched"); + }); + + model.destroy(); + }); + QUnit.test('default_get: fetch x2manys inside x2manys', function (assert) { assert.expect(3); @@ -2007,5 +2065,25 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('default_get with value false for a one2many', function (assert) { + assert.expect(1); + + this.data.partner.fields.product_ids.default = false; + this.params.fieldNames = ['product_ids']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = createModel({ + Model: BasicModel, + data: this.data, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.deepEqual(record.data.product_ids.data, [], "o2m default should be []"); + }); + + model.destroy(); + }); });}); diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index 6aa01daac5a..9f9be3a4042 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -440,7 +440,9 @@ QUnit.module('Views', { initialDate: initialDate, }, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, mockRPC: function (route, args) { if (args.method === "create") { @@ -507,7 +509,7 @@ QUnit.module('Views', { }); QUnit.test('create event with timezone in week mode with formViewDialog', function (assert) { - assert.expect(7); + assert.expect(8); this.data.event.records = []; this.data.event.onchanges = { @@ -541,7 +543,9 @@ QUnit.module('Views', { initialDate: initialDate, }, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, mockRPC: function (route, args) { if (args.method === "create") { @@ -554,12 +558,8 @@ QUnit.module('Views', { "should send the context to create events"); } if (args.method === "write") { - assert.deepEqual(args.args[1], { - "allday": false, - "start": "2016-12-12 06:00:00", - "stop": "2016-12-12 08:00:00" - }, - "should move the event"); + assert.deepEqual(args.args[1], expectedEvent, + "should move the event"); } return this._super(route, args); }, @@ -631,17 +631,34 @@ QUnit.module('Views', { left = pos.left + 5; top = pos.top + 5; + // Mode this event to another day + var expectedEvent = { + "allday": false, + "start": "2016-12-12 06:00:00", + "stop": "2016-12-12 08:00:00" + }; testUtils.triggerPositionalMouseEvent(left, top, "mousedown"); left = calendar.$('.fc-day:eq(1)').offset().left + 5; testUtils.triggerPositionalMouseEvent(left, top, "mousemove"); testUtils.triggerPositionalMouseEvent(left, top, "mouseup"); + // Move to "All day" + expectedEvent = { + "allday": true, + "start": "2016-12-12 00:00:00", + "stop": "2016-12-12 00:00:00" + }; + testUtils.triggerPositionalMouseEvent(left, top, "mousedown"); + top = calendar.$('.fc-day:eq(1)').offset().top + 5; + testUtils.triggerPositionalMouseEvent(left, top, "mousemove"); + testUtils.triggerPositionalMouseEvent(left, top, "mouseup"); + calendar.destroy(); $view.remove(); }); - QUnit.test('create all day event', function (assert) { - assert.expect(2); + QUnit.test('create all day event in week mode', function (assert) { + assert.expect(3); this.data.event.records = []; @@ -664,7 +681,9 @@ QUnit.module('Views', { initialDate: initialDate, }, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, }); @@ -690,6 +709,8 @@ QUnit.module('Views', { assert.strictEqual($newevent.text().replace(/[\s\n\r]+/g, ''), "newevent", "should display the new event with time and title"); + assert.strictEqual($newevent.parent().attr('colspan'), "2", + "should appear over two days."); assert.deepEqual($newevent.data('fcSeg').event.record, { @@ -706,6 +727,141 @@ QUnit.module('Views', { $view.remove(); }); + QUnit.test('create all day event in week mode (no quickCreate)', function (assert) { + assert.expect(1); + + this.data.event.records = []; + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week" '+ + 'quick_add="False" '+ + 'readonly_form_view_id="1">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + intercepts: { + do_action: function (event) { + assert.deepEqual(event.data.action.context, { + "default_name": null, + "default_start": "2016-12-14 05:00:00", + "default_stop": "2016-12-15 17:00:00", + }, + "should send the correct data to create events"); + }, + }, + }); + + var $view = $('#qunit-fixture').contents(); + $view.prependTo('body'); // => select with click position + + + var pos = calendar.$('.fc-bg td:eq(4)').offset(); + try { + testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown"); + } catch (e) { + calendar.destroy(); + $view.remove(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + pos = calendar.$('.fc-bg td:eq(5)').offset(); + testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove"); + testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup"); + + calendar.destroy(); + $view.remove(); + }); + + QUnit.test('create event in month mode', function (assert) { + assert.expect(4); + + this.data.event.records = []; + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'event_open_popup="true" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month" '+ + 'readonly_form_view_id="1">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return 120; + }, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.deepEqual(args.args[0], { + "name": "new event", + "start": "2016-12-14 05:00:00", + "stop": "2016-12-15 17:00:00", + }, + "should send the correct data to create events"); + } + return this._super(route, args); + }, + }); + + var $view = $('#qunit-fixture').contents(); + $view.prependTo('body'); // => select with click position + + var pos = calendar.$('.fc-bg td:eq(20)').offset(); + try { + testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown"); + } catch (e) { + calendar.destroy(); + $view.remove(); + throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.'); + } + pos = calendar.$('.fc-bg td:eq(21)').offset(); + testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove"); + testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup"); + + $('.modal input:first').val('new event').trigger('input'); + $('.modal button.btn:contains(Create)').trigger('click'); + var $newevent = calendar.$('.fc-event:contains(new event)'); + + assert.strictEqual($newevent.text().replace(/[\s\n\r]+/g, ''), "newevent", + "should display the new event with time and title"); + assert.strictEqual($newevent.parent().attr('colspan'), "2", + "should appear over two days."); + + assert.deepEqual($newevent.data('fcSeg').event.record, { + display_name: "new event", + start: fieldUtils.parse.datetime("2016-12-14 05:00:00", this.data.event.fields.start, {isUTC: true}), + stop: fieldUtils.parse.datetime("2016-12-15 17:00:00", this.data.event.fields.stop, {isUTC: true}), + name: "new event", + id: 1 + }, "the new record should have the utc datetime (quickCreate)"); + + calendar.destroy(); + $view.remove(); + }); + QUnit.test('use mini calendar', function (assert) { assert.expect(12); @@ -728,7 +884,9 @@ QUnit.module('Views', { initialDate: initialDate, }, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, }); @@ -866,6 +1024,62 @@ QUnit.module('Views', { assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body"); }); + QUnit.test('create and edit event in month mode (all_day: false)', function (assert) { + assert.expect(2); + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar class="o_calendar_test" '+ + 'string="Events" ' + + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="month" '+ + 'readonly_form_view_id="1">'+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + session: { + getTZOffset: function () { + return -240; + }, + }, + }); + + // create a new event and edit it + var $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + $('.modal-body input:first').val('coucou').trigger('input'); + + testUtils.intercept(calendar, 'do_action', function (event) { + assert.deepEqual(event.data.action, + { + type: "ir.actions.act_window", + res_model: "event", + views: [[false, "form"]], + target: "current", + context: { + "default_name": "coucou", + "default_start": "2016-12-27 11:00:00", // 7:00 + 4h + "default_stop": "2016-12-27 23:00:00", // 19:00 + 4h + } + }, + "should open the form view with the context default values"); + }); + + $('.modal button.btn:contains(Edit)').trigger('click'); + + calendar.destroy(); + assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body"); + }); + + QUnit.test('readonly date_start field', function (assert) { assert.expect(4); @@ -981,37 +1195,6 @@ QUnit.module('Views', { calendar.destroy(); }); - QUnit.test('ensure calendar adapts to screen size', function (assert) { - assert.expect(2); - - // We want to be able to restore changes on 'body' - var bodyStyle = $('body').attr('style'); - - // Force the view height to be smaller than the natural calendar height - // (FullCalendar requires that the height be modified on body not $target) - var height = 500; - $('body').height(height); - - var calendar = createView({ - View: CalendarView, - model: 'event', - data: this.data, - arch: '<calendar mode="week" date_start="start" />', - }); - - var $scroller = calendar.$('.fc-scroller') - assert.strictEqual($scroller.height() < height, true, - "the scroller should be smaller than the view height"); - assert.strictEqual($scroller.prop('scrollHeight') > height, true, - "the scroller should have a scrollbar"); - calendar.destroy(); - - $('body').removeAttr('style'); - if (bodyStyle) { - $('body').attr('style', bodyStyle); - } - }); - QUnit.test('ensure events are still shown if filters give an empty domain', function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index b1ded45d9a2..c71ec8f2d93 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -2885,7 +2885,9 @@ QUnit.module('Views', { '</form>', res_id: 1, session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, }); @@ -3779,6 +3781,60 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('one2manys (list editable) inside one2manys are saved correctly', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="p">' + + '<tree>' + + '<field name="p"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + archs: { + "partner,false,form": '<form>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '</form>' + }, + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0].p, + [[0, false, { + p: [[0, false, {display_name: "xtv"}]], + }]], + "create should be called with the correct arguments"); + } + return this._super.apply(this, arguments); + }, + }); + + // add a o2m subrecord + form.$('.o_field_x2many_list_row_add a').click(); + $('.modal-body .o_field_one2many .o_field_x2many_list_row_add a').click(); + $('.modal-body input').val('xtv').trigger('input'); + $('.modal-footer button:first').click(); // save & close + assert.strictEqual($('.modal').length, 0, + "dialog should be closed"); + + var row = form.$('.o_field_one2many .o_list_view .o_data_row'); + assert.strictEqual(row.children()[0].textContent, '1 record', + "the cell should contains the number of record: 1"); + + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + }); + QUnit.test('*_view_ref in context are passed correctly', function (assert) { var done = assert.async(); assert.expect(4); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index be8a8a496af..84091f91a08 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -136,11 +136,12 @@ QUnit.module('Views', { }); QUnit.test('basic grouped rendering with active field', function (assert) { - assert.expect(4); + assert.expect(6); // add active field on partner model and make all records active this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test var kanban = createView({ View: KanbanView, model: 'partner', @@ -152,6 +153,12 @@ QUnit.module('Views', { '<div><field name="foo"/></div>' + '</t></templates></kanban>', groupBy: ['bar'], + intercepts: { + env_updated: function (event) { + assert.deepEqual(event.data.ids, envIDs, + "should notify the environment with the correct ids"); + }, + }, }); // check available actions in kanban header's config dropdown @@ -163,6 +170,7 @@ QUnit.module('Views', { // archive the records of the first column assert.strictEqual(kanban.$('.o_kanban_group:last .o_kanban_record').length, 3, "last column should contain 3 records"); + envIDs = [4]; kanban.$('.o_kanban_group:last .o_column_archive').click(); // click on 'Archive' assert.strictEqual(kanban.$('.o_kanban_group:last .o_kanban_record').length, 0, "last column should contain no record"); @@ -546,8 +554,9 @@ QUnit.module('Views', { }); QUnit.test('can drag and drop a record from one column to the next', function (assert) { - assert.expect(7); + assert.expect(9); + var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test this.data.partner.fields.sequence = {type: 'number', string: "Sequence"}; var kanban = createView({ View: KanbanView, @@ -569,6 +578,12 @@ QUnit.module('Views', { } return this._super(route, args); }, + intercepts: { + env_updated: function (event) { + assert.deepEqual(event.data.ids, envIDs, + "should notify the environment with the correct ids"); + }, + }, }); assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2, "column should contain 2 record(s)"); @@ -578,6 +593,7 @@ QUnit.module('Views', { assert.strictEqual(kanban.$('.thisiseditable').length, 4, "all records should be editable"); var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first'); var $group = kanban.$('.o_kanban_group:nth-child(2)'); + envIDs = [3, 2, 4, 1]; // first record of first column moved to the bottom of second column testUtils.dragAndDrop($record, $group); assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 1, @@ -676,6 +692,42 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('environment is updated when (un)folding groups', function (assert) { + assert.expect(3); + + var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + intercepts: { + env_updated: function (event) { + assert.deepEqual(envIDs, event.data.ids, + "should notify the environment with the correct ids"); + }, + }, + }); + + // fold the second group and check that the res_ids it contains are no + // longer in the environment + envIDs = [1, 3]; + kanban.$('.o_kanban_group:last .o_kanban_toggle_fold').click(); + + // re-open the second group and check that the res_ids it contains are + // back in the environment + envIDs = [1, 3, 2, 4]; + kanban.$('.o_kanban_group:last .o_kanban_toggle_fold').click(); + + kanban.destroy(); + }); + QUnit.test('create a column in grouped on m2o', function (assert) { assert.expect(13); @@ -1282,8 +1334,9 @@ QUnit.module('Views', { }); QUnit.test('resequence columns in grouped by m2o', function (assert) { - assert.expect(4); + assert.expect(7); + var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test var kanban = createView({ View: KanbanView, model: 'partner', @@ -1301,6 +1354,12 @@ QUnit.module('Views', { } return this._super.apply(this, arguments); }, + intercepts: { + env_updated: function (event) { + assert.deepEqual(event.data.ids, envIDs, + "should notify the environment with the correct ids"); + }, + }, }); assert.ok(kanban.$('.o_kanban_view').hasClass('ui-sortable'), @@ -1313,6 +1372,7 @@ QUnit.module('Views', { // there is a 100ms delay on the d&d feature (jquery sortable) for // kanban columns, making it hard to test. So we rather bypass the d&d // for this test, and directly call the event handler + envIDs = [2, 4, 1, 3]; // the columns will be inverted kanban._onResequenceColumn({data: {ids: [5, 3]}}); kanban.update({}, {reload: false}); // re-render without reloading @@ -1441,8 +1501,9 @@ QUnit.module('Views', { }); QUnit.test('load more records in column', function (assert) { - assert.expect(9); + assert.expect(12); + var envIDs = [1, 2, 4]; // the ids that should be in the environment during this test var kanban = createView({ View: KanbanView, model: 'partner', @@ -1462,12 +1523,19 @@ QUnit.module('Views', { } return this._super.apply(this, arguments); }, + intercepts:{ + env_updated: function (event) { + assert.deepEqual(event.data.ids, envIDs, + "should notify the environment with the correct ids"); + }, + }, }); assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2, "there should be 2 records in the column"); // load more + envIDs = [1, 2, 3, 4]; // id 3 will be loaded kanban.$('.o_kanban_group:eq(1)').find('.o_kanban_load_more').click(); assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3, @@ -1476,6 +1544,8 @@ QUnit.module('Views', { assert.verifySteps([[2, undefined], [2, undefined], [2, 2]], "the records should be correctly fetched"); + // reload + envIDs = [1, 2, 4]; // first group is limited again to 2 records kanban.reload(); assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2, "there should be 2 records in the column after reload"); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 2247a64fa3c..4b33af9f0a6 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -1271,7 +1271,9 @@ QUnit.module('Views', { data: this.data, arch: '<tree><field name="date"/><field name="datetime"/></tree>', session: { - tzOffset: 120 + getTZOffset: function () { + return 120; + }, }, }); @@ -1322,12 +1324,13 @@ QUnit.module('Views', { }); QUnit.test('list view with nested groups', function (assert) { - assert.expect(37); + assert.expect(42); this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1}); this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 2}); var nbRPCs = {readGroup: 0, searchRead: 0}; + var envIDs = []; // the ids that should be in the environment during this test var list = createView({ View: ListView, @@ -1352,10 +1355,14 @@ QUnit.module('Views', { return this._super.apply(this, arguments); }, intercepts: { - switch_view: function (event) { + switch_view: function (event) { assert.strictEqual(event.data.res_id, 4, "'switch_view' event has been triggered"); }, + env_updated: function (event) { + assert.deepEqual(event.data.ids, envIDs, + "should notify the environment with the correct ids"); + }, }, }); @@ -1398,6 +1405,7 @@ QUnit.module('Views', { // open subgroup nbRPCs = {readGroup: 0, searchRead: 0}; + envIDs = [4, 5]; // the opened subgroup contains these two records $openGroup.find('.o_group_header:nth(2)').click(); assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group"); assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read"); @@ -1414,6 +1422,7 @@ QUnit.module('Views', { // sort by int_field (ASC) and check that open groups are still open nbRPCs = {readGroup: 0, searchRead: 0}; + envIDs = [5, 4]; // order of the records changed list.$('thead th:last').click(); assert.strictEqual(nbRPCs.readGroup, 2, "should have done two read_groups"); assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read"); @@ -1427,6 +1436,7 @@ QUnit.module('Views', { // close first level group nbRPCs = {readGroup: 0, searchRead: 0}; + envIDs = []; // the group being closed, there is no more record in the environment list.$('.o_group_header:first').click(); assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group"); assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read"); @@ -2519,6 +2529,55 @@ QUnit.module('Views', { list.destroy(); }); + + QUnit.test('grouped list view, indentation for empty group', function (assert) { + assert.expect(3); + + this.data.foo.fields.priority = { + string: "Priority", + type: "selection", + selection: [[1, "Low"], [2, "Medium"], [3, "High"]], + default: 1, + }; + this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2}); + this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3}); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="id"/></tree>', + groupBy: ['priority', 'm2o'], + mockRPC: function (route, args) { + // Override of the read_group to display the row even if there is no record in it, + // to mock the behavihour of some fields e.g stage_id on the sale order. + if (args.method === 'read_group' && args.kwargs.groupby[0] === "m2o") { + return $.when([ + { + id: 8, + m2o:[1,"Value 1"], + m2o_count: 0 + }, { + id: 2, + m2o:[2,"Value 2"], + m2o_count: 1 + } + ]); + } + return this._super.apply(this, arguments); + }, + }); + + // open the first group + list.$('.o_group_header:first').click(); + assert.strictEqual(list.$('th.o_group_name').eq(1).children().length, 1, + "There should be an empty element creating the indentation for the subgroup."); + assert.strictEqual(list.$('th.o_group_name').eq(1).children().eq(0).hasClass('fa'), true, + "The first element of the row name should have the fa class"); + assert.strictEqual(list.$('th.o_group_name').eq(1).children().eq(0).is('span'), true, + "The first element of the row name should be a span"); + list.destroy(); + }); }); }); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 8c46d8fff46..43bf4ee6694 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -140,6 +140,35 @@ QUnit.module('Views', { dialog.destroy(); }); + QUnit.test('SelectCreateDialog list view in readonly', function (assert) { + assert.expect(1); + + var parent = createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree string="Partner" editable="bottom">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</tree>', + 'partner,false,search': + '<search/>' + }, + }); + + var dialog = new dialogs.SelectCreateDialog(parent, { + res_model: 'partner', + }).open(); + + // click on the first row to see if the list is editable + dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first').click(); + + assert.equal(dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first input').length, 0, + "list view should not be editable in a SelectCreateDialog"); + + dialog.destroy(); + }); + }); -});
\ No newline at end of file +}); diff --git a/addons/web/static/tests/widgets/data_export_tests.js b/addons/web/static/tests/widgets/data_export_tests.js index 83169a64e1f..d251e3dea15 100644 --- a/addons/web/static/tests/widgets/data_export_tests.js +++ b/addons/web/static/tests/widgets/data_export_tests.js @@ -4,6 +4,7 @@ odoo.define('web.data_export_tests', function (require) { var framework = require('web.framework'); var ListView = require('web.ListView'); var testUtils = require('web.test_utils'); +var data = require('web.data'); var createView = testUtils.createView; @@ -127,6 +128,89 @@ QUnit.module('widgets', { ]); }); + QUnit.test('saving fields list when exporting data', function (assert) { + assert.expect(6); + + var create = data.DataSet.prototype.create; + + data.DataSet.prototype.create = function (data, options) { + assert.step('create'); + return $.when([]); + }; + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree><field name="foo"/></tree>', + viewOptions: { + sidebar: true, + }, + mockRPC: function (route) { + if (route === '/web/export/formats') { + return $.when([ + {tag: 'csv', label: 'CSV'}, + {tag: 'xls', label: 'Excel'}, + ]); + } + if (route === '/web/export/get_fields') { + return $.when([ + { + field_type: "one2many", + string: "Activities", + required: false, + value: "activity_ids/id", + id: "activity_ids", + params: {"model": "mail.activity", "prefix": "activity_ids", "name": "Activities"}, + relation_field: "res_id", + children: true, + }, + ]); + } + return this._super.apply(this, arguments); + }, + }); + + list.getParent().searchview = { + build_search_data: function () { + assert.step('build_search_data'); + return { + contexts: [], + domains: [], + groupbys: [], + }; + }, + }; + + // Open the export modal + list.$('thead th.o_list_record_selector input').click(); + list.sidebar.$('a:contains(Export)').click(); + assert.strictEqual($('.modal').length, 1, + "a modal dialog should be open"); + + // Select 'Activities' in fields to export + assert.strictEqual($('.modal select.o_fields_list option').length, 0, + "the fields list should be empty"); + $('.modal .o_export_tree_item:contains(Activities)').click(); + $('.modal button:contains(Add)').click(); + assert.strictEqual($('.modal select.o_fields_list option').length, 1, + "there should be one item in the fields list"); + + // Save fields list + $('.modal a:contains(Save fields list)').click(); + $('.modal .o_save_list > input').val('fields list').trigger('input'); + $('.modal .o_save_list > button').click(); + assert.verifySteps(['build_search_data', 'create'], + "create should have been called"); + + // Close the modal and destroy list + $('.modal button span:contains(Close)').click(); + list.destroy(); + + // restore create function + data.DataSet.prototype.create = create; + }); + }); }); diff --git a/addons/web_diagram/controllers/main.py b/addons/web_diagram/controllers/main.py index 56b6c76eefe..d29b60df40e 100644 --- a/addons/web_diagram/controllers/main.py +++ b/addons/web_diagram/controllers/main.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import odoo.http as http -from odoo.tools import pycompat from odoo.tools.safe_eval import safe_eval @@ -46,7 +45,7 @@ class DiagramView(http.Controller): isolate_nodes[blnk_node['id']] = blnk_node y = [ t['y'] - for t in pycompat.values(nodes) + for t in nodes.values() if t['x'] == 20 if t['y'] ] @@ -99,11 +98,11 @@ class DiagramView(http.Controller): color='white', options={} ) - for color, expr in pycompat.items(bgcolors): + for color, expr in bgcolors.items(): if safe_eval(expr, act): n['color'] = color - for shape, expr in pycompat.items(shapes): + for shape, expr in shapes.items(): if safe_eval(expr, act): n['shape'] = shape diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 5a3eca776ef..50a9540323a 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - -import cStringIO +import base64 import io import json import logging @@ -14,11 +13,11 @@ from lxml import etree, html from odoo.http import request from odoo import http, tools +from odoo.tools import pycompat from odoo.modules.module import get_resource_path, get_module_path logger = logging.getLogger(__name__) - class Web_Editor(http.Controller): #------------------------------------------------------ # Backend snippet @@ -40,7 +39,7 @@ class Web_Editor(http.Controller): debug=request.debug) for k in kwargs: - if isinstance(kwargs[k], basestring) and kwargs[k].isdigit(): + if isinstance(kwargs[k], pycompat.string_types) and kwargs[k].isdigit(): kwargs[k] = int(kwargs[k]) trans = dict( @@ -96,7 +95,7 @@ class Web_Editor(http.Controller): font_obj = ImageFont.truetype(addons_path + font, size) # if received character is not a number, keep old behaviour (icon is character) - icon = unichr(int(icon)) if icon.isdigit() else icon + icon = pycompat.unichr(int(icon)) if icon.isdigit() else icon # Determine the dimensions of the icon image = Image.new("RGBA", (size, size), color=(0, 0, 0, 0)) @@ -165,7 +164,7 @@ class Web_Editor(http.Controller): for c_file in request.httprequest.files.getlist('upload'): data = c_file.read() try: - image = Image.open(cStringIO.StringIO(data)) + image = Image.open(io.BytesIO(data)) w, h = image.size if w*h > 42e6: # Nokia Lumia 1020 photo resolution raise ValueError( @@ -178,7 +177,7 @@ class Web_Editor(http.Controller): attachment = Attachments.create({ 'name': c_file.filename, - 'datas': data.encode('base64'), + 'datas': base64.b64encode(data), 'datas_fname': c_file.filename, 'public': True, 'res_model': 'ir.ui.view', @@ -187,7 +186,7 @@ class Web_Editor(http.Controller): uploads += attachments.read(['name', 'mimetype', 'checksum', 'url']) except Exception as e: logger.exception("Failed to upload image to attachment") - message = unicode(e) + message = pycompat.text_type(e) return """<script type='text/javascript'> window.parent['%s'](%s, %s); @@ -316,7 +315,7 @@ class Web_Editor(http.Controller): content = None if url_info["customized"]: # If the file is already customized, the content is found in the corresponding attachment - content = custom_attachments.filtered(lambda a: a.url == url).datas.decode("base64") + content = base64.b64decode(custom_attachments.filtered(lambda a: a.url == url).datas) else: # If the file is not yet customized, the content is found by reading the local less file module = url_info["module"] @@ -354,14 +353,14 @@ class Web_Editor(http.Controller): custom_attachment = IrAttachment.search([("url", "=", custom_url)]) if custom_attachment: # If it was already modified, simply override the corresponding attachment content - custom_attachment.write({"datas": content.encode("utf-8").encode("base64")}) + custom_attachment.write({"datas": base64.b64encode(content.encode("utf-8"))}) else: # If not, create a new attachment to copy the original LESS file content, with its modifications IrAttachment.create(dict( name = custom_url, type = "binary", mimetype = "text/less", - datas = content.encode("utf-8").encode("base64"), + datas = base64.b64encode(content.encode("utf-8")), datas_fname = url.split("/")[-1], url = custom_url, # Having an attachment of "binary" type with an non empty "url" field # is quite of an hack. This allows to fetch the "datas" field by adding diff --git a/addons/web_editor/models/ir_qweb.py b/addons/web_editor/models/ir_qweb.py index 4cceec10421..f2132c596f0 100644 --- a/addons/web_editor/models/ir_qweb.py +++ b/addons/web_editor/models/ir_qweb.py @@ -9,7 +9,8 @@ Also, adds methods to convert values back to Odoo models. """ import ast -import cStringIO +import base64 +import io import itertools import json import logging @@ -47,7 +48,10 @@ class QWeb(models.AbstractModel): el.set('t-call', el.attrib.pop('t-snippet')) name = self.env['ir.ui.view'].search([('key', '=', el.attrib.get('t-call'))]).display_name thumbnail = el.attrib.pop('t-thumbnail', "oe-thumbnail") - div = u'<div name="%s" data-oe-type="snippet" data-oe-thumbnail="%s">' % (escape(ir_qweb.unicodifier(name)), escape(ir_qweb.unicodifier(thumbnail))) + div = u'<div name="%s" data-oe-type="snippet" data-oe-thumbnail="%s">' % ( + escape(pycompat.to_text(name)), + escape(pycompat.to_text(thumbnail)) + ) return [self._append(ast.Str(div))] + self._compile_node(el, options) + [self._append(ast.Str(u'</div>'))] def _compile_directive_install(self, el, options): @@ -57,7 +61,11 @@ class QWeb(models.AbstractModel): return [] name = el.attrib.get('string') or 'Snippet' thumbnail = el.attrib.pop('t-thumbnail', 'oe-thumbnail') - div = u'<div name="%s" data-oe-type="snippet" data-module-id="%s" data-oe-thumbnail="%s"><section/></div>' % (escape(ir_qweb.unicodifier(name)), module.id, escape(ir_qweb.unicodifier(thumbnail))) + div = u'<div name="%s" data-oe-type="snippet" data-module-id="%s" data-oe-thumbnail="%s"><section/></div>' % ( + escape(pycompat.to_text(name)), + module.id, + escape(pycompat.to_text(thumbnail)) + ) return [self._append(ast.Str(div))] else: return [] @@ -201,7 +209,7 @@ class DateTime(models.AbstractModel): def attributes(self, record, field_name, options, values): attrs = super(DateTime, self).attributes(record, field_name, options, values) value = record[field_name] - if isinstance(value, basestring): + if isinstance(value, pycompat.string_types): value = fields.Datetime.from_string(value) if value: # convert from UTC (server timezone) to user timezone @@ -274,7 +282,7 @@ class HTML(models.AbstractModel): content = [] if element.text: content.append(element.text) - content.extend(html.tostring(child) + content.extend(html.tostring(child, encoding='unicode') for child in element.iterchildren(tag=etree.Element)) return '\n'.join(content) @@ -307,7 +315,7 @@ class Image(models.AbstractModel): if max_width or max_height: max_size = '%sx%s' % (max_width, max_height) - sha = hashlib.sha1(getattr(record, '__last_update')).hexdigest()[0:7] + sha = hashlib.sha1(getattr(record, '__last_update').encode('utf-8')).hexdigest()[0:7] max_size = '' if max_size is None else '/%s' % max_size src = '/web/image/%s/%s/%s%s?unique=%s' % (record._name, record.id, field_name, max_size, sha) @@ -325,7 +333,7 @@ class Image(models.AbstractModel): img = '<img class="%s" src="%s" style="%s"%s%s/>' % \ (classes, src, options.get('style', ''), ' alt="%s"' % alt if alt else '', ' data-zoom="1" data-zoom-image="%s"' % src_zoom if src_zoom else '') - return ir_qweb.unicodifier(img) + return pycompat.to_text(img) local_url_re = re.compile(r'^/(?P<module>[^]]+)/static/(?P<rest>.+)$') @@ -374,7 +382,7 @@ class Image(models.AbstractModel): image = I.open(f) image.load() f.seek(0) - return f.read().encode('base64') + return base64.b64encode(f.read()) except Exception: logger.exception("Failed to load local image %r", url) return None @@ -390,7 +398,7 @@ class Image(models.AbstractModel): req = requests.get(url, timeout=REMOTE_CONNECTION_TIMEOUT) # PIL needs a seekable file-like image so wrap result in IO buffer - image = I.open(cStringIO.StringIO(req.content)) + image = I.open(io.BytesIO(req.content)) # force a complete load of the image data to validate it image.load() except Exception: @@ -399,9 +407,9 @@ class Image(models.AbstractModel): # don't use original data in case weird stuff was smuggled in, with # luck PIL will remove some of it? - out = cStringIO.StringIO() + out = io.BytesIO() image.save(out, image.format) - return out.getvalue().encode('base64') + return base64.b64encode(out.getvalue()) class Monetary(models.AbstractModel): diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py index 7cf9e2f3a27..977f02f4b44 100644 --- a/addons/web_editor/models/ir_ui_view.py +++ b/addons/web_editor/models/ir_ui_view.py @@ -91,7 +91,7 @@ class IrUiView(models.Model): @api.model def to_field_ref(self, el): # filter out meta-information inserted in the document - attributes = {k: v for k, v in pycompat.items(el.attrib) + attributes = {k: v for k, v in el.attrib.items() if not k.startswith('data-oe-')} attributes['t-field'] = el.get('data-oe-expression') @@ -127,7 +127,7 @@ class IrUiView(models.Model): @api.model def _view_obj(self, view_id): - if isinstance(view_id, basestring): + if isinstance(view_id, pycompat.string_types): return self.env.ref(view_id) elif isinstance(view_id, pycompat.integer_types): return self.browse(view_id) diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js index cdcf5167e4a..03c767328d1 100644 --- a/addons/web_editor/static/src/js/widgets/ace.js +++ b/addons/web_editor/static/src/js/widgets/ace.js @@ -224,7 +224,15 @@ var ViewEditor = Widget.extend({ initType = 'less'; } if (!this.options.doNotLoadViews) { - initResID = (typeof this.viewKey === 'number' ? this.viewKey : _.findWhere(this.views, {xml_id: this.viewKey}).id); + if (typeof this.viewKey === "number") { + initResID = this.viewKey; + } else { + var view = _.findWhere(this.views, {xml_id: this.viewKey}); + if (!view) { + view = _.findWhere(this.views, {key: this.viewKey}); + } + initResID = view.id; + } initType = 'xml'; } } diff --git a/addons/web_editor/tests/test_converter.py b/addons/web_editor/tests/test_converter.py index 91d4b578383..761de27515f 100644 --- a/addons/web_editor/tests/test_converter.py +++ b/addons/web_editor/tests/test_converter.py @@ -136,7 +136,7 @@ class TestConvertBack(common.TransactionCase): converter = self.env[model] if model in self.env else self.env['ir.qweb.field'] value_back = converter.from_html(model, record._fields[field], element) - if isinstance(expected, str): + if isinstance(expected, bytes): expected = expected.decode('utf-8') self.assertEqual(value_back, expected) diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 0ab03af4522..8f200b7bc25 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import datetime from itertools import islice import json @@ -38,11 +38,11 @@ class QueryURL(object): def __call__(self, path=None, path_args=None, **kw): path = path or self.path - for key, value in pycompat.items(self.args): + for key, value in self.args.items(): kw.setdefault(key, value) path_args = OrderedSet(path_args or []) | self.path_args paths, fragments = {}, [] - for key, value in pycompat.items(kw): + for key, value in kw.items(): if value and key in path_args: if isinstance(value, models.BaseModel): paths[key] = slug(value) @@ -87,7 +87,7 @@ class Website(Home): response = super(Website, self).web_login(redirect=redirect, *args, **kw) if not redirect and request.params['login_success']: if request.env['res.users'].browse(request.uid).has_group('base.group_user'): - redirect = '/web?' + request.httprequest.query_string + redirect = b'/web?' + request.httprequest.query_string else: redirect = '/' return http.redirect_with_hash(redirect) @@ -114,7 +114,7 @@ class Website(Home): } # /page/website.XXX --> /page/XXX if page.startswith('website.'): - return request.redirect('/page/%s?%s' % (page[8:], request.httprequest.query_string), code=301) + return request.redirect(b'/page/%s?%s' % (page[8:].encode('utf-8'), request.httprequest.query_string), code=301) elif '.' not in page: page = 'website.%s' % page @@ -149,7 +149,7 @@ class Website(Home): def create_sitemap(url, content): return Attachment.create({ - 'datas': content.encode('base64'), + 'datas': base64.b64encode(content), 'mimetype': mimetype, 'type': 'binary', 'name': url, @@ -162,7 +162,7 @@ class Website(Home): create_date = fields.Datetime.from_string(sitemap.create_date) delta = datetime.datetime.now() - create_date if delta < SITEMAP_CACHE_TIME: - content = sitemap.datas.decode('base64') + content = base64.b64decode(sitemap) if not content: # Remove all sitemaps in ir.attachments as we're going to regenerated them @@ -318,9 +318,14 @@ class Website(Home): def get_view_ids(self, xml_ids): ids = [] + View = request.env["ir.ui.view"].with_context(active_test=False) for xml_id in xml_ids: if "." in xml_id: - record_id = request.env.ref(xml_id).id + # Get website-specific view if possible + record_id = View.search([ + ("website_id", "=", request.website.id), + ("key", "=", xml_id), + ]).id or request.env.ref(xml_id).id else: record_id = int(xml_id) ids.append(record_id) @@ -381,7 +386,7 @@ class Website(Home): action = action_id = None # find the action_id: either an xml_id, the path, or an ID - if isinstance(path_or_xml_id_or_id, basestring) and '.' in path_or_xml_id_or_id: + if isinstance(path_or_xml_id_or_id, pycompat.string_types) and '.' in path_or_xml_id_or_id: action = request.env.ref(path_or_xml_id_or_id, raise_if_not_found=False) if not action: action = ServerActions.search([('website_path', '=', path_or_xml_id_or_id), ('website_published', '=', True)], limit=1) diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index e6cedfe07d2..a20db972c0b 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -5,7 +5,6 @@ import ast from odoo import models from odoo.http import request -from odoo.tools import pycompat class QWeb(models.AbstractModel): @@ -82,7 +81,7 @@ class QWeb(models.AbstractModel): atts = super(QWeb, self)._get_dynamic_att(tagName, atts, options, values) if options.get('rendering_bundle'): return atts - for name, value in pycompat.items(atts): + for name, value in atts.items(): atts[name] = self._website_build_attribute(tagName, name, value, options, values) return atts diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index fcb7e3015f2..eec00e2e897 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -50,7 +50,7 @@ class View(models.Model): @api.model def _view_obj(self, view_id): - if isinstance(view_id, basestring): + if isinstance(view_id, pycompat.string_types): if 'website_id' in self._context: domain = [('key', '=', view_id), '|', ('website_id', '=', False), ('website_id', '=', self._context.get('website_id'))] order = 'website_id' @@ -69,6 +69,11 @@ class View(models.Model): return view_id @api.model + def _get_inheriting_views_arch_domain(self, view_id, model): + domain = super(View, self)._get_inheriting_views_arch_domain(view_id, model) + return ['|', ('website_id', '=', False), ('website_id', '=', self.env.context.get('website_id'))] + domain + + @api.model @tools.ormcache_context('self._uid', 'xml_id', keys=('website_id',)) def get_view_id(self, xml_id): if 'website_id' in self._context and not isinstance(xml_id, pycompat.integer_types): diff --git a/addons/website/models/website.py b/addons/website/models/website.py index bfd5e3e5fc5..3cbcab51647 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -249,7 +249,7 @@ class Website(models.Model): def get_url_localized(router, lang): arguments = dict(request.endpoint_arguments) - for key, val in list(pycompat.items(arguments)): + for key, val in list(arguments.items()): if isinstance(val, models.BaseModel): arguments[key] = val.with_context(lang=lang) return router.build(request.endpoint, arguments) @@ -261,7 +261,7 @@ class Website(models.Model): shorts.append(lg_codes[0]) uri = get_url_localized(router, code) if request.endpoint else request.httprequest.path if req.query_string: - uri += '?' + req.query_string + uri += u'?' + req.query_string.decode('utf-8') lang = { 'hreflang': ('-'.join(lg_codes)).lower(), 'short': lg_codes[0], @@ -327,7 +327,7 @@ class Website(models.Model): endpoint = rule.endpoint methods = endpoint.routing.get('methods') or ['GET'] - converters = list(pycompat.values(rule._converters)) + converters = list(rule._converters.values()) if not ('GET' in methods and endpoint.routing['type'] == 'http' and endpoint.routing['auth'] in ('none', 'public') @@ -372,7 +372,7 @@ class Website(models.Model): values = [{}] # converters with a domain are processed after the other ones convitems = sorted( - pycompat.items(converters), + converters.items(), key=lambda x: hasattr(x[1], 'domain') and (x[1].domain != '[]')) for (i, (name, converter)) in enumerate(convitems): newval = [] @@ -388,7 +388,7 @@ class Website(models.Model): for value in values: domain_part, url = rule.build(value, append_unknown=False) page = {'loc': url} - for key, val in pycompat.items(value): + for key, val in value.items(): if key.startswith('__'): page[key[2:]] = val if url in ('/sitemap.xml',): @@ -414,7 +414,7 @@ class Website(models.Model): def image_url(self, record, field, size=None): """ Returns a local url that points to the image field of a given browse record. """ sudo_record = record.sudo() - sha = hashlib.sha1(getattr(sudo_record, '__last_update')).hexdigest()[0:7] + sha = hashlib.sha1(getattr(sudo_record, '__last_update').encode('utf-8')).hexdigest()[0:7] size = '' if size is None else '/%s' % size return '/web/image/%s/%s/%s%s?unique=%s' % (record._name, record.id, field, size, sha) @@ -494,7 +494,7 @@ class Menu(models.Model): self.browse(to_delete).unlink() for menu in data['data']: mid = menu['id'] - if isinstance(mid, basestring): + if isinstance(mid, pycompat.string_types): new_menu = self.create({'name': menu['name']}) replace_id(mid, new_menu.id) for menu in data['data']: diff --git a/addons/website/tests/test_converter.py b/addons/website/tests/test_converter.py index e5047971fa9..ce74f1009d4 100644 --- a/addons/website/tests/test_converter.py +++ b/addons/website/tests/test_converter.py @@ -4,8 +4,6 @@ import unittest from odoo.addons.http_routing.models.ir_http import slugify, unslug -from odoo.tools import pycompat - class TestUnslug(unittest.TestCase): @@ -25,7 +23,7 @@ class TestUnslug(unittest.TestCase): 'foo1': (None, None), } - for slug, expected in pycompat.items(tests): + for slug, expected in tests.items(): self.assertEqual(unslug(slug), expected) diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py index 6eb5040f539..7293eae4c56 100644 --- a/addons/website/tests/test_views.py +++ b/addons/website/tests/test_views.py @@ -11,11 +11,10 @@ from lxml import etree as ET, html from lxml.html import builder as h from odoo.tests import common -from odoo.tools import pycompat def attrs(**kwargs): - return {'data-oe-%s' % key: str(value) for key, value in pycompat.items(kwargs)} + return {'data-oe-%s' % key: str(value) for key, value in kwargs.items()} class TestViewSaving(common.TransactionCase): @@ -48,7 +47,7 @@ class TestViewSaving(common.TransactionCase): self.view_id = self.env['ir.ui.view'].create({ 'name': "Test View", 'type': 'qweb', - 'arch': ET.tostring(self.arch, encoding='utf-8').decode('utf-8') + 'arch': ET.tostring(self.arch, encoding='unicode') }) def test_embedded_extraction(self): @@ -160,7 +159,7 @@ class TestViewSaving(common.TransactionCase): h.LI(h.SPAN("Acme Corporation", attrs(model='res.company', id=1, field='name', expression="bob", type='char'))), h.LI(h.SPAN("+12 3456789", attrs(model='res.company', id=1, field='phone', expression="edmund", type='char'))), ) - ), encoding='utf-8') + ), encoding='unicode') self.view_id.save(value=replacement, xpath='/div/div[2]') # the xml_id of the view should be flagged as 'noupdate' @@ -170,7 +169,7 @@ class TestViewSaving(common.TransactionCase): self.assertEqual(company.name, "Acme Corporation") self.assertEqual(company.phone, "+12 3456789") self.eq( - ET.fromstring(self.view_id.arch.encode('utf-8')), + ET.fromstring(self.view_id.arch), h.DIV( h.DIV( h.H3("Column 1"), @@ -191,29 +190,29 @@ class TestViewSaving(common.TransactionCase): def test_save_escaped_text(self): """ Test saving html special chars in text nodes """ view = self.env['ir.ui.view'].create({ - 'arch': '<t t-name="dummy"><p><h1>hello world</h1></p></t>', + 'arch': u'<t t-name="dummy"><p><h1>hello world</h1></p></t>', 'type': 'qweb' }) # script and style text nodes should not escaped client side - replacement = '<script>1 && "hello & world"</script>' + replacement = u'<script>1 && "hello & world"</script>' view.save(replacement, xpath='/t/p/h1') self.assertIn( - replacement.replace('&', '&'), + replacement.replace(u'&', u'&'), view.arch, 'inline script should be escaped server side' ) self.assertIn( replacement, - view.render(), + view.render().decode('utf-8'), 'inline script should not be escaped when rendering' ) # common text nodes should be be escaped client side - replacement = 'world &amp; &lt;b&gt;cie' + replacement = u'world &amp; &lt;b&gt;cie' view.save(replacement, xpath='/t/p') self.assertIn(replacement, view.arch, 'common text node should not be escaped server side') self.assertIn( replacement, - view.render().replace('&', '&'), + view.render().decode('utf-8').replace(u'&', u'&'), 'text node characters wrongly unescaped when rendering' ) @@ -225,7 +224,8 @@ class TestViewSaving(common.TransactionCase): node = html.tostring(h.SPAN( "Acme Corporation", - attrs(model='res.company', id=company_id, field="name", expression='bob', type='char'))) + attrs(model='res.company', id=company_id, field="name", expression='bob', type='char')), + encoding='unicode') View = self.env['ir.ui.view'] View.browse(company_id).save(value=node) self.assertEqual(company.name, "Acme Corporation") diff --git a/addons/website_blog/controllers/main.py b/addons/website_blog/controllers/main.py index 49ad2901ccc..a7ac33d7997 100644 --- a/addons/website_blog/controllers/main.py +++ b/addons/website_blog/controllers/main.py @@ -288,8 +288,8 @@ class WebsiteBlog(http.Controller): "id": message.id, "author_name": message.author_id.name, "author_image": message.author_id.image and \ - ("data:image/png;base64,%s" % message.author_id.image) or \ - '/website_blog/static/src/img/anonymous.png', + (b"data:image/png;base64,%s" % message.author_id.image) or \ + b'/website_blog/static/src/img/anonymous.png', "date": message.date, 'body': html2plaintext(message.body), 'website_published' : message.website_published, diff --git a/addons/website_blog/models/website_blog.py b/addons/website_blog/models/website_blog.py index f9df60422b2..cb50a8682b3 100644 --- a/addons/website_blog/models/website_blog.py +++ b/addons/website_blog/models/website_blog.py @@ -136,7 +136,7 @@ class BlogPost(models.Model): default='{"background-image": "none", "background-color": "oe_black", "opacity": "0.2", "resize_class": ""}') blog_id = fields.Many2one('blog.blog', 'Blog', required=True, ondelete='cascade') tag_ids = fields.Many2many('blog.tag', string='Tags') - content = fields.Html('Content', default=_default_content, translate=html_translate, sanitize_attributes=False) + content = fields.Html('Content', default=_default_content, translate=html_translate, sanitize=False) teaser = fields.Text('Teaser', compute='_compute_teaser', inverse='_set_teaser') teaser_manual = fields.Text(string='Teaser Content') diff --git a/addons/website_crm_partner_assign/controllers/main.py b/addons/website_crm_partner_assign/controllers/main.py index bb973843018..63d4926d185 100644 --- a/addons/website_crm_partner_assign/controllers/main.py +++ b/addons/website_crm_partner_assign/controllers/main.py @@ -14,7 +14,6 @@ from odoo.addons.http_routing.models.ir_http import slug, unslug from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.addons.website_partner.controllers.main import WebsitePartnerPage -from odoo.tools import pycompat from odoo.tools.translate import _ @@ -150,7 +149,7 @@ class WebsiteAccount(CustomerPortal): 'pager': pager, 'searchbar_sortings': searchbar_sortings, 'sortby': sortby, - 'searchbar_filters': OrderedDict(sorted(pycompat.items(searchbar_filters))), + 'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())), 'filterby': filterby, }) return request.render("website_crm_partner_assign.portal_my_opportunities", values) diff --git a/addons/website_crm_partner_assign/data/crm_partner_assign_data.xml b/addons/website_crm_partner_assign/data/crm_partner_assign_data.xml index a6541946090..6944f68287c 100644 --- a/addons/website_crm_partner_assign/data/crm_partner_assign_data.xml +++ b/addons/website_crm_partner_assign/data/crm_partner_assign_data.xml @@ -43,7 +43,7 @@ <p>Thanks,</p> -${ctx['partner_id'].user_id and ctx['partner_id'].user_id.signature or '' | safe} +${ctx['partner_id'].user_id and ctx['partner_id'].user_id.signature | safe or ''} % if not ctx['partner_id'].user_id: PS: It looks like you do not have an account manager assigned to you, please contact us. diff --git a/addons/website_crm_partner_assign/models/crm_lead.py b/addons/website_crm_partner_assign/models/crm_lead.py index f1f002d5dd0..b73be955c42 100644 --- a/addons/website_crm_partner_assign/models/crm_lead.py +++ b/addons/website_crm_partner_assign/models/crm_lead.py @@ -5,7 +5,6 @@ import random from odoo.addons.base_geolocalize.models.res_partner import geo_find, geo_query_address from odoo import api, fields, models, _ -from odoo.tools import pycompat class CrmLead(models.Model): @@ -45,7 +44,7 @@ class CrmLead(models.Model): if lead.partner_assigned_id and lead.partner_assigned_id.user_id != lead.user_id: salesmans_leads.setdefault(lead.partner_assigned_id.user_id.id, []).append(lead.id) - for salesman_id, leads_ids in pycompat.items(salesmans_leads): + for salesman_id, leads_ids in salesmans_leads.items(): leads = self.browse(leads_ids) leads.write({'user_id': salesman_id}) diff --git a/addons/website_crm_partner_assign/wizard/crm_forward_to_partner.py b/addons/website_crm_partner_assign/wizard/crm_forward_to_partner.py index cb2fbf3ba03..5aa8073192f 100644 --- a/addons/website_crm_partner_assign/wizard/crm_forward_to_partner.py +++ b/addons/website_crm_partner_assign/wizard/crm_forward_to_partner.py @@ -3,7 +3,6 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.tools import pycompat class CrmLeadForwardToPartner(models.TransientModel): @@ -86,7 +85,7 @@ class CrmLeadForwardToPartner(models.TransientModel): else: partners_leads[partner.id] = {'partner': partner, 'leads': [lead_details]} - for partner_id, partner_leads in pycompat.items(partners_leads): + for partner_id, partner_leads in partners_leads.items(): in_portal = False if portal_group: for contact in (partner.child_ids or partner).filtered(lambda contact: contact.user_ids): diff --git a/addons/website_crm_partner_assign/wizard/crm_forward_to_partner_view.xml b/addons/website_crm_partner_assign/wizard/crm_forward_to_partner_view.xml index 2c95ea01549..7adc57ec617 100644 --- a/addons/website_crm_partner_assign/wizard/crm_forward_to_partner_view.xml +++ b/addons/website_crm_partner_assign/wizard/crm_forward_to_partner_view.xml @@ -17,7 +17,7 @@ </group> <field name="assignation_lines" attrs="{'invisible': [('forward_type', 'in', ['single',False])]}"> <tree create="false" editable="bottom"> - <field name="lead_id" readonly="1"/> + <field name="lead_id" readonly="1" force_save="1" /> <field name="lead_location" readonly="1"/> <field name="partner_assigned_id"/> <field name="partner_location" readonly="1"/> diff --git a/addons/website_event/controllers/main.py b/addons/website_event/controllers/main.py index 694a71959d2..ff1f9c00ce1 100644 --- a/addons/website_event/controllers/main.py +++ b/addons/website_event/controllers/main.py @@ -9,7 +9,6 @@ from dateutil.relativedelta import relativedelta from odoo import fields, http, _ from odoo.addons.http_routing.models.ir_http import slug from odoo.http import request -from odoo.tools import pycompat class WebsiteEventController(http.Controller): @@ -80,7 +79,7 @@ class WebsiteEventController(http.Controller): def dom_without(without): domain = [('state', "in", ['draft', 'confirm', 'done'])] - for key, search in pycompat.items(domain_search): + for key, search in domain_search.items(): if key != without: domain += search return domain @@ -234,16 +233,16 @@ class WebsiteEventController(http.Controller): ''' Process data posted from the attendee details form. ''' registrations = {} global_values = {} - for key, value in pycompat.items(details): + for key, value in details.items(): counter, field_name = key.split('-', 1) if counter == '0': global_values[field_name] = value else: registrations.setdefault(counter, dict())[field_name] = value - for key, value in pycompat.items(global_values): - for registration in pycompat.values(registrations): + for key, value in global_values.items(): + for registration in registrations.values(): registration[key] = value - return list(pycompat.values(registrations)) + return list(registrations.values()) @http.route(['/event/<model("event.event"):event>/registration/confirm'], type='http', auth="public", methods=['POST'], website=True) def registration_confirm(self, event, **post): diff --git a/addons/website_event_questions/controllers/main.py b/addons/website_event_questions/controllers/main.py index e709e0b3d06..684999a26ff 100644 --- a/addons/website_event_questions/controllers/main.py +++ b/addons/website_event_questions/controllers/main.py @@ -3,8 +3,6 @@ from odoo.addons.website_event.controllers.main import WebsiteEventController -from odoo.tools import pycompat - class WebsiteEvent(WebsiteEventController): @@ -13,7 +11,7 @@ class WebsiteEvent(WebsiteEventController): registrations = super(WebsiteEvent, self)._process_registration_details(details) for registration in registrations: answer_ids = [] - for key, value in pycompat.items(registration): + for key, value in registration.items(): if key.startswith('answer_ids-'): answer_ids.append([4, int(value)]) registration['answer_ids'] = answer_ids diff --git a/addons/website_event_sale/controllers/main.py b/addons/website_event_sale/controllers/main.py index 324fa1026ed..7ccb7f9e4b5 100644 --- a/addons/website_event_sale/controllers/main.py +++ b/addons/website_event_sale/controllers/main.py @@ -4,7 +4,6 @@ from odoo import http, _ from odoo.addons.website_event.controllers.main import WebsiteEventController from odoo.http import request -from odoo.tools import pycompat class WebsiteEventSaleController(WebsiteEventController): @@ -16,7 +15,7 @@ class WebsiteEventSaleController(WebsiteEventController): def _process_tickets_details(self, data): ticket_post = {} - for key, value in pycompat.items(data): + for key, value in data.items(): if not key.startswith('nb_register') or '-' not in key: continue items = key.split('-') diff --git a/addons/website_event_track/controllers/main.py b/addons/website_event_track/controllers/main.py index 1864b28379c..f11aa0bea13 100644 --- a/addons/website_event_track/controllers/main.py +++ b/addons/website_event_track/controllers/main.py @@ -8,7 +8,7 @@ import pytz from odoo import fields, http from odoo.http import request -from odoo.tools import html_escape as escape, html2plaintext, pycompat +from odoo.tools import html_escape as escape, html2plaintext class WebsiteEventTrackController(http.Controller): @@ -75,7 +75,7 @@ class WebsiteEventTrackController(http.Controller): days = {} tracks_by_days = {} - for day, tracks in pycompat.items(days_tracks): + for day, tracks in days_tracks.items(): tracks_by_days[day] = tracks days[day] = self._prepare_calendar(event, tracks) diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index cdf1034c7a9..978692e894a 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -10,7 +10,7 @@ from psycopg2 import IntegrityError from odoo import http from odoo.http import request -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT, pycompat +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.translate import _ from odoo.exceptions import ValidationError from odoo.addons.base.ir.ir_qweb.fields import nl2br @@ -118,7 +118,7 @@ class WebsiteForm(http.Controller): error_fields = [] - for field_name, field_value in pycompat.items(values): + for field_name, field_value in values.items(): # If the value of the field if a file if hasattr(field_value, 'filename'): # Undo file upload field name indexing @@ -164,7 +164,7 @@ class WebsiteForm(http.Controller): if hasattr(dest_model, "website_form_input_filter"): data['record'] = dest_model.website_form_input_filter(request, data['record']) - missing_required_fields = [label for label, field in pycompat.items(authorized_fields) if field['required'] and not label in data['record']] + missing_required_fields = [label for label, field in authorized_fields.items() if field['required'] and not label in data['record']] if any(error_fields): raise ValidationError(error_fields + missing_required_fields) diff --git a/addons/website_form/models/models.py b/addons/website_form/models/models.py index 654fbfa9ee4..67364d59dc2 100644 --- a/addons/website_form/models/models.py +++ b/addons/website_form/models/models.py @@ -5,7 +5,6 @@ import itertools from odoo import models, fields, api from odoo.http import request -from odoo.tools import pycompat class website_form_config(models.Model): @@ -42,7 +41,7 @@ class website_form_model(models.Model): ]) } return { - k: v for k, v in pycompat.items(self.get_authorized_fields(self.model)) + k: v for k, v in self.get_authorized_fields(self.model).items() if k in included } @@ -52,7 +51,7 @@ class website_form_model(models.Model): model = self.env[model_name] fields_get = model.fields_get() - for key, val in pycompat.items(model._inherits): + for key, val in model._inherits.items(): fields_get.pop(val, None) # Unrequire fields with default values diff --git a/addons/website_forum/controllers/main.py b/addons/website_forum/controllers/main.py index 1344f641f3f..911e3eb6d93 100644 --- a/addons/website_forum/controllers/main.py +++ b/addons/website_forum/controllers/main.py @@ -577,7 +577,7 @@ class WebsiteForum(http.Controller): img_path = modules.get_module_resource('web', 'static/src/img', 'placeholder.png') with open(img_path, 'rb') as f: image = f.read() - content = image.encode('base64') + content = base64.b64encode(image) if status == 304: return werkzeug.wrappers.Response(status=304) image_base64 = base64.b64decode(content) @@ -711,7 +711,7 @@ class WebsiteForum(http.Controller): values['image'] = False elif kwargs.get('ufile'): image = kwargs.get('ufile').read() - values['image'] = image.encode('base64') + values['image'] = base64.b64encode(image) if request.uid == user.id: # the controller allows to edit only its own privacy settings; use partner management for other cases values['website_published'] = kwargs.get('website_published') == 'True' diff --git a/addons/website_forum/models/res_users.py b/addons/website_forum/models/res_users.py index 9d22ee3c9d8..054e3a4d56a 100644 --- a/addons/website_forum/models/res_users.py +++ b/addons/website_forum/models/res_users.py @@ -66,11 +66,12 @@ class Users(models.Model): and is a hash based on a (secret) uuid generated by the forum module, the user_id, the email and currently the day (to be updated if necessary). """ forum_uuid = self.env['ir.config_parameter'].sudo().get_param('website_forum.uuid') - return hashlib.sha256('%s-%s-%s-%s' % ( + return hashlib.sha256((u'%s-%s-%s-%s' % ( datetime.now().replace(hour=0, minute=0, second=0, microsecond=0), forum_uuid, user_id, - email)).hexdigest() + email + )).encode('utf-8')).hexdigest() @api.one def send_forum_validation_email(self, forum_id=None): diff --git a/addons/website_hr_recruitment/controllers/main.py b/addons/website_hr_recruitment/controllers/main.py index e1faf43fdc7..4556976d9cf 100644 --- a/addons/website_hr_recruitment/controllers/main.py +++ b/addons/website_hr_recruitment/controllers/main.py @@ -49,9 +49,9 @@ class WebsiteHrRecruitment(http.Controller): countries = set(o.country_id for o in offices if o.country_id) if department: - jobs = (j for j in jobs if j.department_id and j.department_id.id == department.id) + jobs = [j for j in jobs if j.department_id and j.department_id.id == department.id] if office_id and office_id in [x.id for x in offices]: - jobs = (j for j in jobs if j.address_id and j.address_id.id == office_id) + jobs = [j for j in jobs if j.address_id and j.address_id.id == office_id] else: office_id = False diff --git a/addons/website_hr_recruitment/security/website_hr_recruitment_security.xml b/addons/website_hr_recruitment/security/website_hr_recruitment_security.xml index 61fddcd5b3c..43ecfc36521 100644 --- a/addons/website_hr_recruitment/security/website_hr_recruitment_security.xml +++ b/addons/website_hr_recruitment/security/website_hr_recruitment_security.xml @@ -34,7 +34,7 @@ <record id="hr_department_public" model="ir.rule"> <field name="name">Job department: Public</field> <field name="model_id" ref="hr.model_hr_department"/> - <field name="domain_force">[('jobs_ids.website_published', '=', True)]</field> + <field name="domain_force">['|', ('jobs_ids.website_published', '=', True), ('child_ids', 'not in', [])]</field> <field name="groups" eval="[(4, ref('base.group_public'))]"/> <field name="perm_read" eval="True"/> <field name="perm_write" eval="False"/> diff --git a/addons/website_livechat/controllers/main.py b/addons/website_livechat/controllers/main.py index 675b56e561f..753e4eef1ae 100644 --- a/addons/website_livechat/controllers/main.py +++ b/addons/website_livechat/controllers/main.py @@ -3,7 +3,6 @@ from odoo import http from odoo.http import request -from odoo.tools import pycompat class WebsiteLivechat(http.Controller): @@ -27,7 +26,7 @@ class WebsiteLivechat(http.Controller): # compute percentage percentage = dict.fromkeys(['great', 'okay', 'bad'], 0) for grade in repartition: - percentage[grade] = repartition[grade] * 100 / sum(pycompat.values(repartition)) if sum(pycompat.values(repartition)) else 0 + percentage[grade] = repartition[grade] * 100 / sum(repartition.values()) if sum(repartition.values()) else 0 # the value dict to render the template values = { diff --git a/addons/website_mail/controllers/main.py b/addons/website_mail/controllers/main.py index ac73c0653c1..62533f08a91 100644 --- a/addons/website_mail/controllers/main.py +++ b/addons/website_mail/controllers/main.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - from odoo import http from odoo.http import request diff --git a/addons/website_mail_channel/models/mail_channel.py b/addons/website_mail_channel/models/mail_channel.py index c8ef8f3414c..f0b0f3665e2 100644 --- a/addons/website_mail_channel/models/mail_channel.py +++ b/addons/website_mail_channel/models/mail_channel.py @@ -68,4 +68,4 @@ class MailGroup(models.Model): str(self.id), str(partner_id), action]) - return hmac.new(secret.encode('utf-8'), data).hexdigest() + return hmac.new(secret.encode('utf-8'), data.encode('utf-8')).hexdigest() diff --git a/addons/website_quote/controllers/main.py b/addons/website_quote/controllers/main.py index 89d93a1854e..c0b0648bfd8 100644 --- a/addons/website_quote/controllers/main.py +++ b/addons/website_quote/controllers/main.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 import werkzeug @@ -91,7 +92,7 @@ class sale_quote(http.Controller): return request.render('website.404') if Order.state != 'sent': return False - attachments = [('signature.png', sign.decode('base64'))] if sign else [] + attachments = [('signature.png', base64.b64decode(sign))] if sign else [] Order.action_confirm() message = _('Order signed by %s') % (signer,) _message_post_helper(message=message, res_id=order_id, res_model='sale.order', attachments=attachments, **({'token': token, 'token_field': 'access_token'} if token else {})) diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index c5b62d8cf28..98e271dfde1 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -12,8 +12,6 @@ from odoo.addons.website.controllers.main import QueryURL from odoo.exceptions import ValidationError from odoo.addons.website_form.controllers.main import WebsiteForm -from odoo.tools import pycompat - _logger = logging.getLogger(__name__) PPG = 20 # Products Per Page @@ -78,10 +76,10 @@ class TableCompute(object): index += 1 # Format table according to HTML needs - rows = sorted(pycompat.items(self.table)) + rows = sorted(self.table.items()) rows = [r[1] for r in rows] for col in range(len(rows)): - cols = sorted(pycompat.items(rows[col])) + cols = sorted(rows[col].items()) x += len(cols) rows[col] = [r[1] for r in cols if r[1]] @@ -371,7 +369,7 @@ class WebsiteSale(http.Controller): return request.redirect("/shop/cart") def _filter_attributes(self, **kw): - return {k: v for k, v in pycompat.items(kw) if "attribute" in k} + return {k: v for k, v in kw.items() if "attribute" in k} @http.route(['/shop/cart/update_json'], type='json', auth="public", methods=['POST'], website=True, csrf=False) def cart_update_json(self, product_id, line_id=None, add_qty=None, set_qty=None, display=True): @@ -489,7 +487,7 @@ class WebsiteSale(http.Controller): except ValidationError: error["vat"] = 'error' - if [err for err in pycompat.items(error) if err == 'missing']: + if [err for err in error.items() if err == 'missing']: error_message.append(_('Some required fields are empty.')) return error, error_message @@ -497,7 +495,7 @@ class WebsiteSale(http.Controller): def _checkout_form_save(self, mode, checkout, all_values): Partner = request.env['res.partner'] if mode[0] == 'new': - partner_id = Partner.sudo().create(checkout) + partner_id = Partner.sudo().create(checkout).id elif mode[0] == 'edit': partner_id = int(all_values.get('partner_id', 0)) if partner_id: @@ -515,7 +513,7 @@ class WebsiteSale(http.Controller): def values_postprocess(self, order, mode, values, errors, error_msg): new_values = {} authorized_fields = request.env['ir.model']._get('res.partner')._get_form_writable_fields() - for k, v in pycompat.items(values): + for k, v in values.items(): # don't drop empty value, it could be a field to reset if k in authorized_fields and v is not None: new_values[k] = v @@ -678,7 +676,7 @@ class WebsiteSale(http.Controller): # if form posted if 'post_values' in post: values = {} - for field_name, field_value in pycompat.items(post): + for field_name, field_value in post.items(): if field_name in request.env['sale.order']._fields and field_name.startswith('x_'): values[field_name] = field_value if values: diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index cffff7f2f3d..724d24b5c03 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -6,7 +6,6 @@ import random from odoo import api, models, fields, _ from odoo.http import request from odoo.exceptions import UserError, ValidationError -from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -93,7 +92,7 @@ class SaleOrder(models.Model): # add untracked attributes in the name untracked_attributes = [] - for k, v in pycompat.items(attributes): + for k, v in attributes.items(): # attribute should be like 'attribute-48-1' where 48 is the product_id, 1 is the attribute_id and v is the attribute value attribute_value = self.env['product.attribute.value'].sudo().browse(int(v)) if attribute_value and not attribute_value.attribute_id.create_variant: diff --git a/addons/website_sale/models/website_config_settings.py b/addons/website_sale/models/website_config_settings.py index 3cb2ead0846..8dc53454412 100644 --- a/addons/website_sale/models/website_config_settings.py +++ b/addons/website_sale/models/website_config_settings.py @@ -90,7 +90,7 @@ class WebsiteConfigSettings(models.TransientModel): sale_show_tax = fields.Selection([ ('total', 'Tax-Included Prices'), ('subtotal', 'Tax-Excluded Prices')], - "Product Prices", default='total') + "Product Prices", default='subtotal') @api.model def get_values(self): diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py index cf97fd815cc..21accb52814 100644 --- a/addons/website_sale/tests/test_website_sale_pricelist.py +++ b/addons/website_sale/tests/test_website_sale_pricelist.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from mock import patch from odoo.tests.common import TransactionCase -from odoo.tools import pycompat class TestWebsitePriceList(TransactionCase): @@ -61,7 +60,7 @@ class TestWebsitePriceList(TransactionCase): 'CA': ['Canada'], 'US': ['USD', 'EUR', 'Benelux', 'Canada'] } - for country, result in pycompat.items(country_list): + for country, result in country_list.items(): pls = self.get_pl(show, current_pl, country) self.assertEquals(len(set(pls.mapped('name')) & set(result)), len(pls), 'Test failed for %s (%s %s vs %s %s)' % (country, len(pls), pls.mapped('name'), len(result), result)) @@ -78,7 +77,7 @@ class TestWebsitePriceList(TransactionCase): 'CA': ['Canada'] } - for country, result in pycompat.items(country_list): + for country, result in country_list.items(): pls = self.get_pl(show, current_pl, country) self.assertEquals(len(set(pls.mapped('name')) & set(result)), len(pls), 'Test failed for %s (%s %s vs %s %s)' % (country, len(pls), pls.mapped('name'), len(result), result)) @@ -99,7 +98,7 @@ class TestWebsitePriceList(TransactionCase): 'CA': False } - for country, result in pycompat.items(country_list): + for country, result in country_list.items(): self.args['country'] = country # mock patch method could not pass env context available = self.website.is_pricelist_available(christmas_pl) @@ -120,7 +119,7 @@ class TestWebsitePriceList(TransactionCase): 'CA': ['EUR', 'Canada'], 'US': ['USD', 'EUR', 'Benelux', 'Canada'] } - for country, result in pycompat.items(country_list): + for country, result in country_list.items(): pls = self.get_pl(show, current_pl, country) self.assertEquals(len(set(pls.mapped('name')) & set(result)), len(pls), 'Test failed for %s (%s %s vs %s %s)' % (country, len(pls), pls.mapped('name'), len(result), result)) diff --git a/addons/website_sale_digital/controllers/main.py b/addons/website_sale_digital/controllers/main.py index 2bc81416f82..90f6de0c48a 100644 --- a/addons/website_sale_digital/controllers/main.py +++ b/addons/website_sale_digital/controllers/main.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 -from cStringIO import StringIO +import io from werkzeug.utils import redirect from odoo import http @@ -100,7 +100,7 @@ class WebsiteSaleDigital(CustomerPortal): else: return request.not_found() elif attachment["datas"]: - data = StringIO(base64.standard_b64decode(attachment["datas"])) - return http.send_file(data, filename=attachment['name'], as_attachment=True) + data = io.BytesIO(base64.standard_b64decode(attachment["datas"])) + return http.send_file(data, filename=attachment['name'].encode('utf-8'), as_attachment=True) else: return request.not_found() diff --git a/addons/website_sale_options/controllers/main.py b/addons/website_sale_options/controllers/main.py index 8349f0e08bc..af48c17f2e1 100644 --- a/addons/website_sale_options/controllers/main.py +++ b/addons/website_sale_options/controllers/main.py @@ -5,8 +5,6 @@ from odoo import http from odoo.http import request from odoo.addons.website_sale.controllers.main import WebsiteSale -from odoo.tools import pycompat - class WebsiteSaleOptions(WebsiteSale): @@ -26,7 +24,7 @@ class WebsiteSaleOptions(WebsiteSale): option_ids = product.optional_product_ids.mapped('product_variant_ids').ids optional_product_ids = [] - for k, v in pycompat.items(kw): + for k, v in kw.items(): if "optional-product-" in k and int(kw.get(k.replace("product", "add"))) and int(v) in option_ids: optional_product_ids.append(int(v)) diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index 0b7fdb47982..7bf77624fe6 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -164,7 +164,7 @@ class WebsiteSlides(http.Controller): @http.route('''/slides/slide/<model("slide.slide", "[('channel_id.can_see', '=', True), ('datas', '!=', False), ('slide_type', '=', 'presentation')]"):slide>/pdf_content''', type='http', auth="public", website=True) def slide_get_pdf_content(self, slide): response = werkzeug.wrappers.Response() - response.data = slide.datas and slide.datas.decode('base64') or '' + response.data = slide.datas and base64.b64decode(slide.datas) or b'' response.mimetype = 'application/pdf' return response diff --git a/addons/website_slides/models/slides.py b/addons/website_slides/models/slides.py index 01a0fda6dc2..92dbc89f51a 100644 --- a/addons/website_slides/models/slides.py +++ b/addons/website_slides/models/slides.py @@ -3,6 +3,7 @@ import requests from PIL import Image +import base64 import datetime import io import json @@ -12,7 +13,7 @@ from werkzeug import urls from odoo import api, fields, models, SUPERUSER_ID, _ from odoo.addons.http_routing.models.ir_http import slug -from odoo.tools import image, pycompat +from odoo.tools import image from odoo.tools.translate import html_translate from odoo.exceptions import Warning @@ -322,8 +323,8 @@ class Slide(models.Model): values = res['values'] if not values.get('document_id'): raise Warning(_('Please enter valid Youtube or Google Doc URL')) - for key, value in pycompat.items(values): - setattr(self, key, value) + for key, value in values.items(): + self[key] = value # website date_published = fields.Datetime('Publish Date') @@ -384,7 +385,7 @@ class Slide(models.Model): values['date_published'] = datetime.datetime.now() if values.get('url') and not values.get('document_id'): doc_data = self._parse_document_url(values['url']).get('values', dict()) - for key, value in pycompat.items(doc_data): + for key, value in doc_data.items(): values.setdefault(key, value) # Do not publish slide if user has not publisher rights if not self.user_has_groups('website.group_website_publisher'): @@ -398,7 +399,7 @@ class Slide(models.Model): def write(self, values): if values.get('url') and values['url'] != self.url: doc_data = self._parse_document_url(values['url']).get('values', dict()) - for key, value in pycompat.items(doc_data): + for key, value in doc_data.items(): values.setdefault(key, value) if values.get('channel_id'): custom_channels = self.env['slide.channel'].search([('custom_slide_id', '=', self.id), ('id', '!=', values.get('channel_id'))]) @@ -498,13 +499,12 @@ class Slide(models.Model): try: response = requests.get(base_url, params=data) response.raise_for_status() - content = response.content if content_type == 'json': - result['values'] = json.loads(content) + result['values'] = response.json() elif content_type in ('image', 'pdf'): - result['values'] = content.encode('base64') + result['values'] = base64.b64encode(response.content) else: - result['values'] = content + result['values'] = response.content except requests.exceptions.HTTPError as e: result['error'] = e.response.content except requests.exceptions.ConnectionError as e: @@ -565,7 +565,7 @@ class Slide(models.Model): # TDE FIXME: WTF ?? slide_type = 'presentation' if vals.get('image'): - image = Image.open(io.BytesIO(vals['image'].decode('base64'))) + image = Image.open(io.BytesIO(base64.b64decode(vals['image']))) width, height = image.size if height > width: return 'document' diff --git a/doc/_extensions/github_link.py b/doc/_extensions/github_link.py index d3e5d61c28e..155e4083025 100644 --- a/doc/_extensions/github_link.py +++ b/doc/_extensions/github_link.py @@ -4,6 +4,8 @@ import os.path from werkzeug import urls +from odoo.tools import pycompat + """ * adds github_link(mode) context variable: provides URL (in relevant mode) of current document on github @@ -99,7 +101,7 @@ def add_doc_link(app, pagename, templatename, context, doctree): # in Sphinx 1.3 it's possible to have mutliple source suffixes and that # may be useful in the future source_suffix = app.config.source_suffix - source_suffix = source_suffix if isinstance(source_suffix, basestring) else source_suffix[0] + source_suffix = source_suffix if isinstance(source_suffix, pycompat.string_types) else source_suffix[0] # can't use functools.partial because 3rd positional is line not mode context['github_link'] = lambda mode='edit': make_github_link( app, 'doc/%s%s' % (pagename, source_suffix), mode=mode) diff --git a/doc/_extensions/odoo_ext/translator.py b/doc/_extensions/odoo_ext/translator.py index 3a1928364eb..443458f33c7 100644 --- a/doc/_extensions/odoo_ext/translator.py +++ b/doc/_extensions/odoo_ext/translator.py @@ -11,6 +11,8 @@ from docutils import nodes from sphinx import addnodes, util from sphinx.locale import admonitionlabels +from odoo.tools import pycompat + def _parents(node): while node.parent: @@ -62,7 +64,7 @@ class BootstrapTranslator(nodes.NodeVisitor, object): self.param_separator = ',' def encode(self, text): - return unicode(text).translate({ + return pycompat.text_type(text).translate({ ord('&'): u'&', ord('<'): u'<', ord('"'): u'"', @@ -71,7 +73,7 @@ class BootstrapTranslator(nodes.NodeVisitor, object): }) def starttag(self, node, tagname, **attributes): - tagname = unicode(tagname).lower() + tagname = pycompat.text_type(tagname).lower() # extract generic attributes attrs = {name.lower(): value for name, value in attributes.items()} @@ -105,8 +107,8 @@ class BootstrapTranslator(nodes.NodeVisitor, object): ) # only "space characters" SPACE, CHARACTER TABULATION, LINE FEED, # FORM FEED and CARRIAGE RETURN should be collapsed, not al White_Space - def attval(self, value, whitespace=re.compile(u'[ \t\n\f\r]')): - return self.encode(whitespace.sub(u' ', unicode(value))) + def attval(self, value, whitespace=re.compile(u'[ \t\n\f\r]+')): + return self.encode(whitespace.sub(u' ', pycompat.text_type(value))) def astext(self): return u''.join(self.body) diff --git a/doc/cla/corporate/vauxoo.md b/doc/cla/corporate/vauxoo.md index 036e1a63620..096c835ef55 100644 --- a/doc/cla/corporate/vauxoo.md +++ b/doc/cla/corporate/vauxoo.md @@ -36,4 +36,5 @@ Luis Gonzรกlez lgonzalez@vauxoo.com https://github.com/luisg123v Oriana Maita oriana@vauxoo.com https://github.com/maitaoriana Gabriela Mogollon gmogollon@vauxoo.com https://github.com/GavyMG Jesus Zapata jesus@vauxoo.com https://github.com/JesusZapata -Germana Oliveira germana@vauxoo.com https://github.com/goliveirab
\ No newline at end of file +Germana Oliveira germana@vauxoo.com https://github.com/goliveirab +Mariano Fernandez mariano@vauxoo.com https://github.com/Batuto diff --git a/doc/cla/individual/NothingCtrl.md b/doc/cla/individual/NothingCtrl.md new file mode 100644 index 00000000000..bf58b0034d3 --- /dev/null +++ b/doc/cla/individual/NothingCtrl.md @@ -0,0 +1,9 @@ +Vietnam, 2017-08-15 + +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, + +Duong Bao Thang nothingctrl@gmail.com https://github.com/NothingCtrl diff --git a/doc/cla/individual/borni-dhifi.md b/doc/cla/individual/borni-dhifi.md new file mode 100644 index 00000000000..429f208f918 --- /dev/null +++ b/doc/cla/individual/borni-dhifi.md @@ -0,0 +1,9 @@ +Tunisia, 2017-08-22 + +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, + +Borni Dhifi dhifi.borni@gmail.com https://github.com/borni-dhifi diff --git a/doc/cla/individual/repodevs.md b/doc/cla/individual/repodevs.md new file mode 100644 index 00000000000..06f8ec2f166 --- /dev/null +++ b/doc/cla/individual/repodevs.md @@ -0,0 +1,10 @@ +Indonesia, 2017-08-12 + +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, +Edi Santoso repodevs@gmail.com https://github.com/repodevs diff --git a/doc/python3.rst b/doc/python3.rst index 3773d77c4e7..c8e8269936e 100644 --- a/doc/python3.rst +++ b/doc/python3.rst @@ -61,6 +61,11 @@ features whereas: generator delegation, pathlib, ...), you must *not* use them in Odoo until Python 2 support is dropped +.. note:: + + In the *very rare* cases where you *need* to differentiate between + Python 2 and Python 3, use the :data:`odoo.tools.pycompat.PY2` flag. + Semantics changes ================= @@ -228,10 +233,7 @@ methods or functions having been *removed entirely*: .. important:: When possible, use comprehensions (list, generator, ...) rather than - ``map`` or ``filter``, otherwise use the cross-version ``pycompat`` - versions (``pycompat.imap``, ``pycompat.ifilter`` and - ``pycompat.izip``). The ``pycompat`` versions all return *iterators* and - may need to be wrapped in a ``list()`` call to yield a list. + ``map`` or ``filter``. * In Python 3, ``dict.keys``, ``dict.values`` and ``dict.items`` return *views* rather than lists, and the ``iter*`` and ``view*`` methods have @@ -239,11 +241,9 @@ methods or functions having been *removed entirely*: .. important:: - Prefer using :func:`odoo.tools.pycompat.keys`, - :func:`odoo.tools.pycompat.values` and :func:`odoo.tools.pycompat.items` - return cross-version iterators. When needing actual lists (e.g. to - modify a dictionary during iteration), wrap one of the calls above in a - ``list()``. + When the result of the above methods is used for more than a one-shot + loop (e.g. to be included in returned value), or when the dict needs + to be modified during iteration, wrap the calls in a ``list()``. builtins -------- @@ -369,8 +369,121 @@ Minor syntax changes error, octal literals now follow the hexadecimal convention with a ``0o`` prefix. +.. _changed-strings: + +Bytes/String/Text: The Big One +============================== + +The most impactful Python 3 change by far is to the text model: for historical +reasons the distinction Python 2's bytestrings (``bytes``/``str``) and text +strings (``unicode``) is fuzzy and it will try to implicitly convert between +one and the other using the ASCII encoding. + +Python 3 changes this, it removes the implicit conversions, removes APIs which +contribute to the fuzz and tends to strictly segregate other to work on either +bytes or text. + +This is fundamentally good and mostly sensible, but it means lots of breakage: + +the builtins +------------ + +Python 3 removes both ``unicode`` and ``basestring``, and ``str`` now +corresponds to *text* strings (the old ``unicode``) with ``bytes`` being +bytestrings in both languages [#bytes]_. + +Both versions have the following prefixes for string literals: + +* ``b'foo'`` is a bytestring (``bytes`` object). + +* ``'foo'`` is that version's ``str`` type, which may be either a bytestring + or a text string [#native-string]_. + +* ``u'foo'`` is that version's text string. + +For best cross-version compatibility you should avoid unprefixed string +literals unless you *specifically* need a "native string" [#native-string]_. + +For easier type-testing, :mod:`odoo.tools.pycompat` provides the following +constants: + +* :data:`~odoo.tools.pycompat.string_types` is an alias/type tuple for testing + string types, essentially a replacement of testing for ``basestring`` or + ``(str, unicode)``. +* :data:`~odoo.tools.pycompat.text_type` is the proper *text* type for the + current version, it should mostly be used for converting non-bytes objects + to text. +* ``bytes`` should be avoided for type conversions, though it can be used to + check if an object is a bytestring. + +``open`` +-------- + +.. important:: + + the ``open`` builtin should always be explicitly used in binary mode + (``rb``, ``wb``, ...) + + To read *text* files, use ``io.open``. + +On both P2 and P3, ``open`` defaults to returning *native strings* in default +("text") mode, however in P3 that means it actually decodes the file's bytes +using whatever encoding was set up (default: UTF-8) while on Python 2 it has +no concept of encoding. + +Using ``open`` in binary mode provides bytestrings on both versions and works +fine. To read *text* files, use ``io.open`` and provide an explicit encoding. + +base64 +------ + +base64 is a bytes->bytes conversion. bytes->bytes codecs were removed from the +"native" encoding/decoding system which is now exclusively for bytes<->text +conversions: text is *encoded* to bytes and bytes are *decoded* to text. + +.. important:: + + both ``bytes.encode('base64')`` and ``bytes.decode('base64')`` must be + migrated to using ``base64.b64encode`` and ``base64.b64decode`` + respectively. + +csv +--- + +``csv`` is a fairly vicious one: not only is it not a very good format, the +Python 2 and Python 3 versions of the library are text-model incompatible in +significant ways: + +* Python 2's CSV only works on *ascii-compatible byte streams* (it has no + encoding support at all) and extracts bytestring values +* Python 3's CSV only works on *text streams* and extract text values +* And ``io`` doesn't provide "native string" streaming facilities. + +However with respect to Odoo it turns out most or all uses of ``csv`` fit +inside a model of *byte stream to and from text values*. + +The latter is thus a model implemented by cross-version wrappers +:func:`odoo.tools.pycompat.csv_reader` and +:func:`odoo.tools.pycompat.csv_writer`: they take a *UTF-8 byte stream* and +read or write *text* values. + .. _hash randomisation: http://bugs.python.org/issue13703 .. _requests: http://docs.python-requests.org/ .. _werkzeug: http://werkzeug.pocoo.org/docs/urls/ + +.. [#bytes] + + with the caveat that Python 3 makes them less text-y and more byte-y e.g. + in Python 2 ``b"foo"[0]`` is ``b"f"``, but in Python 3 it's ``102`` (the + value of the first byte), you'll want to *slice* bytestrings for + compatibility. + +.. [#native-string] + + this is important because some API/contexts take a *native string* rather + than either bytes or text. The ``csv`` module of the standard library is + one such problematic API (it is also notoriously problematic for its + terrible support of non-ascii-compatible encodings in Python 2). + ``email.message_from_string`` is an other one. diff --git a/doc/reference/cmdline.rst b/doc/reference/cmdline.rst index c8b1954f1a9..37b0e9db646 100644 --- a/doc/reference/cmdline.rst +++ b/doc/reference/cmdline.rst @@ -159,7 +159,11 @@ database - ``%h`` is replaced by the whole hostname the request is made on. - ``%d`` is replaced by the subdomain the request is made on, with the exception of ``www`` (so domain ``odoo.com`` and ``www.odoo.com`` both - match the database ``odoo``) + match the database ``odoo``). + + These operations are case sensitive. Add option ``(?i)`` to match all + databases (so domain ``odoo.com`` using ``(?i)%d`` matches the database + ``Odoo``). .. option:: --db-template <template> diff --git a/doc/reference/guidelines.rst b/doc/reference/guidelines.rst index 058fe7ffb1c..fc15c10f7ab 100644 --- a/doc/reference/guidelines.rst +++ b/doc/reference/guidelines.rst @@ -452,12 +452,9 @@ So, you can write ``if some_collection:`` instead of ``if len(some_collection):` # better for key in my_dict: "do something..." - # creates a temporary list + # accessing the key,value pair for key, value in my_dict.items(): "do something..." - # only iterates - for key, value in my_dict.iteritems(): - "do something..." - Use dict.setdefault diff --git a/doc/reference/views.rst b/doc/reference/views.rst index 2a9b5ece4bc..d5e5b10c58c 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -846,17 +846,13 @@ attributes: Possible children of the view element are: ``field`` - declares fields to aggregate or to use in kanban *logic*. If the field is - simply displayed in the kanban view, it does not need to be pre-declared. + declares fields to use in kanban *logic*. If the field is simply displayed in + the kanban view, it does not need to be pre-declared. Possible attributes are: ``name`` (required) the name of the field to fetch - ``sum``, ``avg``, ``min``, ``max``, ``count`` - displays the corresponding aggregation at the top of a kanban column, the - field's value is the label of the aggregation (a string). Only one - aggregate operation per field is supported. ``templates`` defines a list of :ref:`reference/qweb` templates. Cards definition may be diff --git a/odoo/addons/base/ir/ir_actions_report.py b/odoo/addons/base/ir/ir_actions_report.py index 768d8dfa84d..d7adaf42363 100644 --- a/odoo/addons/base/ir/ir_actions_report.py +++ b/odoo/addons/base/ir/ir_actions_report.py @@ -10,6 +10,7 @@ from odoo.http import request import time import base64 +import io import logging import os import lxml.html @@ -53,9 +54,9 @@ except (OSError, IOError): else: _logger.info('Will use the Wkhtmltopdf binary at %s' % _get_wkhtmltopdf_bin()) out, err = process.communicate() - match = re.search('([0-9.]+)', out) + match = re.search(b'([0-9.]+)', out) if match: - version = match.group(0) + version = match.group(0).decode('ascii') if LooseVersion(version) < LooseVersion('0.12.0'): _logger.info('Upgrade Wkhtmltopdf to (at least) 0.12.0') wkhtmltopdf_state = 'upgrade' @@ -371,13 +372,13 @@ class IrActionsReport(models.Model): temporary_files = [] if header: head_file_fd, head_file_path = tempfile.mkstemp(suffix='.html', prefix='report.header.tmp.') - with closing(os.fdopen(head_file_fd, 'w')) as head_file: + with closing(os.fdopen(head_file_fd, 'wb')) as head_file: head_file.write(header) temporary_files.append(head_file_path) files_command_args.extend(['--header-html', head_file_path]) if footer: foot_file_fd, foot_file_path = tempfile.mkstemp(suffix='.html', prefix='report.footer.tmp.') - with closing(os.fdopen(foot_file_fd, 'w')) as foot_file: + with closing(os.fdopen(foot_file_fd, 'wb')) as foot_file: foot_file.write(footer) temporary_files.append(foot_file_path) files_command_args.extend(['--footer-html', foot_file_path]) @@ -386,7 +387,7 @@ class IrActionsReport(models.Model): for i, body in enumerate(bodies): prefix = '%s%d.' % ('report.body.tmp.', i) body_file_fd, body_file_path = tempfile.mkstemp(suffix='.html', prefix=prefix) - with closing(os.fdopen(body_file_fd, 'w')) as body_file: + with closing(os.fdopen(body_file_fd, 'wb')) as body_file: body_file.write(body) paths.append(body_file_path) temporary_files.append(body_file_path) diff --git a/odoo/addons/base/ir/ir_attachment.py b/odoo/addons/base/ir/ir_attachment.py index 9d19d076438..41b85913ed9 100644 --- a/odoo/addons/base/ir/ir_attachment.py +++ b/odoo/addons/base/ir/ir_attachment.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import hashlib import itertools import logging @@ -11,7 +11,7 @@ from collections import defaultdict from odoo import api, fields, models, tools, SUPERUSER_ID, _ from odoo.exceptions import AccessError -from odoo.tools import config, human_size, ustr, html_escape, pycompat +from odoo.tools import config, human_size, ustr, html_escape from odoo.tools.mimetypes import guess_mimetype _logger = logging.getLogger(__name__) @@ -97,14 +97,14 @@ class IrAttachment(models.Model): if bin_size: r = human_size(os.path.getsize(full_path)) else: - r = open(full_path,'rb').read().encode('base64') + r = base64.b64encode(open(full_path,'rb').read()) except (IOError, OSError): _logger.info("_read_file reading %s", full_path, exc_info=True) return r @api.model def _file_write(self, value, checksum): - bin_value = value.decode('base64') + bin_value = base64.b64decode(value) fname, full_path = self._get_path(bin_value, checksum) if not os.path.exists(full_path): try: @@ -167,7 +167,7 @@ class IrAttachment(models.Model): # remove garbage files, and clean up checklist removed = 0 - for fname, filepath in pycompat.items(checklist): + for fname, filepath in checklist.items(): if fname not in whitelist: try: os.unlink(self._full_path(fname)) @@ -195,7 +195,7 @@ class IrAttachment(models.Model): for attach in self: # compute the fields that depend on datas value = attach.datas - bin_data = value and value.decode('base64') or '' + bin_data = base64.b64decode(value) if value else b'' vals = { 'file_size': len(bin_data), 'checksum': self._compute_checksum(bin_data), @@ -220,7 +220,7 @@ class IrAttachment(models.Model): :param bin_data : datas in its binary form """ # an empty file has a checksum too (for caching) - return hashlib.sha1(bin_data or '').hexdigest() + return hashlib.sha1(bin_data or b'').hexdigest() def _compute_mimetype(self, values): """ compute the mimetype of the given values @@ -235,7 +235,7 @@ class IrAttachment(models.Model): if not mimetype and values.get('url'): mimetype = mimetypes.guess_type(values['url'])[0] if values.get('datas') and (not mimetype or mimetype == 'application/octet-stream'): - mimetype = guess_mimetype(values['datas'].decode('base64')) + mimetype = guess_mimetype(base64.b64decode(values['datas'])) return mimetype or 'application/octet-stream' def _check_contents(self, values): @@ -258,8 +258,8 @@ class IrAttachment(models.Model): if file_type: index_content = file_type.split('/')[0] if index_content == 'text': # compute index_content only for text type - words = re.findall("[^\x00-\x1F\x7F-\xFF]{4,}", bin_data) - index_content = ustr("\n".join(words)) + words = re.findall(b"[\x20-\x7E]{4,}", bin_data) + index_content = b"\n".join(words).decode('ascii') return index_content name = fields.Char('Attachment Name', required=True) @@ -318,7 +318,7 @@ class IrAttachment(models.Model): model_ids[values['res_model']].add(values['res_id']) # check access rights on the records - for res_model, res_ids in pycompat.items(model_ids): + for res_model, res_ids in model_ids.items(): # ignore attachments that are not attached to a resource anymore # when checking access rights (resource was deleted but attachment # was not) @@ -374,12 +374,12 @@ class IrAttachment(models.Model): # To avoid multiple queries for each attachment found, checks are # performed in batch as much as possible. - for res_model, targets in pycompat.items(model_attachments): + for res_model, targets in model_attachments.items(): if res_model not in self.env: continue if not self.env[res_model].check_access_rights('read', False): # remove all corresponding attachment ids - ids.difference_update(itertools.chain(*pycompat.values(targets))) + ids.difference_update(itertools.chain(*targets.values())) continue # filter ids according to what access rules permit target_ids = list(targets) diff --git a/odoo/addons/base/ir/ir_config_parameter.py b/odoo/addons/base/ir/ir_config_parameter.py index 8e4ea20b5a5..a286a8836ce 100644 --- a/odoo/addons/base/ir/ir_config_parameter.py +++ b/odoo/addons/base/ir/ir_config_parameter.py @@ -16,8 +16,8 @@ _logger = logging.getLogger(__name__) A dictionary holding some configuration parameters to be initialized when the database is created. """ _default_parameters = { - "database.secret": lambda: str(uuid.uuid4()), - "database.uuid": lambda: str(uuid.uuid1()), + "database.secret": lambda: pycompat.text_type(uuid.uuid4()), + "database.uuid": lambda: pycompat.text_type(uuid.uuid1()), "database.create_date": fields.Datetime.now, "web.base.url": lambda: "http://localhost:%s" % config.get('xmlrpc_port'), } @@ -42,7 +42,7 @@ class IrConfigParameter(models.Model): Initializes the parameters listed in _default_parameters. It overrides existing parameters if force is ``True``. """ - for key, func in pycompat.items(_default_parameters): + for key, func in _default_parameters.items(): # force=True skips search and always performs the 'if' body (because ids=False) params = self.sudo().search([('key', '=', key)]) if force or not params: diff --git a/odoo/addons/base/ir/ir_fields.py b/odoo/addons/base/ir/ir_fields.py index 204d4599425..d7521ed6ba1 100644 --- a/odoo/addons/base/ir/ir_fields.py +++ b/odoo/addons/base/ir/ir_fields.py @@ -12,9 +12,9 @@ from odoo.tools import ustr, pycompat REFERENCING_FIELDS = {None, 'id', '.id'} def only_ref_fields(record): - return {k: v for k, v in pycompat.items(record) if k in REFERENCING_FIELDS} + return {k: v for k, v in record.items() if k in REFERENCING_FIELDS} def exclude_ref_fields(record): - return {k: v for k, v in pycompat.items(record) if k not in REFERENCING_FIELDS} + return {k: v for k, v in record.items() if k not in REFERENCING_FIELDS} CREATE = lambda values: (0, False, values) UPDATE = lambda id, values: (1, id, values) @@ -38,12 +38,12 @@ class IrFieldsConverter(models.AbstractModel): @api.model def _format_import_error(self, error_type, error_msg, error_params=(), error_args=None): # sanitize error params for later formatting by the import system - sanitize = lambda p: p.replace('%', '%%') if isinstance(p, basestring) else p + sanitize = lambda p: p.replace('%', '%%') if isinstance(p, pycompat.string_types) else p if error_params: - if isinstance(error_params, basestring): + if isinstance(error_params, pycompat.string_types): error_params = sanitize(error_params) elif isinstance(error_params, dict): - error_params = {k: sanitize(v) for k, v in pycompat.items(error_params)} + error_params = {k: sanitize(v) for k, v in error_params.items()} elif isinstance(error_params, tuple): error_params = tuple(sanitize(v) for v in error_params) return error_type(error_msg % error_params, error_args) @@ -64,12 +64,12 @@ class IrFieldsConverter(models.AbstractModel): converters = { name: self.to_field(model, field, fromtype) - for name, field in pycompat.items(model._fields) + for name, field in model._fields.items() } def fn(record, log): converted = {} - for field, value in pycompat.items(record): + for field, value in record.items(): if field in REFERENCING_FIELDS: continue if not value: @@ -78,7 +78,7 @@ class IrFieldsConverter(models.AbstractModel): try: converted[field], ws = converters[field](value) for w in ws: - if isinstance(w, basestring): + if isinstance(w, pycompat.string_types): # wrap warning string in an ImportWarning for # uniform handling w = ImportWarning(w) @@ -269,14 +269,14 @@ class IrFieldsConverter(models.AbstractModel): for item, label in selection: label = ustr(label) labels = [label] + self._get_translations(('selection', 'model', 'code'), label) - if value == unicode(item) or value in labels: + if value == pycompat.text_type(item) or value in labels: return item, [] raise self._format_import_error( ValueError, _(u"Value '%s' not found in selection field '%%(field)s'"), value, - {'moreinfo': [_label or unicode(item) for item, _label in selection if _label or item]} + {'moreinfo': [_label or pycompat.text_type(item) for item, _label in selection if _label or item]} ) @api.model diff --git a/odoo/addons/base/ir/ir_http.py b/odoo/addons/base/ir/ir_http.py index 32738298293..3b19add6ea9 100644 --- a/odoo/addons/base/ir/ir_http.py +++ b/odoo/addons/base/ir/ir_http.py @@ -131,7 +131,7 @@ class IrHttp(models.AbstractModel): attach = env['ir.attachment'].search_read(domain, fields) if attach: wdate = attach[0]['__last_update'] - datas = attach[0]['datas'] or '' + datas = attach[0]['datas'] or b'' name = attach[0]['name'] checksum = attach[0]['checksum'] or hashlib.sha1(datas).hexdigest() @@ -154,7 +154,7 @@ class IrHttp(models.AbstractModel): return response response.mimetype = attach[0]['mimetype'] or 'application/octet-stream' - response.data = datas.decode('base64') + response.data = base64.b64decode(datas) return response @classmethod @@ -295,7 +295,7 @@ class IrHttp(models.AbstractModel): if module_resource_path.startswith(module_path): with open(module_resource_path, 'rb') as f: content = base64.b64encode(f.read()) - last_update = str(os.path.getmtime(module_resource_path)) + last_update = pycompat.text_type(os.path.getmtime(module_resource_path)) if not module_resource_path: module_resource_path = obj.url @@ -331,7 +331,7 @@ class IrHttp(models.AbstractModel): # cache etag = bool(request) and request.httprequest.headers.get('If-None-Match') - retag = '"%s"' % hashlib.md5(last_update).hexdigest() + retag = '"%s"' % hashlib.md5(last_update.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_mail_server.py b/odoo/addons/base/ir/ir_mail_server.py index 91cf0e61e73..0ecb895e082 100644 --- a/odoo/addons/base/ir/ir_mail_server.py +++ b/odoo/addons/base/ir/ir_mail_server.py @@ -39,20 +39,8 @@ class WriteToLogger(object): _logger.debug(s) smtplib.stderr = WriteToLogger() -def try_coerce_ascii(string_utf8): - """Attempts to decode the given utf8-encoded string - as ASCII after coercing it to UTF-8, then return - the confirmed 7-bit ASCII string. - - If the process fails (because the string - contains non-ASCII characters) returns ``None``. - """ - try: - string_utf8.decode('ascii') - except UnicodeDecodeError: - return - return string_utf8 - +def is_ascii(s): + return all(ord(cp) < 128 for cp in s) def encode_header(header_text): """Returns an appropriate representation of the given header value, @@ -70,16 +58,10 @@ def encode_header(header_text): """ if not header_text: return "" - # convert anything to utf-8, suitable for testing ASCIIness, as 7-bit chars are - # encoded as ASCII in utf-8 - header_text_utf8 = ustr(header_text).encode('utf-8') - header_text_ascii = try_coerce_ascii(header_text_utf8) - # if this header contains non-ASCII characters, - # we'll need to wrap it up in a message.header.Header - # that will take care of RFC2047-encoding it as - # 7-bit string. - return header_text_ascii or Header(header_text_utf8, 'utf-8') - + header_text = ustr(header_text) # FIXME: require unicode higher up? + if is_ascii(header_text): + return pycompat.to_native(header_text) + Header(header_text, 'utf-8') def encode_header_param(param_text): """Returns an appropriate RFC2047 encoded representation of the given @@ -97,10 +79,10 @@ def encode_header_param(param_text): # For details see the encode_header() method that uses the same logic if not param_text: return "" - param_text_utf8 = ustr(param_text).encode('utf-8') - param_text_ascii = try_coerce_ascii(param_text_utf8) - return param_text_ascii or Charset('utf8').header_encode(param_text_utf8) - + param_text = ustr(param_text) # FIXME: require unicode higher up? + if is_ascii(param_text): + return pycompat.to_native(param_text) # TODO: is that actually necessary? + return Charset("utf-8").header_encode(param_text) address_pattern = re.compile(r'([^ ,<@]+@[^> ,]+)') @@ -111,8 +93,8 @@ def extract_rfc2822_addresses(text): """ if not text: return [] - candidates = address_pattern.findall(ustr(text).encode('utf-8')) - return [c for c in candidates if try_coerce_ascii(c)] + candidates = address_pattern.findall(ustr(text)) + return [c for c in candidates if is_ascii(c)] def encode_rfc2822_address_header(header_text): @@ -124,11 +106,20 @@ def encode_rfc2822_address_header(header_text): """ def encode_addr(addr): name, email = addr - if not try_coerce_ascii(name): - name = str(Header(name, 'utf-8')) + # If s is a <text string>, then charset is a hint specifying the + # character set of the characters in the string. The Unicode string + # will be encoded using the following charsets in order: us-ascii, + # the charset hint, utf-8. The first character set to not provoke a + # UnicodeError is used. + # -> always pass a text string to Header + + # also Header.__str__ in Python 3 "Returns an approximation of the + # Header as a string, using an unlimited line length.", the old one + # was "A synonym for Header.encode()." so call encode() directly? + name = Header(pycompat.to_text(name)).encode() return formataddr((name, email)) - addresses = getaddresses([ustr(header_text).encode('utf-8')]) + addresses = getaddresses([pycompat.to_native(ustr(header_text))]) return COMMASPACE.join(encode_addr(a) for a in addresses) @@ -252,8 +243,8 @@ class IrMailServer(models.Model): # The user/password must be converted to bytestrings in order to be usable for # certain hashing schemes, like HMAC. # See also bug #597143 and python issue #5285 - smtp_user = ustr(smtp_user).encode('utf-8') - smtp_password = ustr(smtp_password).encode('utf-8') + smtp_user = pycompat.to_native(ustr(smtp_user)) + smtp_password = pycompat.to_native(ustr(smtp_password)) connection.login(smtp_user, smtp_password) return connection @@ -301,8 +292,8 @@ class IrMailServer(models.Model): email_bcc = email_bcc or [] body = body or u'' - email_body_utf8 = ustr(body).encode('utf-8') - email_text_part = MIMEText(email_body_utf8, _subtype=subtype, _charset='utf-8') + email_body = ustr(body) + email_text_part = MIMEText(email_body, _subtype=subtype, _charset='utf-8') msg = MIMEMultipart() if not message_id: @@ -327,21 +318,21 @@ class IrMailServer(models.Model): msg['Bcc'] = encode_rfc2822_address_header(COMMASPACE.join(email_bcc)) msg['Date'] = formatdate() # Custom headers may override normal headers or provide additional ones - for key, value in pycompat.items(headers): - msg[ustr(key).encode('utf-8')] = encode_header(value) + for key, value in headers.items(): + msg[pycompat.to_native(ustr(key))] = encode_header(value) if subtype == 'html' and not body_alternative: # Always provide alternative text body ourselves if possible. - text_utf8 = html2text.html2text(email_body_utf8.decode('utf-8')).encode('utf-8') + text = html2text.html2text(email_body_utf8) alternative_part = MIMEMultipart(_subtype="alternative") - alternative_part.attach(MIMEText(text_utf8, _charset='utf-8', _subtype='plain')) + alternative_part.attach(MIMEText(text, _charset='utf-8', _subtype='plain')) alternative_part.attach(email_text_part) msg.attach(alternative_part) elif body_alternative: # Include both alternatives, as specified, within a multipart/alternative part alternative_part = MIMEMultipart(_subtype="alternative") - body_alternative_utf8 = ustr(body_alternative).encode('utf-8') - alternative_body_part = MIMEText(body_alternative_utf8, _subtype=subtype_alternative, _charset='utf-8') + body_alternative_ = ustr(body_alternative) + alternative_body_part = MIMEText(body_alternative_, _subtype=subtype_alternative, _charset='utf-8') alternative_part.attach(alternative_body_part) alternative_part.attach(email_text_part) msg.attach(alternative_part) diff --git a/odoo/addons/base/ir/ir_model.py b/odoo/addons/base/ir/ir_model.py index 4fa66406108..9f3ef47faa0 100644 --- a/odoo/addons/base/ir/ir_model.py +++ b/odoo/addons/base/ir/ir_model.py @@ -18,11 +18,6 @@ _logger = logging.getLogger(__name__) MODULE_UNINSTALL_FLAG = '_force_unlink' -def encode(s): - """ Return an UTF8-encoded version of ``s``. """ - return s.encode('utf8') if isinstance(s, unicode) else s - - # base environment for doing a safe_eval SAFE_EVAL_BASE = { 'datetime': datetime, @@ -199,7 +194,7 @@ class IrModel(models.Model): @api.multi def write(self, vals): if '__last_update' in self._context: - self = self.with_context({k: v for k, v in pycompat.items(self._context) if k != '__last_update'}) + self = self.with_context({k: v for k, v in self._context.items() if k != '__last_update'}) if 'model' in vals and any(rec.model != vals['model'] for rec in self): raise UserError(_('Field "Model" cannot be modified on models.')) if 'state' in vals and any(rec.state != vals['state'] for rec in self): @@ -272,7 +267,7 @@ class IrModel(models.Model): def _instanciate(self, model_data): """ Return a class for the custom model given by parameters ``model_data``. """ class CustomModel(models.Model): - _name = encode(model_data['model']) + _name = pycompat.to_native(model_data['model']) _description = model_data['name'] _module = False _custom = True @@ -524,7 +519,8 @@ class IrModelFields(models.Model): if field.state == 'manual' and field.ttype == 'many2many': rel_name = field.relation_table or model._fields[field.name].relation tables_to_drop.add(rel_name) - model._pop_field(field.name) + if field.state == 'manual': + model._pop_field(field.name) if tables_to_drop: # drop the relation tables that are not used by other fields @@ -625,7 +621,7 @@ class IrModelFields(models.Model): if not self.search([('model_id', '=', vals['relation']), ('name', '=', vals['relation_field']), ('ttype', '=', 'many2one')]): raise UserError(_("Many2one %s on model %s does not exist!") % (vals['relation_field'], vals['relation'])) - self.clear_caches() + self.clear_caches() # for _existing_field_data() if vals['model'] in self.pool: # setup models; this re-initializes model in registry @@ -684,7 +680,7 @@ class IrModelFields(models.Model): res = super(IrModelFields, self).write(vals) - self.clear_caches() + self.clear_caches() # for _existing_field_data() if column_rename: # rename column in database, and its corresponding index if present @@ -771,7 +767,7 @@ class IrModelFields(models.Model): fields_data[field.name] = dict(params, id=record.id) return record - diff = {key for key, val in pycompat.items(params) if field_data[key] != val} + diff = {key for key, val in params.items() if field_data[key] != val} if diff: cr = self.env.cr # update the entry in this table @@ -789,7 +785,7 @@ class IrModelFields(models.Model): def _reflect_model(self, model): """ Reflect the given model's fields. """ self.clear_caches() - for field in pycompat.values(model._fields): + for field in model._fields.values(): self._reflect_field(field) if not self.pool._init: @@ -872,7 +868,7 @@ class IrModelFields(models.Model): def _add_manual_fields(self, model): """ Add extra fields on model. """ fields_data = self._get_manual_field_data(model._name) - for name, field_data in pycompat.items(fields_data): + for name, field_data in fields_data.items(): if name not in model._fields and field_data['state'] == 'manual': field = self._instanciate(field_data) if field: @@ -1134,7 +1130,7 @@ class IrModelAccess(models.Model): # User root have all accesses return True - assert isinstance(model, basestring), 'Not a model name: %s' % (model,) + assert isinstance(model, pycompat.string_types), 'Not a model name: %s' % (model,) assert mode in ('read', 'write', 'create', 'unlink'), 'Invalid access mode' # TransientModel records have no access rights, only an implicit access rule @@ -1283,7 +1279,7 @@ class IrModelData(models.Model): model_id_name[xid.model][xid.res_id] = None # fill in model_id_name with name_get() of corresponding records - for model, id_name in pycompat.items(model_id_name): + for model, id_name in model_id_name.items(): try: ng = self.env[model].browse(id_name).name_get() id_name.update(ng) @@ -1380,7 +1376,7 @@ class IrModelData(models.Model): record = self.get_object(module, xml_id) if record: self.loads[(module, xml_id)] = (model, record.id) - for parent_model, parent_field in pycompat.items(self.env[model]._inherits): + for parent_model, parent_field in self.env[model]._inherits.items(): parent = record[parent_field] parent_xid = '%s_%s' % (xml_id, parent_model.replace('.', '_')) self.loads[(module, parent_xid)] = (parent_model, parent.id) @@ -1435,7 +1431,7 @@ class IrModelData(models.Model): elif record: record.write(values) if xml_id: - for parent_model, parent_field in pycompat.items(record._inherits): + for parent_model, parent_field in record._inherits.items(): self.sudo().create({ 'name': xml_id + '_' + parent_model.replace('.', '_'), 'model': parent_model, @@ -1454,7 +1450,7 @@ class IrModelData(models.Model): elif mode == 'init' or (mode == 'update' and xml_id): existing_parents = set() # {parent_model, ...} if xml_id: - for parent_model, parent_field in pycompat.items(record._inherits): + for parent_model, parent_field in record._inherits.items(): xid = self.sudo().search([ ('module', '=', module), ('name', '=', xml_id + '_' + parent_model.replace('.', '_')), @@ -1474,7 +1470,7 @@ class IrModelData(models.Model): inherit_models = [record] while inherit_models: current_model = inherit_models.pop() - for parent_model_name, parent_field in pycompat.items(current_model._inherits): + for parent_model_name, parent_field in current_model._inherits.items(): inherit_models.append(self.env[parent_model_name]) if parent_model_name in existing_parents: continue @@ -1500,7 +1496,7 @@ class IrModelData(models.Model): if xml_id and record: self.loads[(module, xml_id)] = (model, record.id) - for parent_model, parent_field in pycompat.items(record._inherits): + for parent_model, parent_field in record._inherits.items(): parent_xml_id = xml_id + '_' + parent_model.replace('.', '_') self.loads[(module, parent_xml_id)] = (parent_model, record[parent_field].id) diff --git a/odoo/addons/base/ir/ir_qweb/__init__.py b/odoo/addons/base/ir/ir_qweb/__init__.py index b7fb78857b1..b0add281fdb 100644 --- a/odoo/addons/base/ir/ir_qweb/__init__.py +++ b/odoo/addons/base/ir/ir_qweb/__init__.py @@ -5,5 +5,5 @@ from . import fields from . import assetsbundle from .assetsbundle import AssetsBundle -from .qweb import unicodifier, QWebException +from .qweb import QWebException from .ir_qweb import IrQWeb, QWeb diff --git a/odoo/addons/base/ir/ir_qweb/assetsbundle.py b/odoo/addons/base/ir/ir_qweb/assetsbundle.py index ac7c04fba38..fbf0d42f8b5 100644 --- a/odoo/addons/base/ir/ir_qweb/assetsbundle.py +++ b/odoo/addons/base/ir/ir_qweb/assetsbundle.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import base64 import os import re import hashlib @@ -12,7 +13,7 @@ from odoo import fields, tools from odoo.http import request from odoo.modules.module import get_resource_path import psycopg2 -from odoo.tools import func, misc, pycompat +from odoo.tools import func, misc import logging _logger = logging.getLogger(__name__) @@ -101,7 +102,7 @@ class AssetsBundle(object): def to_html(self, sep=None, css=True, js=True, debug=False, async=False, url_for=(lambda url: url)): if sep is None: - sep = '\n ' + sep = u'\n ' response = [] if debug == 'assets': if css and self.stylesheets: @@ -120,12 +121,12 @@ class AssetsBundle(object): if css and self.stylesheets: css_attachments = self.css() or [] for attachment in css_attachments: - response.append('<link href="%s" rel="stylesheet"/>' % url_for(attachment.url)) + response.append(u'<link href="%s" rel="stylesheet"/>' % url_for(attachment.url)) if self.css_errors: msg = '\n'.join(self.css_errors) response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_html()) if js and self.javascripts: - response.append('<script %s type="text/javascript" src="%s"></script>' % (async and 'async="async"' or '', url_for(self.js().url))) + response.append(u'<script %s type="text/javascript" src="%s"></script>' % (async and u'async="async"' or '', url_for(self.js().url))) response.extend(self.remains) return sep + sep.join(response) @@ -148,8 +149,8 @@ class AssetsBundle(object): Not really a full checksum. We compute a SHA1 on the rendered bundle + max linked files last_modified date """ - check = json.dumps(self.files) + ",".join(self.remains) + str(self.last_modified) - return hashlib.sha1(check).hexdigest() + check = u"%s%s%s" % (json.dumps(self.files), u",".join(self.remains), self.last_modified) + return hashlib.sha1(check.encode('utf-8')).hexdigest() def clean_attachments(self, type): """ Takes care of deleting any outdated ir.attachment records associated to a bundle before @@ -207,7 +208,7 @@ class AssetsBundle(object): 'res_id': False, 'type': 'binary', 'public': True, - 'datas': content.encode('utf8').encode('base64'), + 'datas': base64.b64encode(content.encode('utf8')), } attachment = ira.sudo().create(values) @@ -315,11 +316,11 @@ class AssetsBundle(object): outdated = True break if asset._content is None: - asset._content = attachment.datas and attachment.datas.decode('base64').decode('utf8') or '' + asset._content = attachment.datas and base64.b64decode(attachment.datas).decode('utf8') or '' if not asset._content and attachment.file_size > 0: asset._content = None # file missing, force recompile - if any(asset._content is None for asset in pycompat.values(assets)): + if any(asset._content is None for asset in assets.values()): outdated = True if outdated: @@ -357,7 +358,7 @@ class AssetsBundle(object): url = asset.html_url with self.env.cr.savepoint(): self.env['ir.attachment'].sudo().create(dict( - datas=asset.content.encode('utf8').encode('base64'), + datas=base64.b64encode(asset.content.encode('utf8')), mimetype='text/css', type='binary', name=url, @@ -501,7 +502,7 @@ class WebAsset(object): with open(self._filename, 'rb') as fp: return fp.read().decode('utf-8') else: - return self._ir_attach['datas'].decode('base64').decode('utf-8') + return base64.b64decode(self._ir_attach['datas']).decode('utf-8') except UnicodeDecodeError: raise AssetError('%s is not utf-8 encoded.' % self.name) except IOError: diff --git a/odoo/addons/base/ir/ir_qweb/fields.py b/odoo/addons/base/ir/ir_qweb/fields.py index 6b9b0536b1a..27d13d3076c 100644 --- a/odoo/addons/base/ir/ir_qweb/fields.py +++ b/odoo/addons/base/ir/ir_qweb/fields.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- +import base64 import re from collections import OrderedDict from io import BytesIO from odoo import api, fields, models, _ from PIL import Image import babel -from odoo.tools import html_escape as escape, posix_to_ldml, safe_eval, float_utils, format_date -from .qweb import unicodifier +from odoo.tools import html_escape as escape, posix_to_ldml, safe_eval, float_utils, format_date, pycompat import logging _logger = logging.getLogger(__name__) @@ -19,7 +19,7 @@ def nl2br(string): :param str string: :rtype: unicode """ - return unicodifier(string).replace(u'\n', u'<br>\n') + return pycompat.to_text(string).replace(u'\n', u'<br>\n') def html_escape(string, options): """ Automatically escapes content unless options['html-escape'] @@ -88,7 +88,7 @@ class FieldConverter(models.AbstractModel): Converts a single value to its HTML version/output :rtype: unicode """ - return html_escape(unicodifier(value) or u'', options) + return html_escape(pycompat.to_text(value), options) @api.model def record_to_html(self, record, field_name, options): @@ -123,7 +123,7 @@ class IntegerConverter(models.AbstractModel): @api.model def value_to_html(self, value, options): - return unicodifier(self.user_lang().format('%d', value, grouping=True).replace(r'-', u'\u2011')) + return pycompat.to_text(self.user_lang().format('%d', value, grouping=True).replace(r'-', u'\u2011')) class FloatConverter(models.AbstractModel): @@ -152,7 +152,7 @@ class FloatConverter(models.AbstractModel): if precision is None: formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted) - return unicodifier(formatted) + return pycompat.to_text(formatted) @api.model def record_to_html(self, record, field_name, options): @@ -182,7 +182,7 @@ class DateTimeConverter(models.AbstractModel): lang = self.user_lang() locale = babel.Locale.parse(lang.code) - if isinstance(value, basestring): + if isinstance(value, pycompat.string_types): value = fields.Datetime.from_string(value) value = fields.Datetime.context_timestamp(self, value) @@ -200,7 +200,7 @@ class DateTimeConverter(models.AbstractModel): if options and options.get('hide_seconds'): pattern = pattern.replace(":ss", "").replace(":s", "") - return unicodifier(babel.dates.format_datetime(value, format=pattern, locale=locale)) + return pycompat.to_text(babel.dates.format_datetime(value, format=pattern, locale=locale)) class TextConverter(models.AbstractModel): @@ -223,7 +223,7 @@ class SelectionConverter(models.AbstractModel): def value_to_html(self, value, options): if not value: return '' - return html_escape(unicodifier(options['selection'][value]) or u'', options) + return html_escape(pycompat.to_text(options['selection'][value]) or u'', options) @api.model def record_to_html(self, record, field_name, options): @@ -252,7 +252,7 @@ class HTMLConverter(models.AbstractModel): @api.model def value_to_html(self, value, options): - return unicodifier(value) or u'' + return pycompat.to_text(value) class ImageConverter(models.AbstractModel): @@ -269,15 +269,15 @@ class ImageConverter(models.AbstractModel): @api.model def value_to_html(self, value, options): - try: - image = Image.open(BytesIO(value.decode('base64'))) + try: # FIXME: maaaaaybe it could also take raw bytes? + image = Image.open(BytesIO(base64.b64decode(value))) image.verify() except IOError: raise ValueError("Non-image binary fields can not be converted to HTML") except: # image.verify() throws "suitable exceptions", I have no idea what they are raise ValueError("Invalid image content") - return unicodifier('<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value)) + return u'<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value.decode('ascii')) class MonetaryConverter(models.AbstractModel): @@ -398,13 +398,13 @@ class RelativeDatetimeConverter(models.AbstractModel): def value_to_html(self, value, options): locale = babel.Locale.parse(self.user_lang().code) - if isinstance(value, basestring): + if isinstance(value, pycompat.string_types): value = fields.Datetime.from_string(value) # value should be a naive datetime in UTC. So is fields.Datetime.now() reference = fields.Datetime.from_string(options['now']) - return unicodifier(babel.dates.format_timedelta(value - reference, add_direction=True, locale=locale)) + return pycompat.to_text(babel.dates.format_timedelta(value - reference, add_direction=True, locale=locale)) @api.model def record_to_html(self, record, field_name, options): @@ -427,8 +427,8 @@ class BarcodeConverter(models.AbstractModel): barcode = self.env['ir.actions.report'].barcode( barcode_type, value, - **dict((key, value) for key, value in options.items() if key in ['width', 'height', 'humanreadable'])) - return unicodifier('<img src="data:%s;base64,%s">' % ('png', barcode.encode('base64'))) + **{key: value for key, value in options.items() if key in ['width', 'height', 'humanreadable']}) + return u'<img src="data:png;base64,%s">' % base64.b64encode(barcode).decode('ascii') @api.model def from_html(self, model, field, element): @@ -481,4 +481,4 @@ class QwebView(models.AbstractModel): view = view.with_context(object=record) - return unicodifier(view.render(view._context, engine='ir.qweb')) + return pycompat.to_text(view.render(view._context, engine='ir.qweb')) diff --git a/odoo/addons/base/ir/ir_qweb/ir_qweb.py b/odoo/addons/base/ir/ir_qweb/ir_qweb.py index 102b36e0898..8bb333ea3fe 100644 --- a/odoo/addons/base/ir/ir_qweb/ir_qweb.py +++ b/odoo/addons/base/ir/ir_qweb/ir_qweb.py @@ -66,7 +66,7 @@ class IrQWeb(models.AbstractModel, QWeb): # assume cache will be invalidated by third party on write to ir.ui.view def _get_template_cache_keys(self): """ Return the list of context keys to use for caching ``_get_template``. """ - return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations'] + return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations', 'website_id'] # apply ormcache_context decorator unless in dev mode... @tools.conditional( @@ -169,7 +169,7 @@ class IrQWeb(models.AbstractModel, QWeb): if field_options and 'monetary' in field_options: try: options = "{'widget': 'monetary'" - for k, v in pycompat.items(json.loads(field_options)): + for k, v in json.loads(field_options).items(): if k in ('display_currency', 'from_currency'): options = "%s, '%s': %s" % (options, k, v) else: @@ -189,14 +189,14 @@ class IrQWeb(models.AbstractModel, QWeb): # in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear # by restarting the server after updating the source code (or using the "Clear server cache" in debug tools) 'xml' not in tools.config['dev_mode'], - tools.ormcache('xmlid', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async'), + tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async', keys=("website_id",)), ) def _get_asset(self, xmlid, options, css=True, js=True, debug=False, async=False, values=None): files, remains = self._get_asset_content(xmlid, options) asset = AssetsBundle(xmlid, files, remains, env=self.env) return asset.to_html(css=css, js=js, debug=debug, async=async, url_for=(values or {}).get('url_for', lambda url: url)) - @tools.ormcache('xmlid', 'options.get("lang", "en_US")') + @tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', keys=("website_id",)) def _get_asset_content(self, xmlid, options): options = dict(options, inherit_branding=False, inherit_branding_auto=False, @@ -218,8 +218,8 @@ class IrQWeb(models.AbstractModel, QWeb): files = [] remains = [] for el in html.fragments_fromstring(template): - if isinstance(el, basestring): - remains.append(el) + if isinstance(el, pycompat.string_types): + remains.append(pycompat.to_text(el)) elif isinstance(el, html.HtmlElement): href = el.get('href', '') src = el.get('src', '') @@ -243,10 +243,10 @@ class IrQWeb(models.AbstractModel, QWeb): filename = get_resource_path(*path) if path else None files.append({'atype': atype, 'url': src, 'filename': filename, 'content': el.text, 'media': media}) else: - remains.append(html.tostring(el)) + remains.append(html.tostring(el, encoding='unicode')) else: try: - remains.append(html.tostring(el)) + remains.append(html.tostring(el, encoding='unicode')) except Exception: # notYETimplementederror raise NotImplementedError diff --git a/odoo/addons/base/ir/ir_qweb/qweb.py b/odoo/addons/base/ir/ir_qweb/qweb.py index 69a01635f2d..43c10fe19f0 100644 --- a/odoo/addons/base/ir/ir_qweb/qweb.py +++ b/odoo/addons/base/ir/ir_qweb/qweb.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import ast import logging +import os.path import re import traceback @@ -176,13 +177,6 @@ class QWebException(Exception): # Avoid DeprecationWarning while still remaining compatible with werkzeug pre-0.9 escape = (lambda text: _escape(text, quote=True)) if getattr(werkzeug, '__version__', '0.0') < '0.9.0' else _escape -def unicodifier(val): - if val is None or val is False: - return u'' - if isinstance(val, str): - return val.decode('utf-8') - return unicode(val) - def foreach_iterator(base_ctx, enum, name): ctx = base_ctx.copy() if not enum: @@ -193,7 +187,7 @@ def foreach_iterator(base_ctx, enum, name): if isinstance(enum, Sized): ctx["%s_size" % name] = size = len(enum) if isinstance(enum, Mapping): - enum = pycompat.items(enum) + enum = enum.items() else: enum = pycompat.izip(*tee(enum)) value_key = '%s_value' % name @@ -246,7 +240,7 @@ class frozendict(dict): def update(self, *args, **kwargs): raise NotImplementedError("'update' not supported on frozendict") def __hash__(self): - return hash(frozenset((key, freehash(val)) for key, val in pycompat.items(self))) + return hash(frozenset((key, freehash(val)) for key, val in self.items())) #################################### @@ -380,10 +374,11 @@ class QWeb(object): if isinstance(document, etree._Element): element = document document = etree.tostring(document) - elif document.startswith("<?xml"): - element = etree.fromstring(document) - else: + elif os.path.exists(document): element = etree.parse(document).getroot() + else: + element = etree.fromstring(document) + for node in element: if node.get('t-name') == str(template): return (node, document) @@ -527,12 +522,13 @@ class QWeb(object): * itertools Define: * escape - * unicodifier (empty string for a None or False, otherwise unicode string) + * to_text (empty string for a None or False, otherwise unicode string) + * string_types (replacement for basestring) """ return ast.parse(dedent(""" from collections import OrderedDict - from odoo.tools import pycompat - from odoo.addons.base.ir.ir_qweb.qweb import escape, unicodifier, foreach_iterator + from odoo.tools.pycompat import to_text, string_types + from odoo.addons.base.ir.ir_qweb.qweb import escape, foreach_iterator """)) def _create_def(self, options, body, prefix='fn', lineno=None): @@ -748,7 +744,7 @@ class QWeb(object): attrib = {} # If `el` introduced new namespaces, write them as attribute by using the # `attrib` dict. - for ns_prefix, ns_definition in set(pycompat.items(el.nsmap)) - set(pycompat.items(options['nsmap'])): + for ns_prefix, ns_definition in set(el.nsmap.items()) - set(options['nsmap'].items()): if ns_prefix is None: attrib['xmlns'] = ns_definition else: @@ -757,9 +753,9 @@ class QWeb(object): # Etree will also remove the ns prefixes indirection in the attributes. As we only have # the namespace definition, we'll use an nsmap where the keys are the definitions and # the values the prefixes in order to get back the right prefix and restore it. - ns = itertools.chain(pycompat.items(options['nsmap']), pycompat.items(el.nsmap)) + ns = itertools.chain(options['nsmap'].items(), el.nsmap.items()) nsprefixmap = {v: k for k, v in ns} - for key, value in pycompat.items(el.attrib): + for key, value in el.attrib.items(): attrib_qname = etree.QName(key) if attrib_qname.namespace: attrib['%s:%s' % (nsprefixmap[attrib_qname.namespace], attrib_qname.localname)] = value @@ -777,7 +773,7 @@ class QWeb(object): if unqualified_el_tag == 't': return content - tag = u'<%s%s' % (el_tag, u''.join([u' %s="%s"' % (name, escape(unicodifier(value))) for name, value in pycompat.items(attrib)])) + tag = u'<%s%s' % (el_tag, u''.join([u' %s="%s"' % (name, escape(pycompat.to_text(value))) for name, value in attrib.items()])) if unqualified_el_tag in self._void_elements: return [self._append(ast.Str(tag + '/>'))] + content else: @@ -789,10 +785,10 @@ class QWeb(object): # Etree will also remove the ns prefixes indirection in the attributes. As we only have # the namespace definition, we'll use an nsmap where the keys are the definitions and # the values the prefixes in order to get back the right prefix and restore it. - nsprefixmap = {v: k for k, v in itertools.chain(pycompat.items(options['nsmap']), pycompat.items(el.nsmap))} + nsprefixmap = {v: k for k, v in itertools.chain(options['nsmap'].items(), el.nsmap.items())} nodes = [] - for key, value in pycompat.items(el.attrib): + for key, value in el.attrib.items(): if not key.startswith('t-'): attrib_qname = etree.QName(key) if attrib_qname.namespace: @@ -807,7 +803,7 @@ class QWeb(object): We do not support namespaced dynamic attributes. """ nodes = [] - for name, value in pycompat.items(el.attrib): + for name, value in el.attrib.items(): if name.startswith('t-attf-'): nodes.append((name[7:], self._compile_format(value))) elif name.startswith('t-att-'): @@ -833,7 +829,7 @@ class QWeb(object): def _compile_all_attributes(self, el, options, attr_already_created=False): """ Compile the attributes of the given elements into a list of AST nodes. """ body = [] - if any(name.startswith('t-att') or not name.startswith('t-') for name, value in pycompat.items(el.attrib)): + if any(name.startswith('t-att') or not name.startswith('t-') for name, value in el.attrib.items()): if not attr_already_created: attr_already_created = True body.append( @@ -874,22 +870,22 @@ class QWeb(object): ))) if attr_already_created: - # for name, value in pycompat.items(t_attrs): + # for name, value in t_attrs.items(): # if value or isinstance(value, basestring)): # append(u' ') # append(name) # append(u'="') - # append(escape(unicodifier((value))) + # append(escape(to_text((value))) # append(u'"') body.append(ast.For( target=ast.Tuple(elts=[ast.Name(id='name', ctx=ast.Store()), ast.Name(id='value', ctx=ast.Store())], ctx=ast.Store()), iter=ast.Call( func=ast.Attribute( - value=ast.Name(id='pycompat', ctx=ast.Load()), + value=ast.Name(id='t_attrs', ctx=ast.Load()), attr='items', ctx=ast.Load() ), - args=[ast.Name(id='t_attrs', ctx=ast.Load())], keywords=[], + args=[], keywords=[], starargs=None, kwargs=None ), body=[ast.If( @@ -901,7 +897,7 @@ class QWeb(object): func=ast.Name(id='isinstance', ctx=ast.Load()), args=[ ast.Name(id='value', ctx=ast.Load()), - ast.Name(id='basestring', ctx=ast.Load()) + ast.Name(id='string_types', ctx=ast.Load()) ], keywords=[], starargs=None, kwargs=None @@ -915,7 +911,7 @@ class QWeb(object): self._append(ast.Call( func=ast.Name(id='escape', ctx=ast.Load()), args=[ast.Call( - func=ast.Name(id='unicodifier', ctx=ast.Load()), + func=ast.Name(id='to_text', ctx=ast.Load()), args=[ast.Name(id='value', ctx=ast.Load())], keywords=[], starargs=None, kwargs=None )], keywords=[], @@ -946,7 +942,7 @@ class QWeb(object): # If `el` introduced new namespaces, write them as attribute by using the # `extra_attrib` dict. - for ns_prefix, ns_definition in set(pycompat.items(el.nsmap)) - set(pycompat.items(options['nsmap'])): + for ns_prefix, ns_definition in set(el.nsmap.items()) - set(options['nsmap'].items()): if ns_prefix is None: extra_attrib['xmlns'] = ns_definition else: @@ -955,7 +951,7 @@ class QWeb(object): if unqualified_el_tag == 't': return content - body = [self._append(ast.Str(u'<%s%s' % (el_tag, u''.join([u' %s="%s"' % (name, escape(unicodifier(value))) for name, value in pycompat.items(extra_attrib)]))))] + body = [self._append(ast.Str(u'<%s%s' % (el_tag, u''.join([u' %s="%s"' % (name, escape(pycompat.to_text(value))) for name, value in extra_attrib.items()]))))] body.extend(self._compile_all_attributes(el, options, attr_already_created)) if unqualified_el_tag in self._void_elements: body.append(self._append(ast.Str(u'/>'))) @@ -1044,7 +1040,7 @@ class QWeb(object): def _compile_directive_content(self, el, options): body = [] if el.text is not None: - body.append(self._append(ast.Str(unicodifier(el.text)))) + body.append(self._append(ast.Str(pycompat.to_text(el.text)))) if el.getchildren(): for item in el: # ignore comments & processing instructions @@ -1135,7 +1131,7 @@ class QWeb(object): )] def _compile_tail(self, el): - return el.tail is not None and [self._append(ast.Str(unicodifier(el.tail)))] or [] + return el.tail is not None and [self._append(ast.Str(pycompat.to_text(el.tail)))] or [] def _compile_directive_esc(self, el, options): field_options = self._compile_widget_options(el, 'esc') @@ -1148,14 +1144,14 @@ class QWeb(object): return content + self._compile_widget_value(el, options) def _compile_widget(self, el, expression, field_options, escape=None): - # if isinstance(value, basestring): - # value = escape(unicodifier(value)) + # if isinstance(value, string_types): + # value = escape(to_text(value)) escaped = ast.If( test=ast.Call( func=ast.Name(id='isinstance', ctx=ast.Load()), args=[ ast.Name(id='content', ctx=ast.Load()), - ast.Name(id='basestring', ctx=ast.Load()) + ast.Name(id='string_types', ctx=ast.Load()) ], keywords=[], starargs=None, kwargs=None ), @@ -1164,7 +1160,7 @@ class QWeb(object): value=ast.Call( func=ast.Name(id=escape, ctx=ast.Load()), args=[ast.Call( - func=ast.Name(id='unicodifier', ctx=ast.Load()), + func=ast.Name(id='to_text', ctx=ast.Load()), args=[ast.Name(id='content', ctx=ast.Load())], keywords=[], starargs=None, kwargs=None )], keywords=[], @@ -1332,7 +1328,7 @@ class QWeb(object): ] # if content is not None: - # display the tag (unicodifier(content)) + # display the tag (to_text(content)) # else # if default_content: # display the tag with default content @@ -1341,7 +1337,7 @@ class QWeb(object): return [self._if_content_is_not_Falsy( body=self._compile_tag(el, [self._append( ast.Call( - func=ast.Name(id='unicodifier', ctx=ast.Load()), + func=ast.Name(id='to_text', ctx=ast.Load()), args=[ast.Name(id='content', ctx=ast.Load())], keywords=[], starargs=None, kwargs=None ) @@ -1460,8 +1456,8 @@ class QWeb(object): # make the nsmap an ast dict keys = [] values = [] - for key, value in pycompat.items(options['nsmap']): - if isinstance(key, basestring): + for key, value in options['nsmap'].items(): + if isinstance(key, pycompat.string_types): keys.append(ast.Str(s=key)) elif key is None: keys.append(ast.Name(id='None', ctx=ast.Load())) @@ -1547,7 +1543,7 @@ class QWeb(object): def _compile_strexpr(self, expr): # ensure result is unicode return ast.Call( - func=ast.Name(id='unicodifier', ctx=ast.Load()), + func=ast.Name(id='to_text', ctx=ast.Load()), args=[self._compile_expr(expr)], keywords=[], starargs=None, kwargs=None ) @@ -1591,7 +1587,7 @@ class QWeb(object): for m in _FORMAT_REGEX.finditer(f): literal = f[base_idx:m.start()] if literal: - elts.append(ast.Str(literal if isinstance(literal, unicode) else literal.decode('utf-8'))) + elts.append(ast.Str(literal if isinstance(literal, pycompat.text_type) else literal.decode('utf-8'))) expr = m.group(1) or m.group(2) elts.append(self._compile_strexpr(expr)) @@ -1599,7 +1595,7 @@ class QWeb(object): # string past last regex match literal = f[base_idx:] if literal: - elts.append(ast.Str(literal if isinstance(literal, unicode) else literal.decode('utf-8'))) + elts.append(ast.Str(literal if isinstance(literal, pycompat.text_type) else literal.decode('utf-8'))) return reduce(lambda acc, it: ast.BinOp( left=acc, diff --git a/odoo/addons/base/ir/ir_sequence.py b/odoo/addons/base/ir/ir_sequence.py index aa893e3f316..105f0f9bbf4 100644 --- a/odoo/addons/base/ir/ir_sequence.py +++ b/odoo/addons/base/ir/ir_sequence.py @@ -6,7 +6,6 @@ import pytz from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -195,7 +194,7 @@ class IrSequence(models.Model): 'weekday': '%w', 'h24': '%H', 'h12': '%I', 'min': '%M', 'sec': '%S' } res = {} - for key, format in pycompat.items(sequences): + for key, format in sequences.items(): res[key] = effective_date.strftime(format) res['range_' + key] = range_date.strftime(format) res['current_' + key] = now.strftime(format) diff --git a/odoo/addons/base/ir/ir_translation.py b/odoo/addons/base/ir/ir_translation.py index cd954778a92..f77d84e758b 100644 --- a/odoo/addons/base/ir/ir_translation.py +++ b/odoo/addons/base/ir/ir_translation.py @@ -128,7 +128,7 @@ class IrTranslationImport(object): env = api.Environment(cr, SUPERUSER_ID, {}) src_relevant_fields = [] for model in env: - for field_name, field in pycompat.items(env[model]._fields): + for field_name, field in env[model]._fields.items(): if hasattr(field, 'translate') and callable(field.translate): src_relevant_fields.append("%s,%s" % (model, field_name)) @@ -394,7 +394,7 @@ class IrTranslation(models.Model): # always pass unicode so we can remove the string encoding/decoding. if not lang: return tools.ustr(source or '') - if isinstance(types, basestring): + if isinstance(types, pycompat.string_types): types = (types,) if res_id: if isinstance(res_id, pycompat.integer_types): @@ -531,7 +531,7 @@ class IrTranslation(models.Model): # check for read/write access on translated field records fmode = 'read' if mode == 'read' else 'write' - for mname, ids in pycompat.items(model_ids): + for mname, ids in model_ids.items(): records = self.env[mname].browse(ids) records.check_access_rights(fmode) records.check_field_access_rights(fmode, model_fields[mname]) @@ -646,7 +646,7 @@ class IrTranslation(models.Model): return ['&', ('res_id', '=', rec.id), ('name', '=', name)] # insert missing translations, and extend domain for related fields - for name, fld in pycompat.items(record._fields): + for name, fld in record._fields.items(): if not fld.translate: continue diff --git a/odoo/addons/base/ir/ir_ui_view.py b/odoo/addons/base/ir/ir_ui_view.py index 343aa6f570a..aa347f9d2ee 100644 --- a/odoo/addons/base/ir/ir_ui_view.py +++ b/odoo/addons/base/ir/ir_ui_view.py @@ -29,7 +29,7 @@ from odoo.tools.convert import _fix_multiple_roots from odoo.tools.parse_version import parse_version from odoo.tools.safe_eval import safe_eval from odoo.tools.view_validation import valid_view -from odoo.tools.translate import encode, xml_translate, TRANSLATED_ATTRS +from odoo.tools.translate import xml_translate, TRANSLATED_ATTRS _logger = logging.getLogger(__name__) @@ -110,7 +110,7 @@ def get_view_arch_from_file(filename, xmlid): if node.tag == 'record': field = node.find('field[@name="arch"]') _fix_multiple_roots(field) - inner = ''.join([etree.tostring(child) for child in field.iterchildren()]) + inner = u''.join([etree.tostring(child, encoding='unicode') for child in field.iterchildren()]) return field.text + inner elif node.tag == 'template': # The following dom operations has been copied from convert.py's _tag_template() @@ -120,7 +120,7 @@ def get_view_arch_from_file(filename, xmlid): else: node.tag = 'data' node.attrib.pop('id', None) - return etree.tostring(node) + return etree.tostring(node, encoding='unicode') _logger.warning("Could not find view arch definition in file '%s' for xmlid '%s'", filename, xmlid) return None @@ -234,7 +234,7 @@ actual arch. else: _logger.warning("View %s: Full path [%s] cannot be found.", view.xml_id, view.arch_fs) arch_fs = False - view.arch = arch_fs or view.arch_db + view.arch = pycompat.to_text(arch_fs or view.arch_db) def _inverse_arch(self): for view in self: @@ -270,7 +270,7 @@ actual arch. view.model_data_id = data['id'] def _search_model_data_id(self, operator, value): - name = 'name' if isinstance(value, basestring) else 'id' + name = 'name' if isinstance(value, pycompat.string_types) else 'id' domain = [('model', '=', 'ir.ui.view'), (name, operator, value)] data = self.env['ir.model.data'].sudo().search(domain) return [('id', 'in', data.mapped('res_id'))] @@ -320,7 +320,7 @@ actual arch. # Sanity checks: the view should not break anything upon rendering! # Any exception raised below will cause a transaction rollback. for view in self: - view_arch = etree.fromstring(encode(view.arch)) + view_arch = etree.fromstring(view.arch.encode('utf-8')) view._valid_inheritance(view_arch) view_def = view.read_combined(['arch']) view_arch_utf8 = view_def['arch'] @@ -439,6 +439,15 @@ actual arch. # Inheritance mecanism #------------------------------------------------------ @api.model + def _get_inheriting_views_arch_domain(self, view_id, model): + return [ + ['inherit_id', '=', view_id], + ['model', '=', model], + ['mode', '=', 'extension'], + ['active', '=', True], + ] + + @api.model def get_inheriting_views_arch(self, view_id, model): """Retrieves the architecture of views that inherit from the given view, from the sets of views that should currently be used in the system. During the module upgrade phase it @@ -453,13 +462,8 @@ actual arch. :return: [(view_arch,view_id), ...] """ user_groups = self.env.user.groups_id + conditions = self._get_inheriting_views_arch_domain(view_id, model) - conditions = [ - ['inherit_id', '=', view_id], - ['model', '=', model], - ['mode', '=', 'extension'], - ['active', '=', True], - ] if self.pool._init and not self._context.get('load_all_views'): # Module init currently in progress, only consider views from # modules whose code is already loaded @@ -709,7 +713,7 @@ actual arch. # and apply inheritance arch = self.apply_view_inheritance(arch_tree, root.id, self.model) - return dict(view_data, arch=etree.tostring(arch, encoding='utf-8')) + return dict(view_data, arch=etree.tostring(arch, encoding='unicode')) def _apply_group(self, model, node, modifiers, fields): """Apply group restrictions, may be set at view level or model level:: @@ -852,7 +856,7 @@ actual arch. collect(arch, self.env[model_name]) - for field, nodes in pycompat.items(field_nodes): + for field, nodes in field_nodes.items(): # if field should trigger an onchange, add on_change="1" on the # nodes referring to field model = self.env[field.model_name] @@ -917,7 +921,7 @@ actual arch. not self._context.get(action, True) and is_base_model): node.set(action, 'false') - arch = etree.tostring(node, encoding="utf-8").replace('\t', '') + arch = etree.tostring(node, encoding="unicode").replace('\t', '') for k in list(fields): if k not in fields_def: del fields[k] @@ -935,7 +939,7 @@ actual arch. def _read_template_keys(self): """ Return the list of context keys to use for caching ``_read_template``. """ - return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations'] + return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations', 'website_id'] # apply ormcache_context decorator unless in dev mode... @api.model @@ -949,7 +953,7 @@ actual arch. arch_tree = etree.fromstring(arch) self.distribute_branding(arch_tree) root = E.templates(arch_tree) - arch = etree.tostring(root, encoding='utf-8', xml_declaration=True) + arch = etree.tostring(root, encoding='unicode') return arch @api.model @@ -1119,12 +1123,12 @@ actual arch. Model = self.env[model] Node = self.env[node_obj] - for model_key, model_value in pycompat.items(Model._fields): + for model_key, model_value in Model._fields.items(): if model_value.type == 'one2many': if model_value.comodel_name == node_obj: _Node_Field = model_key _Model_Field = model_value.inverse_name - for node_key, node_value in pycompat.items(Node._fields): + for node_key, node_value in Node._fields.items(): if node_value.type == 'one2many': if node_value.comodel_name == conn_obj: # _Source_Field = "Incoming Arrows" (connected via des_node) @@ -1201,7 +1205,7 @@ actual arch. xmlid_filter = "AND md.name IN %s" names = tuple( name - for (xmod, name), (model, res_id) in pycompat.items(self.pool.model_data_reference_ids) + for (xmod, name), (model, res_id) in self.pool.model_data_reference_ids.items() if xmod == module and model == self._name ) if not names: diff --git a/odoo/addons/base/ir/ir_values.py b/odoo/addons/base/ir/ir_values.py index ace2998b2b3..851c4705738 100644 --- a/odoo/addons/base/ir/ir_values.py +++ b/odoo/addons/base/ir/ir_values.py @@ -96,8 +96,8 @@ class IrValues(models.Model): "helper field for binding an action, will " "automatically set the correct reference") - value = fields.Text(help="Default value (pickled) or reference to an action") - value_unpickle = fields.Text(string='Default value or action reference', + value = fields.Binary(help="Default value (pickled) or reference to an action") + value_unpickle = fields.Binary(string='Default value or action reference', compute='_value_unpickle', inverse='_value_pickle') key = fields.Selection([('action', 'Action'), ('default', 'Default')], string='Type', index=True, required=True, default='action', @@ -211,8 +211,8 @@ class IrValues(models.Model): match) :return: the newly created ir.values entry """ - if isinstance(value, unicode): - value = value.encode('utf8') + if isinstance(value, pycompat.string_types): + value = pycompat.to_native(value) if company_id is True: # should be company-specific, need to get company id company_id = self.env.user.company_id.id @@ -261,7 +261,7 @@ class IrValues(models.Model): ('company_id', '=', company_id) ] defaults = self.search(search_criteria) - return pickle.loads(defaults.value.encode('utf-8')) if defaults else None + return pickle.loads(defaults.value) if defaults else None @api.model def get_defaults(self, model, condition=False): @@ -313,9 +313,9 @@ class IrValues(models.Model): # keep only the highest priority default for each field defaults = {} for row in self._cr.dictfetchall(): - value = pickle.loads(row['value'].encode('utf-8')) + value = pickle.loads(row['value']) defaults.setdefault(row['name'], (row['id'], row['name'], value)) - return list(pycompat.values(defaults)) + return list(defaults.values()) # use ormcache: this is called a lot by BaseModel.default_get()! @api.model @@ -347,11 +347,13 @@ class IrValues(models.Model): specific record of the model, not all records. :return: the newly created ir.values entry """ - assert isinstance(action, basestring) and ',' in action, \ + assert isinstance(action, pycompat.string_types) and ',' in action, \ 'Action definition must be an action reference, e.g. "ir.actions.act_window,42"' assert action_slot in ACTION_SLOTS, \ 'Action slot (%s) must be one of: %r' % (action_slot, ACTION_SLOTS) + action = action.encode('utf-8') + # remove existing action definition of same slot and value search_criteria = [ ('key', '=', 'action'), @@ -405,7 +407,7 @@ class IrValues(models.Model): for id, name, value in self._cr.fetchall(): if not value: continue # skip if undefined - action_model, action_id = value.split(',') + action_model, action_id = bytes(value).decode('utf-8').split(',') if action_model not in self.env: continue # unknown model? skip it! action = self.env[action_model].browse(int(action_id)) @@ -426,4 +428,4 @@ class IrValues(models.Model): results[name] = (id, name, action_def) except (AccessError, MissingError): continue - return sorted(pycompat.values(results)) + return sorted(results.values()) diff --git a/odoo/addons/base/module/module.py b/odoo/addons/base/module/module.py index e192a2783d3..0fdb51ffce2 100644 --- a/odoo/addons/base/module/module.py +++ b/odoo/addons/base/module/module.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 from collections import defaultdict from operator import attrgetter import importlib @@ -220,7 +221,7 @@ class Module(models.Model): path = modules.module.get_module_icon(module.name) if path: with tools.file_open(path, 'rb') as image_file: - module.icon_image = image_file.read().encode('base64') + module.icon_image = base64.b64encode(image_file.read()) name = fields.Char('Technical Name', readonly=True, required=True, index=True) category_id = fields.Many2one('ir.module.category', string='Category', readonly=True, index=True) @@ -647,7 +648,7 @@ class Module(models.Model): updated_values = {} for key in values: old = getattr(mod, key) - updated = tools.ustr(values[key]) if isinstance(values[key], basestring) else values[key] + updated = tools.ustr(values[key]) if isinstance(values[key], pycompat.string_types) else values[key] if (old or updated) and updated != old: updated_values[key] = values[key] if terp.get('installable', True) and mod.state == 'uninstallable': @@ -696,7 +697,7 @@ class Module(models.Model): _logger.debug('Install from url: %r', urls) try: # 1. Download & unzip missing modules - for module_name, url in pycompat.items(urls): + for module_name, url in urls.items(): if not url: continue # nothing to download, local version is already the last one @@ -717,7 +718,7 @@ class Module(models.Model): assert os.path.isdir(os.path.join(tmp, module_name)) # 2a. Copy/Replace module source in addons path - for module_name, url in pycompat.items(urls): + for module_name, url in urls.items(): if module_name == OPENERP or not url: continue # OPENERP is special case, handled below, and no URL means local module module_path = modules.get_module_path(module_name, downloaded=True, display_warning=False) @@ -749,7 +750,7 @@ class Module(models.Model): self.update_list() - with_urls = [module_name for module_name, url in pycompat.items(urls) if url] + with_urls = [module_name for module_name, url in urls.items() if url] downloaded = self.search([('name', 'in', with_urls)]) installed = self.search([('id', 'in', downloaded.ids), ('state', '=', 'installed')]) diff --git a/odoo/addons/base/module/report/ir_module_reference_print.py b/odoo/addons/base/module/report/ir_module_reference_print.py index a933cdd9430..03632644159 100644 --- a/odoo/addons/base/module/report/ir_module_reference_print.py +++ b/odoo/addons/base/module/report/ir_module_reference_print.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, models -from odoo.tools import pycompat class IrModelReferenceReport(models.AbstractModel): @@ -23,7 +22,7 @@ class IrModelReferenceReport(models.AbstractModel): if data: res_ids = data.mapped('res_id') fnames = self.env['ir.model.fields'].browse(res_ids).mapped('name') - return sorted(pycompat.items(self.env[model].fields_get(fnames))) + return sorted(self.env[model].fields_get(fnames).items()) return [] @api.model diff --git a/odoo/addons/base/res/ir_property.py b/odoo/addons/base/res/ir_property.py index ec086fb6c61..73f8aafccad 100644 --- a/odoo/addons/base/res/ir_property.py +++ b/odoo/addons/base/res/ir_property.py @@ -217,7 +217,7 @@ class Property(models.Model): prop.write({'value': value}) # create new properties for records that do not have one yet - for ref, id in pycompat.items(refs): + for ref, id in refs.items(): value = clean(values[id]) if value != default_value: self.create({ diff --git a/odoo/addons/base/res/report_paperformat_views.xml b/odoo/addons/base/res/report_paperformat_views.xml index 94f5e94b247..1f48320d088 100644 --- a/odoo/addons/base/res/report_paperformat_views.xml +++ b/odoo/addons/base/res/report_paperformat_views.xml @@ -29,7 +29,7 @@ <field name="header_line" /> <field name="header_spacing" /> <field name="dpi" /> - <field name="report_ids" widget="many2many_tags"/> + <field name="report_ids" widget="many2many_tags" options="{'not_delete': True}"/> </group> </form> </field> diff --git a/odoo/addons/base/res/res_bank.py b/odoo/addons/base/res/res_bank.py index e1f9bb85f00..5a397fcc831 100644 --- a/odoo/addons/base/res/res_bank.py +++ b/odoo/addons/base/res/res_bank.py @@ -2,8 +2,11 @@ import re +import collections + from odoo import api, fields, models from odoo.osv import expression +from odoo.tools import pycompat def sanitize_account_number(acc_number): @@ -88,7 +91,7 @@ class ResPartnerBank(models.Model): if args[pos][0] == 'acc_number': op = args[pos][1] value = args[pos][2] - if hasattr(value, '__iter__'): + if not isinstance(value, pycompat.string_types) and isinstance(value, collections.Iterable): value = [sanitize_account_number(i) for i in value] else: value = sanitize_account_number(value) diff --git a/odoo/addons/base/res/res_company.py b/odoo/addons/base/res/res_company.py index 387ba1afe1c..5a711c6b9c0 100644 --- a/odoo/addons/base/res/res_company.py +++ b/odoo/addons/base/res/res_company.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import os import re from odoo import api, fields, models, tools, _ from odoo.exceptions import ValidationError -from odoo.tools import pycompat class Company(models.Model): @@ -28,7 +27,7 @@ class Company(models.Model): return super(Company, self).copy(default) def _get_logo(self): - return open(os.path.join(tools.config['root_path'], 'addons', 'base', 'res', 'res_company_logo.png'), 'rb') .read().encode('base64') + return base64.b64encode(open(os.path.join(tools.config['root_path'], 'addons', 'base', 'res', 'res_company_logo.png'), 'rb') .read()) @api.model def _get_euro(self): @@ -154,7 +153,7 @@ class Company(models.Model): if self.country_id: res['domain']['state_id'] = [('country_id', '=', self.country_id.id)] values = self.on_change_country(self.country_id.id)['value'] - for fname, value in pycompat.items(values): + for fname, value in values.items(): setattr(self, fname, value) return res diff --git a/odoo/addons/base/res/res_config.py b/odoo/addons/base/res/res_config.py index 64b2ee18f57..7fd426ed2c5 100644 --- a/odoo/addons/base/res/res_config.py +++ b/odoo/addons/base/res/res_config.py @@ -9,7 +9,7 @@ from lxml import etree from odoo import api, models, registry, SUPERUSER_ID, _ from odoo.exceptions import AccessError, RedirectWarning, UserError -from odoo.tools import ustr, pycompat +from odoo.tools import ustr _logger = logging.getLogger(__name__) @@ -250,7 +250,7 @@ class ResConfigInstaller(models.TransientModel, ResConfigModuleInstallationMixin :returns: a list of all installed modules in this installer :rtype: recordset (collection of Record) """ - selectable = [name for name, field in pycompat.items(self._fields) + selectable = [name for name, field in self._fields.items() if field.type == 'boolean'] return self.env['ir.module.module'].search([('name', 'in', selectable), ('state', 'in', ['to install', 'installed', 'to upgrade'])]) @@ -275,7 +275,7 @@ class ResConfigInstaller(models.TransientModel, ResConfigModuleInstallationMixin """ base = set(module_name for installer in self.read() - for module_name, to_install in pycompat.items(installer) + for module_name, to_install in installer.items() if self._fields[module_name].type == 'boolean' and to_install) hooks_results = set() @@ -285,7 +285,7 @@ class ResConfigInstaller(models.TransientModel, ResConfigModuleInstallationMixin hooks_results.update(hook() or set()) additionals = set(module - for requirements, consequences in pycompat.items(self._install_if) + for requirements, consequences in self._install_if.items() if base.issuperset(requirements) for module in consequences) @@ -408,7 +408,7 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin) node.set("on_change", "onchange_module(%s, '%s')" % (field, field)) - ret_val['arch'] = etree.tostring(doc) + ret_val['arch'] = etree.tostring(doc, encoding='unicode') return ret_val @api.multi @@ -445,7 +445,7 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin) ref = self.env.ref defaults, groups, modules, others = [], [], [], [] - for name, field in pycompat.items(self._fields): + for name, field in self._fields.items(): if name.startswith('default_') and hasattr(field, 'default_model'): defaults.append((name, field.default_model, name[8:])) elif name.startswith('group_') and field.type in ('boolean', 'selection') and \ diff --git a/odoo/addons/base/res/res_lang.py b/odoo/addons/base/res/res_lang.py index f4e2d7a1578..1cdc8a5db43 100644 --- a/odoo/addons/base/res/res_lang.py +++ b/odoo/addons/base/res/res_lang.py @@ -8,7 +8,6 @@ import re from operator import itemgetter from odoo import api, fields, models, tools, _ -from odoo.tools import pycompat from odoo.tools.safe_eval import safe_eval from odoo.exceptions import UserError, ValidationError @@ -126,7 +125,7 @@ class Lang(models.Model): # For some locales, nl_langinfo returns a D_FMT/T_FMT that contains # unsupported '%-' patterns, e.g. for cs_CZ format = format.replace('%-', '%') - for pattern, replacement in pycompat.items(tools.DATETIME_FORMATS_MAP): + for pattern, replacement in tools.DATETIME_FORMATS_MAP.items(): format = format.replace(pattern, replacement) return str(format) diff --git a/odoo/addons/base/res/res_partner.py b/odoo/addons/base/res/res_partner.py index c3f52ed6302..6fa0e251311 100644 --- a/odoo/addons/base/res/res_partner.py +++ b/odoo/addons/base/res/res_partner.py @@ -61,7 +61,7 @@ class FormatAddressMixin(models.AbstractModel): except ValueError: return arch address_node.getparent().replace(address_node, sub_view_node) - arch = etree.tostring(doc) + arch = etree.tostring(doc, encoding='unicode') return arch class PartnerCategory(models.Model): @@ -336,7 +336,7 @@ class Partner(models.Model): @api.depends('name', 'email') def _compute_email_formatted(self): for partner in self: - partner.email_formatted = formataddr((partner.name, partner.email)) + partner.email_formatted = formataddr((partner.name or u"False", partner.email or u"False")) @api.depends('is_company') def _compute_company_type(self): @@ -664,7 +664,7 @@ class Partner(models.Model): return partners.id or self.name_create(email)[0] def _get_gravatar_image(self, email): - email_hash = hashlib.md5(email.lower()).hexdigest() + email_hash = hashlib.md5(email.lower().encode('utf-8')).hexdigest() url = "https://www.gravatar.com/avatar/" + email_hash try: res = requests.get(url, params={'d': '404', 's': '128'}, timeout=5) diff --git a/odoo/addons/base/res/res_users.py b/odoo/addons/base/res/res_users.py index 1cc720b3696..f0a98285a85 100644 --- a/odoo/addons/base/res/res_users.py +++ b/odoo/addons/base/res/res_users.py @@ -107,7 +107,7 @@ class Groups(models.Model): return expression.AND(domains) else: return expression.OR(domains) - if isinstance(operand, basestring): + if isinstance(operand, pycompat.string_types): lst = False operand = [operand] where = [] @@ -306,7 +306,7 @@ class Users(models.Model): @api.model def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): - groupby_fields = set([groupby] if isinstance(groupby, basestring) else groupby) + groupby_fields = set([groupby] if isinstance(groupby, pycompat.string_types) else groupby) if groupby_fields.intersection(USER_PRIVATE_FIELDS): raise AccessError(_("Invalid 'group by' parameter")) return super(Users, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) @@ -744,7 +744,7 @@ class GroupsView(models.Model): xml2.append({'class': "o_label_nowrap"}) xml = E.field(E.group(*(xml1), col="2"), E.group(*(xml2), col="4"), name="groups_id", position="replace") xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS")) - xml_content = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding="utf-8") + xml_content = etree.tostring(xml, pretty_print=True, encoding="unicode") view.with_context(lang=None).write({'arch': xml_content, 'arch_fs': False}) def get_application_groups(self, domain): @@ -766,7 +766,7 @@ class GroupsView(models.Model): # determine sequence order: a group appears after its implied groups order = {g: len(g.trans_implied_ids & gs) for g in gs} # check whether order is total, i.e., sequence orders are distinct - if len(set(pycompat.values(order))) == len(gs): + if len(set(order.values())) == len(gs): return (app, 'selection', gs.sorted(key=order.get)) else: return (app, 'boolean', gs) @@ -780,7 +780,7 @@ class GroupsView(models.Model): others += g # build the result res = [] - for app, gs in sorted(pycompat.items(by_app), key=lambda it: it[0].sequence or 0): + for app, gs in sorted(by_app.items(), key=lambda it: it[0].sequence or 0): res.append(linearize(app, gs)) if others: res.append((self.env['ir.module.category'], 'boolean', others)) @@ -820,7 +820,7 @@ class UsersView(models.Model): add, rem = [], [] values1 = {} - for key, val in pycompat.items(values): + for key, val in values.items(): if is_boolean_group(key): (add if val else rem).append(get_boolean_group(key)) elif is_selection_groups(key): diff --git a/odoo/addons/base/static/img/public_user-image.png b/odoo/addons/base/static/img/public_user-image.png Binary files differindex e333176e3e4..e69de29bb2d 100644 --- a/odoo/addons/base/static/img/public_user-image.png +++ b/odoo/addons/base/static/img/public_user-image.png diff --git a/odoo/addons/base/tests/test_api.py b/odoo/addons/base/tests/test_api.py index ad2dd1f0e85..96fba1179b7 100644 --- a/odoo/addons/base/tests/test_api.py +++ b/odoo/addons/base/tests/test_api.py @@ -98,7 +98,7 @@ class TestAPI(common.TransactionCase): self.assertIsRecordset(user.groups_id, 'res.groups') partners = self.env['res.partner'].search([]) - for name, field in pycompat.items(partners._fields): + for name, field in partners._fields.items(): if field.type == 'many2one': for p in partners: self.assertIsRecord(p[name], field.comodel_name) @@ -296,7 +296,7 @@ class TestAPI(common.TransactionCase): self.assertItemsEqual(partners.ids, country_id_cache) # partners' countries are ready for prefetching - country_ids = set(cid for cids in pycompat.values(country_id_cache) for cid in cids) + country_ids = set(cid for cids in country_id_cache.values() for cid in cids) self.assertTrue(len(country_ids) > 1) self.assertItemsEqual(country_ids, partners._prefetch['res.country']) diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py index 2ee7429f90a..ded008afa07 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -5,7 +5,7 @@ import psycopg2 from odoo.models import BaseModel from odoo.tests.common import TransactionCase -from odoo.tools import mute_logger, pycompat +from odoo.tools import mute_logger from odoo.osv import expression @@ -85,10 +85,10 @@ class TestExpression(TransactionCase): 'b ab': [cids['B'], cids['AB']], } pids = {} - for name, cat_ids in pycompat.items(partners_config): + for name, cat_ids in partners_config.items(): pids[name] = partners.create({'name': name, 'category_id': [(6, 0, cat_ids)]}).id - base_domain = [('id', 'in', list(pycompat.values(pids)))] + base_domain = [('id', 'in', list(pids.values()))] def test(op, value, expected): found_ids = partners.search(base_domain + [('category_id', op, value)]).ids diff --git a/odoo/addons/base/tests/test_func.py b/odoo/addons/base/tests/test_func.py index ac394cb9b79..66ad02f4e9d 100644 --- a/odoo/addons/base/tests/test_func.py +++ b/odoo/addons/base/tests/test_func.py @@ -4,7 +4,7 @@ import functools import unittest -from odoo.tools import frozendict +from odoo.tools import frozendict, pycompat from odoo.tools.func import compose @@ -16,7 +16,7 @@ class TestCompose(unittest.TestCase): def test_decorator(self): """ ensure compose() can be partially applied as a decorator """ - @functools.partial(compose, unicode) + @functools.partial(compose, pycompat.text_type) def mul(a, b): return a * b diff --git a/odoo/addons/base/tests/test_ir_actions.py b/odoo/addons/base/tests/test_ir_actions.py index cb48752a960..a2663964f59 100644 --- a/odoo/addons/base/tests/test_ir_actions.py +++ b/odoo/addons/base/tests/test_ir_actions.py @@ -64,7 +64,7 @@ class TestServerActions(TestServerActionsBase): # Test: ir_values created ir_values = self.env['ir.values'].search([('name', '=', 'Run TestAction')]) self.assertEqual(len(ir_values), 1, 'ir_actions_server: create_action should have created an entry in ir_values') - self.assertEqual(ir_values.value, 'ir.actions.server,%s' % self.action.id, 'ir_actions_server: created ir_values should reference the server action') + self.assertEqual(ir_values.value, b'ir.actions.server,%d' % self.action.id, 'ir_actions_server: created ir_values should reference the server action') self.assertEqual(ir_values.model, 'res.partner', 'ir_actions_server: created ir_values should be linked to the action base model') # Do: remove contextual action diff --git a/odoo/addons/base/tests/test_ir_attachment.py b/odoo/addons/base/tests/test_ir_attachment.py index 284d8c71da2..8c3769b1178 100644 --- a/odoo/addons/base/tests/test_ir_attachment.py +++ b/odoo/addons/base/tests/test_ir_attachment.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import hashlib import os @@ -16,14 +16,14 @@ class TestIrAttachment(TransactionCase): self.filestore = self.Attachment._filestore() # Blob1 - self.blob1 = 'blob1' - self.blob1_b64 = self.blob1.encode('base64') + self.blob1 = b'blob1' + self.blob1_b64 = base64.b64encode(self.blob1) blob1_hash = hashlib.sha1(self.blob1).hexdigest() self.blob1_fname = blob1_hash[:HASH_SPLIT] + '/' + blob1_hash # Blob2 - self.blob2 = 'blob2' - self.blob2_b64 = self.blob2.encode('base64') + self.blob2 = b'blob2' + self.blob2_b64 = base64.b64encode(self.blob2) def test_01_store_in_db(self): # force storing in database diff --git a/odoo/addons/base/tests/test_ir_http.py b/odoo/addons/base/tests/test_ir_http.py index ef50cdb5a3f..420a16004ce 100644 --- a/odoo/addons/base/tests/test_ir_http.py +++ b/odoo/addons/base/tests/test_ir_http.py @@ -3,7 +3,7 @@ from odoo.tests import common import odoo -GIF = "R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" +GIF = b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" class test_ir_http_mimetype(common.TransactionCase): diff --git a/odoo/addons/base/tests/test_mail.py b/odoo/addons/base/tests/test_mail.py index 1a30ef4690b..84cf892ea30 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -171,14 +171,14 @@ class TestSanitizer(unittest.TestCase): for ext in test_mail_examples.QUOTE_BLOCKQUOTE_IN: self.assertIn(ext, html) for ext in test_mail_examples.QUOTE_BLOCKQUOTE_OUT: - self.assertIn('<span data-o-mail-quote="1">%s' % misc.html_escape(ext.decode('utf-8')), html) + self.assertIn(u'<span data-o-mail-quote="1">%s' % misc.html_escape(ext), html) def test_quote_thunderbird(self): html = html_sanitize(test_mail_examples.QUOTE_THUNDERBIRD_1) for ext in test_mail_examples.QUOTE_THUNDERBIRD_1_IN: self.assertIn(ext, html) for ext in test_mail_examples.QUOTE_THUNDERBIRD_1_OUT: - self.assertIn('<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext.decode('utf-8')), html) + self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) def test_quote_hotmail_html(self): html = html_sanitize(test_mail_examples.QUOTE_HOTMAIL_HTML) @@ -225,7 +225,7 @@ class TestSanitizer(unittest.TestCase): for text in in_lst: self.assertIn(text, new_html) for text in out_lst: - self.assertIn('<span data-o-mail-quote="1">%s</span>' % misc.html_escape(text), new_html) + self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(text), new_html) def test_quote_signature(self): test_data = [ @@ -244,27 +244,27 @@ class TestSanitizer(unittest.TestCase): for ext in test_mail_examples.GMAIL_1_IN: self.assertIn(ext, html) for ext in test_mail_examples.GMAIL_1_OUT: - self.assertIn('<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) + self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) def test_quote_text(self): html = html_sanitize(test_mail_examples.TEXT_1) for ext in test_mail_examples.TEXT_1_IN: self.assertIn(ext, html) for ext in test_mail_examples.TEXT_1_OUT: - self.assertIn('<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) + self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) html = html_sanitize(test_mail_examples.TEXT_2) for ext in test_mail_examples.TEXT_2_IN: self.assertIn(ext, html) for ext in test_mail_examples.TEXT_2_OUT: - self.assertIn('<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) + self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) def test_quote_bugs(self): html = html_sanitize(test_mail_examples.BUG1) for ext in test_mail_examples.BUG_1_IN: self.assertIn(ext, html) for ext in test_mail_examples.BUG_1_OUT: - self.assertIn('<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext.decode('utf-8')), html) + self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) def test_misc(self): # False / void should not crash diff --git a/odoo/addons/base/tests/test_mail_examples.py b/odoo/addons/base/tests/test_mail_examples.py index dfecfe7c0d4..4f3b5b48300 100644 --- a/odoo/addons/base/tests/test_mail_examples.py +++ b/odoo/addons/base/tests/test_mail_examples.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -MISC_HTML_SOURCE = """ +MISC_HTML_SOURCE = u""" <font size="2" style="color: rgb(31, 31, 31); font-family: monospace; font-variant: normal; line-height: normal; ">test1</font> <div style="color: rgb(31, 31, 31); font-family: monospace; font-variant: normal; line-height: normal; font-size: 12px; font-style: normal; "> <b>test2</b></div><div style="color: rgb(31, 31, 31); font-family: monospace; font-variant: normal; line-height: normal; font-size: 12px; "> @@ -19,7 +19,7 @@ test12</font></div><div><font color="#1f1f1f" face="monospace" size="2"><br></fo <a href="javascript:alert('malicious code')">test link</a> """ -EDI_LIKE_HTML_SOURCE = """<div style="font-family: 'Lucida Grande', Ubuntu, Arial, Verdana, sans-serif; font-size: 12px; color: rgb(34, 34, 34); background-color: #FFF; "> +EDI_LIKE_HTML_SOURCE = u"""<div style="font-family: 'Lucida Grande', Ubuntu, Arial, Verdana, sans-serif; font-size: 12px; color: rgb(34, 34, 34); background-color: #FFF; "> <p>Hello ${object.partner_id.name},</p> <p>A new invoice is available for you: </p> <p style="border-left: 1px solid #8e0000; margin-left: 30px;"> @@ -63,7 +63,7 @@ EDI_LIKE_HTML_SOURCE = """<div style="font-family: 'Lucida Grande', Ubuntu, Aria # QUOTES -QUOTE_BLOCKQUOTE = """<html> +QUOTE_BLOCKQUOTE = u"""<html> <head> <meta content="text/html; charset=utf-8" http-equiv="Content-Type"> </head> @@ -103,14 +103,14 @@ web: <a class="moz-txt-link-freetext" href="https://opener.am">https://opener.am </body> </html>""" -QUOTE_BLOCKQUOTE_IN = ["""<blockquote cite="mid:CAEJSRZvWvud8c6Qp=wfNG6O1+wK3i_jb33qVrF7XyrgPNjnyUA@mail.gmail.com" type="cite" data-o-mail-quote-node="1" data-o-mail-quote="1">"""] -QUOTE_BLOCKQUOTE_OUT = ["""-- +QUOTE_BLOCKQUOTE_IN = [u"""<blockquote cite="mid:CAEJSRZvWvud8c6Qp=wfNG6O1+wK3i_jb33qVrF7XyrgPNjnyUA@mail.gmail.com" type="cite" data-o-mail-quote-node="1" data-o-mail-quote="1">"""] +QUOTE_BLOCKQUOTE_OUT = [u"""-- Opener B.V. - Business solutions driven by open source collaboration Stefan Rijnhart - Consultant/developer"""] -QUOTE_THUNDERBIRD_HTML = """<html> +QUOTE_THUNDERBIRD_HTML = u"""<html> <head> <meta content="text/html; charset=utf-8" http-equiv="Content-Type"> </head> @@ -137,13 +137,13 @@ Raoul Poilvache </html>""" -QUOTE_THUNDERBIRD_HTML_IN = ["""<blockquote cite="mid:CAP76m_WWFH2KVrbjOxbaozvkmbzZYLWJnQ0n0sy9XpGaCWRf1g@mail.gmail.com" type="cite" data-o-mail-quote-node="1" data-o-mail-quote="1">"""] -QUOTE_THUNDERBIRD_HTML_OUT = ["""<pre class="moz-signature" cols="72"><span data-o-mail-quote="1">-- +QUOTE_THUNDERBIRD_HTML_IN = [u"""<blockquote cite="mid:CAP76m_WWFH2KVrbjOxbaozvkmbzZYLWJnQ0n0sy9XpGaCWRf1g@mail.gmail.com" type="cite" data-o-mail-quote-node="1" data-o-mail-quote="1">"""] +QUOTE_THUNDERBIRD_HTML_OUT = [u"""<pre class="moz-signature" cols="72"><span data-o-mail-quote="1">-- Raoul Poilvache </span></pre>"""] -QUOTE_HOTMAIL_HTML = """ +QUOTE_HOTMAIL_HTML = u""" <html> <head> <style><!-- @@ -170,12 +170,12 @@ To: tartelette@example.com grosbedon@example.com<br><br> </div>-- <br><div class="ecxgmail_signature"> Raoul Poilvache</div> </div></div></div></body></html>""" -QUOTE_HOTMAIL_HTML_IN = ["""I don't like that.<br><br>"""] +QUOTE_HOTMAIL_HTML_IN = [u"""I don't like that.<br><br>"""] QUOTE_HOTMAIL_HTML_OUT = [ - """<hr id="stopSpelling" data-o-mail-quote="1">""", - """<div dir="ltr" data-o-mail-quote="1"><b data-o-mail-quote="1"><i data-o-mail-quote="1">Test reply. The suite.</i></b>"""] + u"""<hr id="stopSpelling" data-o-mail-quote="1">""", + u"""<div dir="ltr" data-o-mail-quote="1"><b data-o-mail-quote="1"><i data-o-mail-quote="1">Test reply. The suite.</i></b>"""] -QUOTE_THUNDERBIRD_1 = """<div>On 11/08/2012 05:29 PM, +QUOTE_THUNDERBIRD_1 = u"""<div>On 11/08/2012 05:29 PM, <a href="mailto:dummy@example.com">dummy@example.com</a> wrote:<br></div> <blockquote> <div>I contact you about our meeting for tomorrow. Here is the @@ -203,15 +203,15 @@ Raoul Grosbedonnée </pre>""" QUOTE_THUNDERBIRD_1_IN = [ - '<a href="mailto:dummy@example.com">dummy@example.com</a> ', - '<blockquote data-o-mail-quote-node="1" data-o-mail-quote="1">', - 'Ok for me. I am replying directly below your mail, using Thunderbird, with a signature.'] -QUOTE_THUNDERBIRD_1_OUT = ["""-- + u'<a href="mailto:dummy@example.com">dummy@example.com</a> ', + u'<blockquote data-o-mail-quote-node="1" data-o-mail-quote="1">', + u'Ok for me. I am replying directly below your mail, using Thunderbird, with a signature.'] +QUOTE_THUNDERBIRD_1_OUT = [u"""-- Raoul Grosbedonnรฉe """] -TEXT_1 = """I contact you about our meeting tomorrow. Here is the schedule I propose: +TEXT_1 = u"""I contact you about our meeting tomorrow. Here is the schedule I propose: 9 AM: brainstorming about our new amazing business app 9.45 AM: summary 10 AM: meeting with Ignasse to present our app @@ -219,16 +219,16 @@ Is everything ok for you ? -- MySignature""" -TEXT_1_IN = ["""I contact you about our meeting tomorrow. Here is the schedule I propose: +TEXT_1_IN = [u"""I contact you about our meeting tomorrow. Here is the schedule I propose: 9 AM: brainstorming about our new amazing business app 9.45 AM: summary 10 AM: meeting with Ignasse to present our app Is everything ok for you ?"""] -TEXT_1_OUT = [""" +TEXT_1_OUT = [u""" -- MySignature"""] -TEXT_2 = """Salut Raoul! +TEXT_2 = u"""Salut Raoul! Le 28 oct. 2012 ร 00:02, Raoul Grosbedon a รฉcrit : > I contact you about our meeting tomorrow. Here is the schedule I propose: (quote) @@ -253,8 +253,8 @@ Bert TARTOPOILS bert.tartopoils@miam.miam """ -TEXT_2_IN = ["Salut Raoul!", "Of course. This seems viable."] -TEXT_2_OUT = [""" +TEXT_2_IN = [u"Salut Raoul!", "Of course. This seems viable."] +TEXT_2_OUT = [u""" > I contact you about our meeting tomorrow. Here is the schedule I propose: (quote)""", """ > 2012/10/27 Bert Tartopoils : @@ -272,17 +272,17 @@ TEXT_2_OUT = [""" # MISC -GMAIL_1 = """Hello,<div><br></div><div>Ok for me. I am replying directly in gmail, without signature.</div><div><br></div><div>Kind regards,</div><div><br></div><div>Demo.<br><br><div>On Thu, Nov 8, 2012 at 5:29 PM, <span><<a href="mailto:dummy@example.com">dummy@example.com</a>></span> wrote:<br><blockquote><div>I contact you about our meeting for tomorrow. Here is the schedule I propose:</div><div><ul><li>9 AM: brainstorming about our new amazing business app</span></li></li> +GMAIL_1 = u"""Hello,<div><br></div><div>Ok for me. I am replying directly in gmail, without signature.</div><div><br></div><div>Kind regards,</div><div><br></div><div>Demo.<br><br><div>On Thu, Nov 8, 2012 at 5:29 PM, <span><<a href="mailto:dummy@example.com">dummy@example.com</a>></span> wrote:<br><blockquote><div>I contact you about our meeting for tomorrow. Here is the schedule I propose:</div><div><ul><li>9 AM: brainstorming about our new amazing business app</span></li></li> <li>9.45 AM: summary</li><li>10 AM: meeting with Fabien to present our app</li></ul></div><div>Is everything ok for you ?</div> <div><p>-- <br>Administrator</p></div> <div><p>Log in our portal at: <a href="http://localhost:8069#action=login&db=mail_1&login=demo">http://localhost:8069#action=login&db=mail_1&login=demo</a></p></div> </blockquote></div><br></div>""" -GMAIL_1_IN = ['Ok for me. I am replying directly in gmail, without signature.', '<blockquote data-o-mail-quote-node="1" data-o-mail-quote="1">'] +GMAIL_1_IN = [u'Ok for me. I am replying directly in gmail, without signature.', '<blockquote data-o-mail-quote-node="1" data-o-mail-quote="1">'] GMAIL_1_OUT = [] -HOTMAIL_1 = """<div> +HOTMAIL_1 = u"""<div> <div dir="ltr"><br> I have an amazing company, i'm learning OpenERP, it is a small company yet, but plannig to grow up quickly. <br><br>Kindest regards,<br>xxx<br> @@ -309,19 +309,19 @@ HOTMAIL_1 = """<div> </div> </div> </div>""" -HOTMAIL_1_IN = ["""<div dir="ltr"><br> +HOTMAIL_1_IN = [u"""<div dir="ltr"><br> I have an amazing company, i'm learning OpenERP, it is a small company yet, but plannig to grow up quickly. <br><br>Kindest regards,<br>xxx<br>"""] HOTMAIL_1_OUT = [ - """<hr id="stopSpelling" data-o-mail-quote="1">""", - """<pre data-o-mail-quote="1"> + u"""<hr id="stopSpelling" data-o-mail-quote="1">""", + u"""<pre data-o-mail-quote="1"> <a href="http://openerp.com" target="_blank" data-o-mail-quote="1">http://openerp.com</a> Belgium: +32.81.81.37.00 U.S.: +1 (650) 307-6736 India: +91 (79) 40 500 100 </pre>"""] -MSOFFICE_1 = """ +MSOFFICE_1 = u""" <div> <div class="WordSection1"> <p class="MsoNormal"> @@ -423,8 +423,8 @@ MSOFFICE_1 = """ </div> </div>""" -MSOFFICE_1_IN = ['Our requirements are simple. Just looking to replace some spreadsheets for tracking quotes and possibly using the timecard module.'] -MSOFFICE_1_OUT = ['I noticed you recently downloaded OpenERP.', 'Uou mentioned you wish to use OpenERP in your own company.', 'Belgium: +32.81.81.37.00'] +MSOFFICE_1_IN = [u'Our requirements are simple. Just looking to replace some spreadsheets for tracking quotes and possibly using the timecard module.'] +MSOFFICE_1_OUT = [u'I noticed you recently downloaded OpenERP.', 'Uou mentioned you wish to use OpenERP in your own company.', 'Belgium: +32.81.81.37.00'] # ------------------------------------------------------------ @@ -432,7 +432,7 @@ MSOFFICE_1_OUT = ['I noticed you recently downloaded OpenERP.', 'Uou mentioned y # ------------------------------------------------------------ # bug: read more not apparent, strange message in read more span -BUG1 = """<pre>Hi Migration Team, +BUG1 = u"""<pre>Hi Migration Team, Paragraph 1, blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah blah @@ -472,10 +472,10 @@ Tel: +32.81.81.37.00 Web: http://www.openerp.com</pre>""" BUG_1_IN = [ - 'Hi Migration Team', - 'Paragraph 1' + u'Hi Migration Team', + u'Paragraph 1' ] -BUG_1_OUT = [""" +BUG_1_OUT = [u""" -- Olivier Laurent Migration Manager @@ -486,7 +486,7 @@ Tel: +32.81.81.37.00 Web: http://www.openerp.com"""] -REMOVE_CLASS = """ +REMOVE_CLASS = u""" <div style="FONT-SIZE: 12pt; FONT-FAMILY: 'Times New Roman'; COLOR: #000000"> <div>Hello</div> <div>I have just installed Odoo 9 and I've got the following error:</div> @@ -510,9 +510,9 @@ REMOVE_CLASS = """ </div> """ REMOVE_CLASS_IN = [ - '<div style="font-size: 12pt; font-family: \'Times New Roman\'; color: #000000">', - 'An error occured in a modal and I will send you back the html to try opening one on your end'] + u'<div style="font-size: 12pt; font-family: \'Times New Roman\'; color: #000000">', + u'An error occured in a modal and I will send you back the html to try opening one on your end'] REMOVE_CLASS_OUT = [ - '<div class="modal-backdrop in">', - '<div class="modal-content openerp">', - '<div class="modal-header">'] + u'<div class="modal-backdrop in">', + u'<div class="modal-content openerp">', + u'<div class="modal-header">'] diff --git a/odoo/addons/base/tests/test_mimetypes.py b/odoo/addons/base/tests/test_mimetypes.py index 2ba8cc05972..b2de483f7c6 100644 --- a/odoo/addons/base/tests/test_mimetypes.py +++ b/odoo/addons/base/tests/test_mimetypes.py @@ -3,9 +3,9 @@ import unittest from odoo.tools.mimetypes import guess_mimetype -PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC' -GIF = "R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" -BMP = """Qk1+AAAAAAAAAHoAAABsAAAAAQAAAAEAAAABABgAAAAAAAQAAAATCwAAEwsAAAAAAAAAAAAAQkdScwAAAAAAAAAAAA +PNG = b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC' +GIF = b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" +BMP = b"""Qk1+AAAAAAAAAHoAAABsAAAAAQAAAAEAAAABABgAAAAAAAQAAAATCwAAEwsAAAAAAAAAAAAAQkdScwAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAD///8A""" JPG = """/9j/4AAQSkZJRgABAQEASABIAAD//gATQ3JlYXRlZCB3aXRoIEdJTVD/2wBDAP //////////////////////////////////////////////////////////////////////////////////////2wBDAf/////// @@ -20,18 +20,18 @@ AE/IX//2gAMAwEAAgADAAAAEB//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/EH//xAAUEQEAA class test_guess_mimetype(unittest.TestCase): def test_default_mimetype_empty(self): - mimetype = guess_mimetype('') + mimetype = guess_mimetype(b'') # odoo implementation returns application/octet-stream by default # if available, python-magic returns application/x-empty self.assertIn(mimetype, ('application/octet-stream', 'application/x-empty')) def test_default_mimetype(self): - mimetype = guess_mimetype('', default='test') + mimetype = guess_mimetype(b'', default='test') # if available, python-magic returns application/x-empty self.assertIn(mimetype, ('test', 'application/x-empty')) def test_mimetype_octet_stream(self): - mimetype = guess_mimetype('\0') + mimetype = guess_mimetype(b'\0') self.assertEqual(mimetype, 'application/octet-stream') def test_mimetype_png(self): diff --git a/odoo/addons/base/tests/test_orm.py b/odoo/addons/base/tests/test_orm.py index bf95bee08f7..c24758dc9bf 100644 --- a/odoo/addons/base/tests/test_orm.py +++ b/odoo/addons/base/tests/test_orm.py @@ -141,7 +141,7 @@ class TestORM(TransactionCase): partner_ids_by_year = defaultdict(list) partners = self.env['res.partner'] - for name, date in pycompat.items(partners_data): + for name, date in partners_data.items(): p = partners.create(dict(name=name, date=date)) partner_ids.append(p.id) partner_ids_by_day[date].append(p.id) diff --git a/odoo/addons/base/tests/test_qweb.py b/odoo/addons/base/tests/test_qweb.py index 7a8f921af55..ea4ef849b75 100644 --- a/odoo/addons/base/tests/test_qweb.py +++ b/odoo/addons/base/tests/test_qweb.py @@ -11,7 +11,7 @@ from lxml import etree from odoo.modules import get_module_resource from odoo.tests.common import TransactionCase from odoo.addons.base.ir.ir_qweb import QWebException -from odoo.tools import pycompat, misc, ustr +from odoo.tools import misc, ustr class TestQWebTField(TransactionCase): @@ -406,7 +406,7 @@ class TestQWebNS(TransactionCase): ('cac', 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'), ('cbc', 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'), } - self.assertEqual(set(pycompat.items(result_etree.nsmap)), expected_ns) + self.assertEqual(set(result_etree.nsmap.items()), expected_ns) # check that the t-call did its work cac_lines = result_etree.findall('.//cac:line', namespaces={'cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'}) @@ -484,7 +484,7 @@ class FileSystemLoader(object): if node.get('t-name') == name: root = etree.Element('templates') root.append(deepcopy(node)) - arch = etree.tostring(root, encoding='utf-8', xml_declaration=True) + arch = etree.tostring(root, encoding='unicode') return arch diff --git a/odoo/addons/base/tests/test_res_config.py b/odoo/addons/base/tests/test_res_config.py index 0be7dcc7c8d..1e30e1f1a24 100644 --- a/odoo/addons/base/tests/test_res_config.py +++ b/odoo/addons/base/tests/test_res_config.py @@ -46,7 +46,7 @@ class TestResConfig(TransactionCase): # Check types self.assertIsInstance(res, tuple) self.assertEqual(len(res), 2, "The result should contain 2 elements") - self.assertIsInstance(res[0], basestring) + self.assertIsInstance(res[0], pycompat.string_types) self.assertIsInstance(res[1], pycompat.integer_types) # Check returned values @@ -58,7 +58,7 @@ class TestResConfig(TransactionCase): res = self.ResConfig.get_option_name(self.full_field_name) # Check type - self.assertIsInstance(res, basestring) + self.assertIsInstance(res, pycompat.string_types) # Check returned value self.assertEqual(res, self.expected_name) diff --git a/odoo/addons/base/tests/test_search.py b/odoo/addons/base/tests/test_search.py index 97670fdd805..1c2e60291d6 100644 --- a/odoo/addons/base/tests/test_search.py +++ b/odoo/addons/base/tests/test_search.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.tests.common import TransactionCase -from odoo.tools import pycompat class test_search(TransactionCase): @@ -108,7 +107,7 @@ class test_search(TransactionCase): user_ids[u] = Users.create({'name': u, 'login': u}).id cron_ids[u] = Cron.create({'name': u, 'model_id': self.env.ref('base.model_res_partner').id, 'user_id': user_ids[u]}).id - ids = Cron.search([('id', 'in', list(pycompat.values(cron_ids)))], order='user_id').ids + ids = Cron.search([('id', 'in', list(cron_ids.values()))], order='user_id').ids expected_ids = [cron_ids[l] for l in 'ABC'] self.assertEqual(ids, expected_ids) @@ -128,7 +127,7 @@ class test_search(TransactionCase): create('F', parent_id=cat_ids['D']) expected_ids = [cat_ids[x] for x in 'ADEFBC'] - found_ids = Cats.search([('id', 'in', list(pycompat.values(cat_ids)))]).ids + found_ids = Cats.search([('id', 'in', list(cat_ids.values()))]).ids self.assertEqual(found_ids, expected_ids) def test_13_m2o_order_loop_multi(self): diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py index 6d54a0b7886..19405c3145d 100644 --- a/odoo/addons/base/tests/test_translate.py +++ b/odoo/addons/base/tests/test_translate.py @@ -9,6 +9,8 @@ from odoo.tests.common import TransactionCase class TranslationToolsTestCase(unittest.TestCase): + def assertItemsEqual(self, a, b, msg=None): + self.assertEqual(sorted(a), sorted(b), msg) def test_quote_unquote(self): diff --git a/odoo/addons/base/tests/test_view_validation.py b/odoo/addons/base/tests/test_view_validation.py index a408b993605..ff8ce85267a 100644 --- a/odoo/addons/base/tests/test_view_validation.py +++ b/odoo/addons/base/tests/test_view_validation.py @@ -11,7 +11,7 @@ from odoo.tools.view_validation import ( valid_field_in_graph, valid_field_in_tree, ) -invalid_form = etree.parse(io.BytesIO('''\ +invalid_form = etree.parse(io.BytesIO(b'''\ <form> <label></label> <group> @@ -34,7 +34,7 @@ invalid_form = etree.parse(io.BytesIO('''\ </form> ''')).getroot() -valid_form = etree.parse(io.BytesIO('''\ +valid_form = etree.parse(io.BytesIO(b'''\ <form string=""> <field name=""></field> <field name=""></field> @@ -54,7 +54,7 @@ valid_form = etree.parse(io.BytesIO('''\ </form> ''')).getroot() -invalid_graph = etree.parse(io.BytesIO('''\ +invalid_graph = etree.parse(io.BytesIO(b'''\ <graph> <label/> <group> @@ -66,14 +66,14 @@ invalid_graph = etree.parse(io.BytesIO('''\ </graph> ''')).getroot() -valid_graph = etree.parse(io.BytesIO('''\ +valid_graph = etree.parse(io.BytesIO(b'''\ <graph string=""> <field name=""></field> <field name=""></field> </graph> ''')).getroot() -invalid_tree = etree.parse(io.BytesIO('''\ +invalid_tree = etree.parse(io.BytesIO(b'''\ <tree> <group> <div> @@ -84,7 +84,7 @@ invalid_tree = etree.parse(io.BytesIO('''\ </tree> ''')).getroot() -valid_tree = etree.parse(io.BytesIO('''\ +valid_tree = etree.parse(io.BytesIO(b'''\ <tree string=""> <field name=""></field> <field name=""></field> diff --git a/odoo/addons/base/tests/test_views.py b/odoo/addons/base/tests/test_views.py index 3f007b5f71b..aba01b0aa32 100644 --- a/odoo/addons/base/tests/test_views.py +++ b/odoo/addons/base/tests/test_views.py @@ -144,7 +144,7 @@ class TestViewInheritance(ViewCase): E.attribute(name, name='string'), position='attributes' ) - return etree.tostring(element) + return etree.tostring(element, encoding='unicode') def makeView(self, name, parent=None, arch=None): """ Generates a basic ir.ui.view with the provided name, parent and arch. diff --git a/odoo/addons/test_converter/tests/test_html.py b/odoo/addons/test_converter/tests/test_html.py index c9bb6769d08..e915785e333 100644 --- a/odoo/addons/test_converter/tests/test_html.py +++ b/odoo/addons/test_converter/tests/test_html.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import datetime import os @@ -123,7 +123,7 @@ class TestCurrencyExport(TestExport): converted, u'<span class="oe_currency_value">\u20110.12</span>' u'\N{NO-BREAK SPACE}{symbol}'.format( obj=obj, - symbol=currency.symbol.encode('utf-8') + symbol=currency.symbol ),) def test_currency_pre(self): @@ -136,9 +136,9 @@ class TestCurrencyExport(TestExport): self.assertEqual( converted, u'{symbol}\N{NO-BREAK SPACE}' - '<span class="oe_currency_value">0.12</span>'.format( + u'<span class="oe_currency_value">0.12</span>'.format( obj=obj, - symbol=currency.symbol.encode('utf-8') + symbol=currency.symbol ),) def test_currency_precision(self): @@ -151,10 +151,10 @@ class TestCurrencyExport(TestExport): self.assertEqual( converted, - '<span class="oe_currency_value">0.12</span>' + u'<span class="oe_currency_value">0.12</span>' u'\N{NO-BREAK SPACE}{symbol}'.format( obj=obj, - symbol=currency.symbol.encode('utf-8') + symbol=currency.symbol ),) @@ -213,25 +213,23 @@ class TestBinaryExport(TestBasicExport): with open(os.path.join(directory, 'test_vectors', 'image'), 'rb') as f: content = f.read() - encoded_content = content.encode('base64') + encoded_content = base64.b64encode(content) value = converter.value_to_html(encoded_content, {}) self.assertEqual( - value, '<img src="data:image/jpeg;base64,%s">' % ( - encoded_content - )) + value, u'<img src="data:image/jpeg;base64,%s">' % encoded_content.decode('ascii')) with open(os.path.join(directory, 'test_vectors', 'pdf'), 'rb') as f: content = f.read() with self.assertRaises(ValueError): - converter.value_to_html(content.encode('base64'), {}) + converter.value_to_html(base64.b64encode(content), {}) with open(os.path.join(directory, 'test_vectors', 'pptx'), 'rb') as f: content = f.read() with self.assertRaises(ValueError): - converter.value_to_html(content.encode('base64'), {}) + converter.value_to_html(base64.b64encode(content), {}) class TestSelectionExport(TestBasicExport): diff --git a/odoo/addons/test_impex/models.py b/odoo/addons/test_impex/models.py index f1e10b10abf..027739b2538 100644 --- a/odoo/addons/test_impex/models.py +++ b/odoo/addons/test_impex/models.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models +from odoo.tools import pycompat def selection_fn(model): @@ -48,7 +49,7 @@ for name, field in MODELS: @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): - if isinstance(name, basestring) and name.split(':')[0] == self._name: + if isinstance(name, pycompat.string_types) and name.split(':')[0] == self._name: records = self.search([('value', operator, int(name.split(':')[1]))]) return records.name_get() else: @@ -70,7 +71,7 @@ class One2ManyChild(models.Model): @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): - if isinstance(name, basestring) and name.split(':')[0] == self._name: + if isinstance(name, pycompat.string_types) and name.split(':')[0] == self._name: records = self.search([('value', operator, int(name.split(':')[1]))]) return records.name_get() else: @@ -124,7 +125,7 @@ class Many2ManyChild(models.Model): @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): - if isinstance(name, basestring) and name.split(':')[0] == self._name: + if isinstance(name, pycompat.string_types) and name.split(':')[0] == self._name: records = self.search([('value', operator, int(name.split(':')[1]))]) return records.name_get() else: diff --git a/odoo/addons/test_impex/tests/test_export.py b/odoo/addons/test_impex/tests/test_export.py index 72a3b2a6f75..9c7311319e8 100644 --- a/odoo/addons/test_impex/tests/test_export.py +++ b/odoo/addons/test_impex/tests/test_export.py @@ -69,7 +69,7 @@ class test_integer_field(CreatorCase): def test_huge(self): self.assertEqual( self.export(2**31-1), - [[unicode(2**31-1)]]) + [[pycompat.text_type(2**31-1)]]) class test_float_field(CreatorCase): @@ -319,7 +319,7 @@ class test_m2o(CreatorCase): record = self.env['export.integer'].create({'value': 42}) self.assertEqual( self.export(record.id, fields=['value/.id', 'value/value']), - [[unicode(record.id), u'42']]) + [[pycompat.text_type(record.id), u'42']]) def test_external_id(self): record = self.env['export.integer'].create({'value': 42}) diff --git a/odoo/addons/test_impex/tests/test_load.py b/odoo/addons/test_impex/tests/test_load.py index d8c7e3d87d5..15331174d53 100644 --- a/odoo/addons/test_impex/tests/test_load.py +++ b/odoo/addons/test_impex/tests/test_load.py @@ -965,7 +965,7 @@ class test_o2m_multiple(ImporterCase): class test_realworld(common.TransactionCase): def test_bigfile(self): - data = json.loads(pkgutil.get_data(self.__module__, 'contacts_big.json')) + data = json.loads(pkgutil.get_data(self.__module__, 'contacts_big.json').decode('utf-8')) result = self.env['res.partner'].load(['name', 'mobile', 'email', 'image'], data) self.assertFalse(result['messages']) self.assertEqual(len(result['ids']), len(data)) @@ -973,7 +973,7 @@ class test_realworld(common.TransactionCase): def test_backlink(self): fnames = ["name", "type", "street", "city", "country_id", "category_id", "supplier", "customer", "is_company", "parent_id"] - data = json.loads(pkgutil.get_data(self.__module__, 'contacts.json')) + data = json.loads(pkgutil.get_data(self.__module__, 'contacts.json').decode('utf-8')) result = self.env['res.partner'].load(fnames, data) self.assertFalse(result['messages']) self.assertEqual(len(result['ids']), len(data)) 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 5fecc80f1ea..6a527b5759a 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -5,7 +5,7 @@ from datetime import date, datetime from odoo.exceptions import AccessError, except_orm from odoo.tests import common -from odoo.tools import mute_logger, float_repr +from odoo.tools import mute_logger, float_repr, pycompat class TestFields(common.TransactionCase): @@ -16,8 +16,8 @@ class TestFields(common.TransactionCase): discussion = self.env.ref('test_new_api.discussion_0') # read field as a record attribute or as a record item - self.assertIsInstance(discussion.name, basestring) - self.assertIsInstance(discussion['name'], basestring) + self.assertIsInstance(discussion.name, pycompat.string_types) + self.assertIsInstance(discussion['name'], pycompat.string_types) self.assertEqual(discussion['name'], discussion.name) # read it with method read() diff --git a/odoo/addons/test_pylint/tests/test_pylint.py b/odoo/addons/test_pylint/tests/test_pylint.py index 08a1b1110ca..86d193b161d 100644 --- a/odoo/addons/test_pylint/tests/test_pylint.py +++ b/odoo/addons/test_pylint/tests/test_pylint.py @@ -46,6 +46,7 @@ class TestPyLint(TransactionCase): 'old-ne-operator', 'old-octal-operator', 'parameter-unpacking', + 'invalid-string-codec', 'metaclass-assignment', 'deprecated-module', @@ -75,6 +76,10 @@ class TestPyLint(TransactionCase): 'filter', 'zip', + 'basestring', + 'unichr', + 'unicode', + 'file', 'reduce', ] @@ -82,6 +87,8 @@ class TestPyLint(TransactionCase): BAD_MODULES = [ 'commands', 'cPickle', + 'csv', + 'cStringIO', 'md5', 'urllib', 'urllib2', @@ -92,6 +99,7 @@ class TestPyLint(TransactionCase): 'htmlentitydefs', 'HTMLParser', 'Queue', + 'StringIO', 'UserDict', 'UserString', 'UserList', @@ -139,4 +147,4 @@ class TestPyLint(TransactionCase): else: out, err = process.communicate() if process.returncode: - self.fail("pylint test failed:\n" + (out + "\n" + err).strip()) + self.fail("pylint test failed:\n" + (b"\n" + out + b"\n" + err).decode('utf-8').strip()) diff --git a/odoo/api.py b/odoo/api.py index dc51870f20a..4f4f93071fd 100644 --- a/odoo/api.py +++ b/odoo/api.py @@ -55,7 +55,7 @@ from weakref import WeakSet from decorator import decorator from werkzeug.local import Local, release_local -from odoo.tools import frozendict, classproperty, pycompat +from odoo.tools import frozendict, classproperty _logger = logging.getLogger(__name__) @@ -85,7 +85,7 @@ class Params(object): params = [] for arg in self.args: params.append(repr(arg)) - for item in sorted(pycompat.items(self.kwargs)): + for item in sorted(self.kwargs.items()): params.append("%s=%r" % item) return ', '.join(params) @@ -100,7 +100,7 @@ class Meta(type): # dummy parent class to catch overridden methods decorated with 'returns' parent = type.__new__(meta, name, bases, {}) - for key, value in list(pycompat.items(attrs)): + for key, value in list(attrs.items()): if not key.startswith('__') and callable(value): # make the method inherit from decorators value = propagate(getattr(parent, key, None), value) @@ -155,6 +155,14 @@ def constrains(*args): ``@constrains`` only supports simple field names, dotted names (fields of relational fields e.g. ``partner_id.customer``) are not supported and will be ignored + + ``@constrains`` will be triggered only if the declared fields in the + decorated method are included in the ``create`` or ``write`` call. + It implies that fields not present in a view will not trigger a call + during a record creation. A override of ``create`` is necessary to make + sure a constraint will always be triggered (e.g. to test the absence of + value). + """ return attrsetter('_constrains', args) @@ -938,13 +946,13 @@ class Environment(Mapping): # make a full copy of the cache, and invalidate it cache_dump = dict( (field, dict(field_cache)) - for field, field_cache in pycompat.items(self.cache) + for field, field_cache in self.cache.items() ) self.invalidate_all() # re-fetch the records, and compare with their former cache invalids = [] - for field, field_dump in pycompat.items(cache_dump): + for field, field_dump in cache_dump.items(): records = self[field.model_name].browse(f for f in field_dump if f) for record in records: try: diff --git a/odoo/cli/server.py b/odoo/cli/server.py index 180efcfb70f..d740dc7282a 100644 --- a/odoo/cli/server.py +++ b/odoo/cli/server.py @@ -12,7 +12,7 @@ GNU Public Licence. """ import atexit -import csv +import csv # pylint: disable=deprecated-module import logging import os import signal diff --git a/odoo/fields.py b/odoo/fields.py index 18174f1d145..43ee8edbad1 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -11,6 +11,7 @@ import itertools import logging import pytz + try: from xmlrpc.client import MAXINT except ImportError: @@ -19,12 +20,11 @@ except ImportError: import psycopg2 -from odoo.sql_db import LazyCursor -from odoo.tools import float_repr, float_round, frozendict, html_sanitize, human_size, pg_varchar, ustr, OrderedSet, pycompat -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT -from odoo.tools.translate import html_translate, _ -import odoo.tools.sql as sql +from .sql_db import LazyCursor +from .tools import float_repr, float_round, frozendict, html_sanitize, human_size, pg_varchar, ustr, OrderedSet, pycompat, sql +from .tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT +from .tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT +from .tools.translate import html_translate, _ DATE_LENGTH = len(date.today().strftime(DATE_FORMAT)) DATETIME_LENGTH = len(datetime.now().strftime(DATETIME_FORMAT)) @@ -340,7 +340,7 @@ class Field(MetaField('DummyField', (object,), {})): def __init__(self, string=Default, **kwargs): kwargs['string'] = string self._sequence = kwargs['_sequence'] = next(_global_seq) - args = {key: val for key, val in pycompat.items(kwargs) if val is not Default} + args = {key: val for key, val in kwargs.items() if val is not Default} self.args = args or EMPTY_DICT self._setup_done = None @@ -369,7 +369,7 @@ class Field(MetaField('DummyField', (object,), {})): """ Set all field attributes at once (with slot defaults). """ # optimization: we assign slots only assign = object.__setattr__ - for key, val in pycompat.items(self._slots): + for key, val in self._slots.items(): assign(self, key, attrs.pop(key, val)) if attrs: assign(self, '_attrs', attrs) @@ -498,7 +498,7 @@ class Field(MetaField('DummyField', (object,), {})): def make_depends(deps): return tuple(deps(model) if callable(deps) else deps) - if isinstance(self.compute, basestring): + if isinstance(self.compute, pycompat.string_types): # if the compute method has been overridden, concatenate all their _depends self.depends = () for method in resolve_mro(model, self.compute, callable): @@ -517,7 +517,7 @@ class Field(MetaField('DummyField', (object,), {})): def _setup_related_full(self, model): """ Setup the attributes of a related field. """ # fix the type of self.related if necessary - if isinstance(self.related, basestring): + if isinstance(self.related, pycompat.string_types): self.related = tuple(self.related.split('.')) # determine the chain of fields, and make sure they are all set up @@ -547,7 +547,7 @@ class Field(MetaField('DummyField', (object,), {})): if not getattr(self, attr): setattr(self, attr, getattr(field, prop)) - for attr, value in pycompat.items(field._attrs): + for attr, value in field._attrs.items(): if attr not in self._attrs: setattr(self, attr, value) @@ -655,7 +655,7 @@ class Field(MetaField('DummyField', (object,), {})): model = model0.env.get(field.comodel_name) # add self's model dependencies - for mname, fnames in pycompat.items(model0._depends): + for mname, fnames in model0._depends.items(): model = model0.env[mname] for fname in fnames: field = model._fields[fname] @@ -745,9 +745,7 @@ class Field(MetaField('DummyField', (object,), {})): """ Convert ``value`` from the ``write`` format to the SQL format. """ if value is None or value is False: return None - if isinstance(value, unicode): - return value.encode('utf8') - return str(value) + return pycompat.to_native(value) def convert_to_cache(self, value, record, validate=True): """ Convert ``value`` to the cache format; ``value`` may come from an @@ -973,7 +971,7 @@ class Field(MetaField('DummyField', (object,), {})): for field in fields: for record in records: record._cache[field] = field.convert_to_cache(False, record, validate=False) - if isinstance(self.compute, basestring): + if isinstance(self.compute, pycompat.string_types): getattr(records, self.compute)() else: self.compute(records) @@ -1047,14 +1045,14 @@ class Field(MetaField('DummyField', (object,), {})): def determine_inverse(self, records): """ Given the value of ``self`` on ``records``, inverse the computation. """ - if isinstance(self.inverse, basestring): + if isinstance(self.inverse, pycompat.string_types): getattr(records, self.inverse)() else: self.inverse(records) def determine_domain(self, records, operator, value): """ Return a domain representing a condition on ``self``. """ - if isinstance(self.search, basestring): + if isinstance(self.search, pycompat.string_types): return getattr(records, self.search)(operator, value) else: return self.search(records, operator, value) @@ -1371,12 +1369,12 @@ class Char(_String): return None # we need to convert the string to a unicode object to be able # to evaluate its length (and possibly truncate it) reliably - return ustr(value)[:self.size].encode('utf8') + return pycompat.to_text(value)[:self.size] def convert_to_cache(self, value, record, validate=True): if value is None or value is False: return False - return ustr(value)[:self.size] + return pycompat.to_text(value)[:self.size] class Text(_String): @@ -1509,7 +1507,7 @@ class Date(Field): def convert_to_cache(self, value, record, validate=True): if not value: return False - if isinstance(value, basestring): + if isinstance(value, pycompat.string_types): if validate: # force parsing for validation self.from_string(value) @@ -1579,7 +1577,7 @@ class Datetime(Field): def convert_to_cache(self, value, record, validate=True): if not value: return False - if isinstance(value, basestring): + if isinstance(value, pycompat.string_types): if validate: # force parsing for validation self.from_string(value) @@ -1598,6 +1596,11 @@ class Datetime(Field): assert record, 'Record expected' return Datetime.to_string(Datetime.context_timestamp(record, Datetime.from_string(value))) +# http://initd.org/psycopg/docs/usage.html#binary-adaptation +# Received data is returned as buffer (in Python 2) or memoryview (in Python 3). +_BINARY = memoryview +if pycompat.PY2: + _BINARY = buffer #pylint: disable=buffer-builtin class Binary(Field): type = 'binary' @@ -1619,11 +1622,15 @@ class Binary(Field): # unicode in some circumstances, hence the str() cast here. # This str() coercion will only work for pure ASCII unicode strings, # on purpose - non base64 data must be passed as a 8bit byte strings. - return psycopg2.Binary(str(value)) if value else None + if not value: + return None + if isinstance(value, bytes): + return psycopg2.Binary(value) + return psycopg2.Binary(pycompat.text_type(value).encode('ascii')) def convert_to_cache(self, value, record, validate=True): - if isinstance(value, buffer): - return str(value) + if isinstance(value, _BINARY): + return bytes(value) if isinstance(value, pycompat.integer_types) and \ (record._context.get('bin_size') or record._context.get('bin_size_' + self.name)): @@ -1724,14 +1731,14 @@ class Selection(Field): if 'selection_add' in field.args: # use an OrderedDict to update existing values selection_add = field.args['selection_add'] - self.selection = list(pycompat.items(OrderedDict(self.selection + selection_add))) + self.selection = list(OrderedDict(self.selection + selection_add).items()) def _description_selection(self, env): """ return the selection list (pairs (value, label)); labels are translated according to context language """ selection = self.selection - if isinstance(selection, basestring): + if isinstance(selection, pycompat.string_types): return getattr(env[self.model_name], selection)() if callable(selection): return selection(env[self.model_name]) @@ -1748,7 +1755,7 @@ class Selection(Field): def get_values(self, env): """ return a list of the possible values """ selection = self.selection - if isinstance(selection, basestring): + if isinstance(selection, pycompat.string_types): selection = getattr(env[self.model_name], selection)() elif callable(selection): selection = selection(env[self.model_name]) @@ -1772,14 +1779,6 @@ class Selection(Field): return item[1] return False - def convert_to_column(self, value, record, values=None): - """ Convert ``value`` from the ``write`` format to the SQL format. """ - if value is None or value is False: - return None - if isinstance(value, unicode): - return value.encode('utf8') - return str(value) - class Reference(Selection): type = 'reference' @@ -1797,7 +1796,7 @@ class Reference(Selection): if isinstance(value, BaseModel): if not validate or (value._name in self.get_values(record.env) and len(value) <= 1): return process(value._name, value.id) if value else False - elif isinstance(value, basestring): + elif isinstance(value, pycompat.string_types): res_model, res_id = value.split(',') if record.env[res_model].browse(int(res_id)).exists(): return process(res_model, int(res_id)) @@ -1894,7 +1893,7 @@ class Many2one(_Relational): super(Many2one, self)._setup_attrs(model, name) # determine self.delegate if not self.delegate: - self.delegate = name in pycompat.values(model._inherits) + self.delegate = name in model._inherits.values() def update_db(self, model, columns): comodel = model.env[self.comodel_name] @@ -2117,7 +2116,7 @@ class _RelationalMulti(_Relational): self.depends += tuple( self.name + '.' + arg[0] for arg in self.domain - if isinstance(arg, (tuple, list)) and isinstance(arg[0], basestring) + if isinstance(arg, (tuple, list)) and isinstance(arg[0], pycompat.string_types) ) diff --git a/odoo/http.py b/odoo/http.py index 13234c1044a..288f13be8e3 100644 --- a/odoo/http.py +++ b/odoo/http.py @@ -44,12 +44,12 @@ except ImportError: psutil = None import odoo -from odoo.service.server import memory_info -from odoo.service import security, model as service_model -from odoo.tools.func import lazy_property -from odoo.tools import ustr, consteq, frozendict, pycompat, unique +from .service.server import memory_info +from .service import security, model as service_model +from .tools.func import lazy_property +from .tools import ustr, consteq, frozendict, pycompat, unique -from odoo.modules.module import module_manifest +from .modules.module import module_manifest _logger = logging.getLogger(__name__) rpc_request = logging.getLogger(__name__ + '.rpc.request') @@ -162,8 +162,12 @@ def redirect_with_hash(url, code=303): # See extensive test page at http://greenbytes.de/tech/tc/httpredirects/ if request.httprequest.user_agent.browser in ('firefox',): return werkzeug.utils.redirect(url, code) + # FIXME: decide whether urls should be bytes or text, apparently + # addons/website/controllers/main.py:91 calls this with a bytes url + # but addons/web/controllers/main.py:481 uses text... (blows up on login) + url = pycompat.to_text(url) if urls.url_parse(url, scheme='http').scheme not in ('http', 'https'): - url = 'http://' + url + url = u'http://' + url url = url.replace("'", "%27").replace("<", "%3C") return "<html><head><script>window.location = '%s' + location.hash;</script></head></html>" % url @@ -286,7 +290,7 @@ class WebRequest(object): def set_handler(self, endpoint, arguments, auth): # is this needed ? - arguments ={k: v for k, v in pycompat.items(arguments) + arguments ={k: v for k, v in arguments.items() if not k.startswith("_ignored_")} self.endpoint_arguments = arguments self.endpoint = endpoint @@ -301,7 +305,8 @@ class WebRequest(object): and not isinstance(exception, werkzeug.exceptions.HTTPException): odoo.tools.debugger.post_mortem( odoo.tools.config, sys.exc_info()) - raise + # otherwise "no active exception to reraise" + raise exception def _call_function(self, *args, **kwargs): request = self @@ -392,7 +397,7 @@ class WebRequest(object): msg = '%s%s' % (token, max_ts) secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') assert secret, "CSRF protection requires a configured database secret" - hm = hmac.new(str(secret), msg, hashlib.sha1).hexdigest() + hm = hmac.new(secret.encode('ascii'), msg.encode('utf-8'), hashlib.sha1).hexdigest() return '%so%s' % (hm, max_ts) def validate_csrf(self, csrf): @@ -416,7 +421,7 @@ class WebRequest(object): msg = '%s%s' % (token, max_ts) secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') assert secret, "CSRF protection requires a configured database secret" - hm_expected = hmac.new(str(secret), msg, hashlib.sha1).hexdigest() + hm_expected = hmac.new(secret.encode('ascii'), msg.encode('utf-8'), hashlib.sha1).hexdigest() return consteq(hm, hm_expected) def route(route=None, **kw): @@ -510,7 +515,7 @@ def route(route=None, **kw): if isinstance(response, Response) or f.routing_type == 'json': return response - if isinstance(response, basestring): + if isinstance(response, (bytes, pycompat.text_type)): return Response(response) if isinstance(response, werkzeug.exceptions.HTTPException): @@ -597,7 +602,7 @@ class JsonRequest(WebRequest): request = self.session.pop('jsonp_request_%s' % (request_id,), '{}') else: # regular jsonrpc2 - request = self.httprequest.stream.read() + request = self.httprequest.stream.read().decode(self.httprequest.charset) # Read POST content or POST Form Data named "request" try: @@ -761,7 +766,7 @@ class HttpRequest(WebRequest): req = request.httprequest if req.method == 'POST': request.session.save_request_data() - redirect = '/web/proxy/post{r.path}?{r.query_string}'.format(r=req) + redirect = '/web/proxy/post{r.full_path}'.format(r=req) elif not request.params.get('noredirect'): redirect = req.url if redirect: @@ -835,7 +840,7 @@ more details. """ response = Response(data, headers=headers) if cookies: - for k, v in pycompat.items(cookies): + for k, v in cookies.items(): response.set_cookie(k, v) return response @@ -876,7 +881,7 @@ class ControllerType(type): super(ControllerType, cls).__init__(name, bases, attrs) # flag old-style methods with req as first argument - for k, v in pycompat.items(attrs): + for k, v in attrs.items(): if inspect.isfunction(v) and hasattr(v, 'original_func'): # Set routing type on original functions routing_type = v.routing.get('type') @@ -1132,7 +1137,7 @@ class OpenERPSession(werkzeug.contrib.sessions.Session): # NOTE we do not store files in the session itself to avoid loading them in memory. # By storing them in the session store, we ensure every worker (even ones on other # servers) can access them. It also allow stale files to be deleted by `session_gc`. - for f in pycompat.values(req.files): + for f in req.files.values(): storename = 'werkzeug_%s_%s.file' % (self.sid, uuid.uuid4().hex) path = os.path.join(root.session_store.path, storename) with open(path, 'w') as fp: @@ -1150,7 +1155,7 @@ class OpenERPSession(werkzeug.contrib.sessions.Session): try: if data: # regenerate files filenames with the current session store - for name, (storename, filename, content_type) in pycompat.items(data['files']): + for name, (storename, filename, content_type) in data['files'].items(): path = os.path.join(root.session_store.path, storename) files.add(name, (path, filename, content_type)) yield werkzeug.datastructures.CombinedMultiDict([data['form'], files]) @@ -1158,7 +1163,7 @@ class OpenERPSession(werkzeug.contrib.sessions.Session): yield None finally: # cleanup files - for f, _, _ in pycompat.values(files): + for f, _, _ in files.values(): try: os.unlink(f) except IOError: @@ -1380,13 +1385,13 @@ class Root(object): if isinstance(result, Response) and result.is_qweb: try: result.flatten() - except(Exception) as e: + except Exception as e: if request.db: result = request.registry['ir.http']._handle_exception(e) else: raise - if isinstance(result, basestring): + if isinstance(result, (bytes, pycompat.text_type)): response = Response(result, mimetype='text/html') else: response = result @@ -1560,7 +1565,7 @@ def send_file(filepath_or_fp, mimetype=None, as_attachment=False, filename=None, :param cache_timeout: the timeout in seconds for the headers. """ - if isinstance(filepath_or_fp, (str, unicode)): + if isinstance(filepath_or_fp, pycompat.string_types): if not filename: filename = os.path.basename(filepath_or_fp) file = open(filepath_or_fp, 'rb') @@ -1610,7 +1615,7 @@ def send_file(filepath_or_fp, mimetype=None, as_attachment=False, filename=None, mtime, size, adler32( - filename.encode('utf-8') if isinstance(filename, unicode) + filename.encode('utf-8') if isinstance(filename, pycompat.text_type) else filename ) & 0xffffffff )) diff --git a/odoo/loglevels.py b/odoo/loglevels.py index 8c0242867d2..4380ff29427 100644 --- a/odoo/loglevels.py +++ b/odoo/loglevels.py @@ -37,13 +37,16 @@ def get_encodings(hint_encoding='utf-8'): if prefenc: yield prefenc +# not using pycompat to avoid circular import: pycompat is in tools much of +# which comes back to import loglevels +text_type = type(u'') def ustr(value, hint_encoding='utf-8', errors='strict'): """This method is similar to the builtin `unicode`, except that it may try multiple encodings to find one that works for decoding `value`, and defaults to 'utf-8' first. - :param: value: the value to convert - :param: hint_encoding: an optional encoding that was detecte + :param value: the value to convert + :param hint_encoding: an optional encoding that was detecte upstream and should be tried first to decode ``value``. :param str errors: optional `errors` flag to pass to the unicode built-in to indicate how illegal character values should be @@ -60,24 +63,24 @@ def ustr(value, hint_encoding='utf-8', errors='strict'): # cases faster (isinstance/issubclass are significantly slower) ttype = type(value) - if ttype is unicode: + if ttype is text_type: return value # special short-circuit for str, as we still needs to support # str subclasses such as `odoo.tools.unquote` - if ttype is str or issubclass(ttype, str): + if ttype is bytes or issubclass(ttype, bytes): # try hint_encoding first, avoids call to get_encoding() # for the most common case try: - return unicode(value, hint_encoding, errors=errors) + return value.decode(hint_encoding, errors=errors) except Exception: pass # rare: no luck with hint_encoding, attempt other ones for ln in get_encodings(hint_encoding): try: - return unicode(value, ln, errors=errors) + return value.decode(ln, errors=errors) except Exception: pass @@ -86,7 +89,7 @@ def ustr(value, hint_encoding='utf-8', errors='strict'): # fallback for non-string values try: - return unicode(value) + return text_type(value) except Exception: raise UnicodeError('unable to convert %r' % (value,)) @@ -95,6 +98,6 @@ def exception_to_unicode(e): if hasattr(e, 'args'): return "\n".join((ustr(a) for a in e.args)) try: - return unicode(e) + return text_type(e) except Exception: return u"Unknown message" diff --git a/odoo/models.py b/odoo/models.py index 81fba2863c7..d4e184ccdf2 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -148,7 +148,7 @@ class MetaModel(api.Meta): self.module_to_models[self._module].append(self) # check for new-api conversion error: leave comma after field definition - for key, val in pycompat.items(attrs): + for key, val in attrs.items(): if type(val) is tuple and len(val) == 1 and isinstance(val[0], Field): _logger.error("Trailing comma after field definition: %s.%s", self, key) if isinstance(val, Field): @@ -173,7 +173,7 @@ class NewId(object): return False __nonzero__ = __bool__ -IdType = pycompat.integer_types + (str, unicode, NewId) +IdType = pycompat.integer_types + pycompat.string_types + (NewId,) # maximum number of prefetched records @@ -184,6 +184,7 @@ LOG_ACCESS_COLUMNS = ['create_uid', 'create_date', 'write_uid', 'write_date'] MAGIC_COLUMNS = ['id'] + LOG_ACCESS_COLUMNS +@pycompat.implements_to_string class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): """ Base class for Odoo models. @@ -405,7 +406,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # determine inherited models parents = cls._inherit - parents = [parents] if isinstance(parents, basestring) else (parents or []) + parents = [parents] if isinstance(parents, pycompat.string_types) else (parents or []) # determine the model's name name = cls._name or (len(parents) == 1 and parents[0]) or cls.__name__ @@ -513,7 +514,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): cls._inherits.update(base._inherits) - for mname, fnames in pycompat.items(base._depends): + for mname, fnames in base._depends.items(): cls._depends[mname] = cls._depends.get(mname, []) + fnames for cons in base._constraints: @@ -523,7 +524,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): cls._sql_constraints += base._sql_constraints cls._sequence = cls._sequence or (cls._table + '_id_seq') - cls._constraints = list(pycompat.values(cls._constraints)) + cls._constraints = list(cls._constraints.values()) # update _inherits_children of parent models for parent_name in cls._inherits: @@ -769,7 +770,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # avoid broken transaction) and keep going cr.execute('ROLLBACK TO SAVEPOINT model_load_save') except Exception as e: - message = (_('Unknown error during import:') + ' %s: %s' % (type(e), unicode(e))) + message = (_(u'Unknown error during import:') + u' %s: %s' % (type(e), e)) moreinfo = _('Resolve other errors first') messages.append(dict(info, type='error', message=message, moreinfo=moreinfo)) # Failed for some reason, perhaps due to invalid data supplied, @@ -874,7 +875,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): :returns: a list of triplets of (id, xid, record) :rtype: list((int|None, str|None, dict)) """ - field_names = {name: field.string for name, field in pycompat.items(self._fields)} + field_names = {name: field.string for name, field in self._fields.items()} if self.env.lang: field_names.update(self.env['ir.translation'].get_field_string(self._name)) @@ -886,7 +887,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # processing of response, but injects human readable in message exc_vals = dict(base, record=record, field=field_names[field]) record = dict(base, type=type, record=record, field=field, - message=unicode(exception.args[0]) % exc_vals) + message=pycompat.text_type(exception.args[0]) % exc_vals) if len(exception.args) > 1 and exception.args[1]: record.update(exception.args[1]) log(record) @@ -1005,7 +1006,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): defaults = self._convert_to_write(defaults) # add default values for inherited fields - for model, names in pycompat.items(parent_fields): + for model, names in parent_fields.items(): defaults.update(self.env[model].default_get(names)) return defaults @@ -1081,7 +1082,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): :rtype: etree._Element """ group = E.group(col="4") - for fname, field in pycompat.items(self._fields): + for fname, field in self._fields.items(): if field.automatic: continue elif field.type in ('one2many', 'many2many', 'text', 'html'): @@ -1263,7 +1264,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # fallback on default views methods if no ir.ui.view could be found try: arch_etree = getattr(self, '_get_default_%s_view' % view_type)() - result['arch'] = etree.tostring(arch_etree, encoding='utf-8') + result['arch'] = etree.tostring(arch_etree, encoding='unicode') result['type'] = view_type result['name'] = 'default' except AttributeError: @@ -1503,14 +1504,14 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # avoid overriding inherited values when parent is set avoid_models = { parent_model - for parent_model, parent_field in pycompat.items(self._inherits) + for parent_model, parent_field in self._inherits.items() if parent_field in values } # compute missing fields missing_defaults = { name - for name, field in pycompat.items(self._fields) + for name, field in self._fields.items() if name not in values if self._log_access and name not in MAGIC_COLUMNS if not (field.inherited and field.related_field.model_name in avoid_models) @@ -1521,7 +1522,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # override defaults with the provided values, never allow the other way around defaults = self.default_get(list(missing_defaults)) - for name, value in pycompat.items(defaults): + for name, value in defaults.items(): if self._fields[name].type == 'many2many' and value and isinstance(value[0], pycompat.integer_types): # convert a list of ids into a list of commands defaults[name] = [(6, 0, value)] @@ -1606,10 +1607,10 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if field.relational and groups._fold_name in groups._fields: fold = {group.id: group[groups._fold_name] for group in groups.browse([key for key in result if key])} - for key, line in pycompat.items(result): + for key, line in result.items(): line['__fold'] = fold.get(key, False) - return list(pycompat.values(result)) + return list(result.values()) @api.model def _read_group_prepare(self, orderby, aggregated_fields, annotated_groupbys, query): @@ -1714,7 +1715,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): value = False if value is None else value gb = groupby_dict.get(key) if gb and gb['type'] in ('date', 'datetime') and value: - if isinstance(value, basestring): + if isinstance(value, pycompat.string_types): dt_format = DEFAULT_SERVER_DATETIME_FORMAT if gb['type'] == 'datetime' else DEFAULT_SERVER_DATE_FORMAT value = datetime.datetime.strptime(value, dt_format) if gb['tz_convert']: @@ -1821,7 +1822,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): """ result = self._read_group_raw(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) - groupby = [groupby] if isinstance(groupby, basestring) else list(OrderedSet(groupby)) + groupby = [groupby] if isinstance(groupby, pycompat.string_types) else list(OrderedSet(groupby)) dt = [ f for f in groupby if self._fields[f.split(':')[0]].type in ('date', 'datetime') @@ -1843,9 +1844,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): def _read_group_raw(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): self.check_access_rights('read') query = self._where_calc(domain) - fields = fields or [f.name for f in pycompat.values(self._fields) if f.store] + fields = fields or [f.name for f in self._fields.values() if f.store] - groupby = [groupby] if isinstance(groupby, basestring) else list(OrderedSet(groupby)) + groupby = [groupby] if isinstance(groupby, pycompat.string_types) else list(OrderedSet(groupby)) groupby_list = groupby[:1] if lazy else groupby annotated_groupbys = [self._read_group_process_groupby(gb, query) for gb in groupby_list] groupby_fields = [g['field'] for g in annotated_groupbys] @@ -1917,7 +1918,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): self._read_group_resolve_many2one_fields(fetched_data, annotated_groupbys) - data = ({k: self._read_group_prepare_data(k,v, groupby_dict) for k,v in pycompat.items(r)} for r in fetched_data) + data = ({k: self._read_group_prepare_data(k,v, groupby_dict) for k,v in r.items()} for r in fetched_data) result = [self._read_group_format_result(d, annotated_groupbys, groupby, domain) for d in data] if lazy: # Right now, read_group only fill results in lazy mode (by default). @@ -2023,7 +2024,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # fields which were required but have been removed (or will be added by # another module) cr = self._cr - cols = [name for name, field in pycompat.items(self._fields) + cols = [name for name, field in self._fields.items() if field.store and field.column_type] cr.execute("SELECT a.attname, a.attnotnull" " FROM pg_class c, pg_attribute a" @@ -2123,7 +2124,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): recs = self.with_context(active_test=False).search([]) recs._recompute_todo(field) - for field in pycompat.values(self._fields): + for field in self._fields.values(): if not field.store: continue @@ -2211,9 +2212,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): """ Determine inherited fields. """ # determine candidate inherited fields fields = {} - for parent_model, parent_field in pycompat.items(self._inherits): + for parent_model, parent_field in self._inherits.items(): parent = self.env[parent_model] - for name, field in pycompat.items(parent._fields): + for name, field in parent._fields.items(): # inherited fields are implemented as related fields, with the # following specific properties: # - reading inherited fields should not bypass access rights @@ -2226,13 +2227,13 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): ) # add inherited fields that are not redefined locally - for name, field in pycompat.items(fields): + for name, field in fields.items(): if name not in self._fields: self._add_field(name, field) @api.model def _inherits_check(self): - for table, field_name in pycompat.items(self._inherits): + for table, field_name in self._inherits.items(): field = self._fields.get(field_name) if not field: _logger.info('Missing many2one field definition for _inherits reference "%s" in "%s", using default one.', field_name, self._name) @@ -2245,7 +2246,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): field.ondelete = "cascade" # reflect fields with delegate=True in dictionary self._inherits - for field in pycompat.values(self._fields): + for field in self._fields.values(): if field.type == 'many2one' and not field.related and field.delegate: if not field.required: _logger.warning("Field %s with delegate=True must be required.", field) @@ -2333,7 +2334,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # set up fields bad_fields = [] - for name, field in pycompat.items(cls._fields): + for name, field in cls._fields.items(): try: field.setup_full(self) except Exception: @@ -2352,11 +2353,11 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # map each field to the fields computed with the same method groups = defaultdict(list) - for field in pycompat.values(cls._fields): + for field in cls._fields.values(): if field.compute: cls._field_computed[field] = group = groups[field.compute] group.append(field) - for fields in pycompat.values(groups): + for fields in groups.values(): compute_sudo = fields[0].compute_sudo if not all(field.compute_sudo == compute_sudo for field in fields): _logger.warning("%s: inconsistent 'compute_sudo' for computed fields: %s", @@ -2369,7 +2370,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if isinstance(self, Model): # set up field triggers (on database-persisted models only) - for field in pycompat.values(cls._fields): + for field in cls._fields.values(): # dependencies of custom fields may not exist; ignore that case exceptions = (Exception,) if field.manual else () with tools.ignore(*exceptions): @@ -2408,7 +2409,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): readonly = not (has_access('write') or has_access('create')) res = {} - for fname, field in pycompat.items(self._fields): + for fname, field in self._fields.items(): if allfields and fname not in allfields: continue if field.groups and not self.user_has_groups(field.groups): @@ -2420,7 +2421,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): description['states'] = {} if attributes: description = {key: val - for key, val in pycompat.items(description) + for key, val in description.items() if key in attributes} res[fname] = description @@ -2531,7 +2532,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if self._context.get('prefetch_fields', True) and field.prefetch: fs.update( f - for f in pycompat.values(self._fields) + for f in self._fields.values() # select fields that can be prefetched if f.prefetch # discard fields with groups that the user may not access @@ -2986,7 +2987,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # split up fields into old-style and pure new-style ones old_vals, new_vals, unknown = {}, {}, [] - for key, val in pycompat.items(vals): + for key, val in vals.items(): field = self._fields.get(key) if field: if field.store or field.inherited: @@ -3060,7 +3061,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): direct = [] # list of direcly updated columns has_trans = self.env.lang and self.env.lang != 'en_US' single_lang = len(self.env['res.lang'].get_installed()) <= 1 - for name, val in pycompat.items(vals): + for name, val in vals.items(): field = self._fields[name] if field and field.deprecated: _logger.warning('Field %s.%s is deprecated: %s', self._name, name, field.deprecated) @@ -3122,7 +3123,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # defaults in context must be removed when call a one2many or many2many rel_context = {key: val - for key, val in pycompat.items(self._context) + for key, val in self._context.items() if not key.startswith('default_')} # call the 'write' method of fields which are not columns @@ -3135,7 +3136,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # write inherited fields on the corresponding parent records unknown_fields = set(updend) - for parent_model, parent_field in pycompat.items(self._inherits): + for parent_model, parent_field in self._inherits.items(): parent_ids = [] for sub_ids in cr.split_for_in_conditions(self.ids): query = "SELECT DISTINCT %s FROM %s WHERE id IN %%s" % (parent_field, self._table) @@ -3259,7 +3260,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # split up fields into old-style and pure new-style ones old_vals, new_vals, unknown = {}, {}, [] - for key, val in pycompat.items(vals): + for key, val in vals.items(): field = self._fields.get(key) if field: if field.store or field.inherited: @@ -3296,7 +3297,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # data of parent records to create or update, by model tocreate = { parent_model: {'id': vals.pop(parent_field, None)} - for parent_model, parent_field in pycompat.items(self._inherits) + for parent_model, parent_field in self._inherits.items() } # list of column assignments defined as tuples like: @@ -3311,7 +3312,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): upd_todo = [] unknown_fields = [] protected_fields = [] - for name, val in list(pycompat.items(vals)): + for name, val in list(vals.items()): field = self._fields.get(name) if not field: unknown_fields.append(name) @@ -3327,7 +3328,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): _logger.warning('No such field(s) in model %s: %s.', self._name, ', '.join(unknown_fields)) # create or update parent records - for parent_model, parent_vals in pycompat.items(tocreate): + for parent_model, parent_vals in tocreate.items(): parent_id = parent_vals.pop('id') if not parent_id: parent_id = self.env[parent_model].create(parent_vals).id @@ -3336,13 +3337,13 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): vals[self._inherits[parent_model]] = parent_id # set boolean fields to False by default (to make search more powerful) - for name, field in pycompat.items(self._fields): + for name, field in self._fields.items(): if field.type == 'boolean' and field.store and name not in vals: vals[name] = False # determine SQL values self = self.browse() - for name, val in pycompat.items(vals): + for name, val in vals.items(): field = self._fields[name] if field.store and field.column_type: column_val = field.convert_to_column(val, self, vals) @@ -3374,7 +3375,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if self.env.lang and self.env.lang != 'en_US': # add translations for self.env.lang - for name, val in pycompat.items(vals): + for name, val in vals.items(): field = self._fields[name] if field.store and field.column_type and field.translate is True: tname = "%s,%s" % (self._name, name) @@ -3422,7 +3423,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # defaults in context must be removed when call a one2many or many2many rel_context = {key: val - for key, val in pycompat.items(self._context) + for key, val in self._context.items() if not key.startswith('default_')} # call the 'write' method of fields which are not columns @@ -3727,11 +3728,11 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # build a black list of fields that should not be copied blacklist = set(MAGIC_COLUMNS + ['parent_left', 'parent_right']) - whitelist = set(name for name, field in pycompat.items(self._fields) if not field.inherited) + whitelist = set(name for name, field in self._fields.items() if not field.inherited) def blacklist_given_fields(model): # blacklist the fields that are given by inheritance - for parent_model, parent_field in pycompat.items(model._inherits): + for parent_model, parent_field in model._inherits.items(): blacklist.add(parent_field) if parent_field in default: # all the fields of 'parent_model' are given by the record: @@ -3740,17 +3741,17 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): else: blacklist_given_fields(self.env[parent_model]) # blacklist deprecated fields - for name, field in pycompat.items(model._fields): + for name, field in model._fields.items(): if field.deprecated: blacklist.add(name) blacklist_given_fields(self) fields_to_copy = {name: field - for name, field in pycompat.items(self._fields) + for name, field in self._fields.items() if field.copy and name not in default and name not in blacklist} - for name, field in pycompat.items(fields_to_copy): + for name, field in fields_to_copy.items(): if field.type == 'one2many': # duplicate following the order of the ids because we'll rely on # it later for copying translations in copy_translation()! @@ -3788,7 +3789,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): old_wo_lang, new_wo_lang = (old + new).with_context(lang=None) Translation = old.env['ir.translation'] - for name, field in pycompat.items(old._fields): + for name, field in old._fields.items(): if not field.copy: continue @@ -3971,7 +3972,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): """ results = self._get_external_ids() return {key: val[0] if val else '' - for key, val in pycompat.items(results)} + for key, val in results.items()} # backwards compatibility get_xml_id = get_external_id @@ -4344,7 +4345,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): target = self if update else self.browse([], self._prefetch) return { name: fields[name].convert_to_cache(value, target, validate=validate) - for name, value in pycompat.items(values) + for name, value in values.items() if name in fields } @@ -4354,14 +4355,14 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): """ return { name: self._fields[name].convert_to_record(value, self) - for name, value in pycompat.items(values) + for name, value in values.items() } def _convert_to_write(self, values): """ Convert the ``values`` dictionary into the format of :meth:`write`. """ fields = self._fields result = {} - for name, value in pycompat.items(values): + for name, value in values.items(): if name in fields: field = fields[name] value = field.convert_to_cache(value, self, validate=False) @@ -4398,7 +4399,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): """ if not func: return self # support for an empty path of fields - if isinstance(func, basestring): + if isinstance(func, pycompat.string_types): recs = self for name in func.split('.'): recs = recs._mapped_func(operator.itemgetter(name)) @@ -4423,7 +4424,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): :param func: a function or a dot-separated sequence of field names """ - if isinstance(func, basestring): + if isinstance(func, pycompat.string_types): name = func func = lambda rec: any(rec.mapped(name)) return self.browse([rec.id for rec in self if func(rec)]) @@ -4440,7 +4441,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if key is None: recs = self.search([('id', 'in', self.ids)]) return self.browse(reversed(recs._ids)) if reverse else recs - if isinstance(key, basestring): + if isinstance(key, pycompat.string_types): key = itemgetter(key) return self.browse(item.id for item in sorted(self, key=key, reverse=reverse)) @@ -4448,7 +4449,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): def update(self, values): """ Update the records in ``self`` with ``values``. """ for record in self: - for name, value in pycompat.items(values): + for name, value in values.items(): record[name] = value # @@ -4525,7 +4526,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): """ if isinstance(item, BaseModel) and self._name == item._name: return len(item) == 1 and item.id in self._ids - elif isinstance(item, basestring): + elif isinstance(item, pycompat.string_types): return item in self._fields else: raise TypeError("Mixing apples and oranges: %s in %s" % (item, self)) @@ -4618,11 +4619,8 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): def __str__(self): return "%s%s" % (self._name, getattr(self, '_ids', "")) - - def __unicode__(self): - return unicode(str(self)) - - __repr__ = __str__ + def __repr__(self): + return str(self) def __hash__(self): if hasattr(self, '_ids'): @@ -4642,7 +4640,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): rs = inst[10:20] # subset of inst nm = rs['name'] # name of first record in inst """ - if isinstance(key, basestring): + if isinstance(key, pycompat.string_types): # important: one must call the field's getter return self._fields[key].__get__(self, type(self)) elif isinstance(key, slice): @@ -4696,7 +4694,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if fnames is None: if ids is None: return self.env.invalidate_all() - fields = list(pycompat.values(self._fields)) + fields = list(self._fields.values()) else: fields = [self._fields[n] for n in fnames] @@ -4792,7 +4790,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): updates[frozendict(vals)].add(rec.id) # update records in batch when possible with recs.env.norecompute(): - for vals, ids in pycompat.items(updates): + for vals, ids in updates.items(): target = recs.browse(ids) try: target._write(dict(vals)) @@ -4832,7 +4830,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if not result.get(names): result[names] = node.attrib.get('on_change') # traverse the subviews included in relational fields - for subinfo in pycompat.values(info['fields'][name].get('views', {})): + for subinfo in info['fields'][name].get('views', {}).values(): process(etree.fromstring(subinfo['arch']), subinfo, names) else: for child in node: @@ -4855,7 +4853,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): return if res.get('value'): res['value'].pop('id', None) - self.update({key: val for key, val in pycompat.items(res['value']) if key in self._fields}) + self.update({key: val for key, val in res['value'].items() if key in self._fields}) if res.get('domain'): result.setdefault('domain', {}).update(res['domain']) if res.get('warning'): @@ -4984,7 +4982,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): record.mapped(field_seq) # determine which fields have been modified - for name, oldval in pycompat.items(values): + for name, oldval in values.items(): field = self._fields[name] newval = record[name] if newval != oldval or ( @@ -4995,7 +4993,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # At the moment, the client does not support updates on a *2many field # while this one is modified by the user. - if isinstance(field_name, basestring) and \ + if isinstance(field_name, pycompat.string_types) and \ self._fields[field_name].type in ('one2many', 'many2many'): dirty.discard(field_name) @@ -5020,13 +5018,13 @@ class RecordCache(MutableMapping): def contains(self, field): """ Return whether `records[0]` has a value for ``field`` in cache. """ - if isinstance(field, basestring): + if isinstance(field, pycompat.string_types): field = self._recs._fields[field] return self._recs.id in self._recs.env.cache[field] def __contains__(self, field): """ Return whether `records[0]` has a regular value for ``field`` in cache. """ - if isinstance(field, basestring): + if isinstance(field, pycompat.string_types): field = self._recs._fields[field] dummy = SpecialValue(None) value = self._recs.env.cache[field].get(self._recs.id, dummy) @@ -5034,7 +5032,7 @@ class RecordCache(MutableMapping): def get(self, field, default=None): """ Return the cached, regular value of ``field`` for `records[0]`, or ``default``. """ - if isinstance(field, basestring): + if isinstance(field, pycompat.string_types): field = self._recs._fields[field] dummy = SpecialValue(None) value = self._recs.env.cache[field].get(self._recs.id, dummy) @@ -5042,14 +5040,14 @@ class RecordCache(MutableMapping): def __getitem__(self, field): """ Return the cached value of ``field`` for `records[0]`. """ - if isinstance(field, basestring): + if isinstance(field, pycompat.string_types): field = self._recs._fields[field] value = self._recs.env.cache[field][self._recs.id] return value.get() if isinstance(value, SpecialValue) else value def __setitem__(self, field, value): """ Assign the cached value of ``field`` for all records in ``records``. """ - if isinstance(field, basestring): + if isinstance(field, pycompat.string_types): field = self._recs._fields[field] values = dict.fromkeys(self._recs._ids, value) self._recs.env.cache[field].update(values) @@ -5060,7 +5058,7 @@ class RecordCache(MutableMapping): """ if args and isinstance(args[0], SpecialValue): values = dict.fromkeys(self._recs._ids, args[0]) - for name, field in pycompat.items(self._recs._fields): + for name, field in self._recs._fields.items(): if name != 'id': self._recs.env.cache[field].update(values) else: @@ -5068,7 +5066,7 @@ class RecordCache(MutableMapping): def __delitem__(self, field): """ Remove the cached value of ``field`` for all ``records``. """ - if isinstance(field, basestring): + if isinstance(field, pycompat.string_types): field = self._recs._fields[field] field_cache = self._recs.env.cache[field] for id in self._recs._ids: @@ -5078,7 +5076,7 @@ class RecordCache(MutableMapping): """ Iterate over the field names with a regular value in cache. """ cache, id = self._recs.env.cache, self._recs.id dummy = SpecialValue(None) - for name, field in pycompat.items(self._recs._fields): + for name, field in self._recs._fields.items(): if name != 'id' and not isinstance(cache[field].get(id, dummy), SpecialValue): yield name diff --git a/odoo/modules/graph.py b/odoo/modules/graph.py index 731b6f6557a..0f2e329fab9 100644 --- a/odoo/modules/graph.py +++ b/odoo/modules/graph.py @@ -8,7 +8,6 @@ import logging import odoo import odoo.tools as tools -from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -36,7 +35,7 @@ class Graph(dict): return # update the graph with values from the database (if exist) ## First, we set the default values for each package in graph - additional_data = {key: {'id': 0, 'state': 'uninstalled', 'dbdemo': False, 'installed_version': None} for key in pycompat.keys(self)} + additional_data = {key: {'id': 0, 'state': 'uninstalled', 'dbdemo': False, 'installed_version': None} for key in self.keys()} ## Then we get the values from the database cr.execute('SELECT name, id, state, demo AS dbdemo, latest_version AS installed_version' ' FROM ir_module_module' @@ -46,8 +45,8 @@ class Graph(dict): ## and we update the default values with values from the database additional_data.update((x['name'], x) for x in cr.dictfetchall()) - for package in pycompat.values(self): - for k, v in pycompat.items(additional_data[package.name]): + for package in self.values(): + for k, v in additional_data[package.name].items(): setattr(package, k, v) def add_module(self, cr, module, force=None): @@ -102,9 +101,9 @@ class Graph(dict): def __iter__(self): level = 0 - done = set(pycompat.keys(self)) + done = set(self.keys()) while done: - level_modules = sorted((name, module) for name, module in pycompat.items(self) if module.depth==level) + level_modules = sorted((name, module) for name, module in self.items() if module.depth==level) for name, module in level_modules: done.remove(name) yield module diff --git a/odoo/modules/loading.py b/odoo/modules/loading.py index bd58376a7b9..c06c219f7eb 100644 --- a/odoo/modules/loading.py +++ b/odoo/modules/loading.py @@ -20,7 +20,6 @@ import odoo.tools as tools from odoo import api, SUPERUSER_ID from odoo.modules.module import adapt_version, initialize_sys_path, load_openerp_module -from odoo.tools import pycompat _logger = logging.getLogger(__name__) _test_logger = logging.getLogger('odoo.tests') @@ -298,13 +297,13 @@ def load_modules(db, force_demo=False, status=None, update_module=False): _check_module_names(cr, itertools.chain(tools.config['init'], tools.config['update'])) - module_names = [k for k, v in pycompat.items(tools.config['init']) if v] + module_names = [k for k, v in tools.config['init'].items() if v] if module_names: modules = Module.search([('state', '=', 'uninstalled'), ('name', 'in', module_names)]) if modules: modules.button_install() - module_names = [k for k, v in pycompat.items(tools.config['update']) if v] + module_names = [k for k, v in tools.config['update'].items() if v] if module_names: modules = Module.search([('state', '=', 'installed'), ('name', 'in', module_names)]) if modules: @@ -392,7 +391,7 @@ def load_modules(db, force_demo=False, status=None, update_module=False): getattr(py_module, uninstall_hook)(cr, registry) Module = env['ir.module.module'] - Module.browse(pycompat.values(modules_to_remove)).module_uninstall() + Module.browse(modules_to_remove.values()).module_uninstall() # Recursive reload, should only happen once, because there should be no # modules to remove next time cr.commit() @@ -415,7 +414,7 @@ def load_modules(db, force_demo=False, status=None, update_module=False): _logger.info('Modules loaded.') # STEP 8: call _register_hook on every model - for model in pycompat.values(env): + for model in env.values(): model._register_hook() # STEP 9: save installed/updated modules for post-install tests diff --git a/odoo/modules/migration.py b/odoo/modules/migration.py index 0fa6699cf24..8edc9586415 100644 --- a/odoo/modules/migration.py +++ b/odoo/modules/migration.py @@ -13,7 +13,6 @@ from os.path import join as opj from odoo.modules.module import get_resource_path import odoo.release as release import odoo.tools as tools -from odoo.tools import pycompat from odoo.tools.parse_version import parse_version @@ -90,8 +89,8 @@ class MigrationManager(object): def _get_migration_versions(pkg): versions = sorted({ ver - for lv in pycompat.values(self.migrations[pkg.name]) - for ver, lf in pycompat.items(lv) + for lv in self.migrations[pkg.name].values() + for ver, lf in lv.items() if lf }, key=lambda k: parse_version(convert_version(k))) return versions diff --git a/odoo/modules/module.py b/odoo/modules/module.py index f0836cbe041..b249f01c1d9 100644 --- a/odoo/modules/module.py +++ b/odoo/modules/module.py @@ -434,7 +434,7 @@ def get_test_modules(module): mod = importlib.import_module('.tests', modpath) except Exception as e: # If module has no `tests` sub-module, no problem. - if str(e) != 'No module named tests': + if not pycompat.text_type(e).startswith(u'No module named'): _logger.exception('Can not `import %s`.', module) return [] diff --git a/odoo/modules/registry.py b/odoo/modules/registry.py index cade92c23ac..1efc6aa195f 100644 --- a/odoo/modules/registry.py +++ b/odoo/modules/registry.py @@ -17,7 +17,7 @@ import odoo from .. import SUPERUSER_ID from odoo.tools import (assertion_report, config, existing_tables, lazy_classproperty, lazy_property, table_exists, - topological_sort, OrderedSet, pycompat) + topological_sort, OrderedSet) from odoo.tools.lru import LRU _logger = logging.getLogger(__name__) @@ -157,7 +157,7 @@ class Registry(Mapping): def delete_all(cls): """ Delete all the registries. """ with cls._lock: - for db_name in list(pycompat.keys(cls.registries)): + for db_name in list(cls.registries.keys()): cls.delete(db_name) # @@ -193,8 +193,8 @@ class Registry(Mapping): # map fields on their dependents dependents = { field: set(dep for dep, _ in model._field_triggers[field] if dep != field) - for model in pycompat.values(self) - for field in pycompat.values(model._fields) + for model in self.values() + for field in model._fields.values() } # sort them topologically, and associate a sequence number to each field mapping = { @@ -262,7 +262,7 @@ class Registry(Mapping): env['ir.model']._add_manual_models() # prepare the setup on all models - models = list(pycompat.values(env)) + models = list(env.values()) for model in models: model._prepare_setup() @@ -316,7 +316,7 @@ class Registry(Mapping): missing = {table2model[table] for table in missing_tables} _logger.warning("Models have no table: %s.", ", ".join(missing)) # recreate missing tables following model dependencies - deps = {name: model._depends for name, model in pycompat.items(env)} + deps = {name: model._depends for name, model in env.items()} for name in topological_sort(deps): if name in missing: _logger.info("Recreate table of model %s.", name) @@ -341,7 +341,7 @@ class Registry(Mapping): """ Clear the caches associated to methods decorated with ``tools.ormcache`` or ``tools.ormcache_multi`` for all the models. """ - for model in pycompat.values(self.models): + for model in self.models.values(): model.clear_caches() def setup_signaling(self): diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py index 8df300a2bd4..69833f45de0 100644 --- a/odoo/osv/expression.py +++ b/odoo/osv/expression.py @@ -337,7 +337,7 @@ def generate_table_alias(src_table_alias, joined_tables=[]): # We have to fit a crc32 hash and one underscore # into a 63 character alias. The remaining space we can use to add # a human readable prefix. - alias_hash = hex(crc32(alias))[2:] + alias_hash = hex(crc32(alias.encode('utf-8')))[2:] ALIAS_PREFIX_LENGTH = 63 - len(alias_hash) - 1 alias = "%s_%s" % ( alias[:ALIAS_PREFIX_LENGTH], alias_hash) @@ -377,7 +377,7 @@ def normalize_leaf(element): def is_operator(element): """ Test whether an object is a valid domain operator. """ - return isinstance(element, basestring) and element in DOMAIN_OPERATORS + return isinstance(element, pycompat.string_types) and element in DOMAIN_OPERATORS def is_leaf(element, internal=False): @@ -398,7 +398,7 @@ def is_leaf(element, internal=False): return (isinstance(element, tuple) or isinstance(element, list)) \ and len(element) == 3 \ and element[1] in INTERNAL_OPS \ - and ((isinstance(element[0], basestring) and element[0]) + and ((isinstance(element[0], pycompat.string_types) and element[0]) or tuple(element) in (TRUE_LEAF, FALSE_LEAF)) @@ -697,9 +697,9 @@ class expression(object): return the list of related ids """ names = [] - if isinstance(value, basestring): + if isinstance(value, pycompat.string_types): names = [value] - elif value and isinstance(value, (tuple, list)) and all(isinstance(item, basestring) for item in value): + elif value and isinstance(value, (tuple, list)) and all(isinstance(item, pycompat.string_types) for item in value): names = value elif isinstance(value, pycompat.integer_types): return [value] @@ -925,7 +925,7 @@ class expression(object): domain = domain(model) is_integer_m2o = comodel._fields[field.inverse_name].type == 'integer' if right is not False: - if isinstance(right, basestring): + if isinstance(right, pycompat.string_types): op = {'!=': '=', 'not like': 'like', 'not ilike': 'ilike'}.get(operator, operator) ids2 = [x[0] for x in comodel.name_search(right, domain or [], op, limit=None)] if ids2: @@ -988,12 +988,12 @@ class expression(object): else: subquery = 'SELECT "%s" FROM "%s" WHERE "%s" IN %%s' % (rel_id1, rel_table, rel_id2) # avoid flattening of argument in to_sql() - subquery = cr.mogrify(subquery, [tuple(ids2)]) + subquery = cr.mogrify(subquery, [tuple(ids2)]).decode('utf-8') push(create_substitution_leaf(leaf, ('id', 'inselect', (subquery, [])), internal=True)) else: call_null_m2m = True if right is not False: - if isinstance(right, basestring): + if isinstance(right, pycompat.string_types): op = {'!=': '=', 'not like': 'like', 'not ilike': 'ilike'}.get(operator, operator) domain = field.domain if callable(domain): @@ -1018,7 +1018,7 @@ class expression(object): subop = 'not inselect' if operator in NEGATIVE_TERM_OPERATORS else 'inselect' subquery = 'SELECT "%s" FROM "%s" WHERE "%s" IN %%s' % (rel_id1, rel_table, rel_id2) # avoid flattening of argument in to_sql() - subquery = cr.mogrify(subquery, [tuple(it for it in res_ids if it)]) + subquery = cr.mogrify(subquery, [tuple(it for it in res_ids if it)]).decode('utf-8') push(create_substitution_leaf(leaf, ('id', subop, (subquery, [])), internal=True)) if call_null_m2m: @@ -1051,8 +1051,8 @@ class expression(object): res_ids.append(False) # TODO this should not be appended if False was in 'right' return left, 'in', res_ids # resolve string-based m2o criterion into IDs - if isinstance(right, basestring) or \ - right and isinstance(right, (tuple, list)) and all(isinstance(item, basestring) for item in right): + if isinstance(right, pycompat.string_types) or \ + right and isinstance(right, (tuple, list)) and all(isinstance(item, pycompat.string_types) for item in right): push(create_substitution_leaf(leaf, _get_expression(comodel, left, right, operator), model)) else: # right == [] or right == False and all other cases are handled by __leaf_to_sql() @@ -1263,21 +1263,16 @@ class expression(object): add_null = False if need_wildcard: - if isinstance(right, str): - str_utf8 = right - elif isinstance(right, unicode): - str_utf8 = right.encode('utf-8') - else: - str_utf8 = str(right) - params = '%%%s%%' % str_utf8 - add_null = not str_utf8 + native_str = pycompat.to_native(right) + params = '%%%s%%' % native_str + add_null = not native_str elif left in model: params = model._fields[left].convert_to_column(right, model) if add_null: query = '(%s OR %s."%s" IS NULL)' % (query, table_alias, left) - if isinstance(params, basestring): + if isinstance(params, pycompat.string_types): params = [params] return query, params diff --git a/odoo/osv/orm.py b/odoo/osv/orm.py index e4be2706c7f..f7cc7ae0701 100644 --- a/odoo/osv/orm.py +++ b/odoo/osv/orm.py @@ -46,12 +46,12 @@ def transfer_field_to_modifiers(field, modifiers): for attr in ('invisible', 'readonly', 'required'): state_exceptions[attr] = [] default_values[attr] = bool(field.get(attr)) - for state, modifs in pycompat.items(field.get("states",{})): + for state, modifs in field.get("states",{}).items(): for modif in modifs: if default_values[modif[0]] != modif[1]: state_exceptions[modif[0]].append(state) - for attr, default_value in pycompat.items(default_values): + for attr, default_value in default_values.items(): if state_exceptions[attr]: modifiers[attr] = [("state", "not in" if default_value else "in", state_exceptions[attr])] else: @@ -125,7 +125,7 @@ def setup_modifiers(node, field=None, context=None, in_tree_view=False): def test_modifiers(what, expected): modifiers = {} - if isinstance(what, basestring): + if isinstance(what, pycompat.string_types): node = etree.fromstring(what) transfer_node_to_modifiers(node, modifiers) simplify_modifiers(modifiers) diff --git a/odoo/service/db.py b/odoo/service/db.py index bdcf0e404d9..4c6c23822ab 100644 --- a/odoo/service/db.py +++ b/odoo/service/db.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +import base64 import json import logging import os @@ -23,6 +23,7 @@ import odoo.sql_db import odoo.tools from odoo.sql_db import db_connect from odoo.release import version_info +from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -160,7 +161,7 @@ def exp_dump(db_name, format): with tempfile.TemporaryFile() as t: dump_db(db_name, t, format) t.seek(0) - return t.read().encode('base64') + return base64.b64encode(t.read()) def dump_db_manifest(cr): pg_version = "%d.%d" % divmod(cr._obj.connection.server_version / 100, 100) @@ -215,7 +216,7 @@ def dump_db(db_name, stream, backup_format='zip'): def exp_restore(db_name, data, copy=False): data_file = tempfile.NamedTemporaryFile(delete=False) try: - data_file.write(data.decode('base64')) + data_file.write(base64.b64decode(data)) data_file.close() restore_db(db_name, data_file.name, copy=copy) finally: @@ -223,7 +224,7 @@ def exp_restore(db_name, data, copy=False): return True def restore_db(db, dump_file, copy=False): - assert isinstance(db, basestring) + assert isinstance(db, pycompat.string_types) if exp_db_exist(db): _logger.info('RESTORE DB: %s already exists', db) raise Exception("Database already exists") diff --git a/odoo/service/model.py b/odoo/service/model.py index 185d5ce72cc..47eeeab1af1 100644 --- a/odoo/service/model.py +++ b/odoo/service/model.py @@ -10,7 +10,6 @@ import time import odoo from odoo.exceptions import UserError, ValidationError, QWebException from odoo.models import check_method_name -from odoo.tools import pycompat from odoo.tools.translate import translate from odoo.tools.translate import _ @@ -68,10 +67,6 @@ def check(f): except Exception: pass - uid = 1 - if args and isinstance(args[0], pycompat.integer_types): - uid = args[0] - lang = ctx and ctx.get('lang') if not (lang or hasattr(src, '__call__')): return src @@ -119,7 +114,7 @@ def check(f): time.sleep(wait_time) except IntegrityError as inst: registry = odoo.registry(dbname) - for key in pycompat.keys(registry._sql_error): + for key in registry._sql_error.keys(): if key in inst[0]: raise ValidationError(tr(registry._sql_error[key], 'sql_constraint') or inst[0]) if inst.pgcode in (errorcodes.NOT_NULL_VIOLATION, errorcodes.FOREIGN_KEY_VIOLATION, errorcodes.RESTRICT_VIOLATION): diff --git a/odoo/service/server.py b/odoo/service/server.py index 56acaaed901..c3b523efe7e 100644 --- a/odoo/service/server.py +++ b/odoo/service/server.py @@ -40,7 +40,7 @@ from odoo.modules.module import run_unit_tests, runs_post_install from odoo.modules.registry import Registry from odoo.release import nt_service_name from odoo.tools import config -from odoo.tools import stripped_sys_argv, dumpstacks, log_ormcache_stats, pycompat +from odoo.tools import stripped_sys_argv, dumpstacks, log_ormcache_stats _logger = logging.getLogger(__name__) @@ -216,7 +216,7 @@ class ThreadedServer(CommonServer): time.sleep(SLEEP_INTERVAL + number) # Steve Reich timing style registries = odoo.modules.registry.Registry.registries _logger.debug('cron%d polling for jobs', number) - for db_name, registry in pycompat.items(registries): + for db_name, registry in registries.items(): while registry.ready: try: acquired = ir_cron._acquire_job(db_name) @@ -433,7 +433,7 @@ class PreforkServer(CommonServer): def pipe_ping(self, pipe): try: - os.write(pipe[1], '.') + os.write(pipe[1], b'.') except IOError as e: if e.errno not in [errno.EAGAIN, errno.EINTR]: raise @@ -525,7 +525,7 @@ class PreforkServer(CommonServer): def process_timeout(self): now = time.time() - for (pid, worker) in pycompat.items(self.workers): + for (pid, worker) in self.workers.items(): if worker.watchdog_timeout is not None and \ (now - worker.watchdog_time) >= worker.watchdog_timeout: _logger.error("%s (%s) timeout after %ss", @@ -546,8 +546,8 @@ class PreforkServer(CommonServer): def sleep(self): try: # map of fd -> worker - fds = {w.watchdog_pipe[0]: w for k, w in pycompat.items(self.workers)} - fd_in = list(pycompat.keys(fds)) + [self.pipe[0]] + fds = {w.watchdog_pipe[0]: w for w in self.workers.values()} + fd_in = list(fds) + [self.pipe[0]] # check for ping or internal wakeups ready = select.select(fd_in, [], [], self.beat) # update worker watchdogs @@ -771,7 +771,7 @@ class WorkerHTTP(Worker): client, addr = self.multi.socket.accept() self.process_request(client, addr) except socket.error as e: - if e[0] not in (errno.EAGAIN, errno.ECONNABORTED): + if e.errno not in (errno.EAGAIN, errno.ECONNABORTED): raise def start(self): @@ -887,7 +887,7 @@ def load_test_file_yml(registry, test_file): def load_test_file_py(registry, test_file): # Locate python module based on its filename and run the tests test_path, _ = os.path.splitext(os.path.abspath(test_file)) - for mod_name, mod_mod in list(pycompat.items(sys.modules)): + for mod_name, mod_mod in list(sys.modules.items()): if mod_mod: mod_path, _ = os.path.splitext(getattr(mod_mod, '__file__', '')) if test_path == mod_path: diff --git a/odoo/service/wsgi_server.py b/odoo/service/wsgi_server.py index ec699dc13e0..95276f336b1 100644 --- a/odoo/service/wsgi_server.py +++ b/odoo/service/wsgi_server.py @@ -12,12 +12,15 @@ import sys import threading import traceback + try: from xmlrpc import client as xmlrpclib except ImportError: # pylint: disable=bad-python3-import import xmlrpclib +import werkzeug.exceptions +import werkzeug.wrappers import werkzeug.serving import werkzeug.contrib.fixers @@ -36,90 +39,54 @@ RPC_FAULT_CODE_WARNING = 2 RPC_FAULT_CODE_ACCESS_DENIED = 3 RPC_FAULT_CODE_ACCESS_ERROR = 4 -def xmlrpc_return(start_response, service, method, params, string_faultcode=False): - """ - Helper to call a service's method with some params, using a wsgi-supplied - ``start_response`` callback. - - This is the place to look at to see the mapping between core exceptions - and XML-RPC fault codes. - """ - # Map OpenERP core exceptions to XML-RPC fault codes. Specific exceptions - # defined in ``odoo.exceptions`` are mapped to specific fault codes; - # all the other exceptions are mapped to the generic - # RPC_FAULT_CODE_APPLICATION_ERROR value. - # This also mimics SimpleXMLRPCDispatcher._marshaled_dispatch() for - # exception handling. - try: - result = odoo.http.dispatch_rpc(service, method, params) - response = xmlrpclib.dumps((result,), methodresponse=1, allow_none=False, encoding=None) - except Exception as e: - if string_faultcode: - response = xmlrpc_handle_exception_string(e) - else: - response = xmlrpc_handle_exception_int(e) - start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))]) - return [response] - def xmlrpc_handle_exception_int(e): if isinstance(e, odoo.exceptions.UserError): fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, odoo.tools.ustr(e.value)) - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance(e, odoo.exceptions.RedirectWarning): fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, str(e)) - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance(e, odoo.exceptions.MissingError): fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, str(e)) - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance (e, odoo.exceptions.AccessError): fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_ERROR, str(e)) - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance(e, odoo.exceptions.AccessDenied): fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e)) - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance(e, odoo.exceptions.DeferredException): info = e.traceback # Which one is the best ? formatted_info = "".join(traceback.format_exception(*info)) #formatted_info = odoo.tools.exception_to_unicode(e) + '\n' + info fault = xmlrpclib.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info) - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) else: info = sys.exc_info() # Which one is the best ? formatted_info = "".join(traceback.format_exception(*info)) #formatted_info = odoo.tools.exception_to_unicode(e) + '\n' + info fault = xmlrpclib.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info) - response = xmlrpclib.dumps(fault, allow_none=None, encoding=None) - return response + + return xmlrpclib.dumps(fault, allow_none=None) def xmlrpc_handle_exception_string(e): if isinstance(e, odoo.exceptions.UserError): fault = xmlrpclib.Fault('warning -- %s\n\n%s' % (e.name, e.value), '') - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance(e, odoo.exceptions.RedirectWarning): fault = xmlrpclib.Fault('warning -- Warning\n\n' + str(e), '') elif isinstance(e, odoo.exceptions.MissingError): fault = xmlrpclib.Fault('warning -- MissingError\n\n' + str(e), '') - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance(e, odoo.exceptions.AccessError): fault = xmlrpclib.Fault('warning -- AccessError\n\n' + str(e), '') - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance(e, odoo.exceptions.AccessDenied): fault = xmlrpclib.Fault('AccessDenied', str(e)) - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) elif isinstance(e, odoo.exceptions.DeferredException): info = e.traceback formatted_info = "".join(traceback.format_exception(*info)) fault = xmlrpclib.Fault(odoo.tools.ustr(e), formatted_info) - response = xmlrpclib.dumps(fault, allow_none=False, encoding=None) #InternalError else: info = sys.exc_info() formatted_info = "".join(traceback.format_exception(*info)) fault = xmlrpclib.Fault(odoo.tools.exception_to_unicode(e), formatted_info) - response = xmlrpclib.dumps(fault, allow_none=None, encoding=None) - return response + + return xmlrpclib.dumps(fault, allow_none=None, encoding=None) def wsgi_xmlrpc(environ, start_response): """ Two routes are available for XML-RPC @@ -136,14 +103,25 @@ def wsgi_xmlrpc(environ, start_response): # Distinguish betweed the 2 faultCode modes string_faultcode = True + service = environ['PATH_INFO'][len('/xmlrpc/'):] if environ['PATH_INFO'].startswith('/xmlrpc/2/'): - service = environ['PATH_INFO'][len('/xmlrpc/2/'):] + service = service[len('2/'):] string_faultcode = False - else: - service = environ['PATH_INFO'][len('/xmlrpc/'):] params, method = xmlrpclib.loads(data) - return xmlrpc_return(start_response, service, method, params, string_faultcode) + try: + result = odoo.http.dispatch_rpc(service, method, params) + response = xmlrpclib.dumps((result,), methodresponse=1, allow_none=False) + except Exception as e: + if string_faultcode: + response = xmlrpc_handle_exception_string(e) + else: + response = xmlrpc_handle_exception_int(e) + + return werkzeug.wrappers.Response( + response=response, + mimetype='text/xml', + )(environ, start_response) def application_unproxied(environ, start_response): """ WSGI entry point.""" @@ -168,9 +146,7 @@ def application_unproxied(environ, start_response): return result # We never returned from the loop. - response = 'No handler found.\n' - start_response('404 Not Found', [('Content-Type', 'text/plain'), ('Content-Length', str(len(response)))]) - return [response] + return werkzeug.exceptions.NotFound("No handler found.\n")(environ, start_response) def application(environ, start_response): if config['proxy_mode'] and 'HTTP_X_FORWARDED_HOST' in environ: diff --git a/odoo/sql_db.py b/odoo/sql_db.py index efc0e51972d..749abc69932 100644 --- a/odoo/sql_db.py +++ b/odoo/sql_db.py @@ -21,8 +21,6 @@ from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT, ISOLATION_LEVEL_READ from psycopg2.pool import PoolError from werkzeug import urls -from .tools import pycompat - psycopg2.extensions.register_type(psycopg2.extensions.UNICODE) _logger = logging.getLogger(__name__) @@ -43,7 +41,7 @@ def undecimalize(symb, cr): return None return float(symb) -for name, typeoid in pycompat.items(types_mapping): +for name, typeoid in types_mapping.items(): psycopg2.extensions.register_type(psycopg2.extensions.new_type(typeoid, name, lambda x, cr: x)) psycopg2.extensions.register_type(psycopg2.extensions.new_type((700, 701, 1700,), 'float', undecimalize)) @@ -269,7 +267,7 @@ class Cursor(object): sqllogs = {'from': self.sql_from_log, 'into': self.sql_into_log} sum = 0 if sqllogs[type]: - sqllogitems = pycompat.items(sqllogs[type]) + sqllogitems = sqllogs[type].items() _logger.debug("SQL LOG %s:", type) for r in sorted(sqllogitems, key=lambda k: k[1]): delay = timedelta(microseconds=r[1][1]) diff --git a/odoo/tests/common.py b/odoo/tests/common.py index 5afa24a20c8..cf1c664d229 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -313,9 +313,11 @@ class HttpCase(TransactionCase): Other lines are relayed to the test log. """ + logger = _logger.getChild('phantomjs') t0 = datetime.now() td = timedelta(seconds=timeout) buf = bytearray() + pid = phantom.stdout.fileno() while True: # timeout self.assertLess(datetime.now() - t0, td, @@ -323,7 +325,7 @@ class HttpCase(TransactionCase): # read a byte try: - ready, _, _ = select.select([phantom.stdout], [], [], 0.5) + ready, _, _ = select.select([pid], [], [], 0.5) except select.error as e: # In Python 2, select.error has no relation to IOError or # OSError, and no errno/strerror/filename, only a pair of @@ -333,39 +335,35 @@ class HttpCase(TransactionCase): continue raise - if ready: - s = phantom.stdout.read(1) - if not s: - break - buf.append(s) + if not ready: + continue + + s = os.read(pid, 4096) + if not s: + self.fail("Ran out of data to read") + buf.extend(s) # process lines - if '\n' in buf and (not buf.startswith('<phantomLog>') or '</phantomLog>' in buf): - if buf.startswith('<phantomLog>'): - line = buf[12:buf.index('</phantomLog>')] - buf = bytearray() + while b'\n' in buf and (not buf.startswith(b'<phantomLog>') or b'</phantomLog>' in buf): + + if buf.startswith(b'<phantomLog>'): + line, buf = buf[12:].split(b'</phantomLog>\n', 1) else: - line, buf = buf.split('\n', 1) - line = str(line) + line, buf = buf.split(b'\n', 1) + line = line.decode('utf-8') lline = line.lower() if lline.startswith(("error", "server application error")): try: # when errors occur the execution stack may be sent as a JSON prefix = lline.index('error') + 6 - _logger.error("phantomjs: %s", pformat(json.loads(line[prefix:]))) + self.fail(pformat(json.loads(line[prefix:]))) except ValueError: - line_ = line.split('\n\n') - _logger.error("phantomjs: %s", line_[0]) - # The second part of the log is for debugging - if len(line_) > 1: - _logger.info("phantomjs: \n%s", line.split('\n\n', 1)[1]) - pass - break + self.fail(lline) elif lline.startswith("warning"): - _logger.warn("phantomjs: %s", line) + logger.warn(line) else: - _logger.info("phantomjs: %s", line) + logger.info(line) if line == "ok": return True @@ -382,40 +380,40 @@ class HttpCase(TransactionCase): phantom = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=None, close_fds=True) except OSError: raise unittest.SkipTest("PhantomJS not found") - result = False try: result = self.phantom_poll(phantom, timeout) + self.assertTrue( + result, + "PhantomJS test completed without reporting success; " + "the log may contain errors or hints.") finally: # kill phantomjs if phantom.exit() wasn't called in the test if phantom.poll() is None: + _logger.info("Terminating phantomjs") phantom.terminate() phantom.wait() - - # check PhantomJS health - from signal import SIGSEGV - _logger.info("Phantom JS return code: %d" % phantom.returncode) - if phantom.returncode == -SIGSEGV: - _logger.error("Phantom JS has crashed (segmentation fault) during testing; log may not be relevant") + else: + # if we had to terminate phantomjs its return code is + # always -15 so we don't care + # check PhantomJS health + from signal import SIGSEGV + _logger.info("Phantom JS return code: %d" % phantom.returncode) + if phantom.returncode == -SIGSEGV: + _logger.error("Phantom JS has crashed (segmentation fault) during testing; log may not be relevant") self._wait_remaining_requests() - # we ignore phantomjs return code as we kill it as soon as we have ok - _logger.info("phantom_run execution finished") - self.assertTrue( - result, - "PhantomJS test completed without reporting success; " - "the log may contain errors or hints.") def _wait_remaining_requests(self): t0 = int(time.time()) for thread in threading.enumerate(): if thread.name.startswith('odoo.service.http.request.'): - thread.join_retry_count = 10 + join_retry_count = 10 while thread.isAlive(): # Need a busyloop here as thread.join() masks signals # and would prevent the forced shutdown. thread.join(0.05) - thread.join_retry_count -= 1 - if thread.join_retry_count < 0: + join_retry_count -= 1 + if join_retry_count < 0: _logger.warning("Stop waiting for thread %s handling request for url %s", thread.name, thread.url) break diff --git a/odoo/tools/__init__.py b/odoo/tools/__init__.py index 4eb84a9de16..3feb0940ff6 100644 --- a/odoo/tools/__init__.py +++ b/odoo/tools/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import copy +from . import pycompat from . import win32 from . import appdirs from . import pdf diff --git a/odoo/tools/appdirs.py b/odoo/tools/appdirs.py index 8617dabfa4f..6176e6bce75 100644 --- a/odoo/tools/appdirs.py +++ b/odoo/tools/appdirs.py @@ -13,6 +13,7 @@ from __future__ import print_function # http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120 # - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html # - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html +from . import pycompat __version_info__ = (1, 3, 0) __version__ = '.'.join(str(v) for v in __version_info__) @@ -21,12 +22,6 @@ __version__ = '.'.join(str(v) for v in __version_info__) import sys import os -PY3 = sys.version_info[0] == 3 - -if PY3: - unicode = str - - def user_data_dir(appname=None, appauthor=None, version=None, roaming=False): r"""Return full path to the user-specific data dir for this application. @@ -397,7 +392,7 @@ def _get_win_folder_with_pywin32(csidl_name): # not return unicode strings when there is unicode data in the # path. try: - dir = unicode(dir) + dir = pycompat.text_type(dir) # Downgrade to short path name if have highbit chars. See # <http://bugs.activestate.com/show_bug.cgi?id=85099>. diff --git a/odoo/tools/cache.py b/odoo/tools/cache.py index 2554331ce9b..b6b23175773 100644 --- a/odoo/tools/cache.py +++ b/odoo/tools/cache.py @@ -8,7 +8,7 @@ from decorator import decorator from inspect import formatargspec, getargspec import logging -from odoo.tools import pycompat +from . import pycompat unsafe_eval = eval @@ -135,7 +135,7 @@ class ormcache_multi(ormcache): def determine_key(self): """ Determine the function that computes a cache key from arguments. """ assert self.skiparg is None, "ormcache_multi() no longer supports skiparg" - assert isinstance(self.multi, basestring), "ormcache_multi() parameter multi must be an argument name" + assert isinstance(self.multi, pycompat.string_types), "ormcache_multi() parameter multi must be an argument name" super(ormcache_multi, self).determine_key() @@ -202,10 +202,10 @@ def log_ormcache_stats(sig=None, frame=None): me = threading.currentThread() me_dbname = getattr(me, 'dbname', 'n/a') entries = defaultdict(int) - for dbname, reg in pycompat.items(Registry.registries): + for dbname, reg in Registry.registries.items(): for key in reg.cache: entries[(dbname,) + key[:2]] += 1 - for key, count in sorted(pycompat.items(entries)): + for key, count in sorted(entries.items()): dbname, model_name, method = key me.dbname = dbname stat = STAT[key] diff --git a/odoo/tools/config.py b/odoo/tools/config.py index 375f55b4714..5a8f7efabfe 100644 --- a/odoo/tools/config.py +++ b/odoo/tools/config.py @@ -5,15 +5,13 @@ try: import configparser as ConfigParser except ImportError: import ConfigParser - + +import logging import optparse import os import sys import odoo -import odoo.conf -import odoo.loglevels as loglevels -import logging -import odoo.release as release +from .. import release, conf, loglevels from . import appdirs, pycompat @@ -57,7 +55,7 @@ def _deduplicate_loggers(loggers): # there are no duplicates within the output sequence return ( '{}:{}'.format(logger, level) - for logger, level in pycompat.items(dict(it.split(':') for it in loggers)) + for logger, level in dict(it.split(':') for it in loggers).items() ) @@ -398,10 +396,10 @@ class configmanager(object): if getattr(opt, arg): self.options[arg] = getattr(opt, arg) # ... or keep, but cast, the config file value. - elif isinstance(self.options[arg], basestring) and self.casts[arg].type in optparse.Option.TYPE_CHECKER: + elif isinstance(self.options[arg], pycompat.string_types) and self.casts[arg].type in optparse.Option.TYPE_CHECKER: self.options[arg] = optparse.Option.TYPE_CHECKER[self.casts[arg].type](self.casts[arg], arg, self.options[arg]) - if isinstance(self.options['log_handler'], basestring): + if isinstance(self.options['log_handler'], pycompat.string_types): self.options['log_handler'] = self.options['log_handler'].split(',') self.options['log_handler'].extend(opt.log_handler) @@ -433,7 +431,7 @@ class configmanager(object): if getattr(opt, arg) is not None: self.options[arg] = getattr(opt, arg) # ... or keep, but cast, the config file value. - elif isinstance(self.options[arg], basestring) and self.casts[arg].type in optparse.Option.TYPE_CHECKER: + elif isinstance(self.options[arg], pycompat.string_types) and self.casts[arg].type in optparse.Option.TYPE_CHECKER: self.options[arg] = optparse.Option.TYPE_CHECKER[self.casts[arg].type](self.casts[arg], arg, self.options[arg]) self.options['root_path'] = os.path.abspath(os.path.expanduser(os.path.expandvars(os.path.join(os.path.dirname(__file__), '..')))) @@ -471,9 +469,9 @@ class configmanager(object): if opt.save: self.save() - odoo.conf.addons_paths = self.options['addons_path'].split(',') + conf.addons_paths = self.options['addons_path'].split(',') - odoo.conf.server_wide_modules = [ + conf.server_wide_modules = [ m.strip() for m in self.options['server_wide_modules'].split(',') if m.strip() ] @@ -529,9 +527,9 @@ class configmanager(object): def save(self): p = ConfigParser.RawConfigParser() - loglevelnames = dict(pycompat.izip(pycompat.values(self._LOGLEVELS), pycompat.keys(self._LOGLEVELS))) + loglevelnames = dict(pycompat.izip(self._LOGLEVELS.values(), self._LOGLEVELS)) p.add_section('options') - for opt in sorted(pycompat.keys(self.options)): + for opt in sorted(self.options): if opt in ('version', 'language', 'translate_out', 'translate_in', 'overwrite_existing_translations', 'init', 'update'): continue if opt in self.blacklist_for_save: @@ -543,9 +541,9 @@ class configmanager(object): else: p.set('options', opt, self.options[opt]) - for sec in sorted(pycompat.keys(self.misc)): + for sec in sorted(self.misc): p.add_section(sec) - for opt in sorted(pycompat.keys(self.misc[sec])): + for opt in sorted(self.misc[sec]): p.set(sec,opt,self.misc[sec][opt]) # try to create the directories and write the file @@ -575,7 +573,7 @@ class configmanager(object): def __setitem__(self, key, value): self.options[key] = value - if key in self.options and isinstance(self.options[key], basestring) and \ + if key in self.options and isinstance(self.options[key], pycompat.string_types) and \ key in self.casts and self.casts[key].type in optparse.Option.TYPE_CHECKER: self.options[key] = optparse.Option.TYPE_CHECKER[self.casts[key].type](self.casts[key], key, self.options[key]) diff --git a/odoo/tools/convert.py b/odoo/tools/convert.py index 779abec9db1..57b692f7ae4 100644 --- a/odoo/tools/convert.py +++ b/odoo/tools/convert.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import base64 import io -import csv import logging import os.path import re @@ -16,7 +15,6 @@ import pytz from lxml import etree, builder import odoo -import odoo.release from . import assertion_report, pycompat from .config import config from .misc import file_open, unquote, ustr, SKIPPED_ELEMENT_TYPES @@ -87,11 +85,11 @@ def _fix_multiple_roots(node): def _eval_xml(self, node, env): if node.tag in ('field','value'): t = node.get('type','char') - f_model = node.get('model', '').encode('utf-8') + f_model = node.get('model') if node.get('search'): - f_search = node.get("search",'').encode('utf-8') - f_use = node.get("use",'id').encode('utf-8') - f_name = node.get("name",'').encode('utf-8') + f_search = node.get("search") + f_use = node.get("use",'id') + f_name = node.get("name") idref2 = {} if f_search: idref2 = _get_idref(self, env, f_model, self.idref) @@ -108,7 +106,7 @@ def _eval_xml(self, node, env): if isinstance(f_val, tuple): f_val = f_val[0] return f_val - a_eval = node.get('eval','') + a_eval = node.get('eval') if a_eval: idref2 = _get_idref(self, env, f_model, self.idref) try: @@ -118,32 +116,40 @@ def _eval_xml(self, node, env): 'Could not eval(%s) for %s in %s', a_eval, node.get('name'), env.context) raise def _process(s): - matches = re.finditer(r'[^%]%\((.*?)\)[ds]', s) - done = [] + matches = re.finditer(br'[^%]%\((.*?)\)[ds]', s) + done = set() for m in matches: found = m.group()[1:] if found in done: continue - done.append(found) - id = m.groups()[0] + done.add(found) + id = m.groups()[0].decode('utf-8') if not id in self.idref: self.idref[id] = self.id_get(id) - s = s.replace(found, str(self.idref[id])) - s = s.replace('%%', '%') # Quite wierd but it's for (somewhat) backward compatibility sake + # So funny story: in Python 3, bytes(n: int) returns a + # bytestring of n nuls. In Python 2 it obviously returns the + # stringified number, which is what we're expecting here + s = s.replace(found, pycompat.text_type(self.idref[id]).encode('utf-8')) + s = s.replace(b'%%', b'%') # Quite wierd but it's for (somewhat) backward compatibility sake return s if t == 'xml': _fix_multiple_roots(node) - return '<?xml version="1.0"?>\n'\ - +_process("".join([etree.tostring(n, encoding='utf-8') for n in node])) + return b'<?xml version="1.0"?>\n'\ + +_process(b"".join(etree.tostring(n, encoding='utf-8') for n in node)) if t == 'html': - return _process("".join([etree.tostring(n, encoding='utf-8') for n in node])) + return _process(b"".join(etree.tostring(n, encoding='utf-8') for n in node)) data = node.text if node.get('file'): with file_open(node.get('file'), 'rb') as f: data = f.read() + if t == 'base64': + return base64.b64encode(data) + + # after that, only text content makes sense + data = pycompat.to_text(data) if t == 'file': from ..modules import module path = data.strip() @@ -155,9 +161,6 @@ def _eval_xml(self, node, env): if t == 'char': return data - if t == 'base64': - return data.encode('base64') - if t == 'int': d = data.strip() if d == 'None': @@ -176,7 +179,7 @@ def _eval_xml(self, node, env): return res elif node.tag == "function": args = [] - a_eval = node.get('eval','') + a_eval = node.get('eval') # FIXME: should probably be exclusive if a_eval: self.idref['ref'] = self.id_get @@ -185,7 +188,7 @@ def _eval_xml(self, node, env): return_val = _eval_xml(self, n, env) if return_val is not None: args.append(return_val) - model = env[node.get('model', '')] + model = env[node.get('model')] method = node.get('name') # this one still depends on the old API return odoo.api.call_kw(model, method, args, {}) @@ -212,8 +215,8 @@ class xml_import(object): return self.noupdate or (len(data_node) and self.nodeattr2bool(data_node, 'noupdate', False)) def get_context(self, data_node, node, eval_dict): - data_node_context = (len(data_node) and data_node.get('context','').encode('utf8')) - node_context = node.get("context",'').encode('utf8') + data_node_context = (len(data_node) and data_node.get('context','')) + node_context = node.get("context") context = {} for ctx in (data_node_context, node_context): if ctx: @@ -252,7 +255,7 @@ form: module.record_id""" % (xml_id,) def _tag_delete(self, rec, data_node=None, mode=None): d_model = rec.get("model") - d_search = rec.get("search",'').encode('utf-8') + d_search = rec.get("search") d_id = rec.get("id") records = self.env[d_model] @@ -283,7 +286,7 @@ form: module.record_id""" % (xml_id,) def _tag_report(self, rec, data_node=None, mode=None): res = {} for dest,f in (('name','string'),('model','model'),('report_name','name')): - res[dest] = rec.get(f,'').encode('utf8') + res[dest] = rec.get(f) assert res[dest], "Attribute %s of report is empty !" % (f,) for field, dest in (('attachment', 'attachment'), ('attachment_use', 'attachment_use'), @@ -294,7 +297,7 @@ form: module.record_id""" % (xml_id,) ('print_report_name', 'print_report_name'), ): if rec.get(field): - res[dest] = rec.get(field).encode('utf8') + res[dest] = rec.get(field) if rec.get('auto'): res['auto'] = safe_eval(rec.get('auto','False')) if rec.get('header'): @@ -302,7 +305,7 @@ form: module.record_id""" % (xml_id,) res['multi'] = rec.get('multi') and safe_eval(rec.get('multi','False')) - xml_id = rec.get('id','').encode('utf8') + xml_id = rec.get('id','') self._test_xml_id(xml_id) if rec.get('groups'): @@ -346,20 +349,20 @@ form: module.record_id""" % (xml_id,) return def _tag_act_window(self, rec, data_node=None, mode=None): - name = rec.get('name','').encode('utf-8') - xml_id = rec.get('id','').encode('utf8') + name = rec.get('name') + xml_id = rec.get('id','') self._test_xml_id(xml_id) - type = rec.get('type','').encode('utf-8') or 'ir.actions.act_window' + type = rec.get('type') or 'ir.actions.act_window' view_id = False if rec.get('view_id'): - view_id = self.id_get(rec.get('view_id','').encode('utf-8')) - domain = rec.get('domain','').encode('utf-8') or '[]' - res_model = rec.get('res_model','').encode('utf-8') - src_model = rec.get('src_model','').encode('utf-8') - view_type = rec.get('view_type','').encode('utf-8') or 'form' - view_mode = rec.get('view_mode','').encode('utf-8') or 'tree,form' - usage = rec.get('usage','').encode('utf-8') - limit = rec.get('limit','').encode('utf-8') + view_id = self.id_get(rec.get('view_id')) + domain = rec.get('domain') or '[]' + res_model = rec.get('res_model') + src_model = rec.get('src_model') + view_type = rec.get('view_type') or 'form' + view_mode = rec.get('view_mode') or 'tree,form' + usage = rec.get('usage') + limit = rec.get('limit') uid = self.uid # Act_window's 'domain' and 'context' contain mostly literals @@ -445,9 +448,9 @@ form: module.record_id""" % (xml_id,) model = src_model if isinstance(model, (list, tuple)): model, res_id = model - keyword = rec.get('key2','').encode('utf-8') or 'client_action_relate' - value = 'ir.actions.act_window,'+str(id) - replace = rec.get('replace','') or True + keyword = rec.get('key2') or 'client_action_relate' + value = 'ir.actions.act_window,%s' % id + replace = rec.get('replace') or True self.env['ir.values'].set_action(xml_id, action_slot=keyword, model=model, action=value, res_id=res_id) # TODO add remove ir.model.data @@ -461,7 +464,7 @@ form: module.record_id""" % (xml_id,) return res = {} for field in rec.findall('./field'): - f_name = field.get("name",'').encode('utf-8') + f_name = field.get("name") f_val = _eval_xml(self, field, self.env) res[f_name] = f_val ir_values = self.env['ir.values'] @@ -475,7 +478,7 @@ form: module.record_id""" % (xml_id,) ir_values.set_action(res['name'], action_slot=res['key2'], model=model, action=res['value'], res_id=res_id) def _tag_menuitem(self, rec, data_node=None, mode=None): - rec_id = rec.get("id",'').encode('ascii') + rec_id = rec.get("id") self._test_xml_id(rec_id) # The parent attribute was specified, if non-empty determine its ID, otherwise @@ -495,7 +498,7 @@ form: module.record_id""" % (xml_id,) res = None if rec.get('action'): - a_action = rec.get('action','').encode('utf8') + a_action = rec.get('action') # determine the type of action action_type, action_id = self.model_id_get(a_action) @@ -548,13 +551,13 @@ form: module.record_id""" % (xml_id,) if self.isnoupdate(data_node) and self.mode != 'init': return - rec_model = rec.get("model",'').encode('ascii') - rec_id = rec.get("id",'').encode('ascii') + rec_model = rec.get("model") + rec_id = rec.get("id") self._test_xml_id(rec_id) - rec_src = rec.get("search",'').encode('utf8') + rec_src = rec.get("search") rec_src_count = rec.get("count") - rec_string = rec.get("string",'').encode('utf8') or 'unknown' + rec_string = rec.get("string") or 'unknown' records = None eval_dict = {'ref': self.id_get} @@ -587,7 +590,7 @@ form: module.record_id""" % (xml_id,) globals_dict['ref'] = ref globals_dict['_ref'] = ref for test in rec.findall('./test'): - f_expr = test.get("expr",'').encode('utf-8') + f_expr = test.get("expr",'') env = self.env(user=uid, context=context) expected_value = _eval_xml(self, test, env) or True expression_value = safe_eval(f_expr, globals_dict) @@ -597,16 +600,16 @@ form: module.record_id""" % (xml_id,) ' xmltag: %s\n' \ ' expected value: %r\n' \ ' obtained value: %r\n' \ - % (rec_string, etree.tostring(test), expected_value, expression_value) + % (rec_string, etree.tostring(test, encoding='unicode'), expected_value, expression_value) _logger.error(msg) return else: # all tests were successful for this assertion tag (no break) self.assertion_report.record_success() def _tag_record(self, rec, data_node=None, mode=None): - rec_model = rec.get("model").encode('ascii') + rec_model = rec.get("model") model = self.env[rec_model] - rec_id = rec.get("id",'').encode('ascii') + rec_id = rec.get("id",'') rec_context = rec.get("context", {}) if rec_context: rec_context = safe_eval(rec_context) @@ -647,13 +650,13 @@ form: module.record_id""" % (xml_id,) res = {} for field in rec.findall('./field'): #TODO: most of this code is duplicated above (in _eval_xml)... - f_name = field.get("name").encode('utf-8') - f_ref = field.get("ref",'').encode('utf-8') - f_search = field.get("search",'').encode('utf-8') - f_model = field.get("model",'').encode('utf-8') + f_name = field.get("name") + f_ref = field.get("ref") + f_search = field.get("search") + f_model = field.get("model") if not f_model and f_name in model._fields: f_model = model._fields[f_name].comodel_name - f_use = field.get("use",'').encode('utf-8') or 'id' + f_use = field.get("use",'') or 'id' f_val = False if f_search: @@ -683,7 +686,7 @@ form: module.record_id""" % (xml_id,) f_val = int(f_val) elif model._fields[f_name].type in ['float', 'monetary']: f_val = float(f_val) - elif model._fields[f_name].type == 'boolean' and isinstance(f_val, basestring): + elif model._fields[f_name].type == 'boolean' and isinstance(f_val, pycompat.string_types): f_val = str2bool(f_val) res[f_name] = f_val @@ -696,7 +699,7 @@ form: module.record_id""" % (xml_id,) def _tag_template(self, el, data_node=None, mode=None): # This helper transforms a <template> element into a <record> and forwards it - tpl_id = el.get('id', el.get('t-name', '')).encode('ascii') + tpl_id = el.get('id', el.get('t-name')) full_tpl_id = tpl_id if '.' not in full_tpl_id: full_tpl_id = '%s.%s' % (self.module, tpl_id) @@ -785,7 +788,7 @@ form: module.record_id""" % (xml_id,) exc_info = sys.exc_info() pycompat.reraise( ParseError, - ParseError(ustr(e), etree.tostring(rec).rstrip(), rec.getroottree().docinfo.URL, rec.sourceline), + ParseError(ustr(e), etree.tostring(rec, encoding='unicode').rstrip(), rec.getroottree().docinfo.URL, rec.sourceline), exc_info[2] ) return True @@ -817,10 +820,9 @@ form: module.record_id""" % (xml_id,) def convert_file(cr, module, filename, idref, mode='update', noupdate=False, kind=None, report=None, pathname=None): if pathname is None: pathname = os.path.join(module, filename) - fp = file_open(pathname) ext = os.path.splitext(filename)[1].lower() - try: + with file_open(pathname, 'rb') as fp: if ext == '.csv': convert_csv_import(cr, module, pathname, fp.read(), idref, mode, noupdate) elif ext == '.sql': @@ -833,8 +835,6 @@ def convert_file(cr, module, filename, idref, mode='update', noupdate=False, kin pass # .js files are valid but ignored here. else: raise ValueError("Can't load unknown file type %s.", filename) - finally: - fp.close() def convert_sql_import(cr, fp): cr.execute(fp.read()) @@ -845,28 +845,21 @@ def convert_csv_import(cr, module, fname, csvcontent, idref=None, mode='init', quote: " delimiter: , encoding: utf-8''' - if not idref: - idref={} - model = ('.'.join(fname.split('.')[:-1]).split('-'))[0] - #remove folder path from model - head, model = os.path.split(model) + filename, _ext = os.path.splitext(os.path.basename(fname)) + model = filename.split('-')[0] - input = io.BytesIO(csvcontent) #FIXME - reader = csv.reader(input, quotechar='"', delimiter=',') + reader = pycompat.csv_reader(io.BytesIO(csvcontent), quotechar='"', delimiter=',') fields = next(reader) if not (mode == 'init' or 'id' in fields): _logger.error("Import specification does not contain 'id' and we are in init mode, Cannot continue.") return - datas = [] - for line in reader: - if not (line and any(line)): - continue - try: - datas.append([ustr(v) for v in line]) - except Exception: - _logger.error("Cannot import the line: %s", line) + # filter out empty lines (any([]) == False) and lines containing only empty cells + datas = [ + line for line in reader + if any(line) + ] context = { 'mode': mode, @@ -893,7 +886,7 @@ def convert_xml_import(cr, module, xmlfile, idref=None, mode='init', noupdate=Fa if idref is None: idref={} - if isinstance(xmlfile, basestring): + if isinstance(xmlfile, pycompat.string_types): xml_filename = xmlfile else: xml_filename = xmlfile.name diff --git a/odoo/tools/float_utils.py b/odoo/tools/float_utils.py index d71576adc92..93f3c79ef2a 100644 --- a/odoo/tools/float_utils.py +++ b/odoo/tools/float_utils.py @@ -6,6 +6,25 @@ import math from odoo.tools import pycompat +if not pycompat.PY2: + import builtins + def round(f): + # P3's builtin round differs from P2 in the following manner: + # * it rounds half to even rather than up (away from 0) + # * round(-0.) loses the sign (it returns -0 rather than 0) + # * round(x) returns an int rather than a float + # + # this compatibility shim implements Python 2's round in terms of + # Python 3's so that important rounding error under P3 can be + # trivially fixed, assuming the P2 behaviour to be debugged and + # correct. + roundf = builtins.round(f) + if builtins.round(f + 1) - roundf != 1: + return f + math.copysign(0.5, f) + # copysign ensures round(-0.) -> -0 *and* result is a float + return math.copysign(roundf, f) +else: + round = round def _float_check_precision(precision_digits=None, precision_rounding=None): assert (precision_digits is not None or precision_rounding is not None) and \ diff --git a/odoo/tools/graph.py b/odoo/tools/graph.py index 5e3edabf05a..d43088d97ab 100755 --- a/odoo/tools/graph.py +++ b/odoo/tools/graph.py @@ -5,8 +5,6 @@ import operator import math -from odoo.tools import pycompat - class graph(object): def __init__(self, nodes, transitions, no_ancester=None): @@ -259,7 +257,7 @@ class graph(object): """The ranks are normalized by setting the least rank to zero. """ - least_rank = min(x['x'] for x in pycompat.values(self.result.values)) + least_rank = min(x['x'] for x in self.result.values.values()) if least_rank!=0: for node in self.result: @@ -383,7 +381,7 @@ class graph(object): """Finds actual-order of the nodes with respect to maximum number of nodes in a rank in component """ mid_pos = 0.0 - max_level = max(len(x) for x in pycompat.values(self.levels.values)) + max_level = max(len(x) for x in self.levels.values.values()) for level in self.levels: if level: @@ -474,7 +472,7 @@ class graph(object): """ if self.Is_Cyclic: - max_level = max(len(x) for x in pycompat.values(self.levels.values)) + max_level = max(len(x) for x in self.levels.values.values()) if max_level%2: self.result[self.start]['y'] = (max_level+1)/2 + self.max_order + (self.max_order and 1) @@ -486,7 +484,7 @@ class graph(object): else: self.result[self.start]['y'] = 0 self.tree_order(self.start, 0) - min_order = math.fabs(min(x['y'] for x in pycompat.values(self.result.values))) + min_order = math.fabs(min(x['y'] for x in self.result.values.values())) index = self.start_nodes.index(self.start) same = False @@ -540,7 +538,7 @@ class graph(object): self.result[start]['y'] = base + factor factor += 1 - self.max_order = max(x['y'] for x in pycompat.values(self.result.values)) + self.max_order = max(x['y'] for x in self.result.values.values()) def find_starts(self): """Finds other start nodes of the graph in the case when graph is disconneted @@ -660,7 +658,7 @@ class graph(object): for node in self.no_ancester: for sec_node in self.transitions.get(node, []): - if sec_node in pycompat.keys(self.partial_order): + if sec_node in self.partial_order: self.transitions[self.start_nodes[0]].append(node) break @@ -746,7 +744,7 @@ if __name__=='__main__': for node in nodes: node_res[node] = result[node] - for name,node in pycompat.items(node_res): + for name, node in node_res.items(): draw.arc( (int(node['y']-radius), int(node['x']-radius),int(node['y']+radius), int(node['x']+radius) ), 0, 360, (128,128,128)) draw.text( (int(node['y']), int(node['x'])), str(name), (128,128,128)) diff --git a/odoo/tools/image.py b/odoo/tools/image.py index 791d1a978d3..0f830b0924c 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -try: - import cStringIO as StringIO -except ImportError: - import StringIO +import base64 +import codecs +import io from PIL import Image from PIL import ImageEnhance @@ -52,7 +51,7 @@ def image_resize_image(base64_source, size=(1024, 1024), encoding='base64', file return False if size == (None, None): return base64_source - image_stream = StringIO.StringIO(base64_source.decode(encoding)) + image_stream = io.BytesIO(codecs.decode(base64_source, encoding)) image = Image.open(image_stream) # store filetype here, as Image.new below will lose image.format filetype = (filetype or image.format).upper() @@ -77,9 +76,9 @@ def image_resize_image(base64_source, size=(1024, 1024), encoding='base64', file if image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (filetype == 'JPEG' and image.mode == 'RGBA'): image = image.convert("RGB") - background_stream = StringIO.StringIO() + background_stream = io.BytesIO() image.save(background_stream, filetype) - return background_stream.getvalue().encode(encoding) + return codecs.encode(background_stream.getvalue(), encoding) def image_resize_and_sharpen(image, size, preserve_aspect_ratio=False, factor=2.0): """ @@ -130,7 +129,7 @@ def image_save_for_web(image, fp=None, format=None): if fp: image.save(fp, **opt) else: - img = StringIO.StringIO() + img = io.BytesIO() image.save(img, **opt) return img.getvalue() @@ -172,8 +171,8 @@ def crop_image(data, type='top', ratio=False, thumbnail_ratio=None, image_format """ if not data: return False - image_stream = Image.open(StringIO.StringIO(data.decode('base64'))) - output_stream = StringIO.StringIO() + image_stream = Image.open(io.BytesIO(base64.b64decode(data))) + output_stream = io.BytesIO() w, h = image_stream.size new_h = h new_w = w @@ -199,10 +198,10 @@ def crop_image(data, type='top', ratio=False, thumbnail_ratio=None, image_format raise ValueError('ERROR: invalid value for crop_type') # TDE FIXME: should not have a ratio, makes no sense -> should have maximum width (std: 64; 256 px) if thumbnail_ratio: - thumb_image = Image.open(StringIO.StringIO(output_stream.getvalue())) + thumb_image = Image.open(io.BytesIO(output_stream.getvalue())) thumb_image.thumbnail((new_w // thumbnail_ratio, new_h // thumbnail_ratio), Image.ANTIALIAS) thumb_image.save(output_stream, image_format) - return output_stream.getvalue().encode('base64') + return base64.b64encode(output_stream.getvalue()) # ---------------------------------------- # Colors @@ -215,7 +214,7 @@ def image_colorize(original, randomize=True, color=(255, 255, 255)): :param color: background-color, if not randomize """ # create a new image, based on the original one - original = Image.open(StringIO.StringIO(original)) + original = Image.open(io.BytesIO(original)) image = Image.new('RGB', original.size) # generate the background color, past it as background if randomize: @@ -223,7 +222,7 @@ def image_colorize(original, randomize=True, color=(255, 255, 255)): image.paste(color, box=(0, 0) + original.size) image.paste(original, mask=original) # return the new image - buffer = StringIO.StringIO() + buffer = io.BytesIO() image.save(buffer, 'PNG') return buffer.getvalue() @@ -286,6 +285,6 @@ if __name__=="__main__": assert len(sys.argv)==3, 'Usage to Test: image.py SRC.png DEST.png' - img = open(sys.argv[1],'rb').read().encode('base64') + img = base64.b64encode(open(sys.argv[1],'rb').read()) new = image_resize_image(img, (128,100)) - open(sys.argv[2], 'wb').write(new.decode('base64')) + open(sys.argv[2], 'wb').write(base64.b64decode(new)) diff --git a/odoo/tools/lru.py b/odoo/tools/lru.py index 21ecaf6383a..119fce0e1ee 100644 --- a/odoo/tools/lru.py +++ b/odoo/tools/lru.py @@ -2,7 +2,6 @@ # taken from http://code.activestate.com/recipes/252524-length-limited-o1-lru-cache-implementation/ import threading -from odoo.tools import pycompat from .func import synchronized __all__ = ['LRU'] @@ -110,12 +109,11 @@ class LRU(object): @synchronized() def itervalues(self): - for i,j in pycompat.items(self): - yield j + return iter(self.d.values()) @synchronized() def keys(self): - return list(pycompat.keys(self.d)) + return list(self.d) @synchronized() def pop(self,key): diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py index 530f3e63862..288defdcf1f 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -85,7 +85,7 @@ class _Cleaner(clean.Cleaner): new_node.text = text new_node.tail = tail if attrs: - for key, val in pycompat.items(attrs): + for key, val in attrs.items(): new_node.set(key, val) return new_node @@ -154,7 +154,7 @@ class _Cleaner(clean.Cleaner): if style[0].lower() in self._style_whitelist: valid_styles[style[0].lower()] = style[1] if valid_styles: - el.attrib['style'] = '; '.join('%s: %s' % (key, val) for (key, val) in pycompat.items(valid_styles)) + el.attrib['style'] = '; '.join('%s: %s' % (key, val) for (key, val) in valid_styles.items()) else: del el.attrib['style'] @@ -170,7 +170,7 @@ def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=Fals src = ustr(src, errors='replace') # html: remove encoding attribute inside tags doctype = re.compile(r'(<[^>]*\s)(encoding=(["\'][^"\']*?["\']|[^\s\n\r>]+)(\s[^>]*|/)?>)', re.IGNORECASE | re.DOTALL) - src = doctype.sub(r"", src) + src = doctype.sub(u"", src) logger = logging.getLogger(__name__ + '.html_sanitize') @@ -178,10 +178,10 @@ def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=Fals part = re.compile(r"(<(([^a<>]|a[^<>\s])[^<>]*)@[^<>]+>)", re.IGNORECASE | re.DOTALL) # remove results containing cite="mid:email_like@address" (ex: blockquote cite) # cite_except = re.compile(r"^((?!cite[\s]*=['\"]).)*$", re.IGNORECASE) - src = part.sub(lambda m: ('cite=' not in m.group(1) and 'alt=' not in m.group(1)) and misc.html_escape(m.group(1)) or m.group(1), src) + src = part.sub(lambda m: (u'cite=' not in m.group(1) and u'alt=' not in m.group(1)) and misc.html_escape(m.group(1)) or m.group(1), src) # html encode mako tags <% ... %> to decode them later and keep them alive, otherwise they are stripped by the cleaner - src = src.replace('<%', misc.html_escape('<%')) - src = src.replace('%>', misc.html_escape('%>')) + src = src.replace(u'<%', misc.html_escape(u'<%')) + src = src.replace(u'%>', misc.html_escape(u'%>')) kwargs = { 'page_structure': True, @@ -222,33 +222,34 @@ def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=Fals # some corner cases make the parser crash (such as <SCRIPT/XSS SRC=\"http://ha.ckers.org/xss.js\"></SCRIPT> in test_mail) cleaner = _Cleaner(**kwargs) cleaned = cleaner.clean_html(src) + assert isinstance(cleaned, pycompat.text_type) # MAKO compatibility: $, { and } inside quotes are escaped, preventing correct mako execution - cleaned = cleaned.replace('%24', '$') - cleaned = cleaned.replace('%7B', '{') - cleaned = cleaned.replace('%7D', '}') - cleaned = cleaned.replace('%20', ' ') - cleaned = cleaned.replace('%5B', '[') - cleaned = cleaned.replace('%5D', ']') - cleaned = cleaned.replace('%7C', '|') - cleaned = cleaned.replace('<%', '<%') - cleaned = cleaned.replace('%>', '%>') + cleaned = cleaned.replace(u'%24', u'$') + cleaned = cleaned.replace(u'%7B', u'{') + cleaned = cleaned.replace(u'%7D', u'}') + cleaned = cleaned.replace(u'%20', u' ') + cleaned = cleaned.replace(u'%5B', u'[') + cleaned = cleaned.replace(u'%5D', u']') + cleaned = cleaned.replace(u'%7C', u'|') + cleaned = cleaned.replace(u'<%', u'<%') + cleaned = cleaned.replace(u'%>', u'%>') # html considerations so real html content match database value - cleaned.replace(u'\xa0', ' ') + cleaned.replace(u'\xa0', u' ') except etree.ParserError as e: - if 'empty' in str(e): - return "" + if u'empty' in pycompat.text_type(e): + return u"" if not silent: raise - logger.warning('ParserError obtained when sanitizing %r', src, exc_info=True) - cleaned = '<p>ParserError when sanitizing</p>' + logger.warning(u'ParserError obtained when sanitizing %r', src, exc_info=True) + cleaned = u'<p>ParserError when sanitizing</p>' except Exception: if not silent: raise - logger.warning('unknown error obtained when sanitizing %r', src, exc_info=True) - cleaned = '<p>Unknown error when sanitizing</p>' + logger.warning(u'unknown error obtained when sanitizing %r', src, exc_info=True) + cleaned = u'<p>Unknown error when sanitizing</p>' # this is ugly, but lxml/etree tostring want to put everything in a 'div' that breaks the editor -> remove that - if cleaned.startswith('<div>') and cleaned.endswith('</div>'): + if cleaned.startswith(u'<div>') and cleaned.endswith(u'</div>'): cleaned = cleaned[5:-6] return cleaned diff --git a/odoo/tools/mimetypes.py b/odoo/tools/mimetypes.py index 3abdc22963d..59421cf8bb6 100644 --- a/odoo/tools/mimetypes.py +++ b/odoo/tools/mimetypes.py @@ -10,8 +10,6 @@ import logging import re import zipfile -from odoo.tools import pycompat - __all__ = ['guess_mimetype'] _logger = logging.getLogger(__name__) @@ -35,7 +33,7 @@ def _check_ooxml(data): # then there is a directory whose name denotes the type of the file: # word, pt (powerpoint) or xl (excel) - for dirname, mime in pycompat.items(_ooxml_dirs): + for dirname, mime in _ooxml_dirs.items(): if any(entry.startswith(dirname) for entry in filenames): return mime @@ -51,13 +49,20 @@ _mime_validator = re.compile(r""" (?:\+[\w-]+)? # optional structured syntax specifier """, re.VERBOSE) def _check_open_container_format(data): + # Open Document Format for Office Applications (OpenDocument) Version 1.2 + # + # Part 3: Packages + # 3 Packages + # 3.3 MIME Media Type with io.BytesIO(data) as f, zipfile.ZipFile(f) as z: - # OCF zip files must contain a ``mimetype`` entry + # If a MIME media type for a document exists, then an OpenDocument + # package should contain a file with name "mimetype". if 'mimetype' not in z.namelist(): return False - # it holds the exact mimetype for the file - marcel = z.read('mimetype') + # The content of this file shall be the ASCII encoded MIME media type + # associated with the document. + marcel = z.read('mimetype').decode('ascii') # check that it's not too long (RFC6838 ยง 4.2 restricts type and # subtype to 127 characters each + separator, strongly recommends # limiting them to 64 but does not require it) and that it looks a lot @@ -67,11 +72,11 @@ def _check_open_container_format(data): return False -_xls_pattern = re.compile(""" +_xls_pattern = re.compile(b""" \x09\x08\x10\x00\x00\x06\x05\x00 | \xFD\xFF\xFF\xFF(\x10|\x1F|\x20|"|\\#|\\(|\\)) """, re.VERBOSE) -_ppt_pattern = re.compile(""" +_ppt_pattern = re.compile(b""" \x00\x6E\x1E\xF0 | \x0F\x00\xE8\x03 | \xA0\x46\x1D\xF0 @@ -87,11 +92,11 @@ def _check_olecf(data): ignore that. """ offset = 0x200 - if data.startswith('\xEC\xA5\xC1\x00', offset): + if data.startswith(b'\xEC\xA5\xC1\x00', offset): return 'application/msword' # the _xls_pattern stuff doesn't seem to work correctly (the test file # only has a bunch of \xf* at offset 0x200), that apparently works - elif 'Microsoft Excel' in data: + elif b'Microsoft Excel' in data: return 'application/vnd.ms-excel' elif _ppt_pattern.match(data, offset): return 'application/vnd.ms-powerpoint' @@ -104,18 +109,18 @@ def _check_olecf(data): _Entry = collections.namedtuple('_Entry', ['mimetype', 'signatures', 'discriminants']) _mime_mappings = ( # pdf - _Entry('application/pdf', ['%PDF'], []), + _Entry('application/pdf', [b'%PDF'], []), # jpg, jpeg, png, gif, bmp - _Entry('image/jpeg', ['\xFF\xD8\xFF\xE0', '\xFF\xD8\xFF\xE2', '\xFF\xD8\xFF\xE3', '\xFF\xD8\xFF\xE1'], []), - _Entry('image/png', ['\x89PNG\r\n\x1A\n'], []), - _Entry('image/gif', ['GIF87a', 'GIF89a'], []), - _Entry('image/bmp', ['BM'], []), + _Entry('image/jpeg', [b'\xFF\xD8\xFF\xE0', b'\xFF\xD8\xFF\xE2', b'\xFF\xD8\xFF\xE3', b'\xFF\xD8\xFF\xE1'], []), + _Entry('image/png', [b'\x89PNG\r\n\x1A\n'], []), + _Entry('image/gif', [b'GIF87a', b'GIF89a'], []), + _Entry('image/bmp', [b'BM'], []), # OLECF files in general (Word, Excel, PPT, default to word because why not?) - _Entry('application/msword', ['\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1', '\x0D\x44\x4F\x43'], [ + _Entry('application/msword', [b'\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1', b'\x0D\x44\x4F\x43'], [ _check_olecf ]), # zip, but will include jar, odt, ods, odp, docx, xlsx, pptx, apk - _Entry('application/zip', ['PK\x03\x04'], [_check_ooxml, _check_open_container_format]), + _Entry('application/zip', [b'PK\x03\x04'], [_check_ooxml, _check_open_container_format]), ) def guess_mimetype(bin_data, default='application/octet-stream'): """ Attempts to guess the mime type of the provided binary data, similar diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py index 8d3854aeb6d..9116e08cec8 100644 --- a/odoo/tools/misc.py +++ b/odoo/tools/misc.py @@ -12,6 +12,8 @@ import datetime import subprocess import io import os + +import collections import passlib.utils import pickle as pickle_ import re @@ -28,7 +30,6 @@ from lxml import etree from .which import which import traceback -import csv from operator import itemgetter try: @@ -274,16 +275,12 @@ def flatten(list): >>> flatten(t) [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] """ - - def isiterable(x): - return hasattr(x, "__iter__") - r = [] for e in list: - if isiterable(e): - r.extend(flatten(e)) - else: + if isinstance(e, (bytes, pycompat.text_type)) or not isinstance(e, collections.Iterable): r.append(e) + else: + r.extend(flatten(e)) return r def reverse_enumerate(l): @@ -405,14 +402,15 @@ def scan_languages(): csvpath = odoo.modules.module.get_resource_path('base', 'res', 'res.lang.csv') try: # read (code, name) from languages in base/res/res.lang.csv - result = [] - with open(csvpath) as csvfile: - reader = csv.reader(csvfile, delimiter=',', quotechar='"') + with open(csvpath, 'rb') as csvfile: + reader = pycompat.csv_reader(csvfile, delimiter=',', quotechar='"') fields = next(reader) code_index = fields.index("code") name_index = fields.index("name") - for row in reader: - result.append((ustr(row[code_index]), ustr(row[name_index]))) + result = [ + (row[code_index], row[name_index]) + for row in reader + ] except Exception: _logger.error("Could not read %s", csvpath) result = [] @@ -465,7 +463,7 @@ def human_size(sz): if not sz: return False units = ('bytes', 'Kb', 'Mb', 'Gb') - if isinstance(sz,basestring): + if isinstance(sz,pycompat.string_types): sz=len(sz) s, i = float(sz), 0 while s >= 1024 and i < len(units)-1: @@ -481,7 +479,7 @@ def logged(f): vector = ['Call -> function: %r' % f] for i, arg in enumerate(args): vector.append(' arg %02d: %s' % (i, pformat(arg))) - for key, value in pycompat.items(kwargs): + for key, value in kwargs.items(): vector.append(' kwarg %10s: %s' % (key, pformat(value))) timeb4 = time.time() @@ -798,7 +796,7 @@ class mute_logger(object): def __enter__(self): for logger in self.loggers: - assert isinstance(logger, basestring),\ + assert isinstance(logger, pycompat.string_types),\ "A logger name must be a string, got %s" % type(logger) logging.getLogger(logger).addFilter(self) @@ -910,7 +908,7 @@ def dumpstacks(sig=None, frame=None): 'dbname': getattr(th, 'dbname', 'n/a'), 'url': getattr(th, 'url', 'n/a')} for th in threading.enumerate()} - for threadId, stack in pycompat.items(sys._current_frames()): + for threadId, stack in sys._current_frames().items(): thread_info = threads_info.get(threadId, {}) code.append("\n# Thread: %s (id:%s) (db:%s) (uid:%s) (url:%s)" % (thread_info.get('name', 'n/a'), @@ -962,7 +960,7 @@ class frozendict(dict): def update(self, *args, **kwargs): raise NotImplementedError("'update' not supported on frozendict") def __hash__(self): - return hash(frozenset((key, freehash(val)) for key, val in pycompat.items(self))) + return hash(frozenset((key, freehash(val)) for key, val in self.items())) class Collector(Mapping): """ A mapping from keys to lists. This is essentially a space optimization @@ -1073,7 +1071,7 @@ def formatLang(env, value, digits=None, grouping=True, monetary=False, dp=False, if not digits and digits is not 0: digits = DEFAULT_DIGITS - if isinstance(value, (str, unicode)) and not value: + if isinstance(value, pycompat.string_types) and not value: return '' lang = env.user.company_id.partner_id.lang or 'en_US' @@ -1108,7 +1106,7 @@ def format_date(env, value, lang_code=False, date_format=False): return '' if isinstance(value, datetime.datetime): value = value.date() - elif isinstance(value, basestring): + elif isinstance(value, pycompat.string_types): if len(value) < DATE_LENGTH: return '' value = value[:DATE_LENGTH] diff --git a/odoo/tools/pycompat.py b/odoo/tools/pycompat.py index 19b821863b2..feb65da7a67 100644 --- a/odoo/tools/pycompat.py +++ b/odoo/tools/pycompat.py @@ -1,56 +1,82 @@ # -*- coding: utf-8 -*- # to remove if we decide to add a dependency on six or future # very strongly inspired by https://github.com/pallets/werkzeug/blob/master/werkzeug/_compat.py +#pylint: disable=deprecated-module +import csv +import collections +import io import sys + PY2 = sys.version_info[0] == 2 +_Writer = collections.namedtuple('_Writer', 'writerow writerows') if PY2: - # pylint: disable=long-builtin,dict-iter-method - integer_types = (int, long) - round = round + # pylint: disable=long-builtin,unichr-builtin,unicode-builtin + unichr = unichr + text_type = unicode + string_types = (str, unicode) + def to_native(source, encoding='utf-8', falsy_empty=False): + if not source and falsy_empty: + return '' + + if isinstance(source, text_type): + return source.encode(encoding) - keys = lambda d: iter(d.iterkeys()) - values = lambda d: iter(d.itervalues()) - items = lambda d: iter(d.iteritems()) + return str(source) + + integer_types = (int, long) # noinspection PyUnresolvedReferences from itertools import imap, izip, ifilter + def implements_to_string(cls): + cls.__unicode__ = cls.__str__ + cls.__str__ = lambda x: x.__unicode__().encode('utf-8') + return cls + def implements_iterator(cls): cls.next = cls.__next__ del cls.__next__ return cls exec ('def reraise(tp, value, tb=None):\n raise tp, value, tb') + + def csv_reader(stream, **params): + for row in csv.reader(stream, **params): + yield [c.decode('utf-8') for c in row] + def csv_writer(stream, **params): + w = csv.writer(stream, **params) + return _Writer( + writerow=lambda r: w.writerow([c.encode('utf-8') for c in r]), + writerows=lambda rs: w.writerows( + [c.encode('utf-8') for c in r] + for r in rs + ) + ) else: - import builtins, math # pylint: disable=bad-functions + unichr = chr + text_type = str + string_types = (str,) + def to_native(source, encoding='utf-8', falsy_empty=False): + if not source and falsy_empty: + return '' + + if isinstance(source, bytes): + return source.decode(encoding) + + return str(source) + integer_types = (int,) - def round(f): - # P3's builtin round differs from P2 in the following manner: - # * it rounds half to even rather than up (away from 0) - # * round(-0.) loses the sign (it returns -0 rather than 0) - # * round(x) returns an int rather than a float - # - # this compatibility shim implements Python 2's round in terms of - # Python 3's so that important rounding error under P3 can be - # trivially fixed, assuming the P2 behaviour to be debugged and - # correct. - roundf = builtins.round(f) - if builtins.round(f + 1) - roundf != 1: - return f + math.copysign(0.5, f) - # copysign ensures round(-0.) -> -0 *and* result is a float - return math.copysign(roundf, f) - - keys = lambda d: iter(d.keys()) - values = lambda d: iter(d.values()) - items = lambda d: iter(d.items()) imap = map izip = zip ifilter = filter + def implements_to_string(cls): + return cls + def implements_iterator(cls): return cls @@ -58,3 +84,29 @@ else: if value.__traceback__ != tb: raise value.with_traceback(tb) raise value + + def csv_reader(stream, **params): + assert not isinstance(stream, io.TextIOBase),\ + "For cross-compatibility purposes, csv_reader takes a bytes stream" + return csv.reader(io.TextIOWrapper(stream, encoding='utf-8'), **params) + def csv_writer(stream, **params): + assert not isinstance(stream, io.TextIOBase), \ + "For cross-compatibility purposes, csv_writer takes a bytes stream" + return csv.writer(io.TextIOWrapper(stream, encoding='utf-8', line_buffering=True), **params) + +def to_text(source): + """ Generates a text value (an instance of text_type) from an arbitrary + source. + + * False and None are converted to empty strings + * text is passed through + * bytes are decoded as UTF-8 + * rest is textified via the current version's relevant data model method + """ + if source is None or source is False: + return u'' + + if isinstance(source, bytes): + return source.decode('utf-8') + + return text_type(source) diff --git a/odoo/tools/safe_eval.py b/odoo/tools/safe_eval.py index de767fb7453..fec89fac087 100644 --- a/odoo/tools/safe_eval.py +++ b/odoo/tools/safe_eval.py @@ -26,6 +26,7 @@ import werkzeug from . import pycompat from .misc import ustr +from . import pycompat import odoo @@ -267,8 +268,9 @@ _BUILTINS = { 'True': True, 'False': False, 'None': None, + 'bytes': bytes, 'str': str, - 'unicode': unicode, + 'unicode': pycompat.text_type, 'bool': bool, 'int': int, 'float': float, diff --git a/odoo/tools/test_reports.py b/odoo/tools/test_reports.py index cad44431c1d..a3588577c08 100644 --- a/odoo/tools/test_reports.py +++ b/odoo/tools/test_reports.py @@ -7,15 +7,14 @@ through the code of yaml tests. """ -import odoo -import odoo.tools as tools import logging - -from odoo.tools import pycompat -from odoo.tools.safe_eval import safe_eval -from subprocess import Popen, PIPE import os import tempfile +from subprocess import Popen, PIPE + +from .. import api +from . import pycompat, ustr, config +from .safe_eval import safe_eval _logger = logging.getLogger(__name__) _test_logger = logging.getLogger('odoo.tests') @@ -30,7 +29,7 @@ def try_report(cr, uid, rname, ids, data=None, context=None, our_module=None, re context = {} _test_logger.info(" - Trying %s.create(%r)", rname, ids) - env = odoo.api.Environment(cr, uid, context) + env = api.Environment(cr, uid, context) report_id = env['ir.actions.report'].search([('report_name', '=', rname)], limit=1) if not report_id: @@ -41,12 +40,12 @@ def try_report(cr, uid, rname, ids, data=None, context=None, our_module=None, re if not res_data: raise ValueError("Report %s produced an empty result!" % rname) - if tools.config['test_report_directory']: - open(os.path.join(tools.config['test_report_directory'], rname+ '.'+res_format), 'wb+').write(res_data) + if config['test_report_directory']: + open(os.path.join(config['test_report_directory'], rname+ '.'+res_format), 'wb+').write(res_data) _logger.debug("Have a %s report for %s, will examine it", res_format, rname) if res_format == 'pdf': - if res_data[:5] != '%PDF-': + if res_data[:5] != b'%PDF-': raise ValueError("Report %s produced a non-pdf header, %r" % (rname, res_data[:10])) res_text = False try: @@ -56,7 +55,7 @@ def try_report(cr, uid, rname, ids, data=None, context=None, our_module=None, re proc = Popen(['pdftotext', '-enc', 'UTF-8', '-nopgbrk', rfname, '-'], shell=False, stdout=PIPE) stdout, stderr = proc.communicate() - res_text = tools.ustr(stdout) + res_text = ustr(stdout) os.unlink(rfname) except Exception: _logger.debug("Unable to parse PDF report: install pdftotext to perform automated tests.") @@ -92,14 +91,14 @@ def try_report_action(cr, uid, action_id, active_model=None, active_ids=None, Eg. 'OK' or 'fa-print' :param our_module: the name of the calling module (string), like 'account' """ - if not our_module and isinstance(action_id, basestring): + if not our_module and isinstance(action_id, pycompat.string_types): if '.' in action_id: our_module = action_id.split('.', 1)[0] context = dict(context or {}) # TODO context fill-up - env = odoo.api.Environment(cr, uid, context) + env = api.Environment(cr, uid, context) def log_test(msg, *args): _test_logger.info(" - " + msg, *args) @@ -113,7 +112,7 @@ def try_report_action(cr, uid, action_id, active_model=None, active_ids=None, if not wiz_buttons: wiz_buttons = [] - if isinstance(action_id, basestring): + if isinstance(action_id, pycompat.string_types): if '.' in action_id: _, act_xmlid = action_id.split('.', 1) else: @@ -138,7 +137,7 @@ def try_report_action(cr, uid, action_id, active_model=None, active_ids=None, if datas.get('id',False): context.update( {'active_id': datas.get('id',False), 'active_ids': datas.get('ids',[]), 'active_model': datas.get('model',False)}) context1 = action.get('context', {}) - if isinstance(context1, basestring): + if isinstance(context1, pycompat.string_types): context1 = safe_eval(context1, dict(context)) context.update(context1) env = env(context=context) @@ -174,7 +173,7 @@ def try_report_action(cr, uid, action_id, active_model=None, active_ids=None, view_data.update(wiz_data) _logger.debug("View data is: %r", view_data) - for fk, field in pycompat.items(view_res.get('fields',{})): + for fk, field in view_res.get('fields',{}).items(): # Default fields returns list of int, while at create() # we need to send a [(6,0,[int,..])] if field['type'] in ('one2many', 'many2many') \ diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index 49aeb2864a8..c244f88e849 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import codecs -import csv import fnmatch import inspect import io @@ -121,18 +120,17 @@ _LOCALE2WIN32 = { ENGLISH_SMALL_WORDS = set("as at by do go if in me no of ok on or to up us we".split()) +# these direct uses of CSV are ok. +import csv # pylint: disable=deprecated-module class UNIX_LINE_TERMINATOR(csv.excel): lineterminator = '\n' csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR) -# -# Helper functions for translating fields -# +# FIXME: holy shit this whole thing needs to be cleaned up hard it's a mess def encode(s): - if isinstance(s, unicode): - return s.encode('utf8') + assert isinstance(s, pycompat.text_type) return s # which elements are translated inline @@ -260,7 +258,7 @@ def translate_xml_node(node, callback, parse, serialize): append_content(result, translate_content(todo) if todo_has_text else todo) # translate the required attributes - for name, value in pycompat.items(result.attrib): + for name, value in result.attrib.items(): if name in TRANSLATED_ATTRS: result.set(name, translate_text(value) or value) @@ -280,18 +278,18 @@ def translate_xml_node(node, callback, parse, serialize): def parse_xml(text): - return etree.fromstring(encode(text)) + return etree.fromstring(text) def serialize_xml(node): - return etree.tostring(node, method='xml', encoding='utf8').decode('utf8') + return etree.tostring(node, method='xml', encoding='unicode') _HTML_PARSER = etree.HTMLParser(encoding='utf8') def parse_html(text): - return html.fragment_fromstring(encode(text), parser=_HTML_PARSER) + return html.fragment_fromstring(text, parser=_HTML_PARSER) def serialize_html(node): - return etree.tostring(node, method='html', encoding='utf8').decode('utf8') + return etree.tostring(node, method='html', encoding='unicode') def xml_translate(callback, value): @@ -307,7 +305,7 @@ def xml_translate(callback, value): return serialize_xml(result) except etree.ParseError: # fallback for translated terms: use an HTML parser and wrap the term - root = parse_html("<div>%s</div>" % value) + root = parse_html(u"<div>%s</div>" % value) result = translate_xml_node(root, callback, parse_xml, serialize_xml) # remove tags <div> and </div> from result return serialize_xml(result)[5:-6] @@ -643,14 +641,13 @@ class PoFile(object): # only strings in python code are python formated self.buffer.write(u"#, python-format\n") - if not isinstance(trad, unicode): - trad = unicode(trad, 'utf8') - if not isinstance(source, unicode): - source = unicode(source, 'utf8') - - msg = u"msgid %s\n" \ - u"msgstr %s\n\n" \ - % (quote(source), quote(trad)) + msg = ( + u"msgid %s\n" + u"msgstr %s\n\n" + ) % ( + quote(pycompat.text_type(source)), + quote(pycompat.text_type(trad)) + ) self.buffer.write(msg) @@ -660,7 +657,7 @@ def trans_export(lang, modules, buffer, format, cr): def _process(format, modules, rows, buffer, lang): if format == 'csv': - writer = csv.writer(buffer, 'UNIX') + writer = pycompat.csv_writer(buffer, dialect='UNIX') # write header first writer.writerow(("module","type","name","res_id","src","value","comments")) for module, type, name, res_id, src, trad, comments in rows: @@ -681,7 +678,7 @@ def trans_export(lang, modules, buffer, format, cr): row.setdefault('tnrs', []).append((type, name, res_id)) row.setdefault('comments', set()).update(comments) - for src, row in sorted(pycompat.items(grouped_rows)): + for src, row in sorted(grouped_rows.items()): if not lang: # translation template, so no translation value row['translation'] = '' @@ -695,7 +692,7 @@ def trans_export(lang, modules, buffer, format, cr): module = row[0] rows_by_module.setdefault(module, []).append(row) tmpdir = tempfile.mkdtemp() - for mod, modrows in pycompat.items(rows_by_module): + for mod, modrows in rows_by_module.items(): tmpmoddir = join(tmpdir, mod, 'i18n') os.makedirs(tmpmoddir) pofilename = (lang if lang else mod) + ".po" + ('t' if not lang else '') @@ -810,9 +807,9 @@ def trans_generate(lang, modules, cr): try: # verify the minimal size without eventual xml tags # wrap to make sure html content like '<a>b</a><c>d</c>' is accepted by lxml - wrapped = "<div>%s</div>" % sanitized_term + wrapped = u"<div>%s</div>" % sanitized_term node = etree.fromstring(wrapped) - sanitized_term = etree.tostring(node, encoding='UTF-8', method='text') + sanitized_term = etree.tostring(node, encoding='unicode', method='text') except etree.ParseError: pass # remove non-alphanumeric chars @@ -847,12 +844,10 @@ def trans_generate(lang, modules, cr): cr.execute(query, query_param) for (xml_name, model, res_id, module) in cr.fetchall(): - module = encode(module) - model = encode(model) - xml_name = "%s.%s" % (module, encode(xml_name)) + xml_name = "%s.%s" % (module, xml_name) if model not in env: - _logger.error("Unable to find object %r", model) + _logger.error(u"Unable to find object %r", model) continue record = env[model].browse(res_id) @@ -861,14 +856,14 @@ def trans_generate(lang, modules, cr): continue if not record.exists(): - _logger.warning("Unable to find object %r with id %d", model, res_id) + _logger.warning(u"Unable to find object %r with id %d", model, res_id) continue - if model=='ir.model.fields': + if model==u'ir.model.fields': try: - field_name = encode(record.name) + field_name = record.name except AttributeError as exc: - _logger.error("name error in %s: %s", xml_name, str(exc)) + _logger.error(u"name error in %s: %s", xml_name, str(exc)) continue field_model = env.get(record.model) if (field_model is None or not field_model._translate or @@ -877,11 +872,11 @@ def trans_generate(lang, modules, cr): field = field_model._fields[field_name] if isinstance(getattr(field, 'selection', None), (list, tuple)): - name = "%s,%s" % (encode(record.model), field_name) + name = "%s,%s" % (record.model, field_name) for dummy, val in field.selection: - push_translation(module, 'selection', name, 0, encode(val)) + push_translation(module, 'selection', name, 0, val) - for field_name, field in record._fields.iteritems(): + for field_name, field in record._fields.items(): if field.translate: name = model + "," + field_name try: @@ -889,13 +884,13 @@ def trans_generate(lang, modules, cr): except Exception: continue for term in set(field.get_trans_terms(value)): - push_translation(module, 'model', name, xml_name, encode(term)) + push_translation(module, 'model', name, xml_name, term) # End of data for ir.model.data query results def push_constraint_msg(module, term_type, model, msg): if not callable(msg): - push_translation(encode(module), term_type, encode(model), 0, encode(msg)) + push_translation(encode(module), term_type, encode(model), 0, msg) def push_local_constraints(module, model, cons_type='sql_constraints'): """ Climb up the class hierarchy and ignore inherited constraints from other modules. """ @@ -1005,7 +1000,7 @@ def trans_generate(lang, modules, cr): def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None): try: - with file_open(filename) as fileobj: + with file_open(filename, mode='rb') as fileobj: _logger.info("loading %s", filename) fileformat = os.path.splitext(filename)[-1][1:].lower() result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context) @@ -1038,11 +1033,9 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, # now, the serious things: we read the language file fileobj.seek(0) if fileformat == 'csv': - reader = csv.reader(fileobj, quotechar='"', delimiter=',') + reader = pycompat.csv_reader(fileobj, quotechar='"', delimiter=',') # read the first line of the file (it contains columns titles) - for row in reader: - fields = row - break + fields = next(reader) elif fileformat == 'po': reader = PoFile(fileobj) @@ -1060,7 +1053,7 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, addons_module, i18n_dir = os.path.split(addons_module_i18n) addons, module = os.path.split(addons_module) pot_handle = file_open(os.path.join( - addons, module, i18n_dir, module + '.pot')) + addons, module, i18n_dir, module + '.pot'), mode='rb') pot_reader = PoFile(pot_handle) except: pass @@ -1109,7 +1102,7 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, return if isinstance(res_id, pycompat.integer_types) or \ - (isinstance(res_id, basestring) and res_id.isdigit()): + (isinstance(res_id, pycompat.string_types) and res_id.isdigit()): dic['res_id'] = int(res_id) if module_name: dic['module'] = module_name @@ -1132,7 +1125,7 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, # Then process the entries implied by the POT file (which is more # correct w.r.t. the targets) if some of them remain. pot_rows = [] - for src, target in pycompat.items(pot_targets): + for src, target in pot_targets.items(): if target.value: for type, name, res_id in target.targets: pot_rows.append((type, name, res_id, src, target.value, target.comments)) diff --git a/odoo/tools/yaml_import.py b/odoo/tools/yaml_import.py index 4cb2e7ea184..79f997045a2 100644 --- a/odoo/tools/yaml_import.py +++ b/odoo/tools/yaml_import.py @@ -12,8 +12,7 @@ from lxml import etree import yaml import odoo -from . import assertion_report -from . import yaml_tag, pycompat +from . import assertion_report, pycompat, yaml_tag from .config import config from .misc import file_open, DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT from odoo import SUPERUSER_ID @@ -25,9 +24,6 @@ from .safe_eval import safe_eval _logger = logging.getLogger(__name__) -def encode(s): - return s.encode('utf8') if isinstance(s, unicode) else s - class YamlImportException(Exception): pass @@ -41,7 +37,7 @@ def _is_yaml_mapping(node, tag_constructor): return value def is_comment(node): - return isinstance(node, basestring) + return isinstance(node, pycompat.string_types) def is_assert(node): return isinstance(node, yaml_tag.Assert) \ @@ -87,7 +83,7 @@ def is_ir_set(node): return _is_yaml_mapping(node, yaml_tag.IrSet) def is_string(node): - return isinstance(node, basestring) + return isinstance(node, pycompat.string_types) class RecordDictWrapper(dict): """ @@ -202,7 +198,7 @@ class YamlInterpreter(object): def process_assert(self, node): if isinstance(node, dict): - assertion, expressions = next(pycompat.items(node)) + assertion, expressions = list(node.items())[0] else: assertion, expressions = node, [] @@ -275,10 +271,10 @@ class YamlInterpreter(object): return record_dict def process_record(self, node): - record, fields = next(pycompat.items(node)) + record, fields = list(node.items())[0] model = self.env[record.model] view_id = record.view - if view_id and (view_id is not True) and isinstance(view_id, basestring): + if view_id and (view_id is not True) and isinstance(view_id, pycompat.string_types): if '.' not in view_id: view_id = self.module + '.' + view_id view_id = self.env.ref(view_id).id @@ -358,7 +354,10 @@ class YamlInterpreter(object): traverse(child, elems) elems = OrderedDict() - traverse(etree.fromstring(encode(view['arch'])), elems) + arch = view['arch'] + if isinstance(arch, pycompat.text_type): + arch = arch.encode('utf-8') + traverse(etree.fromstring(arch), elems) return elems def is_readonly(field_elem): @@ -377,7 +376,7 @@ class YamlInterpreter(object): def process_vals(fg, vals): """ sanitize the given field values """ result = {} - for field_name, field_value in pycompat.items(vals): + for field_name, field_value in vals.items(): if field_name not in fg: continue if fg[field_name]['type'] == 'many2one' and isinstance(field_value, (tuple, list)): @@ -398,7 +397,7 @@ class YamlInterpreter(object): def post_process(fg, elems, vals): """ filter out readonly fields from vals """ result = {} - for field_name, field_value in pycompat.items(vals): + for field_name, field_value in vals.items(): if is_readonly(elems[field_name]): continue if fg[field_name]['type'] in ('one2many', 'many2many'): @@ -444,7 +443,7 @@ class YamlInterpreter(object): record_dict.update(process_vals(fg, result.get('value', {}))) # fill in fields, and execute onchange where necessary - for field_name, field_elem in pycompat.items(elems): + for field_name, field_elem in elems.items(): assert field_name in fg, "The field '%s' is defined in the form view but not on the object '%s'!" % (field_name, model._name) if is_readonly(field_elem): # skip readonly fields @@ -474,7 +473,7 @@ class YamlInterpreter(object): result = recs.onchange(dict(record_dict, **parent_values), field_name, onchange_spec) record_dict.update(process_vals(fg, { key: val - for key, val in pycompat.items(result.get('value', {})) + for key, val in result.get('value', {}).items() if key not in fields # do not shadow values explicitly set in yaml })) @@ -483,7 +482,7 @@ class YamlInterpreter(object): else: record_dict = {} - for field_name, expression in pycompat.items(fields): + for field_name, expression in fields.items(): if record_dict.get(field_name): continue field_value = self._eval_field(model, field_name, expression, parent=record_dict, default=False, context=context) @@ -494,7 +493,7 @@ class YamlInterpreter(object): # should not be sent to create. This bug appears with not stored function fields in the new API. return { key: val - for key, val in pycompat.items(record_dict) + for key, val in record_dict.items() for field in [model._fields[key].base_field] if field.store or field.inverse } @@ -580,11 +579,11 @@ class YamlInterpreter(object): self.sudo_env = self.env(user=SUPERUSER_ID) def process_python(self, node): - python, statements = next(pycompat.items(node)) + python, statements = list(node.items())[0] assert python.model or python.id, "!python node must have attribute `model` or `id`" if python.id is None: record = self.env[python.model] - elif isinstance(python.id, basestring): + elif isinstance(python.id, pycompat.string_types): record = self.get_record(python.id) else: record = self.env[python.model].browse(python.id) @@ -640,7 +639,7 @@ class YamlInterpreter(object): return args def process_function(self, node): - function, params = next(pycompat.items(node)) + function, params = list(node.items())[0] if self.isnoupdate(function) and self.mode != 'init': return model = self.env[function.model] @@ -770,9 +769,9 @@ class YamlInterpreter(object): def process_ir_set(self, node): if not self.mode == 'init': return False - _, fields = next(pycompat.items(node)) + _, fields = list(node.items())[0] res = {} - for fieldname, expression in pycompat.items(fields): + for fieldname, expression in fields.items(): if is_eval(expression): value = safe_eval(expression.expression, self.eval_context) else: @@ -878,7 +877,7 @@ class YamlInterpreter(object): elif not is_preceded_by_comment: if isinstance(node, dict): msg = "Creating %s\n with %s" - args = next(pycompat.items(node)) + args = list(node.items())[0] self._log(msg, *args) else: self._log(node) diff --git a/odoo/tools/yaml_tag.py b/odoo/tools/yaml_tag.py index 2b7141b6693..9f57111bf37 100644 --- a/odoo/tools/yaml_tag.py +++ b/odoo/tools/yaml_tag.py @@ -1,8 +1,6 @@ import yaml import logging -from . import pycompat - class YamlTag(object): """ @@ -16,7 +14,7 @@ class YamlTag(object): def __getattr__(self, attr): return None def __repr__(self): - return "<%s %s>" % (self.__class__.__name__, sorted(pycompat.items(self.__dict__))) + return "<%s %s>" % (self.__class__.__name__, sorted(self.__dict__.items())) class Assert(YamlTag): def __init__(self, model, id=None, severity=logging.WARNING, string="NONAME", **kwargs): diff --git a/requirements.txt b/requirements.txt index 65a3a1640f0..1ef8ebd2252 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,6 @@ ebaysdk==2.1.4 feedparser==5.2.1 gevent==1.1.2 greenlet==0.4.10 -jcconv==0.2.3 html2text==2016.9.19 Jinja2==2.8 lxml==3.5.0 @@ -37,7 +36,6 @@ suds-jurko==0.6 vatnumber==1.2 vobject==0.9.3 Werkzeug==0.11.11 -wsgiref==0.1.2 XlsxWriter==0.9.3 xlwt==1.1.2 xlrd==1.0.0 diff --git a/setup/package.py b/setup/package.py index d7c712aba7e..c3eedeee3e7 100755 --- a/setup/package.py +++ b/setup/package.py @@ -133,7 +133,7 @@ class OdooDocker(object): def end(self): try: _rpc_count_modules(port=str(self.port)) - except Exception, e: + except Exception as e: print('Exception during docker execution: %s:' % str(e)) print('Error during docker execution: printing the bash output:') with open(self.log_file.name) as f: @@ -152,7 +152,7 @@ def docker(docker_image, build_dir, pub_dir): _docker.start(docker_image, build_dir, pub_dir) try: yield _docker - except Exception, e: + except Exception: raise finally: _docker.end() @@ -439,7 +439,7 @@ def main(): if not o.no_testing: test_tgz(o) published_files = publish(o, 'tarball', ['tar.gz', 'zip']) - except Exception, e: + except Exception as e: print("Won't publish the tgz release.\n Exception: %s" % str(e)) if not o.no_debian: build_deb(o) @@ -448,7 +448,7 @@ def main(): test_deb(o) published_files = publish(o, 'debian', ['deb', 'dsc', 'changes', 'tar.gz']) gen_deb_package(o, published_files) - except Exception, e: + except Exception as e: print("Won't publish the deb release.\n Exception: %s" % str(e)) if not o.no_rpm: build_rpm(o) @@ -457,7 +457,7 @@ def main(): test_rpm(o) published_files = publish(o, 'redhat', ['noarch.rpm']) gen_rpm_repo(o, published_files[0]) - except Exception, e: + except Exception as e: print("Won't publish the rpm release.\n Exception: %s" % str(e)) if not o.no_windows: _prepare_build_dir(o, win32=True) @@ -466,7 +466,7 @@ def main(): if not o.no_testing: test_exe(o) published_files = publish(o, 'windows', ['exe']) - except Exception, e: + except Exception as e: print("Won't publish the exe release.\n Exception: %s" % str(e)) except: pass |
