diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 326e1574014..da381195dd2 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -1071,16 +1071,20 @@ class AccountTax(models.Model): rslt = super(AccountTax, self).default_get(vals + ['company_id']) company_id = rslt.get('company_id') + company = self.env['res.company'].browse(company_id) + if 'refund_repartition_line_ids' in vals: + # We write on the related country_id field so that the field is recomputed. Without that, it will stay empty until we save the record. rslt['refund_repartition_line_ids'] = [ - (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}), - (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}), + (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}), + (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}), ] if 'invoice_repartition_line_ids' in vals: + # We write on the related country_id field so that the field is recomputed. Without that, it will stay empty until we save the record. rslt['invoice_repartition_line_ids'] = [ - (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}), - (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id}), + (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}), + (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'country_id': company.country_id.id}), ] return rslt diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index dbdb3b075a2..8e0744403e4 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -926,6 +926,8 @@ class AccountInvoice(models.Model): bank_id = bank_ids[0].id if bank_ids else False self.partner_bank_id = bank_id domain = {'partner_bank_id': [('id', 'in', bank_ids.ids)]} + elif type == 'out_invoice': + domain = {'partner_bank_id': [('partner_id.ref_company_ids', 'in', [self.company_id.id])]} res = {} if warning: diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 24a3709a3a6..3c181f3357b 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -1663,7 +1663,8 @@ class AccountPartialReconcile(models.Model): move_date = self.debit_move_id.date newly_created_move = self.env['account.move'] with self.env.norecompute(): - for move in (self.debit_move_id.move_id, self.credit_move_id.move_id): + # We use a set here in case the reconciled lines belong to the same move (it happens with POS) + for move in {self.debit_move_id.move_id, self.credit_move_id.move_id}: #move_date is the max of the 2 reconciled items if move_date < move.date: move_date = move.date diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index 5898f41489a..83f4f188163 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -636,7 +636,10 @@ - + + diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index e9154f04d31..f557e54b64d 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -67,6 +67,7 @@
Rounding Method +
How total tax amount is computed in orders and invoices
diff --git a/addons/account/wizard/setup_wizards.py b/addons/account/wizard/setup_wizards.py index c9d52ee6e02..13a70058741 100644 --- a/addons/account/wizard/setup_wizards.py +++ b/addons/account/wizard/setup_wizards.py @@ -40,6 +40,23 @@ class FinancialYearOpeningWizard(models.TransientModel): (wiz.fiscalyear_last_month, wiz.fiscalyear_last_day) ) + @api.multi + def write(self, vals): + # Amazing workaround: non-stored related fields on company are a BAD idea since the 3 fields + # must follow the constraint '_check_fiscalyear_last_day'. The thing is, in case of related + # fields, the inverse write is done one value at a time, and thus the constraint is verified + # one value at a time... so it is likely to fail. + for wiz in self: + wiz.company_id.write({ + 'account_opening_date': vals.get('opening_date') or wiz.company_id.account_opening_date, + 'fiscalyear_last_day': vals.get('fiscalyear_last_day') or wiz.company_id.fiscalyear_last_day, + 'fiscalyear_last_month': vals.get('fiscalyear_last_month') or wiz.company_id.fiscalyear_last_month, + }) + vals.pop('opening_date', None) + vals.pop('fiscalyear_last_day', None) + vals.pop('fiscalyear_last_month', None) + return super().write(vals) + @api.multi def action_save_onboarding_fiscal_year(self): self.env.company.set_onboarding_step_done('account_setup_fy_data_state') diff --git a/addons/base_geolocalize/models/base_geocoder.py b/addons/base_geolocalize/models/base_geocoder.py index 6da9c2f7f03..17a10f7e0e5 100644 --- a/addons/base_geolocalize/models/base_geocoder.py +++ b/addons/base_geolocalize/models/base_geocoder.py @@ -112,6 +112,8 @@ class GeoCoder(models.AbstractModel): self._raise_query_error(e) try: + if result['status'] == 'ZERO_RESULTS': + return None if result['status'] != 'OK': _logger.debug('Invalid Gmaps call: %s - %s', result['status'], result.get('error_message', '')) diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 0384fe3b40e..d98717b7bb8 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -772,6 +772,9 @@ help="Opportunities that are assigned to me"/> + diff --git a/addons/crm/views/crm_team_views.xml b/addons/crm/views/crm_team_views.xml index e16453588f4..73b191009cd 100644 --- a/addons/crm/views/crm_team_views.xml +++ b/addons/crm/views/crm_team_views.xml @@ -206,7 +206,7 @@
- + Open Opportunity Open Opportunities diff --git a/addons/google_account/models/google_service.py b/addons/google_account/models/google_service.py index fb9213f96b2..aa3040d7947 100644 --- a/addons/google_account/models/google_service.py +++ b/addons/google_account/models/google_service.py @@ -189,12 +189,24 @@ class GoogleService(models.TransientModel): status = error.response.status_code response = "" else: - req = json.loads(error.request.body or 'null') - res = error.response.json() - _logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", pformat(req), pformat(res)) + try: + exception_req = pformat(json.loads(error.request.body)) + except: + exception_req = error.request.body + + try: + exception_res = pformat(error.response.json()) + error_msg = _("Error while requesting Google Services: %s") % res['error']['message'] + warning_msg = _("Something went wrong with your request to google: %s") % res['error']['message'] + except: + exception_res = error.response.text + error_msg = _("Error while requesting Google Services") + warning_msg = _("Something went wrong with your request to google") + + _logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", exception_req, exception_res) if error.response.status_code in (400, 401, 410): - raise UserError(_("Error while requesting Google Services: %s") % res['error']['message']) - raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google: %s") % res['error']['message']) + raise UserError(error_msg) + raise self.env['res.config.settings'].get_config_warning(warning_msg) return (status, response, ask_time) # TODO : remove me, it is only used in google calendar. Make google_calendar use the constants diff --git a/addons/hr_timesheet/static/src/js/timesheet_uom.js b/addons/hr_timesheet/static/src/js/timesheet_uom.js index 6fc8b9c22b5..9699dc6feaf 100644 --- a/addons/hr_timesheet/static/src/js/timesheet_uom.js +++ b/addons/hr_timesheet/static/src/js/timesheet_uom.js @@ -80,7 +80,6 @@ var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({ * implementation (float_time, float_toggle, ...). The default * value will be 'float_factor'. **/ -var FieldTimesheetUom = FieldTimesheetFactor; var widgetName = 'timesheet_uom' in session ? session.timesheet_uom.timesheet_widget : 'float_factor'; var FieldTimesheetUom = widgetName === 'float_toggle' ? @@ -96,7 +95,7 @@ var _tweak_options = function(options) { options.factor = session.timesheet_uom_factor; } return options; -} +}; fieldUtils.format.timesheet_uom = function(value, field, options) { options = _tweak_options(options || {}); @@ -112,4 +111,3 @@ fieldUtils.parse.timesheet_uom = function(value, field, options) { return FieldTimesheetUom; }); - diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py index 34ca956a2c4..14b89d8c7fa 100644 --- a/addons/l10n_fr_fec/wizard/account_fr_fec.py +++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py @@ -305,7 +305,9 @@ class AccountFrFec(models.TransientModel): END AS PieceRef, TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate, - CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/' ELSE replace(replace(aml.name, '|', '/'), '\t', '') END AS EcritureLib, + CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/' + WHEN aml.name SIMILAR TO '[\t|\s|\n]*' THEN '/' + ELSE replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', '') END AS EcritureLib, replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit, CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet, diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index 083b46c9ff2..6214fc8b5b7 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -135,10 +135,6 @@ MailManager.include({ thread.close(); thread.isCreatingWindow = false; }); - } else if (!options.passively) { - if (threadWindow.isHidden()) { - this._makeThreadWindowVisible(threadWindow); - } } return prom.then(function () { threadWindow.updateVisualFoldState(); diff --git a/addons/mail/static/tests/thread_window/hidden_thread_window_tests.js b/addons/mail/static/tests/thread_window/hidden_thread_window_tests.js index 691e37df362..078230e276a 100644 --- a/addons/mail/static/tests/thread_window/hidden_thread_window_tests.js +++ b/addons/mail/static/tests/thread_window/hidden_thread_window_tests.js @@ -229,6 +229,94 @@ QUnit.test('hidden thread windows dropdown when not enough horizontal space (ver testUtils.mock.unpatch(this.services.mail_service); }); +QUnit.test('receive message from hidden thread window', async function (assert) { + // This is almost the same test as the one before, except there are + // at most 3 thread windows visible, and 2 thread windows are visible + // when it shows the 'hidden thread window' button. + // This case occurs when the amount of available slots depends on + // whether the hidden button should be displayed or not + // Example: + // - global width of 400px + // - button width of 100px + // - thread width of 250px + // + // Without button: 1 thread window (250px < 800px) + // With button: 1 thread window (250px + 100px = 350px < 400px) + assert.expect(7); + + testUtils.patch(this.services.mail_service, { + HIDDEN_THREAD_WINDOW_DROPDOWN_BUTTON_WIDTH: 100, + THREAD_WINDOW_WIDTH: 250, + _getGlobalWidth: function () { return 400; }, + }); + + var channels = [{ + id: 1, + channel_type: 'channel', + name: "channel" + 1, + }, { + id: 2, + channel_type: 'channel', + name: "channel" + 2, + }]; + + this.data.initMessaging = { + channel_slots: { + channel_channel: channels, + }, + }; + + var parent = this.createParent({ + data: this.data, + services: this.services, + }); + await testUtils.nextTick(); + + // detach channel 2 first, so that chanel 1 is open and channel 2 is hidden + parent.call('mail_service', 'getChannel', 2).detach(); + parent.call('mail_service', 'getChannel', 1).detach(); + await testUtils.nextTick(); + + var $visibleThreadWindows = $('.o_thread_window:not(.o_thread_window_dropdown, .o_hidden)'); + + assert.strictEqual($visibleThreadWindows.length, 1, + "should have 1 thread windows visible (as many as available slots)"); + assert.strictEqual($visibleThreadWindows.filter('[data-thread-id="1"]').length, 1, + "the thread window with ID 1 should be visible"); + assert.strictEqual($('.o_thread_window.o_hidden[data-thread-id="2"]').length, 1, + "the thread window with ID 2 should be hidden"); + assert.strictEqual( + $('.o_thread_window_dropdown .o_total_unread_counter').text().trim(), + "", + "should have no unread counter on hidden dropup menu"); + + // simulate receiving a message in channel ID 2 + var messageData = { + author_id: [5, "Someone else"], + body: "

Test message

", + id: 1, + model: 'mail.channel', + res_id: 2, + channel_ids: [2], + }; + var notification = [[false, 'mail.channel', 2], messageData]; + parent.call('bus_service', 'trigger', 'notification', [notification]); + await testUtils.nextTick(); + + $visibleThreadWindows = $('.o_thread_window:not(.o_thread_window_dropdown, .o_hidden)'); + assert.strictEqual($visibleThreadWindows.filter('[data-thread-id="1"]').length, 1, + "the thread window with ID 1 should stay visible"); + assert.strictEqual($('.o_thread_window.o_hidden[data-thread-id="2"]').length, 1, + "the thread window with ID 2 should stay hidden"); + assert.strictEqual( + $('.o_thread_window_dropdown .o_total_unread_counter').text().trim(), + "1", + "should have unread counter of 1 on hidden dropup menu"); + + parent.destroy(); + testUtils.unpatch(this.services.mail_service); +}); + }); }); }); diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index cf65644076b..1f01b90b9da 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -7,7 +7,7 @@ from itertools import groupby from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import AccessError, UserError -from odoo.tools import date_utils, float_round +from odoo.tools import date_utils, float_round, float_is_zero class MrpProduction(models.Model): @@ -776,7 +776,7 @@ class MrpProduction(models.Model): ) move_lines = self.move_raw_ids.mapped('move_line_ids').filtered(lambda ml: lots_short & ml.lot_produced_ids) for ml in move_lines: - error_msg += ml.product_id.display_name + ' (' + (lots_short & ml.lot_produced_ids).mapped('name') + ')\n' + error_msg += ml.product_id.display_name + ' (' + ', '.join((lots_short & ml.lot_produced_ids).mapped('name')) + ')\n' raise UserError(error_msg) @api.multi @@ -829,6 +829,8 @@ class MrpProduction(models.Model): moves_to_do = order.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) for move in moves_to_do.filtered(lambda m: m.product_qty == 0.0 and m.quantity_done > 0): move.product_uom_qty = move.quantity_done + for move in moves_to_do.filtered(lambda m: float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding)): + move._action_cancel() # MRP do not merge move, catch the result of _action_done in order # to get extra moves. moves_to_do = moves_to_do._action_done() diff --git a/addons/mrp/report/mrp_report_bom_structure.py b/addons/mrp/report/mrp_report_bom_structure.py index b67583361ca..c7a56b8bf7b 100644 --- a/addons/mrp/report/mrp_report_bom_structure.py +++ b/addons/mrp/report/mrp_report_bom_structure.py @@ -14,8 +14,7 @@ class ReportBomStructure(models.AbstractModel): docs = [] for bom_id in docids: bom = self.env['mrp.bom'].browse(bom_id) - variant = data and data.get('variant') - candidates = variant and self.env['product.product'].browse(variant) or bom.product_tmpl_id.product_variant_ids + candidates = bom.product_id or bom.product_tmpl_id.product_variant_ids for product_variant_id in candidates: if data and data.get('childs'): doc = self._get_pdf_line(bom_id, product_id=product_variant_id, qty=float(data.get('quantity')), child_bom_ids=json.loads(data.get('childs'))) diff --git a/addons/payment_stripe/i18n/payment_stripe.pot b/addons/payment_stripe/i18n/payment_stripe.pot index 16b83b04500..6f2e50dc7ec 100644 --- a/addons/payment_stripe/i18n/payment_stripe.pot +++ b/addons/payment_stripe/i18n/payment_stripe.pot @@ -184,6 +184,12 @@ msgstr "" msgid "Stripe: no order found for reference %s" msgstr "" +#. module: payment_stripe +#: code:addons/payment_stripe/models/payment.py:260 +#, python-format +msgid "Stripe: no payment token was provided or the token creation failed." +msgstr "" + #. module: payment_stripe #: code:addons/payment_stripe/models/payment.py:170 #, python-format diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py index 78920e93580..f35a44165e7 100644 --- a/addons/payment_stripe/models/payment.py +++ b/addons/payment_stripe/models/payment.py @@ -257,7 +257,7 @@ class PaymentTokenStripe(models.Model): description = 'Partner: %s (id: %s)' % (partner_id.name, partner_id.id) if not token: - raise Exception('stripe_create: No token provided!') + raise UserError(_("Stripe: no payment token was provided or the token creation failed.")) res = self._stripe_create_customer(token, description, payment_acquirer.id) diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index db3e48a9867..53c0b79e7d7 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -107,9 +107,17 @@ odoo.define('payment_stripe.stripe', function(require) { var invoice_num = get_input_value("invoice_num"); var merchant = get_input_value("merchant"); + // Search if the user wants to save the credit card information + var form_save_token = false; + var acquirer_form = $('#o_payment_form_acq_' + acquirer_id); + if (acquirer_form.length) { + form_save_token = acquirer_form.find('input[name="o_payment_form_save_token"]').prop('checked'); + } + ajax.jsonRpc(payment_tx_url, 'call', { acquirer_id: acquirer_id, access_token: access_token, + save_token: form_save_token, }).then(function(data) { var $pay_stripe = $('#pay_stripe').detach(); try { provider_form[0].innerHTML = data; } catch (e) {} diff --git a/addons/pos_discount/static/src/js/discount.js b/addons/pos_discount/static/src/js/discount.js index 87b97f88e16..fc41e55bc05 100644 --- a/addons/pos_discount/static/src/js/discount.js +++ b/addons/pos_discount/static/src/js/discount.js @@ -42,10 +42,16 @@ var DiscountButton = screens.ActionButtonWidget.extend({ } // Add discount + // We add the price as manually set to avoid recomputation when changing customer. var discount = - pc / 100.0 * order.get_total_with_tax(); if( discount < 0 ){ - order.add_product(product, { price: discount }); + order.add_product(product, { + price: discount, + extras: { + price_manually_set: true, + }, + }); } }, }); diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 4d52ca6a5e2..e35665d38ce 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -557,6 +557,16 @@ class ProductTemplate(models.Model): if variants_to_create: Product.create(variants_to_create) + # Avoid access errors in case the products is shared amongst companies but the underlying + # objects are not. If unlink fails because of an AccessError (e.g. while recomputing + # fields), the 'write' call will fail as well for the same reason since the field has + # been set to recompute. + if variants_to_unlink: + variants_to_unlink.check_access_rights('unlink') + variants_to_unlink.check_access_rule('unlink') + variants_to_unlink.check_access_rights('write') + variants_to_unlink.check_access_rule('write') + variants_to_unlink = variants_to_unlink.sudo() # unlink or inactive product # try in batch first because it is much faster try: diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index be124faa658..41331af134e 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1446,7 +1446,7 @@ class SaleOrderLine(models.Model): if currency != self.order_id.pricelist_id.currency_id: base_price = currency._convert( base_price, self.order_id.pricelist_id.currency_id, - self.order_id.company_id, self.order_id.date_order or fields.Date.today()) + self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) # negative discounts (= surcharge) are included in the display price return max(base_price, final_price) @@ -1581,7 +1581,7 @@ class SaleOrderLine(models.Model): if currency_id.id == product_currency.id: cur_factor = 1.0 else: - cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.user.company_id, self.order_id.date_order or fields.Date.today()) + cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) product_uom = self.env.context.get('uom') or product.uom_id.id if uom and uom.id != product_uom: @@ -1627,7 +1627,7 @@ class SaleOrderLine(models.Model): # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency new_list_price = currency._convert( new_list_price, self.order_id.pricelist_id.currency_id, - self.order_id.company_id, self.order_id.date_order or fields.Date.today()) + self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) discount = (new_list_price - price) / new_list_price * 100 if (discount > 0 and new_list_price > 0) or (discount < 0 and new_list_price < 0): self.discount = discount diff --git a/addons/sale/models/sales_team.py b/addons/sale/models/sales_team.py index aae3b6c6e31..6d297bb0ee5 100644 --- a/addons/sale/models/sales_team.py +++ b/addons/sale/models/sales_team.py @@ -57,7 +57,7 @@ class CrmTeam(models.Model): def _compute_sales_to_invoice(self): sale_order_data = self.env['sale.order'].read_group([ ('team_id', 'in', self.ids), - ('order_line.qty_to_invoice', '>', 0), + ('invoice_status','=','to invoice'), ], ['team_id'], ['team_id']) data_map = {datum['team_id'][0]: datum['team_id_count'] for datum in sale_order_data } for team in self: diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 69359a982cc..8a38dfa6cae 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -1214,7 +1214,7 @@ form tree,form,calendar,graph,kanban,pivot - [('state', '=', 'sale'),('invoice_status','=','to invoice')] + [('invoice_status','=','to invoice')] { 'search_default_team_id': [active_id], 'default_team_id': active_id, diff --git a/addons/sale_coupon/models/sale_order.py b/addons/sale_coupon/models/sale_order.py index 3487317330e..6c0fa712229 100644 --- a/addons/sale_coupon/models/sale_order.py +++ b/addons/sale_coupon/models/sale_order.py @@ -79,7 +79,7 @@ class SaleOrder(models.Model): return applied_programs.filtered(lambda program: program._is_global_discount_program()) def _get_reward_values_product(self, program): - price_unit = self.order_line.filtered(lambda line: program.reward_product_id == line.product_id)[0].price_unit + price_unit = self.order_line.filtered(lambda line: program.reward_product_id == line.product_id)[0].price_reduce order_lines = (self.order_line - self._get_reward_lines()).filtered(lambda x: program._is_valid_product(x.product_id)) max_product_qty = sum(order_lines.mapped('product_uom_qty')) or 1 @@ -120,10 +120,10 @@ class SaleOrder(models.Model): def _get_cheapest_line(self): # Unit prices tax included - return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_unit > 0), key=lambda x: x['price_unit']) + return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_reduce > 0), key=lambda x: x['price_reduce']) def _get_reward_values_discount_percentage_per_line(self, program, line): - discount_amount = line.product_uom_qty * line.price_unit * (program.discount_percentage / 100) + discount_amount = line.product_uom_qty * line.price_reduce * (program.discount_percentage / 100) return discount_amount def _get_reward_values_discount(self, program): @@ -142,7 +142,7 @@ class SaleOrder(models.Model): if program.discount_apply_on == 'cheapest_product': line = self._get_cheapest_line() if line: - discount_line_amount = line.price_unit * (program.discount_percentage / 100) + discount_line_amount = line.price_reduce * (program.discount_percentage / 100) if discount_line_amount: taxes = line.tax_id if self.fiscal_position_id: diff --git a/addons/sale_coupon/tests/common.py b/addons/sale_coupon/tests/common.py index fec5d669aba..e6b8bd1b359 100644 --- a/addons/sale_coupon/tests/common.py +++ b/addons/sale_coupon/tests/common.py @@ -60,6 +60,14 @@ class TestSaleCouponCommon(TestSaleProductAttributeValueSetup): 'taxes_id': [(6, 0, [self.tax_15pc_excl.id])], }) + self.product_C = self.env['product.product'].create({ + 'name': 'Product C', + 'list_price': 100, + 'sale_ok': True, + 'taxes_id': [(6, 0, [])], + + }) + # Immediate Program By A + B: get B free # No Conditions self.immediate_promotion_program = self.env['sale.coupon.program'].create({ @@ -79,3 +87,14 @@ class TestSaleCouponCommon(TestSaleProductAttributeValueSetup): 'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id), 'active': True, }) + + self.code_promotion_program_with_discount = self.env['sale.coupon.program'].create({ + 'name': 'Buy 1 C + Enter code, 10 percent discount on C', + 'promo_code_usage': 'code_needed', + 'reward_type': 'discount', + 'discount_type': 'percentage', + 'discount_percentage': 10, + 'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_C.id), + 'active': True, + 'discount_apply_on': 'on_order', + }) diff --git a/addons/sale_coupon/tests/test_program_with_code_operations.py b/addons/sale_coupon/tests/test_program_with_code_operations.py index 276b20c2221..2a19854f25e 100644 --- a/addons/sale_coupon/tests/test_program_with_code_operations.py +++ b/addons/sale_coupon/tests/test_program_with_code_operations.py @@ -66,6 +66,45 @@ class TestProgramWithCodeOperations(TestSaleCouponCommon): self.assertEqual(len(order.order_line.ids), 0) self.assertEqual(coupon.state, 'new') + def test_coupon_code_with_pricelist(self): + # Test case: Generate a coupon (10% discount) and apply it on an order with a specific pricelist (10% discount) + + self.env['sale.coupon.generate'].with_context(active_id=self.code_promotion_program_with_discount.id).create({ + 'generation_type': 'nbr_coupon', + 'nbr_coupons': 1, + }).generate_coupon() + coupon = self.code_promotion_program_with_discount.coupon_ids + + first_pricelist = self.env['product.pricelist'].create({ + 'name': 'First pricelist', + 'discount_policy': 'with_discount', + 'item_ids': [(0, 0, { + 'compute_price': 'percentage', + 'base': 'list_price', + 'percent_price': 10, + 'applied_on': '3_global', + 'name': 'First discount' + })] + }) + + order = self.empty_order + order.pricelist_id = first_pricelist + order.write({'order_line': [ + (0, False, { + 'product_id': self.product_C.id, + 'name': '1 Product C', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 2) + self.assertEqual(coupon.state, 'used') + self.assertEqual(order.amount_total, 81, "SO total should be 81: (10% of 100 with pricelist) + 10% of 90 with coupon code") + def test_on_next_order_reward_promotion_program(self): # The flow: # 1. Create a program `A` that gives a free `Product B` on next order if you buy a an `product A` diff --git a/addons/sale_coupon_delivery/models/sale_order.py b/addons/sale_coupon_delivery/models/sale_order.py index e4002fa9abf..dab962d9e78 100644 --- a/addons/sale_coupon_delivery/models/sale_order.py +++ b/addons/sale_coupon_delivery/models/sale_order.py @@ -42,7 +42,7 @@ class SaleOrder(models.Model): def _get_cheapest_line(self): # Unit prices tax included - return min(self.order_line.filtered(lambda x: not x.is_reward_line and not x.is_delivery and x.price_unit > 0), key=lambda x: x['price_unit']) + return min(self.order_line.filtered(lambda x: not x.is_reward_line and not x.is_delivery and x.price_reduce > 0), key=lambda x: x['price_reduce']) class SalesOrderLine(models.Model): _inherit = "sale.order.line" diff --git a/addons/sale_margin/models/sale_order.py b/addons/sale_margin/models/sale_order.py index ae0515d7bf2..a500d65c48e 100644 --- a/addons/sale_margin/models/sale_order.py +++ b/addons/sale_margin/models/sale_order.py @@ -18,7 +18,8 @@ class SaleOrderLine(models.Model): if product_uom_id != product_id.uom_id: purchase_price = product_id.uom_id._compute_price(purchase_price, product_uom_id) price = frm_cur._convert( - purchase_price, to_cur, order_id.company_id, order_id.date_order or fields.Date.today(), round=False) + purchase_price, to_cur, order_id.company_id or self.env.company, + order_id.date_order or fields.Date.today(), round=False) return price @api.model diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py index d1a78b9318c..e495efe906a 100644 --- a/addons/sale_timesheet/models/project_overview.py +++ b/addons/sale_timesheet/models/project_overview.py @@ -340,7 +340,7 @@ class Project(models.Model): def _table_get_empty_so_lines(self): """ get the Sale Order Lines having no timesheet but having generated a task or a project """ - so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense) + so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense and not sol.is_downpayment) # include the service SO line of SO sharing the same project sale_order = self.env['sale.order'].search([('project_id', 'in', self.ids)]) return set(so_lines.ids) | set(sale_order.mapped('order_line').filtered(lambda sol: sol.is_service and not sol.is_expense).ids), set(so_lines.mapped('order_id').ids) | set(sale_order.ids) diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 254d34f0c6d..7df6c05510b 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -393,10 +393,12 @@ class StockMove(models.Model): receipt_moves_to_reassign = self.env['stock.move'] if 'product_uom_qty' in vals: for move in self.filtered(lambda m: m.state not in ('done', 'draft') and m.picking_id): - if vals['product_uom_qty'] != move.product_uom_qty: + if float_compare(vals['product_uom_qty'], move.product_uom_qty, precision_rounding=move.product_uom.rounding): self.env['stock.move.line']._log_message(move.picking_id, move, 'stock.track_move_template', vals) if self.env.context.get('do_not_unreserve') is None: - move_to_unreserve = self.filtered(lambda m: m.state not in ['draft', 'done', 'cancel'] and m.reserved_availability > vals.get('product_uom_qty')) + move_to_unreserve = self.filtered( + lambda m: m.state not in ['draft', 'done', 'cancel'] and float_compare(m.reserved_availability, vals.get('product_uom_qty'), precision_rounding=m.product_uom.rounding) == 1 + ) move_to_unreserve._do_unreserve() (self - move_to_unreserve).filtered(lambda m: m.state == 'assigned').write({'state': 'partially_available'}) # When editing the initial demand, directly run again action assign on receipt moves. diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index a75c793b161..ec2a917a4ed 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -279,7 +279,10 @@ class StockMoveLine(models.Model): # When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves. next_moves = self.env['stock.move'] if updates or 'qty_done' in vals: - for ml in self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product'): + mls = self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product') + if not updates: # we can skip those where qty_done is already good up to UoM rounding + mls = mls.filtered(lambda ml: not float_is_zero(ml.qty_done - vals['qty_done'], precision_rounding=ml.product_uom_id.rounding)) + for ml in mls: # undo the original move line qty_done_orig = ml.move_id.product_uom._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') in_date = Quant._update_available_quantity(ml.product_id, ml.location_dest_id, -qty_done_orig, lot_id=ml.lot_id, diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 14f03a83222..171a6da7735 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -1682,7 +1682,7 @@ var FieldBinaryFile = AbstractFieldBinary.extend({ 'id': this.res_id, 'field': this.name, 'filename_field': filename_fieldname, - 'filename': this.recordData[filename_fieldname] || null, + 'filename': this.recordData[filename_fieldname] || "", 'download': true, 'data': utils.is_bin_size(this.value) ? null : this.value, }, diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index c3934d930d7..57e7a36c403 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -274,7 +274,6 @@ return AbstractModel.extend({ this.data.highlight_date = this.data.target_date = start.clone(); // set dates in UTC with timezone applied manually this.data.start_date = this.data.end_date = start; - this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes'); switch (this.data.scale) { case 'month': @@ -289,6 +288,9 @@ return AbstractModel.extend({ this.data.start_date = this.data.start_date.clone().startOf('day'); this.data.end_date = this.data.end_date.clone().endOf('day'); } + + this.data.start_date.utc(); + this.data.end_date.utc(); }, /** * @param {string} scale the scale to set diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 25aa4be7e26..bf7c70e2859 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -957,8 +957,8 @@ var PivotModel = AbstractModel.extend({ } if (measureComparisonValue instanceof Array) { dataPoints[groupIdentifier][measureName + 'Aggregate'].comparisonData = 1; - dataPoints[groupIdentifier][measureName].variation = computeVariation( - dataPoints[groupIdentifier][measureName].data, + dataPoints[groupIdentifier][measureName + 'Aggregate'].variation = computeVariation( + dataPoints[groupIdentifier][measureName + 'Aggregate'].data, 1 ); diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index ba69596c916..333f51f75ba 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -1693,7 +1693,7 @@ QUnit.module('Views', { results = [ "2", "0", "100%", "0", "1", "-100%", "2", "1", "100%" , "1", "0", "100%", "1", "0" , "100%" , - "1" , "0", "100%", "0", "1", "-100%", "1" , "1" , "100%" + "1" , "0", "100%", "0", "1", "-100%", "1" , "1" , "0%" ]; checkCellValues(results); diff --git a/addons/web_tour/static/src/js/tip.js b/addons/web_tour/static/src/js/tip.js index 021098f7ede..5bd64ce1c49 100644 --- a/addons/web_tour/static/src/js/tip.js +++ b/addons/web_tour/static/src/js/tip.js @@ -146,6 +146,7 @@ var Tip = Widget.extend({ }, _reposition: function () { if (this.tip_opened) return; + if (!this.$el) return; this.$el.removeClass("o_animated"); // Reverse left/right position if direction is right to left diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index 5ef38ab9a49..1557d6fd50f 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -115,7 +115,7 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ _.each($('textarea.load_editor'), function (textarea) { var $textarea = $(textarea); - var editorKarma = $textarea.data('karma') || 30; // default value for backward compatibility + var editorKarma = $textarea.data('karma') || 0; // default value for backward compatibility if (!$textarea.val().match(/\S/)) { $textarea.val('


'); } diff --git a/addons/website_profile/static/src/js/website_profile.js b/addons/website_profile/static/src/js/website_profile.js index b1638f4b30e..caccf454f1a 100644 --- a/addons/website_profile/static/src/js/website_profile.js +++ b/addons/website_profile/static/src/js/website_profile.js @@ -2,6 +2,7 @@ odoo.define('website_profile.website_profile', function (require) { 'use strict'; var publicWidget = require('web.public.widget'); +var Wysiwyg = require('web_editor.wysiwyg.root'); publicWidget.registry.websiteProfile = publicWidget.Widget.extend({ selector: '.o_wprofile_email_validation_container', read_events: { @@ -40,6 +41,47 @@ publicWidget.registry.websiteProfile = publicWidget.Widget.extend({ }, }); +publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({ + selector: '.o_wprofile_editor_form', + read_events: { + 'click .o_wprofile_submit_btn': '_onSubmitClick', + }, + + /** + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + if (this.editableMode) { + return def; + } + var $textarea = this.$('textarea.load_editor'); + var wysiwyg = new Wysiwyg(this, { + recordInfo: { + context: this._getContext(), + res_model: 'res.users', + res_id: parseInt(this.$('input[name=user_id]').val()), + }, + }); + + if (!$textarea.val().match(/\S/)) { + $textarea.val('


'); + } + return Promise.all([def, wysiwyg.attachTo($textarea)]); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onSubmitClick: function () { + this.$('textarea.load_editor').data('wysiwyg').save(); + }, +}); + return publicWidget.registry.websiteProfile; }); diff --git a/addons/website_profile/views/website_profile.xml b/addons/website_profile/views/website_profile.xml index 8ea869d5455..3060431e87f 100644 --- a/addons/website_profile/views/website_profile.xml +++ b/addons/website_profile/views/website_profile.xml @@ -125,7 +125,7 @@
-
+
@@ -174,7 +174,7 @@ class="form-control load_editor">
- +
@@ -403,7 +403,7 @@
  • - Rank badge + Rank badge
    points diff --git a/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js b/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js index a1093ca98ee..4ac148854f9 100644 --- a/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js +++ b/addons/website_sale_delivery/static/src/js/website_free_delivery_tour.js @@ -5,21 +5,16 @@ var tour = require("web_tour.tour"); tour.register('check_free_delivery', { test: true, - url: '/shop?search=conference chair', + url: '/shop?search=office chair black', }, [ { - content: "select conference chair", - trigger: '.oe_product_cart:first a:contains("Conference Chair")', + content: "select office chair black", + trigger: '.oe_product_cart:first a:contains("Office Chair Black")', }, { content: "click on add to cart", - extra_trigger: 'label:contains(Steel) input:propChecked', - trigger: '#product_detail form[action^="/shop/cart/update"] .btn-primary', - }, - { - content: "click in modal on 'Proceed to checkout' button", - trigger: 'button:contains("Proceed to Checkout")', + trigger: '#product_details #add_to_cart', }, { content: "go to checkout", @@ -28,6 +23,7 @@ tour.register('check_free_delivery', { }, { content: "Check Free Delivery value to be zero", + extra_trigger: '#delivery_carrier label:containsExact("Delivery Now Free Over 10")', trigger: "#delivery_carrier span:contains('0.0')" }, ]); diff --git a/addons/website_sale_delivery/tests/test_ui.py b/addons/website_sale_delivery/tests/test_ui.py index 1c61c00b9fb..1bf4ce3d3bf 100644 --- a/addons/website_sale_delivery/tests/test_ui.py +++ b/addons/website_sale_delivery/tests/test_ui.py @@ -7,6 +7,7 @@ class TestUi(odoo.tests.HttpCase): def test_01_free_delivery_when_exceed_threshold(self): self.env.ref("delivery.free_delivery_carrier").write({ + 'name': 'Delivery Now Free Over 10', 'fixed_price': 2, 'free_over': True, 'amount': 10, diff --git a/addons/website_sale_slides/__manifest__.py b/addons/website_sale_slides/__manifest__.py index 951611eba62..d37fb3dbcda 100644 --- a/addons/website_sale_slides/__manifest__.py +++ b/addons/website_sale_slides/__manifest__.py @@ -11,6 +11,7 @@ 'installable': True, 'auto_install': True, 'data': [ + 'views/assets.xml', 'views/slide_channel_views.xml', 'views/website_slides_templates.xml', ], diff --git a/addons/website_sale_slides/static/src/js/slides_course_quiz.js b/addons/website_sale_slides/static/src/js/slides_course_quiz.js new file mode 100644 index 00000000000..9dc0154596a --- /dev/null +++ b/addons/website_sale_slides/static/src/js/slides_course_quiz.js @@ -0,0 +1,25 @@ +odoo.define('website_sale_slides.quiz', function (require) { +"use strict"; + +var sAnimations = require('website.content.snippets.animation'); +var Quiz = require('website_slides.quiz'); + +sAnimations.registry.websiteSlidesQuizNoFullscreen.include({ + _extractChannelData: function (slideData){ + return _.extend({}, this._super.apply(this, arguments), { + productId: slideData.productId, + enroll: slideData.enroll, + currencyName: slideData.currencyName, + currencySymbol: slideData.currencySymbol, + price: slideData.price, + hasDiscountedPrice: slideData.hasDiscountedPrice + }); + } +}); + +Quiz.include({ + xmlDependencies: (Quiz.prototype.xmlDependencies || []).concat( + ["/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml"] + ) +}); +}); diff --git a/addons/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml b/addons/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml new file mode 100644 index 00000000000..bcf0dc5a032 --- /dev/null +++ b/addons/website_sale_slides/static/src/xml/website_sale_slides_quiz.xml @@ -0,0 +1,43 @@ + + + + + +
    +
    + + + Buy the course + + +
    + + + + +
    +
    +
    + + + diff --git a/addons/website_sale_slides/views/assets.xml b/addons/website_sale_slides/views/assets.xml new file mode 100644 index 00000000000..ce0ecddc580 --- /dev/null +++ b/addons/website_sale_slides/views/assets.xml @@ -0,0 +1,10 @@ + + + +