diff options
26 files changed, 481 insertions, 93 deletions
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 27e9dcdd7e6..7cf32537e81 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -346,7 +346,7 @@ class AccountInvoice(models.Model): payment_move_line_ids = fields.Many2many('account.move.line', string='Payment Move Lines', compute='_compute_payments', store=True) user_id = fields.Many2one('res.users', string='Salesperson', track_visibility='onchange', readonly=True, states={'draft': [('readonly', False)]}, - default=lambda self: self.env.user) + default=lambda self: self.env.user, copy=False) fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', oldname='fiscal_position', readonly=True, states={'draft': [('readonly', False)]}) commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', compute_sudo=True, diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index f61cf860d84..2b48b3c37a2 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -168,6 +168,10 @@ <label for="currency_exchange_journal_id" class="col-md-3 o_light_label"/> <field name="currency_exchange_journal_id"/> </div> + <div class="row mt16"> + <label for="currency_exchange_journal_id" class="col-md-3 o_light_label"/> + <field name="currency_exchange_journal_id"/> + </div> </div> </div> </div> diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 21067b32c44..ab0feaf2c96 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -402,5 +402,57 @@ QUnit.test('non-existing action in a dashboard', function (assert) { form.destroy(); }); +QUnit.test('clicking on a kanban\'s button should trigger the action', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'board', + data: this.data, + arch: '<form string="My Dashboard">' + + '<board style="2-1">' + + '<column>' + + '<action name="149" string="Partner" view_mode="kanban" id="action_0_1"></action>' + + '</column>' + + '</board>' + + '</form>', + archs: { + 'partner,false,kanban': + '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' + + '<div>' + + '<field name="foo"/>' + + '</div>' + + '<div><button name="sitting_on_a_park_bench" type="object">Eying little girls with bad intent</button>' + + '</div>' + + '</t></templates></kanban>', + }, + intercepts: { + execute_action: function (event) { + var data = event.data; + assert.strictEqual(data.env.model, 'partner', "should have correct model"); + assert.strictEqual(data.action_data.name, 'sitting_on_a_park_bench', + "should call correct method"); + } + }, + + mockRPC: function (route) { + if (route === '/board/static/src/img/layout_1-1-1.png') { + return $.when(); + } + if (route === '/web/action/load') { + return $.when({res_model: 'partner', view_mode: 'kanban', views: [[false, 'kanban']]}); + } + if (route === '/web/dataset/search_read') { + return $.when({records: [{foo: 'aqualung'}]}); + } + return this._super.apply(this, arguments); + } + }); + + form.$('.o_kanban_test').find('button:first').click(); + + form.destroy(); +}); + }); diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index eb7707af2d1..791b9cee72e 100644 --- a/addons/calendar/views/calendar_views.xml +++ b/addons/calendar/views/calendar_views.xml @@ -122,10 +122,10 @@ <field name="stop" attrs="{'invisible': True}"/> <field name="id" attrs="{'invisible': True}"/> - <field name="start_date" string="Starting at" attrs="{'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> - <field name="stop_date" string="Ending at" attrs="{'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> + <field name="start_date" string="Starting at" attrs="{'required': [('allday','=',True)], 'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> + <field name="stop_date" string="Ending at" attrs="{'required': [('allday','=',True)],'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> - <field name="start_datetime" string="Starting at" attrs="{'invisible': [('allday','=',True)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> + <field name="start_datetime" string="Starting at" attrs="{'required': [('allday','=',False)], 'invisible': [('allday','=',True)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> <field name="stop_datetime" invisible="1"/> <label for="duration" attrs="{'invisible': [('allday','=',True)]}"/> <div attrs="{'invisible': [('allday','=',True)]}"> diff --git a/addons/google_drive/models/google_drive.py b/addons/google_drive/models/google_drive.py index 3e7235c2d75..784b60ebf57 100644 --- a/addons/google_drive/models/google_drive.py +++ b/addons/google_drive/models/google_drive.py @@ -153,6 +153,9 @@ class GoogleDrive(models.Model): a length of 1 element only (batch processing is not supported in the code, though nothing really prevent it) :return: the config id and config name ''' + # TO DO in master: fix my signature and my model + if isinstance(res_model, basestring): + res_model = self.env['ir.model'].search([('model', '=', res_model)]).id if not res_id: raise UserError(_("Creating google drive may only be done by one at a time.")) # check if a model is configured with a template diff --git a/addons/google_drive/static/src/js/gdrive.js b/addons/google_drive/static/src/js/gdrive.js index b7d2542bb7a..def3e0d76a4 100644 --- a/addons/google_drive/static/src/js/gdrive.js +++ b/addons/google_drive/static/src/js/gdrive.js @@ -1,78 +1,109 @@ -odoo.define('google_drive.google_drive', function (require) { +odoo.define('google_drive.sidebar', function (require) { "use strict"; -var data = require('web.data'); +/** + * The purpose of this file is to include the Sidebar widget to add Google + * Drive related items. + */ + var Sidebar = require('web.Sidebar'); + Sidebar.include({ - init: function () { - var self = this; - var ids; - this._super.apply(this, arguments); - var view = self.getParent(); - var result; - if (view.fields_view && view.fields_view.type === "form") { - ids = []; - view.on("load_record", self, function (r) { - ids = [r.id]; - self.add_gdoc_items(view, r.id); - }); + // TO DO: clean me in master + /** + * @override + */ + start: function () { + var def; + if (this.options.viewType === "form") { + def = this._addGoogleDocItems(this.env.model, this.env.activeIds[0]); } + return $.when(def).then(this._super.bind(this)); }, - add_gdoc_items: function (view, res_id) { + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} model + * @param {integer} resID + * @returns {Deferred} + */ + _addGoogleDocItems: function (model, resID) { var self = this; + if (!resID) { + return $.when(); + } var gdoc_item = _.indexOf(_.pluck(self.items.other, 'classname'), 'oe_share_gdoc'); if (gdoc_item !== -1) { self.items.other.splice(gdoc_item, 1); } - if (res_id) { - view.sidebar_eval_context().done(function (context) { - var ds = new data.DataSet(this, 'google.drive.config', context); - ds.call('get_google_drive_config', [view.dataset.model, res_id, context]).done(function (r) { - if (!_.isEmpty(r)) { - _.each(r, function (res) { - var already_there = false; - for (var i = 0;i < self.items.other.length;i++){ - if (self.items.other[i].classname === "oe_share_gdoc" && self.items.other[i].label.indexOf(res.name) > -1){ - already_there = true; - break; - } - } - if (!already_there){ - self.add_items('other', [{ - label: res.name, - config_id: res.id, - res_id: res_id, - res_model: view.dataset.model, - callback: self.on_google_doc, - classname: 'oe_share_gdoc' - }, - ]); - } - }); + return this._rpc({ + args: [this.env.model, resID], + context: this.env.context, + method: 'get_google_drive_config', + model: 'google.drive.config', + }).then(function (r) { + if (!_.isEmpty(r)) { + _.each(r, function (res) { + var already_there = false; + for (var i = 0; i < self.items.other.length; i++) { + var item = self.items.other[i]; + if (item.classname === 'oe_share_gdoc' && item.label.indexOf(res.name) > -1) { + already_there = true; + break; + } + } + if (!already_there) { + self._addItems('other', [{ + callback: self._onGoogleDocItemClicked.bind(self, res.id, resID), + classname: 'oe_share_gdoc', + config_id: res.id, + label: res.name, + res_id: resID, + res_model: model, + }]); } }); - }); - } + } + }); }, - on_google_doc: function (doc_item) { + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {integer} configID + * @param {integer} resID + */ + _onGoogleDocItemClicked: function (configID, resID) { var self = this; - var domain = [['id', '=', doc_item.config_id]]; + var domain = [['id', '=', configID]]; var fields = ['google_drive_resource_id', 'google_drive_client_id']; this._rpc({ + args: [domain, fields], + method: 'search_read', + model: 'google.drive.config', + }).then(function (configs) { + self._rpc({ + args: [configID, resID, configs[0].google_drive_resource_id], + context: self.env.context, + method: 'get_google_drive_url', model: 'google.drive.config', - method: 'search_read', - args: [domain, fields], - }) - .then(function (configs) { - var ds = new data.DataSet(self, 'google.drive.config'); - ds.call('get_google_drive_url', [doc_item.config_id, doc_item.res_id,configs[0].google_drive_resource_id, self.dataset.context]).done(function (url) { - if (url){ - window.open(url, '_blank'); - } - }); + }).then(function (url) { + if (url){ + window.open(url, '_blank'); + } }); + }); }, + }); +return Sidebar; }); diff --git a/addons/google_drive/static/tests/gdrive_test.js b/addons/google_drive/static/tests/gdrive_test.js new file mode 100644 index 00000000000..75548822cce --- /dev/null +++ b/addons/google_drive/static/tests/gdrive_test.js @@ -0,0 +1,112 @@ +odoo.define('google_drive.gdrive_integration', function (require) { +"use strict"; +//rebuild +var FormView = require('web.FormView'); +var testUtils = require('web.test_utils'); +var GoogleDriveSideBar = require('google_drive.sidebar'); + +var createView = testUtils.createView; + +/* + * @override + * Avoid breaking other tests because of the new route + * that the module introduces + */ +var _addGoogleDocItemsOriginal = GoogleDriveSideBar.prototype._addGoogleDocItems; + +var _addGoogleDocItemsMocked = function (model, resID) { + return $.when(); +}; + +GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsMocked; + +QUnit.module('gdrive_integration', { + beforeEach: function () { + // For our test to work, the _addGoogleDocItems function needs to be the original + GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsOriginal; + + this.data = { + partner: { + fields: { + display_name: {string: "Displayed name", type: "char", searchable: true}, + }, + records: [{ + id: 1, + display_name: "Locomotive Breath", + }], + }, + 'google.drive.config': { + fields: { + model_id: {string: 'Model', type: 'int'}, + name: {string: 'Name', type: 'char'}, + google_drive_resource_id: {string: 'Resource ID', type: 'char'}, + }, + records: [{ + id: 27, + name: 'Cyberdyne Systems', + model_id: 1, + google_drive_resource_id: 'T1000', + }], + }, + 'ir.attachment': { + fields: { + name: {string: 'Name', type:'char'} + }, + records: [], + } + }; + }, + + afterEach: function() { + GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsMocked; + } + +}, function () { + QUnit.module('Google Drive Sidebar'); + + QUnit.test('rendering of the google drive attachments in Sidebar', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>', + res_id: 1, + viewOptions: {sidebar: true}, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_config') { + assert.deepEqual(args.args, ['partner', 1], + 'The route to get google drive config should have been called'); + return $.when([{id: 27, name: 'Cyberdyne Systems'}]); + } + if (route === '/web/dataset/call_kw/google.drive.config/search_read'){ + return $.when([{google_drive_resource_id: "T1000", + google_drive_client_id: "cyberdyne.org", + id: 1}]); + } + if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_url') { + assert.deepEqual(args.args, [27, 1, 'T1000'], + 'The route to get the Google url should have been called'); + // We don't return anything useful, otherwise it will open a new tab + return $.when(); + } + return this._super.apply(this, arguments); + } + }); + + var google_action = form.sidebar.$('.oe_share_gdoc'); + + assert.strictEqual(google_action.length, 1, + 'The button to the google action should be present'); + + // Trigger opening of the dynamic link + google_action.find('a:first').click(); + + form.destroy(); + }); +}); + +}); diff --git a/addons/google_drive/views/google_drive_templates.xml b/addons/google_drive/views/google_drive_templates.xml index 1906a27696b..9f4725702e0 100644 --- a/addons/google_drive/views/google_drive_templates.xml +++ b/addons/google_drive/views/google_drive_templates.xml @@ -8,4 +8,9 @@ </xpath> </template> + <template id="qunit_suite" name="google_drive tests" inherit_id="web.qunit_suite"> + <xpath expr="//t[@t-set='head']" position="inside"> + <script type="text/javascript" src="/google_drive/static/tests/gdrive_test.js"></script> + </xpath> + </template> </odoo> diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index fdf93d99951..726e5efe375 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -102,6 +102,7 @@ class HrPayslip(models.Model): def refund_sheet(self): for payslip in self: copied_payslip = payslip.copy({'credit_note': True, 'name': _('Refund: ') + payslip.name}) + copied_payslip.compute_sheet() copied_payslip.action_payslip_done() formview_ref = self.env.ref('hr_payroll.view_hr_payslip_form', False) treeview_ref = self.env.ref('hr_payroll.view_hr_payslip_tree', False) diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index 4202e49bc45..af27e3094a4 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -58,4 +58,19 @@ class Task(models.Model): if context.get('default_parent_id', False): vals['parent_id'] = context.pop('default_parent_id', None) task = super(Task, self.with_context(context)).create(vals) - return task
\ No newline at end of file + return task + + @api.multi + def write(self, values): + result = super(Task, self).write(values) + # reassign project_id on related timesheet lines + if 'project_id' in values: + project_id = values.get('project_id') + # a timesheet must have an analytic account (and a project) + if not project_id: + raise UserError(_('This task must have a project since they are linked to timesheets.')) + self.sudo().mapped('timesheet_ids').write({ + 'project_id': project_id, + 'account_id': self.env['project.project'].browse(project_id).sudo().analytic_account_id.id + }) + return result
\ No newline at end of file diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index e27ee1a6534..0cc92853fbc 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -2,13 +2,13 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.tests.common import TransactionCase -from odoo.exceptions import AccessError +from odoo.exceptions import AccessError, UserError -class TestTimesheet(TransactionCase): +class TestCommonTimesheet(TransactionCase): def setUp(self): - super(TestTimesheet, self).setUp() + super(TestCommonTimesheet, self).setUp() self.project_customer = self.env['project.project'].create({ 'name': 'Project X', @@ -59,6 +59,9 @@ class TestTimesheet(TransactionCase): 'user_id': self.user_manager.id, }) + +class TestTimesheet(TestCommonTimesheet): + def test_log_timesheet(self): """ Test when log timesheet : check analytic account, user and employee are correctly set. """ Timesheet = self.env['account.analytic.line'] @@ -115,3 +118,42 @@ class TestTimesheet(TransactionCase): 'employee_id': self.empl_employee2.id, }) self.assertEquals(timesheet1.user_id, self.user_employee2, 'Changing timesheet employee should change the related user') + + def test_transfert_project(self): + """ Test transfert task with timesheet to another project """ + Timesheet = self.env['account.analytic.line'] + # create a second project + self.project_customer2 = self.env['project.project'].create({ + 'name': 'Project NUMBER DEUX', + 'allow_timesheets': True, + }) + # employee 1 log some timesheet on task 1 + Timesheet.create({ + 'project_id': self.project_customer.id, + 'task_id': self.task1.id, + 'name': 'my first timesheet', + 'unit_amount': 4, + }) + + timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)]) + timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)]) + self.assertEquals(timesheet_count1, 1, "One timesheet in project 1") + self.assertEquals(timesheet_count2, 0, "No timesheet in project 2") + self.assertEquals(len(self.task1.timesheet_ids), 1, "The timesheet should be linked to task 1") + + # change project of task 1 + self.task1.write({ + 'project_id': self.project_customer2.id + }) + + timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)]) + timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)]) + self.assertEquals(timesheet_count1, 0, "No timesheet in project 1") + self.assertEquals(timesheet_count2, 1, "One timesheet in project 2") + self.assertEquals(len(self.task1.timesheet_ids), 1, "The timesheet should be linked to task 1") + + # it is forbidden to set a task with timesheet without project + with self.assertRaises(UserError): + self.task1.write({ + 'project_id': False + }) diff --git a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py index 8ecca4ee5d4..7e5fa858cf1 100644 --- a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py +++ b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py @@ -67,7 +67,7 @@ class HrContract(models.Model): help="Number of days of paid leaves the employee gets per year.") holidays_editable = fields.Boolean(string="Editable Leaves", default=True) holidays_compensation = fields.Monetary(compute='_compute_holidays_compensation', string="Holidays Compensation") - wage_with_holidays = fields.Monetary(compute='_compute_wage_with_holidays', string="Wage update with holidays retenues") + wage_with_holidays = fields.Monetary(compute='_compute_wage_with_holidays', inverse='_inverse_wage_with_holidays', string="Wage update with holidays retenues") additional_net_amount = fields.Monetary(string="Net Supplements", help="Monthly net amount the employee receives.") retained_net_amount = fields.Monetary(sting="Net Retained", @@ -85,6 +85,23 @@ class HrContract(models.Model): else: contract.wage_with_holidays = contract.wage + def _inverse_wage_with_holidays(self): + for contract in self: + if contract.holidays > 20.0: + remaining_for_gross = contract.wage_with_holidays * (13.0 + 13.0 * 0.3507 + 0.92) + yearly_cost = remaining_for_gross \ + + 12.0 * contract.representation_fees \ + + 12.0 * contract.fuel_card \ + + 12.0 * contract.internet \ + + 12.0 * (contract.mobile + contract.mobile_plus) \ + + 12.0 * contract.transport_employer_cost \ + + (1.326 / 1.05 * 12.0) * contract.commission_on_target \ + + 220.0 * contract.meal_voucher_paid_by_employer + contract.final_yearly_costs = yearly_cost / (1.0 - (contract.holidays - 20.0) / 231.0) + contract.wage = contract._get_gross_from_employer_costs(contract.final_yearly_costs) + else: + contract.wage = contract.wage_with_holidays + @api.depends('transport_mode', 'company_car_total_depreciated_cost', 'public_transport_reimbursed_amount', 'others_reimbursed_amount') def _compute_transport_employer_cost(self): diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index e7f487b6e6e..9fa6701be16 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -148,7 +148,7 @@ class Message(models.Model): given, restrict to messages written in one of those channels. """ partner_id = self.env.user.partner_id.id delete_mode = not self.env.user.share # delete employee notifs, keep customer ones - if domain is None and delete_mode: + if not domain and delete_mode: query = "DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s" args = [(partner_id,)] if channel_ids: @@ -468,6 +468,7 @@ class Message(models.Model): - if author_id == pid, uid is the author, OR - uid belongs to a notified channel, OR - uid is in the specified recipients, OR + - uid has a notification on the message, OR - uid have read access to the related document is model, res_id - otherwise: remove the id """ @@ -495,17 +496,22 @@ class Message(models.Model): # check read access rights before checking the actual rules on the given ids super(Message, self.sudo(access_rights_uid or self._uid)).check_access_rights('read') - self._cr.execute("""SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, partner_rel.res_partner_id, channel_partner.channel_id as channel_id + self._cr.execute(""" + SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, + COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), + channel_partner.channel_id as channel_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel - ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = (%%s) + ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s + LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel + ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner - ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s) - WHERE m.id = ANY (%%s)""" % self._table, (pid, pid, ids,)) + ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s + WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=pid, ids=ids)) for id, rmod, rid, author_id, partner_id, channel_id in self._cr.fetchall(): if author_id == pid: author_ids.add(id) @@ -533,6 +539,7 @@ class Message(models.Model): - read: if - author_id == pid, uid is the author OR - uid is in the recipients (partner_ids) OR + - uid has been notified (needaction) OR - uid is member of a listern channel (channel_ids.partner_ids) OR - uid have read access to the related document if model, res_id - otherwise: raise @@ -583,17 +590,22 @@ class Message(models.Model): message_values = dict((res_id, {}) for res_id in self.ids) if operation in ['read', 'write']: - self._cr.execute("""SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, partner_rel.res_partner_id, channel_partner.channel_id as channel_id + self._cr.execute(""" + SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, + COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), + channel_partner.channel_id as channel_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel - ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = (%%s) + ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s + LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel + ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner - ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s) - WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.partner_id.id, self.env.user.partner_id.id, self.ids,)) + ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s + WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, ids=self.ids)) for mid, rmod, rid, author_id, parent_id, partner_id, channel_id in self._cr.fetchall(): message_values[mid] = { 'model': rmod, diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index c8496d62ef4..9d7c357131c 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -258,7 +258,7 @@ var Chatter = Widget.extend(chat_mixin, { var suggested_partners = []; var thread_recipients = result[self.context.default_res_id]; _.each(thread_recipients, function (recipient) { - var parsed_email = utils.parse_email(recipient[1]); + var parsed_email = recipient[1] && utils.parse_email(recipient[1]); suggested_partners.push({ checked: true, partner_id: recipient[0], diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index 6364be77eb5..49d1705eccd 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -100,15 +100,17 @@ function inline (node, transform_children) { // Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False function parse_email (text) { - var result = text.match(/(.*)<(.*@.*)>/); - if (result) { - return [_.str.trim(result[1]), _.str.trim(result[2])]; - } - result = text.match(/(.*@.*)/); - if (result) { - return [_.str.trim(result[1]), _.str.trim(result[1])]; + if (text){ + var result = text.match(/(.*)<(.*@.*)>/); + if (result) { + return [_.str.trim(result[1]), _.str.trim(result[2])]; + } + result = text.match(/(.*@.*)/); + if (result) { + return [_.str.trim(result[1]), _.str.trim(result[1])]; + } + return [text, false]; } - return [text, false]; } // Replaces textarea text into html text (add <p>, <a>) diff --git a/addons/mail/tests/test_mail_features.py b/addons/mail/tests/test_mail_features.py index 8e2264ad6d3..7a65fc755e4 100644 --- a/addons/mail/tests/test_mail_features.py +++ b/addons/mail/tests/test_mail_features.py @@ -24,7 +24,6 @@ class TestMailFeatures(TestMail): self.env['res.partner'].create({'name': 'Actual Partner'}) self.assertTrue(record.name) - @mute_logger('odoo.addons.mail.models.mail_mail') def test_needaction(self): # needaction use Inbox notification diff --git a/addons/mail/tests/test_mail_message.py b/addons/mail/tests/test_mail_message.py index 566a038d9ef..64b4c16ecbd 100644 --- a/addons/mail/tests/test_mail_message.py +++ b/addons/mail/tests/test_mail_message.py @@ -288,3 +288,70 @@ class TestMailMessageAccess(TestMail): msg_cnt = len(self.test_pigs.message_ids) self.test_pigs.message_post(body='Hi!', subject='test') self.assertEqual(len(self.test_pigs.message_ids), msg_cnt + 1) + + @mute_logger('openerp.addons.mail.models.mail_mail') + def test_mark_all_as_read(self): + self.user_employee.notification_type = 'inbox' + emp_partner = self.user_employee.partner_id.sudo(self.user_employee.id) + + group_private = self.env['mail.channel'].with_context({ + 'mail_create_nolog': True, + 'mail_create_nosubscribe': True, + 'mail_channel_noautofollow': True, + }).create({ + 'name': 'Private', + 'description': 'Private James R.', + 'public': 'private', + 'alias_name': 'private', + 'alias_contact': 'followers'} + ).with_context({'mail_create_nosubscribe': False}) + + # mark all as read clear needactions + group_private.message_post(body='Test', message_type='comment', subtype='mail.mt_comment', partner_ids=[emp_partner.id]) + emp_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[]) + na_count = emp_partner.get_needaction_count() + self.assertEqual(na_count, 0, "mark all as read should conclude all needactions") + + # mark all as read also clear inaccessible needactions + new_msg = group_private.message_post(body='Zest', message_type='comment', subtype='mail.mt_comment', partner_ids=[emp_partner.id]) + needaction_accessible = len(emp_partner.env['mail.message'].search([['needaction', '=', True]])) + self.assertEqual(needaction_accessible, 1, "a new message to a partner is readable to that partner") + + new_msg.sudo().partner_ids = self.env['res.partner'] + emp_partner.env['mail.message'].search([['needaction', '=', True]]) + needaction_length = len(emp_partner.env['mail.message'].search([['needaction', '=', True]])) + self.assertEqual(needaction_length, 1, "message should still be readable when notified") + + na_count = emp_partner.get_needaction_count() + self.assertEqual(na_count, 1, "message not accessible is currently still counted") + + emp_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[]) + na_count = emp_partner.get_needaction_count() + self.assertEqual(na_count, 0, "mark all read should conclude all needactions even inacessible ones") + + @mute_logger('openerp.addons.mail.models.mail_mail') + def test_mark_all_as_read_share(self): + self.user_portal.notification_type = 'inbox' + portal_partner = self.user_portal.partner_id.sudo(self.user_portal.id) + + # mark all as read clear needactions + self.group_pigs.message_post(body='Test', message_type='comment', subtype='mail.mt_comment', partner_ids=[portal_partner.id]) + portal_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[]) + na_count = portal_partner.get_needaction_count() + self.assertEqual(na_count, 0, "mark all as read should conclude all needactions") + + # mark all as read also clear inaccessible needactions + new_msg = self.group_pigs.message_post(body='Zest', message_type='comment', subtype='mail.mt_comment', partner_ids=[portal_partner.id]) + needaction_accessible = len(portal_partner.env['mail.message'].search([['needaction', '=', True]])) + self.assertEqual(needaction_accessible, 1, "a new message to a partner is readable to that partner") + + new_msg.sudo().partner_ids = self.env['res.partner'] + needaction_length = len(portal_partner.env['mail.message'].search([['needaction', '=', True]])) + self.assertEqual(needaction_length, 1, "message should still be readable when notified") + + na_count = portal_partner.get_needaction_count() + self.assertEqual(na_count, 1, "message not accessible is currently still counted") + + portal_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[]) + na_count = portal_partner.get_needaction_count() + self.assertEqual(na_count, 0, "mark all read should conclude all needactions even inacessible ones") diff --git a/addons/maintenance/models/maintenance.py b/addons/maintenance/models/maintenance.py index 0afbbc71e1b..d46b688ca79 100644 --- a/addons/maintenance/models/maintenance.py +++ b/addons/maintenance/models/maintenance.py @@ -214,6 +214,15 @@ class MaintenanceEquipment(models.Model): return super(MaintenanceEquipment, self).write(vals) @api.model + def _message_get_auto_subscribe_fields(self, updated_fields, auto_follow_fields=None): + """ mail.thread override so user_id which has no special access allowance is not + automatically subscribed. + """ + if auto_follow_fields is None: + auto_follow_fields = [] + return super(MaintenanceEquipment, self)._message_get_auto_subscribe_fields(updated_fields, auto_follow_fields) + + @api.model def _read_group_category_ids(self, categories, domain, order): """ Read group customization in order to display all the categories in the kanban view, even if they are empty. diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index 195829b82e2..b059e825628 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -95,7 +95,9 @@ class AccountInvoice(models.Model): def _onchange_currency_id(self): if self.currency_id: for line in self.invoice_line_ids.filtered(lambda r: r.purchase_line_id): - line.price_unit = line.purchase_id.currency_id.with_context(date=self.date_invoice).compute(line.purchase_line_id.price_unit, self.currency_id, round=False) + if line.purchase_id.currency_id != self.currency_id: + line.price_unit = line.purchase_id.currency_id.with_context(date=self.date_invoice)\ + .compute(line.purchase_line_id.price_unit, self.currency_id, round=False) @api.onchange('invoice_line_ids') def _onchange_origin(self): diff --git a/addons/purchase/security/purchase_security.xml b/addons/purchase/security/purchase_security.xml index 9327210daf9..8b4f7f2227d 100644 --- a/addons/purchase/security/purchase_security.xml +++ b/addons/purchase/security/purchase_security.xml @@ -61,7 +61,7 @@ <record id="portal_purchase_order_user_rule" model="ir.rule"> <field name="name">Portal Purchase Orders</field> <field name="model_id" ref="purchase.model_purchase_order"/> - <field name="domain_force">[('message_partner_ids','child_of',[user.commercial_partner_id.id])]</field> + <field name="domain_force">['|', ('message_partner_ids','child_of',[user.commercial_partner_id.id]),('partner_id', 'child_of', [user.commercial_partner_id.id])]</field> <field name="groups" eval="[(4, ref('base.group_portal'))]"/> <field name="perm_unlink" eval="1"/> <field name="perm_write" eval="1"/> @@ -72,7 +72,7 @@ <record id="portal_purchase_order_line_rule" model="ir.rule"> <field name="name">Portal Purhcase Orders Line</field> <field name="model_id" ref="purchase.model_purchase_order_line"/> - <field name="domain_force">[('order_id.message_partner_ids','child_of',[user.commercial_partner_id.id])]</field> + <field name="domain_force">['|',('order_id.message_partner_ids','child_of',[user.commercial_partner_id.id]),('order_id.partner_id','child_of',[user.commercial_partner_id.id])]</field> <field name="groups" eval="[(4, ref('base.group_portal'))]"/> </record> </data> diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py index f61014caf0c..a1eb43c76c6 100644 --- a/addons/stock_landed_costs/models/stock_landed_cost.py +++ b/addons/stock_landed_costs/models/stock_landed_cost.py @@ -171,9 +171,13 @@ class LandedCost(models.Model): val_line_values.update({'cost_id': cost.id, 'cost_line_id': cost_line.id}) self.env['stock.valuation.adjustment.lines'].create(val_line_values) total_qty += val_line_values.get('quantity', 0.0) - total_cost += val_line_values.get('former_cost', 0.0) total_weight += val_line_values.get('weight', 0.0) total_volume += val_line_values.get('volume', 0.0) + + former_cost = val_line_values.get('former_cost', 0.0) + # round this because former_cost on the valuation lines is also rounded + total_cost += tools.float_round(former_cost, precision_digits=digits[1]) if digits else former_cost + total_line += 1 for line in cost.cost_lines: 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 9e97ec40686..5a7cb536f43 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -207,6 +207,7 @@ var KanbanController = BasicController.extend({ * @param {OdooEvent} event */ _onButtonClicked: function (event) { + event.stopPropagation(); var self = this; var attrs = event.data.attrs; var record = event.data.record; diff --git a/addons/website_form/static/src/js/website_form.js b/addons/website_form/static/src/js/website_form.js index aa540650473..47ba66c244b 100644 --- a/addons/website_form/static/src/js/website_form.js +++ b/addons/website_form/static/src/js/website_form.js @@ -22,7 +22,7 @@ odoo.define('website_form.animation', function (require) { start: function () { var self = this; - qweb.add_template('/website_form/static/src/xml/website_form.xml'); + this.templates_loaded = ajax.loadXML('/website_form/static/src/xml/website_form.xml', qweb); this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);}); // Initialize datetimepickers @@ -233,7 +233,10 @@ odoo.define('website_form.animation', function (require) { if (status !== 'success') { // Restore send button behavior if result is an error this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);}); } - this.$target.find('#o_website_form_result').replaceWith(qweb.render("website_form.status_" + status)); + var $result = this.$('#o_website_form_result'); + this.templates_loaded.done(function () { + $result.replaceWith(qweb.render("website_form.status_" + status)); + }); }, }); }); diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 9248e27fcef..3f05085554f 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -684,6 +684,7 @@ </template> <template id="wizard_checkout" name="Wizard Checkout"> + <t t-set="website_sale_order" t-value="website.sale_get_order()"/> <ul class="wizard pull-right hidden-xs"> <li t-att-class="step==10 and 'text-primary' or 'text-muted'" id="step10"> <t t-set="step_review_order" t-value="step>10 and step<50"/> diff --git a/addons/website_slides/views/website_slides.xml b/addons/website_slides/views/website_slides.xml index 1d51e1aabb9..dd6d9938113 100644 --- a/addons/website_slides/views/website_slides.xml +++ b/addons/website_slides/views/website_slides.xml @@ -112,11 +112,6 @@ <i class="fa fa-cloud-upload"></i> Upload </a> </t> - <t t-if="channel.can_upload and is_public_user"> - <a class="btn btn-primary" title="Upload Presentation" t-attf-href="/web/login?redirect=/slides/#{slug(channel)}"> - <i class="fa fa-cloud-upload"></i> Upload - </a> - </t> </h3> </div> <!-- Search box --> diff --git a/doc/cla/individual/nguyenductamlhp.md b/doc/cla/individual/nguyenductamlhp.md new file mode 100644 index 00000000000..898f465c52b --- /dev/null +++ b/doc/cla/individual/nguyenductamlhp.md @@ -0,0 +1,11 @@ +Vietnam, 2018-01-16, + +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, + +Nguyen Duc Tam nguyenductamlhp@gmail.com https://github.com/nguyenductamlhp |
