diff options
86 files changed, 1189 insertions, 434 deletions
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index dacbc7e2df1..b89c22d529f 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -350,7 +350,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, @@ -998,8 +998,6 @@ class AccountInvoice(models.Model): 'invoice_id': self.id, 'analytic_tag_ids': analytic_tag_ids } - if line['account_analytic_id']: - move_line_dict['analytic_line_ids'] = [(0, 0, line._get_analytic_line())] res.append(move_line_dict) return res @@ -1445,21 +1443,6 @@ class AccountInvoiceLine(models.Model): _description = "Invoice Line" _order = "invoice_id,sequence,id" - @api.multi - def _get_analytic_line(self): - ref = self.invoice_id.number - return { - 'name': self.name, - 'date': self.invoice_id.date_invoice, - 'account_id': self.account_analytic_id.id, - 'unit_amount': self.quantity, - 'amount': self.price_subtotal_signed, - 'product_id': self.product_id.id, - 'product_uom_id': self.uom_id.id, - 'general_account_id': self.account_id.id, - 'ref': ref, - } - @api.one @api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity', 'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id', diff --git a/addons/account/models/company.py b/addons/account/models/company.py index ed821367846..d73dbf82c64 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -174,7 +174,6 @@ Best Regards,''')) def setting_init_fiscal_year_action(self): """ Called by the 'Fiscal Year Opening' button of the setup bar.""" company = self.env.user.company_id - company.create_op_move_if_non_existant() new_wizard = self.env['account.financial.year.op'].create({'company_id': company.id}) view_id = self.env.ref('account.setup_financial_year_opening_form').id @@ -265,7 +264,7 @@ Best Regards,''')) default_journal = self.env['account.journal'].search([('type', '=', 'general'), ('company_id', '=', self.id)], limit=1) if not default_journal: - raise UserError(_("No miscellaneous journal could be found. Please create one before proceeding.")) + raise UserError(_("Please install a chart of accounts or create a miscellaneous journal before proceeding.")) self.account_opening_move_id = self.env['account.move'].create({ 'name': _('Opening Journal Entry'), diff --git a/addons/account/views/account_portal_templates.xml b/addons/account/views/account_portal_templates.xml index 2ff82b4bc3a..51ac3563c2e 100644 --- a/addons/account/views/account_portal_templates.xml +++ b/addons/account/views/account_portal_templates.xml @@ -179,7 +179,7 @@ </td> </tr> <tr t-foreach="range(max(5-len(invoice.invoice_line_ids),0))" t-as="l"> - <td>&nbsp;</td> + <td t-translation="off">&nbsp;</td> <td class="hidden"></td> <td></td> <td></td> diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index e39b1605d3c..270c792c606 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -88,7 +88,7 @@ </td> </tr> <tr t-foreach="range(max(5-len(o.invoice_line_ids),0))" t-as="l"> - <td>&nbsp;</td> + <td t-translation="off">&nbsp;</td> <td class="hidden"></td> <td></td> <td></td> @@ -112,12 +112,6 @@ <t t-foreach="o._get_tax_amount_by_group()" t-as="amount_by_group"> <tr style="border-bottom:1px solid #dddddd;"> <t t-if="len(o.tax_line_ids) == 1 and o.amount_untaxed == amount_by_group[2]"> - <td><span t-esc="amount_by_group[0]"/></td> - <td class="text-right"> - <span t-esc="amount_by_group[3]"/> - </td> - </t> - <t t-else=""> <td> <span t-esc="amount_by_group[0]"/> <span>&nbsp;<span>on</span> @@ -125,6 +119,12 @@ </span> </td> <td class="text-right"> + <span t-esc="amount_by_group[3]"/> + </td> + </t> + <t t-else=""> + <td><span t-esc="amount_by_group[0]"/></td> + <td class="text-right"> <span t-esc="amount_by_group[3]" /> </td> </t> diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index 22726578d1b..7a878d265c7 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 fd5971a353d..c9bca3b52cf 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..cf5e8c55345 100644 --- a/addons/google_drive/models/google_drive.py +++ b/addons/google_drive/models/google_drive.py @@ -9,6 +9,7 @@ import werkzeug.urls from odoo import api, fields, models from odoo.exceptions import RedirectWarning, UserError +from odoo.tools import pycompat from odoo.tools.safe_eval import safe_eval from odoo.tools.translate import _ @@ -153,6 +154,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, pycompat.string_types): + 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_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index d3b8d74f00e..28a852af5df 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -27,13 +27,13 @@ <field eval="25" name="priority"/> <field name="arch" type="xml"> <form string="Expenses" class="o_expense_form"> - <header> + <header invisible="context.get('expense_adding_line')"> <button name="submit_expenses" states="draft" string="Submit to Manager" type="object" class="oe_highlight o_expense_submit"/> <field name="state" widget="statusbar" statusbar_visible="draft,reported,done"/> <button name="view_sheet" type="object" string="View Report" class="oe_highlight" attrs="{'invisible': [('state', '=','draft')]}"/> </header> <sheet> - <div class="oe_button_box"> + <div class="oe_button_box" invisible="context.get('expense_adding_line')"> <button name="action_get_attachment_view" class="oe_stat_button" icon="fa-book" @@ -419,7 +419,7 @@ </group> <notebook> <page string="Expense Lines"> - <field name="expense_line_ids" nolabel="1" widget="many2many" domain="[('state', '=', 'draft')]" options="{'not_delete': True, 'reload_on_button': True, 'no_create': True}"> + <field name="expense_line_ids" nolabel="1" widget="many2many" domain="[('state', '=', 'draft')]" options="{'not_delete': True, 'reload_on_button': True, 'no_create': True}" context="{'expense_adding_line': True}"> <tree decoration-danger="is_refused"> <field name="date"/> <field name="name"/> diff --git a/addons/hr_holidays/wizard/hr_holidays_summary_department.py b/addons/hr_holidays/wizard/hr_holidays_summary_department.py index 82a398c3c4c..1d8cf868b46 100644 --- a/addons/hr_holidays/wizard/hr_holidays_summary_department.py +++ b/addons/hr_holidays/wizard/hr_holidays_summary_department.py @@ -31,4 +31,4 @@ class HolidaysSummaryDept(models.TransientModel): 'model': 'hr.department', 'form': data } - return self.env.ref('hr_holidays.action_report_holidayssummary').report_action(departments, data=datas) + return self.env.ref('hr_holidays.action_report_holidayssummary').with_context(from_transient_model=True).report_action(departments, data=datas) 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/security/hr_timesheet_security.xml b/addons/hr_timesheet/security/hr_timesheet_security.xml index 9a75f999347..332d4ec08e1 100644 --- a/addons/hr_timesheet/security/hr_timesheet_security.xml +++ b/addons/hr_timesheet/security/hr_timesheet_security.xml @@ -48,7 +48,7 @@ <field name="perm_create" eval="1"/> <field name="perm_write" eval="1"/> <field name="perm_unlink" eval="1"/> - <field name="perm_read" eval="1"/> + <field name="perm_read" eval="0"/> </record> <record id="timesheet_rule_portal" model="ir.rule"> @@ -68,4 +68,4 @@ </data> -</odoo>
\ No newline at end of file +</odoo> diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index 0c9fadc6a90..f5ad8f40076 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() # customer partner self.partner = self.env['res.partner'].create({ @@ -69,6 +69,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'] @@ -134,3 +137,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/iap/models/iap.py b/addons/iap/models/iap.py index 4f24f68af99..6f24205f8d4 100644 --- a/addons/iap/models/iap.py +++ b/addons/iap/models/iap.py @@ -72,6 +72,11 @@ def jsonrpc(url, method='call', params=None): #---------------------------------------------------------- # Helpers for proxy #---------------------------------------------------------- +class IapTransaction(object): + + def __init__(self): + self.credit = None + @contextlib.contextmanager def charge(env, key, account_token, credit, description=None, credit_template=None): """ @@ -107,7 +112,9 @@ def charge(env, key, account_token, credit, description=None, credit_template=No e.args = (json.dumps(arguments),) raise e try: - yield + transaction = IapTransaction() + transaction.credit = credit + yield transaction except Exception as e: params = { 'token': transaction_token, @@ -119,6 +126,7 @@ def charge(env, key, account_token, credit, description=None, credit_template=No params = { 'token': transaction_token, 'key': key, + 'credit_to_capture': transaction.credit, } r = jsonrpc(endpoint + '/iap/1/capture', params=params) # noqa 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..c80ccc983a5 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", @@ -76,7 +76,7 @@ class HrContract(models.Model): default=lambda self: self.get_attribute('eco_checks', 'default_value'), help="Yearly amount the employee receives in the form of eco vouchers.") - @api.depends('holidays', 'wage') + @api.depends('holidays', 'wage', 'final_yearly_costs') def _compute_wage_with_holidays(self): for contract in self: if contract.holidays > 20.0: @@ -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/l10n_fr_certification/models/account.py b/addons/l10n_fr_certification/models/account.py index ff7acf2678e..208b5238967 100644 --- a/addons/l10n_fr_certification/models/account.py +++ b/addons/l10n_fr_certification/models/account.py @@ -36,13 +36,13 @@ class AccountMove(models.Model): _('An error occured when computing the inalterability. Impossible to get the unique previous posted journal entry.')) #build and return the hash - return self._compute_hash(prev_move.l10n_fr_hash if prev_move else '') + return self._compute_hash(prev_move.l10n_fr_hash if prev_move else u'') def _compute_hash(self, previous_hash): """ Computes the hash of the browse_record given as self, based on the hash of the previous record in the company's securisation sequence given as parameter""" self.ensure_one() - hash_string = sha256(previous_hash + self.l10n_fr_string_to_hash) + hash_string = sha256((previous_hash + self.l10n_fr_string_to_hash).encode('utf-8')) return hash_string.hexdigest() def _compute_string_to_hash(self): @@ -63,7 +63,7 @@ class AccountMove(models.Model): values[k] = _getattrstring(line, field) #make the json serialization canonical # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00) - move.l10n_fr_string_to_hash = dumps(values, sort_keys=True, encoding="utf-8", + move.l10n_fr_string_to_hash = dumps(values, sort_keys=True, ensure_ascii=True, indent=None, separators=(',',':')) @@ -118,7 +118,7 @@ class AccountMove(models.Model): if not moves: raise UserError(_('There isn\'t any journal entry flagged for data inalterability yet for the company %s. This mechanism only runs for journal entries generated after the installation of the module France - Certification CGI 286 I-3 bis.') % self.env.user.company_id.name) - previous_hash = '' + previous_hash = u'' start_move_info = [] for move in moves: if move.l10n_fr_hash != move._compute_hash(previous_hash=previous_hash): diff --git a/addons/l10n_fr_pos_cert/models/pos.py b/addons/l10n_fr_pos_cert/models/pos.py index f3a35a5a878..d74641e3c88 100644 --- a/addons/l10n_fr_pos_cert/models/pos.py +++ b/addons/l10n_fr_pos_cert/models/pos.py @@ -79,13 +79,13 @@ class pos_order(models.Model): _('An error occured when computing the inalterability. Impossible to get the unique previous posted point of sale order.')) #build and return the hash - return self._compute_hash(prev_order.l10n_fr_hash if prev_order else '') + return self._compute_hash(prev_order.l10n_fr_hash if prev_order else u'') def _compute_hash(self, previous_hash): """ Computes the hash of the browse_record given as self, based on the hash of the previous record in the company's securisation sequence given as parameter""" self.ensure_one() - hash_string = sha256(previous_hash + self.l10n_fr_string_to_hash) + hash_string = sha256((previous_hash + self.l10n_fr_string_to_hash).encode('utf-8')) return hash_string.hexdigest() def _compute_string_to_hash(self): @@ -108,7 +108,7 @@ class pos_order(models.Model): values[k] = _getattrstring(line, field) #make the json serialization canonical # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00) - order.l10n_fr_string_to_hash = dumps(values, sort_keys=True, encoding="utf-8", + order.l10n_fr_string_to_hash = dumps(values, sort_keys=True, ensure_ascii=True, indent=None, separators=(',',':')) @@ -155,7 +155,7 @@ class pos_order(models.Model): if not orders: raise UserError(_('There isn\'t any order flagged for data inalterability yet for the company %s. This mechanism only runs for point of sale orders generated after the installation of the module France - Certification CGI 286 I-3 bis. - POS') % self.env.user.company_id.name) - previous_hash = '' + previous_hash = u'' start_order_info = [] for order in orders: if order.l10n_fr_hash != order._compute_hash(previous_hash=previous_hash): 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/models/res_users.py b/addons/mail/models/res_users.py index 3d6518010e6..86fc989d129 100644 --- a/addons/mail/models/res_users.py +++ b/addons/mail/models/res_users.py @@ -125,7 +125,7 @@ class Users(models.Model): @api.model def activity_user_count(self): - query = """SELECT m.name, count(*), act.res_model as model, + query = """SELECT m.id, count(*), act.res_model as model, CASE WHEN now()::date - act.date_deadline::date = 0 Then 'today' WHEN now()::date - act.date_deadline::date > 0 Then 'overdue' @@ -134,16 +134,18 @@ class Users(models.Model): FROM mail_activity AS act JOIN ir_model AS m ON act.res_model_id = m.id WHERE user_id = %s - GROUP BY m.name, states, act.res_model; + GROUP BY m.id, states, act.res_model; """ self.env.cr.execute(query, [self.env.uid]) activity_data = self.env.cr.dictfetchall() + model_ids = [a['id'] for a in activity_data] + model_names = {n[0]:n[1] for n in self.env['ir.model'].browse(model_ids).name_get()} user_activities = {} for activity in activity_data: if not user_activities.get(activity['model']): user_activities[activity['model']] = { - 'name': activity['name'], + 'name': model_names[activity['id']], 'model': activity['model'], 'icon': modules.module.get_module_icon(self.env[activity['model']]._original_module), 'total_count': 0, 'today_count': 0, 'overdue_count': 0, 'planned_count': 0, 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/systray.js b/addons/mail/static/src/js/systray.js index 91bbdbe8275..2006ae297ef 100644 --- a/addons/mail/static/src/js/systray.js +++ b/addons/mail/static/src/js/systray.js @@ -3,7 +3,6 @@ odoo.define('mail.systray', function (require) { var config = require('web.config'); var core = require('web.core'); -var framework = require('web.framework'); var session = require('web.session'); var SystrayMenu = require('web.SystrayMenu'); var Widget = require('web.Widget'); @@ -175,6 +174,9 @@ var ActivityMenu = Widget.extend({ return self._rpc({ model: 'res.users', method: 'activity_user_count', + kwargs: { + context: session.user_context, + }, }).then(function (data) { self.activities = data; self.activityCounter = _.reduce(data, function(total_count, p_data){ return total_count + p_data.total_count; }, 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/static/tests/client_action_mobile_tests.js b/addons/mail/static/tests/client_action_mobile_tests.js new file mode 100644 index 00000000000..e781ae34fd3 --- /dev/null +++ b/addons/mail/static/tests/client_action_mobile_tests.js @@ -0,0 +1,105 @@ +odoo.define('mail.client_action_mobile_tests', function (require) { +"use strict"; + +var session = require('web.session'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +var ChatAction = require('mail.chat_client_action'); +var chat_manager = require('mail.chat_manager'); + +QUnit.module('mail', {}, function () { + +QUnit.module('Discuss client action in mobile', { + beforeEach: function () { + this.data = { + 'mail.message': { + fields: {}, + }, + }; + this.createChatAction = function (params) { + var Parent = Widget.extend({ + do_push_state: function () {}, + }); + var parent = new Parent(); + testUtils.addMockEnvironment(parent, { + data: this.data, + archs: { + 'mail.message,false,search': '<search/>', + }, + session: params.session || {}, + intercepts: params.intercepts || {}, + }); + var chatAction = new ChatAction(parent, params); + chatAction.set_cp_bus(new Widget()); + chatAction.appendTo($('#qunit-fixture')); + + return chatAction; + }; + + }, +}); + +QUnit.test('mobile basic rendering', function (assert) { + // This is a very basic first test for the client action. However, with + // the chat_manager, it is hard to override RPCs (for instance, the + // /mail/client_action route is always called when the test suite is + // launched), and we must wait for this RPC to be done before starting to + // test the interface. This should be refactored to facilitate the testing. + assert.expect(9); + var done = assert.async(); + + var rpc = session.rpc; + + var chatAction = this.createChatAction({ + id: 1, + context: {}, + params: {}, + intercepts: { + get_session: function (ev) { + ev.data.callback({}); + }, + }, + session: { + rpc: function (route, args) { + if (args.method === 'message_fetch') { + return $.when([]); + } + return rpc.apply(this, arguments); + }, + }, + }); + + chat_manager.is_ready.then(function () { + // test basic rendering in mobile + assert.strictEqual(chatAction.$('.o_mail_chat_mobile_control_panel').length, 1, + "should have rendered a control panel"); + assert.strictEqual(chatAction.$('.o_mail_chat_content .o_mail_no_content').length, 1, + "should display the no content message"); + assert.strictEqual(chatAction.$('.o_mail_mobile_tabs').length, 1, + "should have rendered the tabs"); + assert.ok(chatAction.$('.o_mail_mobile_tab[data-type=channel_inbox]').hasClass('active'), + "should be in inbox tab"); + assert.strictEqual(chatAction.$('.o_mail_chat_mobile_inbox_buttons:visible').length, 1, + "inbox/starred buttons should be visible"); + assert.ok(chatAction.$('.o_mail_chat_mobile_inbox_buttons .o_channel_inbox_item[data-type=channel_inbox]').hasClass('btn-primary'), + "should be in inbox"); + + // move to DMs tab + chatAction.$('.o_mail_mobile_tab[data-type=dm]').click(); + assert.ok(chatAction.$('.o_mail_mobile_tab[data-type=dm]').hasClass('active'), + "should be in DMs tab"); + assert.strictEqual(chatAction.$('.o_mail_chat_content .o_mail_no_content').length, 0, + "should display the no content message"); + chatAction.$('.o_mail_chat_button_dm').click(); // click to add a channel + assert.strictEqual(chatAction.$('.o_mail_add_channel input:visible').length, 1, + "should display the input to add a channel"); + + chatAction.destroy(); + done(); + }); +}); + +}); + +}); diff --git a/addons/mail/static/tests/client_action_tests.js b/addons/mail/static/tests/client_action_tests.js deleted file mode 100644 index 378328037fd..00000000000 --- a/addons/mail/static/tests/client_action_tests.js +++ /dev/null @@ -1,88 +0,0 @@ -odoo.define('mail.client_action_test', function (require) { -"use strict"; - -var testUtils = require('web.test_utils'); -var Widget = require('web.Widget'); - -var ChatAction = require("mail.chat_client_action"); - -QUnit.module('mail', {}, function () { - -QUnit.module('Discuss client action', { - beforeEach: function () { - this.data = { - 'mail.message': { - fields: {}, - }, - }; - this.createChatAction = function (params) { - var parent = new Widget(); - testUtils.addMockEnvironment(parent, { - data: this.data, - archs: { - 'mail.message,false,search': '<search/>', - }, - config: {device: {isMobile: true}}, - }); - var chatAction = new ChatAction(parent, params); - chatAction.set_cp_bus(new Widget()); - chatAction.appendTo($('#qunit-fixture')); - - return chatAction; - }; - - }, -}); - -QUnit.skip('mobile basic rendering', function (assert) { - // Unfortunately, this test is skipped for now because there is no way to - // execute the whole test suite in mobile (it only works test by test), so - // as the client action include for mobile is rejected when we are not in - // mobile, it isn't possible to test it - // Moreover, RPCs done by the chat_manager (e.g. message_fetch) should be - // properly mocked. - assert.expect(11); - - var parent = new Widget(); - testUtils.addMockEnvironment(parent, { - data: this.data, - archs: { - 'mail.message,false,search': '<search/>', - }, - config: {device: {isMobile: true}}, - }); - - var params = { - id: 1, - context: {}, - params: {}, - }; - var chatAction = this.createChatAction(params); - - // test basic rendering in mobile - assert.equal(chatAction.$(".o_mail_chat_mobile_control_panel").length, 1, "Mobile control panel created"); - assert.equal(chatAction.$(".o_mail_mobile_tab").length, 4, "Four mobile tabs created"); - assert.equal(chatAction.$('.o_mail_chat_content').length, 1, "One default chat content pane created"); - assert.equal(chatAction.$(".o_mail_chat_tab_pane").length, 3, "Three mobile tab panes created"); - - // Inbox - assert.equal(chatAction.activeMobileTab, "channel_inbox", "'channel_inbox' is default active tab"); - assert.ok(chatAction.$(".o_channel_inbox_item:nth(0)").hasClass("btn-primary"), "Showing 'Inbox'"); - - // Starred - chatAction.$(".o_channel_inbox_item[data-type='channel_starred']").click(); - assert.ok(chatAction.$(".o_channel_inbox_item:nth(1)").hasClass("btn-primary"), "Clicked on 'Starred'"); - - assert.ok(chatAction.$(".o_mail_chat_content").is(":visible"), "Default main content pane visible"); - - chatAction.$(".o_mail_mobile_tab[data-type='dm']").click(); - assert.equal(chatAction.activeMobileTab, "dm", "After click on 'Conversation', is now active tab"); - - assert.ok(!chatAction.$(".o_mail_chat_content").is(":visible"), "none", "'Main' content pane is invisible"); - assert.ok(chatAction.$(".o_mail_chat_tab_pane:nth(0)").is(":visible"), "'Conversation' pane is visible"); - - chatAction.destroy(); -}); - -}); -}); diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index 8c4ab97754b..31bbcf1ee47 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -43,13 +43,18 @@ <template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite"> <xpath expr="//t[@t-set='head']" position="inside"> - <script type="text/javascript" src="/mail/static/tests/client_action_tests.js"></script> <script type="text/javascript" src="/mail/static/tests/chatter_tests.js"></script> <script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script> <script type="text/javascript" src="/mail/static/tests/chat_window_test.js"></script> </xpath> </template> + <template id="qunit_mobile_suite" name="mail_mobile_tests" inherit_id="web.qunit_mobile_suite"> + <xpath expr="//t[@t-set='head']" position="inside"> + <script type="text/javascript" src="/mail/static/tests/client_action_mobile_tests.js"></script> + </xpath> + </template> + <template id="message_origin_link"> <p>This <t t-esc="self._description.lower()"/> has been <span t-if="edit">modified</span><span t-if="not edit">created</span> from: <t t-foreach="origin" t-as="o"> 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/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index 05647bc366d..8dd42b61b67 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -585,14 +585,15 @@ class MassMailing(models.Model): self.ensure_one() mass_mailing_copy = self.copy() if mass_mailing_copy: + context = dict(self.env.context) + context['form_view_initial_mode'] = 'edit' return { 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'mail.mass_mailing', 'res_id': mass_mailing_copy.id, - 'context': self.env.context, - 'flags': {'initial_mode': 'edit'}, + 'context': context, } return False diff --git a/addons/mrp/models/mrp_unbuild.py b/addons/mrp/models/mrp_unbuild.py index 6c87ed8614e..0ff3db0a337 100644 --- a/addons/mrp/models/mrp_unbuild.py +++ b/addons/mrp/models/mrp_unbuild.py @@ -88,6 +88,10 @@ class MrpUnbuild(models.Model): if self.product_id.tracking != 'none' and not self.lot_id.id: raise UserError(_('Should have a lot for the finished product')) + if self.mo_id: + if self.mo_id.state != 'done': + raise UserError(_('You cannot unbuild a undone manufacturing order.')) + consume_move = self._generate_consume_moves()[0] produce_moves = self._generate_produce_moves() @@ -156,12 +160,31 @@ class MrpUnbuild(models.Model): def _generate_produce_moves(self): moves = self.env['stock.move'] for unbuild in self: - factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty - boms, lines = unbuild.bom_id.explode(unbuild.product_id, factor, picking_type=unbuild.bom_id.picking_type_id) - for line, line_data in lines: - moves += unbuild._generate_move_from_bom_line(line, line_data['qty']) + if unbuild.mo_id: + raw_moves = unbuild.mo_id.move_raw_ids.filtered(lambda move: move.state == 'done') + factor = unbuild.product_qty / unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.product_qty, unbuild.product_uom_id) + for raw_move in raw_moves: + moves += unbuild._generate_move_from_raw_moves(raw_move, factor) + else: + factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty + boms, lines = unbuild.bom_id.explode(unbuild.product_id, factor, picking_type=unbuild.bom_id.picking_type_id) + for line, line_data in lines: + moves += unbuild._generate_move_from_bom_line(line, line_data['qty']) return moves + def _generate_move_from_raw_moves(self, raw_move, factor): + return self.env['stock.move'].create({ + 'name': self.name, + 'date': self.create_date, + 'product_id': raw_move.product_id.id, + 'product_uom_qty': raw_move.product_uom_qty * factor, + 'product_uom': raw_move.product_uom.id, + 'procure_method': 'make_to_stock', + 'location_dest_id': self.location_dest_id.id, + 'location_id': raw_move.location_dest_id.id, + 'unbuild_id': self.id, + }) + def _generate_move_from_bom_line(self, bom_line, quantity): return self.env['stock.move'].create({ 'name': self.name, diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index f9436f66870..73f4c861727 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -195,6 +195,12 @@ class StockMove(models.Model): move_lines = self.move_line_ids.filtered(lambda ml: ml.lot_id == lot and not ml.lot_produced_id) else: move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_produced_id) + + # Sanity check: if the product is a serial number and `lot` is already present in the other + # consumed move lines, raise. + if lot and self.product_id.tracking == 'serial' and lot in self.move_line_ids.filtered(lambda ml: ml.qty_done).mapped('lot_id'): + raise UserError(_('You cannot consume the same serial number twice. Please correct the serial numbers encoded.')) + for ml in move_lines: rounding = ml.product_uom_id.rounding if float_compare(qty_to_add, 0, precision_rounding=rounding) <= 0: diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml index a0f14e9d6d2..d10e18819d4 100644 --- a/addons/mrp/views/mrp_unbuild_views.xml +++ b/addons/mrp/views/mrp_unbuild_views.xml @@ -122,7 +122,7 @@ </div> </group> <group> - <field name="mo_id" required="True"/> + <field name="mo_id" domain="[('state','=','done')]"/> <field name="location_id" groups="stock.group_stock_multi_locations"/> <field name="location_dest_id" groups="stock.group_stock_multi_locations"/> <field name="has_tracking" invisible="1"/> diff --git a/addons/mrp/wizard/mrp_product_produce.py b/addons/mrp/wizard/mrp_product_produce.py index 33ffb3d48f9..15c1684561e 100644 --- a/addons/mrp/wizard/mrp_product_produce.py +++ b/addons/mrp/wizard/mrp_product_produce.py @@ -125,6 +125,8 @@ class MrpProductProduce(models.TransientModel): raise UserError(_('You need to provide a lot for the finished product')) existing_move_line = produce_move.move_line_ids.filtered(lambda x: x.lot_id == self.lot_id) if existing_move_line: + if self.product_id.tracking == 'serial': + raise UserError(_('You cannot produce the same serial number twice.')) existing_move_line.product_uom_qty += self.product_qty existing_move_line.qty_done += self.product_qty else: @@ -181,27 +183,25 @@ class MrpProductProduceLine(models.TransientModel): @api.onchange('lot_id') def _onchange_lot_id(self): + """ When the user is encoding a produce line for a tracked product, we apply some logic to + help him. This onchange will automatically switch `qty_done` to 1.0. + """ res = {} if self.product_id.tracking == 'serial': self.qty_done = 1 return res - @api.constrains('lot_id') - def _check_lot_id(self): - for ml in self: - if ml.product_id.tracking == 'serial': - produce_lines_to_check = ml.product_produce_id.produce_line_ids.filtered(lambda l: l.product_id == ml.product_id and l.lot_id) - message = produce_lines_to_check._check_for_duplicated_serial_numbers() - if message: - raise ValidationError(message) - - def _check_for_duplicated_serial_numbers(self): - if self.mapped('lot_id'): - lots_map = [(ml.product_id.id, ml.lot_id.name) for ml in self] - recorded_serials_counter = Counter(lots_map) - for (product_id, lot_id), occurrences in recorded_serials_counter.items(): - if occurrences > 1 and lot_id is not False: - return _('You cannot consume the same serial number twice. Please correct the serial numbers encoded.') + @api.onchange('qty_done') + def _onchange_qty_done(self): + """ When the user is encoding a produce line for a tracked product, we apply some logic to + help him. This onchange will warn him if he set `qty_done` to a non-supported value. + """ + res = {} + if self.product_id.tracking == 'serial': + if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0: + message = _('You can only process 1.0 %s for products with unique serial number.') % self.product_id.uom_id.name + res['warning'] = {'title': _('Warning'), 'message': message} + return res @api.onchange('product_id') def _onchange_product_id(self): diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index 265309f8d34..4047ef7d30a 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -51,7 +51,10 @@ class WebsitePayment(http.Controller): 'amount': float(amount), 'payment_form': payment_form, } - return request.render('payment.pay', values) + # TODO: remove return when payment is implemented with refactored payments, i.e. use of + # payment.payment_tokens_list in the template. + # return request.render('payment.pay', values) + return request.render('website.404') @http.route(['/website_payment/transaction'], type='json', auth="public", website=True) def transaction(self, reference, amount, currency_id, acquirer_id): diff --git a/addons/payment_authorize/models/payment.py b/addons/payment_authorize/models/payment.py index a47040be6ad..c354f2e6aa9 100644 --- a/addons/payment_authorize/models/payment.py +++ b/addons/payment_authorize/models/payment.py @@ -127,7 +127,7 @@ class PaymentAcquirerAuthorize(models.Model): for field_name in mandatory_fields: if not data.get(field_name): error[field_name] = 'missing' - if data['cc_expiry'] and datetime.now().strftime('%y%M') > datetime.strptime(data['cc_expiry'], '%M / %y').strftime('%y%M'): + if data['cc_expiry'] and datetime.now().strftime('%y%m') > datetime.strptime(data['cc_expiry'], '%m / %y').strftime('%y%m'): return False return False if error else True diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index 7a6d1447f87..d616ddfe0be 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -92,7 +92,7 @@ odoo.define('payment_stripe.stripe', function(require) { return false; } - var access_token = $("input[name='access_token']").val() || $("input[name='token']").val(); + var access_token = $("input[name='access_token']").val() || $("input[name='token']").val() || ''; var so_id = $("input[name='return_url']").val().match(/quote\/([0-9]+)/) || undefined; if (so_id) { so_id = parseInt(so_id[1]); diff --git a/addons/phone_validation/tools/phone_validation.py b/addons/phone_validation/tools/phone_validation.py index 718b0518802..940c4300f26 100644 --- a/addons/phone_validation/tools/phone_validation.py +++ b/addons/phone_validation/tools/phone_validation.py @@ -43,11 +43,10 @@ try: else: _logger.warning(_('Unable to format %s:\n%s') % number, e) return number - if always_international and phone_nbr.country_code != country_phone_code: + if always_international or phone_nbr.country_code != country_phone_code: phone_fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL else: phone_fmt = phonenumbers.PhoneNumberFormat.NATIONAL - return phonenumbers.format_number(phone_nbr, phone_fmt) except ImportError: diff --git a/addons/phone_validation/views/res_company_view.xml b/addons/phone_validation/views/res_company_view.xml index d788e5aa7fe..c6401c02cb9 100644 --- a/addons/phone_validation/views/res_company_view.xml +++ b/addons/phone_validation/views/res_company_view.xml @@ -1,8 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- - The purpose of this view is to move the VAT field at the top of the - company form. That way, the user will fill it first ---> <odoo> <record id="res_company_view_form_phone_validation" model="ir.ui.view"> <field name="name">res.company.view.form.phone_validation</field> diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index 2af34a25e0b..60551814593 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -128,6 +128,15 @@ <filter string="Archived" name="inactive" domain="[('active','=',False)]"/> <field string="Attributes" name="attribute_line_ids" groups="product.group_product_variant"/> <field name="pricelist_id" widget="selection" context="{'pricelist': self}" filter_domain="[]" groups="product.group_sale_pricelist"/> <!-- Keep widget=selection on this field to pass numeric `self` value, which is not the case for regular m2o widgets! --> + <separator/> + <filter string="Late Activities" name="activities_overdue" + domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d'))]" + help="Show all records which has next action date is before today"/> + <filter string="Today Activities" name="activities_today" + domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + <filter string="Future Activities" name="activities_upcoming_all" + domain="[('activity_ids.date_deadline', '>', context_today().strftime('%Y-%m-%d')) + ]"/> </search> </field> </record> diff --git a/addons/product_expiry/models/production_lot.py b/addons/product_expiry/models/production_lot.py index 6be9a2865dd..45e465ea146 100644 --- a/addons/product_expiry/models/production_lot.py +++ b/addons/product_expiry/models/production_lot.py @@ -44,7 +44,7 @@ class StockProductionLot(models.Model): # Assign dates according to products data @api.model def create(self, vals): - dates = self._get_dates(vals.get('product_id')) + dates = self._get_dates(vals.get('product_id') or self.env.context.get('default_product_id')) for d in dates: if not vals.get(d): vals[d] = dates[d] diff --git a/addons/project/models/project.py b/addons/project/models/project.py index ca69c79115f..1a9f6db378a 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -512,19 +512,19 @@ class Task(models.Model): partner_id = fields.Many2one('res.partner', string='Customer', default=_get_default_partner) - manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id', readonly=True) + manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id', readonly=True, related_sudo=False) company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env['res.company']._company_default_get()) color = fields.Integer(string='Color Index') - user_email = fields.Char(related='user_id.email', string='User Email', readonly=True) + user_email = fields.Char(related='user_id.email', string='User Email', readonly=True, related_sudo=False) attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment_ids', string="Main Attachments", help="Attachment that don't come from message.") # In the domain of displayed_image_id, we couln't use attachment_ids because a one2many is represented as a list of commands so we used res_model & res_id displayed_image_id = fields.Many2one('ir.attachment', domain="[('res_model', '=', 'project.task'), ('res_id', '=', id), ('mimetype', 'ilike', 'image')]", string='Cover Image') - legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True) - legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True) - legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True) + legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False) + legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False) + legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False) parent_id = fields.Many2one('project.task', string='Parent Task') child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks") subtask_project_id = fields.Many2one('project.project', related="project_id.subtask_project_id", string='Sub-task Project', readonly=True) diff --git a/addons/project_timesheet_holidays/models/hr_holidays.py b/addons/project_timesheet_holidays/models/hr_holidays.py index 2d245e834d9..633a0b9d6c3 100644 --- a/addons/project_timesheet_holidays/models/hr_holidays.py +++ b/addons/project_timesheet_holidays/models/hr_holidays.py @@ -60,7 +60,7 @@ class Holidays(models.Model): work_hours_data = [item for item in holiday.employee_id.iter_work_hours_count(fields.Datetime.from_string(holiday.date_from), fields.Datetime.from_string(holiday.date_to))] for index, (day_date, work_hours_count) in enumerate(work_hours_data): self.env['account.analytic.line'].create({ - 'name': "%s (%s/%s)" % (holiday.name, index + 1, len(work_hours_data)), + 'name': "%s (%s/%s)" % (holiday.name or '', index + 1, len(work_hours_data)), 'project_id': holiday_project.id, 'task_id': holiday_task.id, 'account_id': holiday_project.analytic_account_id.id, @@ -77,6 +77,7 @@ class Holidays(models.Model): def action_refuse(self): """ Remove the timesheets linked to the refused holidays """ result = super(Holidays, self).action_refuse() - self.mapped('timesheet_ids').write({'holiday_id': False}) - self.mapped('timesheet_ids').unlink() + timesheets = self.sudo().mapped('timesheet_ids') + timesheets.write({'holiday_id': False}) + timesheets.unlink() return result diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index 4cab61623f0..02d02844a82 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -98,7 +98,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/models/purchase.py b/addons/purchase/models/purchase.py index a8ae709e023..1a1e65d9611 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -776,10 +776,10 @@ class PurchaseOrderLine(models.Model): self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id result['domain'] = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]} - product_lang = self.product_id.with_context({ - 'lang': self.partner_id.lang, - 'partner_id': self.partner_id.id, - }) + product_lang = self.product_id.with_context( + lang=self.partner_id.lang, + partner_id=self.partner_id.id, + ) self.name = product_lang.display_name if product_lang.description_purchase: self.name += '\n' + product_lang.description_purchase 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/sale/models/analytic.py b/addons/sale/models/analytic.py index d8bbb2da419..73b9f95d93a 100644 --- a/addons/sale/models/analytic.py +++ b/addons/sale/models/analytic.py @@ -128,11 +128,13 @@ class AccountAnalyticLine(models.Model): raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name) price = analytic_line._sale_get_invoice_price(sale_order) - so_line = self.env['sale.order.line'].search([ - ('order_id', '=', sale_order.id), - ('price_unit', '=', price), - ('product_id', '=', self.product_id.id) - ], limit=1) + so_line = None + if analytic_line.product_id.expense_policy == 'sales_price' and analytic_line.product_id.invoice_policy == 'delivery': + so_line = self.env['sale.order.line'].search([ + ('order_id', '=', sale_order.id), + ('price_unit', '=', price), + ('product_id', '=', self.product_id.id) + ], limit=1) if not so_line: # generate a new SO line @@ -142,7 +144,7 @@ class AccountAnalyticLine(models.Model): so_line = self.env['sale.order.line'].create(so_line_values) so_line._compute_tax_id() else: - so_line.write({'qty_delivered': analytic_line.unit_amount}) + so_line.write({'qty_delivered': so_line.qty_delivered + analytic_line.unit_amount}) if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line) analytic_line.with_context(sale_analytic_norecompute=True).write({'so_line': so_line.id}) diff --git a/addons/sale/report/sale_report_templates.xml b/addons/sale/report/sale_report_templates.xml index 350756da6ee..3352f75b048 100644 --- a/addons/sale/report/sale_report_templates.xml +++ b/addons/sale/report/sale_report_templates.xml @@ -150,7 +150,10 @@ <t t-foreach="doc._get_tax_amount_by_group()" t-as="amount_by_group"> <tr style="border-bottom:1px solid #dddddd;"> <t t-if="amount_by_group[3] == 1 and doc.amount_untaxed == amount_by_group[2]"> - <td><span t-esc="amount_by_group[0]"/></td> + <td> + <span t-esc="amount_by_group[0]"/> + <span>&nbsp;<span>on</span>&nbsp;<t t-esc="amount_by_group[2]" t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/></span> + </td> <td class="text-right"> <span t-esc="amount_by_group[1]" t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/> @@ -158,7 +161,7 @@ </t> <t t-else =""> <td> - <span t-esc="amount_by_group[0]"/><span>&nbsp;<span>on</span>&nbsp;<t t-esc="amount_by_group[2]" t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/></span> + <span t-esc="amount_by_group[0]"/> </td> <td class="text-right"> <span t-esc="amount_by_group[1]" diff --git a/addons/sms/wizard/send_sms.py b/addons/sms/wizard/send_sms.py index 1856b2f17dd..a637b43133b 100644 --- a/addons/sms/wizard/send_sms.py +++ b/addons/sms/wizard/send_sms.py @@ -9,6 +9,16 @@ from odoo.addons.iap.models import iap _logger = logging.getLogger(__name__) +try: + import phonenumbers + _sms_phonenumbers_lib_imported = True + +except ImportError: + _sms_phonenumbers_lib_imported = False + _logger.warning( + "The `phonenumbers` Python module is not installed. Try: pip install phonenumbers." + ) + class SendSMS(models.TransientModel): _name = 'sms.send_sms' @@ -16,6 +26,27 @@ class SendSMS(models.TransientModel): recipients = fields.Char('Recipients', required=True) message = fields.Text('Message', required=True) + def _phone_get_country(self, partner): + if 'country_id' in partner: + return partner.country_id + return self.env.user.company_id.country_id + + def _sms_sanitization(self, partner, field_name): + number = partner[field_name] + if _sms_phonenumbers_lib_imported: + country = self._phone_get_country(partner) + country_code = country.code if country else None + try: + phone_nbr = phonenumbers.parse(number, region=country_code, keep_raw_input=True) + except phonenumbers.phonenumberutil.NumberParseException: + return number + if not phonenumbers.is_possible_number(phone_nbr) or not phonenumbers.is_valid_number(phone_nbr): + return number + phone_fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL + return phonenumbers.format_number(phone_nbr, phone_fmt).replace(' ', '') + else: + return number + def _get_records(self, model): if self.env.context.get('active_domain'): records = model.search(self.env.context.get('active_domain')) @@ -38,7 +69,8 @@ class SendSMS(models.TransientModel): phone_numbers = [] no_phone_partners = [] for partner in partners: - number = partner.mobile + default_field_name = 'mobile' if partner.mobile else 'phone' + number = self._sms_sanitization(partner, self.env.context('field_name') or default_field_name) if number: phone_numbers.append(number) else: diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py index 21e41caa10f..05e4bb2b60d 100644 --- a/addons/stock/models/stock_location.py +++ b/addons/stock/models/stock_location.py @@ -183,10 +183,6 @@ class Route(models.Model): 'domain': [('route_ids', 'in', self.ids)], } - @api.onchange('warehouse_selectable') - def _onchange_warehouse_selectable(self): - if not self.warehouse_selectable: - self.warehouse_ids = [(5, 0, 0)] class PushedFlow(models.Model): _name = "stock.location.path" diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 05b9c9af85f..2ac3bd05415 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -1000,8 +1000,16 @@ class StockMove(models.Model): moves = self.filtered(lambda x: x.state not in ('done', 'cancel')) moves_todo = self.env['stock.move'] - # Create extra moves where necessary + + # Cancel moves where necessary ; we should do it before creating the extra moves because + # this operation could trigger a merge of moves. for move in moves: + if move.quantity_done <= 0: + if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0: + move._action_cancel() + + # Create extra moves where necessary + for move in moves.filtered(lambda m: m.state != 'cancel'): # Here, the `quantity_done` was already rounded to the product UOM by the `do_produce` wizard. However, # it is possible that the user changed the value before posting the inventory by a value that should be # rounded according to the move's UOM. In this specific case, we chose to round up the value, because it @@ -1010,10 +1018,6 @@ class StockMove(models.Model): # FIXME: move rounding to move line # rounding = move.product_uom.rounding # move.quantity_done = float_round(move.quantity_done, precision_rounding=rounding, rounding_method ='UP') - if move.quantity_done <= 0: - if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0: - move._action_cancel() - continue moves_todo |= move moves_todo |= move._create_extra_move() # Split moves where necessary and move quants diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 4582c907575..cb9244e4511 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -109,36 +109,49 @@ class StockMoveLine(models.Model): @api.onchange('lot_name', 'lot_id') def onchange_serial_number(self): + """ When the user is encoding a move line for a tracked product, we apply some logic to + help him. This includes: + - automatically switch `qty_done` to 1.0 + - warn if he has already encoded `lot_name` in another move line + """ res = {} if self.product_id.tracking == 'serial': - self.qty_done = 1 - move_lines_to_check = self._get_similar_move_lines() - self - message = move_lines_to_check._check_for_duplicated_serial_numbers() + if not self.qty_done: + self.qty_done = 1 + + message = None + if self.lot_name or self.lot_id: + move_lines_to_check = self._get_similar_move_lines() - self + if self.lot_name: + counter = Counter(move_lines_to_check.mapped('lot_name')) + if counter.get(self.lot_name) and counter[self.lot_name] > 1: + message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.') + elif self.lot_id: + counter = Counter(move_lines_to_check.mapped('lot_id.id')) + if counter.get(self.lot_id.id) and counter[self.lot_id.id] > 1: + message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.') + if message: res['warning'] = {'title': _('Warning'), 'message': message} return res + @api.onchange('qty_done') + def _onchange_qty_done(self): + """ When the user is encoding a move line for a tracked product, we apply some logic to + help him. This onchange will warn him if he set `qty_done` to a non-supported value. + """ + res = {} + if self.product_id.tracking == 'serial': + if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0: + message = _('You can only process 1.0 %s for products with unique serial number.') % self.product_id.uom_id.name + res['warning'] = {'title': _('Warning'), 'message': message} + return res + @api.constrains('qty_done') def _check_positive_qty_done(self): if any([ml.qty_done < 0 for ml in self]): raise ValidationError(_('You can not enter negative quantities!')) - @api.constrains('lot_id', 'lot_name', 'qty_done') - def _check_unique_serial_number(self): - for ml in self.filtered(lambda ml: ml.move_id.product_id.tracking == 'serial' and (ml.lot_id or ml.lot_name)): - move_lines_to_check = ml._get_similar_move_lines() - message = move_lines_to_check._check_for_duplicated_serial_numbers() - if message: - raise ValidationError(message) - if float_compare(ml.qty_done, 1.0, precision_rounding=ml.move_id.product_id.uom_id.rounding) == 1: - raise UserError(_( - 'You can only process 1.0 %s for products with unique serial number.') % ml.product_id.uom_id.name) - if ml.lot_name: - already_exist = self.env['stock.production.lot'].search( - [('name', '=', ml.lot_name), ('product_id', '=', ml.product_id.id)]) - if already_exist: - return _('You have already assigned this serial number to this product. Please correct the serial numbers encoded.') - def _get_similar_move_lines(self): self.ensure_one() lines = self.env['stock.move.line'] @@ -147,25 +160,6 @@ class StockMoveLine(models.Model): lines |= picking_id.move_line_ids.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name)) return lines - def _check_for_duplicated_serial_numbers(self): - """ This method is used in _check_unique_serial_number and in onchange_serial_number to check that a same serial number is not used twice amongst the recordset passed. - - :return: an error message directed to the user if needed else False - """ - if self.mapped('lot_id'): - lots_map = [(ml.product_id.id, ml.lot_id.name) for ml in self] - recorded_serials_counter = Counter(lots_map) - for (product_id, lot_id), occurrences in recorded_serials_counter.items(): - if occurrences > 1 and lot_id is not False: - return _('You cannot use the same serial number twice. Please correct the serial numbers encoded.') - elif self.mapped('lot_name'): - lots_map = [(ml.product_id.id, ml.lot_name) for ml in self] - recorded_serials_counter = Counter(lots_map) - for (product_id, lot_id), occurrences in recorded_serials_counter.items(): - if occurrences > 1 and lot_id is not False: - return _('You cannot use the same serial number twice. Please correct the serial numbers encoded.') - return False - @api.model def create(self, vals): vals['ordered_qty'] = vals.get('product_uom_qty') diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 82b74d5f32e..7da23c0b44d 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -246,7 +246,7 @@ </group> <group> <field name="scheduled_date"/> - <field name="origin" placeholder="e.g. PO0032" readonly="1"/> + <field name="origin" placeholder="e.g. PO0032"/> <field name="owner_id" groups="stock.group_tracking_owner"/> <div groups="stock.group_tracking_owner" colspan="2" col="2"> <button name="action_assign_owner" string="Assign Owner" type="object" attrs="{'invisible': ['|',('move_line_exist', '=', False),('state', 'not in', ('draft','assigned','confirmed'))]}" 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/stock_picking_batch/wizard/stock_picking_to_batch_views.xml b/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml index 9f994be520b..dc45cf58269 100644 --- a/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml +++ b/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml @@ -8,7 +8,7 @@ <field name="arch" type="xml"> <form string="Add pickings to batch"> <separator string="Select a batch"/> - <field name="batch_id" class="oe_inline"/> + <field name="batch_id" class="oe_inline" options="{'no_create_edit': True, 'no_open': True}"/> <footer> <button name="attach_pickings" type="object" string="Add to Batch" class="btn-primary"/> <button string="Cancel" class="btn-default" special="cancel"/> diff --git a/addons/test_mail/tests/test_mail_message.py b/addons/test_mail/tests/test_mail_message.py index 1fe8924dda6..34956afe545 100644 --- a/addons/test_mail/tests/test_mail_message.py +++ b/addons/test_mail/tests/test_mail_message.py @@ -274,3 +274,71 @@ class TestMessageAccess(common.BaseFunctionalTest, common.MockEmails): def test_mail_message_access_create_reply(self): self.message.write({'partner_ids': [(4, self.user_employee.partner_id.id)]}) self.env['mail.message'].sudo(self.user_employee).create({'model': 'mail.channel', 'res_id': self.group_private.id, 'body': 'Test', 'parent_id': self.message.id}) + + + @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")
\ No newline at end of file diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index 8bb9afca065..2534f8ae90a 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -45,11 +45,6 @@ var AbstractField = Widget.extend({ }, /** - * fields can extend the context, e.g. binary fields add {bin_size: true} - */ - context: null, - - /** * An object representing fields to be fetched by the model eventhough not present in the view * This object contains "field name" as key and an object as value. * That value object must contain the key "type" diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index f3e66cee421..94facd80ceb 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -1094,7 +1094,6 @@ var AbstractFieldBinary = AbstractField.extend({ }, 'click .o_clear_file_button': 'on_clear', }), - context: { bin_size: true }, init: function (parent, name, record) { this._super.apply(this, arguments); this.fields = record.fields; diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js index 5ac180b1de1..5a11f1b0ba8 100644 --- a/addons/web/static/src/js/fields/field_utils.js +++ b/addons/web/static/src/js/fields/field_utils.js @@ -30,7 +30,7 @@ var _t = core._t; /** * Convert binary to bin_size * - * @param {string} value base64 representation of the binary (might be already a bin_size!) + * @param {string} [value] base64 representation of the binary (might be already a bin_size!) * @param {Object} [field] * a description of the field (note: this parameter is ignored) * @param {Object} [options] additional options (note: this parameter is ignored) @@ -38,6 +38,9 @@ var _t = core._t; * @returns {string} bin_size (which is human-readable) */ function formatBinary(value, field, options) { + if (!value) { + return ''; + } return utils.binaryToBinsize(value); } diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js index 948d3a1c73a..72ec816efdd 100644 --- a/addons/web/static/src/js/services/data_manager.js +++ b/addons/web/static/src/js/services/data_manager.js @@ -387,10 +387,6 @@ return core.Class.extend({ attrs.fieldDependencies = attrs.Widget.prototype.fieldDependencies; } - if (attrs.Widget.prototype.context) { - attrs.context = attrs.Widget.prototype.context; - } - return attrs; }, /** 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 a6979776c99..5296c6db7d5 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1854,7 +1854,7 @@ var BasicModel = AbstractModel.extend({ model: record.model, method: 'read', args: [[record.res_id], fieldNames], - context: record.getContext(), + context: _.extend({}, record.getContext(), {bin_size: true}), }) .then(function (result) { if (result.length === 0) { @@ -2374,7 +2374,7 @@ var BasicModel = AbstractModel.extend({ }); record.data[fieldName] = list.id; if (!fieldInfo.__no_fetch) { - var def = self._readUngroupedList(list).then(function (list) { + var def = self._readUngroupedList(list).then(function () { return $.when( self._fetchX2ManysBatched(list), self._fetchReferencesBatched(list) @@ -2403,6 +2403,7 @@ var BasicModel = AbstractModel.extend({ var fields = view ? view.fields : fieldInfo.relatedFields; var viewType = view ? view.type : fieldInfo.viewType; list = this._applyX2ManyOperations(list); + this._sortList(list); var x2mRecords = []; // step 1: collect ids @@ -2640,7 +2641,7 @@ var BasicModel = AbstractModel.extend({ var relRecordAdded = []; var relRecordUpdated = []; _.each(list._changes, function (change) { - if (change.operation === 'ADD') { + if (change.operation === 'ADD' && change.id) { relRecordAdded.push(self.localData[change.id]); } else if (change.operation === 'UPDATE' && !self.isNew(change.id)) { // ignore new records that would have been updated @@ -2699,6 +2700,10 @@ var BasicModel = AbstractModel.extend({ } else if (_.contains(addedIds, list.res_ids[i])) { // this is a new id (maybe existing in DB, but new in JS) relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); + if (!relRecord) { + commands[fieldName].push(x2ManyCommands.link_to(list.res_ids[i])); + continue; + } changes = this._generateChanges(relRecord, options); if (changes.id) { // the subrecord already exists in db @@ -3092,23 +3097,13 @@ var BasicModel = AbstractModel.extend({ id: {type: 'integer'}, }, params.fields); - // datapoint context is extended by fields widgets - var context = _.extend({}, params.context); - if (params.fieldsInfo && params.viewType) { - _.each(params.fieldsInfo[params.viewType], function (field) { - if (field.context) { - _.extend(context, field.context); - } - }); - } - var dataPoint = { _cache: type === 'list' ? {} : undefined, _changes: null, _domains: {}, _rawChanges: {}, aggregateValues: params.aggregateValues || {}, - context: context, + context: params.context, count: params.count || res_ids.length, data: data, domain: params.domain || [], 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 0e50e37194f..d3553d8db12 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column.js +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -167,6 +167,13 @@ var KanbanColumn = Widget.extend({ * Adds the quick create record to the top of the column. */ addQuickCreate: function () { + if (this.folded) { + // first open the column, and then add the quick create + this.trigger_up('column_toggle_fold', { + openQuickCreate: true, + }); + return; + } if (this.quickCreateWidget) { return; } 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..26f379d4a39 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; @@ -388,13 +389,18 @@ var KanbanController = BasicController.extend({ /** * @private * @param {OdooEvent} event + * @param {boolean} [event.data.openQuickCreate=false] if true, opens the + * QuickCreate in the toggled column (it assumes that we are opening it) */ _onToggleColumn: function (event) { var self = this; var column = event.target; this.model.toggleGroup(column.db_id).then(function (db_id) { var data = self.model.get(db_id); - self.renderer.updateColumn(db_id, data); + var options = { + openQuickCreate: !!event.data.openQuickCreate, + }; + self.renderer.updateColumn(db_id, data, options); self._updateEnv(); }); }, diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index aba59555e93..df6172f0d8b 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -22,6 +22,7 @@ var KanbanModel = BasicModel.extend({ * @returns {Deferred<string>} resolves to the local id of the new record */ addRecordToGroup: function (groupID, resId) { + var self = this; var group = this.localData[groupID]; var new_record = this._makeDataPoint({ res_id: resId, @@ -31,15 +32,16 @@ var KanbanModel = BasicModel.extend({ viewType: group.viewType, parentID: groupID, }); - group.data.unshift(new_record.id); - group.res_ids.unshift(resId); - group.count++; - - // update the res_ids and count of the parent - this.localData[group.parentID].count++; - this._updateParentResIDs(group); return this._fetchRecord(new_record).then(function (result) { + group.data.unshift(new_record.id); + group.res_ids.unshift(resId); + group.count++; + + // update the res_ids and count of the parent + self.localData[group.parentID].count++; + self._updateParentResIDs(group); + return result.id; }); }, diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index ef5139511fe..ff9a9c63621 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -6916,6 +6916,7 @@ QUnit.module('relational_fields', { if (args.method === 'read' && args.model === 'partner') { assert.deepEqual(args.kwargs.context, { active_field: 2, + bin_size: true, someKey: 'some value', }, "sent context should be correct"); } @@ -7639,6 +7640,74 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many with multiple pages and sequence field, part2', function (assert) { + assert.expect(1); + + this.data.partner.records[0].turtles = [3, 2, 1]; + this.data.partner.onchanges.turtles = function () {}; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="turtles">' + + '<tree limit="2">' + + '<field name="turtle_int" widget="handle"/>' + + '<field name="turtle_foo"/>' + + '<field name="partner_ids" invisible="1"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return $.when({value: { turtles: [ + [5], + [1, 1, {turtle_foo: "from onchange id2", partner_ids: [[5]]}], + [1, 3, {turtle_foo: "from onchange id3", partner_ids: [[5]]}], + ]}}); + } + return this._super(route, args); + }, + viewOptions: { + mode: 'edit', + }, + }); + form.$('span[name="delete"]').first().click(); + assert.strictEqual(form.$('.o_data_row').text(), 'from onchange id2from onchange id3', + 'onchange has been properly applied'); + form.destroy(); + }); + + QUnit.test('new record, with one2many with more default values than limit', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="turtles">' + + '<tree limit="2">' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</form>', + context: { default_turtles: [1,2,3]}, + viewOptions: { + mode: 'edit', + }, + }); + assert.strictEqual(form.$('.o_data_row').text(), 'yopblip', + 'data has been properly loaded'); + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('.o_data_row').text(), 'yopblip', + 'data has been properly saved'); + form.destroy(); + }); + QUnit.module('FieldMany2Many'); QUnit.test('many2many kanban: edition', function (assert) { diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 72061f326e0..492dfd4224c 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -74,6 +74,7 @@ QUnit.module('Views', { mockRPC: function (route, args) { assert.deepEqual(args.kwargs.context, { active_field: 2, + bin_size: true, someKey: 'some value', }, "should have sent the correct context"); return this._super.apply(this, arguments); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 9f2a9b68698..9e7c89628c4 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -199,7 +199,7 @@ QUnit.module('Views', { }); QUnit.test('only necessary fields are fetched with correct context', function (assert) { - assert.expect(1); + assert.expect(2); var form = createView({ View: FormView, @@ -214,6 +214,8 @@ QUnit.module('Views', { // field, not sure why. Maybe this test should be modified. assert.deepEqual(args.args[1], ["foo", "display_name"], "should only fetch requested fields"); + assert.deepEqual(args.kwargs.context, {bin_size: true}, + "bin_size should always be in the context"); return this._super(route, args); } }); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 9e6d6e9e30f..250bb8ef64f 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -33,9 +33,10 @@ QUnit.module('Views', { state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]}, date: {string: "Date Field", type: 'date'}, datetime: {string: "Datetime Field", type: 'datetime'}, + image: {string: "Image", type: "binary"}, }, records: [ - {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: []}, + {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: [], 'image': 'R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA=='}, {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6]}, {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7]}, {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: []}, @@ -440,6 +441,50 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('quick create when first column is folded', function (assert) { + assert.expect(6); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + }); + + assert.notOk(kanban.$('.o_kanban_group:first').hasClass('o_column_folded'), + "first column should not be folded"); + + // fold the first column + kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').click(); + + assert.ok(kanban.$('.o_kanban_group:first').hasClass('o_column_folded'), + "first column should be folded"); + + // click on 'Create' to open the quick create in the first column + kanban.$buttons.find('.o-kanban-button-new').click(); + + assert.notOk(kanban.$('.o_kanban_group:first').hasClass('o_column_folded'), + "first column should no longer be folded"); + var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create'); + assert.strictEqual($quickCreate.length, 1, + "should have added a quick create element in first column"); + + // fold again the first column + kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').click(); + + assert.ok(kanban.$('.o_kanban_group:first').hasClass('o_column_folded'), + "first column should be folded"); + assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 0, + "there should be no more quick create"); + + kanban.destroy(); + }); + QUnit.test('quick create and edit in grouped mode', function (assert) { assert.expect(6); @@ -545,6 +590,80 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('quick create several records on slow network', function (assert) { + assert.expect(6); + + var self = this; + var def; + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<field name="bar"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + groupBy: ['bar'], + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create') { + return result.then(function (result) { + // simulate a dynamic default value for foo based on display_name, + // so that we can check that the record has been fetched before + // being displayed + var newRec = _.findWhere(self.data.partner.records, {id: result[0]}); + newRec.foo = newRec.display_name; + return result; + }); + } + if (args.method === 'read') { + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1, + "first column should contain one record"); + + // click to add a record, and add two in a row (first one will be delayed) + kanban.$('.o_kanban_header .o_kanban_quick_add i').first().click(); + + assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 1, + "the quick create should be open"); + + def = $.Deferred(); + kanban.$('.o_kanban_quick_create input') + .val('new partner 1') + .trigger($.Event('keypress', { + which: $.ui.keyCode.ENTER, + keyCode: $.ui.keyCode.ENTER, + })); + var firstDef = def; + def = null; + kanban.$('.o_kanban_quick_create input') + .val('new partner 2') + .trigger($.Event('keypress', { + which: $.ui.keyCode.ENTER, + keyCode: $.ui.keyCode.ENTER, + })); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2, + "first column should now contain two records"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record:first').text(), + 'new partner 2', "new record should have been fetched"); + + firstDef.resolve(); + + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 3, + "first column should now contain three records"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record:first').text(), + 'new partner 1', "new record should have been fetched"); + + kanban.destroy(); + }); + QUnit.test('quick create fail in grouped', function (assert) { assert.expect(7); @@ -2339,6 +2458,32 @@ QUnit.module('Views', { "quick create should have been added in the first column"); } }); + + QUnit.test('test displaying image', function (assert) { + assert.expect(2); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="id"/>' + + '<field name="image"/>' + + '<templates><t t-name="kanban-box"><div>' + + '<img t-att-src="kanban_image(\'partner\', \'image\', record.id.raw_value)"/>' + + '</div></t></templates>' + + '</kanban>', + }); + + var imageOnRecord = kanban.$('img[src*="/web/image"][src*="&id=1"]'); + assert.strictEqual(imageOnRecord.length, 1, "partner with image display image"); + + var placeholders = kanban.$('img[src$="/web/static/src/img/placeholder.png"]'); + assert.strictEqual(placeholders.length, this.data.partner.records.length - 1, + "partner with image should displaiy"); + + kanban.destroy(); + }); }); }); diff --git a/addons/web/tests/test_js.py b/addons/web/tests/test_js.py index 49f5b8d6dce..ab0eefb360b 100644 --- a/addons/web/tests/test_js.py +++ b/addons/web/tests/test_js.py @@ -13,7 +13,7 @@ class WebSuite(odoo.tests.HttpCase): def test_01_js(self): # webclient desktop test suite - self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=300) + self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=360) def test_02_js(self): # webclient mobile test suite diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js index 6da2ea72ea1..d2bcd29e5ce 100644 --- a/addons/web_editor/static/src/js/editor/rte.js +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -374,10 +374,11 @@ var RTEWidget = Widget.extend({ * @param {boolean} internal_history */ historyRecordUndo: function ($target, event, internal_history) { + $target = $($target); var rng = range.create(); var $editable = $(rng && rng.sc).closest('.o_editable'); if (!rng || !$editable.length) { - $editable = $($target).closest('.o_editable'); + $editable = $target.closest('.o_editable'); rng = range.create($target.closest('*')[0],0); } else { rng = $editable.data('range') || rng; diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js index d6f9805599e..8ce2ed692da 100644 --- a/addons/web_editor/static/src/js/editor/summernote.js +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -846,37 +846,67 @@ range.WrappedRange.prototype.reRange = function (keep_end, isNotBreakable) { return new range.WrappedRange(sc, so, ec, eo); }; -// isOnImg: judge whether range is an image node or not +/** + * Returns the image the range is in or matches (if any, false otherwise). + * + * @todo this implementation may not cover all corner cases but should do the + * trick for all reproductible ones + * @returns {DOMElement|boolean} + */ range.WrappedRange.prototype.isOnImg = function () { + // If not a selection but a cursor position, just check if a point's + // ancestor is an image or not + if (this.sc === this.ec && this.so === this.eo) { + return dom.ancestor(this.sc, dom.isImg); + } + + var startPoint = {node: this.sc, offset: this.so}; + var endPoint = {node: this.ec, offset: this.eo}; + var nb = 0; var image; - var startPoint = {node: this.sc.childNodes.length && this.sc.childNodes[this.so] || this.sc}; - startPoint.offset = startPoint.node === this.sc ? this.so : 0; - var endPoint = {node: this.ec.childNodes.length && this.ec.childNodes[this.eo] || this.ec}; - endPoint.offset = endPoint.node === this.ec ? this.eo : 0; - - if (dom.isImg(startPoint.node)) { - nb ++; - image = startPoint.node; - } + var textNode; dom.walkPoint(startPoint, endPoint, function (point) { - if (!dom.isText(endPoint.node) && point.node === endPoint.node && point.offset === endPoint.offset) { + // If the element has children (not a text node and not empty node), + // the element cannot be considered as selected (these children will + // be processed to determine that) + if (dom.hasChildren(point.node)) { return; } - var node = point.node.childNodes.length && point.node.childNodes[point.offset] || point.node; - var offset = node === point.node ? point.offset : 0; - var isImg = dom.ancestor(node, dom.isImg); - if (!isImg && ((!dom.isBR(node) && !dom.isText(node)) || (offset && node.textContent.length !== offset && node.textContent.match(/\S|\u00A0/)))) { + + // Check if an ancestor of the current point is an image + var pointImg = dom.ancestor(point.node, dom.isImg); + var isText = dom.isText(point.node); + + // Check if a visible element is selected, i.e. + // - If an ancestor of the current is an image we did not see yet + // - If the point is not in a br or a text (so a node with no children) + // - If the point is in a non empty text node we already saw + if (pointImg ? + (image !== pointImg) : + ((!dom.isBR(point.node) && !isText) || (textNode === point.node && point.node.textContent.match(/\S|\u00A0/)))) { nb++; } - if (isImg && image !== isImg) { - image = isImg; - nb ++; + + // If an ancestor of the current point is an image, then save it as the + // image we are looking for + if (pointImg) { + image = pointImg; + } + // If the current point is a text node save it as the last text node + // seen (if we see it again, this might mean it is selected) + if (isText) { + textNode = point.node; } }); + return nb === 1 && image; }; range.WrappedRange.prototype.deleteContents = function (towrite) { + if (this.sc === this.ec && this.so === this.eo) { + return this; + } + var prevBP = dom.removeBetween(this.sc, this.so, this.ec, this.eo, towrite); $(dom.node(prevBP.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler 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/static/src/js/website_sale_validate.js b/addons/website_sale/static/src/js/website_sale_validate.js index 6630044a6a4..a75b24fd0ad 100644 --- a/addons/website_sale/static/src/js/website_sale_validate.js +++ b/addons/website_sale/static/src/js/website_sale_validate.js @@ -21,7 +21,10 @@ $(document).ready(function () { setTimeout(function () { payment_transaction_poll_status(); }, Math.ceil(_poll_nbr / 3) * 1000); } else { - result.message = "<i class='fa fa-warning' />"; + var $message = $(result.message); + $message.find('span:first').prepend($( + "<i title='We are waiting the confirmation of the bank or payment provider' class='fa fa-warning' style='margin-right:10px;'>")); + result.message = $message.html(); } } $('div.oe_website_sale_tx_status').html(result.message); diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index e51ff65a80a..9fd121bd471 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_sale_delivery/static/src/js/website_sale_delivery.js b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js index 913addf8fb8..bbdece5fe55 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 @@ -11,12 +11,14 @@ odoo.define('website_sale_delivery.checkout', function (require) { var $amount_delivery = $('#order_delivery span.oe_currency_value'); var $amount_untaxed = $('#order_total_untaxed span.oe_currency_value'); var $amount_tax = $('#order_total_taxes span.oe_currency_value'); + var $amount_total = $('#order_total span.oe_currency_value'); var $carrier_badge = $('#delivery_carrier input[name="delivery_type"][value=' + result.carrier_id + '] ~ .badge.hidden'); var $compute_badge = $('#delivery_carrier input[name="delivery_type"][value=' + result.carrier_id + '] ~ .o_delivery_compute'); if (result.status === true) { $amount_delivery.text(result.new_amount_delivery); $amount_untaxed.text(result.new_amount_untaxed); $amount_tax.text(result.new_amount_tax); + $amount_total.text(result.new_amount_delivery + result.new_amount_untaxed + result.new_amount_tax); $carrier_badge.children('span').text(result.new_amount_delivery); $carrier_badge.removeClass('hidden'); $compute_badge.addClass('hidden'); diff --git a/addons/website_slides/static/src/js/slides.js b/addons/website_slides/static/src/js/slides.js index c4df6643f99..279c256c5ac 100644 --- a/addons/website_slides/static/src/js/slides.js +++ b/addons/website_slides/static/src/js/slides.js @@ -7,12 +7,12 @@ var core = require('web.core'); var time = require('web.time'); var Widget = require('web.Widget'); var local_storage = require('web.local_storage'); +var websiteRootInstance = require('website.WebsiteRoot.instance'); var _t = core._t; var page_widgets = {}; -$(document).ready(function () { - +(function () { var widget_parent = $('body'); @@ -23,7 +23,7 @@ $(document).ready(function () { // then return fix formate string else timeago display_str = ""; if (datetime_obj && new Date().getTime() - datetime_obj.getTime() > 7 * 24 * 60 * 60 * 1000) { - display_str = datetime_obj.toDateString(); + display_str = moment(datetime_obj).format('ll'); } else { display_str = moment(datetime_obj).fromNow(); } @@ -216,7 +216,7 @@ $(document).ready(function () { }); }); } -}); +})(); return { page_widgets: page_widgets, 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/corporate/bas-solutions.md b/doc/cla/corporate/bas-solutions.md deleted file mode 100644 index f0d49c7facf..00000000000 --- a/doc/cla/corporate/bas-solutions.md +++ /dev/null @@ -1,16 +0,0 @@ -Netherlands, 8-2-2015 - -BAS 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, - -Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions - -List of contributors: - -Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions -Ruchir Shukla ruchir@bas-solutions.nl https://github.com/ruchir-bas-solutions
\ No newline at end of file diff --git a/doc/cla/corporate/odooexperts.md b/doc/cla/corporate/odooexperts.md new file mode 100644 index 00000000000..3a73e799b87 --- /dev/null +++ b/doc/cla/corporate/odooexperts.md @@ -0,0 +1,16 @@ +Netherlands, 18-1-2018 + +Odoo Experts 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, + +Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions + +List of contributors: + +Erwin van der Ploeg erwin@odooexperts.nl https://github.com/ploegvde +Yenthe Van Ginneken yenthe@odooexperts.nl https://github.com/Yenthe666 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 diff --git a/doc/setup/install.rst b/doc/setup/install.rst index 71a6ea7edb7..99871bda78f 100644 --- a/doc/setup/install.rst +++ b/doc/setup/install.rst @@ -360,13 +360,6 @@ Source installation requires manually installing dependencies: Install ``psycopg`` using the installer here http://www.stickpeople.com/projects/python/win-psycopg/ - Then edit the requirements.txt file: - - - remove ``psycopg2`` as you already have it. - - remove the optional ``python-ldap``, ``gevent`` and ``psutil`` because - they require compilation. - - add ``pypiwin32`` because it's needed under windows. - Then use pip to install the dependencies using the following command from a cmd.exe prompt (replace ``\YourOdooPath`` by the actual path where you downloaded Odoo): diff --git a/doc/webservices/iap.rst b/doc/webservices/iap.rst index 186daf772b6..23467c267c3 100644 --- a/doc/webservices/iap.rst +++ b/doc/webservices/iap.rst @@ -82,8 +82,9 @@ Overview The Credits Every service provided through the In-App platform can be used by the - clients with tokens or *credits*. The monetary value of a credit depends - on the service and is decided by the provider. This could be: + clients with tokens or *credits*. The credits are an integer unit and + their monetary value depends on the service and is decided by the + provider. This could be: * for an sms service: 1 credit = 1 sms, * for an add service: 1 credit = 1 add, @@ -210,14 +211,20 @@ The credit packages are essentially a product with 4 characteristics. * Credits: the amount of credits the client is entitled to when buying the package, * Price: the price in *EUROS* for the time being (USD support is planned). -.. image:: images/package.png - :align: center - .. note:: + Odoo takes a 25% commission on all package sales. Adjust your selling price accordingly. + + +.. note:: + Depending on the strategy, the price per credit can vary from one package to another. + +.. image:: images/package.png + :align: center + .. _iap-odoo-app: Odoo App @@ -309,15 +316,22 @@ perform the service within: .. todo:: "My Account" > "Your InApp Services"? + The :class:`~odoo.addons.iap.models.iap.charge` helper will: +.. note:: + + Since the 15th of January 2018, a new functionality that allows one to capture a different amount than autorized has been added. + See :ref:`Charging <iap-charging>` + 1. authorize (create) a transaction with the specified number of credits, if the account does not have enough credits it will raise the relevant error 2. execute the body of the ``with`` statement -3. if the body of the ``with`` executes succesfully, capture (confirm) the - transaction -4. otherwise if an error is raised from the body of the ``with`` cancel the +3. (NEW) if the body of the ``with`` executes succesfully, update the price + of the transaction if needed +4. capture (confirm) the transaction +5. otherwise if an error is raised from the body of the ``with`` cancel the transaction (and release the hold on the credits) .. danger:: @@ -420,10 +434,12 @@ Capture :param TransactionToken token: :param ServiceKey key: + :param int credit_to_capture: (new - 15 Jan 2018) optional parameter to capture a smaller amount of credits than authorized :raises: :class:`~odoo.exceptions.AccessError` .. code-block:: python - + :emphasize-lines: 8 + r2 = requests.post(ODOO + '/iap/1/capture', json={ 'jsonrpc': '2.0', 'id': None, @@ -431,6 +447,7 @@ Capture 'params': { 'token': tx, 'key': SERVICE_KEY, + 'credit_to_capture': credit or False, } }).json() if 'error' in r: @@ -523,9 +540,17 @@ Odoo Helpers For convenience, if you are implementing your service using Odoo the ``iap`` module provides a few helpers to make IAP flow even simpler: +.. _iap-charging: + Charging -------- +.. note:: + + A new functionality was introduced to capture a different amount of credits than reserved. + As this patch was added on the 15th of January 2018, you will need to upgrade your ``iap`` module in order to use it. + The specifics of the new functionality are highlighted in the code. + .. class:: odoo.addons.iap.models.iap.charge(env, key, account_token, credit[, description, credit_template]) A *context manager* for authorizing and automatically capturing or @@ -544,8 +569,10 @@ Charging :param UserToken token: :param int credit: :param str description: + :param Qweb template credit_template: .. code-block:: python + :emphasize-lines: 10,13,14,15 @route('/deathstar/superlaser', type='json') def superlaser(self, user_account, @@ -556,8 +583,11 @@ Charging 0.0 is none, 1.0 is full power """ credits = int(MAXIMUM_POWER * factor) - with charge(request.env, SERVICE_KEY, user_account, credits): + with charge(request.env, SERVICE_KEY, user_account, credits) as transaction: # TODO: allow other targets + transaction.credit = max(credits, 2) + # Sales ongoing one the energy price, + # a maximum of 2 credits will be charged/captured. self.env['systems.planets'].search([ ('grid', '=', 'M-10'), ('name', '=', 'Alderaan'), diff --git a/odoo/addons/base/data/res_country_data.xml b/odoo/addons/base/data/res_country_data.xml index 031af455c5b..819082fb27d 100644 --- a/odoo/addons/base/data/res_country_data.xml +++ b/odoo/addons/base/data/res_country_data.xml @@ -1597,7 +1597,7 @@ <field name="code">tl</field> <field file="base/static/img/country_flags/tl.png" name="image" type="base64" /> <field name="currency_id" ref="USD" /> - <field eval="0" name="phone_code" /> + <field eval="670" name="phone_code" /> </record> <record id="tr" model="res.country"> <field name="name">Turkey</field> diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py index f4e3648b578..3d66287289a 100644 --- a/odoo/addons/base/models/ir_actions_report.py +++ b/odoo/addons/base/models/ir_actions_report.py @@ -678,7 +678,7 @@ class IrActionsReport(models.Model): """ discard_logo_check = self.env.context.get('discard_logo_check') if (self.env.uid == SUPERUSER_ID) and ((not self.env.user.company_id.external_report_layout) or (not discard_logo_check and not self.env.user.company_id.logo)) and config: - template = self.env.ref('base.view_company_report_form_with_print') + template = self.env.ref('base.view_company_report_form_with_print') if self.env.context.get('from_transient_model', False) else self.env.ref('base.view_company_report_form') return { 'name': _('Choose Your Document Layout'), 'type': 'ir.actions.act_window', diff --git a/requirements.txt b/requirements.txt index 311aaea0e8b..27127c70862 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,22 +3,23 @@ decorator==4.0.10 docutils==0.12 ebaysdk==2.1.5 feedparser==5.2.1 -gevent==1.1.2 +gevent==1.1.2 ; sys_platform != 'win32' greenlet==0.4.10 html2text==2016.9.19 Jinja2==2.8 -lxml==3.5.0 +lxml==3.7.1 ; sys_platform != 'win32' +lxml ; sys_platform == 'win32' Mako==1.0.4 MarkupSafe==0.23 mock==2.0.0 num2words==0.5.4 ofxparse==0.16 passlib==1.6.5 -Pillow==3.4.1 -psutil==4.3.1 -psycopg2==2.7.3.1 +Pillow==4.0.0 +psutil==4.3.1; sys_platform != 'win32' +psycopg2==2.7.3.1; sys_platform != 'win32' pydot==1.2.3 -pyldap==2.4.28 +pyldap==2.4.28; sys_platform != 'win32' pyparsing==2.1.10 PyPDF2==1.26.0 pyserial==3.1.1 @@ -29,11 +30,11 @@ PyYAML==3.12 qrcode==5.3 reportlab==3.3.0 requests==2.11.1 -six==1.10.0 suds-jurko==0.6 vatnumber==1.2 vobject==0.9.3 -Werkzeug==0.11.11 +Werkzeug==0.11.15 XlsxWriter==0.9.3 xlwt==1.3.* xlrd==1.0.0 +pypiwin32 ; sys_platform == 'win32' |
