diff options
Diffstat (limited to 'addons')
34 files changed, 495 insertions, 96 deletions
diff --git a/addons/account/views/account_payment_view.xml b/addons/account/views/account_payment_view.xml index a534992cc8f..480b1378a29 100644 --- a/addons/account/views/account_payment_view.xml +++ b/addons/account/views/account_payment_view.xml @@ -144,7 +144,7 @@ <group> <group> <field name="payment_type" widget="radio" attrs="{'readonly': [('state', '!=', 'draft')]}"/> - <field name="partner_type" widget="selection" attrs="{'required': [('payment_type', 'in', ('inbound', 'outbound'))], 'invisible': [('payment_type', 'not in', ('inbound', 'outbound'))], 'readonly': [('state', '!=', 'draft')]}"/> + <field name="partner_type" widget="selection" attrs="{'required': [('state', '=', 'draft'), ('payment_type', 'in', ('inbound', 'outbound'))], 'invisible': [('payment_type', 'not in', ('inbound', 'outbound'))], 'readonly': [('state', '!=', 'draft')]}"/> <field name="partner_id" attrs="{'required': [('state', '=', 'draft'), ('payment_type', 'in', ('inbound', 'outbound'))], 'invisible': [('payment_type', 'not in', ('inbound', 'outbound'))], 'readonly': [('state', '!=', 'draft')]}" context="{'default_is_company': True, 'default_supplier': payment_type == 'outbound', 'default_customer': payment_type == 'inbound'}"/> <label for="amount"/> <div name="amount_div" class="o_row"> diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py index 3bcbfc39d55..9bd4a429cf5 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -44,7 +44,7 @@ class AccountPayment(models.Model): _inherit = "account.payment" check_amount_in_words = fields.Char(string="Amount in Words") - check_manual_sequencing = fields.Boolean(related='journal_id.check_manual_sequencing') + check_manual_sequencing = fields.Boolean(related='journal_id.check_manual_sequencing', readonly=1) check_number = fields.Integer(string="Check Number", readonly=True, copy=False, help="The selected journal is configured to print check numbers. If your pre-printed check paper already has numbers " "or if the current numbering is wrong, you can change it in the journal configuration page.") diff --git a/addons/barcodes/static/src/js/barcode_form_view.js b/addons/barcodes/static/src/js/barcode_form_view.js index 1730b42290e..015119c5830 100644 --- a/addons/barcodes/static/src/js/barcode_form_view.js +++ b/addons/barcodes/static/src/js/barcode_form_view.js @@ -146,10 +146,10 @@ FormController.include({ * @param {Object} activeBarcode: options sent by the field who use barcode features * @returns {Deferred} */ - _barcodeSelectedCandidate: function (candidate, record, barcode, activeBarcode) { + _barcodeSelectedCandidate: function (candidate, record, barcode, activeBarcode, quantity) { var changes = {}; var candidateChanges = {}; - candidateChanges[activeBarcode.quantity] = candidate.data[activeBarcode.quantity] + 1; + candidateChanges[activeBarcode.quantity] = quantity ? quantity : candidate.data[activeBarcode.quantity] + 1; changes[activeBarcode.fieldName] = { operation: 'UPDATE', id: candidate.id, @@ -283,15 +283,14 @@ FormController.include({ function (reserved) {return barcode.indexOf(reserved) === 0;}); var hasCommand = false; var defs = []; + if (! $.contains(target, self.el)) { + return; + } for (var k in self.activeBarcode) { var activeBarcode = self.activeBarcode[k]; // Handle the case where there are several barcode widgets on the same page. Since the // event is global on the page, all barcode widgets will be triggered. However, we only // want to keep the event on the target widget. - if (! $.contains(target, self.el)) { - continue; - } - var methods = self.activeBarcode[k].commands; var method = prefixed ? methods[barcode] : methods.barcode; if (method) { @@ -306,6 +305,8 @@ FormController.include({ } return self.alive($.when.apply($, defs)).then(function () { if (!prefixed) { + // remember the barcode scanned for the quantity listener + self.current_barcode = barcode; // redraw the view if we scanned a real barcode (required if // we manually apply the change in JS, e.g. incrementing the // quantity) @@ -321,6 +322,9 @@ FormController.include({ _quantityListener: function (event) { var character = String.fromCharCode(event.which); + if (! $.contains(event.target, this.el)) { + return; + } // only catch the event if we're not focused in // another field and it's a number if (!$(event.target).is('body, .modal') || !/[0-9]/.test(character)) { @@ -355,9 +359,10 @@ FormController.include({ title: _t('Set quantity'), buttons: [{text: _t('Select'), classes: 'btn-primary', close: true, click: function () { var new_qty = this.$content.find('.o_set_qty_input').val(); - var values = {}; - values[activeBarcode.quantity] = parseFloat(new_qty); - return self.model.notifyChanges(activeBarcode.candidate.id, values).then(function () { + var record = self.model.get(self.handle); + return self._barcodeSelectedCandidate(activeBarcode.candidate, record, + self.current_barcode, activeBarcode, parseFloat(new_qty)) + .then(function () { self.update({}, {reload: false}); }); }}, {text: _t('Discard'), close: true}], diff --git a/addons/barcodes/static/tests/barcode_tests.js b/addons/barcodes/static/tests/barcode_tests.js index cf77497a2a7..b2a2bf46126 100644 --- a/addons/barcodes/static/tests/barcode_tests.js +++ b/addons/barcodes/static/tests/barcode_tests.js @@ -404,4 +404,136 @@ QUnit.test('specification of widget barcode_handler', function (assert) { barcodeEvents.BarcodeEvents.max_time_between_keys_in_ms = delay; delete fieldRegistry.map.test_barcode_handler; }); + +QUnit.test('specification of widget barcode_handler with keypress and notifyChange', function (assert) { + assert.expect(6); + var done = assert.async(); + + var delay = barcodeEvents.BarcodeEvents.max_time_between_keys_in_ms; + barcodeEvents.BarcodeEvents.max_time_between_keys_in_ms = 0; + + this.data.order.onchanges = { + _barcode_scanned: function () {}, + }; + + // Define a specific barcode_handler widget for this test case + var TestBarcodeHandler = AbstractField.extend({ + init: function () { + this._super.apply(this, arguments); + + this.trigger_up('activeBarcode', { + name: 'test', + fieldName: 'line_ids', + notifyChange: false, + setQuantityWithKeypress: true, + quantity: 'quantity', + commands: { + barcode: '_barcodeAddX2MQuantity', + } + }); + }, + }); + fieldRegistry.add('test_barcode_handler', TestBarcodeHandler); + + var form = createView({ + View: FormView, + model: 'order', + data: this.data, + arch: '<form>' + + '<field name="_barcode_scanned" widget="test_barcode_handler"/>' + + '<field name="line_ids">' + + '<tree>' + + '<field name="product_id"/>' + + '<field name="product_barcode" invisible="1"/>' + + '<field name="quantity"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + _.each(['1','2','3','4','5','6','7','8','9','0','Enter'], triggerKeypressEvent); + // Quantity listener should open a dialog. + triggerKeypressEvent('5'); + + setTimeout(function () { + var keycode = $.ui.keyCode.ENTER; + + assert.strictEqual($('.modal .modal-body').length, 1, 'should open a modal with a quantity as input'); + assert.strictEqual($('.modal .modal-body .o_set_qty_input').val(), '5', 'the quantity by default in the modal shoud be 5'); + + $('.modal .modal-body .o_set_qty_input').val('7'); + + $('.modal .modal-body .o_set_qty_input').trigger($.Event('keypress', {which: keycode, keyCode: keycode})); + assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(1)').text(), '7', + "quantity checked should be 7"); + + assert.verifySteps(['read', 'read']); + + form.destroy(); + barcodeEvents.BarcodeEvents.max_time_between_keys_in_ms = delay; + delete fieldRegistry.map.test_barcode_handler; + done(); + }); +}); +QUnit.test('barcode_scanned only trigger error for active view', function (assert) { + assert.expect(2); + + this.data.order_line.fields._barcode_scanned = {string: 'Barcode scanned', type: 'char'}; + + var form = createView({ + View: FormView, + model: 'order', + data: this.data, + arch: '<form>' + + '<field name="_barcode_scanned" widget="barcode_handler"/>' + + '<field name="line_ids">' + + '<tree>' + + '<field name="product_id"/>' + + '<field name="product_barcode" invisible="1"/>' + + '<field name="quantity"/>' + + '</tree>' + + '</field>' + + '</form>', + archs: { + "order_line,false,form": + '<form string="order line">' + + '<field name="_barcode_scanned" widget="barcode_handler"/>' + + '<field name="product_id"/>' + + '</form>', + }, + res_id: 1, + intercepts: { + warning: function (event) { + assert.step(event.name); + } + }, + viewOptions: { + mode: 'edit', + }, + }); + + form.$('.o_data_row:first').click(); + + // We do not trigger on the body since modal and + // form view are both inside it. + function modalTriggerKeypressEvent(char) { + var keycode; + if (char === "Enter") { + keycode = $.ui.keyCode.ENTER; + } else { + keycode = char.charCodeAt(0); + } + return $('.modal').trigger($.Event('keypress', {which: keycode, keyCode: keycode})); + } + _.each(['O','-','B','T','N','.','c','a','n','c','e','l','Enter'], modalTriggerKeypressEvent); + assert.verifySteps(['warning'], "only one event should be triggered"); + form.destroy(); +}); }); diff --git a/addons/board/static/src/js/board_view.js b/addons/board/static/src/js/board_view.js index e5fd56ad049..f65e6bb1e85 100644 --- a/addons/board/static/src/js/board_view.js +++ b/addons/board/static/src/js/board_view.js @@ -28,7 +28,7 @@ var BoardController = FormController.extend({ */ init: function (parent, model, renderer, params) { this._super.apply(this, arguments); - this.viewID = params.viewID; + this.customViewID = params.customViewID; }, //-------------------------------------------------------------------------- @@ -58,9 +58,9 @@ var BoardController = FormController.extend({ var board = this.renderer.getBoard(); var arch = QWeb.render('DashBoard.xml', _.extend({}, board)); return this._rpc({ - route: '/web/view/add_custom', + route: '/web/view/edit_custom', params: { - view_id: this.viewID, + custom_id: this.customViewID, arch: arch, } }).then(dataManager.invalidate.bind(dataManager)); @@ -426,7 +426,7 @@ var BoardView = FormView.extend({ */ init: function (viewInfo) { this._super.apply(this, arguments); - this.controllerParams.viewID = viewInfo.view_id; + this.controllerParams.customViewID = viewInfo.custom_view_id; }, }); diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 1ef295e3962..1211fc5da05 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -126,8 +126,8 @@ QUnit.test('basic functionality, with one sub action', function (assert) { if (route === '/web/dataset/search_read') { assert.deepEqual(args.domain, [['foo', '!=', 'False']], "the domain should be passed"); } - if (route === '/web/view/add_custom') { - assert.step('add custom'); + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); return $.when(true); } return this._super.apply(this, arguments); @@ -158,7 +158,7 @@ QUnit.test('basic functionality, with one sub action', function (assert) { form.$('.oe_fold').click(); assert.ok(form.$('.oe_content').is(':visible'), "content is visible again"); - assert.verifySteps(['load action', 'add custom', 'add custom']); + assert.verifySteps(['load action', 'edit custom', 'edit custom']); assert.strictEqual($('.modal').length, 0, "should have no modal open"); @@ -186,7 +186,7 @@ QUnit.test('basic functionality, with one sub action', function (assert) { assert.strictEqual($('.modal').length, 0, "should have no modal open"); assert.strictEqual(form.$('.oe_action').length, 0, "should have no displayed action"); - assert.verifySteps(['load action', 'add custom', 'add custom', 'add custom', 'add custom']); + assert.verifySteps(['load action', 'edit custom', 'edit custom', 'edit custom', 'edit custom']); form.destroy(); }); @@ -295,8 +295,8 @@ QUnit.test('can drag and drop a view', function (assert) { views: [[4, 'list']], }); } - if (route === '/web/view/add_custom') { - assert.step('add custom'); + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); return $.when(true); } return this._super.apply(this, arguments); @@ -342,8 +342,8 @@ QUnit.test('twice the same action in a dashboard', function (assert) { views: [[4, 'list'],[5, 'kanban']], }); } - if (route === '/web/view/add_custom') { - assert.step('add custom'); + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); return $.when(true); } return this._super.apply(this, arguments); diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 5cae21147a8..793e7cf2286 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -618,13 +618,21 @@ class Meeting(models.Model): event_date = event_date.astimezone(timezone) # transform "+hh:mm" timezone rset1 = rrule.rrulestr(str(self.rrule), dtstart=event_date, forceset=True, tzinfos={}) recurring_meetings = self.search([('recurrent_id', '=', self.id), '|', ('active', '=', False), ('active', '=', True)]) - for meeting in recurring_meetings: - recurring_date = fields.Datetime.from_string(meeting.recurrent_id_date) - if use_naive_datetime: - recurring_date = recurring_date.replace(tzinfo=None) - else: - recurring_date = todate(meeting.recurrent_id_date) - rset1.exdate(recurring_date) + + # We handle a maximum of 50,000 meetings at a time, and clear the cache at each step to + # control the memory usage. + invalidate = False + for meetings in self.env.cr.split_for_in_conditions(recurring_meetings, size=50000): + if invalidate: + self.invalidate_cache() + for meeting in meetings: + recurring_date = fields.Datetime.from_string(meeting.recurrent_id_date) + if use_naive_datetime: + recurring_date = recurring_date.replace(tzinfo=None) + else: + recurring_date = todate(meeting.recurrent_id_date) + rset1.exdate(recurring_date) + invalidate = True return [d.astimezone(pytz.UTC) if d.tzinfo else d for d in rset1] @api.multi @@ -1149,6 +1157,7 @@ class Meeting(models.Model): def key(record): # we need to deal with undefined fields, as sorted requires an homogeneous iterable def boolean_product(x): + x = False if (isinstance(x, models.Model) and not x) else x if isinstance(x, bool): return (x, x) return (True, x) diff --git a/addons/fleet/models/fleet_vehicle.py b/addons/fleet/models/fleet_vehicle.py index 3ef914c2612..d7bac0d2925 100644 --- a/addons/fleet/models/fleet_vehicle.py +++ b/addons/fleet/models/fleet_vehicle.py @@ -22,7 +22,7 @@ class FleetVehicle(models.Model): license_plate = fields.Char(required=True, track_visibility="onchange", help='License plate number of the vehicle (i = plate number for a car)') vin_sn = fields.Char('Chassis Number', help='Unique number written on the vehicle motor (VIN/SN number)', copy=False) - driver_id = fields.Many2one('res.partner', 'Driver', help='Driver of the vehicle', copy=False) + driver_id = fields.Many2one('res.partner', 'Driver', track_visibility="onchange", help='Driver of the vehicle', copy=False) model_id = fields.Many2one('fleet.vehicle.model', 'Model', required=True, help='Model of the vehicle') log_fuel = fields.One2many('fleet.vehicle.log.fuel', 'vehicle_id', 'Fuel Logs') log_services = fields.One2many('fleet.vehicle.log.services', 'vehicle_id', 'Services Logs') diff --git a/addons/l10n_be_hr_payroll_fleet/models/fleet.py b/addons/l10n_be_hr_payroll_fleet/models/fleet.py index 8541a255675..da0bf8600fc 100644 --- a/addons/l10n_be_hr_payroll_fleet/models/fleet.py +++ b/addons/l10n_be_hr_payroll_fleet/models/fleet.py @@ -139,19 +139,6 @@ class FleetVehicleModel(models.Model): for model in self: model.default_total_depreciated_cost = model.co2_fee + model.default_recurring_cost_amount_depreciated - @api.multi - @api.depends('name', 'brand_id', 'default_total_depreciated_cost') - def name_get(self): - res = super(FleetVehicleModel, self).name_get() - new_res = [] - for res_item in res: - model = self.browse(res_item[0]) - if model.default_total_depreciated_cost != 0.0: - new_res.append((res_item[0], res_item[1] + u" \u2022 " + str(round(model.default_total_depreciated_cost, 2)))) - else: - new_res.append(res_item) - return new_res - @api.depends('default_co2') def _compute_co2_fee(self): for model in self: diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index f94eead0736..f2a2f176a6b 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -680,7 +680,7 @@ class Channel(models.Model): msg += _(" This channel is private. People must be invited to join it.") else: channel_partners = self.env['mail.channel.partner'].search([('partner_id', '!=', partner.id), ('channel_id', '=', self.id)]) - msg = _("You are in a private conversation with <b>@%s</b>.") % channel_partners[0].partner_id.name + msg = _("You are in a private conversation with <b>@%s</b>.") % (channel_partners[0].partner_id.name if channel_partners else _('Anonymous')) msg += _("""<br><br> You can mention someone by typing <b>@username</b>, this will grab its attention.<br> You can mention a channel by typing <b>#channel</b>.<br> diff --git a/addons/mass_mailing/wizard/test_mailing.py b/addons/mass_mailing/wizard/test_mailing.py index f1231fd2b38..7fafbb920a8 100644 --- a/addons/mass_mailing/wizard/test_mailing.py +++ b/addons/mass_mailing/wizard/test_mailing.py @@ -26,7 +26,7 @@ class TestMassMailing(models.TransientModel): 'reply_to': mailing.reply_to, 'email_to': test_mail, 'subject': mailing.name, - 'body_html': mailing.body_html, + 'body_html': tools.html_sanitize(mailing.body_html, sanitize_attributes=True, sanitize_style=True, strip_classes=True), 'notification': True, 'mailing_id': mailing.id, 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 8bac597e6a9..c6ec88533ec 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -345,28 +345,25 @@ class MrpWorkorder(models.Model): # If last work order, then post lots used # TODO: should be same as checking if for every workorder something has been done? if not self.next_work_order_id: - production_moves = self.production_id.move_finished_ids.filtered(lambda x: (x.state not in ('done', 'cancel'))) - for production_move in production_moves: - if production_move.product_id.id == self.production_id.product_id.id and production_move.has_tracking != 'none': - move_line = production_move.move_line_ids.filtered(lambda x: x.lot_id.id == self.final_lot_id.id) - if move_line: - move_line.product_uom_qty += self.qty_producing - else: - move_line.create({'move_id': production_move.id, - 'product_id': production_move.product_id.id, - 'lot_id': self.final_lot_id.id, - 'product_uom_qty': self.qty_producing, - 'product_uom_id': production_move.product_uom.id, - 'qty_done': self.qty_producing, - 'workorder_id': self.id, - 'location_id': production_move.location_id.id, - 'location_dest_id': production_move.location_dest_id.id, - }) - elif production_move.unit_factor: - rounding = production_move.product_uom.rounding - production_move.quantity_done += float_round(self.qty_producing * production_move.unit_factor, precision_rounding=rounding) + production_move = self.production_id.move_finished_ids.filtered( + lambda x: (x.product_id.id == self.production_id.product_id.id) and (x.state not in ('done', 'cancel'))) + if production_move.product_id.tracking != 'none': + move_line = production_move.move_line_ids.filtered(lambda x: x.lot_id.id == self.final_lot_id.id) + if move_line: + move_line.product_uom_qty += self.qty_producing else: - production_move.quantity_done += self.qty_producing + move_line.create({'move_id': production_move.id, + 'product_id': production_move.product_id.id, + 'lot_id': self.final_lot_id.id, + 'product_uom_qty': self.qty_producing, + 'product_uom_id': production_move.product_uom.id, + 'qty_done': self.qty_producing, + 'workorder_id': self.id, + 'location_id': production_move.location_id.id, + 'location_dest_id': production_move.location_dest_id.id, + }) + else: + production_move.quantity_done += self.qty_producing if not self.next_work_order_id: for by_product_move in self.production_id.move_finished_ids.filtered(lambda x: (x.product_id.id != self.production_id.product_id.id) and (x.state not in ('done', 'cancel'))): diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index deb920ec69e..9cdcbec12af 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -75,7 +75,7 @@ class PadCommon(models.AbstractModel): @api.model def pad_get_content(self, url): company = self.env.user.sudo().company_id - myPad = EtherpadLiteClient(company.pad_key, company.pad_server + '/api') + myPad = EtherpadLiteClient(company.pad_key, (company.pad_server or '') + '/api') content = '' if url: split_url = url.split('/p/') @@ -132,7 +132,7 @@ class PadCommon(models.AbstractModel): for k, field in self._fields.items(): if hasattr(field, 'pad_content_field') and vals.get(field.pad_content_field) and self[k]: company = self.env.user.sudo().company_id - myPad = EtherpadLiteClient(company.pad_key, company.pad_server + '/api') + myPad = EtherpadLiteClient(company.pad_key, (company.pad_server or '') + '/api') path = self[k].split('/p/')[1] myPad.setHtmlFallbackText(path, vals[field.pad_content_field]) diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index be70f46309e..6f13400356b 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -89,7 +89,7 @@ class WebsitePayment(http.Controller): 'amount': float(amount), 'currency_id': currency_id, 'partner_id': partner_id, - 'type': 'form_save' if acquirer.save_token else 'form', + 'type': 'form_save' if acquirer.save_token != 'none' and partner_id else 'form', } tx = request.env['payment.transaction'].sudo().create(values) @@ -119,7 +119,7 @@ class WebsitePayment(http.Controller): 'currency_id': int(currency_id), 'partner_id': partner_id, 'payment_token_id': pm_id, - 'type': 'form_save' if token.acquirer_id.save_token else 'form', + 'type': 'form_save' if token.acquirer_id.save_token != 'none' and partner_id else 'form', } tx = request.env['payment.transaction'].sudo().create(values) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index bf88e322a9d..843b091c4d2 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -450,7 +450,7 @@ class SaleOrder(models.Model): if references.get(invoices.get(group_key)): if order not in references[invoices[group_key]]: - references[invoice] = references[invoice] | order + references[invoices[group_key]] |= order if not invoices: raise UserError(_('There is no invoiceable line.')) diff --git a/addons/stock/views/procurement_views.xml b/addons/stock/views/procurement_views.xml index e7f846a69b8..b22a7ff849d 100644 --- a/addons/stock/views/procurement_views.xml +++ b/addons/stock/views/procurement_views.xml @@ -78,6 +78,7 @@ <group string="Applied On"> <field name="location_id"/> <field name="warehouse_id" groups="base.group_no_one"/> + <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/> </group> <group string="Creates"> <field name="location_src_id" attrs="{'required': [('action', '=', 'move')], 'invisible':[('action', '!=', 'move')]}" domain="[('usage','!=','view')]"/> diff --git a/addons/stock/views/stock_location_views.xml b/addons/stock/views/stock_location_views.xml index 780bb5891b4..810fc4ba078 100644 --- a/addons/stock/views/stock_location_views.xml +++ b/addons/stock/views/stock_location_views.xml @@ -271,7 +271,7 @@ <field name="push_ids" colspan="4" nolabel="1"/> </group> <group string="Procurement Rules" colspan="4" > - <field name="pull_ids" colspan="4" nolabel="1"/> + <field name="pull_ids" colspan="4" nolabel="1" context="{'default_company_id': company_id}"/> </group> </sheet> </form> diff --git a/addons/stock_account/views/product_views.xml b/addons/stock_account/views/product_views.xml index e0b925cc2b3..69ce7f4910f 100644 --- a/addons/stock_account/views/product_views.xml +++ b/addons/stock_account/views/product_views.xml @@ -98,7 +98,7 @@ <field name="view_type">form</field> <field name="view_id" ref="view_stock_product_tree2"/> <field name="domain">[('type', '=', 'product'), ('qty_available', '!=', 0)]</field> - <field name="context">{}</field> + <field name="context">{'company_owned': True}</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Create a new product valuation diff --git a/addons/stock_account/wizard/stock_quantity_history.py b/addons/stock_account/wizard/stock_quantity_history.py index 6b244d13ac2..fe920a457bb 100644 --- a/addons/stock_account/wizard/stock_quantity_history.py +++ b/addons/stock_account/wizard/stock_quantity_history.py @@ -24,7 +24,7 @@ class StockQuantityHistory(models.TransientModel): 'name': _('Products'), 'res_model': 'product.product', 'domain': "[('type', '=', 'product'), ('qty_available', '!=', 0)]", - 'context': dict(self.env.context, to_date=self.date), + 'context': dict(self.env.context, to_date=self.date, company_owned=True), } return action else: diff --git a/addons/transifex/models/ir_translation.py b/addons/transifex/models/ir_translation.py index 46ceecdb91f..316fbba47b1 100644 --- a/addons/transifex/models/ir_translation.py +++ b/addons/transifex/models/ir_translation.py @@ -78,11 +78,14 @@ class IrTranslation(models.Model): translation.transifex_url = False continue - # e.g. 'https://www.transifex.com/odoo/odoo-10/translate/#fr/sale/42?q=Sale+Order' + # e.g. https://www.transifex.com/odoo/odoo-10/translate/#fr/sale/42?q=text'Sale+Order' translation.transifex_url = "%(url)s/%(project)s/translate/#%(lang)s/%(module)s/42?q=%(src)s" % { 'url': base_url, 'project': project, 'lang': lang_code, 'module': translation.module, - 'src': werkzeug.url_quote_plus(translation.source[:50]), + 'src': werkzeug.url_quote_plus( + "text:'" + + translation.source[:50].replace("'", "\'") + + "'"), } diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index ccf7b932669..c1eb41ac13e 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -961,14 +961,17 @@ class DataSet(http.Controller): class View(http.Controller): - @http.route('/web/view/add_custom', type='json', auth="user") - def add_custom(self, view_id, arch): - CustomView = request.env['ir.ui.view.custom'] - CustomView.create({ - 'user_id': request.session.uid, - 'ref_id': view_id, - 'arch': arch - }) + @http.route('/web/view/edit_custom', type='json', auth="user") + def edit_custom(self, custom_id, arch): + """ + Edit a custom view + + :param int custom_id: the id of the edited custom view + :param str arch: the edited arch of the custom view + :returns: dict with acknowledged operation (result set to True) + """ + custom_view = request.env['ir.ui.view.custom'].browse(custom_id) + custom_view.write({ 'arch': arch }) return {'result': True} class Binary(http.Controller): diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js index 225599e0f7e..e2b3a23d2a6 100644 --- a/addons/web/static/src/js/chrome/web_client.js +++ b/addons/web/static/src/js/chrome/web_client.js @@ -91,6 +91,10 @@ return AbstractWebClient.extend({ bind_hashchange: function() { var self = this; $(window).bind('hashchange', this.on_hashchange); + var didHashChanged = false; + $(window).one('hashchange', function () { + didHashChanged = true; + }); var state = $.bbq.getState(true); if (_.isEmpty(state) || state.action === "login") { @@ -101,6 +105,9 @@ return AbstractWebClient.extend({ args: [[session.uid], ['action_id']], }) .done(function(result) { + if (didHashChanged) { + return; + } var data = result[0]; if(data.action_id) { self.action_manager.doAction(data.action_id[0]); diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 0f426dedd3b..b14d71c0b26 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -1197,9 +1197,6 @@ var FieldBinaryImage = AbstractFieldBinary.extend({ self.do_warn(_t("Image"), _t("Could not display the selected image.")); }); }, - isSet: function () { - return true; - }, }); var FieldBinaryFile = AbstractFieldBinary.extend({ diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index c3df03fcff9..423dbf40235 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -2335,6 +2335,21 @@ var FieldReference = FieldMany2One.extend({ }, //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + if (this.mode === 'edit' && !this.field.relation) { + return this.$('select'); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- 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 962bb2899e1..e86d7dba8f7 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -3646,8 +3646,9 @@ var BasicModel = AbstractModel.extend({ if (isFieldInView) { var field = r.fields[fieldName]; var fieldType = field.type; + var rec; if (fieldType === 'many2one') { - var rec = self._makeDataPoint({ + rec = self._makeDataPoint({ context: r.context, modelName: field.relation, data: {id: r._changes[fieldName]}, @@ -3655,6 +3656,16 @@ var BasicModel = AbstractModel.extend({ }); r._changes[fieldName] = rec.id; many2ones[fieldName] = true; + } else if (fieldType === 'reference') { + var reference = r._changes[fieldName].split(','); + rec = self._makeDataPoint({ + context: r.context, + modelName: reference[0], + data: {id: parseInt(reference[1])}, + parentID: r.id, + }); + r._changes[fieldName] = rec.id; + many2ones[fieldName] = true; } else if (_.contains(['one2many', 'many2many'], fieldType)) { var x2mCommands = commands[0][2][fieldName]; defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands)); diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 93201843633..4a5d9b45b4a 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -267,6 +267,7 @@ var PivotModel = AbstractModel.extend({ this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys; this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy; this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures; + this.defaultGroupedBy = this.data.groupedBy.length ? this.data.groupedBy : this.defaultGroupedBy; } if ('domain' in params) { this.data.domain = params.domain; @@ -295,7 +296,7 @@ var PivotModel = AbstractModel.extend({ self._updateTree(old_col_root, self.data.main_col.root); new_groupby_length = self._getHeaderDepth(self.data.main_col.root) - 1; - self.data.main_row.groupbys = old_col_root.groupbys.slice(0, new_groupby_length); + self.data.main_row.groupbys = old_row_root.groupbys.slice(0, new_groupby_length); }); }, /** diff --git a/addons/web/static/src/less/fields.less b/addons/web/static/src/less/fields.less index ff6fe9eb20b..761e8577a29 100644 --- a/addons/web/static/src/less/fields.less +++ b/addons/web/static/src/less/fields.less @@ -361,6 +361,10 @@ &:hover .o_form_image_controls { opacity: 0.8; } + + &.o_field_invalid > img { + border: 1px solid @brand-danger; + } } // Input loading/file diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 6aa28fd9a2e..f2a49cbc387 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -1796,7 +1796,6 @@ QUnit.module('basic_fields', { assert.strictEqual(form.$('div[name="document"] > img').css('max-width'), "90px", "the image should correctly set its attributes"); form.destroy(); - }); QUnit.test('image fields in subviews are loaded correctly', function (assert) { @@ -1853,6 +1852,34 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('image fields with required attribute', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="document" required="1" widget="image"/>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'create') { + throw new Error("Should not do a create RPC with unset required image field"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_save').click(); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), + "form view should still be editable"); + assert.ok(form.$('.o_field_widget').hasClass('o_field_invalid'), + "image field should be displayed as invalid"); + + form.destroy(); + }); + QUnit.module('JournalDashboardGraph', { beforeEach: function () { _.extend(this.data.partner.fields, { diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index a046e79de6c..01739c7888c 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -11392,6 +11392,38 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('default_get a reference field in a x2m', function (assert) { + assert.expect(1); + + this.data.partner.fields.turtles.default = [ + [0, false, {turtle_ref: 'product,37'}] + ]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="turtles">' + + '<tree>' + + '<field name="turtle_ref"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + viewOptions: { + mode: 'edit', + }, + archs: { + 'turtle,false,form': '<form><field name="display_name"/><field name="turtle_ref"/></form>', + }, + }); + assert.strictEqual(form.$('.o_field_one2many[name="turtles"] .o_data_row:first').text(), "xphone", + "the default value should be correctly handled"); + form.destroy(); + }); + QUnit.test('widget reference on char field, reset by onchange', function (assert) { assert.expect(4); @@ -11442,6 +11474,32 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('reference and list navigation', function (assert) { + assert.expect(2); + + var list = createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: '<tree editable="bottom"><field name="reference"/></tree>', + }); + + // edit first row + list.$('.o_data_row .o_data_cell').first().click(); + assert.strictEqual(list.$('.o_data_row:eq(0) .o_field_widget[name="reference"] input')[0], document.activeElement, + 'input of first data row should be selected'); + + // press TAB to go to next line + list.$('.o_data_row:eq(0) input').trigger($.Event('keydown', { + which: $.ui.keyCode.TAB, + keyCode: $.ui.keyCode.TAB, + })); + assert.strictEqual(list.$('.o_data_row:eq(1) .o_field_widget[name="reference"] select')[0], document.activeElement, + 'select of second data row should be selected'); + + list.destroy(); + }); + QUnit.test('one2many with extra field from server not in form', function (assert) { assert.expect(6); diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index 77c1c66fae8..26733a5edca 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -1083,4 +1083,45 @@ QUnit.module('Views', { 'should have loaded the proper data'); pivot.destroy(); }); + + QUnit.test('Row and column groupbys plus a domain', function (assert) { + assert.expect(3); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + }); + + // Set a column groupby + pivot.$('thead .o_pivot_header_cell_closed').click(); + pivot.$('.o_field_selection li[data-field=customer] a').click(); + + // Set a Row groupby + pivot.$('tbody .o_pivot_header_cell_closed').click(); + pivot.$('.o_pivot_field_menu li[data-field=product_id] a').click(); + + // Set a domain + pivot.update({domain: [['product_id', '=', 41]]}); + + var expectedContext = {pivot_column_groupby: ['customer'], + pivot_measures: ['foo'], + pivot_row_groupby: ['product_id']}; + + // Mock 'save as favorite' + assert.deepEqual(pivot.getContext(), expectedContext, + 'The pivot view should have the right context'); + + var $xpadHeader = pivot.$('tbody .o_pivot_header_cell_closed[data-original-title=Product]'); + assert.equal($xpadHeader.length, 1, + 'There should be only one product line because of the domain'); + + assert.equal($xpadHeader.text(), 'xpad', + 'The product should be the right one'); + + pivot.destroy(); + }); });}); diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 68051f8c07c..2174dae0b90 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -164,7 +164,7 @@ class Web_Editor(http.Controller): 'datas_fname': datas_fname, 'type': 'url', 'url': url, - 'public': True, + 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, }) @@ -195,7 +195,7 @@ class Web_Editor(http.Controller): 'name': name, 'datas': base64.b64encode(data), 'datas_fname': datas_fname, - 'public': True, + 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, }) diff --git a/addons/web_editor/static/src/js/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js index bbb43fc2f82..479a26469a3 100644 --- a/addons/web_editor/static/src/js/backend/fields.js +++ b/addons/web_editor/static/src/js/backend/fields.js @@ -90,11 +90,49 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi //-------------------------------------------------------------------------- /** + * Returns the domain for attachments used in media dialog. + * We look for attachments related to the current document. If there is a value for the model + * field, it is used to search attachments, and the attachments from the current document are + * filtered to display only user-created documents. + * In the case of a wizard such as mail, we have the documents uploaded and those of the model + * + * @private + * @returns {Array} "ir.attachment" odoo domain. + */ + _getAttachmentsDomain: function () { + var domain = ['|', ['id', 'in', _.pluck(this.attachments, 'id')]]; + var attachedDocumentDomain = [ + '&', + ['res_model', '=', this.model], + ['res_id', '=', this.res_id|0] + ]; + // if the document is not yet created, do not see the documents of other users + if (!this.res_id) { + attachedDocumentDomain.unshift('&'); + attachedDocumentDomain.push(['create_uid', '=', session.uid]); + } + if (this.recordData.model) { + var relatedDomain = ['&', + ['res_model', '=', this.recordData.model], + ['res_id', '=', this.recordData.res_id|0]]; + if (!this.recordData.res_id) { + relatedDomain.unshift('&'); + relatedDomain.push(['create_uid', '=', session.uid]); + } + domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain); + } else { + domain = domain.concat(attachedDocumentDomain); + } + return domain; + }, + /** * @private * @returns {Object} the summernote configuration */ _getSummernoteConfig: function () { var summernoteConfig = { + model: this.model, + id: this.res_id, focus: false, height: 180, toolbar: [ @@ -113,6 +151,22 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi lang: "odoo", onChange: this._doDebouncedAction.bind(this), }; + + var fieldNameAttachment =_.chain(this.recordData) + .pairs() + .find(function (value) { + return _.isObject(value[1]) && value[1].model === "ir.attachment"; + }) + .first() + .value(); + + if (fieldNameAttachment) { + this.fieldNameAttachment = fieldNameAttachment; + this.attachments = []; + summernoteConfig.onImageUpload = this._onImageUpload.bind(this); + summernoteConfig.getMediaDomain = this._getAttachmentsDomain.bind(this); + } + if (config.debug) { summernoteConfig.toolbar.splice(7, 0, ['view', ['codeview']]); } @@ -131,6 +185,34 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi return this.$content.html(); }, /** + * trigger_up 'field_changed' add record into the "ir.attachment" field found in the view. + * This method is called when an image is uploaded by the media dialog. + * + * For e.g. when sending email, this allows people to add attachments with the content + * editor interface and that they appear in the attachment list. + * The new documents being attached to the email, they will not be erased by the CRON + * when closing the wizard. + * + * @private + */ + _onImageUpload: function (attachments) { + var self = this; + attachments = _.filter(attachments, function (attachment) { + return !_.findWhere(self.attachments, {id: attachment.id}); + }); + if (!attachments.length) { + return; + } + this.attachments = this.attachments.concat(attachments); + this.trigger_up('field_changed', { + dataPointID: this.dataPointID, + changes: _.object([this.fieldNameAttachment], [{ + operation: 'ADD_M2M', + ids: attachments + }]) + }); + }, + /** * @override * @private */ @@ -140,8 +222,6 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi this.$textarea.summernote(this._getSummernoteConfig()); this.$content = this.$('.note-editable:first'); this.$content.html(this._textToHtml(this.value)); - this.$content.data('oe-id', this.recordData.res_id || this.res_id); - this.$content.data('oe-model', this.recordData.model || this.model); // trigger a mouseup to refresh the editor toolbar this.$content.trigger('mouseup'); if (this.nodeOptions['style-inline']) { diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js index fb4d6fc7206..f6e2ac63aa9 100644 --- a/addons/web_editor/static/src/js/editor/rte.summernote.js +++ b/addons/web_editor/static/src/js/editor/rte.summernote.js @@ -397,6 +397,7 @@ eventHandler.modules.imageDialog.showImageDialog = function ($editable) { media: media, options: { lastFilters: ['background'], + onUpload: $editable.data('callbacks').onImageUpload, }, }); return new $.Deferred().reject(); @@ -788,6 +789,14 @@ eventHandler.attach = function (oLayoutInfo, options) { $(document).on("keyup", reRangeSelectKey); var clone_data = false; + + if (options.model) { + oLayoutInfo.editable().data({'oe-model': options.model, 'oe-id': options.id}); + } + if (options.getMediaDomain) { + oLayoutInfo.editable().data('oe-media-domain', options.getMediaDomain); + } + var $node = oLayoutInfo.editor(); if ($node.data('oe-model') || $node.data('oe-translation-id')) { $node.on('content_changed', function () { @@ -1089,6 +1098,7 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, { _.extend({ res_model: data.$editable.data('oe-model'), res_id: data.$editable.data('oe-id'), + domain: data.$editable.data('oe-media-domain'), }, data.options), data.$editable, data.media diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js index 53df66e9ba7..f7c1c808016 100644 --- a/addons/web_editor/static/src/js/widgets/widgets.js +++ b/addons/web_editor/static/src/js/widgets/widgets.js @@ -209,7 +209,9 @@ var ImageWidget = MediaWidget.extend({ this.options = options; this.accept = options.accept || (options.document ? '*/*' : 'image/*'); - if (options.res_id) { + if (options.domain) { + this.domain = typeof options.domain === 'function' ? options.domain() : options.domain; + } else if (options.res_id) { this.domain = ['|', '&', ['res_model', '=', options.res_model], ['res_id', '=', options.res_id], ['res_model', '=', 'ir.ui.view']]; @@ -330,6 +332,11 @@ var ImageWidget = MediaWidget.extend({ self.$media.css(style); } + if (self.options.onUpload) { + // We consider that when selecting an image it is as if we upload it in the html content. + self.options.onUpload([img]); + } + return self.media; }); }, @@ -504,6 +511,10 @@ var ImageWidget = MediaWidget.extend({ _processFile(attachments[i], error); } + if (self.options.onUpload) { + self.options.onUpload(attachments); + } + function _processFile(attachment, error) { var $button = self.$('.o_upload_image_button'); if (!error) { |
