diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-10-22 15:39:05 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-10-22 16:59:51 +0200 |
| commit | b81c2bce846c94b5695d680ebbc30f1cfe51e614 (patch) | |
| tree | 385190eb54adfbcdf8ff437d2cc667ddd1013718 | |
| parent | 933165ec3afa0676de4b2f3662c4c1bb2f97efc5 (diff) | |
| parent | 3c108977c1e7d9361894c38bcd253030e8097479 (diff) | |
[MERGE] forward port branch saas-11.4 up to 3c108977c1e
85 files changed, 1439 insertions, 192 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index edb44f3d983..15ed4ecb5a6 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -381,7 +381,7 @@ class AccountBankStatementLine(models.Model): #################################################### def _get_common_sql_query(self, overlook_partner = False, excluded_ids = None, split = False): - acc_type = "acc.internal_type IN ('payable', 'receivable')" if (self.partner_id or overlook_partner) else "acc.reconcile = true" + acc_type = "acc.reconcile = true" select_clause = "SELECT aml.id " from_clause = "FROM account_move_line aml JOIN account_account acc ON acc.id = aml.account_id " account_clause = '' diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index f65487073fe..25cfa07eb1c 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -226,7 +226,7 @@ class AccountInvoice(models.Model): for line in self.move_id.line_ids.filtered(lambda l: l.account_id.id == self.account_id.id): payment_lines.update(line.mapped('matched_credit_ids.credit_move_id.id')) payment_lines.update(line.mapped('matched_debit_ids.debit_move_id.id')) - self.payment_move_line_ids = self.env['account.move.line'].browse(list(payment_lines)) + self.payment_move_line_ids = self.env['account.move.line'].browse(list(payment_lines)).sorted() name = fields.Char(string='Reference/Description', index=True, readonly=True, states={'draft': [('readonly', False)]}, copy=False, help='The name that will be used on account move lines') diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index a6bd6908e2f..948cebf5c23 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -18,18 +18,29 @@ class ReportAgedPartnerBalance(models.AbstractModel): # Do an invoice and a payment and unreconcile. The amount will be nullified # By default, the partner wouldn't appear in this report. # The context key allow it to appear + # In case of a period_length of 30 days as of 2019-02-08, we want the following periods: + # Name Stop Start + # 1 - 30 : 2019-02-07 - 2019-01-09 + # 31 - 60 : 2019-01-08 - 2018-12-10 + # 61 - 90 : 2018-12-09 - 2018-11-10 + # 91 - 120 : 2018-11-09 - 2018-10-11 + # +120 : 2018-10-10 ctx = self._context periods = {} date_from = fields.Date.from_string(date_from) start = date_from - relativedelta(days=1) for i in range(5)[::-1]: stop = start - relativedelta(days=period_length) + period_name = str((5-(i+1)) * period_length + 1) + '-' + str((5-i) * period_length) + period_stop = (start - relativedelta(days=1)).strftime('%Y-%m-%d') + if i == 0: + period_name = '+' + str(4 * period_length) periods[str(i)] = { - 'name': (i!=0 and (str((5-(i+1)) * period_length) + '-' + str((5-i) * period_length)) or ('+'+str(4 * period_length))), - 'stop': start.strftime('%Y-%m-%d'), + 'name': period_name, + 'stop': period_stop, 'start': (i!=0 and stop.strftime('%Y-%m-%d') or False), } - start = stop - relativedelta(days=1) + start = stop res = [] total = [] diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index 0c605527b6e..7d572bc74b3 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -43,6 +43,7 @@ var StatementRenderer = Widget.extend(FieldManagerMixin, { var defs = [this._super.apply(this, arguments)]; this.time = Date.now(); this.$progress = this.$('.progress'); + this.clickStatementName = this._initialState.bank_statement_id ? true : false; if (this._initialState.bank_statement_id) { var def = this.model.makeRecord("account.bank.statement", [{ diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 1e23f2d887b..bbb083de9c5 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -782,7 +782,7 @@ class TestReconciliation(AccountingTestCase): def test_aged_report(self): AgedReport = self.env['report.account.report_agedpartnerbalance'].with_context(include_nullified_amount=True) account_type = ['receivable'] - report_date_to = time.strftime('%Y') + '-07-16' + report_date_to = time.strftime('%Y') + '-07-17' partner = self.env['res.partner'].create({'name': 'AgedPartner'}) currency = self.env.user.company_id.currency_id diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index c04674b20c5..1e09d4b6935 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -89,6 +89,7 @@ <field name="currency_id" invisible="1"/> </group> <group> + <field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags" options="{'color_field': 'color'}"/> <field domain="[('company_id', '=', parent.company_id)]" name="account_id" groups="account.group_account_user"/> <field name="invoice_line_tax_ids" context="{'type':parent.type}" domain="[('type_tax_use','!=','none'),('company_id', '=', parent.company_id)]" widget="many2many_tags" options="{'no_create': True}"/> <field domain="[('company_id', '=', parent.company_id)]" name="account_analytic_id" groups="analytic.group_analytic_accounting"/> diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 2a57d6be7cf..1268c9d1232 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -430,8 +430,10 @@ </div> <group> <group> - <field name="bank_acc_number"/> - <field name="bank_id"/> + <field name="company_partner_id" invisible="1"/> + <field name="bank_account_id"/> + <field name="bank_acc_number" attrs="{'readonly': [('bank_account_id', '!=', False)]}"/> + <field name="bank_id" attrs="{'readonly': [('bank_account_id', '!=', False)]}"/> </group> <group> <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/> diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py index b4c8ebd1667..d6161e17b91 100644 --- a/addons/auth_signup/models/res_partner.py +++ b/addons/auth_signup/models/res_partner.py @@ -27,7 +27,7 @@ class ResPartner(models.Model): signup_token = fields.Char(copy=False, groups="base.group_erp_manager") signup_type = fields.Char(string='Signup Token Type', copy=False, groups="base.group_erp_manager") signup_expiration = fields.Datetime(copy=False, groups="base.group_erp_manager") - signup_valid = fields.Boolean(compute='_compute_signup_valid', string='Signup Token is Valid', compute_sudo=True) + signup_valid = fields.Boolean(compute='_compute_signup_valid', compute_sudo=True, string='Signup Token is Valid') signup_url = fields.Char(compute='_compute_signup_url', string='Signup URL') @api.multi diff --git a/addons/board/static/src/js/board_view.js b/addons/board/static/src/js/board_view.js index 126214cedf1..1cf27c79fe8 100644 --- a/addons/board/static/src/js/board_view.js +++ b/addons/board/static/src/js/board_view.js @@ -10,6 +10,7 @@ var FormController = require('web.FormController'); var FormRenderer = require('web.FormRenderer'); var FormView = require('web.FormView'); var pyUtils = require('web.py_utils'); +var session = require('web.session'); var viewRegistry = require('web.view_registry'); var _t = core._t; @@ -246,7 +247,10 @@ var BoardRenderer = FormRenderer.extend({ // the action does not exist anymore return $.when(); } - var context = pyUtils.eval('context', new Context(params.context, action.context)); + // tz and lang are saved in the custom view + // override the language to take the current one + var rawContext = new Context(params.context, action.context, {lang: session.user_context.lang}); + var context = pyUtils.eval('context', rawContext); var domain = params.domain || pyUtils.eval('domain', action.domain || '[]', action.context); var viewType = params.viewType || action.views[0][1]; var view = _.find(action.views, function (descr) { diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 8b148c3ebb6..42fb870458f 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -6,6 +6,7 @@ var BoardView = require('board.BoardView'); var ListController = require('web.ListController'); var testUtils = require('web.test_utils'); var ListRenderer = require('web.ListRenderer'); +var pyUtils = require('web.py_utils'); var createActionManager = testUtils.createActionManager; var createView = testUtils.createView; @@ -746,4 +747,44 @@ QUnit.test('save to dashboard actions with flag keepSearchView', function (asser actionManager.destroy(); }); +QUnit.test("Views should be loaded in the user's language", function (assert) { + assert.expect(2); + var form = createView({ + View: BoardView, + model: 'board', + data: this.data, + session: {user_context: {lang: 'fr_FR'}}, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action context="{\'lang\': \'en_US\'}" view_mode="list" string="ABC" name="51" domain="[]"></action>' + + '</column>' + + '</board>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'load_views') { + assert.deepEqual(pyUtils.eval('context', args.kwargs.context), {lang: 'fr_FR'}, + 'The views should be loaded with the correct context'); + } + if (route === "/web/dataset/search_read") { + assert.deepEqual(args.context, {lang: 'fr_FR'}, + 'The data should be loaded with the correct context'); + } + if (route === '/web/action/load') { + return $.when({ + res_model: 'partner', + views: [[4, 'list']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '<list string="Partner"><field name="foo"/></list>', + }, + }); + + form.destroy(); +}); + }); diff --git a/addons/calendar/static/src/js/base_calendar.js b/addons/calendar/static/src/js/base_calendar.js index 897c8ef09e2..c2e26206e4e 100644 --- a/addons/calendar/static/src/js/base_calendar.js +++ b/addons/calendar/static/src/js/base_calendar.js @@ -57,10 +57,8 @@ WebClient.include({ // Clear previously set timeouts and destroy currently displayed calendar notifications clearTimeout(this.get_next_calendar_notif_timeout); _.each(this.calendar_notif_timeouts, clearTimeout); - _.each(this.calendar_notif, function(notif) { - if (!notif.isDestroyed()) { - notif.destroy(); - } + _.each(this.calendar_notif, function (notificationID) { + self.call('notification', 'close', notificationID, true); }); this.calendar_notif_timeouts = {}; this.calendar_notif = {}; @@ -68,13 +66,13 @@ WebClient.include({ // For each notification, set a timeout to display it _.each(notifications, function(notif) { self.calendar_notif_timeouts[notif.event_id] = setTimeout(function() { - var notification = new CalendarNotification(self.notification_manager, { + var notificationID = self.call('notification', 'notify', { + Notification: CalendarNotification, title: notif.title, text: notif.message, eventID: notif.event_id, }); - self.notification_manager.display(notification); - self.calendar_notif[notif.event_id] = notification; + self.calendar_notif[notif.event_id] = notificationID; }, notif.timer * 1000); last_notif_timer = Math.max(last_notif_timer, notif.timer); }); diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index 92734975333..48ca69b7a8e 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -67,7 +67,7 @@ </div> <group> <group> - <field name="product_id" context="{'default_can_be_expensed': 1}"/> + <field name="product_id" context="{'default_can_be_expensed': 1, 'tree_view_ref': 'hr_expense.product_product_expense_tree_view'}"/> <field name="unit_amount" required="1" widget="monetary" options="{'currency_field': 'currency_id'}"/> <label for="quantity"/> <div> @@ -315,6 +315,22 @@ </field> </record> + <record id="product_product_expense_tree_view" model="ir.ui.view"> + <field name="name">product.product.expense.tree</field> + <field name="model">product.product</field> + <field eval="50" name="priority"/> + <field name="arch" type="xml"> + <tree string="Product Variants"> + <field name="default_code"/> + <field name="name"/> + <field name="attribute_value_ids" widget="many2many_tags" groups="product.group_product_variant"/> + <field name="standard_price"/> + <field name="uom_id" options="{'no_open': True, 'no_create': True}" groups="uom.group_uom"/> + <field name="barcode"/> + </tree> + </field> + </record> + <record id="hr_expense_product" model="ir.actions.act_window"> <field name="name">Expense Products</field> <field name="res_model">product.product</field> diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index a664838b5a9..02a2a0faa5d 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -297,7 +297,7 @@ <!-- Discuss utility templates for notifications --> <template id="message_user_assigned"> <p style="margin: 0px;"> - <span>Dear <t t-esc="object.user_id.name"/>,</span><br /> + <span>Dear <t t-esc="object.user_id.sudo().name"/>,</span><br /> <span style="margin-top: 8px;">You have been assigned to the <t t-esc="object._description.lower()"/> <t t-esc="object.name_get()[0][1]"/>.</span> </p> <p style="margin-top: 24px; margin-bottom: 16px;"> diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 922d55b0f68..aea14012767 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -119,6 +119,8 @@ class MailMail(models.Model): ids = filtered_ids else: ids = list(set(filtered_ids) & set(ids)) + ids.sort() + res = None try: # auto-commit except in testing mode @@ -292,7 +294,6 @@ class MailMail(models.Model): values['partner_id'] = partner email_list.append(values) - # headers headers = {} ICP = self.env['ir.config_parameter'].sudo() @@ -316,6 +317,22 @@ class MailMail(models.Model): 'state': 'exception', 'failure_reason': _('Error without exception. Probably due do sending an email without computed recipients.'), }) + # Update notification in a transient exception state to avoid concurrent + # update in case an email bounces while sending all emails related to current + # mail record. + notifs = self.env['mail.notification'].search([ + ('is_email', '=', True), + ('mail_id', 'in', mail.ids), + ('email_status', 'not in', ('sent', 'canceled')) + ]) + if notifs: + notif_msg = _('Error without exception. Probably due do concurrent access update of notification records. Please see with an administrator.') + notifs.write({ + 'email_status': 'exception', + 'failure_type': 'UNKNOWN', + 'failure_reason': notif_msg, + }) + # build an RFC2822 email.message.Message object and send it without queuing res = None for email in email_list: diff --git a/addons/mail/models/mail_tracking_value.py b/addons/mail/models/mail_tracking_value.py index 874ce4fde9a..4edeb10fa65 100644 --- a/addons/mail/models/mail_tracking_value.py +++ b/addons/mail/models/mail_tracking_value.py @@ -63,8 +63,8 @@ class MailTracking(models.Model): values.update({ 'old_value_integer': initial_value and initial_value.id or 0, 'new_value_integer': new_value and new_value.id or 0, - 'old_value_char': initial_value and initial_value.name_get()[0][1] or '', - 'new_value_char': new_value and new_value.name_get()[0][1] or '' + 'old_value_char': initial_value and initial_value.sudo().name_get()[0][1] or '', + 'new_value_char': new_value and new_value.sudo().name_get()[0][1] or '' }) else: tracked = False diff --git a/addons/maintenance/models/maintenance.py b/addons/maintenance/models/maintenance.py index 719c912e038..fe1335c1d31 100644 --- a/addons/maintenance/models/maintenance.py +++ b/addons/maintenance/models/maintenance.py @@ -349,8 +349,8 @@ class MaintenanceRequest(models.Model): res = super(MaintenanceRequest, self).write(vals) if vals.get('owner_user_id') or vals.get('user_id'): self._add_followers() - if self.stage_id.done and 'stage_id' in vals: - self.write({'close_date': fields.Date.today()}) + if 'stage_id' in vals: + self.filtered(lambda m: m.stage_id.done).write({'close_date': fields.Date.today()}) self.activity_feedback(['maintenance.mail_act_maintenance_request']) if vals.get('user_id') or vals.get('schedule_date'): self.activity_update() diff --git a/addons/mass_mailing/static/src/scss/themes/theme_default.scss b/addons/mass_mailing/static/src/scss/themes/theme_default.scss index 96d6a61e805..2fcc5bf2915 100644 --- a/addons/mass_mailing/static/src/scss/themes/theme_default.scss +++ b/addons/mass_mailing/static/src/scss/themes/theme_default.scss @@ -87,7 +87,6 @@ td { } .btn.btn-primary { - -webkit-user-select: none; color: $o-mm-def-btn-text; border: none; text-decoration: none; diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index b12d0eb6e0a..d7658edb70e 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -310,8 +310,10 @@ class MrpWorkorder(models.Model): if self.product_id.tracking != 'none': qty_to_add = float_round(self.qty_producing * move.unit_factor, precision_rounding=rounding) move._generate_consumed_move_line(qty_to_add, self.final_lot_id) - else: + elif len(move._get_move_lines()) < 2: move.quantity_done += float_round(self.qty_producing * move.unit_factor, precision_rounding=rounding) + else: + move._set_quantity_done(move.quantity_done + float_round(self.qty_producing * move.unit_factor, precision_rounding=rounding)) # Transfer quantities from temporary to final move lots or make them final for move_line in self.active_move_line_ids: diff --git a/addons/mrp/report/mrp_report_bom_structure.py b/addons/mrp/report/mrp_report_bom_structure.py index 7a02ccbf205..0c4b5866e6a 100644 --- a/addons/mrp/report/mrp_report_bom_structure.py +++ b/addons/mrp/report/mrp_report_bom_structure.py @@ -55,7 +55,7 @@ class ReportBomStructure(models.AbstractModel): @api.model def get_operations(self, bom_id=False, qty=0, level=0): bom = self.env['mrp.bom'].browse(bom_id) - lines = self._get_operation_line(bom.routing_id, qty, level) + lines = self._get_operation_line(bom.routing_id, float_round(qty / bom.product_qty, precision_rounding=1, rounding_method='UP'), level) values = { 'bom_id': bom_id, 'currency': self.env.user.company_id.currency_id, @@ -109,7 +109,7 @@ class ReportBomStructure(models.AbstractModel): else: product = bom.product_tmpl_id attachments = self.env['mrp.document'].search([('res_model', '=', 'product.template'), ('res_id', '=', product.id)]) - operations = self._get_operation_line(bom.routing_id, (bom_quantity / bom.product_qty), 0) + operations = self._get_operation_line(bom.routing_id, float_round(bom_quantity / bom.product_qty, precision_rounding=1, rounding_method='UP'), 0) lines = { 'bom': bom, 'bom_qty': bom_quantity, @@ -139,7 +139,7 @@ class ReportBomStructure(models.AbstractModel): continue price = line.product_id.uom_id._compute_price(line.product_id.standard_price, line.product_uom_id) * line_quantity if line.child_bom_id: - factor = line.product_uom_id._compute_quantity(line_quantity, line.child_bom_id.product_uom_id) * line.child_bom_id.product_qty + factor = float_round(line.product_uom_id._compute_quantity(line_quantity, line.child_bom_id.product_uom_id) / line.child_bom_id.product_qty, precision_rounding=1, rounding_method='UP') sub_total = self._get_price(line.child_bom_id, factor) else: sub_total = price @@ -167,7 +167,8 @@ class ReportBomStructure(models.AbstractModel): operations = [] total = 0.0 for operation in routing.operation_ids: - duration_expected = qty * operation.time_cycle + operation.workcenter_id.time_stop + operation.workcenter_id.time_start + operation_cycle = float_round(qty / operation.workcenter_id.capacity, precision_rounding=1, rounding_method='UP') + duration_expected = operation_cycle * operation.time_cycle + operation.workcenter_id.time_stop + operation.workcenter_id.time_start total = ((duration_expected / 60.0) * operation.workcenter_id.costs_hour) operations.append({ 'level': level or 0, diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 1812d2f9cfa..914284459c5 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -1509,6 +1509,7 @@ exports.Orderline = Backbone.Model.extend({ // when we add an new orderline we want to merge it with the last line to see reduce the number of items // in the orderline. This returns true if it makes sense to merge the two can_be_merged_with: function(orderline){ + var price = parseFloat(round_di(this.price || 0, this.pos.dp['Product Price']).toFixed(this.pos.dp['Product Price'])); if( this.get_product().id !== orderline.get_product().id){ //only orderline of the same product can be merged return false; }else if(!this.get_unit() || !this.get_unit().is_pos_groupable){ @@ -1517,7 +1518,7 @@ exports.Orderline = Backbone.Model.extend({ return false; }else if(this.get_discount() > 0){ // we don't merge discounted orderlines return false; - }else if(this.price !== orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity())){ + }else if(price !== orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity())){ return false; }else if(this.product.tracking == 'lot') { return false; diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 4e61ec32ce8..435c0cbe62c 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -34,6 +34,7 @@ var core = require('web.core'); var rpc = require('web.rpc'); var utils = require('web.utils'); var field_utils = require('web.field_utils'); +var BarcodeEvents = require('barcodes.BarcodeEvents').BarcodeEvents; var QWeb = core.qweb; var _t = core._t; @@ -1689,6 +1690,13 @@ var PaymentScreenWidget = ScreenWidget.extend({ // also called explicitly to handle some keydown events that // do not generate keypress events. this.keyboard_handler = function(event){ + // On mobile Chrome BarcodeEvents relies on an invisible + // input being filled by a barcode device. Let events go + // through when this input is focused. + if (BarcodeEvents.$barcodeInput && BarcodeEvents.$barcodeInput.is(":focus")) { + return; + } + var key = ''; if (event.type === "keypress") { diff --git a/addons/product_margin/models/product_product.py b/addons/product_margin/models/product_product.py index 46adfc5118b..9fc94ea6310 100644 --- a/addons/product_margin/models/product_product.py +++ b/addons/product_margin/models/product_product.py @@ -107,7 +107,7 @@ class ProductProduct(models.Model): select sum(l.price_unit * l.quantity)/nullif(sum(l.quantity),0) as avg_unit_price, sum(l.quantity) as num_qty, - sum(l.quantity * (l.price_subtotal/(nullif(l.quantity,0)))) as total, + sum(l.quantity * (l.price_subtotal_signed/(nullif(l.quantity,0)))) as total, sum(l.quantity * pt.list_price) as sale_expected from account_invoice_line l left join account_invoice i on (l.invoice_id = i.id) diff --git a/addons/purchase_mrp/models/purchase_mrp.py b/addons/purchase_mrp/models/purchase_mrp.py index 086b9684e08..c554d64e152 100644 --- a/addons/purchase_mrp/models/purchase_mrp.py +++ b/addons/purchase_mrp/models/purchase_mrp.py @@ -27,7 +27,8 @@ class PurchaseOrderLine(models.Model): # In the case of a kit, we need to check if all components are shipped. Since the BOM might # have changed, we don't compute the quantities but verify the move state. if bom: - bom_delivered = all([move.state == 'done' for move in self.move_ids]) + moves = self.move_ids.filtered(lambda m: m.picking_id and m.picking_id.state != 'cancel') + bom_delivered = all([move.state == 'done' for move in moves]) if bom_delivered: return self.product_qty else: diff --git a/addons/sale/static/src/js/sale.js b/addons/sale/static/src/js/sale.js index 289d26ecae3..1121a38501a 100644 --- a/addons/sale/static/src/js/sale.js +++ b/addons/sale/static/src/js/sale.js @@ -21,8 +21,8 @@ KanbanRecord.include({ ev.preventDefault(); this.$target_input = $('<input>'); - this.$('.o_kanban_primary_bottom').html(this.$target_input); - this.$('.o_kanban_primary_bottom').prepend(_t("Set an invoicing target: ")); + this.$('.o_kanban_primary_bottom:last').html(this.$target_input); + this.$('.o_kanban_primary_bottom:last').prepend(_t("Set an invoicing target: ")); this.$target_input.focus(); var self = this; diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale/views/sale_product_configurator_templates.xml index 5dcb37fc8e1..5cd7317b09b 100644 --- a/addons/sale/views/sale_product_configurator_templates.xml +++ b/addons/sale/views/sale_product_configurator_templates.xml @@ -131,14 +131,12 @@ <input type="hidden" class="product_display_name" t-attf-name="optional-productname-#{product.id}" t-att-value="product.product_variant_id.display_name if len(product.product_variant_ids) == 1 else ''"/> <span t-field="product.image_small" t-options='{"widget": "image"}' class="variant_image"/> </td> - <td class='td-product_name'> + <td class='td-product_name' colspan="2"> <div class="float-left"> <strong class="product-name" t-field="product.name"/> <div class="text-muted small" t-field="product.description_sale"/> </div> - </td> - <td class="td-qty"> - <div class="float-left"> + <div class="float-right"> <t t-call="sale.variants"/> </div> </td> @@ -268,4 +266,4 @@ </t> </ul> </template> -</odoo>
\ No newline at end of file +</odoo> diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index 8db9adc9916..6ee721f6f45 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -187,7 +187,7 @@ class SaleOrderLine(models.Model): qty = 0.0 for move in line.move_ids.filtered(lambda r: r.state == 'done' and not r.scrapped): if move.location_dest_id.usage == "customer": - if not move.origin_returned_move_id: + if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund): qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) elif move.location_dest_id.usage != "customer" and move.to_refund: qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index ff7b2e71163..49a35ce4b7d 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -188,7 +188,7 @@ <td> <div t-if="repartition_employee_max" class="progress" t-att-style="'width: ' + str(repartition_employee[employee_id]['total'] / repartition_employee_max * 100) +'%'"> - <t t-set="total" t-value="repartition_employee[employee_id]['total']" /> + <t t-set="total" t-value="repartition_employee[employee_id]['total'] or 1.0" /> <t t-set="billable_fixed" t-value="repartition_employee[employee_id]['billable_fixed']" /> <t t-set="billable_time" t-value="repartition_employee[employee_id]['billable_time']" /> <t t-set="non_billable" t-value="repartition_employee[employee_id]['non_billable']" /> diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index 1e66ccffe20..c63288d8b60 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -233,7 +233,7 @@ class Inventory(models.Model): 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])]) - domain = ' location_id in %s AND quantity != 0' + domain = ' location_id in %s AND quantity != 0 AND active = TRUE' args = (tuple(locations.ids),) vals = [] @@ -274,6 +274,8 @@ class Inventory(models.Model): self.env.cr.execute("""SELECT product_id, sum(quantity) as product_qty, location_id, lot_id as prod_lot_id, package_id, owner_id as partner_id FROM stock_quant + LEFT JOIN product_product + ON product_product.id = stock_quant.product_id WHERE %s GROUP BY product_id, location_id, lot_id, package_id, partner_id """ % domain, args) diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index ccd2aea5a85..5f15e641ad7 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -182,6 +182,8 @@ class StockMoveLine(models.Model): for ml in mls: if ml.state == 'done': + if 'qty_done' in vals: + ml.move_id.product_uom_qty = ml.move_id.quantity_done if ml.product_id.type == 'product': Quant = self.env['stock.quant'] quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id,rounding_method='HALF-UP') @@ -507,6 +509,8 @@ class StockMoveLine(models.Model): candidate.product_uom_qty = 0.0 else: candidate.unlink() + if float_is_zero(quantity, precision_rounding=rounding): + break else: # split this move line and assign the new part to our extra move quantity_split = float_round( @@ -514,8 +518,6 @@ class StockMoveLine(models.Model): precision_rounding=self.product_uom_id.rounding, rounding_method='UP') candidate.product_uom_qty = self.product_id.uom_id._compute_quantity(quantity_split, candidate.product_uom_id, rounding_method='HALF-UP') - quantity -= quantity_split move_to_recompute_state |= candidate.move_id - if quantity == 0.0: break move_to_recompute_state._recompute_state() diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index dcc379d9725..2037ec4f34e 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -2008,6 +2008,48 @@ class StockMove(TransactionCase): self.assertAlmostEqual(move_pack_cust.reserved_availability, 1.0) self.assertEqual(move_pack_cust.state, 'partially_available') + def test_use_reserved_move_line_1(self): + """ Test that _free_reservation work when quantity is only available on + reserved move lines. + """ + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 10.0) + move1 = self.env['stock.move'].create({ + 'name': 'test_use_unreserved_move_line_1_1', + 'location_id': self.stock_location.id, + 'location_dest_id': self.customer_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 5.0, + }) + move2 = self.env['stock.move'].create({ + 'name': 'test_use_unreserved_move_line_1_1', + 'location_id': self.stock_location.id, + 'location_dest_id': self.customer_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 5.0, + }) + move1._action_confirm() + move1._action_assign() + move2._action_confirm() + move2._action_assign() + move3 = self.env['stock.move'].create({ + 'name': 'test_use_unreserved_move_line_1_1', + 'location_id': self.stock_location.id, + 'location_dest_id': self.customer_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 0.0, + 'quantity_done': 1.0, + }) + move3._action_confirm() + move3._action_assign() + move3._action_done() + self.assertEqual(move3.state, 'done') + quant = self.env['stock.quant']._gather(self.product1, self.stock_location) + self.assertEqual(quant.quantity, 9.0) + self.assertEqual(quant.reserved_quantity, 9.0) + def test_use_unreserved_move_line_1(self): """ Test that validating a stock move linked to an untracked product reserved by another one correctly unreserves the other one. diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index c506c5f9722..f98e9507650 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -332,6 +332,7 @@ <field name="group_id" groups="base.group_no_one"/> <field name="priority" attrs="{'invisible': [('picking_type_code', '=', 'incoming')]}"/> </group> + <field name="note" string="Note" placeholder="Add an internal note that will be printed on the Picking Operations sheet"/> </group> </page> </notebook> diff --git a/addons/stock/wizard/product_replenish.py b/addons/stock/wizard/product_replenish.py index 509375599d5..ced229de126 100644 --- a/addons/stock/wizard/product_replenish.py +++ b/addons/stock/wizard/product_replenish.py @@ -4,6 +4,7 @@ import datetime from odoo import api, fields, models from odoo.exceptions import UserError +from odoo.tools.misc import clean_context class ProductReplenish(models.TransientModel): @@ -51,7 +52,7 @@ class ProductReplenish(models.TransientModel): uom_reference = self.product_id.uom_id self.quantity = self.product_uom_id._compute_quantity(self.quantity, uom_reference) try: - self.env['procurement.group'].run( + self.env['procurement.group'].with_context(clean_context(self.env.context)).run( self.product_id, self.quantity, uom_reference, diff --git a/addons/stock_dropshipping/models/sale.py b/addons/stock_dropshipping/models/sale.py index 81f975cbce6..b527701a049 100644 --- a/addons/stock_dropshipping/models/sale.py +++ b/addons/stock_dropshipping/models/sale.py @@ -11,9 +11,11 @@ class SaleOrderLine(models.Model): @api.multi def _get_qty_procurement(self): - if not self.move_ids.filtered(lambda r: r.state != 'cancel') and self.purchase_line_ids.filtered(lambda r: r.state != 'cancel'): + # People without purchase rights should be able to do this operation + purchase_lines_sudo = self.sudo().purchase_line_ids + if not self.move_ids.filtered(lambda r: r.state != 'cancel') and purchase_lines_sudo.filtered(lambda r: r.state != 'cancel'): qty = 0.0 - for po_line in self.purchase_line_ids.filtered(lambda r: r.state != 'cancel'): + for po_line in purchase_lines_sudo.filtered(lambda r: r.state != 'cancel'): qty += po_line.product_uom._compute_quantity(po_line.product_qty, self.product_uom, rounding_method='HALF-UP') return qty else: diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py index 017a983b515..aeddfcd93dd 100644 --- a/addons/test_mail/tests/__init__.py +++ b/addons/test_mail/tests/__init__.py @@ -4,6 +4,7 @@ from . import test_mail_activity from . import test_mail_followers from . import test_mail_message from . import test_mail_mail +from . import test_mail_race from . import test_mail_resend from . import test_mail_channel from . import test_mail_gateway diff --git a/addons/test_mail/tests/test_mail_race.py b/addons/test_mail/tests/test_mail_race.py new file mode 100644 index 00000000000..943a1b30922 --- /dev/null +++ b/addons/test_mail/tests/test_mail_race.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from contextlib import closing +import psycopg2 +from odoo import api +from odoo.addons.test_mail.tests import common as mail_common +from odoo.tests import common +from odoo.tools import mute_logger + +class TestMailRace(common.TransactionCase, mail_common.MockEmails): + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_bounce_during_send(self): + self.partner = self.env['res.partner'].create({ + 'name': 'Ernest Partner', + }) + # we need to simulate a mail sent by the cron task, first create mail, message and notification by hand + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'notification': True, + 'state': 'outgoing', + 'recipient_ids': [(4, self.partner.id)] + }) + message = self.env['mail.message'].create({ + 'subject': 'S', + 'body': 'B', + 'subtype_id': self.ref('mail.mt_comment'), + 'needaction_partner_ids': [(6, 0, [self.partner.id])], + }) + notif = self.env['mail.notification'].search([('res_partner_id', '=', self.partner.id)]) + notif.write({ + 'mail_id': mail.id, + 'is_email': True, + 'is_read': True, + 'email_status': 'ready', + }) + # we need to commit transaction or cr will keep the lock on notif + self.cr.commit() + + # patch send_email in order to create a concurent update and check the notif is already locked by _send() + this = self # coding in javascript ruinned my life + bounce_deferred = [] + @api.model + def send_email(self, message, *args, **kwargs): + with this.registry.cursor() as cr, mute_logger('odoo.sql_db'): + try: + # try ro aquire lock (no wait) on notification (should fail) + cr.execute("SELECT email_status FROM mail_message_res_partner_needaction_rel WHERE id = %s FOR UPDATE NOWAIT", [notif.id]) + except psycopg2.OperationalError: + # record already locked by send, all good + bounce_deferred.append(True) + else: + # this should trigger psycopg2.extensions.TransactionRollbackError in send(). + # Only here to simulate the initial use case + # If the record is lock, this line would create a deadlock since we are in the same thread + # In practice, the update will wait the end of the send() transaction and set the notif as bounce, as expeced + cr.execute("UPDATE mail_message_res_partner_needaction_rel SET email_status='bounce' WHERE id = %s", [notif.id]) + return message['Message-Id'] + self.env['ir.mail_server']._patch_method('send_email', send_email) + + mail.send() + + self.assertTrue(bounce_deferred, "The bounce should have been deferred") + self.assertEqual(notif.email_status, 'sent') + + # some cleaning since we commited the cr + notif.unlink() + message.unlink() + mail.unlink() + self.partner.unlink() + self.env.cr.commit() diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py index ef99b2cb3b3..9a2d82ac053 100644 --- a/addons/test_mail/tests/test_performance.py +++ b/addons/test_mail/tests/test_performance.py @@ -205,7 +205,7 @@ class TestAdvMailPerformance(TransactionCase): self.user_test.write({'notification_type': 'email'}) record = self.env['mail.test.track'].create({'name': 'Test'}) - with self.assertQueryCount(__system__=56, emp=75): # com runbot: 56 - 75 // test_mail only: 56 - 75 + with self.assertQueryCount(__system__=58, emp=77): # com runbot: 56 - 75 // test_mail only: 56 - 75 record.write({ 'user_id': self.user_test.id, }) @@ -259,7 +259,7 @@ class TestAdvMailPerformance(TransactionCase): def test_message_post_one_email_notification(self): record = self.env['mail.test.simple'].create({'name': 'Test'}) - with self.assertQueryCount(__system__=48, emp=68): # com runbot: 45 - 65 // test_mail only: 48 - 68 + with self.assertQueryCount(__system__=50, emp=70): # com runbot: 45 - 65 // test_mail only: 48 - 68 record.message_post( body='<p>Test Post Performances with an email ping</p>', partner_ids=self.customer.ids, @@ -382,7 +382,7 @@ class TestHeavyMailPerformance(TransactionCase): }) mail_ids = mail.ids - with self.assertQueryCount(__system__=13, emp=20): # test_mail only: 13 - 20 + with self.assertQueryCount(__system__=14, emp=21): # test_mail only: 13 - 20 self.env['mail.mail'].browse(mail_ids).send() self.assertEqual(mail.body_html, '<p>Test</p>') @@ -395,7 +395,7 @@ class TestHeavyMailPerformance(TransactionCase): self.umbrella.message_subscribe(self.user_portal.partner_id.ids) record = self.umbrella.sudo(self.env.user) - with self.assertQueryCount(__system__=80, emp=103): # com runbot 90 - 113 // test_mail only: 97 - 120 + with self.assertQueryCount(__system__=84, emp=107): # com runbot 90 - 113 // test_mail only: 97 - 120 record.message_post( body='<p>Test Post Performances</p>', message_type='comment', @@ -412,7 +412,7 @@ class TestHeavyMailPerformance(TransactionCase): record = self.umbrella.sudo(self.env.user) template_id = self.env.ref('test_mail.mail_test_tpl').id - with self.assertQueryCount(__system__=99, emp=134): # com runbot 109 - 144 // test_mail only: 116 - 151 + with self.assertQueryCount(__system__=103, emp=138): # com runbot 109 - 144 // test_mail only: 116 - 151 record.message_post_with_template(template_id, message_type='comment', composition_mode='comment') self.assertEqual(record.message_ids[0].body, '<p>Adding stuff on %s</p>' % record.name) @@ -482,7 +482,7 @@ class TestHeavyMailPerformance(TransactionCase): }) self.assertEqual(rec.message_partner_ids, self.partners | self.env.user.partner_id) - with self.assertQueryCount(__system__=58, emp=77): # com runbot: 65 - 83 // test_mail only: 66 - 84 + with self.assertQueryCount(__system__=60, emp=79): # com runbot: 65 - 83 // test_mail only: 66 - 84 rec.write({'user_id': self.user_portal.id}) self.assertEqual(rec.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id) @@ -505,7 +505,7 @@ class TestHeavyMailPerformance(TransactionCase): customer_id = self.customer.id user_id = self.user_portal.id - with self.assertQueryCount(__system__=156, emp=190): # com runbot: 156 - 190 // test_mail only: 156 - 190 + with self.assertQueryCount(__system__=162, emp=198): # com runbot: 156 - 190 // test_mail only: 156 - 190 rec = self.env['mail.test.full'].create({ 'name': 'Test', 'umbrella_id': umbrella_id, @@ -534,7 +534,7 @@ class TestHeavyMailPerformance(TransactionCase): }) self.assertEqual(rec.message_partner_ids, self.user_portal.partner_id | self.env.user.partner_id) - with self.assertQueryCount(__system__=94, emp=111): # com runbot: 104 - 123 // test_mail only: 105 - 124 + with self.assertQueryCount(__system__=99, emp=119): # com runbot: 104 - 123 // test_mail only: 105 - 124 rec.write({ 'name': 'Test2', 'umbrella_id': self.umbrella.id, @@ -572,7 +572,7 @@ class TestHeavyMailPerformance(TransactionCase): }) self.assertEqual(rec.message_partner_ids, self.user_portal.partner_id | self.env.user.partner_id) - with self.assertQueryCount(__system__=100, emp=122): # test_mail only: 111 - 134 + with self.assertQueryCount(__system__=104, emp=126): # test_mail only: 111 - 134 rec.write({ 'name': 'Test2', 'umbrella_id': umbrella_id, @@ -606,7 +606,7 @@ class TestHeavyMailPerformance(TransactionCase): }) self.assertEqual(rec.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id) - with self.assertQueryCount(__system__=54, emp=76): # test_mail only: 54 - 75 + with self.assertQueryCount(__system__=55, emp=77): # test_mail only: 54 - 75 rec.write({ 'name': 'Test2', 'customer_id': customer_id, diff --git a/addons/test_mass_mailing/tests/test_performance.py b/addons/test_mass_mailing/tests/test_performance.py index b406474e224..9f47f3aabba 100644 --- a/addons/test_mass_mailing/tests/test_performance.py +++ b/addons/test_mass_mailing/tests/test_performance.py @@ -30,7 +30,7 @@ class TestMassMailPerformance(common.MassMailingCase): 'mailing_domain': [('id', 'in', self.mm_recs.ids)], }) - with self.assertQueryCount(__system__=2382, marketing=3038): + with self.assertQueryCount(__system__=2432, marketing=3088): mailing.send_mail() self.assertEqual(mailing.sent, 50) @@ -67,7 +67,7 @@ class TestMassMailBlPerformance(common.MassMailingCase): 'mailing_domain': [('id', 'in', self.mm_recs.ids)], }) - with self.assertQueryCount(__system__=2757, marketing=3509): + with self.assertQueryCount(__system__=2807, marketing=3559): mailing.send_mail() self.assertEqual(mailing.sent, 50) diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 00b8874a6b1..b324d6f2efa 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -500,6 +500,10 @@ var FieldDate = InputField.extend({ this._super.apply(this, arguments); // use the session timezone when formatting dates this.formatOptions.timezone = true; + this.datepickerOptions = _.defaults( + this.nodeOptions.datepicker || {}, + {defaultDate: this.value} + ); }, /** * In edit mode, instantiates a DateWidget datepicker and listen to changes. @@ -583,13 +587,7 @@ var FieldDate = InputField.extend({ * @private */ _makeDatePicker: function () { - return new datepicker.DateWidget( - this, - _.defaults( - this.nodeOptions.datepicker || {}, - {defaultDate: this.value} - ) - ); + return new datepicker.DateWidget(this, this.datepickerOptions); }, /** @@ -607,6 +605,17 @@ var FieldDate = InputField.extend({ var FieldDateTime = FieldDate.extend({ supportedFieldTypes: ['datetime'], + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (this.value) { + var offset = this.getSession().getTZOffset(this.value); + var displayedValue = this.value.clone().add(offset, 'minutes'); + this.datepickerOptions.defaultDate = displayedValue; + } + }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -637,10 +646,8 @@ var FieldDateTime = FieldDate.extend({ * @private */ _makeDatePicker: function () { - var value = this.value && this.value.clone().add(this.getSession().getTZOffset(this.value), 'minutes'); - return new datepicker.DateTimeWidget(this, {defaultDate: value}); + return new datepicker.DateTimeWidget(this, this.datepickerOptions); }, - /** * Set the datepicker to the right value rather than the default one. * @@ -1171,6 +1178,9 @@ var HandleWidget = AbstractField.extend({ var FieldEmail = InputField.extend({ className: 'o_field_email', + events: _.extend({}, InputField.prototype.events, { + 'click': '_onClick', + }), prefix: 'mailto', supportedFieldTypes: ['char'], @@ -1211,7 +1221,21 @@ var FieldEmail = InputField.extend({ this.$el.text(this.value) .addClass('o_form_uri o_text_overflow') .attr('href', this.prefix + ':' + this.value); - } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Prevent the URL click from opening the record (when used on a list). + * + * @private + * @param {MouseEvent} ev + */ + _onClick: function (ev) { + ev.stopPropagation(); + }, }); var FieldPhone = FieldEmail.extend({ @@ -1238,6 +1262,9 @@ var FieldPhone = FieldEmail.extend({ var UrlWidget = InputField.extend({ className: 'o_field_url', + events: _.extend({}, InputField.prototype.events, { + 'click': '_onClick', + }), supportedFieldTypes: ['char'], /** @@ -1279,7 +1306,21 @@ var UrlWidget = InputField.extend({ .addClass('o_form_uri o_text_overflow') .attr('target', '_blank') .attr('href', this.value); - } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Prevent the URL click from opening the record (when used on a list). + * + * @private + * @param {MouseEvent} ev + */ + _onClick: function (ev) { + ev.stopPropagation(); + }, }); var CopyClipboard = { @@ -1645,19 +1686,23 @@ var FieldPdfViewer = FieldBinaryFile.extend({ }, /** * @private + * @param {string} [fileURI] file URI if specified * @returns {string} the pdf viewer URI */ - _getURI: function () { - var queryObj = { - model: this.model, - field: this.name, - id: this.res_id, - }; + _getURI: function (fileURI) { var page = this.recordData[this.name + '_page'] || 1; - var queryString = $.param(queryObj); - var url = encodeURIComponent('/web/image?' + queryString); + if (!fileURI) { + var queryObj = { + model: this.model, + field: this.name, + id: this.res_id, + }; + var queryString = $.param(queryObj); + fileURI = '/web/image?' + queryString + } + fileURI = encodeURIComponent(fileURI); var viewerURL = '/web/static/lib/pdfjs/web/viewer.html?file='; - return viewerURL + url + '#page=' + page; + return viewerURL + fileURI + '#page=' + page; }, /** * @private @@ -1701,14 +1746,16 @@ var FieldPdfViewer = FieldBinaryFile.extend({ */ on_file_change: function (ev) { this._super.apply(this, arguments); + var files = ev.target.files; + if (!files || files.length === 0) { + return; + } + // TOCheck: is there requirement to fallback on FileReader if browser don't support URL + var fileURI = URL.createObjectURL(files[0]); if (this.PDFViewerApplication) { - var files = ev.target.files; - if (!files || files.length === 0) { - return; - } - var file = files[0]; - // TOCheck: is there requirement to fallback on FileReader if browser don't support URL - this.PDFViewerApplication.open(URL.createObjectURL(file), 0); + this.PDFViewerApplication.open(fileURI, 0); + } else { + this.$('.o_pdfview_iframe').attr('src', this._getURI(fileURI)); } }, /** diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index e27128a1e3b..bf46c538c23 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -2584,10 +2584,12 @@ var FieldSelectionBadge = FieldSelection.extend({ * a FieldMany2one for its value. * Its intern representation is similar to the many2one (a datapoint with a * `name_get` as data). + * Note that there is some logic to support char field because of one use in our + * codebase, but this use should be removed along with this note. */ var FieldReference = FieldMany2One.extend({ specialData: "_fetchSpecialReference", - supportedFieldTypes: ['char', 'reference'], + supportedFieldTypes: ['reference'], template: 'FieldReference', events: _.extend({}, FieldMany2One.prototype.events, { 'change select': '_onSelectionChange', diff --git a/addons/web/static/src/js/services/notification_service.js b/addons/web/static/src/js/services/notification_service.js index cc87305a6a2..a0d2720604b 100644 --- a/addons/web/static/src/js/services/notification_service.js +++ b/addons/web/static/src/js/services/notification_service.js @@ -58,6 +58,8 @@ var NotificationService = AbstractService.extend({ * Note that this method does not wait for the appendTo method to complete. * * @param {Object} params + * @param {function} [params.Notification] javascript class of a notification + * to instantiate by default use 'web.Notification' * @param {string} params.title notification title * @param {string} params.message notification main message * @param {string} params.type 'notification' or 'warning' @@ -78,7 +80,8 @@ var NotificationService = AbstractService.extend({ this.$el = $('<div class="o_notification_manager"/>'); this.$el.prependTo('body'); } - var notification = this.notifications[++id] = new Notification(this, params); + var NotificationWidget = params.Notification || Notification; + var notification = this.notifications[++id] = new NotificationWidget(this, params); notification.appendTo(this.$el); return id; }, diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 7705ee5972e..f3277d394a3 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -308,3 +308,55 @@ var AbstractView = Class.extend({ return AbstractView; }); + +odoo.define('web.viewUtils', function () { +"use strict"; + +/** + * FIXME: move this module to its own file in master + */ + +var utils = { + /** + * Returns the value of a group dataPoint, i.e. the value of the groupBy + * field for the records in that group. + * + * @param {Object} group dataPoint of type list, corresponding to a group + * @param {string} groupByField the name of the groupBy field + * @returns {string | integer | false} + */ + getGroupValue: function (group, groupByField) { + var groupedByField = group.fields[groupByField]; + switch (groupedByField.type) { + case 'many2one': + return group.res_id || false; + case 'selection': + var descriptor = _.findWhere(groupedByField.selection, group.value); + return descriptor && descriptor[0]; + default: + return group.value; + } + }, + /** + * States whether or not the quick create feature is available for the given + * datapoint, depending on its groupBy field. + * + * @param {Object} list dataPoint of type list + * @returns {Boolean} true iff the kanban quick create feature is available + */ + isQuickCreateEnabled: function (list) { + var groupByField = list.groupedBy[0] && list.groupedBy[0].split(':')[0]; + if (!groupByField) { + return false; + } + var availableTypes = ['char', 'boolean', 'many2one', 'selection']; + if (!_.contains(availableTypes, list.fields[groupByField].type)) { + return false; + } + return true; + }, +}; + +return utils; + +}); 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 0afb65a9a88..de879018ad5 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1016,27 +1016,51 @@ var BasicModel = AbstractModel.extend({ route: '/web/dataset/resequence', params: params, }) - .then(function () { - var offset = options.offset ? options.offset : 0; - var old_data = data.data.slice(); - data.data = _.sortBy(data.data, function (d) { - if (_.contains(resIDs, self.localData[d].res_id)) { - return _.indexOf(resIDs, self.localData[d].res_id) + offset; - } else { - return _.indexOf(old_data, d); - } - }); - 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); + .then(function (wasResequenced) { + if (!wasResequenced) { + // the field on which the resequence was triggered does not + // exist, so no resequence happened server-side + return $.when(); + } + var field = params.field ? params.field : 'sequence'; + + return self._rpc({ + model: modelName, + method: 'read', + args: [resIDs, [field]], + }).then(function (records) { + if (data.data.length) { + var dataType = self.localData[data.data[0]].type; + if (dataType === 'record') { + _.each(data.data, function (dataPoint) { + var recordData = self.localData[dataPoint].data; + var inRecords = _.findWhere(records, {id: recordData.id}); + if (inRecords) { + recordData[field] = inRecords[field]; + } + }); + data.data = _.sortBy(data.data, function (d) { + return self.localData[d].data[field]; + }); + } + if (dataType === 'list') { + data.data = _.sortBy(data.data, function (d) { + return _.indexOf(resIDs, self.localData[d].res_id) + }); + } } - }); - self._updateParentResIDs(data); - return parentID; + 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/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index 7bc5e95bfc8..9e1cde2aada 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -225,7 +225,7 @@ var BasicView = AbstractView.extend({ attrs.options = attrs.options ? pyUtils.py_eval(attrs.options) : {}; } - if (attrs.on_change && !field.onChange) { + if (attrs.on_change && attrs.on_change !== "0" && !field.onChange) { field.onChange = "1"; } @@ -378,6 +378,10 @@ var BasicView = AbstractView.extend({ if (!(dependency_name in fields)) { fields[dependency_name] = dependency_dict; } + + if (fv.fields && !(dependency_name in fv.fields)) { + fv.fields[dependency_name] = dependency_dict; + } } } return false; 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 e76a487b73b..c2c90ea7e62 100644 --- a/addons/web/static/src/js/views/calendar/calendar_renderer.js +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -77,9 +77,9 @@ var SidebarFilter = Widget.extend(FieldManagerMixin, { self.model.get(recordID), { mode: 'edit', - can_create: false, attrs: { - 'placeholder': _.str.sprintf(_t("Add %s"), self.title), + placeholder: _.str.sprintf(_t("Add %s"), self.title), + can_create: false }, }); }); diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js index 190fbe88ad2..c7a32b72ba9 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -995,10 +995,7 @@ var FormRenderer = BasicRenderer.extend({ * @param {OdooEvent} ev */ _onNavigationMove: function (ev) { - if (ev.data.direction !== "cancel") { - ev.stopPropagation(); - } - + ev.stopPropagation(); var index; if (ev.data.direction === "next") { index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target || ev.target); diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js index ef75caca8c0..ffb63ad9083 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column.js +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -7,6 +7,7 @@ var Dialog = require('web.Dialog'); var KanbanRecord = require('web.KanbanRecord'); var RecordQuickCreate = require('web.kanban_record_quick_create'); var view_dialogs = require('web.view_dialogs'); +var viewUtils = require('web.viewUtils'); var Widget = require('web.Widget'); var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); @@ -201,7 +202,7 @@ var KanbanColumn = Widget.extend({ this.trigger_up('close_quick_create'); // close other quick create widgets this.trigger_up('start_quick_create'); var context = this.data.getContext(); - context['default_' + this.groupedBy] = this.id; + context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy); this.quickCreateWidget = new RecordQuickCreate(this, { context: context, formViewRef: this.quickCreateView, 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 9599d29db26..e195b33068c 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -12,6 +12,7 @@ var Context = require('web.Context'); var core = require('web.core'); var Domain = require('web.Domain'); var view_dialogs = require('web.view_dialogs'); +var viewUtils = require('web.viewUtils'); var _t = core._t; var qweb = core.qweb; @@ -35,12 +36,15 @@ var KanbanController = BasicController.extend({ /** * @override * @param {Object} params + * @param {boolean} params.quickCreateEnabled set to false to disable the + * quick create feature */ init: function (parent, model, renderer, params) { this._super.apply(this, arguments); this.on_create = params.on_create; this.hasButtons = params.hasButtons; + this.quickCreateEnabled = params.quickCreateEnabled; }, //-------------------------------------------------------------------------- @@ -268,8 +272,8 @@ var KanbanController = BasicController.extend({ */ _onButtonNew: function () { var state = this.model.get(this.handle, {raw: true}); - var hasColumns = state.groupedBy.length > 0 && state.data.length > 0; - if (hasColumns && this.on_create === 'quick_create') { + var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state); + if (this.on_create === 'quick_create' && quickCreateEnabled) { // Activate the quick create in the first column this.renderer.addQuickCreate(); } else if (this.on_create && this.on_create !== 'quick_create') { @@ -396,10 +400,11 @@ var KanbanController = BasicController.extend({ var columnState = self.model.get(column.db_id, {raw: true}); var context = columnState.getContext(); var state = self.model.get(self.handle, {raw: true}); - context['default_' + state.groupedBy[0]] = columnState.res_id; + var groupedBy = state.groupedBy[0]; + context['default_' + groupedBy] = viewUtils.getGroupValue(columnState, groupedBy); new view_dialogs.FormViewDialog(self, { res_model: state.model, - context: _.extend({default_name: name}, context), + context: _.extend({default_name: values.name || values.display_name}, context), title: _t("Create"), disable_multiple_selection: true, on_saved: function (record) { 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 905ea05ac1e..fd21ed4df86 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -7,6 +7,7 @@ odoo.define('web.KanbanModel', function (require) { */ var BasicModel = require('web.BasicModel'); +var viewUtils = require('web.viewUtils'); var KanbanModel = BasicModel.extend({ @@ -105,7 +106,8 @@ var KanbanModel = BasicModel.extend({ var group = this.localData[groupID]; var context = this._getContext(group); var parent = this.localData[group.parentID]; - context['default_' + parent.groupedBy[0]] = group.res_id; + var groupedBy = parent.groupedBy; + context['default_' + groupedBy] = viewUtils.getGroupValue(group, groupedBy); var def; if (Object.keys(values).length === 1 && 'display_name' in values) { // only 'display_name is given, perform a 'name_create' @@ -132,7 +134,7 @@ var KanbanModel = BasicModel.extend({ }, /** * Add the following (kanban specific) keys when performing a `get`: - * + * * - tooltipData * - progressBarValues * - isGroupedByM2ONoColumn diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js index 7013464ea87..d9d7ac77fe2 100644 --- a/addons/web/static/src/js/views/kanban/kanban_record.js +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -509,10 +509,12 @@ var KanbanRecord = Widget.extend({ ischild = false; } var test_event = events && events.click && (events.click.length > 1 || events.click[0].namespace !== 'bs.tooltip'); + var testLinkWithHref = elem.nodeName.toLowerCase() === 'a' && elem.href; if (ischild) { children.push(elem); - if (test_event) { - // do not trigger global click if one child has a click event registered + if (test_event || testLinkWithHref) { + // Do not trigger global click if one child has a click + // event registered (or it is a link with href) trigger = false; } } diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js index c51ac945de1..9356311d51c 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -9,6 +9,7 @@ var ColumnQuickCreate = require('web.kanban_column_quick_create'); var QWeb = require('web.QWeb'); var session = require('web.session'); var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); var qweb = core.qweb; @@ -98,6 +99,9 @@ var KanbanRenderer = BasicRenderer.extend({ /** * @override + * @param {Object} params + * @param {boolean} params.quickCreateEnabled set to false to disable the + * quick create feature */ init: function (parent, state, params) { this._super.apply(this, arguments); @@ -116,6 +120,7 @@ var KanbanRenderer = BasicRenderer.extend({ if (this.columnOptions.hasProgressBar) { this.columnOptions.progressBarStates = {}; } + this.quickCreateEnabled = params.quickCreateEnabled; this._setState(state); }, /** @@ -453,6 +458,7 @@ var KanbanRenderer = BasicRenderer.extend({ groupedBy: groupByField, grouped_by_m2o: this.groupedByM2O, relation: relation, + quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state), }); this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable; }, diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js index 791e3664668..523f0ef1c57 100644 --- a/addons/web/static/src/js/views/kanban/kanban_view.js +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -63,7 +63,6 @@ var KanbanView = BasicView.extend({ deletable: activeActions.group_delete, archivable: this.arch.attrs.archivable ? JSON.parse(this.arch.attrs.archivable) : true, group_creatable: activeActions.group_create && !config.device.isMobile, - quick_create: params.isQuickCreateEnabled || this._isQuickCreateEnabled(), quickCreateView: this.arch.attrs.quick_create_view || null, hasProgressBar: !!progressBar, }; @@ -72,6 +71,7 @@ var KanbanView = BasicView.extend({ deletable: activeActions.delete, read_only_mode: params.readOnlyMode, }; + this.rendererParams.quickCreateEnabled = this._isQuickCreateEnabled(); var examples = this.arch.attrs.examples; if (examples) { this.rendererParams.examples = kanbanExamplesRegistry.get(examples); @@ -80,6 +80,7 @@ var KanbanView = BasicView.extend({ this.controllerParams.on_create = this.arch.attrs.on_create; this.controllerParams.readOnlyMode = false; this.controllerParams.hasButtons = true; + this.controllerParams.quickCreateEnabled = this.rendererParams.quickCreateEnabled; if (config.device.isMobile) { this.jsLibs.push('/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js'); @@ -93,13 +94,10 @@ var KanbanView = BasicView.extend({ /** * @private * @param {Object} viewInfo + * @returns {boolean} true iff the quick create feature is not explicitely + * disabled (with create="False" or quick_create="False" in the arch) */ _isQuickCreateEnabled: function () { - var groupBy = this.loadParams.groupBy[0]; - groupBy = groupBy !== undefined ? groupBy.split(':')[0] : undefined; - if (groupBy !== undefined && !_.contains(['char', 'boolean', 'many2one'], this.fields[groupBy].type)) { - return false; - } if (!this.controllerParams.activeActions.create) { return false; } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 907a02c0237..1eef5047f46 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -810,7 +810,7 @@ <t t-name="FieldMany2One"> <t t-if="widget.mode === 'readonly'"> - <a t-if="!widget.nodeOptions.no_open" class="o_form_uri" href="#"/> + <a t-if="!widget.nodeOptions.no_open" t-att-tabindex="widget.attrs.tabindex" class="o_form_uri" href="#"/> <span t-if="widget.nodeOptions.no_open"/> </t> <div t-if="widget.mode === 'edit'" class="o_field_widget o_field_many2one" aria-atomic="true"> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 7db578b5d4e..81c21cf6052 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -10,6 +10,7 @@ var KanbanView = require('web.KanbanView'); var ListView = require('web.ListView'); var session = require('web.session'); var testUtils = require('web.test_utils'); +var field_registry = require('web.field_registry'); var createView = testUtils.createView; var createAsyncView = testUtils.createAsyncView; @@ -1907,6 +1908,49 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test("pdf_viewer: upload rendering", function (assert) { + assert.expect(6); + + testUtils.patch(field_registry.map.pdf_viewer, { + on_file_change: function (ev) { + ev.target = {files: [new Blob()]}; + this._super.apply(this, arguments); + }, + _getURI: function (fileURI) { + var res = this._super.apply(this, arguments); + assert.step('_getURI'); + assert.ok(_.str.startsWith(fileURI, 'blob:')); + this.PDFViewerApplication = { + open: function (URI) { + assert.step('open'); + assert.ok(_.str.startsWith(URI, 'blob:')); + }, + }; + return 'about:blank'; + }, + }); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<field name="document" widget="pdf_viewer"/>' + + '</form>', + }); + + // first upload initialize iframe + form.$('input[type="file"]').trigger('change'); + assert.verifySteps(['_getURI']); + // second upload call pdfjs method inside iframe + form.$('input[type="file"]').trigger('change'); + assert.verifySteps(['_getURI', 'open']); + + testUtils.unpatch(field_registry.map.pdf_viewer); + form.destroy(); + }); + QUnit.test('text field rendering in list view', function (assert) { assert.expect(1); @@ -2106,6 +2150,40 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('image fields in x2many list are loaded correctly', function (assert) { + assert.expect(2); + + this.data.partner_type.fields.image = {name: 'image', type: 'binary'}; + this.data.partner_type.records[0].image = 'product_image'; + this.data.partner.records[0].timmy = [12]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="timmy" widget="many2many">' + + '<tree>' + + '<field name="image" widget="image"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (route === 'data:image/png;base64,product_image') { + assert.ok(true, "The list's image should have been fetched"); + return $.when(); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('tr.o_data_row').length, 1, + 'There should be one record in the many2many'); + + form.destroy(); + }); + QUnit.test('image fields with required attribute', function (assert) { assert.expect(2); @@ -2779,8 +2857,7 @@ QUnit.module('basic_fields', { form.$buttons.find('.o_form_button_edit').click(); assert.strictEqual(form.$('.o_datepicker_input').val(), expectedDateString, 'the datetime should be correct in edit mode'); - // click on the input and select 22 February at 8:23:33 - form.$('.o_datepicker_input').click(); + // select 22 February at 8:23:33 assert.ok($('.bootstrap-datetimepicker-widget').length, 'datepicker should be open'); $('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Month selection $('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Year selection @@ -2900,8 +2977,7 @@ QUnit.module('basic_fields', { assert.strictEqual(list.$('input.o_datepicker_input').val(), expectedDateString, 'the date should be correct in edit mode'); - // click on the input and select 22 February at 8:23:33 - list.$('input.o_datepicker_input').click(); + // select 22 February at 8:23:33 assert.ok($('.bootstrap-datetimepicker-widget').length, 'datepicker should be open'); $('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Month selection $('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Year selection @@ -3061,6 +3137,32 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('datepicker option: daysOfWeekDisabled', function (assert) { + assert.expect(2); + + this.data.partner.fields.datetime.default = "2017-08-02 12:00:05"; + this.data.partner.fields.datetime.required = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="datetime" ' + + 'options=\'{"datepicker": {"daysOfWeekDisabled": [0, 6]}}\'/>' + + '</form>', + res_id: 1, + }); + + form.$buttons.find('.o_form_button_create').click(); + assert.ok($('.day:last-child(),.day:nth-child(2)').hasClass('disabled'), + 'first and last days must be disabled'); + assert.notOk($('.day:not(:last-child()):not(:nth-child(2))').hasClass('disabled'), + 'other days must stay clickable'); + + form.destroy(); + }); + QUnit.module('FieldMonetary'); QUnit.test('monetary field in form view', function (assert) { diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 99f97fbda54..e1e91863448 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -5580,7 +5580,7 @@ QUnit.module('relational_fields', { form.destroy(); }); - QUnit.test('pressing enter in a o2m with a required empty m2o', function (assert) { + QUnit.test('pressing enter in a o2m with a required empty field', function (assert) { assert.expect(4); this.data.turtle.fields.turtle_foo.required = true; @@ -10259,6 +10259,81 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('click on URL should not open the record', function (assert) { + assert.expect(2); + + this.data.partner.records[0].turtles = [1]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="turtles">' + + '<tree>' + + '<field name="display_name" widget="email"/>' + + '<field name="turtle_foo" widget="url"/>' + + '</tree>' + + '<form></form>' + + '</field>' + + '</form>', + res_id: 1, + }); + + form.$('.o_email_cell a').click(); + assert.strictEqual($('.modal .o_form_view').length, 0, + 'click should not open the modal'); + + form.$('.o_url_cell a').click(); + assert.strictEqual($('.modal .o_form_view').length, 0, + 'click should not open the modal'); + form.destroy(); + }); + + QUnit.test('create and edit on m2o in o2m, and press ESCAPE', function (assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_trululu"/>' + + '</tree>' + + '</field>' + + '</form>', + archs: { + 'partner,false,form': '<form><field name="display_name"/></form>', + }, + }); + + form.$('.o_field_x2many_list_row_add a').click(); + + assert.strictEqual(form.$('.o_selected_row').length, 1, + "should have create a new row in edition"); + + var $input = form.$('.o_field_widget[name="turtle_trululu"] input'); + $input.click(); + $input.autocomplete('widget').find('.o_m2o_dropdown_option').focus().click(); + + assert.strictEqual($('.modal .o_form_view').length, 1, + "should have opened a form view in a dialog"); + + $('.modal .o_form_view .o_field_widget[name=display_name]').trigger($.Event('keydown', { + which: $.ui.keyCode.ESCAPE, + keyCode: $.ui.keyCode.ESCAPE, + })); + + assert.strictEqual($('.modal .o_form_view').length, 0, + "should have closed the dialog"); + assert.strictEqual(form.$('.o_selected_row').length, 1, + "new row should still be present"); + + form.destroy(); + }); + QUnit.test('one2many add a line should not crash if orderedResIDs is not set', function (assert) { // There is no assertion, the code will just crash before the bugfix. assert.expect(0); @@ -13679,6 +13754,59 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('when creating a new many2one on a x2many then discarding it immediately with ESCAPE, it should not crash', function (assert) { + var done = assert.async(); + assert.expect(1); + + this.data.partner.records[0].turtles = []; + + var form = createView({ + View: FormView, + model: 'partner', + viewOptions: { + mode: 'edit', + }, + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<field name="turtles">' + + '<tree editable="top">' + + '<field name="turtle_foo"/>' + + '<field name="turtle_trululu"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + res_id: 1, + archs: { + 'partner,false,form': '<form><group><field name="foo"/><field name="bar"/></group></form>' + }, + }); + + // add a new line + form.$el.find('.o_field_x2many_list_row_add>a').click(); + + // open the field turtle_trululu (one2many) + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + form.$el.find('.o_input_dropdown>input').click(); + + concurrency.delay(0).then(function () { + // click create and edit + $('.ui-autocomplete .ui-menu-item a:contains(Create and)').trigger('mouseenter').click(); + + // hit escape immediately + var escapeKey = $.ui.keyCode.ESCAPE; + $(document.activeElement).trigger( + $.Event('keydown', {which: escapeKey, keyCode: escapeKey})); + + assert.ok('did not crash'); + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + done(); + }); + }); + QUnit.test('navigating through an editable list with custom controls [REQUIRE FOCUS]', function (assert) { assert.expect(5); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index 83bd275d9e0..75f8afeeff2 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -904,10 +904,14 @@ var MockServer = Class.extend({ var offset = args.offset ? Number(args.offset) : 0; var field = args.field ? args.field : 'sequence'; var records = this.data[args.model].records; + if (!(field in this.data[args.model].fields)) { + return false; + } for (var i in args.ids) { var record = _.findWhere(records, {id: args.ids[i]}); record[field] = Number(i) + offset; } + return true; }, /** * Simulate a 'search_count' operation diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index 6594b274be1..6b8ddfee91b 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -257,6 +257,12 @@ function createAsyncView(params) { controlPanel.appendTo($web_client); var $content = $('<div>').addClass('o_content').appendTo($web_client); + if (params.interceptsPropagate) { + _.each(params.interceptsPropagate, function (cb, name) { + intercept(widget, name, cb, true); + }); + } + return view.getController(widget).then(function (view) { // override the view's 'destroy' so that it calls 'destroy' on the widget // instead, as the widget is the parent of the view and the mockServer. diff --git a/addons/web/static/tests/services/notification_service_tests.js b/addons/web/static/tests/services/notification_service_tests.js index 195222881c5..df3c1657c92 100644 --- a/addons/web/static/tests/services/notification_service_tests.js +++ b/addons/web/static/tests/services/notification_service_tests.js @@ -246,4 +246,38 @@ QUnit.module('Services', { }); }); + QUnit.test('Display a custom notification', function (assert) { + var done = assert.async(); + assert.expect(3); + + var Custom = Notification.extend({ + init: function (parent, params) { + this._super.apply(this, arguments); + assert.ok(params.customParams, 'instantiate custom notification'); + }, + start: function () { + var self = this; + return this._super().then(function () { + self.$el.html('Custom'); + }); + }, + }); + + var view = createView(this.viewParams); + view.call('notification', 'notify', { + Notification: Custom, + customParams: true, + }); + assert.strictEqual( + $('body .o_notification_manager .o_notification:contains(Custom)').length, 1, + "should display the notification"); + view.destroy(); + setTimeout(function () { + assert.strictEqual( + $('body .o_notification_manager .o_notification').length, 0, + "should destroy the notification"); + done(); + }); + }); + });}); diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js index 1a3391c720d..20eb2918d04 100644 --- a/addons/web/static/tests/views/kanban_model_tests.js +++ b/addons/web/static/tests/views/kanban_model_tests.js @@ -192,6 +192,8 @@ QUnit.module('Views', { var done = assert.async(); assert.expect(8); + this.data.product.fields.sequence = {string: "Sequence", type: "integer"}; + this.data.partner.fields.sequence = {string: "Sequence", type: "integer"}; this.data.partner.records.push({id: 3, foo: 'aaa', product_id: 37}); var nbReseq = 0; @@ -204,7 +206,7 @@ QUnit.module('Views', { if (nbReseq === 1) { // resequencing columns assert.deepEqual(args.ids, [41, 37], "ids should be correct"); - assert.strictEqual(args.model, 'product_id', + assert.strictEqual(args.model, 'product', "model should be correct"); } else if (nbReseq === 2) { // resequencing records assert.deepEqual(args.ids, [3, 1], @@ -212,7 +214,6 @@ QUnit.module('Views', { assert.strictEqual(args.model, 'partner', "model should be correct"); } - return $.when(); } return this._super.apply(this, arguments); }, @@ -229,7 +230,7 @@ QUnit.module('Views', { "first group should be res_id 37"); // resequence columns - return model.resequence('product_id', [41, 37], stateID); + return model.resequence('product', [41, 37], stateID); }) .then(function (stateID) { var state = model.get(stateID); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 66476aaad35..17c0c022e85 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -1559,7 +1559,7 @@ QUnit.module('Views', { kanban.destroy(); }); - QUnit.test('quick create record fail in grouped', function (assert) { + QUnit.test('quick create record fail in grouped by many2one', function (assert) { assert.expect(8); var kanban = createView({ @@ -1684,6 +1684,120 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('quick create record fails in grouped by char', function (assert) { + assert.expect(7); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,false,form': '<form>' + + '<field name="foo"/>' + + '</form>', + }, + mockRPC: function (route, args) { + if (args.method === 'name_create') { + return $.Deferred().reject({ + code: 200, + data: {}, + message: "Odoo server error", + }, $.Event()); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], {foo: 'yop'}, + "should write the correct value for foo"); + assert.deepEqual(args.kwargs.context, {default_foo: 'yop', default_name: 'test'}, + "should send the correct default value for foo"); + } + return this._super.apply(this, arguments); + }, + groupBy: ['foo'], + }); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1, + "there should be 1 record in first column"); + + kanban.$('.o_kanban_header:first .o_kanban_quick_add i').click(); + kanban.$('.o_kanban_quick_create input').val('test').trigger('input'); + kanban.$('.o_kanban_add').click(); + + assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1, + "a form view dialog should have been opened (in edit)"); + assert.strictEqual($('.modal .o_field_widget[name=foo]').val(), 'yop', + "the correct default value for foo should already be set"); + + $('.modal-footer .btn-primary').click(); + + assert.strictEqual($('.modal').length, 0, "the modal should be closed"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2, + "there should be 2 records in first column"); + + kanban.destroy(); + }); + + QUnit.test('quick create record fails in grouped by selection', function (assert) { + assert.expect(7); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="state"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,false,form': '<form>' + + '<field name="state"/>' + + '</form>', + }, + mockRPC: function (route, args) { + if (args.method === 'name_create') { + return $.Deferred().reject({ + code: 200, + data: {}, + message: "Odoo server error", + }, $.Event()); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], {state: 'abc'}, + "should write the correct value for state"); + assert.deepEqual(args.kwargs.context, {default_state: 'abc', default_name: 'test'}, + "should send the correct default value for state"); + } + return this._super.apply(this, arguments); + }, + groupBy: ['state'], + }); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1, + "there should be 1 record in first column"); + + kanban.$('.o_kanban_header:first .o_kanban_quick_add i').click(); + kanban.$('.o_kanban_quick_create input').val('test').trigger('input'); + kanban.$('.o_kanban_add').click(); + + assert.strictEqual($('.modal .o_form_view.o_form_editable').length, 1, + "a form view dialog should have been opened (in edit)"); + assert.strictEqual($('.modal .o_field_widget[name=state]').val(), '"abc"', + "the correct default value for state should already be set"); + + $('.modal-footer .btn-primary').click(); + + assert.strictEqual($('.modal').length, 0, "the modal should be closed"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2, + "there should be 2 records in first column"); + + kanban.destroy(); + }); + QUnit.test('quick create record in empty grouped kanban', function (assert) { assert.expect(3); @@ -1726,6 +1840,327 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('quick create record in grouped on date(time) field', function (assert) { + assert.expect(6); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['date'], + intercepts: { + switch_view: function (ev) { + assert.deepEqual(_.pick(ev.data, 'res_id', 'view_type'), { + res_id: undefined, + view_type: 'form', + }, "should trigger an event to open the form view (twice)"); + }, + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 0, + "quick create should be disabled when grouped on a date field"); + + // clicking on CREATE in control panel should not open a quick create + kanban.$buttons.find('.o-kanban-button-new').click(); + assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 0, + "should not have opened the quick create widget"); + + kanban.reload({groupBy: ['datetime']}); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 0, + "quick create should be disabled when grouped on a datetime field"); + + // clicking on CREATE in control panel should not open a quick create + kanban.$buttons.find('.o-kanban-button-new').click(); + assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 0, + "should not have opened the quick create widget"); + + kanban.destroy(); + }); + + QUnit.test('quick create record feature is properly enabled/disabled at reload', function (assert) { + assert.expect(3); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['foo'], + }); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 3, + "quick create should be enabled when grouped on a char field"); + + kanban.reload({groupBy: ['date']}); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 0, + "quick create should now be disabled (grouped on date field)"); + + kanban.reload({groupBy: ['bar']}); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 2, + "quick create should be enabled again (grouped on boolean field)"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by char field', function (assert) { + assert.expect(4); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['foo'], + mockRPC: function (route, args) { + if (args.method === 'name_create') { + assert.deepEqual(args.kwargs.context, {default_foo: 'yop'}, + "should send the correct default value for foo"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 3, + "quick create should be enabled when grouped on a char field"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1, + "first column should contain 1 record"); + + kanban.$('.o_kanban_header:first .o_kanban_quick_add i').click(); + kanban.$('.o_kanban_quick_create input').val('new record').trigger('input'); + kanban.$('.o_kanban_add').click(); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2, + "first column should now contain 2 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by boolean field', function (assert) { + assert.expect(4); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + if (args.method === 'name_create') { + assert.deepEqual(args.kwargs.context, {default_bar: true}, + "should send the correct default value for bar"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 2, + "quick create should be enabled when grouped on a boolean field"); + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 3, + "second column (true) should contain 3 records"); + + kanban.$('.o_kanban_header:nth(1) .o_kanban_quick_add i').click(); + kanban.$('.o_kanban_quick_create input').val('new record').trigger('input'); + kanban.$('.o_kanban_add').click(); + + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 4, + "second column (true) should now contain 4 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped on selection field', function (assert) { + assert.expect(4); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="display_name"/></div>' + + '</t></templates>' + + '</kanban>', + mockRPC: function (route, args) { + if (args.method === 'name_create') { + assert.deepEqual(args.kwargs.context, {default_state: 'abc'}, + "should send the correct default value for bar"); + } + return this._super.apply(this, arguments); + }, + groupBy: ['state'], + }); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 3, + "quick create should be enabled when grouped on a selection field"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1, + "first column (abc) should contain 1 record"); + + kanban.$('.o_kanban_header:first .o_kanban_quick_add i').click(); + kanban.$('.o_kanban_quick_create input').val('new record').trigger('input'); + kanban.$('.o_kanban_add').click(); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2, + "first column (abc) should contain 2 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by char field (within quick_create_view)', function (assert) { + assert.expect(6); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="foo"/>' + + '</form>', + }, + groupBy: ['foo'], + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0], {foo: 'yop'}, + "should write the correct value for foo"); + assert.deepEqual(args.kwargs.context, {default_foo: 'yop'}, + "should send the correct default value for foo"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 3, + "quick create should be enabled when grouped on a char field"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1, + "first column should contain 1 record"); + + kanban.$('.o_kanban_header:first .o_kanban_quick_add i').click(); + assert.strictEqual(kanban.$('.o_kanban_quick_create input').val(), 'yop', + "should have set the correct foo value by default"); + kanban.$('.o_kanban_add').click(); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2, + "first column should now contain 2 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by boolean field (within quick_create_view)', function (assert) { + assert.expect(6); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="bar"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="bar"/>' + + '</form>', + }, + groupBy: ['bar'], + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0], {bar: true}, + "should write the correct value for bar"); + assert.deepEqual(args.kwargs.context, {default_bar: true}, + "should send the correct default value for bar"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 2, + "quick create should be enabled when grouped on a boolean field"); + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 3, + "second column (true) should contain 3 records"); + + kanban.$('.o_kanban_header:nth(1) .o_kanban_quick_add i').click(); + assert.ok(kanban.$('.o_kanban_quick_create .o_field_boolean input').is(':checked'), + "should have set the correct bar value by default"); + kanban.$('.o_kanban_add').click(); + + assert.strictEqual(kanban.$('.o_kanban_group:nth(1) .o_kanban_record').length, 4, + "second column (true) should now contain 4 records"); + + kanban.destroy(); + }); + + QUnit.test('quick create record in grouped by selection field (within quick_create_view)', function (assert) { + assert.expect(6); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create" quick_create_view="some_view_ref">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="state"/></div>' + + '</t></templates>' + + '</kanban>', + archs: { + 'partner,some_view_ref,form': '<form>' + + '<field name="state"/>' + + '</form>', + }, + groupBy: ['state'], + mockRPC: function (route, args) { + if (args.method === 'create') { + assert.deepEqual(args.args[0], {state: 'abc'}, + "should write the correct value for state"); + assert.deepEqual(args.kwargs.context, {default_state: 'abc'}, + "should send the correct default value for state"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_header .o_kanban_quick_add i').length, 3, + "quick create should be enabled when grouped on a selection field"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1, + "first column (abc) should contain 1 record"); + + kanban.$('.o_kanban_header:first .o_kanban_quick_add i').click(); + assert.strictEqual(kanban.$('.o_kanban_quick_create select').val(), '"abc"', + "should have set the correct state value by default"); + kanban.$('.o_kanban_add').click(); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2, + "first column (abc) should now contain 2 records"); + + kanban.destroy(); + }); + QUnit.test('many2many_tags in kanban views', function (assert) { assert.expect(12); @@ -1796,6 +2231,72 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('Do not open record when clicking on `a` with `href`', function (assert) { + assert.expect(5); + + this.data.partner.records = [ + { id: 1, foo: 'yop' }, + ]; + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="oe_kanban_global_click">' + + '<field name="foo"/>' + + '<div>' + + '<a class="o_test_link" href="#">test link</a>' + + '</div>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + intercepts: { + // when clicking on a record in kanban view, + // it switches to form view. + switch_view: function () { + throw new Error("should not switch view"); + }, + }, + }); + + var $record = kanban.$('.o_kanban_record:not(.o_kanban_ghost)'); + assert.strictEqual($record.length, 1, + "should display a kanban record"); + + var $testLink = $record.find('a'); + assert.strictEqual($testLink.length, 1, + "should contain a link in the kanban record"); + assert.ok(!!$testLink[0].href, + "link inside kanban record should have non-empty href"); + + // Mocked views prevent accessing a link with href. This is intented + // most of the time, but not in this test which specifically needs to + // let the browser access a link with href. + kanban.$el.off('click', 'a'); + // Prevent the browser default behaviour when clicking on anything. + // This includes clicking on a `<a>` with `href`, so that it does not + // change the URL in the address bar. + // Note that we should not specify a click listener on 'a', otherwise + // it may influence the kanban record global click handler to not open + // the record. + $(document.body).on('click.o_test', function (ev) { + assert.notOk(ev.isDefaultPrevented(), + "should not prevented browser default behaviour beforehand"); + assert.strictEqual(ev.target, $testLink[0], + "should have clicked on the test link in the kanban record"); + ev.preventDefault(); + }); + + $testLink.click(); + + $(document.body).off('click.o_test'); + kanban.destroy(); + }); + QUnit.test('can drag and drop a record from one column to the next', function (assert) { assert.expect(9); @@ -2258,7 +2759,7 @@ QUnit.module('Views', { }); QUnit.test('delete a column in grouped on m2o', function (assert) { - assert.expect(33); + assert.expect(36); testUtils.patch(KanbanRenderer, { _renderGrouped: function () { @@ -3224,6 +3725,7 @@ QUnit.module('Views', { QUnit.test('resequence columns in grouped by m2o', function (assert) { assert.expect(7); + this.data.product.fields.sequence = {string: "Sequence", type: "integer"}; var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test var kanban = createView({ @@ -3237,12 +3739,6 @@ QUnit.module('Views', { '</t></templates>' + '</kanban>', groupBy: ['product_id'], - mockRPC: function (route) { - if (route === '/web/dataset/resequence') { - return $.when(); - } - return this._super.apply(this, arguments); - }, intercepts: { env_updated: function (event) { assert.deepEqual(event.data.env.ids, envIDs, @@ -3266,7 +3762,7 @@ QUnit.module('Views', { kanban.update({}, {reload: false}); // re-render without reloading assert.strictEqual(kanban.$('.o_kanban_group:first').data('id'), 5, - "first column should be id 5 before resequencing"); + "first column should be id 5 after resequencing"); kanban.destroy(); }); @@ -4085,7 +4581,7 @@ QUnit.module('Views', { '<div><field name="foo"/></div>' + '</t></templates>' + '</kanban>', - groupBy: ['int_field'], + groupBy: ['foo'], mockRPC: function (route, args) { if (route === '/web/dataset/resequence') { return $.when(true); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 63e7013c824..5a0d00b6f6d 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -3223,10 +3223,10 @@ QUnit.module('Views', { foo: { fields: {int_field: {string: "int_field", type: "integer", sortable: true}}, records: [ - {id: 1, int_field: 0}, - {id: 2, int_field: 1}, - {id: 3, int_field: 2}, - {id: 4, int_field: 3}, + {id: 1, int_field: 11}, + {id: 2, int_field: 12}, + {id: 3, int_field: 13}, + {id: 4, int_field: 14}, ] } }; @@ -3245,14 +3245,15 @@ QUnit.module('Views', { assert.deepEqual(args, { model: "foo", ids: [4, 3], - offset: 2, + offset: 13, field: "int_field", }); } if (moves === 1) { assert.deepEqual(args, { model: "foo", - ids: [1, 4, 2, 3], + ids: [4, 2], + offset: 12, field: "int_field", }); } @@ -3260,14 +3261,15 @@ QUnit.module('Views', { assert.deepEqual(args, { model: "foo", ids: [2, 4], - offset: 1, + offset: 12, field: "int_field", }); } if (moves === 3) { assert.deepEqual(args, { model: "foo", - ids: [1, 4, 2, 3], + ids: [4, 2], + offset: 12, field: "int_field", }); } @@ -3334,7 +3336,6 @@ QUnit.module('Views', { "should write the right field as sequence"); assert.deepEqual(args.ids, [4, 2, 3], "should write the sequence in correct order"); - return $.when(); } return this._super.apply(this, arguments); }, @@ -3464,13 +3465,16 @@ QUnit.module('Views', { '</tree>', mockRPC: function (route, args) { if (route === '/web/dataset/resequence') { + var _super = this._super.bind(this); assert.strictEqual(args.offset, 1, "should write the sequence starting from the lowest current one"); assert.strictEqual(args.field, 'int_field', "should write the right field as sequence"); assert.deepEqual(args.ids, [4, 2, 3], "should write the sequence in correct order"); - return $.when(def); + return $.when(def).then(function () { + return _super(route, args); + }); } return this._super.apply(this, arguments); }, diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 47a2ed33e7e..5df08ee801d 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -749,6 +749,7 @@ registry.colorpicker = SnippetOption.extend({ */ _onColorResetButtonClick: function () { this.$target.removeClass(this.classes).css('background-color', ''); + self.$target.trigger('content_changed'); this.$el.find('.colorpicker button.selected').removeClass('selected'); }, }); @@ -896,7 +897,7 @@ registry.background = SnippetOption.extend({ * @param {string} value */ _setCustomBackground: function (value) { - this.__customImageSrc = this._getSrcFromCssValue(value); + this.__customImageSrc = value; this.background(false, this.__customImageSrc); this.$target.addClass('oe_custom_bg'); this._setActive(); diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js index 0edc1adb9b9..7c33ab53b97 100644 --- a/addons/web_editor/static/src/js/widgets/widgets.js +++ b/addons/web_editor/static/src/js/widgets/widgets.js @@ -368,7 +368,7 @@ var ImageWidget = MediaWidget.extend({ if (needle && needle.length) { domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]); } - domain.push('!', ['datas_fname', '=like', '%.crop'], '!', ['name', '=like', '%.crop']); + domain.push('|', ['datas_fname', '=', false], '!', ['datas_fname', '=like', '%.crop'], '!', ['name', '=like', '%.crop']); return this._rpc({ model: 'ir.attachment', method: 'search_read', diff --git a/addons/web_editor/static/src/scss/web_editor.ui.scss b/addons/web_editor/static/src/scss/web_editor.ui.scss index 3d465ae18ee..3581df3bf8b 100644 --- a/addons/web_editor/static/src/scss/web_editor.ui.scss +++ b/addons/web_editor/static/src/scss/web_editor.ui.scss @@ -988,6 +988,9 @@ body.editor_enable { left: 0; } } + .btn { + -webkit-user-select: none; + } } #web_editor_inside_iframe { diff --git a/addons/website_links/static/src/js/website_links_charts.js b/addons/website_links/static/src/js/website_links_charts.js index b01e73b87e0..a275dc50743 100644 --- a/addons/website_links/static/src/js/website_links_charts.js +++ b/addons/website_links/static/src/js/website_links_charts.js @@ -62,6 +62,7 @@ if (!$('.o_website_links_chart').length) { var chart = nv.models.lineChart() .x(function (d) { return getDate(d); }) .y(function (d) { return getNbClicks(d); }) + .margin({top: 10, right: 60, bottom: 60, left: 60}) .showYAxis(true) .showXAxis(true); @@ -75,7 +76,7 @@ if (!$('.o_website_links_chart').length) { chart.yAxis .tickFormat(d3.format("d")) - .ticks(chart_data[0]['values'].length - 1); + .ticks(Math.min(chart_data[0]['values'].length - 1, 10)); d3.select(self.$element + ' svg') .datum(chart_data) diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index c157b585669..af7000eea05 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -1069,6 +1069,23 @@ class WebsiteSale(ProductConfiguratorController): phone_code=country.phone_code ) + @http.route(['/shop/update_carrier'], type='json', auth='public', methods=['POST'], website=True, csrf=False) + def update_eshop_carrier(self, **post): + results = {} + if hasattr(self, '_update_website_sale_delivery'): + results.update(self._update_website_sale_delivery(**post)) + + if hasattr(self, '_update_website_sale_coupon'): + results.update(self._update_website_sale_coupon(**post)) + + return results + + def _format_amount(self, amount, currency): + fmt = "%.{0}f".format(currency.decimal_places) + lang = request.env['res.lang']._lang_get(request.env.context.get('lang') or 'en_US') + return lang.format(fmt, currency.round(amount), grouping=True, monetary=True)\ + .replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}') + @http.route(['/shop/cart/update_option'], type='http', auth="public", methods=['POST'], website=True, multilang=False) def cart_options_update_json(self, product_id, add_qty=1, set_qty=0, goto_shop=None, lang=None, **kw): if lang: diff --git a/addons/website_sale_delivery/controllers/main.py b/addons/website_sale_delivery/controllers/main.py index 4eb5af1a554..70202548f65 100644 --- a/addons/website_sale_delivery/controllers/main.py +++ b/addons/website_sale_delivery/controllers/main.py @@ -55,8 +55,7 @@ class WebsiteSaleDelivery(WebsiteSale): values['delivery_action_id'] = request.env.ref('delivery.action_delivery_carrier_form').id return values - @http.route(['/shop/update_carrier'], type='json', auth='public', methods=['POST'], website=True, csrf=False) - def update_eshop_carrier(self, **post): + def _update_website_sale_delivery(self, **post): order = request.website.sale_get_order() carrier_id = int(post['carrier_id']) currency = order.currency_id @@ -70,10 +69,4 @@ class WebsiteSaleDelivery(WebsiteSale): 'new_amount_tax': self._format_amount(order.amount_tax, currency), 'new_amount_total': self._format_amount(order.amount_total, currency), } - - def _format_amount(self, amount, currency): - fmt = "%.{0}f".format(currency.decimal_places) - lang = request.env['res.lang']._lang_get(request.env.context.get('lang') or 'en_US') - - return lang.format(fmt, currency.round(amount), grouping=True, monetary=True)\ - .replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}') + return {} diff --git a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js index ee0c73585ac..476c1bd4faa 100644 --- a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js +++ b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js @@ -3,6 +3,8 @@ odoo.define('website_sale_delivery.checkout', function (require) { require('web.dom_ready'); var ajax = require('web.ajax'); + var core = require('web.core'); + var _t = core._t; /* Handle interactive carrier choice + cart update */ var $pay_button = $('#o_payment_form_pay'); @@ -14,6 +16,17 @@ odoo.define('website_sale_delivery.checkout', function (require) { var $amount_total = $('#order_total span.oe_currency_value'); var $carrier_badge = $('#delivery_carrier input[name="delivery_type"][value=' + result.carrier_id + '] ~ .badge.d-none'); var $compute_badge = $('#delivery_carrier input[name="delivery_type"][value=' + result.carrier_id + '] ~ .o_delivery_compute'); + var $discount = $('#order_discounted'); + + if ($discount && result.new_amount_order_discounted) { + // Cross module without bridge + // Update discount of the order + $discount.find('.oe_currency_value').text(result.new_amount_order_discounted); + + // We are in freeshipping, so every carrier is Free + $('#delivery_carrier .badge').text(_t('Free')); + } + if (result.status === true) { $amount_delivery.text(result.new_amount_delivery); $amount_untaxed.text(result.new_amount_untaxed); diff --git a/doc/cla/corporate/cybrosys.md b/doc/cla/corporate/cybrosys.md new file mode 100644 index 00000000000..0d413d31ba7 --- /dev/null +++ b/doc/cla/corporate/cybrosys.md @@ -0,0 +1,16 @@ +India 2018-08-27 + +Cybrosys Techno SOlutions agrees to the terms of the Odoo Corporate +Contributor License Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Niyas Raphy niyas@cybrosys.com https://github.com/CybroOdoo + +List of contributors: + +Sainu Abideen sainu@cybrosys.com https://github.com/CybroOdoo +Cybrosys odoo@cybrosys.com https://github.com/CybroOdoo diff --git a/doc/cla/individual/linekio.md b/doc/cla/individual/linekio.md new file mode 100644 index 00000000000..7c905b998ba --- /dev/null +++ b/doc/cla/individual/linekio.md @@ -0,0 +1,11 @@ +France, 2018-10-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, + +BenoƮt Fontaine <benoitfontaine.ba@gmail.com> https://github.com/Linekio diff --git a/doc/cla/individual/rakete.md b/doc/cla/individual/rakete.md new file mode 100644 index 00000000000..72fa2bcea7c --- /dev/null +++ b/doc/cla/individual/rakete.md @@ -0,0 +1,11 @@ +Germany, 2017-11-24 + +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, + +Andreas Raster lazor@affenbande.org https://github.com/rakete diff --git a/doc/cla/individual/stijnh92.md b/doc/cla/individual/stijnh92.md new file mode 100644 index 00000000000..b969df67c38 --- /dev/null +++ b/doc/cla/individual/stijnh92.md @@ -0,0 +1,11 @@ +Belgium, 2018-09-05 + +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, + +Stijn Houben stijnhouben92@gmail.com https://github.com/stijnh92 diff --git a/doc/cla/individual/zoggy444.md b/doc/cla/individual/zoggy444.md new file mode 100644 index 00000000000..46f8490f973 --- /dev/null +++ b/doc/cla/individual/zoggy444.md @@ -0,0 +1,11 @@ +FRANCE, 2018-09-26 + +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, + +zoggy 43614933+zoggy444@users.noreply.github.com https://github.com/zoggy444 diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst index fe04dc06a6f..058c49340ed 100644 --- a/doc/reference/javascript_reference.rst +++ b/doc/reference/javascript_reference.rst @@ -1611,7 +1611,7 @@ order. .. code-block:: xml - <field name="datefield" options='{"datepicker": {"daysOfWeekDisabled": [0, 6]}}'/> + <field name="datetimefield" options='{"datepicker": {"daysOfWeekDisabled": [0, 6]}}'/> - monetary (FieldMonetary) This is the default field type for fields of type 'monetary'. It is used to diff --git a/doc/reference/views.rst b/doc/reference/views.rst index eb7cefab965..52af59ecad4 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -1011,7 +1011,7 @@ attributes: ``quick_create`` whether it should be possible to create records without switching to the form view. By default, ``quick_create`` is enabled when the Kanban view is - grouped, and disabled when not. + grouped by many2one, selection, char or boolean fields, and disabled when not. Set to ``true`` to always enable it, and to ``false`` to always disable it. diff --git a/odoo/addons/base/data/base_data.sql b/odoo/addons/base/data/base_data.sql index 58bdd7d3062..367d3da72af 100644 --- a/odoo/addons/base/data/base_data.sql +++ b/odoo/addons/base/data/base_data.sql @@ -108,6 +108,7 @@ CREATE TABLE res_company ( name varchar NOT NULL, partner_id integer, currency_id integer, + sequence integer, create_date timestamp without time zone, primary key(id) ); diff --git a/odoo/addons/base/data/res_country_data.xml b/odoo/addons/base/data/res_country_data.xml index 3e0db583e3e..d924f2ee042 100644 --- a/odoo/addons/base/data/res_country_data.xml +++ b/odoo/addons/base/data/res_country_data.xml @@ -457,7 +457,7 @@ <field name="name">Ecuador</field> <field name="code">ec</field> <field file="base/static/img/country_flags/ec.png" name="image" type="base64" /> - <field name="currency_id" ref="ECS" /> + <field name="currency_id" ref="USD" /> <field eval="593" name="phone_code" /> </record> <record id="ee" model="res.country"> @@ -1734,7 +1734,7 @@ <field file="base/static/img/country_flags/vn.png" name="image" type="base64" /> <field name="currency_id" ref="VND" /> <field eval="84" name="phone_code" /> - <field eval="'%(street)s\n%(street2)s\n%(city)s\n%(name)s %(zip)s %(country_name)s'" name="address_format" /> + <field eval="'%(street)s\n%(street2)s\n%(city)s\n%(state_name)s %(country_name)s'" name="address_format" /> </record> <record id="vu" model="res.country"> <field name="name">Vanuatu</field> diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index 18951749f61..428fabd6dcf 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -345,7 +345,7 @@ class IrActionsServer(models.Model): DEFAULT_PYTHON_CODE = """# Available variables: # - env: Odoo Environment on which the action is triggered # - model: Odoo Model of the record on which the action is triggered; is a void recordset -# - record: record on which the action is triggered; may be be void +# - record: record on which the action is triggered; may be void # - records: recordset of all records on which the action is triggered in multi-mode; may be void # - time, datetime, dateutil, timezone: useful Python libraries # - log: log(message, level='info'): logging function to record debug information in ir.logging table diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py index 5c457f5bd6e..205be37929e 100644 --- a/odoo/addons/base/models/ir_http.py +++ b/odoo/addons/base/models/ir_http.py @@ -307,7 +307,7 @@ class IrHttp(models.AbstractModel): if not cls._check_access_mode(env, id, access_mode, model, access_token=access_token, related_id=related_id): return (403, [], None) - elif not consteq(obj.access_token or '', access_token): + elif not consteq(obj.access_token or u'', access_token): return (403, [], None) # check read access diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index bfe55e30486..76524a10814 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -712,11 +712,15 @@ class IrModelFields(models.Model): field = getattr(obj, '_fields', {}).get(item.name) if vals.get('name', item.name) != item.name: - # We need to rename the column + # We need to rename the field item._prepare_update() - if column_rename: - raise UserError(_('Can only rename one field at a time!')) - column_rename = (obj._table, item.name, vals['name'], item.index) + if item.ttype in ('one2many', 'many2many'): + # those field names are not explicit in the database! + pass + else: + if column_rename: + raise UserError(_('Can only rename one field at a time!')) + column_rename = (obj._table, item.name, vals['name'], item.index) # We don't check the 'state', because it might come from the context # (thus be set for multiple fields) and will be ignored anyway. @@ -779,6 +783,7 @@ class IrModelFields(models.Model): 'index': bool(field.index), 'store': bool(field.store), 'copied': bool(field.copy), + 'on_delete': getattr(field, 'ondelete', None), 'related': ".".join(field.related) if field.related else None, 'readonly': bool(field.readonly), 'required': bool(field.required), diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index 407bec75af1..2c49ee065b8 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -130,6 +130,13 @@ def get_view_arch_from_file(filename, xmlid): if node.tag in ('template', 'record'): if node.tag == 'record': field = node.find('field[@name="arch"]') + if field is None: + if node.find('field[@name="view_id"]') is not None: + view_id = node.find('field[@name="view_id"]').attrib.get('ref') + ref_id = '%s%s' % ('.' not in view_id and xmlid.split('.')[0] + '.' or '', view_id) + return get_view_arch_from_file(filename, ref_id) + else: + return None _fix_multiple_roots(field) inner = u''.join([etree.tostring(child, encoding='unicode') for child in field.iterchildren()]) return field.text + inner diff --git a/odoo/addons/base/security/ir.model.access.csv b/odoo/addons/base/security/ir.model.access.csv index 579390d9e4c..380dda058fd 100644 --- a/odoo/addons/base/security/ir.model.access.csv +++ b/odoo/addons/base/security/ir.model.access.csv @@ -95,4 +95,4 @@ "access_ir_actions_client","ir_actions_client all","model_ir_actions_client",,1,0,0,0 "access_ir_logging","ir_logging admin","model_ir_logging","group_erp_manager",1,1,1,1 "paperformat_access_portal","ir_actions_report_paperformat group_portal","model_report_paperformat",,1,0,0,0 -"paperformat_access_employee","ir_actions_report_paperformat group_hr_user","model_report_paperformat",,1,0,1,0 +"paperformat_access_employee","ir_actions_report_paperformat group_system","model_report_paperformat",group_system,1,1,1,1 diff --git a/odoo/addons/base/tests/test_ir_http.py b/odoo/addons/base/tests/test_ir_http.py index 2cdcaf74dcf..4853c808fe4 100644 --- a/odoo/addons/base/tests/test_ir_http.py +++ b/odoo/addons/base/tests/test_ir_http.py @@ -97,29 +97,29 @@ class test_ir_http_mimetype(common.TransactionCase): def test_access(**kwargs): status, _, _ = self.env['ir.http'].binary_content( - **defaults, **kwargs + **dict(defaults, **kwargs) ) return status status = test_access() self.assertEqual(status, 403, "no access") - status = test_access(access_token='Secret') + status = test_access(access_token=u'Secret') self.assertEqual(status, 403, "no access if access token for attachment without access token") - attachment.access_token = 'Secret' - status = test_access(access_token='Secret') + attachment.access_token = u'Secret' + status = test_access(access_token=u'Secret') self.assertEqual(status, 200, "access for correct access token") - status = test_access(access_token='Wrong') + status = test_access(access_token=u'Wrong') self.assertEqual(status, 403, "no access for wrong access token") attachment.public = True status = test_access() self.assertEqual(status, 200, "access for attachment with access") - status = test_access(access_token='Wrong') + status = test_access(access_token=u'Wrong') self.assertEqual(status, 403, "no access for wrong access token for attachment with access") @@ -127,6 +127,6 @@ class test_ir_http_mimetype(common.TransactionCase): status = test_access() self.assertEqual(status, 404, "no access for deleted attachment") - status = test_access(access_token='Secret') + status = test_access(access_token=u'Secret') self.assertEqual(status, 404, "no access with access token for deleted attachment") diff --git a/odoo/service/server.py b/odoo/service/server.py index 753353fe016..d64b93d9e8c 100644 --- a/odoo/service/server.py +++ b/odoo/service/server.py @@ -180,6 +180,8 @@ class FSWatcher(object): try: source = open(path, 'rb').read() + b'\n' compile(source, path, 'exec') + except FileNotFoundError: + _logger.error('autoreload: python code change detected, FileNotFound for %s', path) except SyntaxError: _logger.error('autoreload: python code change detected, SyntaxError in %s', path) else: diff --git a/odoo/tests/common.py b/odoo/tests/common.py index d1d6b413aab..49a026b52aa 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -6,8 +6,8 @@ helpers and classes to write tests. """ import base64 import collections -import inspect import importlib +import inspect import itertools import json import logging diff --git a/odoo/tools/config.py b/odoo/tools/config.py index 5e406d036f2..47c5b633cff 100644 --- a/odoo/tools/config.py +++ b/odoo/tools/config.py @@ -471,6 +471,8 @@ class configmanager(object): os.path.abspath(os.path.expanduser(os.path.expandvars(x.strip()))) for x in self.options['addons_path'].split(',')) + self.options['data_dir'] = os.path.abspath(os.path.expanduser(os.path.expandvars(self.options['data_dir'].strip()))) + self.options['init'] = opt.init and dict.fromkeys(opt.init.split(','), 1) or {} self.options['demo'] = (dict(self.options['init']) if not self.options['without_demo'] else {}) |
