diff options
79 files changed, 1274 insertions, 353 deletions
diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index fd2fef466fb..d280b6cb850 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -27,7 +27,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): res = [] total = [] cr = self.env.cr - user_company = self.env.user.company_id.id + company_ids = self.env.context.get('company_ids', (self.env.user.company_id.id,)) move_state = ['draft', 'posted'] if target_move == 'posted': move_state = ['posted'] @@ -41,7 +41,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): if reconciled_after_date: reconciliation_clause = '(l.reconciled IS FALSE OR l.id IN %s)' arg_list += (tuple(reconciled_after_date),) - arg_list += (date_from, user_company) + arg_list += (date_from, tuple(company_ids)) query = ''' SELECT DISTINCT l.partner_id, UPPER(res_partner.name) FROM account_move_line AS l left join res_partner on l.partner_id = res_partner.id, account_account, account_move am @@ -51,7 +51,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): AND (account_account.internal_type IN %s) AND ''' + reconciliation_clause + ''' AND (l.date <= %s) - AND l.company_id = %s + AND l.company_id IN %s ORDER BY UPPER(res_partner.name)''' cr.execute(query, arg_list) @@ -76,8 +76,8 @@ class ReportAgedPartnerBalance(models.AbstractModel): AND (COALESCE(l.date_maturity,l.date) > %s)\ AND ((l.partner_id IN %s) OR (l.partner_id IS NULL)) AND (l.date <= %s) - AND l.company_id = %s''' - cr.execute(query, (tuple(move_state), tuple(account_type), date_from, tuple(partner_ids), date_from, user_company)) + AND l.company_id IN %s''' + cr.execute(query, (tuple(move_state), tuple(account_type), date_from, tuple(partner_ids), date_from, tuple(company_ids))) aml_ids = cr.fetchall() aml_ids = aml_ids and [x[0] for x in aml_ids] or [] for line in self.env['account.move.line'].browse(aml_ids): @@ -117,7 +117,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): else: dates_query += ' <= %s)' args_list += (periods[str(i)]['stop'],) - args_list += (date_from, user_company) + args_list += (date_from, tuple(company_ids)) query = '''SELECT l.id FROM account_move_line AS l, account_account, account_move am @@ -127,7 +127,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): AND ((l.partner_id IN %s) OR (l.partner_id IS NULL)) AND ''' + dates_query + ''' AND (l.date <= %s) - AND l.company_id = %s''' + AND l.company_id IN %s''' cr.execute(query, args_list) partners_amount = {} aml_ids = cr.fetchall() diff --git a/addons/base_gengo/wizard/base_gengo_translations.py b/addons/base_gengo/wizard/base_gengo_translations.py index 0a585934af8..43d2e81ca33 100644 --- a/addons/base_gengo/wizard/base_gengo_translations.py +++ b/addons/base_gengo/wizard/base_gengo_translations.py @@ -62,9 +62,9 @@ class BaseGengoTranslations(models.TransientModel): 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', - 'res_model': 'res.company', - 'res_id': self.env.user.company_id.id, + 'res_model': 'res.config.settings', 'target': 'current', + 'context': {'module' : 'general_settings'}, } @api.model diff --git a/addons/bus/static/src/js/bus.js b/addons/bus/static/src/js/bus.js index 25a8b86903f..73b69013389 100644 --- a/addons/bus/static/src/js/bus.js +++ b/addons/bus/static/src/js/bus.js @@ -76,7 +76,8 @@ bus.Bus = Widget.extend({ this.last_partners_presence_check = now; } var data = {channels: self.channels, last: self.last, options: options}; - session.rpc('/longpolling/poll', data, {shadow : true}).then(function(result) { + // The backend has a maximum cycle time of 50 seconds so give +10 seconds + session.rpc('/longpolling/poll', data, {shadow : true, timeout: 60000}).then(function(result) { self.on_notification(result); if(!self.stop){ self.poll(); diff --git a/addons/crm/security/crm_security.xml b/addons/crm/security/crm_security.xml index 235d6854f93..83f3197a5b1 100644 --- a/addons/crm/security/crm_security.xml +++ b/addons/crm/security/crm_security.xml @@ -34,16 +34,6 @@ <field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field> </record> - <record id="crm_rule_lead_assigned_activities" model="ir.rule"> - <field name="name">Leads with assigned activities</field> - <field ref="model_crm_lead" name="model_id"/> - <field name="domain_force">[('activity_ids.user_id','=',user.id)]</field> - <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> - <field name="perm_read" eval="True"/> - <field name="perm_write" eval="False"/> - <field name="perm_create" eval="False"/> - <field name="perm_unlink" eval="False"/> - </record> <record id="crm_rule_all_lead" model="ir.rule"> <field name="name">All Leads</field> <field ref="model_crm_lead" name="model_id"/> diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 5380365b501..2ed20b6a7c5 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -524,7 +524,7 @@ class HrExpenseSheet(models.Model): @api.onchange('employee_id') def _onchange_employee_id(self): - self.address_id = self.employee_id.address_home_id + self.address_id = self.employee_id.sudo().address_home_id self.department_id = self.employee_id.department_id @api.depends('expense_line_ids.total_amount_company') diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index 28a852af5df..c323a71a637 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -61,7 +61,7 @@ <field name="reference"/> <field name="date"/> <field name="account_id" domain="[('internal_type', '=', 'other')]" groups="account.group_account_user"/> - <field name="employee_id"/> + <field name="employee_id" groups="hr_expense.group_hr_expense_user"/> <field name="sheet_id" invisible="1"/> <field name="currency_id" groups="base.group_multi_currency"/> <field name="analytic_account_id" groups="analytic.group_analytic_accounting"/> diff --git a/addons/hr_holidays/models/hr.py b/addons/hr_holidays/models/hr.py index e74e60f314d..bf8fbddd5ea 100644 --- a/addons/hr_holidays/models/hr.py +++ b/addons/hr_holidays/models/hr.py @@ -218,7 +218,12 @@ class Employee(models.Model): def write(self, values): res = super(Employee, self).write(values) - if 'parent_id' in values: + if 'parent_id' in values or 'department_id' in values: holidays = self.env['hr.holidays'].search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)]) - holidays.write({'manager_id': values['parent_id']}) + hr_vals = {} + if values.get('parent_id') is not None: + hr_vals['manager_id'] = values['parent_id'] + if values.get('department_id') is not None: + hr_vals['department_id'] = values['department_id'] + holidays.write(hr_vals) return res diff --git a/addons/hr_holidays/models/hr_holidays.py b/addons/hr_holidays/models/hr_holidays.py index 51664bc1db6..e8672298603 100644 --- a/addons/hr_holidays/models/hr_holidays.py +++ b/addons/hr_holidays/models/hr_holidays.py @@ -217,6 +217,7 @@ class Holidays(models.Model): @api.onchange('employee_id') def _onchange_employee_id(self): self.manager_id = self.employee_id and self.employee_id.parent_id + self.department_id = self.employee_id and self.employee_id.department_id @api.multi @api.depends('number_of_days_temp', 'type') diff --git a/addons/hr_payroll_account/models/hr_payroll_account.py b/addons/hr_payroll_account/models/hr_payroll_account.py index e76173d45a4..da168912095 100644 --- a/addons/hr_payroll_account/models/hr_payroll_account.py +++ b/addons/hr_payroll_account/models/hr_payroll_account.py @@ -158,7 +158,6 @@ class HrContract(models.Model): class HrPayslipRun(models.Model): _inherit = 'hr.payslip.run' - _description = 'Payslip Run' journal_id = fields.Many2one('account.journal', 'Salary Journal', states={'draft': [('readonly', False)]}, readonly=True, required=True, default=lambda self: self.env['account.journal'].search([('type', '=', 'general')], limit=1)) diff --git a/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml b/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml index 14ad3eaefa0..0f6bb86479f 100644 --- a/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml +++ b/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml @@ -545,7 +545,6 @@ result = -contract.car_atn ref('hr_salary_rule_representation_fees'), ref('hr_payroll_rules_suppl_net'), ref('hr_payroll_rules_retained_net'), - ref('hr_salary_rule_commission_on_target'), ref('hr_salary_rule_pp_reduction_employees'), ])]" name="rule_ids"/> @@ -571,7 +570,6 @@ result = -contract.car_atn ref('hr_salary_rule_representation_fees'), ref('hr_payroll_rules_suppl_net'), ref('hr_payroll_rules_retained_net'), - ref('hr_salary_rule_commission_on_target'), ref('hr_salary_rule_pp_reduction_workers') ])]" name="rule_ids"/> <field name="company_id" ref="base.main_company"/> 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 c80ccc983a5..6174699920c 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 @@ -116,8 +116,8 @@ class HrContract(models.Model): @api.depends('commission_on_target') def _compute_warrants_cost(self): for contract in self: - contract.warrants_cost = contract.commission_on_target * 1.326 / 1.05 * 12.0 - contract.warrant_value_employee = contract.warrants_cost * 0.54 + contract.warrants_cost = contract.commission_on_target * 1.05 * 12.0 + contract.warrant_value_employee = contract.commission_on_target * (1.00 - 0.535) * 12.0 @api.depends('wage', 'fuel_card', 'representation_fees', 'transport_employer_cost', 'internet', 'mobile', 'mobile_plus') @@ -230,7 +230,7 @@ class HrContract(models.Model): - 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 \ + - contract.warrants_cost \ - 220.0 * contract.meal_voucher_paid_by_employer gross = remaining_for_gross / (13.0 + 13.0 * 0.3507 + 0.92) return gross diff --git a/addons/mail/models/ir_model.py b/addons/mail/models/ir_model.py index 3a312cfe843..04529610f0e 100644 --- a/addons/mail/models/ir_model.py +++ b/addons/mail/models/ir_model.py @@ -74,6 +74,6 @@ class IrModelField(models.Model): def _instanciate_attrs(self, field_data): attrs = super(IrModelField, self)._instanciate_attrs(field_data) - if field_data.get('track_visibility'): + if attrs and field_data.get('track_visibility'): attrs['track_visibility'] = field_data['track_visibility'] return attrs diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index f4e79cbf8eb..e24a3193f97 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -211,7 +211,7 @@ class Channel(models.Model): # http://blogs.technet.com/b/exchange/archive/2006/10/06/3395024.aspx headers['X-Auto-Response-Suppress'] = 'OOF' if self.alias_domain and self.alias_name: - headers['List-Id'] = '%s.%s' % (self.alias_name, self.alias_domain) + headers['List-Id'] = '<%s.%s>' % (self.alias_name, self.alias_domain) headers['List-Post'] = '<mailto:%s@%s>' % (self.alias_name, self.alias_domain) # Avoid users thinking it was a personal message # X-Forge-To: will replace To: after SMTP envelope is determined by ir.mail.server @@ -505,7 +505,15 @@ class Channel(models.Model): partners_to_add = partners - channel.channel_partner_ids channel.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': partner_id}) for partner_id in partners_to_add.ids]}) for partner in partners_to_add: - notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,) + if partner.id != self.env.user.partner_id.id: + notification = _('<div class="o_mail_notification">%(author)s invited %(new_partner)s to <a href="#" class="o_channel_redirect" data-oe-id="%(channel_id)s">#%(channel_name)s</a></div>') % { + 'author': self.env.user.display_name, + 'new_partner': partner.display_name, + 'channel_id': channel.id, + 'channel_name': channel.name, + } + else: + notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (channel.id, channel.name,) self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id) # broadcast the channel header to the added partner diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 45d07d5b97c..62f0f524ebf 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -11,7 +11,7 @@ from odoo.exceptions import UserError, AccessError from odoo.osv import expression _logger = logging.getLogger(__name__) -_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/]{3,}=*)([\'"])', re.I) +_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])', re.I) class Message(models.Model): diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js index 68a01df2345..f63c7073af9 100644 --- a/addons/mail/static/src/js/thread.js +++ b/addons/mail/static/src/js/thread.js @@ -210,6 +210,10 @@ var Thread = Widget.extend({ } }, on_click_redirect: function (event) { + // ignore inherited branding + if ($(event.target).data('oe-field') !== undefined) { + return; + } var id = $(event.target).data('oe-id'); if (id) { event.preventDefault(); diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index 49d1705eccd..22e9a51b9dd 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -60,7 +60,7 @@ function _parse_and_transform(nodes, transform_function) { // Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url // Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match. -var url_regexp = /\b(?:https?:\/\/|(www\.))[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; +var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&//=]*)/gi; function linkify(text, attrs) { attrs = attrs || {}; if (attrs.target === undefined) { @@ -70,7 +70,7 @@ function linkify(text, attrs) { return key + '="' + _.escape(value) + '"'; }).join(' '); return text.replace(url_regexp, function (url) { - var href = (!/^(f|ht)tps?:\/\//i.test(url)) ? "http://" + url : url; + var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url; return '<a ' + attrs + ' href="' + href + '">' + url + '</a>'; }); } diff --git a/addons/mail/static/tests/mail_utils_tests.js b/addons/mail/static/tests/mail_utils_tests.js new file mode 100644 index 00000000000..e8ec776a8d5 --- /dev/null +++ b/addons/mail/static/tests/mail_utils_tests.js @@ -0,0 +1,34 @@ +odoo.define('mail.mail_utils_tests', function (require) { +"use strict"; + +var utils = require('mail.utils'); + +QUnit.module('mail', {}, function () { + +QUnit.module('Mail utils'); + +QUnit.test('add_link utility function', function (assert) { + assert.expect(7); + + var testInputs = { + 'http://admin:password@example.com:8/%2020': true, + 'https://admin:password@example.com/test': true, + 'www.example.com:8/test': true, + 'https://127.0.0.5:8069': true, + 'www.127.0.0.5': false, + 'should.notmatch': false, + 'fhttps://test.example.com/test': false, + }; + + _.each(testInputs, function (willLinkify, content) { + var output = utils.parse_and_transform(content, utils.add_link); + if (willLinkify) { + assert.strictEqual(output.indexOf('<a '), 0, "There should be a link"); + } else { + assert.strictEqual(output.indexOf('<a '), -1, "There should be no link"); + } + }); +}); + +}); +}); diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index 31bbcf1ee47..a63f6d0edc4 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -44,6 +44,7 @@ <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/chatter_tests.js"></script> + <script type="text/javascript" src="/mail/static/tests/mail_utils_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> diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index 4d9a0e5bcbe..8e5ce0ad6c8 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -81,6 +81,11 @@ class MrpBom(models.Model): if self.product_tmpl_id: self.product_uom_id = self.product_tmpl_id.uom_id.id + @api.onchange('routing_id') + def onchange_routing_id(self): + for line in self.bom_line_ids: + line.operation_id = False + @api.multi def name_get(self): return [(bom.id, '%s%s' % (bom.code and '%s: ' % bom.code or '', bom.product_tmpl_id.display_name)) for bom in self] diff --git a/addons/mrp_repair/views/mrp_repair_views.xml b/addons/mrp_repair/views/mrp_repair_views.xml index 753e8287096..db899a5b06a 100644 --- a/addons/mrp_repair/views/mrp_repair_views.xml +++ b/addons/mrp_repair/views/mrp_repair_views.xml @@ -50,7 +50,7 @@ </h1> <group> <group> - <field name="tracking" invisible="1"/> + <field name="tracking" invisible="1" attrs="{'readonly': 1}"/> <field name="product_id" domain="[('type', 'in', ['product', 'consu'])]"/> <label for="product_qty"/> <div class="o_row"> @@ -116,7 +116,7 @@ <field name="product_uom_qty" string="Quantity"/> <field name="product_uom" string="Unit of Measure" groups="product.group_uom"/> <field name="price_unit"/> - <field name="tax_id" widget="many2many_tags"/> + <field name="tax_id" widget="many2many_tags" domain="[('type_tax_use','=','sale')]"/> <field name="price_subtotal" widget="monetary"/> </tree> </field> diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index 4047ef7d30a..444d936429b 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -31,49 +31,110 @@ class WebsitePayment(http.Controller): currency_id = currency_id and int(currency_id) or user.company_id.currency_id.id currency = env['res.currency'].browse(currency_id) - # Try default one then fallback on first - acquirer_id = acquirer_id and int(acquirer_id) or \ - env['ir.default'].get('payment.transaction', 'acquirer_id', company_id=user.company_id.id) or \ - env['payment.acquirer'].search([('website_published', '=', True), ('company_id', '=', user.company_id.id)])[0].id - - acquirer = env['payment.acquirer'].with_context(submit_class='btn btn-primary pull-right', - submit_txt=_('Pay Now')).browse(acquirer_id) + acquirers = None + if acquirer_id: + acquirers = env['payment.acquirer'].browse(int(acquirer_id)) + if not acquirers: + acquirers = env['payment.acquirer'].search([('website_published', '=', True), ('company_id', '=', user.company_id.id)]) # auto-increment reference with a number suffix if the reference already exists reference = request.env['payment.transaction'].get_next_reference(reference) partner_id = user.partner_id.id if not user._is_public() else False - payment_form = acquirer.sudo().render(reference, float(amount), currency.id, values={'return_url': '/website_payment/confirm', 'partner_id': partner_id}) values = { 'reference': reference, - 'acquirer': acquirer, 'currency': currency, 'amount': float(amount), - 'payment_form': payment_form, + 'return_url': '/website_payment/confirm', + 'partner_id': partner_id, + 'bootstrap_formatting': True, + 'error_msg': kw.get('error_msg') } - # 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): + values['s2s_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 's2s'] + values['form_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 'form'] + values['pms'] = request.env['payment.token'].search([('acquirer_id', 'in', [acq.id for acq in values['s2s_acquirers']])]) + + return request.render('payment.pay', values) + + def _get_existing_transaction(self, reference, amount, partner_id, currency_id, acquirer_id, tx_id): + PaymentTransaction = request.env['payment.transaction'] + tx = None + if tx_id: + tx = PaymentTransaction.sudo().browse(tx_id) + if not tx.exists() or tx.reference != reference or tx.acquirer_id.id != acquirer_id: + tx = None + + if not tx: + tx = PaymentTransaction.sudo().search([('reference', '=', reference), ('acquirer_id', '=', acquirer_id)]) + + if tx and (tx.state != 'draft' or tx.partner_id.id != partner_id or tx.amount != amount or tx.currency_id.id != currency_id): + tx = None + + return tx + + @http.route(['/website_payment/transaction/<string:reference>/<string:amount>/<string:currency_id>',], type='json', auth='public') + def transaction(self, acquirer_id, reference, amount, currency_id, **kwargs): partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False + acquirer = request.env['payment.acquirer'].browse(acquirer_id) + + tx = self._get_existing_transaction(reference, float(amount), partner_id, + int(currency_id), int(acquirer_id), request.session.get('website_payment_tx_id')) + + if not tx: + values = { + 'acquirer_id': int(acquirer_id), + 'reference': reference, + 'amount': float(amount), + 'currency_id': currency_id, + 'partner_id': partner_id, + } + + tx = request.env['payment.transaction'].sudo().create(values) + request.session['website_payment_tx_id'] = tx.id + + render_values = { + 'return_url': '/website_payment/confirm?tx_id=%d' % tx.id, + 'partner_id': partner_id, + } + + return acquirer.sudo().render(reference, float(amount), int(currency_id), values=render_values) + + @http.route(['/website_payment/token/<string:reference>/<float:amount>/<int:currency_id>'], type='http', auth='public', website=True) + def payment_token(self, pm_id, reference, amount, currency_id, return_url=None, **kwargs): + token = request.env['payment.token'].browse(int(pm_id)) + + if not token: + return request.redirect('/website_payment/pay?error_msg=%s' % _('Cannot setup the payment.')) + + partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False + values = { - 'acquirer_id': int(acquirer_id), + 'acquirer_id': token.acquirer_id.id, 'reference': reference, 'amount': float(amount), 'currency_id': int(currency_id), 'partner_id': partner_id, + 'payment_token_id': pm_id } tx = request.env['payment.transaction'].sudo().create(values) request.session['website_payment_tx_id'] = tx.id - return tx.id + + try: + res = tx.s2s_do_transaction() + except Exception as e: + return request.redirect('/website_payment/pay?error_msg=%s' % _('Payment transaction failed.')) + + valid_state = 'authorized' if tx.acquirer_id.capture_manually else 'done' + if not res or tx.state != valid_state: + return request.redirect('/website_payment/pay?error_msg=%s' % _('Payment transaction failed.')) + + return request.redirect(return_url if return_url else '/website_payment/confirm?tx_id=%d' % tx.id) @http.route(['/website_payment/confirm'], type='http', auth='public', website=True) def confirm(self, **kw): - tx_id = request.session.pop('website_payment_tx_id', False) + tx_id = int(kw.get('tx_id', 0)) or request.session.pop('website_payment_tx_id', 0) if tx_id: tx = request.env['payment.transaction'].browse(tx_id) if tx.state == 'done': diff --git a/addons/payment/static/src/js/payment_transaction_portal.js b/addons/payment/static/src/js/payment_transaction_portal.js index 26c0fb35d1e..094149b6274 100644 --- a/addons/payment/static/src/js/payment_transaction_portal.js +++ b/addons/payment/static/src/js/payment_transaction_portal.js @@ -1,24 +1,2 @@ -odoo.define('payment.website_payment', function (require) { -"use strict"; - -require('web.dom_ready'); -var ajax = require('web.ajax'); - - if (!$('.o_website_payment').length) { - return $.Deferred().reject("DOM doesn't contain '.o_website_payment'"); - } - - // When clicking on payment button: create the tx using json then continue to the acquirer - var $payment = $(".o_website_payment_form"); - $payment.on("click", 'button[type="submit"],button[name="submit"]', function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - $(ev.currentTarget).attr('disabled', true); - $(ev.currentTarget).prepend('<i class="fa fa-refresh fa-spin"></i> '); - var $form = $(ev.currentTarget).parents('form'); - var data =$("div[class~='o_website_payment_new_payment']").data(); - ajax.jsonRpc('/website_payment/transaction/', 'call', data).then(function (result) { - $form.submit(); - }); - }); -}); +// This file is kept to avoid breaking assets compilation on instance that didn't run "-u payment". +// TBE TODO: Remove me in master
\ No newline at end of file diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml index d2fa3c1c4cd..166cd429718 100644 --- a/addons/payment/views/payment_portal_templates.xml +++ b/addons/payment/views/payment_portal_templates.xml @@ -46,32 +46,22 @@ <div class="wrap"> <div class="container o_website_payment"> <h1>Payment</h1> - <div class="row"> - <div class="col-md-6"> - <div class="oe_structure"></div> - <div> - <div class="form-horizontal row o_website_payment_new_payment" t-att-data-acquirer_id="acquirer.id" t-att-data-currency_id="currency.id" t-att-data-reference="reference" t-att-data-amount="amount"> - <div class="form-group"> - <label for="form_reference" class="col-sm-3 control-label">Reference</label> - <div class="col-sm-9"> - <span name="form_reference" class="form-control" t-esc="reference"/> - </div> - </div> - <div class="form-group"> - <label for="form_amount" class="col-sm-3 control-label">Amount</label> - <div class="col-sm-9"> - <span name="form_amount" class="form-control" t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/> - </div> - </div> - <div class="col-sm-9 col-sm-offset-3 text-muted mt16" t-raw="acquirer.pre_msg"/> - <div class="col-sm-12 o_website_payment_form" t-raw="payment_form"/> - <div class="col-sm-6 col-sm-offset-6 text-muted mt16"> - <span t-field="acquirer.image_small" t-att-title="acquirer.name" t-options='{"widget": "image", "style":"max-width: 60px; display: inline-block"}'/> - <span>Processed by <t t-esc="acquirer.name"/>.</span> - </div> - </div> + <div t-if="error_msg" class="alert alert-danger col-md-7"> + <t t-esc="error_msg"/> + </div> + <div class="row mt32 mb32"> + <div class="col-md-7"> + <p><b>Reference:</b> <t t-esc="reference"/></p> + <p><b>Amount:</b> <t t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/></p> + <t t-call="payment.payment_tokens_list" t-if="reference and amount and currency"> + <t t-set="mode" t-value="'payment'"/> + <t t-set="prepare_tx_url" t-value="'/website_payment/transaction/' + reference + '/' + str(amount) + '/' + str(currency.id)"/> + <t t-set="form_action" t-value="'/website_payment/token/' + reference + '/' + str(amount) + '/' + str(currency.id)"/> + </t> + <div t-if="not (s2s_acquirers or form_acquirers)" class="alert alert-danger"> + <p>No payment acquirer found.</p> + <p>Please configure a payment acquirer.</p> </div> - <div class="oe_structure"/> </div> </div> </div> diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index a9395ea7bd6..7ecf25284f6 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -108,11 +108,14 @@ odoo.define('payment_stripe.stripe', function(require) { if ($('.o_website_payment').length !== 0) { - var create_tx = ajax.jsonRpc('/website_payment/transaction', 'call', { - reference: $("input[name='invoice_num']").val(), - amount: amount, // exact amount, not stripe cents - currency_id: currency_id, + var invoice_num = $("input[name='invoice_num']").val(); + var url = _.str.sprintf("/website_payment/transaction/%s/%f/%s", + invoice_num, amount, currency_id); + + var create_tx = ajax.jsonRpc(url, 'call', { acquirer_id: acquirer_id + }).then(function (data) { + try { provider_form[0].innerHTML = data; } catch (e) {} }); } else if ($('.o_website_quote').length !== 0) { diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 19412db6cf6..af94179c442 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -227,10 +227,14 @@ class PosOrder(models.Model): return InvoiceLine.sudo().create(inv_line) def _create_account_move_line(self, session=None, move=None): - def _flatten_tax_and_children(taxes): + def _flatten_tax_and_children(taxes, group_done=None): children = self.env['account.tax'] - for tax in taxes: - children |= _flatten_tax_and_children(tax.children_tax_ids) + if group_done is None: + group_done = set() + for tax in taxes.filtered(lambda t: t.amount_type == 'group'): + if tax.id not in group_done: + group_done.add(tax.id) + children |= _flatten_tax_and_children(tax.children_tax_ids, group_done) return taxes + children # Tricky, via the workflow, we only have one id in the ids variable diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 99fdc1ec7db..e6b00499e62 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -729,6 +729,7 @@ exports.PosModel = Backbone.Model.extend({ deferred.resolve(); }; + img.crossOrigin = 'use-credentials'; img.src = url; return deferred; diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 4e53d259248..dadd9cffbb8 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -1170,8 +1170,9 @@ var ClientListScreenWidget = ScreenWidget.extend({ var order = this.pos.get_order(); if( this.has_client_changed() ){ var default_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.pos.config.default_fiscal_position_id[0]}); - if ( this.new_client ) { - order.fiscal_position = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]}); + if ( this.new_client && this.new_client.property_account_position_id ) { + var client_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]}); + order.fiscal_position = client_fiscal_position_id || default_fiscal_position_id; order.set_pricelist(_.findWhere(this.pos.pricelists, {'id': this.new_client.property_product_pricelist[0]}) || this.pos.default_pricelist); } else { order.fiscal_position = default_fiscal_position_id; @@ -1277,9 +1278,14 @@ var ClientListScreenWidget = ScreenWidget.extend({ .then(function(partner_id){ self.saved_client_details(partner_id); },function(type,err){ + var error_body = _t('Your Internet connection is probably down.'); + if (err.data) { + var except = err.data; + error_body = except.arguments && except.arguments[0] || except.message || error_body; + } self.gui.show_popup('error',{ 'title': _t('Error: Could not Save Changes'), - 'body': _t('Your Internet connection is probably down.'), + 'body': error_body, }); }); }, diff --git a/addons/pos_mercury/views/pos_mercury_transaction_templates.xml b/addons/pos_mercury/views/pos_mercury_transaction_templates.xml index 0ac0f8edb75..3573672acb4 100644 --- a/addons/pos_mercury/views/pos_mercury_transaction_templates.xml +++ b/addons/pos_mercury/views/pos_mercury_transaction_templates.xml @@ -6,11 +6,11 @@ <TranType><t t-esc='transaction_type'/></TranType> <TranCode><t t-esc='transaction_code'/></TranCode> <InvoiceNo><t t-esc='invoice_no'/></InvoiceNo> - <Frequency>OneTime</Frequency> + <Frequency t-translation="off">OneTime</Frequency> </template> <template id='mercury_transaction'> - <TStream> + <TStream t-translation="off"> <Transaction> <t t-call='pos_mercury.mercury_common'/> <OperatorID><t t-esc='operator_id'/></OperatorID> @@ -31,7 +31,7 @@ </template> <template id='mercury_voidsale'> - <TStream> + <TStream t-translation="off"> <Transaction> <t t-call='pos_mercury.mercury_common'/> <RefNo><t t-esc='ref_no'/></RefNo> @@ -52,7 +52,7 @@ </template> <template id='mercury_return'> - <TStream> + <TStream t-translation="off"> <Transaction> <t t-call='pos_mercury.mercury_common'/> <OperatorID><t t-esc='operator_id'/></OperatorID> diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index 621113a385b..e847875f2d1 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -14,10 +14,12 @@ class CustomerPortal(CustomerPortal): def _prepare_portal_layout_values(self): values = super(CustomerPortal, self)._prepare_portal_layout_values() - values.update({ - 'project_count': request.env['project.project'].search_count([('privacy_visibility', '=', 'portal')]), - 'task_count': request.env['project.task'].search_count([('project_id.privacy_visibility', '=', 'portal')]) - }) + Project = request.env['project.project'] + Task = request.env['project.task'] + # portal users can't view projects they don't follow + projects = Project.sudo().search([('privacy_visibility', '=', 'portal')]) + values['project_count'] = Project.search_count([('id', 'in', projects.ids)]) + values['task_count'] = Task.search_count([('project_id', 'in', projects.ids)]) return values @http.route(['/my/projects', '/my/projects/page/<int:page>'], type='http', auth="user", website=True) @@ -77,8 +79,6 @@ class CustomerPortal(CustomerPortal): @http.route(['/my/tasks', '/my/tasks/page/<int:page>'], type='http', auth="user", website=True) def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', **kw): values = self._prepare_portal_layout_values() - domain = [('project_id.privacy_visibility', '=', 'portal')] - searchbar_sortings = { 'date': {'label': _('Newest'), 'order': 'create_date desc'}, 'name': {'label': _('Title'), 'order': 'name'}, @@ -96,7 +96,9 @@ class CustomerPortal(CustomerPortal): 'all': {'input': 'all', 'label': _('Search in All')}, } # extends filterby criteria with project (criteria name is the project id) - projects = request.env['project.project'].search([('privacy_visibility', '=', 'portal')]) + # Note: portal users can't view projects they don't follow + projects = request.env['project.project'].sudo().search([('privacy_visibility', '=', 'portal')]) + domain = [('project_id', 'in', projects.ids)] for proj in projects: searchbar_filters.update({ str(proj.id): {'label': proj.name, 'domain': [('project_id', '=', proj.id)]} diff --git a/addons/project/models/project.py b/addons/project/models/project.py index e4b7f259588..b240fdefcc7 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -593,14 +593,12 @@ class Task(models.Model): @api.onchange('project_id') def _onchange_project(self): - default_partner_id = self.env.context.get('default_partner_id') - default_partner = self.env['res.partner'].browse(default_partner_id) if default_partner_id else self.env['res.partner'] if self.project_id: - self.partner_id = self.project_id.partner_id or default_partner + if self.project_id.partner_id: + self.partner_id = self.project_id.partner_id if self.project_id not in self.stage_id.project_ids: self.stage_id = self.stage_find(self.project_id.id, [('fold', '=', False)]) else: - self.partner_id = default_partner self.stage_id = False @api.onchange('user_id') diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index ef23444c32c..4847ddf11ee 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -192,13 +192,13 @@ class AccountInvoice(models.Model): if child.type_tax_use != 'none': tax_ids.append((4, child.id, None)) price_before = line.get('price', 0.0) - line.update({'price': company_currency.round(valuation_price_unit * line['quantity'])}) + line.update({'price': inv.currency_id.round(valuation_price_unit * line['quantity'])}) diff_res.append({ 'type': 'src', 'name': i_line.name[:64], - 'price_unit': company_currency.round(price_unit - valuation_price_unit), + 'price_unit': inv.currency_id.round(price_unit - valuation_price_unit), 'quantity': line['quantity'], - 'price': company_currency.round(price_before - line.get('price', 0.0)), + 'price': inv.currency_id.round(price_before - line.get('price', 0.0)), 'account_id': acc, 'product_id': line['product_id'], 'uom_id': line['uom_id'], diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 1aee90fc83c..a63a2d679e4 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -695,7 +695,7 @@ class PurchaseOrderLine(models.Model): qty = 0.0 price_unit = self._get_stock_move_price_unit() for move in self.move_ids.filtered(lambda x: x.state != 'cancel' and not x.location_dest_id.usage == "supplier"): - qty += move.product_qty + qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') template = { 'name': self.name or '', 'product_id': self.product_id.id, @@ -719,7 +719,14 @@ class PurchaseOrderLine(models.Model): } diff_quantity = self.product_qty - qty if float_compare(diff_quantity, 0.0, precision_rounding=self.product_uom.rounding) > 0: - template['product_uom_qty'] = diff_quantity + quant_uom = self.product_id.uom_id + get_param = self.env['ir.config_parameter'].sudo().get_param + if self.product_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1': + product_qty = self.product_uom._compute_quantity(diff_quantity, quant_uom, rounding_method='HALF-UP') + template['product_uom'] = quant_uom.id + template['product_uom_qty'] = product_qty + else: + template['product_uom_qty'] = diff_quantity res.append(template) return res diff --git a/addons/purchase/tests/test_create_picking.py b/addons/purchase/tests/test_create_picking.py index b29e85b3825..9cdfe0fb4fd 100644 --- a/addons/purchase/tests/test_create_picking.py +++ b/addons/purchase/tests/test_create_picking.py @@ -174,3 +174,53 @@ class TestCreatePicking(common.TestProductCommon): customer_move._action_assign() self.assertEqual(customer_move.state, 'assigned', 'Reservation should work with the new quantity provided by the PO.') + + def test_03_uom(self): + """ Buy a dozen of products stocked in units. Check that the quantities on the purchase order + lines as well as the received quantities are handled in dozen while the moves themselves + are handled in units. Edit the ordered quantities, check that the quantites are correctly + updated on the moves. Edit the ir.config_parameter to propagate the uom of the purchase order + lines to the moves and edit a last time the ordered quantities. Receive, check the quantities. + """ + uom_unit = self.env.ref('product.product_uom_unit') + uom_dozen = self.env.ref('product.product_uom_dozen') + + self.assertEqual(self.product_id_1.uom_po_id.id, uom_unit.id) + + # buy a dozen + po = self.env['purchase.order'].create(self.po_vals) + + po.order_line.product_qty = 1 + po.order_line.product_uom = uom_dozen.id + po.button_confirm() + + # the move should be 12 units + # note: move.product_qty = computed field, always in the uom of the quant + # move.product_uom_qty = stored field representing the initial demand in move.product_uom + move1 = po.picking_ids.move_lines[0] + self.assertEqual(move1.product_uom_qty, 12) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 12) + + # edit the so line, sell 2 dozen, the move should now be 24 units + po.order_line.product_qty = 2 + self.assertEqual(move1.product_uom_qty, 24) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 24) + + # force the propagation of the uom, sell 3 dozen + self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1') + po.order_line.product_qty = 3 + move2 = po.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id) + self.assertEqual(move2.product_uom_qty, 1) + self.assertEqual(move2.product_uom.id, uom_dozen.id) + self.assertEqual(move2.product_qty, 12) + + # deliver everything + move1.quantity_done = 24 + move2.quantity_done = 1 + po.picking_ids.button_validate() + + # check the delivered quantity + self.assertEqual(po.order_line.qty_received, 3.0) + diff --git a/addons/purchase/tests/test_stockvaluation.py b/addons/purchase/tests/test_stockvaluation.py index 0421bde3f71..83f44229d21 100644 --- a/addons/purchase/tests/test_stockvaluation.py +++ b/addons/purchase/tests/test_stockvaluation.py @@ -198,3 +198,76 @@ class TestStockValuation(TransactionCase): self.assertEquals(move1.price_unit, price_unit_usd_new_rate) self.assertAlmostEqual(self.product1.stock_value, price_unit_usd_new_rate * 10, delta=0.1) + + def test_extra_move_fifo_1(self): + """ Check that the extra move when over processing a receipt is correctly merged back in + the original move. + """ + self.product1.product_tmpl_id.cost_method = 'fifo' + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + move1.quantity_done = 15 + res_dict = picking1.button_validate() + self.assertEqual(res_dict['res_model'], 'stock.overprocessed.transfer') + wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) + wizard.action_confirm() + + # there should be only one move + self.assertEqual(len(picking1.move_lines), 1) + self.assertEqual(move1.price_unit, 100) + self.assertEqual(move1.product_qty, 15) + self.assertEqual(self.product1.stock_value, 1500) + + def test_backorder_fifo_1(self): + """ Check that the backordered move when under processing a receipt correctly keep the + price unit of the original move. + """ + self.product1.product_tmpl_id.cost_method = 'fifo' + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + move1.quantity_done = 5 + res_dict = picking1.button_validate() + self.assertEqual(res_dict['res_model'], 'stock.backorder.confirmation') + wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) + wizard.process() + + self.assertEqual(len(picking1.move_lines), 1) + self.assertEqual(move1.price_unit, 100) + self.assertEqual(move1.product_qty, 5) + + picking2 = po1.picking_ids.filtered(lambda p: p.backorder_id) + move2 = picking2.move_lines[0] + self.assertEqual(len(picking2.move_lines), 1) + self.assertEqual(move2.price_unit, 100) + self.assertEqual(move2.product_qty, 5) + diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py index 73b9f95d93a..6d8d381c6f6 100644 --- a/addons/sale/models/analytic.py +++ b/addons/sale/models/analytic.py @@ -41,7 +41,9 @@ class AccountAnalyticLine(models.Model): @api.multi def _sale_postprocess(self, values, additional_so_lines=None): if 'so_line' not in values: # allow to force a False value for so_line - self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no').with_context(sale_analytic_norecompute=True)._sale_determine_order_line() + # only take the AAL from expense or vendor bill, meaning having a negative amount + self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no' and aal.amount <= 0).with_context(sale_analytic_norecompute=True)._sale_determine_order_line() + self.filtered(lambda aal: aal.amount <= 0).with_context(sale_analytic_norecompute=True)._sale_determine_order_line() if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()): if not self._context.get('sale_analytic_norecompute'): diff --git a/addons/sale_mrp/tests/test_sale_mrp_flow.py b/addons/sale_mrp/tests/test_sale_mrp_flow.py index f6268534ef7..1152b7fffa3 100644 --- a/addons/sale_mrp/tests/test_sale_mrp_flow.py +++ b/addons/sale_mrp/tests/test_sale_mrp_flow.py @@ -166,8 +166,8 @@ class TestSaleMrpFlow(common.TransactionCase): mnf_product_a = self.env['mrp.production'].search([('product_id', '=', product_a.id)]) self.assertTrue(mnf_product_a, 'Manufacturing order not created.') - self.assertEqual(mnf_product_a.product_qty, 10, 'Wrong product quantity in manufacturing order.') - self.assertEqual(mnf_product_a.product_uom_id, self.uom_dozen, 'Wrong unit of measure in manufacturing order.') + self.assertEqual(mnf_product_a.product_qty, 120, 'Wrong product quantity in manufacturing order.') + self.assertEqual(mnf_product_a.product_uom_id, self.uom_unit, 'Wrong unit of measure in manufacturing order.') self.assertEqual(mnf_product_a.state, 'confirmed', 'Manufacturing order should be confirmed.') # ------------------------------------------------------------------------------------------ diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index 99b8d299fa8..67daba354bd 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -238,7 +238,7 @@ class SaleOrderLine(models.Model): continue qty = 0.0 for move in line.move_ids.filtered(lambda r: r.state != 'cancel'): - qty += move.product_qty + qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP') if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0: continue @@ -263,8 +263,16 @@ class SaleOrderLine(models.Model): values = line._prepare_procurement_values(group_id=group_id) product_qty = line.product_uom_qty - qty + + procurement_uom = line.product_uom + quant_uom = line.product_id.uom_id + get_param = self.env['ir.config_parameter'].sudo().get_param + if procurement_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1': + product_qty = line.product_uom._compute_quantity(product_qty, quant_uom, rounding_method='HALF-UP') + procurement_uom = quant_uom + try: - self.env['procurement.group'].run(line.product_id, product_qty, line.product_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values) + self.env['procurement.group'].run(line.product_id, product_qty, procurement_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values) except UserError as error: errors.append(error.name) if errors: diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py index 5a6e43377fe..c99f211ce50 100644 --- a/addons/sale_stock/tests/test_sale_stock.py +++ b/addons/sale_stock/tests/test_sale_stock.py @@ -244,8 +244,8 @@ class TestSaleStock(TestSale): the new move lines. """ # sell two products - item1 = self.products['prod_order'] - item2 = self.products['prod_del'] + item1 = self.products['prod_order'] # consumable + item2 = self.products['prod_del'] # stockable self.so = self.env['sale.order'].create({ 'partner_id': self.partner.id, @@ -257,6 +257,66 @@ class TestSaleStock(TestSale): self.so.action_confirm() # deliver them + # One of the move is for a consumable product, thus is assigned. The second one is for a + # stockable product, thus is unavailable. Hitting `button_validate` will first ask to + # process all the reserved quantities and, if the user chose to process, a second wizard + # will ask to create a backorder for the unavailable product. + self.assertEquals(len(self.so.picking_ids), 1) + res_dict = self.so.picking_ids[0].button_validate() + wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) + self.assertEqual(wizard._name, 'stock.immediate.transfer') + res_dict = wizard.process() + wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) + self.assertEqual(wizard._name, 'stock.backorder.confirmation') + wizard.process() + + # Now, the original picking is done and there is a new one (the backorder). + self.assertEquals(len(self.so.picking_ids), 2) + for picking in self.so.picking_ids: + move = picking.move_lines + if picking.backorder_id: + self.assertEqual(move.product_id.id, item2.id) + self.assertEqual(move.state, 'confirmed') + else: + self.assertEqual(picking.move_lines.product_id.id, item1.id) + self.assertEqual(move.state, 'done') + + # update the two original sale order lines + self.so.write({ + 'order_line': [ + (1, self.so.order_line[0].id, {'product_uom_qty': 2}), + (1, self.so.order_line[1].id, {'product_uom_qty': 2}), + ] + }) + # a single picking should be created for the new delivery + self.assertEquals(len(self.so.picking_ids), 2) + backorder = self.so.picking_ids.filtered(lambda p: p.backorder_id) + self.assertEqual(len(backorder.move_lines), 2) + for backorder_move in backorder.move_lines: + if backorder_move.product_id.id == item1.id: + self.assertEqual(backorder_move.product_qty, 1) + elif backorder_move.product_id.id == item2.id: + self.assertEqual(backorder_move.product_qty, 2) + + def test_05_create_picking_update_saleorderline(self): + """ Same test than test_04 but only with enough products in stock so that the reservation + is successful. + """ + # sell two products + item1 = self.products['prod_order'] # consumable + item2 = self.products['prod_del'] # stockable + + self.env['stock.quant']._update_available_quantity(item2, self.env.ref('stock.stock_location_stock'), 2) + self.so = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'order_line': [ + (0, 0, {'name': item1.name, 'product_id': item1.id, 'product_uom_qty': 1, 'product_uom': item1.uom_id.id, 'price_unit': item1.list_price}), + (0, 0, {'name': item2.name, 'product_id': item2.id, 'product_uom_qty': 1, 'product_uom': item2.uom_id.id, 'price_unit': item2.list_price}), + ], + }) + self.so.action_confirm() + + # deliver them self.assertEquals(len(self.so.picking_ids), 1) self.so.picking_ids[0].force_assign() res_dict = self.so.picking_ids[0].button_validate() @@ -303,3 +363,61 @@ class TestSaleStock(TestSale): self.assertEqual(len(so1.picking_ids), 2) picking2 = so1.picking_ids.filtered(lambda p: p.state != 'cancel') self.assertEqual(picking2.partner_id.id, partner2) + + def test_06_uom(self): + """ Sell a dozen of products stocked in units. Check that the quantities on the sale order + lines as well as the delivered quantities are handled in dozen while the moves themselves + are handled in units. Edit the ordered quantities, check that the quantites are correctly + updated on the moves. Edit the ir.config_parameter to propagate the uom of the sale order + lines to the moves and edit a last time the ordered quantities. Deliver, check the + quantities. + """ + uom_unit = self.env.ref('product.product_uom_unit') + uom_dozen = self.env.ref('product.product_uom_dozen') + item1 = self.products['prod_order'] + + self.assertEqual(item1.uom_id.id, uom_unit.id) + + # sell a dozen + so1 = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'order_line': [(0, 0, { + 'name': item1.name, + 'product_id': item1.id, + 'product_uom_qty': 1, + 'product_uom': uom_dozen.id, + 'price_unit': item1.list_price, + })], + }) + so1.action_confirm() + + # the move should be 12 units + # note: move.product_qty = computed field, always in the uom of the quant + # move.product_uom_qty = stored field representing the initial demand in move.product_uom + move1 = so1.picking_ids.move_lines[0] + self.assertEqual(move1.product_uom_qty, 12) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 12) + + # edit the so line, sell 2 dozen, the move should now be 24 units + so1.order_line.product_uom_qty = 2 + self.assertEqual(move1.product_uom_qty, 24) + self.assertEqual(move1.product_uom.id, uom_unit.id) + self.assertEqual(move1.product_qty, 24) + + # force the propagation of the uom, sell 3 dozen + self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1') + so1.order_line.product_uom_qty = 3 + move2 = so1.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id) + self.assertEqual(move2.product_uom_qty, 1) + self.assertEqual(move2.product_uom.id, uom_dozen.id) + self.assertEqual(move2.product_qty, 12) + + # deliver everything + move1.quantity_done = 24 + move2.quantity_done = 1 + so1.picking_ids.button_validate() + + # check the delivered quantity + self.assertEqual(so1.order_line.qty_delivered, 3.0) + diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index aeddf1bd2ce..88b98d97065 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -39,7 +39,7 @@ class ProductTemplate(models.Model): def _inverse_service_policy(self): for product in self: policy = product.service_policy - if not policy: + if not policy and not product.invoice_policy =='delivery': product.invoice_policy = 'order' product.service_type = 'manual' elif policy == 'ordered_timesheet': diff --git a/addons/sales_team/models/crm_team.py b/addons/sales_team/models/crm_team.py index cf95b66a7e2..4542116c47c 100644 --- a/addons/sales_team/models/crm_team.py +++ b/addons/sales_team/models/crm_team.py @@ -232,7 +232,7 @@ class CrmTeam(models.Model): values[index][y_field] = data_item.get('y_value') elif self.dashboard_graph_group == 'week': - weeks_in_start_year = int(date(start_date.year, 12, 31).isocalendar()[1]) + weeks_in_start_year = int(date(start_date.year, 12, 28).isocalendar()[1]) # This date is always in the last week of ISO years for week in range(0, (end_date.isocalendar()[1] - start_date.isocalendar()[1]) % weeks_in_start_year + 1): short_name = get_week_name(start_date + relativedelta(days=7 * week), locale) values.append({x_field: short_name, y_field: 0}) diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index d4db5ff5451..e164edfa331 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -365,8 +365,14 @@ class Product(models.Model): def write(self, values): res = super(Product, self).write(values) - if 'active' in values and not values['active'] and self.mapped('orderpoint_ids').filtered(lambda r: r.active): - raise UserError(_('You still have some active reordering rules on this product. Please archive or delete them first.')) + if 'active' in values and not values['active']: + products = self.mapped('orderpoint_ids').filtered(lambda r: r.active).mapped('product_id') + if products: + msg = _('You still have some active reordering rules on this product. Please archive or delete them first.') + msg += '\n\n' + for product in products: + msg += '- %s \n' % product.display_name + raise UserError(msg) return res class ProductTemplate(models.Model): diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index db19f9d1f95..a30ede5d783 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -952,7 +952,8 @@ class StockMove(models.Model): def _prepare_extra_move_vals(self, qty): vals = { 'product_uom_qty': qty, - 'picking_id': self.picking_id.id + 'picking_id': self.picking_id.id, + 'price_unit': self.price_unit, } return vals @@ -1080,6 +1081,7 @@ class StockMove(models.Model): 'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')], 'move_orig_ids': [(4, x.id) for x in self.move_orig_ids], 'origin_returned_move_id': self.origin_returned_move_id.id, + 'price_unit': self.price_unit, } return vals diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 446b55a2a0d..267308b3da7 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -8,7 +8,7 @@ import time from itertools import groupby from odoo import api, fields, models, _ from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT -from odoo.tools.float_utils import float_compare, float_round +from odoo.tools.float_utils import float_compare, float_is_zero, float_round from odoo.exceptions import UserError from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES from operator import itemgetter @@ -443,7 +443,7 @@ class Picking(models.Model): for picking in self: if self._context.get('planned_picking') and picking.state == 'draft': picking.show_validate = False - elif picking.state not in ('draft', 'confirmed', 'assigned') or not picking.is_locked: + elif picking.state not in ('draft', 'waiting', 'confirmed', 'assigned') or not picking.is_locked: picking.show_validate = False else: picking.show_validate = True @@ -594,8 +594,7 @@ class Picking(models.Model): @return: True """ # TDE FIXME: remove decorator when migration the remaining - # TDE FIXME: draft -> automatically done, if waiting ?? CLEAR ME - todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'partially_available', 'assigned', 'confirmed']) + todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'waiting', 'partially_available', 'assigned', 'confirmed']) # Check if there are ops not linked to moves yet for pick in self: # # Explode manually added packages @@ -684,10 +683,10 @@ class Picking(models.Model): # If no lots when needed, raise error picking_type = self.picking_type_id - no_quantities_done = all(line.qty_done == 0.0 for line in self.move_line_ids) - no_initial_demand = all(move.product_uom_qty == 0.0 for move in self.move_lines) - if no_initial_demand and no_quantities_done: - raise UserError(_('You cannot validate a transfer if you have not processed any quantity.')) + no_quantities_done = all(float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids) + no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids) + if no_reserved_quantities and no_quantities_done: + raise UserError(_('You cannot validate a transfer if you have not processed any quantity. You should rather cancel the transfer.')) if picking_type.use_create_lots or picking_type.use_existing_lots: lines_to_check = self.move_line_ids @@ -832,7 +831,7 @@ class Picking(models.Model): def _put_in_pack(self): package = False - for pick in self: + for pick in self.filtered(lambda p: p.state not in ('done', 'cancel')): operations = pick.move_line_ids.filtered(lambda o: o.qty_done > 0 and not o.result_package_id) operation_ids = self.env['stock.move.line'] if operations: diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py index 06cc2917502..4fc1092839d 100644 --- a/addons/stock/models/stock_production_lot.py +++ b/addons/stock/models/stock_production_lot.py @@ -42,17 +42,3 @@ class ProductionLot(models.Model): # We only care for the quants in internal or transit locations. quants = self.quant_ids.filtered(lambda q: q.location_id.usage in ['internal', 'transit']) self.product_qty = sum(quants.mapped('quantity')) - - @api.multi - def action_traceability(self): - move_ids = self.mapped('quant_ids').mapped('history_ids').ids - if not move_ids: - return False - return { - 'domain': [('id', 'in', move_ids)], - 'name': _('Traceability'), - 'view_mode': 'tree,form', - 'view_type': 'form', - 'context': {'tree_view_ref': 'stock.view_move_tree'}, - 'res_model': 'stock.move', - 'type': 'ir.actions.act_window'} diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index dfa9fc1f8f7..609bf0aa334 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -2356,19 +2356,18 @@ class StockMove(TransactionCase): self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product3, self.stock_location, lot_id=lot2, package_id=package1), 1.0) def test_immediate_validate_1(self): - """ Create a picking and simulates validate button effect. - Move line quantity should be set to their reservation quantity automatically + """ In a picking with a single available move, clicking on validate without filling any + quantities should open a wizard asking to process all the reservation (so, the whole move). """ - owner = self.env['res.partner'].create({'name': 'Jean'}) + partner = self.env['res.partner'].create({'name': 'Jean'}) picking = self.env['stock.picking'].create({ 'location_id': self.supplier_location.id, 'location_dest_id': self.stock_location.id, - 'partner_id': owner.id, + 'partner_id': partner.id, 'picking_type_id': self.env.ref('stock.picking_type_in').id, }) - # move from shelf1 self.env['stock.move'].create({ - 'name': 'test_edit_moveline_1', + 'name': 'test_immediate_validate_1', 'location_id': self.supplier_location.id, 'location_dest_id': self.stock_location.id, 'picking_id': picking.id, @@ -2379,29 +2378,28 @@ class StockMove(TransactionCase): picking.action_confirm() picking.action_assign() res_dict = picking.button_validate() + self.assertEqual(res_dict.get('res_model'), 'stock.immediate.transfer') wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) wizard.process() - self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 10.0) def test_immediate_validate_2(self): - """ Create a picking and simulates validate button effect. - Move line quantity should be set to their reservation quantity automatically. - If the Initial demand quantity is greater than the available quantity. It should only - set the quantity done to the available(reserved) quantity and create a backorder with the - remaining quantity. + """ In a picking with a single partially available move, clicking on validate without + filling any quantities should open a wizard asking to process all the reservation (so, only + a part of the initial demand). Validating this wizard should open another one asking for + the creation of a backorder. If the backorder is created, it should contain the quantities + not processed. """ - owner = self.env['res.partner'].create({'name': 'Jean'}) + partner = self.env['res.partner'].create({'name': 'Jean'}) self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 5.0) picking = self.env['stock.picking'].create({ 'location_id': self.stock_location.id, 'location_dest_id': self.customer_location.id, - 'partner_id': owner.id, + 'partner_id': partner.id, 'picking_type_id': self.env.ref('stock.picking_type_out').id, }) - # move from shelf1 self.env['stock.move'].create({ - 'name': 'test_edit_moveline_1', + 'name': 'test_immediate_validate_2', 'location_id': self.stock_location.id, 'location_dest_id': self.customer_location.id, 'picking_id': picking.id, @@ -2411,67 +2409,96 @@ class StockMove(TransactionCase): }) picking.action_confirm() picking.action_assign() + # Only 5 products are reserved on the move of 10, click on `button_validate`. res_dict = picking.button_validate() + self.assertEqual(res_dict.get('res_model'), 'stock.immediate.transfer') wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) res_dict_for_back_order = wizard.process() + self.assertEqual(res_dict_for_back_order.get('res_model'), 'stock.backorder.confirmation') backorder_wizard = self.env[(res_dict_for_back_order.get('res_model'))].browse(res_dict_for_back_order.get('res_id')) + # Chose to create a backorder. backorder_wizard.process() - self.assertEqual(len(picking.move_lines.move_line_ids), 1) + # Only 5 products should be processed on the initial move. + self.assertEqual(picking.move_lines.state, 'done') self.assertEqual(picking.move_lines.quantity_done, 5.0) - self.assertEqual(picking.move_lines.move_line_ids.qty_done, 5.0) self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0) self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 0.0) + # The backoder should contain a move for the other 5 produts. backorder = self.env['stock.picking'].search([('backorder_id', '=', picking.id)]) self.assertEqual(len(backorder), 1.0) self.assertEqual(backorder.move_lines.product_uom_qty, 5.0) def test_immediate_validate_3(self): - """ Create a picking and simulates validate button effect. - In case of a force assign it should ignore the reserved quantity and set - the quantity done to the initial demand. It should also create the move line. - """ - owner = self.env['res.partner'].create({'name': 'Jean'}) + """ In a picking with two moves, one partially available and one unavailable, clicking + on validate without filling any quantities should open a wizard asking to process all the + reservation (so, only a part of one of the moves). Validating this wizard should open + another one asking for the creation of a backorder. If the backorder is created, it should + contain the quantities not processed. + """ + product5 = self.env['product.product'].create({ + 'name': 'Product 5', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1) + picking = self.env['stock.picking'].create({ 'location_id': self.stock_location.id, - 'location_dest_id': self.customer_location.id, - 'partner_id': owner.id, - 'picking_type_id': self.env.ref('stock.picking_type_out').id, + 'location_dest_id': self.pack_location.id, + 'picking_type_id': self.env.ref('stock.picking_type_internal').id, }) - # move from shelf1 - self.env['stock.move'].create({ - 'name': 'test_edit_moveline_1', + product1_move = self.env['stock.move'].create({ + 'name': 'product1_move', 'location_id': self.stock_location.id, - 'location_dest_id': self.customer_location.id, + 'location_dest_id': self.pack_location.id, 'picking_id': picking.id, 'product_id': self.product1.id, 'product_uom': self.uom_unit.id, - 'product_uom_qty': 10.0, + 'product_uom_qty': 100, + }) + product5_move = self.env['stock.move'].create({ + 'name': 'product3_move', + 'location_id': self.stock_location.id, + 'location_dest_id': self.pack_location.id, + 'picking_id': picking.id, + 'product_id': product5.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 100, }) picking.action_confirm() - picking.force_assign() - res_dict = picking.button_validate() - wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) + picking.action_assign() + + # product1_move should be partially available (1/100), product5_move should be totally + # unavailable (0/100) + self.assertEqual(product1_move.state, 'partially_available') + self.assertEqual(product5_move.state, 'confirmed') + + action = picking.button_validate() + self.assertEqual(action.get('res_model'), 'stock.immediate.transfer') + wizard = self.env[(action.get('res_model'))].browse(action.get('res_id')) + action = wizard.process() + self.assertTrue(isinstance(action, dict), 'Should open backorder wizard') + self.assertEqual(action.get('res_model'), 'stock.backorder.confirmation') + wizard = self.env[(action.get('res_model'))].browse(action.get('res_id')) wizard.process() + backorder = self.env['stock.picking'].search([('backorder_id', '=', picking.id)]) + self.assertEqual(len(backorder), 1.0) - self.assertEqual(picking.move_lines.quantity_done, 10.0) - # Check move_lines data - self.assertEqual(len(picking.move_lines.move_line_ids), 1) - self.assertEqual(picking.move_lines.move_line_ids.qty_done, 10.0) - self.assertEqual(picking.move_lines.move_line_ids.product_qty, 0.0) - # Check quants data - self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0) - self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location, allow_negative=True), -10.0) - self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0) + # The backorder should contain 99 product1 and 100 product5. + for backorder_move in backorder.move_lines: + if backorder_move.product_id.id == self.product1.id: + self.assertEqual(backorder_move.product_qty, 99) + elif backorder_move.product_id.id == product5.id: + self.assertEqual(backorder_move.product_qty, 100) def test_immediate_validate_4(self): - """ Create a picking and simulates validate button effect. - If the product is tracked by lot. The user should set the lot - and the quantity done himself before validate. Otherwise it should - return a UserError. + """ In a picking with a single available tracked by lot move, clicking on validate without + filling any quantities should open an UserError. """ - owner = self.env['res.partner'].create({'name': 'Jean'}) + partner = self.env['res.partner'].create({'name': 'Jean'}) lot1 = self.env['stock.production.lot'].create({ 'name': 'lot1', 'product_id': self.product3.id, @@ -2480,12 +2507,12 @@ class StockMove(TransactionCase): picking = self.env['stock.picking'].create({ 'location_id': self.stock_location.id, 'location_dest_id': self.customer_location.id, - 'partner_id': owner.id, + 'partner_id': partner.id, 'picking_type_id': self.env.ref('stock.picking_type_out').id, }) # move from shelf1 self.env['stock.move'].create({ - 'name': 'test_edit_moveline_1', + 'name': 'test_immediate_validate_4', 'location_id': self.stock_location.id, 'location_dest_id': self.customer_location.id, 'picking_id': picking.id, @@ -2495,10 +2522,11 @@ class StockMove(TransactionCase): }) picking.action_confirm() picking.action_assign() + # No quantites/lot filled, it should raise. with self.assertRaises(UserError): picking.button_validate() picking.move_lines.move_line_ids[0].qty_done = 5.0 - # We do not need to catch wizard since everything is already defined and the action_done will be processed immediately + # All the information are present (lots and quantities), the wizard won't be opened. picking.button_validate() self.assertEqual(picking.move_lines.quantity_done, 5.0) @@ -2535,9 +2563,9 @@ class StockMove(TransactionCase): return picking def test_immediate_validate_5(self): - """ Create a picking and simulates validate button effect. - Test that tracked products can be received without specifying a serial - number when the picking type is configured that way. + """ In a receipt with a single tracked by serial numbers move, clicking on validate without + filling any quantities nor lot should open an UserError except if the picking type is + configured to allow otherwise. """ picking_type_id = self.env.ref('stock.picking_type_in') product_id = self.product2 @@ -2555,23 +2583,17 @@ class StockMove(TransactionCase): self.assertEqual(picking.state, 'done') def test_immediate_validate_6(self): - """ Create a picking and simulates validate button effect. - This tests three cases: - - if a user has processed no quantities and at least one product is tracked, - he should specify lots - - if a user has processed some quantities then it's ok because a backorder - will be created for the ones not filled in - - if a user overprocesses a move he will be asked to confirm if this is ok + """ In a receipt picking with two moves, one tracked and one untracked, clicking on + validate without filling any quantities should displays an UserError as long as no quantity + done and lot_name is set on the tracked move. Now if the user validates the picking, the + wizard telling the user all reserved quantities will be processed will NOT be opened. This + wizard is only opene if no quantities were filled. So validating the picking at this state + will open another wizard asking for the creation of a backorder. Now, if the user processed + on the second move more than the reservation, a wizard will ask him to confirm. """ picking_type = self.env.ref('stock.picking_type_in') picking_type.use_create_lots = True picking_type.use_existing_lots = False - lot1 = self.env['stock.production.lot'].create({ - 'name': 'lot1', - 'product_id': self.product3.id, - }) - self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1) - self.env['stock.quant']._update_available_quantity(self.product3, self.stock_location, 1, lot_id=lot1) picking = self.env['stock.picking'].create({ 'location_id': self.supplier_location.id, 'location_dest_id': self.stock_location.id, @@ -2597,16 +2619,15 @@ class StockMove(TransactionCase): }) picking.action_confirm() picking.action_assign() + with self.assertRaises(UserError): picking.button_validate() - product3_move.move_line_ids[0].qty_done = 1 - # should still complain about lots because no lot_{name,id} was set with self.assertRaises(UserError): picking.button_validate() - product3_move.move_line_ids[0].lot_name = '271828' action = picking.button_validate() # should open backorder wizard + self.assertTrue(isinstance(action, dict), 'Should open backorder wizard') self.assertEqual(action.get('res_model'), 'stock.backorder.confirmation') @@ -2615,6 +2636,33 @@ class StockMove(TransactionCase): self.assertTrue(isinstance(action, dict), 'Should open overprocessing wizard') self.assertEqual(action.get('res_model'), 'stock.overprocessed.transfer') + def test_immediate_validate_7(self): + """ In a picking with a single unavailable move, clicking on validate without filling any + quantities should display an UserError telling the user he cannot process a picking without + any processed quantity. + """ + partner = self.env['res.partner'].create({'name': 'Jean'}) + picking = self.env['stock.picking'].create({ + 'location_id': self.stock_location.id, + 'location_dest_id': self.customer_location.id, + 'partner_id': partner.id, + 'picking_type_id': self.env.ref('stock.picking_type_out').id, + }) + self.env['stock.move'].create({ + 'name': 'test_immediate_validate_2', + 'location_id': self.stock_location.id, + 'location_dest_id': self.customer_location.id, + 'picking_id': picking.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 10.0, + }) + picking.action_confirm() + picking.action_assign() + # No products are reserved on the move of 10, click on `button_validate`. + with self.assertRaises(UserError): + picking.button_validate() + def test_set_quantity_done_1(self): move1 = self.env['stock.move'].create({ 'name': 'test_set_quantity_done_1', diff --git a/addons/stock/wizard/stock_immediate_transfer.py b/addons/stock/wizard/stock_immediate_transfer.py index e137f5ad85d..66a652ac795 100644 --- a/addons/stock/wizard/stock_immediate_transfer.py +++ b/addons/stock/wizard/stock_immediate_transfer.py @@ -23,11 +23,8 @@ class StockImmediateTransfer(models.TransientModel): if picking.state != 'assigned': raise UserError(_("Could not reserve all requested products. Please use the \'Mark as Todo\' button to handle the reservation manually.")) for move in picking.move_lines: - if move.move_line_ids: - for move_line in move.move_line_ids: - move_line.qty_done = move_line.product_uom_qty - else: - move.quantity_done = move.product_uom_qty + for move_line in move.move_line_ids: + move_line.qty_done = move_line.product_uom_qty if picking._check_backorder(): pick_to_backorder |= picking continue diff --git a/addons/stock_account/models/account_invoice.py b/addons/stock_account/models/account_invoice.py index 9547c0625e7..8614fde2f6f 100644 --- a/addons/stock_account/models/account_invoice.py +++ b/addons/stock_account/models/account_invoice.py @@ -30,13 +30,13 @@ class AccountInvoice(models.Model): company_currency = inv.company_id.currency_id price_unit = i_line._get_anglo_saxon_price_unit() if inv.currency_id != company_currency: - currency_id = inv.currency_id.id + currency = inv.currency_id amount_currency = i_line._get_price(company_currency, price_unit) else: - currency_id = False + currency = False amount_currency = False - return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency_id, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids) + return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids) class AccountInvoiceLine(models.Model): diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index 93bc4c980ae..098e976fc7e 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -234,7 +234,7 @@ class ProductProduct(models.Model): 'product_id': product.id, 'uom_id': uom.id, 'account_analytic_id': account_analytic and account_analytic.id, - 'analytic_tag_ids': analytic_tags and analytic_tag.ids and [(6, 0, analytic_tag.ids)] or False, + 'analytic_tag_ids': analytic_tags and analytic_tags.ids and [(6, 0, analytic_tags.ids)] or False, }, { @@ -259,7 +259,7 @@ class ProductProduct(models.Model): price = self.standard_price if not uom or self.uom_id.id == uom.id: return price - return self.uom_id._compute_price(self.uom_id.id, price, to_uom_id=uom.id) + return self.uom_id._compute_price(price, uom) class ProductCategory(models.Model): diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index b0cf820005b..913edc89932 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1382,7 +1382,7 @@ class ExportFormat(object): model, fields, ids, domain, import_compat = \ operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params) - Model = request.env[model].with_context(**params.get('context', {})) + Model = request.env[model].with_context(import_compat=import_compat, **params.get('context', {})) records = Model.browse(ids) or Model.search(domain, offset=0, limit=False, order=False) if not Model._is_an_ordinary_table(): diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js index e725aaee8f6..4b32ffecfb3 100644 --- a/addons/web/static/src/js/chrome/web_client.js +++ b/addons/web/static/src/js/chrome/web_client.js @@ -59,7 +59,7 @@ return AbstractWebClient.extend({ update_logo: function(reload) { var company = session.company_id; var img = session.url('/web/binary/company_logo' + '?db=' + session.db + (company ? '&company=' + company : '')); - this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "#" + Date.now() : '')); + this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "&t=" + Date.now() : '')); this.$('.oe_logo_edit').toggleClass('oe_logo_edit_admin', session.is_superuser); }, logo_edit: function(ev) { @@ -83,13 +83,8 @@ return AbstractWebClient.extend({ action_buttons: true, headless: true, }; - self.action_manager.do_action(result).then(function () { - var form = self.action_manager.dialog_widget.views.form.controller; - form.on("on_button_cancel", self.action_manager, self.action_manager.dialog_stop); - form.on('record_saved', self, function() { - self.action_manager.dialog_stop(); - self.update_logo(); - }); + self.action_manager.do_action(result, { + on_close: self.update_logo.bind(self, true), }); }); }); diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js index 75aaf0105ed..8ce570b8605 100644 --- a/addons/web/static/src/js/core/domain.js +++ b/addons/web/static/src/js/core/domain.js @@ -240,7 +240,7 @@ var Domain = collections.Tree.extend({ */ stringToArray: function (domain, evalContext) { if (!_.isString(domain)) return _.clone(domain); - return pyeval.eval("domain", domain || "[]", evalContext); + return pyeval.eval("domain", domain ? domain.replace(/%%/g, '%') : "[]", evalContext); }, /** * Makes implicit "&" operators explicit in the given JS prefix-array diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 1bdd9e70a69..251cd2b508c 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -392,6 +392,23 @@ var utils = { array[i2] = elem1; array[i1] = elem2; }, + + /** + * @param {string} value + * @param {boolean} allow_mailto + * @returns boolean + */ + is_email: function (value, allow_mailto) { + // http://stackoverflow.com/questions/46155/validate-email-address-in-javascript + var re; + if (allow_mailto) { + re = /^(mailto:)?(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; + } else { + re = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; + } + return re.test(value); + }, + /** * @param {any} str * @param {any} elseValues diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 432d810bbbb..6f07ac1c2f8 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -332,7 +332,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { } self.model.discardChanges(recordID); if (self.model.isNew(recordID)) { - self._abandonRecord(recordID); + if (self.model.canBeAbandoned(recordID)) { + self._abandonRecord(recordID); + } return; } return self._confirmSave(recordID); 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 b5b668a43a1..6eef41f149d 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -478,6 +478,30 @@ var BasicModel = AbstractModel.extend({ return _t("New"); }, /** + * Returns true if a record can be abandoned from a list datapoint. + * + * A record cannot be abandonned if it has been registered as "added" + * in the parent's savepoint, otherwise it can be abandonned. + * + * This is useful when discarding changes on this record, as it means that + * we must keep the record even if some fields are invalids (e.g. required + * field is empty). + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + canBeAbandoned: function (id) { + var data = this.localData[id]; + var parent = this.localData[data.parentID]; + var abandonable = true; + if (parent) { + abandonable = !_.some(parent._savePoint, function (entry) { + return entry.operation === 'ADD' && entry.id === id; + }); + } + return abandonable; + }, + /** * Returns true if a record is dirty. A record is considered dirty if it has * some unsaved changes, marked by the _isDirty property on the record or * one of its subrecords. @@ -1428,25 +1452,23 @@ var BasicModel = AbstractModel.extend({ defs.push(self._applyOnChange(command[2], rec)); } else if (command[0] === 4) { // LINK TO - rec = self.localData[list._cache[command[1]]]; - if (rec) { - // modifications done on a record are discarded if - // the onchange uses a LINK TO - self.discardChanges(rec.id); - } - // the dataPoint id will be set when the record will be - // fetched (for now, this dataPoint may not exist yet) - list._changes.push({ - operation: 'ADD', - id: rec ? rec.id : null, - resID: command[1], - }); + linkRecord(list, command[1]); } else if (command[0] === 5) { // DELETE ALL list._changes = [{operation: 'REMOVE_ALL'}]; + } else if (command[0] === 6) { + list._changes = [{operation: 'REMOVE_ALL'}]; + _.each(command[2], function (resID) { + linkRecord(list, resID); + }); } }); - defs.push(self._readUngroupedList(list)); + var def = self._readUngroupedList(list).then(function () { + var x2ManysDef = self._fetchX2ManysBatched(list); + var referencesDef = self._fetchReferencesBatched(list); + return $.when(x2ManysDef, referencesDef); + }); + defs.push(def); } else { var newValue = self._parseServerValue(field, val); if (newValue !== oldValue) { @@ -1455,6 +1477,25 @@ var BasicModel = AbstractModel.extend({ } }); return $.when.apply($, defs); + + // inner function that adds a record (based on its res_id) to a list + // dataPoint (used for onchanges that return commands 4 (LINK TO) or + // commands 6 (REPLACE WITH)) + function linkRecord (list, resID) { + rec = self.localData[list._cache[resID]]; + if (rec) { + // modifications done on a record are discarded if the onchange + // uses a LINK TO or a REPLACE WITH + self.discardChanges(rec.id); + } + // the dataPoint id will be set when the record will be fetched (for + // now, this dataPoint may not exist yet) + list._changes.push({ + operation: 'ADD', + id: rec ? rec.id : null, + resID: resID, + }); + } }, /** * When an operation is applied to a x2many field, the field widgets @@ -1951,7 +1992,9 @@ var BasicModel = AbstractModel.extend({ _.each(list.data, function (dataPoint) { var record = self.localData[dataPoint]; var value = record.data[fieldName]; - if (value) { + // if the reference field has already been fetched, the value is a + // datapoint ID, and in this case there's nothing to do + if (value && !self.localData[value]) { var model = value.split(',')[0]; var resID = value.split(',')[1]; if (!(model in toFetch)) { 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 df6172f0d8b..0b0ec09ddf4 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -73,7 +73,6 @@ var KanbanModel = BasicModel.extend({ domain: parent.domain.concat([[groupBy,"=",result[0]]]), fields: parent.fields, fieldsInfo: parent.fieldsInfo, - groupedBy: parent.groupedBy, isOpen: true, limit: parent.limit, parentID: parent.id, diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index b57d44e7de2..48078e2a22c 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -54,8 +54,13 @@ ListRenderer.include({ * @returns {Deferred} */ start: function () { - if (this._isEditable()) { - this.$el.css({height: '100%'}); + // deliberately use the 'editable' attribute instead of '_isEditable' + // function, because the groupBy must not be taken into account to + // enable the '_onWindowClicked' handler (otherwise, an editable grouped + // list which is reloaded without groupBy wouldn't have this handler + // bound, and edited rows couldn't be left by clicking outside the list) + if (this.editable) { + this.$el.css({height: '100%'}); // seems useless: to remove in master core.bus.on('click', this, this._onWindowClicked.bind(this)); } return this._super(); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 0782d667690..16f4d29e22a 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1154,6 +1154,60 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('list in form: discard newly added element with empty required field', function (assert) { + // This test simulates discarding a record that has been created with + // one of its required field that is empty. When we discard the changes + // on this empty field, it should not assume that this record should be + // abandonned, since it has been added (even though it is a new record). + assert.expect(6); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="display_name"/>' + + '<field name="trululu" required="1"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({p: [[0, 0, {display_name: 'new record', trululu: false}]]}); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual($('tr.o_data_row').length, 1, + "should have created the new record in the o2m"); + + var requiredElement = $('td.o_data_cell.o_required_modifier'); + assert.strictEqual(requiredElement.length, 1, + "should have a required field on this record"); + assert.strictEqual(requiredElement.text(), "", + "should have empty string in the required field on this record"); + + requiredElement.click(); + // discard by clicking on body + $('body').click(); + + assert.strictEqual($('tr.o_data_row').length, 1, + "should still have the record in the o2m"); + + // update selector of required field element + requiredElement = $('td.o_data_cell.o_required_modifier'); + assert.strictEqual(requiredElement.length, 1, + "should still have the required field on this record"); + assert.strictEqual(requiredElement.text(), "", + "should still have empty string in the required field on this record"); + form.destroy(); + }); + QUnit.test('list in form: default_get with x2many create', function (assert) { assert.expect(5); @@ -2852,6 +2906,163 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('onchange returning a command 6 for an x2many', function (assert) { + assert.expect(2); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.turtles = [[6, false, [1, 2, 3]]]; + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form>' + + '<field name="foo"/>' + + '<field name="turtles">' + + '<tree>' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "there should be one record in the relation"); + + // change the value of foo to trigger the onchange + form.$('.o_field_widget[name=foo]').val('some value').trigger('input'); + + assert.strictEqual(form.$('.o_data_row').length, 3, + "there should be three records in the relation"); + + form.destroy(); + }); + + QUnit.test('x2many fields inside x2manys are fetched after an onchange', function (assert) { + assert.expect(6); + + this.data.turtle.records[0].partner_ids = [1]; + this.data.partner.onchanges = { + foo: function (obj) { + obj.turtles = [[5], [4, 1], [4, 2], [4, 3]]; + }, + }; + + var checkRPC = false; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form>' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '<field name="turtles">' + + '<tree>' + + '<field name="turtle_foo"/>' + + '<field name="partner_ids" widget="many2many_tags"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (checkRPC && args.method === 'read' && args.model === 'partner') { + assert.deepEqual(args.args[1], ['display_name'], + "should only read the display_name for the m2m tags"); + assert.deepEqual(args.args[0], [1], + "should only read the display_name of the unknown record"); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "there should be one record in the relation"); + assert.strictEqual(form.$('.o_data_row .o_field_widget[name=partner_ids]').text().replace(/\s/g, ''), + 'secondrecordaaa', "many2many_tags should be correctly displayed"); + + // change the value of foo to trigger the onchange + checkRPC = true; // enable flag to check read RPC for the m2m field + form.$('.o_field_widget[name=foo]').val('some value').trigger('input'); + + assert.strictEqual(form.$('.o_data_row').length, 3, + "there should be three records in the relation"); + assert.strictEqual(form.$('.o_data_row:first .o_field_widget[name=partner_ids]').text().trim(), + 'first record', "many2many_tags should be correctly displayed"); + + form.destroy(); + }); + + QUnit.test('reference fields inside x2manys are fetched after an onchange', function (assert) { + assert.expect(5); + + this.data.turtle.records[1].turtle_ref = 'product,41'; + this.data.partner.onchanges = { + foo: function (obj) { + obj.turtles = [[5], [4, 1], [4, 2], [4, 3]]; + }, + }; + + var checkRPC = false; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form>' + + '<sheet>' + + '<group>' + + '<field name="foo"/>' + + '<field name="turtles">' + + '<tree>' + + '<field name="turtle_foo"/>' + + '<field name="turtle_ref" class="ref_field"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (checkRPC && args.method === 'name_get') { + assert.deepEqual(args.args[0], [37], + "should only fetch the name_get of the unknown record"); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "there should be one record in the relation"); + assert.strictEqual(form.$('.ref_field').text().trim(), 'xpad', + "reference field should be correctly displayed"); + + // change the value of foo to trigger the onchange + checkRPC = true; // enable flag to check read RPC for reference field + form.$('.o_field_widget[name=foo]').val('some value').trigger('input'); + + assert.strictEqual(form.$('.o_data_row').length, 3, + "there should be three records in the relation"); + assert.strictEqual(form.$('.ref_field').text().trim(), 'xpadxphone', + "reference fields should be correctly displayed"); + + form.destroy(); + }); + QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) { var done = assert.async(); assert.expect(20); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 00625e1e30c..daa68c6c638 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -1947,6 +1947,62 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('archive new kanban column', function (assert) { + assert.expect(15); + + this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban>' + + '<field name="product_id"/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route) { + assert.step(route); + return this._super.apply(this, arguments); + }, + }); + var rpcs = ['/web/dataset/call_kw/partner/read_group', '/web/dataset/search_read', '/web/dataset/search_read']; + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, + "there should be 2 columns"); + + // create a column + kanban.$('.o_column_quick_create').click(); + kanban.$('.o_column_quick_create input').val('new colum'); + kanban.$('.o_column_quick_create button.o_kanban_add').click(); + rpcs.push('/web/dataset/call_kw/product/name_create'); + assert.verifySteps(rpcs); + + // add a record inside + kanban.$('.o_kanban_group:eq(2) .o_kanban_quick_add i').click(); + var $quickCreate = kanban.$('.o_kanban_group:eq(2) .o_kanban_quick_create'); + $quickCreate.find('input').val('new record'); + $quickCreate.find('button.o_kanban_add').click(); + assert.strictEqual(kanban.$('.o_kanban_group').length, 3, + "there should be 3 columns"); + rpcs.push('/web/dataset/call_kw/partner/name_create'); + rpcs.push('/web/dataset/call_kw/partner/read'); + assert.verifySteps(rpcs); + + var $newColumn = kanban.$('.o_kanban_group:eq(2)'); + assert.strictEqual($newColumn.find('.o_kanban_record').length, 1, + "there should be 1 record in the new column"); + $newColumn.find('.o_column_archive').click(); + assert.strictEqual($newColumn.find('.o_kanban_record').length, 0, + "there should not be partners anymore"); + rpcs.push('/web/dataset/call_kw/partner/write'); + rpcs.push('/web/dataset/search_read'); + assert.verifySteps(rpcs, "a search_read should be done after archiving the records"); + + kanban.destroy(); + }); + QUnit.test('load more records in column', function (assert) { assert.expect(12); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 92ae4033562..f1c36aac0a7 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2751,10 +2751,13 @@ QUnit.module('Views', { list.destroy(); }); - QUnit.test('grouped list are not editable', function (assert) { + QUnit.test('grouped list are not editable (ungrouped first)', function (assert) { // Editable grouped list views are not supported, so the purpose of this // test is to check that when a list view is grouped, its editable // attribute is ignored + // In this test, the view isn't grouped at the beginning, so it is first + // editable, and then it is reloaded with a groupBy and is no longer + // editable assert.expect(5); var list = createView({ @@ -2790,6 +2793,59 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('grouped list are not editable (grouped first)', function (assert) { + // Editable grouped list views are not supported, so the purpose of this + // test is to check that when a list view is grouped, its editable + // attribute is ignored + // In this test, the view is grouped at the beginning, so it isn't + // editable, and then it is reloaded with no groupBy and becomes editable + assert.expect(6); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>', + intercepts: { + switch_view: function (event) { + var resID = event.data.res_id || false; + assert.step('switch view ' + event.data.view_type + ' ' + resID); + }, + }, + groupBy: ['bar'], + }); + + // the view being grouped, it is not editable, so clicking on a record + // should open the form view + list.$('.o_group_header:first').click(); // open first group + list.$('.o_data_cell:first').click(); + + // for the same reason, clicking on 'Create' should open the form view + list.$buttons.find('.o_list_button_add').click(); + + assert.verifySteps(['switch view form 1', 'switch view form false'], + "two switch view to form should have been requested"); + + // reload without groupBy + list.reload({groupBy: []}); + + // as the view is no longer grouped, it is editable, so clicking on a + // row should switch it in edition + list.$('.o_data_cell:first').click(); + + assert.verifySteps(['switch view form 1', 'switch view form false'], + "no more switch view should have been requested"); + assert.strictEqual(list.$('.o_selected_row').length, 1, + "a row should be in edition"); + + // clicking on the body should leave the edition + $('body').click(); + assert.strictEqual(list.$('.o_selected_row').length, 0, + "the row should no longer be in edition"); + + list.destroy(); + }); + QUnit.test('field values are escaped', function (assert) { assert.expect(1); var value = '<script>throw Error();</script>'; diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js index 0a89d0d8ce7..64502b41c31 100644 --- a/addons/web_editor/static/src/js/widgets/ace.js +++ b/addons/web_editor/static/src/js/widgets/ace.js @@ -58,6 +58,10 @@ function checkXML(xml) { * @returns {string} formatted xml */ function formatXML(xml) { + // do nothing if an inline script is present to avoid breaking it + if (/<script(?: [^>]*)?>[^<][\s\S]*<\/script>/i.test(xml)) { + return xml; + } return window.vkbeautify.xml(xml, 4); } /** diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js index 8d7b4224b76..a7046eb11e8 100644 --- a/addons/web_editor/static/src/js/widgets/widgets.js +++ b/addons/web_editor/static/src/js/widgets/widgets.js @@ -4,6 +4,7 @@ odoo.define('web_editor.widget', function (require) { var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); +var utils = require('web.utils'); var weContext = require("web_editor.context"); var QWeb = core.qweb; @@ -1208,6 +1209,7 @@ var LinkDialog = Dialog.extend({ if (!$e.length) { $e = this.$('input.url-source:first'); } + $e.closest('.form-group').removeClass('has-error'); var val = $e.val(); var label = this.$('#o_link_dialog_label_input').val() || val; @@ -1228,8 +1230,7 @@ var LinkDialog = Dialog.extend({ var size = this.$("select.link-style").val() || ''; var classes = (this.data.className || "") + (style && style.length ? " btn " : "") + style + " " + size; var isNewWindow = this.$('input.window-new').prop('checked'); - - if ($e.hasClass('email-address') && $e.val().indexOf("@") !== -1) { + if ($e.hasClass('email-address') && (_.str.startsWith(val, 'mailto:') || (val.indexOf("@") !== -1 && !_.str.startsWith(val, 'http') && !_.str.startsWith(val, 'www')))) { self.get_data_buy_mail(def, $e, isNewWindow, label, classes, test); } else { self.get_data_buy_url(def, $e, isNewWindow, label, classes, test); @@ -1238,7 +1239,13 @@ var LinkDialog = Dialog.extend({ }, get_data_buy_mail: function (def, $e, isNewWindow, label, classes, test) { var val = $e.val(); - def.resolve(val.indexOf("mailto:") === 0 ? val : 'mailto:' + val, isNewWindow, label, classes); + if (utils.is_email(val, true)) { + def.resolve(val.indexOf("mailto:") === 0 ? val : 'mailto:' + val, isNewWindow, label, classes); + } else { + $e.closest('.form-group').addClass('has-error'); + $e.focus(); + def.reject(); + } }, get_data_buy_url: function (def, $e, isNewWindow, label, classes, test) { def.resolve($e.val(), isNewWindow, label, classes); @@ -1322,5 +1329,9 @@ return { getCssSelectors: getCssSelectors, fontIcons: fontIcons, computeFonts: computeFonts, + click_event: click_event, + fontIconsDialog: fontIconsDialog, + ImageDialog: ImageDialog, + VideoDialog: VideoDialog, }; }); diff --git a/addons/website/models/website.py b/addons/website/models/website.py index d6deb298eb2..64b9934c126 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -520,7 +520,7 @@ class Website(models.Model): if not force: domain += [('website_indexed', '=', True)] #is_visible - domain += [('website_published', '=', True), '|', ('date_publish', '!=', False), ('date_publish', '>', fields.Datetime.now())] + domain += [('website_published', '=', True), '|', ('date_publish', '=', False), ('date_publish', '<=', fields.Datetime.now())] if query_string: domain += [('url', 'like', query_string)] @@ -875,12 +875,12 @@ class Menu(models.Model): replace_id(mid, new_menu.id) for menu in data['data']: menu_id = self.browse(menu['id']) - if menu_id.page_id: - menu_id.page_id.write({'url': menu['url']}) # if the url match a website.page, set the m2o relation page = self.env['website.page'].search([('url', '=', menu['url'])], limit=1) if page: menu['page_id'] = page.id + elif menu_id.page_id: + menu_id.page_id.write({'url': menu['url']}) menu_id.write(menu) return True diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index abae2c3fdfc..1d788f0db55 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -117,13 +117,16 @@ </xpath> <xpath expr="//t[@t-set='head_website']" position="replace"> <t t-set="head_website"> + <meta name="generator" content="Odoo"/> + <meta t-if="main_object and 'website_indexed' in main_object and not main_object.website_indexed" name="robots" content="noindex"/> - <meta name="description" t-att-content="main_object and 'website_meta_description' in main_object + <t t-set="meta_description" t-value="main_object and 'website_meta_description' in main_object and main_object.website_meta_description or website_meta_description"/> - <meta name="keywords" t-att-content="main_object and 'website_meta_keywords' in main_object + <t t-set="meta_keywords" t-value="main_object and 'website_meta_keywords' in main_object and main_object.website_meta_keywords or website_meta_keywords"/> - <meta name="generator" content="Odoo"/> + <meta t-if="meta_description" name="description" t-att-content="meta_description"/> + <meta t-if="meta_keywords" name="keywords" t-att-content="meta_keywords"/> <!-- OpenGraph tags for Facebook sharing --> <meta property="og:title" t-att-content="additional_title" /> diff --git a/addons/website_event/models/event.py b/addons/website_event/models/event.py index cd551f0f451..c2b37b0fab2 100644 --- a/addons/website_event/models/event.py +++ b/addons/website_event/models/event.py @@ -23,8 +23,8 @@ class Event(models.Model): website_menu = fields.Boolean( 'Dedicated Menu', compute='_compute_website_menu', inverse='_set_website_menu', help="Creates menus Introduction, Location and Register on the page " - " of the event on the website.", store=True, copy=False) - menu_id = fields.Many2one('website.menu', 'Event Menu') + " of the event on the website.", store=True) + menu_id = fields.Many2one('website.menu', 'Event Menu', copy=False) def _compute_is_participating(self): # we don't allow public user to see participating label diff --git a/addons/website_forum/controllers/main.py b/addons/website_forum/controllers/main.py index 94043cdb0b0..ac84166d528 100644 --- a/addons/website_forum/controllers/main.py +++ b/addons/website_forum/controllers/main.py @@ -225,9 +225,9 @@ class WebsiteForum(http.Controller): @http.route('/forum/get_url_title', type='json', auth="user", methods=['POST'], website=True) def get_url_title(self, **kwargs): try: - req = requests.get(kwargs.get('url'), stream=True) + req = requests.get(kwargs.get('url')) req.raise_for_status() - arch = lxml.html.parse(req.raw) + arch = lxml.html.fromstring(req.content) return arch.find(".//title").text except IOError: return False diff --git a/addons/website_quote/models/sale_order.py b/addons/website_quote/models/sale_order.py index a54e8368c24..a798cb01b52 100644 --- a/addons/website_quote/models/sale_order.py +++ b/addons/website_quote/models/sale_order.py @@ -70,9 +70,16 @@ class SaleOrder(models.Model): require_payment = fields.Selection([ (0, 'Online Signature'), (1, 'Online Payment')], default=_get_default_online_payment, string='Confirmation Mode', + compute='_compute_require_payment', store=True, help="Choose how you want to confirm an order to launch the delivery process. You can either " "request a digital signature or an upfront payment. With a digital signature, you can " "request the payment when issuing the invoice.") + @api.one + @api.depends('template_id') + def _compute_require_payment(self): + # Do not overwrite if the field is already set + if self.template_id and (self.require_payment is None or self.require_payment is False): + self.require_payment = self.template_id.require_payment @api.multi def copy(self, default=None): diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 1c3c8bb467b..7764600d14a 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -600,14 +600,11 @@ <input type="hidden" t-if="len(product.product_variant_ids) == 1" class="product_id" name="product_id" t-att-value="product.product_variant_id.id"/> <t t-if="len(product.product_variant_ids) > 1"> <label label-default="label-default" class="radio" t-foreach="product.product_variant_ids" t-as="variant_id"> - <input type="radio" name="product_id" class="js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-att-value="variant_id.id" t-att-data-lst_price="variant_id.lst_price" t-att-data-price="variant_id.price"/> + <input type="radio" name="product_id" class="js_product_change" t-att-checked="'checked' if variant_id_index == 0 else ''" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price"/> <span t-esc="variant_id.name_get()[0][1]"/> - <span class="badge" t-if="variant_id.price_extra"> - <t t-esc="variant_id.price_extra > 0 and '+' or ''"/><span t-field="variant_id.price_extra" style="white-space: nowrap;" t-options='{ - "widget": "monetary", - "from_currency": product.currency_id, - "display_currency": website.currency_id - }'/> + <t t-set="diff_price" t-value="variant_id.price - variant_id.product_tmpl_id.price"/> + <span class="badge" t-if="diff_price != 0"> + <t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(diff_price)" t-esc-options='{"widget": "monetary", "display_currency": "website.currency_id"}'/> </span> </label> </t> diff --git a/doc/_extensions/autojsdoc/parser/tests/test_typespec.py b/doc/_extensions/autojsdoc/parser/tests/test_typespec.py index d90fe7995dd..b2a5a5258b2 100644 --- a/doc/_extensions/autojsdoc/parser/tests/test_typespec.py +++ b/doc/_extensions/autojsdoc/parser/tests/test_typespec.py @@ -35,6 +35,16 @@ def test_parser(): types.Literal('null'), ])]) ]) + assert types.parse('Function<Array<Object[]>>') == types.Alt([ + types.Type('Function', [types.Alt([ + types.Type('Array', [types.Alt([ + types.Type('Array', [ + types.Type('Object', []) + ]) + ])]) + ])]) + ]) + def test_tokens(): toks = list(types.tokenize('A')) @@ -64,6 +74,19 @@ def test_tokens(): (types.OP, '>') ] + toks = list(types.tokenize('Function<Array<Object[]>>')) + assert toks == [ + (types.NAME, 'Function'), + (types.OP, '<'), + (types.NAME, 'Array'), + (types.OP, '<'), + (types.NAME, 'Object'), + (types.OP, '['), + (types.OP, ']'), + (types.OP, '>'), + (types.OP, '>') + ] + def test_peekable(): p = types.Peekable(range(5)) diff --git a/doc/_extensions/autojsdoc/parser/types.py b/doc/_extensions/autojsdoc/parser/types.py index ecbccfe8651..8737b26ecc9 100644 --- a/doc/_extensions/autojsdoc/parser/types.py +++ b/doc/_extensions/autojsdoc/parser/types.py @@ -29,6 +29,9 @@ def tokenize(typespec): elif toktype == token.OP: if string in '|<>[].,': yield (OP, string) + elif string == '>>': + yield (OP, '>') + yield (OP, '>') elif string == '*': # maybe? yield (NAME, 'any') elif string in '()': diff --git a/doc/cla/individual/osarrat.md b/doc/cla/individual/osarrat.md new file mode 100644 index 00000000000..bc1df33fd79 --- /dev/null +++ b/doc/cla/individual/osarrat.md @@ -0,0 +1,11 @@ +France, 2018-02-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, + +Olivier Sarrat olivier.sarrat@laposte.net https://github.com/osarrat diff --git a/doc/cla/individual/subodhdahal.md b/doc/cla/individual/subodhdahal.md new file mode 100644 index 00000000000..4e4c389bc99 --- /dev/null +++ b/doc/cla/individual/subodhdahal.md @@ -0,0 +1,11 @@ +Nepal, 2018-02-09 + +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, + +Subodh Dahal contact.subodhdahal@gmail.com https://github.com/SubodhDahal diff --git a/doc/cla/individual/willmakenoise.md b/doc/cla/individual/willmakenoise.md new file mode 100644 index 00000000000..3c8ac824146 --- /dev/null +++ b/doc/cla/individual/willmakenoise.md @@ -0,0 +1,10 @@ +Norway, 2018-02-08 + +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, +Marius Stedjan marius@stedjan.com https://github.com/willmakenoise diff --git a/doc/reference/cmdline.rst b/doc/reference/cmdline.rst index d35868a718f..2f1626f6dac 100644 --- a/doc/reference/cmdline.rst +++ b/doc/reference/cmdline.rst @@ -54,7 +54,7 @@ Running the server Maximum allowed virtual memory per worker. If the limit is exceeded, the worker is killed and recycled at the end of the current request. - Defaults to 640MB. + Defaults to 2048MB. .. option:: --limit-memory-hard <limit> @@ -62,7 +62,7 @@ Running the server immediately killed without waiting for the end of the current request processing. - Defaults to 768MB. + Defaults to 2560MB. .. option:: --limit-time-cpu <limit> diff --git a/doc/setup/deploy.rst b/doc/setup/deploy.rst index b0a5cf3224e..6a1e22f886c 100644 --- a/doc/setup/deploy.rst +++ b/doc/setup/deploy.rst @@ -214,6 +214,13 @@ Instead you must have a proxy redirecting requests whose URL starts with ``/longpolling/`` to the longpolling port. Other request should be proxied to the :option:`normal HTTP port <odoo-bin --http-port>` +To achieve such a thing, you'll need to deploy a reverse proxy in front of Odoo, +like nginx or apache. When doing so, you'll need to forward some more http Headers +to Odoo, and activate the proxy_mode in Odoo configuration to have Odoo read those +headers. + + + Configuration sample -------------------- @@ -316,16 +323,18 @@ in ``/etc/nginx/sites-enabled/odoo.conf`` set: # log access_log /var/log/nginx/odoo.access.log; error_log /var/log/nginx/odoo.error.log; - + + # Redirect longpoll requests to odoo longpolling port + location /longpolling { + proxy_pass http://odoochat; + } + # Redirect requests to odoo backend server location / { proxy_redirect off; proxy_pass http://odoo; } - location /longpolling { - proxy_pass http://odoochat; - } - + # common gzip gzip_types text/css text/less text/plain text/xml application/xml application/json application/javascript; gzip on; diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 996efde0721..25d7143036b 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -539,7 +539,7 @@ class Partner(models.Model): self.ensure_one() if self.company_name: # Create parent company - values = dict(name=self.company_name, is_company=True) + values = dict(name=self.company_name, is_company=True, vat=self.vat) values.update(self._update_fields_values(self._address_fields())) new_company = self.create(values) # Set new company as my parent diff --git a/odoo/addons/base/views/ir_model_views.xml b/odoo/addons/base/views/ir_model_views.xml index f914807dd54..27699e330df 100644 --- a/odoo/addons/base/views/ir_model_views.xml +++ b/odoo/addons/base/views/ir_model_views.xml @@ -459,7 +459,7 @@ <group col="4"> <field name="type"/> <field name="name"/> - <field name="module"/> + <field name="module" can_create="false"/> <field name="model"/> <newline/> <field name="date_update" /> @@ -495,7 +495,7 @@ <form string="ManyToMany Relations"> <group col="4"> <field name="name"/> - <field name="module"/> + <field name="module" can_create="false"/> <field name="model"/> <newline/> <field name="date_update" /> diff --git a/odoo/addons/test_impex/tests/test_export.py b/odoo/addons/test_impex/tests/test_export.py index a360cfd0ef4..8009f97adc0 100644 --- a/odoo/addons/test_impex/tests/test_export.py +++ b/odoo/addons/test_impex/tests/test_export.py @@ -601,7 +601,54 @@ class test_m2m(CreatorCase): ['', u'export.many2many.other:13'], ]) - # essentially same as o2m, so boring + def test_multiple_records_subfield(self): + r = self.make(self.commands) + xid = self.env['ir.model.data'].create({ + 'name': 'whopwhopwhop', + 'module': '__t__', + 'model': r._name, + 'res_id': r.id, + }).complete_name + sids = [ + self.env['ir.model.data'].create({ + 'name': sub.str, + 'module': '__t__', + 'model': sub._name, + 'res_id': sub.id, + }).complete_name + for sub in r.value + ] + r.invalidate_cache() + + self.assertEqual( + r._export_rows([['value', 'id']]), + [['__t__.record000,__t__.record001,__t__.record010,__t__.record011,__t__.record100']] + ) + self.assertEqual( + r.with_context(import_compat=True)._export_rows([['value', 'id']]), + [['__t__.record000,__t__.record001,__t__.record010,__t__.record011,__t__.record100']] + ) + + self.assertEqual( + r.with_context(import_compat=False)._export_rows([['id'], ['value', 'id'], ['value', 'value']]), + [ + [xid, u'__t__.record000', u'4'], + [u'', u'__t__.record001', u'42'], + [u'', u'__t__.record010', u'36'], + [u'', u'__t__.record011', u'4'], + [u'', u'__t__.record100', u'13'] + ] + ) + self.assertEqual( + r.with_context(import_compat=False)._export_rows([['id'], ['value', 'value'], ['value', 'id']]), + [ + [xid, u'4', u'__t__.record000'], + [u'', u'42', u'__t__.record001'], + [u'', u'36', u'__t__.record010'], + [u'', u'4', u'__t__.record011'], + [u'', u'13', u'__t__.record100'] + ] + ) class test_function(CreatorCase): diff --git a/odoo/models.py b/odoo/models.py index c03ef37b18e..74507e86fce 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -650,8 +650,22 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): :param fields: list of lists of fields to traverse :return: list of lists of corresponding values """ + import_compatible = self.env.context.get('import_compat', True) lines = [] - for record in self: + + def splittor(rs): + """ Splits the self recordset in batches of 1000 (to avoid + entire-recordset-prefetch-effects) & removes the previous batch + from the cache after it's been iterated in full + """ + for idx in range(0, len(rs), 1000): + sub = rs[idx: idx+1000] + for rec in sub: + yield rec + rs.invalidate_cache(ids=sub.ids) + + # memory stable but ends up prefetching 275 fields (???) + for idx, record in enumerate(splittor(self)): # main line of record, initially empty current = [''] * len(fields) lines.append(current) @@ -683,8 +697,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): else: primary_done.append(name) - # This is a special case, its strange behavior is intended! - if field.type == 'many2many' and len(path) > 1 and path[1] == 'id': + # in import_compat mode, m2m should always be exported as + # a comma-separated list of xids in a single cell + if import_compatible and field.type == 'many2many' and len(path) > 1 and path[1] == 'id': xml_ids = [r.__export_xml_id() for r in value] current[i] = ','.join(xml_ids) or False continue @@ -2840,47 +2855,48 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): raise UserError(_('Unable to delete this document because it is used as a default property')) # Delete the records' properties. - self.env['ir.property'].search([('res_id', 'in', refs)]).unlink() + with self.env.norecompute(): + self.env['ir.property'].search([('res_id', 'in', refs)]).unlink() - self.check_access_rule('unlink') + self.check_access_rule('unlink') - cr = self._cr - Data = self.env['ir.model.data'].sudo().with_context({}) - Defaults = self.env['ir.default'].sudo() - Attachment = self.env['ir.attachment'] + cr = self._cr + Data = self.env['ir.model.data'].sudo().with_context({}) + Defaults = self.env['ir.default'].sudo() + Attachment = self.env['ir.attachment'] - for sub_ids in cr.split_for_in_conditions(self.ids): - query = "DELETE FROM %s WHERE id IN %%s" % self._table - cr.execute(query, (sub_ids,)) + for sub_ids in cr.split_for_in_conditions(self.ids): + query = "DELETE FROM %s WHERE id IN %%s" % self._table + cr.execute(query, (sub_ids,)) - # Removing the ir_model_data reference if the record being deleted - # is a record created by xml/csv file, as these are not connected - # with real database foreign keys, and would be dangling references. - # - # Note: the following steps are performed as superuser to avoid - # access rights restrictions, and with no context to avoid possible - # side-effects during admin calls. - data = Data.search([('model', '=', self._name), ('res_id', 'in', sub_ids)]) - if data: - data.unlink() - - # For the same reason, remove the defaults having some of the - # records as value - Defaults.discard_records(self.browse(sub_ids)) - - # For the same reason, remove the relevant records in ir_attachment - # (the search is performed with sql as the search method of - # ir_attachment is overridden to hide attachments of deleted - # records) - query = 'SELECT id FROM ir_attachment WHERE res_model=%s AND res_id IN %s' - cr.execute(query, (self._name, sub_ids)) - attachments = Attachment.browse([row[0] for row in cr.fetchall()]) - if attachments: - attachments.unlink() - - # invalidate the *whole* cache, since the orm does not handle all - # changes made in the database, like cascading delete! - self.invalidate_cache() + # Removing the ir_model_data reference if the record being deleted + # is a record created by xml/csv file, as these are not connected + # with real database foreign keys, and would be dangling references. + # + # Note: the following steps are performed as superuser to avoid + # access rights restrictions, and with no context to avoid possible + # side-effects during admin calls. + data = Data.search([('model', '=', self._name), ('res_id', 'in', sub_ids)]) + if data: + data.unlink() + + # For the same reason, remove the defaults having some of the + # records as value + Defaults.discard_records(self.browse(sub_ids)) + + # For the same reason, remove the relevant records in ir_attachment + # (the search is performed with sql as the search method of + # ir_attachment is overridden to hide attachments of deleted + # records) + query = 'SELECT id FROM ir_attachment WHERE res_model=%s AND res_id IN %s' + cr.execute(query, (self._name, sub_ids)) + attachments = Attachment.browse([row[0] for row in cr.fetchall()]) + if attachments: + attachments.unlink() + + # invalidate the *whole* cache, since the orm does not handle all + # changes made in the database, like cascading delete! + self.invalidate_cache() # recompute new-style fields if self.env.recompute and self._context.get('recompute', True): diff --git a/odoo/service/wsgi_server.py b/odoo/service/wsgi_server.py index af5ae574187..45f9de07b52 100644 --- a/odoo/service/wsgi_server.py +++ b/odoo/service/wsgi_server.py @@ -41,7 +41,7 @@ RPC_FAULT_CODE_ACCESS_ERROR = 4 def xmlrpc_handle_exception_int(e): if isinstance(e, odoo.exceptions.UserError): - fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, odoo.tools.ustr(e.value)) + fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, odoo.tools.ustr(e.name)) elif isinstance(e, odoo.exceptions.RedirectWarning): fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, str(e)) elif isinstance(e, odoo.exceptions.MissingError): |
