diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 80520fab812..156169896a4 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -248,7 +248,7 @@ class AccountInvoice(models.Model): move_name = fields.Char(string='Journal Entry Name', readonly=False, default=False, copy=False, help="Technical field holding the number given to the invoice, automatically set when the invoice is validated then stored to set the same number again if the invoice is cancelled, set to draft and re-validated.") - reference = fields.Char(string='Vendor Reference', + reference = fields.Char(string='Vendor Reference', copy=False, help="The partner reference of this invoice.", readonly=True, states={'draft': [('readonly', False)]}) reference_type = fields.Selection('_get_reference_type', string='Payment Reference', required=True, readonly=True, states={'draft': [('readonly', False)]}, @@ -1582,9 +1582,6 @@ class AccountInvoiceLine(models.Model): self.price_unit = 0.0 domain['uom_id'] = [] else: - # Use the purchase uom by default - self.uom_id = self.product_id.uom_po_id - if part.lang: product = self.product_id.with_context(lang=part.lang) else: diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index dda6ffa0664..d292c83a8df 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -658,6 +658,98 @@ QUnit.module('account', { clientAction.destroy(); }); + QUnit.test('Reconciliation validate without proposition', function (assert) { + assert.expect(1); + // Test added to prevent this issue happening again: https://github.com/odoo/odoo/commit/3549688b21eb65e16b9c3f2b6462eb8d8b52cd47 + var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, + }); + + clientAction.appendTo($('#qunit-fixture')); + + var widget = clientAction.widgets[0]; + // Ensure that when we validate a line without any selection, it is the same + // as when we manually create a line with the line.balance and that only one + // line is send back to server. + testUtils.intercept(clientAction, 'call_service', function (event) { + assert.deepEqual(event.data.args[1].args, + [[5],[{partner_id: 8, counterpart_aml_dicts: [], + payment_aml_ids: [], new_aml_dicts: [{ + account_id: 287, + credit: 1175, + debit: 0, + name: 'SAJ/2014/002 and SAJ/2014/003' + }]}]], + "Should call process_reconciliations with ids"); + }); + + // click on validate button + widget.$('button.o_validate:not(:hidden)').trigger('click'); + clientAction.destroy(); + }); + + QUnit.test('Reconciliation validate with proposition', function (assert) { + assert.expect(1); + // Test added to check this functionality: https://github.com/odoo/odoo/commit/2f3b469dee6f18cbccce1cdf2a81cfe57960c533 + var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, + }); + + clientAction.appendTo($('#qunit-fixture')); + + var widget = clientAction.widgets[0]; + // Add a line as proposition + // open the first line + widget.$('.accounting_view thead td:first').trigger('click'); + // select propositions + widget.$('.match .cell_account_code:first').trigger('click'); + + // Ensure that when we validate a line with propositions and that there is a remaining balance + // We also create a line which is the open balance. + testUtils.intercept(clientAction, 'call_service', function (event) { + assert.deepEqual(event.data.args[1].args, + [[5],[{partner_id: 8, + counterpart_aml_dicts: [{ + counterpart_aml_id: 109, + credit: 650, + debit: 0, + name: 'INV/2017/0002' + }], + payment_aml_ids: [], + new_aml_dicts: [{ + account_id: 287, + credit: 525, + debit: 0, + name: 'SAJ/2014/002 and SAJ/2014/003 : Open balance' + }]}]], + "Should call process_reconciliations with ids"); + }); + + // click on validate button + widget.$('button.o_validate:not(:hidden)').trigger('click'); + clientAction.destroy(); + }); + QUnit.test('Reconciliation partial', function (assert) { assert.expect(10); diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index ac2aca16951..b91d4748954 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -20,13 +20,14 @@ class TestReconciliation(AccountingTestCase): self.res_currency_model = self.registry('res.currency') self.res_currency_rate_model = self.registry('res.currency.rate') - self.partner_agrolait_id = self.env.ref("base.res_partner_2").id + partner_agrolait = self.env.ref("base.res_partner_2") + self.partner_agrolait_id = partner_agrolait.id self.currency_swiss_id = self.env.ref("base.CHF").id self.currency_usd_id = self.env.ref("base.USD").id self.currency_euro_id = self.env.ref("base.EUR").id self.env.ref('base.main_company').write({'currency_id': self.currency_euro_id}) - self.account_rcv = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1) - self.account_rsa = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_payable').id)], limit=1) + self.account_rcv = partner_agrolait.property_account_receivable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1) + self.account_rsa = partner_agrolait.property_account_payable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_payable').id)], limit=1) self.product = self.env.ref("product.product_product_4") self.bank_journal_euro = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'BNK67'}) diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py index 3fe31131944..cddad92eea1 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -204,8 +204,11 @@ class Team(models.Model): def _get_graph(self): graph_datas = super(Team, self)._get_graph() if self.dashboard_graph_model == 'crm.opportunity.report' and self.dashboard_graph_group_pipeline == 'stage': - stage_data = self.env['crm.stage'].browse([d['label'] for d in graph_datas[0]['values']]).read(['sequence', 'name']) + stage_ids = [d['label'] for d in graph_datas[0]['values'] if d['label'] is not None] + stage_data = self.env['crm.stage'].browse(stage_ids).read(['sequence', 'name']) stage_data = {d['id']: {'name': d['name'], 'sequence': d['sequence']} for d in stage_data} + # use "Undefined" stage for unset stage records + stage_data[None] = {'name': _('Undefined'), 'sequence': -1} graph_datas[0]['values'] = sorted(graph_datas[0]['values'], key=lambda el: stage_data[el['label']]['sequence']) for gdata in graph_datas[0]['values']: gdata['label'] = stage_data[gdata['label']]['name'] diff --git a/addons/hr_attendance/models/hr_attendance.py b/addons/hr_attendance/models/hr_attendance.py index 76d442a4b67..1e43f98cd69 100644 --- a/addons/hr_attendance/models/hr_attendance.py +++ b/addons/hr_attendance/models/hr_attendance.py @@ -16,7 +16,8 @@ class HrAttendance(models.Model): return self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) employee_id = fields.Many2one('hr.employee', string="Employee", default=_default_employee, required=True, ondelete='cascade', index=True) - department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id") + department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id", + readonly=True) check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True) check_out = fields.Datetime(string="Check Out") worked_hours = fields.Float(string='Worked Hours', compute='_compute_worked_hours', store=True, readonly=True) diff --git a/addons/hr_payroll/models/hr_contract.py b/addons/hr_payroll/models/hr_contract.py index 6aebe4134de..00fa9750d12 100644 --- a/addons/hr_payroll/models/hr_contract.py +++ b/addons/hr_payroll/models/hr_contract.py @@ -39,16 +39,16 @@ class HrContract(models.Model): @api.multi def get_attribute(self, code, attribute): - return getattr(self.env['hr.contract.advantage.template'].search([('code', '=', code)], limit=1), attribute) + return self.env['hr.contract.advantage.template'].search([('code', '=', code)], limit=1)[attribute] @api.multi def set_attribute_value(self, code, active): for contract in self: if active: value = self.env['hr.contract.advantage.template'].search([('code', '=', code)], limit=1).default_value - setattr(contract, code, value) + contract[code] = value else: - setattr(contract, code, 0.0) + contract[code] = 0.0 class HrContractAdvandageTemplate(models.Model): diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index c7fb34607ed..265309f8d34 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -16,6 +16,7 @@ class WebsitePayment(http.Controller): values = { 'pms': payment_tokens, 'acquirers': acquirers, + 'error_message': [kwargs['error']] if kwargs.get('error') else False, 'return_url': return_url, 'bootstrap_formatting': True, 'partner_id': partner.id diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py index 8ccdfc935f9..61c1f003b59 100644 --- a/addons/payment_stripe/controllers/main.py +++ b/addons/payment_stripe/controllers/main.py @@ -21,8 +21,18 @@ class StripeController(http.Controller): def stripe_s2s_create(self, **post): acquirer_id = int(post.get('acquirer_id')) acquirer = request.env['payment.acquirer'].browse(acquirer_id) - acquirer.s2s_process(post) - return werkzeug.utils.redirect(post.get('return_url', '/')) + error = None + try: + acquirer.s2s_process(post) + except Exception as e: + error = e.message + + return_url = post.get('return_url', '/') + if error: + separator = '?' if werkzeug.urls.url_parse(return_url).query == '' else '&' + return_url += '{}{}'.format(separator, werkzeug.urls.url_encode({'error': error})) + + return werkzeug.utils.redirect(return_url) @http.route(['/payment/stripe/s2s/create_json_3ds'], type='json', auth='public', csrf=False) def stripe_s2s_create_json_3ds(self, verify_validity=False, **kwargs): @@ -63,7 +73,19 @@ class StripeController(http.Controller): tx = TX.sudo().browse(int(tx_id)) if not tx: raise werkzeug.exceptions.NotFound() - response = tx._create_stripe_charge(tokenid=post['tokenid'], email=post['email']) + + stripe_token = post['token'] + response = None + if tx.type == 'form_save' and tx.partner_id: + payment_token_id = request.env['payment.token'].sudo().create({ + 'acquirer_id': tx.acquirer_id.id, + 'partner_id': tx.partner_id.id, + 'stripe_token': stripe_token + }) + tx.payment_token_id = payment_token_id + response = tx._create_stripe_charge(acquirer_ref=payment_token_id.acquirer_ref, email=stripe_token['email']) + else: + response = tx._create_stripe_charge(tokenid=stripe_token['id'], email=stripe_token['email']) _logger.info('Stripe: entering form_feedback with post data %s', pprint.pformat(response)) if response: request.env['payment.transaction'].sudo().with_context(lang=None).form_feedback(response, 'stripe') diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py index 232ec6d9d43..76b8e3aebf7 100644 --- a/addons/payment_stripe/models/payment.py +++ b/addons/payment_stripe/models/payment.py @@ -2,6 +2,7 @@ import logging import requests +import pprint from odoo import api, fields, models, _ from odoo.addons.payment.models.payment_acquirer import ValidationError @@ -227,11 +228,12 @@ class PaymentTokenStripe(models.Model): @api.model def stripe_create(self, values): - res = {} + token = values.get('stripe_token') + description = None payment_acquirer = self.env['payment.acquirer'].browse(values.get('acquirer_id')) - url_token = 'https://%s/tokens' % payment_acquirer._get_stripe_api_url() - url_customer = 'https://%s/customers' % payment_acquirer._get_stripe_api_url() + # when asking to create a token on Stripe servers if values.get('cc_number'): + url_token = 'https://%s/tokens' % payment_acquirer._get_stripe_api_url() payment_params = { 'card[number]': values['cc_number'].replace(' ', ''), 'card[exp_month]': str(values['cc_expiry'][:2]), @@ -244,27 +246,56 @@ class PaymentTokenStripe(models.Model): params=payment_params, headers=STRIPE_HEADERS) token = r.json() - if token.get('id'): - customer_params = { - 'source': token['id'], - 'description': values['cc_holder_name'] - } - if values.get('partner_id'): - partner = self.env['res.partner'].browse(values['partner_id']) - customer_params['email'] = partner.email and partner.email.strip() - r = requests.post(url_customer, - auth=(payment_acquirer.stripe_secret_key, ''), - params=customer_params, - headers=STRIPE_HEADERS) - customer = r.json() - res = { - 'acquirer_ref': customer['id'], - 'name': 'XXXXXXXXXXXX%s - %s' % (values['cc_number'][-4:], values['cc_holder_name']) - } - elif token.get('error'): - raise UserError(token['error']['message']) + description = values['cc_holder_name'] + else: + partner_id = self.env['res.partner'].browse(values['partner_id']) + description = 'Partner: %s (id: %s)' % (partner_id.name, partner_id.id) + + if not token: + raise Exception('stripe_create: No token provided!') + + res = self._stripe_create_customer(token, description, payment_acquirer.id) # pop credit card info to info sent to create - for field_name in ["cc_number", "cvc", "cc_holder_name", "cc_expiry", "cc_brand"]: + for field_name in ["cc_number", "cvc", "cc_holder_name", "cc_expiry", "cc_brand", "stripe_token"]: values.pop(field_name, None) return res + + + def _stripe_create_customer(self, token, description=None, acquirer_id=None): + if token['object'] != 'token': + _logger.error('payment.token.stripe_create_customer: Cannot create a customer for object type "%s"', token.get('object')) + raise Exception('We are unable to process your credit card information.') + + if token['type'] != 'card': + _logger.error('payment.token.stripe_create_customer: Cannot create a customer for token type "%s"', token.get('type')) + raise Exception('We are unable to process your credit card information.') + + if token.get('error'): + _logger.error('payment.token.stripe_create_customer: Token error:\n%s', pprint.pformat(token['error'])) + raise Exception(token['error']['message']) + + payment_acquirer = self.env['payment.acquirer'].browse(acquirer_id or self.acquirer_id.id) + url_customer = 'https://%s/customers' % payment_acquirer._get_stripe_api_url() + + customer_params = { + 'source': token['id'], + 'description': description or token["card"]["name"] + } + + r = requests.post(url_customer, + auth=(payment_acquirer.stripe_secret_key, ''), + params=customer_params, + headers=STRIPE_HEADERS) + customer = r.json() + + if customer.get('error'): + _logger.error('payment.token.stripe_create_customer: Customer error:\n%s', pprint.pformat(customer['error'])) + raise Exception(customer['error']['message']) + + values = { + 'acquirer_ref': customer['id'], + 'name': 'XXXXXXXXXXXX%s - %s' % (token['card']['last4'], customer_params["description"]) + } + + return values diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index 1dfe867d196..7a6d1447f87 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -38,8 +38,9 @@ odoo.define('payment_stripe.stripe', function(require) { }); } ajax.jsonRpc("/payment/stripe/create_charge", 'call', { - tokenid: token.id, - email: token.email, + tokenid: token.id, // TBE TODO: for backward compatibility, remove on master + email: token.email, // TBE TODO: for backward compatibility, remove on master + token: token, amount: $("input[name='amount']").val(), acquirer_id: $("#acquirer_stripe").val(), currency: $("input[name='currency']").val(), diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index fd150d96a61..e7ce561d56f 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -1030,6 +1030,7 @@ class ReportSaleDetails(models.AbstractModel): payments = [] return { + 'currency_precision': user_currency.decimal_places, 'total_paid': user_currency.round(total), 'payments': payments, 'company_name': self.env.user.company_id.name, diff --git a/addons/point_of_sale/static/src/js/devices.js b/addons/point_of_sale/static/src/js/devices.js index 0317ad5e16d..bf11db31bf2 100644 --- a/addons/point_of_sale/static/src/js/devices.js +++ b/addons/point_of_sale/static/src/js/devices.js @@ -5,6 +5,7 @@ var core = require('web.core'); var mixins = require('web.mixins'); var rpc = require('web.rpc'); var Session = require('web.Session'); +var PosBaseWidget = require('point_of_sale.BaseWidget'); var QWeb = core.qweb; var _t = core._t; @@ -447,6 +448,7 @@ var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{ }) .then(function(result){ var env = { + widget: new PosBaseWidget(self), company: self.pos.company, pos: self.pos, products: result.products, diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 71b3b655eb7..088f62fbeeb 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -1022,6 +1022,9 @@ var ProductScreenWidget = ScreenWidget.extend({ this.product_categories_widget.reset_category(); this.numpad.state.reset(); } + if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) { + this.chrome.widget.keyboard.connect($(this.el.querySelector('.searchbox input'))); + } }, close: function(){ @@ -1375,6 +1378,7 @@ var ClientListScreenWidget = ScreenWidget.extend({ // to maintain consistent scroll. display_client_details: function(visibility,partner,clickpos){ var self = this; + var searchbox = this.$('.searchbox input'); var contents = this.$('.client-details-contents'); var parent = this.$('.client-list').parent(); var scroll = parent.scrollTop(); @@ -1411,6 +1415,19 @@ var ClientListScreenWidget = ScreenWidget.extend({ this.details_visible = true; this.toggle_save_button(); } else if (visibility === 'edit') { + // Connect the keyboard to the edited field + if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) { + contents.off('click', '.detail'); + searchbox.off('click'); + contents.on('click', '.detail', function(ev){ + self.chrome.widget.keyboard.connect(ev.target); + self.chrome.widget.keyboard.show(); + }); + searchbox.on('click', function() { + self.chrome.widget.keyboard.connect($(this)); + }); + } + this.editing_client = true; contents.empty(); contents.append($(QWeb.render('ClientDetailsEdit',{widget:this,partner:partner}))); @@ -1453,6 +1470,9 @@ var ClientListScreenWidget = ScreenWidget.extend({ }, close: function(){ this._super(); + if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) { + this.chrome.widget.keyboard.hide(); + } }, }); gui.define_screen({name:'clientlist', widget: ClientListScreenWidget}); diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index 84648f58eff..ec35d6a65ca 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -941,7 +941,7 @@ - + @@ -955,7 +955,7 @@ - + @@ -966,7 +966,7 @@ Total: - + diff --git a/addons/point_of_sale/views/report_saledetails.xml b/addons/point_of_sale/views/report_saledetails.xml index b15046fce9f..dea4d1a9e62 100644 --- a/addons/point_of_sale/views/report_saledetails.xml +++ b/addons/point_of_sale/views/report_saledetails.xml @@ -50,7 +50,7 @@ - + @@ -67,8 +67,8 @@ - - + + @@ -76,7 +76,7 @@

- Total: + Total:
diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py index e107517b210..c12a10ebfaf 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -200,6 +200,8 @@ class CustomerPortal(Controller): # vat validation partner = request.env["res.partner"] if data.get("vat") and hasattr(partner, "check_vat"): + if data.get("country_id"): + data["vat"] = request.env["res.partner"].fix_eu_vat_number(int(data.get("country_id")), data.get("vat")) partner_dummy = partner.new({ 'vat': data['vat'], 'country_id': (int(data['country_id']) diff --git a/addons/product_extended/models/product.py b/addons/product_extended/models/product.py index f0f886b66d4..0efd659dd79 100644 --- a/addons/product_extended/models/product.py +++ b/addons/product_extended/models/product.py @@ -41,10 +41,12 @@ class ProductProduct(models.Model): # No attribute_value_ids means the bom line is not variant specific price += sbom.product_id.uom_id._compute_price(sbom.product_id.standard_price, sbom.product_uom_id) * sbom_data['qty'] if bom.routing_id: - total_cost = 0.0 - for order in bom.routing_id.operation_ids: - total_cost += (order.time_cycle/60) * order.workcenter_id.costs_hour - price += bom.product_uom_id._compute_price(total_cost, bom.product_id.uom_id) + # FIXME master: remove me + if hasattr(self.env['mrp.workcenter'], 'costs_hour'): + total_cost = 0.0 + for order in bom.routing_id.operation_ids: + total_cost += (order.time_cycle/60) * order.workcenter_id.costs_hour + price += bom.product_uom_id._compute_price(total_cost, bom.product_id.uom_id) # Convert on product UoM quantities if price > 0: price = bom.product_uom_id._compute_price(price / bom.product_qty, self.uom_id) diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index c9b868b63b9..195829b82e2 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -63,6 +63,13 @@ class AccountInvoice(models.Model): data['account_id'] = account.id return data + def _onchange_product_id(self): + domain = super(AccountInvoice, self)._onchange_product_id() + if self.purchase_id: + # Use the purchase uom by default + self.uom_id = self.product_id.uom_po_id + return domain + # Load all unsold PO lines @api.onchange('purchase_id') def purchase_order_change(self): diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py index 0dc090173f2..9a458f01b30 100644 --- a/addons/stock/models/stock_location.py +++ b/addons/stock/models/stock_location.py @@ -71,15 +71,13 @@ class Location(models.Model): _sql_constraints = [('barcode_company_uniq', 'unique (barcode,company_id)', 'The barcode for a location must be unique per company !')] @api.one - @api.depends('name', 'location_id.name') + @api.depends('name', 'location_id.complete_name') def _compute_complete_name(self): """ Forms complete name of location from parent location to child location. """ - name = self.name - current = self - while current.location_id: - current = current.location_id - name = '%s/%s' % (current.name, name) - self.complete_name = name + if self.location_id.complete_name: + self.complete_name = '%s/%s' % (self.location_id.complete_name, self.name) + else: + self.complete_name = self.name def write(self, values): if 'usage' in values and values['usage'] == 'view': diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index b6fa82a3b19..7d80f03b366 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -265,14 +265,17 @@ class StockMove(models.Model): detect errors. """ raise UserError(_('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.')) - @api.one + @api.multi @api.depends('move_line_ids.product_qty') def _compute_reserved_availability(self): """ Fill the `availability` field on a stock move, which is the actual reserved quantity and is represented by the aggregated `product_qty` on the linked move lines. If the move is force assigned, the value will be 0. """ - self.reserved_availability = self.product_id.uom_id._compute_quantity(sum(self.move_line_ids.mapped('product_qty')), self.product_uom, rounding_method='HALF-UP') + result = {data['move_id'][0]: data['product_qty'] for data in + self.env['stock.move.line'].read_group([('move_id', 'in', self.ids)], ['move_id','product_qty'], ['move_id'])} + for rec in self: + rec.reserved_availability = rec.product_id.uom_id._compute_quantity(result.get(rec.id, 0.0), rec.product_uom, rounding_method='HALF-UP') @api.one @api.depends('state', 'product_id', 'product_qty', 'location_id') @@ -402,7 +405,7 @@ class StockMove(models.Model): if propagated_date_field: current_date = datetime.strptime(move.date_expected, DEFAULT_SERVER_DATETIME_FORMAT) new_date = datetime.strptime(vals.get(propagated_date_field), DEFAULT_SERVER_DATETIME_FORMAT) - delta = new_date - current_date + delta = relativedelta.relativedelta(new_date, current_date) if abs(delta.days) >= move.company_id.propagation_minimum_delta: old_move_date = datetime.strptime(move.move_dest_ids[0].date_expected, DEFAULT_SERVER_DATETIME_FORMAT) new_move_date = (old_move_date + relativedelta.relativedelta(days=delta.days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT) diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 6f775e789d4..9cdadb1051a 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -95,6 +95,7 @@ var FieldMany2One = AbstractField.extend({ template: 'FieldMany2One', custom_events: _.extend({}, AbstractField.prototype.custom_events, { 'closed_unset': '_onDialogClosedUnset', + 'field_changed': '_onFieldChanged', 'quick_create': '_onQuickCreate', 'search_create_popup': '_onSearchCreatePopup', }), @@ -124,6 +125,12 @@ var FieldMany2One = AbstractField.extend({ // 'recordParams' is a dict of params used when calling functions // 'getDomain' and 'getContext' on this.record this.recordParams = {fieldName: this.name, viewType: this.viewType}; + // We need to know if the widget is dirty (i.e. if the user has changed + // the value, and those changes haven't been acknowledged yet by the + // environment), to prevent erasing that new value on a reset (e.g. + // coming by an onchange on another field) + this.isDirty = false; + this.lastChangeEvent = undefined; }, start: function () { // booleean indicating that the content of the input isn't synchronized @@ -151,9 +158,31 @@ var FieldMany2One = AbstractField.extend({ * TODO */ reinitialize: function (value) { + this.isDirty = false; this.floating = false; this._setValue(value); }, + /** + * Re-renders the widget if it isn't dirty. The widget is dirty if the user + * changed the value, and that change hasn't been acknowledged yet by the + * environment. For example, another field with an onchange has been updated + * and this field is updated before the onchange returns. Two '_setValue' + * are done (this is sequential), the first one returns and this widget is + * reset. However, it has pending changes, so we don't re-render. + * + * @override + */ + reset: function (record, event) { + this._reset(record, event); + if (!event || event === this.lastChangeEvent) { + this.isDirty = false; + } + if (this.isDirty) { + return $.when(); + } else { + return this._render(); + } + }, //-------------------------------------------------------------------------- // Private @@ -237,6 +266,16 @@ var FieldMany2One = AbstractField.extend({ _getDisplayName: function (value) { return value.split('\n')[0]; }, + /** + * Listens to events 'field_changed' to keep track of the last event that + * has been trigerred. This allows to detect that all changes have been + * acknowledged by the environment. + * + * @param {OdooEvent} event 'field_changed' event + */ + _onFieldChanged: function (event) { + this.lastChangeEvent = event; + }, /** * @private * @param {string} name @@ -476,6 +515,7 @@ var FieldMany2One = AbstractField.extend({ * @private */ _onDialogClosedUnset: function () { + this.isDirty = false; this.floating = false; this._render(); }, @@ -544,6 +584,7 @@ var FieldMany2One = AbstractField.extend({ // confirmation that the many2one is not properly set. return; } + this.isDirty = true; if (this.$input.val() === "") { this.reinitialize(false); } else if (this._getDisplayName(this.m2o_value) !== this.$input.val()) { 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 c16f635a604..39f1be0d107 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1495,7 +1495,8 @@ var BasicModel = AbstractModel.extend({ def = this._rpc({ model: list.model, method: 'read', - args: [_.pluck(data, 'id'), fieldNames] + args: [_.pluck(data, 'id'), fieldNames], + context: record.context, }).then(function (records) { _.each(records, function (record) { list_records[record.id].data = record; @@ -1743,22 +1744,21 @@ var BasicModel = AbstractModel.extend({ /** * Fetch all name_gets for the many2ones in a group * - * @param {Object} group a valid resource object + * @param {Object[]} groups a list of object with context and record sub keys * @returns {Deferred} */ - _fetchMany2OneGroup: function (group) { - var ids = _.uniq(_.pluck(group, 'res_id')); - + _fetchMany2OneGroup: function (groups) { + var ids = _.uniq(_.pluck(_.pluck(groups, 'record'), 'res_id')); return this._rpc({ - model: group[0].model, + model: groups[0].record.model, method: 'name_get', args: [ids], - context: group[0].context + context: groups[0].context }) .then(function (name_gets) { - _.each(group, function (record) { - var nameGet = _.find(name_gets, function (n) { return n[0] === record.res_id;}); - record.data.display_name = nameGet[1]; + _.each(groups, function (obj) { + var nameGet = _.find(name_gets, function (n) { return n[0] === obj.record.res_id;}); + obj.record.data.display_name = nameGet[1]; }); }); }, @@ -2019,14 +2019,17 @@ var BasicModel = AbstractModel.extend({ if (!relatedRecord) { return; } - toBeFetched.push(relatedRecord); + toBeFetched.push({ + context: record.getContext({fieldName: name, viewType: record.viewType}), + record: relatedRecord + }); } }); // group them by model and context. Using the context as key is // necessary to make sure the correct context is used for the rpc; - var groups = _.groupBy(toBeFetched, function (rec) { - return [rec.model, JSON.stringify(rec.context)].join(); + var groups = _.groupBy(toBeFetched, function (elem) { + return [elem.record.model, JSON.stringify(elem.context)].join(); }); return $.when.apply($, _.map(groups, this._fetchMany2OneGroup.bind(this))); @@ -2416,7 +2419,7 @@ var BasicModel = AbstractModel.extend({ model: field.relation, method: 'read', args: [ids, fieldNames], - context: {}, // FIXME + context: list.getContext() || {}, }); } else { def = $.when(_.map(ids, function (id) { diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 205473f3191..0739a6252ca 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -258,6 +258,31 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('context in many2one and default get', function (assert) { + assert.expect(1); + + this.data.partner.fields.int_field.default = 14; + this.data.partner.fields.trululu.default = 2; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '', + mockRPC: function (route, args) { + if (args.method === 'name_get') { + assert.strictEqual(args.kwargs.context.blip, 14, + 'context should have been properly sent to the nameget rpc'); + } + return this._super(route, args); + }, + }); + form.destroy(); + }); + QUnit.test('editing a many2one (with form view opened with external button)', function (assert) { assert.expect(1); var form = createView({ @@ -6713,6 +6738,63 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many field: change value before pending onchange returns', function (assert) { + var done = assert.async(); + assert.expect(2); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + this.data.partner.onchanges = { + int_field: function () {} + }; + var def; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + // delay the onchange RPC + return $.when(def).then(_.constant(result)); + } + return result; + }, + }); + + form.$('.o_field_x2many_list_row_add a').click(); + def = $.Deferred(); + form.$('.o_field_widget[name=int_field]') + .val('44') + .trigger('input'); + + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + // set trululu before onchange + form.$('.o_field_many2one input').val('first').trigger('keydown').trigger('keyup'); + // complete the onchange + def.resolve(); + assert.strictEqual(form.$('.o_field_many2one input').val(), 'first', + 'should have kept the new value'); + concurrency.delay(0).then(function () { + // check name_search result + assert.strictEqual($dropdown.find('li:not(.o_m2o_dropdown_option)').length, 1, + 'autocomplete should contains 1 suggestion'); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + done(); + }); + }); + QUnit.test('focus is correctly reset after an onchange in an x2many', function (assert) { assert.expect(2); @@ -8622,7 +8704,7 @@ QUnit.module('relational_fields', { }); QUnit.test('fieldmany2many tags in editable list', function (assert) { - assert.expect(4); + assert.expect(7); this.data.partner.records[0].timmy = [12]; @@ -8630,10 +8712,18 @@ QUnit.module('relational_fields', { View: ListView, model: 'partner', data: this.data, + context: {take: 'five'}, arch:'' + '' + '' + '', + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'partner_type') { + assert.deepEqual(args.kwargs.context, {take: 'five'}, + 'The context should be passed to the RPC'); + } + return this._super.apply(this, arguments); + } }); assert.strictEqual(list.$('.o_data_row:first .o_field_many2manytags .badge').length, 1, diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js index 05c6df465c6..e499e83843d 100644 --- a/addons/web_editor/static/src/js/widgets/widgets.js +++ b/addons/web_editor/static/src/js/widgets/widgets.js @@ -654,7 +654,7 @@ var VideoDialog = Widget.extend({ var $media = $(this.media); if ($media.hasClass('media_iframe_video')) { - var src = $media.data('src') || ''; + var src = $media.data('oe-expression') || $media.data('src') || ''; this.$('textarea#o_video_text').val(src); this.$('input#o_video_autoplay').prop('checked', src.indexOf('autoplay=1') >= 0); diff --git a/odoo/fields.py b/odoo/fields.py index 4203dc46ab7..00fc7ca4c55 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -974,7 +974,7 @@ class Field(MetaField('DummyField', (object,), {})): if env.in_onchange: for invf in record._field_inverses[self]: invf._update(record[self.name], record) - record._set_dirty(self.name) + env.dirty[record].add(self.name) # determine more dependent fields, and invalidate them if self.relational: