diff options
101 files changed, 2300 insertions, 375 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index d5415fba0a6..3ba6dcb1399 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -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 d0fcbf36a44..a59fda008e3 100644 --- a/addons/account/models/account_config_settings.py +++ b/addons/account/models/account_config_settings.py @@ -79,10 +79,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 ee0d1903fd0..3ad85d3cfa2 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -338,9 +338,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") @@ -741,7 +741,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: diff --git a/addons/account/static/src/js/account_payment_field.js b/addons/account/static/src/js/account_payment_field.js index fb038f3e642..10bd54cbeff 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/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index 634e785e333..4ab3bda6e6a 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_voucher/models/account_voucher.py b/addons/account_voucher/models/account_voucher.py index 40ae66c3395..02256abaa22 100644 --- a/addons/account_voucher/models/account_voucher.py +++ b/addons/account_voucher/models/account_voucher.py @@ -393,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 @@ -403,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/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 19941858dfa..c86db44e26c 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -658,7 +658,13 @@ class Import(models.TransientModel): }] _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/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/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/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/mail/controllers/main.py b/addons/mail/controllers/main.py index 52ea655c078..c1b4433e74a 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -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 diff --git a/addons/mail/models/mail_followers.py b/addons/mail/models/mail_followers.py index 3edcfa748c3..32d2e66b028 100644 --- a/addons/mail/models/mail_followers.py +++ b/addons/mail/models/mail_followers.py @@ -23,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( 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/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 767462df892..0d91879224e 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -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 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/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/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_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/static/src/js/tests.js b/addons/point_of_sale/static/src/js/tests.js index 0c578088b20..c12d5c5bb0e 100644 --- a/addons/point_of_sale/static/src/js/tests.js +++ b/addons/point_of_sale/static/src/js/tests.js @@ -110,7 +110,8 @@ odoo.define('point_of_sale.Tour', 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/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_template.py b/addons/product/models/product_template.py index c9a2411f173..a15704940b8 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -423,9 +423,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/project/models/project.py b/addons/project/models/project.py index 8e20f1bc029..362a745a819 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -431,7 +431,7 @@ class Task(models.Model): @api.onchange('project_id') def _onchange_project(self): default_partner_id = self.env.context.get('default_partner_id') - default_partner = self.env['res.partner'].browse(default_partner_id) if default_partner_id else None + default_partner = self.env['res.partner'].browse(default_partner_id) if default_partner_id else self.env['res.partner'] if self.project_id: self.partner_id = self.project_id.partner_id or default_partner self.stage_id = self.stage_find(self.project_id.id, [('fold', '=', False)]) diff --git a/addons/project_issue/views/project_project_views.xml b/addons/project_issue/views/project_project_views.xml index 4ea10a378c4..ebac1d704f8 100644 --- a/addons/project_issue/views/project_project_views.xml +++ b/addons/project_issue/views/project_project_views.xml @@ -24,7 +24,7 @@ <field name="arch" type="xml"> <field name="use_tasks" position="after"> <field name="use_issues"/> - <field name="issue_ids" invisible="1"/> + <field name="issue_count" invisible="1"/> <field name="issue_needaction_count" invisible="1"/> </field> <xpath expr="//div[contains(@class, 'o_kanban_manage_reports')]/div[last()]" position="after"> @@ -35,7 +35,7 @@ <xpath expr="//div[contains(@class, 'o_project_kanban_box')]/div[last()]" position="after"> <div attrs="{'invisible': [('use_issues', '=', False)]}" class="o_project_kanban_box"> <a name="%(act_project_project_2_project_issue_all)d" type="action"> - <span class="o_value"><t t-raw="record.issue_ids.raw_value.length"/></span> + <span class="o_value"><t t-raw="record.issue_count.raw_value"/></span> <span class="o_label"><field name="label_issues"/></span> <a t-if="record.issue_needaction_count.raw_value" class="o_needaction" name="%(act_project_project_2_project_issue_all)d" type="action" context="{'search_default_message_needaction': 1}"> <t t-esc="record.issue_needaction_count.raw_value"/> diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 783c9032801..ca44e7b312a 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -248,9 +248,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_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 ee2a2268f8a..c1f13ee9b51 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): @@ -281,10 +281,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 99333bc52c5..16f02a4d9d1 100644 --- a/addons/sales_team/models/crm_team.py +++ b/addons/sales_team/models/crm_team.py @@ -232,7 +232,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/stock_inventory.py b/addons/stock/models/stock_inventory.py index bb14b839d32..55e63bb4f9f 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -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])]) @@ -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 3c27e5ba6ec..6379289bfe6 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -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. @@ -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/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/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 c98ff7fbe9a..033d7995c9c 100644 --- a/addons/web/static/src/js/core/session.js +++ b/addons/web/static/src/js/core/session.js @@ -285,10 +285,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 () { @@ -410,6 +406,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 2de5a73802d..0026cea0413 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 0a44a52e315..db5cd34d8b9 100644 --- a/addons/web/static/src/js/fields/field_utils.js +++ b/addons/web/static/src/js/fields/field_utils.js @@ -95,8 +95,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); @@ -121,7 +123,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); @@ -357,11 +359,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()); } @@ -405,7 +404,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 6870dbbbe24..91ada3a2629 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(); }); }, /** @@ -325,9 +330,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 @@ -338,6 +346,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 6e69c0217b2..2976ab7926a 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -387,15 +387,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 d38400c2e84..876c37fbd59 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -343,6 +343,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); @@ -402,6 +403,7 @@ var SelectCreateDialog = ViewDialog.extend({ }, })).open(); dialog.on('closed', this, this.close.bind(this)); + 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 c11551ca2cc..a94b38f04b9 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 912a30edb2c..0de80c8e3c6 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 dialog = new Dialog(this, { title: _t("Fields View Get") }).open(); diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 9d4b3326c22..1c633f30100 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -339,7 +339,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"> @@ -356,7 +356,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 ca9b42f55f7..3e886d4d350 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-default o_kanban_cancel">Discard</button> </div> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 508e86d9cd3..939ecabd70f 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 213d87ef8b0..5601f93f202 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -256,8 +256,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) { @@ -282,8 +285,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 26d15e33c2a..4279125f389 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 7fc9343d82c..e46eebe2e5a 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"); @@ -493,8 +501,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, @@ -516,6 +525,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)"); @@ -525,6 +540,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, @@ -623,6 +639,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); @@ -1229,8 +1281,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', @@ -1248,6 +1301,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'), @@ -1260,6 +1319,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 @@ -1388,8 +1448,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', @@ -1409,12 +1470,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, @@ -1423,6 +1491,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_editor/static/src/js/ace.js b/addons/web_editor/static/src/js/ace.js index b1b28d78e83..b7e2daf6ead 100644 --- a/addons/web_editor/static/src/js/ace.js +++ b/addons/web_editor/static/src/js/ace.js @@ -204,7 +204,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/website/controllers/main.py b/addons/website/controllers/main.py index ef8245598a1..99492aaffee 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -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) diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index b5a3284d159..c5e8262eaab 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -71,6 +71,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_blog/models/website_blog.py b/addons/website_blog/models/website_blog.py index 979ae14c5d3..2bef6f61b5f 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/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/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_hr_recruitment/controllers/main.py b/addons/website_hr_recruitment/controllers/main.py index 2ed3eafc2f6..4497ff5b396 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_portal/controllers/main.py b/addons/website_portal/controllers/main.py index 865312cc7c6..5f3c42dc3c7 100644 --- a/addons/website_portal/controllers/main.py +++ b/addons/website_portal/controllers/main.py @@ -70,11 +70,13 @@ class website_account(http.Controller): @http.route(['/my/account'], type='http', auth='user', website=True) def details(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) diff --git a/addons/website_portal/views/website_portal_templates.xml b/addons/website_portal/views/website_portal_templates.xml index 406a79a1b5a..872d0465ce5 100644 --- a/addons/website_portal/views/website_portal_templates.xml +++ b/addons/website_portal/views/website_portal_templates.xml @@ -121,7 +121,7 @@ <div class="o_my_contact" t-if="sales_rep"> <t t-call="website_portal.contact"/> </div> - <div class="o_my_details"> + <div class="o_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.partner_id" t-options='{"widget": "contact", "fields": ["email", "phone", "address", "name"]}'/> </div> diff --git a/addons/website_portal/wizard/portal_wizard_views.xml b/addons/website_portal/wizard/portal_wizard_views.xml index b1cb31e63ba..909b822cf8d 100644 --- a/addons/website_portal/wizard/portal_wizard_views.xml +++ b/addons/website_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/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 40057484efe..7aadaeb8ff8 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -497,7 +497,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: diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index c697973cd5d..0045120b282 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -187,7 +187,9 @@ class Product(models.Model): def _website_price(self): qty = self._context.get('quantity', 1.0) partner = self.env.user.partner_id - pricelist = self.env['website'].get_current_website().get_current_pricelist() + current_website = self.env['website'].get_current_website() + pricelist = current_website.get_current_pricelist() + company_id = current_website.company_id context = dict(self._context, pricelist=pricelist.id, partner=partner) self2 = self.with_context(context) if self._context != context else self @@ -195,7 +197,7 @@ class Product(models.Model): ret = self.env.user.has_group('sale.group_show_price_subtotal') and 'total_excluded' or 'total_included' for p, p2 in pycompat.izip(self, self2): - taxes = partner.property_account_position_id.map_tax(p.taxes_id) + taxes = partner.property_account_position_id.map_tax(p.taxes_id.filtered(lambda x: x.company_id == company_id)) p.website_price = taxes.compute_all(p2.price, pricelist.currency_id, quantity=qty, product=p2, partner=partner)[ret] p.website_public_price = taxes.compute_all(p2.lst_price, quantity=qty, product=p2, partner=partner)[ret] diff --git a/addons/website_sale_digital/controllers/main.py b/addons/website_sale_digital/controllers/main.py index 67e125ee341..0e18cc86bcd 100644 --- a/addons/website_sale_digital/controllers/main.py +++ b/addons/website_sale_digital/controllers/main.py @@ -100,6 +100,6 @@ class WebsiteSaleDigital(website_account): return request.not_found() elif attachment["datas"]: data = io.BytesIO(base64.standard_b64decode(attachment["datas"])) - return http.send_file(data, filename=attachment['name'], as_attachment=True) + return http.send_file(data, filename=attachment['name'].encode('utf-8'), as_attachment=True) else: return request.not_found() diff --git a/doc/cla/corporate/vauxoo.md b/doc/cla/corporate/vauxoo.md index 036e1a63620..096c835ef55 100644 --- a/doc/cla/corporate/vauxoo.md +++ b/doc/cla/corporate/vauxoo.md @@ -36,4 +36,5 @@ Luis González lgonzalez@vauxoo.com https://github.com/luisg123v Oriana Maita oriana@vauxoo.com https://github.com/maitaoriana Gabriela Mogollon gmogollon@vauxoo.com https://github.com/GavyMG Jesus Zapata jesus@vauxoo.com https://github.com/JesusZapata -Germana Oliveira germana@vauxoo.com https://github.com/goliveirab
\ No newline at end of file +Germana Oliveira germana@vauxoo.com https://github.com/goliveirab +Mariano Fernandez mariano@vauxoo.com https://github.com/Batuto diff --git a/doc/cla/individual/NothingCtrl.md b/doc/cla/individual/NothingCtrl.md new file mode 100644 index 00000000000..bf58b0034d3 --- /dev/null +++ b/doc/cla/individual/NothingCtrl.md @@ -0,0 +1,9 @@ +Vietnam, 2017-08-15 + +I hereby agree to the terms of the Odoo Individual Contributor License Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this declaration. + +Signed, + +Duong Bao Thang nothingctrl@gmail.com https://github.com/NothingCtrl diff --git a/doc/cla/individual/repodevs.md b/doc/cla/individual/repodevs.md new file mode 100644 index 00000000000..06f8ec2f166 --- /dev/null +++ b/doc/cla/individual/repodevs.md @@ -0,0 +1,10 @@ +Indonesia, 2017-08-12 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, +Edi Santoso repodevs@gmail.com https://github.com/repodevs diff --git a/doc/reference/cmdline.rst b/doc/reference/cmdline.rst index c8b1954f1a9..37b0e9db646 100644 --- a/doc/reference/cmdline.rst +++ b/doc/reference/cmdline.rst @@ -159,7 +159,11 @@ database - ``%h`` is replaced by the whole hostname the request is made on. - ``%d`` is replaced by the subdomain the request is made on, with the exception of ``www`` (so domain ``odoo.com`` and ``www.odoo.com`` both - match the database ``odoo``) + match the database ``odoo``). + + These operations are case sensitive. Add option ``(?i)`` to match all + databases (so domain ``odoo.com`` using ``(?i)%d`` matches the database + ``Odoo``). .. option:: --db-template <template> diff --git a/doc/reference/views.rst b/doc/reference/views.rst index 2a9b5ece4bc..d5e5b10c58c 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -846,17 +846,13 @@ attributes: Possible children of the view element are: ``field`` - declares fields to aggregate or to use in kanban *logic*. If the field is - simply displayed in the kanban view, it does not need to be pre-declared. + declares fields to use in kanban *logic*. If the field is simply displayed in + the kanban view, it does not need to be pre-declared. Possible attributes are: ``name`` (required) the name of the field to fetch - ``sum``, ``avg``, ``min``, ``max``, ``count`` - displays the corresponding aggregation at the top of a kanban column, the - field's value is the label of the aggregation (a string). Only one - aggregate operation per field is supported. ``templates`` defines a list of :ref:`reference/qweb` templates. Cards definition may be diff --git a/odoo/addons/base/ir/ir_model.py b/odoo/addons/base/ir/ir_model.py index 25967c6b212..9f3ef47faa0 100644 --- a/odoo/addons/base/ir/ir_model.py +++ b/odoo/addons/base/ir/ir_model.py @@ -519,7 +519,8 @@ class IrModelFields(models.Model): if field.state == 'manual' and field.ttype == 'many2many': rel_name = field.relation_table or model._fields[field.name].relation tables_to_drop.add(rel_name) - model._pop_field(field.name) + if field.state == 'manual': + model._pop_field(field.name) if tables_to_drop: # drop the relation tables that are not used by other fields @@ -620,7 +621,7 @@ class IrModelFields(models.Model): if not self.search([('model_id', '=', vals['relation']), ('name', '=', vals['relation_field']), ('ttype', '=', 'many2one')]): raise UserError(_("Many2one %s on model %s does not exist!") % (vals['relation_field'], vals['relation'])) - self.clear_caches() + self.clear_caches() # for _existing_field_data() if vals['model'] in self.pool: # setup models; this re-initializes model in registry @@ -679,7 +680,7 @@ class IrModelFields(models.Model): res = super(IrModelFields, self).write(vals) - self.clear_caches() + self.clear_caches() # for _existing_field_data() if column_rename: # rename column in database, and its corresponding index if present diff --git a/odoo/addons/base/ir/ir_qweb/ir_qweb.py b/odoo/addons/base/ir/ir_qweb/ir_qweb.py index bd40a2ce76c..8bb333ea3fe 100644 --- a/odoo/addons/base/ir/ir_qweb/ir_qweb.py +++ b/odoo/addons/base/ir/ir_qweb/ir_qweb.py @@ -66,7 +66,7 @@ class IrQWeb(models.AbstractModel, QWeb): # assume cache will be invalidated by third party on write to ir.ui.view def _get_template_cache_keys(self): """ Return the list of context keys to use for caching ``_get_template``. """ - return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations'] + return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations', 'website_id'] # apply ormcache_context decorator unless in dev mode... @tools.conditional( @@ -189,14 +189,14 @@ class IrQWeb(models.AbstractModel, QWeb): # in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear # by restarting the server after updating the source code (or using the "Clear server cache" in debug tools) 'xml' not in tools.config['dev_mode'], - tools.ormcache('xmlid', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async'), + tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async', keys=("website_id",)), ) def _get_asset(self, xmlid, options, css=True, js=True, debug=False, async=False, values=None): files, remains = self._get_asset_content(xmlid, options) asset = AssetsBundle(xmlid, files, remains, env=self.env) return asset.to_html(css=css, js=js, debug=debug, async=async, url_for=(values or {}).get('url_for', lambda url: url)) - @tools.ormcache('xmlid', 'options.get("lang", "en_US")') + @tools.ormcache_context('xmlid', 'options.get("lang", "en_US")', keys=("website_id",)) def _get_asset_content(self, xmlid, options): options = dict(options, inherit_branding=False, inherit_branding_auto=False, diff --git a/odoo/addons/base/ir/ir_ui_view.py b/odoo/addons/base/ir/ir_ui_view.py index 085200a7475..c2016018f65 100644 --- a/odoo/addons/base/ir/ir_ui_view.py +++ b/odoo/addons/base/ir/ir_ui_view.py @@ -432,6 +432,15 @@ actual arch. # Inheritance mecanism #------------------------------------------------------ @api.model + def _get_inheriting_views_arch_domain(self, view_id, model): + return [ + ['inherit_id', '=', view_id], + ['model', '=', model], + ['mode', '=', 'extension'], + ['active', '=', True], + ] + + @api.model def get_inheriting_views_arch(self, view_id, model): """Retrieves the architecture of views that inherit from the given view, from the sets of views that should currently be used in the system. During the module upgrade phase it @@ -446,13 +455,8 @@ actual arch. :return: [(view_arch,view_id), ...] """ user_groups = self.env.user.groups_id + conditions = self._get_inheriting_views_arch_domain(view_id, model) - conditions = [ - ['inherit_id', '=', view_id], - ['model', '=', model], - ['mode', '=', 'extension'], - ['active', '=', True], - ] if self.pool._init and not self._context.get('load_all_views'): # Module init currently in progress, only consider views from # modules whose code is already loaded @@ -928,7 +932,7 @@ actual arch. def _read_template_keys(self): """ Return the list of context keys to use for caching ``_read_template``. """ - return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations'] + return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations', 'website_id'] # apply ormcache_context decorator unless in dev mode... @api.model diff --git a/odoo/addons/base/res/report_paperformat_views.xml b/odoo/addons/base/res/report_paperformat_views.xml index 94f5e94b247..1f48320d088 100644 --- a/odoo/addons/base/res/report_paperformat_views.xml +++ b/odoo/addons/base/res/report_paperformat_views.xml @@ -29,7 +29,7 @@ <field name="header_line" /> <field name="header_spacing" /> <field name="dpi" /> - <field name="report_ids" widget="many2many_tags"/> + <field name="report_ids" widget="many2many_tags" options="{'not_delete': True}"/> </group> </form> </field> diff --git a/odoo/api.py b/odoo/api.py index 59bd0636b11..4f4f93071fd 100644 --- a/odoo/api.py +++ b/odoo/api.py @@ -155,6 +155,14 @@ def constrains(*args): ``@constrains`` only supports simple field names, dotted names (fields of relational fields e.g. ``partner_id.customer``) are not supported and will be ignored + + ``@constrains`` will be triggered only if the declared fields in the + decorated method are included in the ``create`` or ``write`` call. + It implies that fields not present in a view will not trigger a call + during a record creation. A override of ``create`` is necessary to make + sure a constraint will always be triggered (e.g. to test the absence of + value). + """ return attrsetter('_constrains', args) |
