diff options
| author | Christophe Simonis <chs@odoo.com> | 2017-08-24 13:17:53 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2017-08-24 13:17:53 +0200 |
| commit | 017ee5eab376b4f09beb62812c1cbc682009ed86 (patch) | |
| tree | 2af185b0604cd8ce9129c39137bbf39d21d7be89 /addons | |
| parent | 006c16198f7f68c9dc8b895defd013f33008840a (diff) | |
| parent | 877e7098719195ed2934fc9c3b5c3bd8ad8bab07 (diff) | |
[MERGE] forward port branch saas-17 up to 877e7098719
Diffstat (limited to 'addons')
223 files changed, 2854 insertions, 1199 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' |
