diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-05-13 11:02:27 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-05-13 11:02:27 +0200 |
| commit | 60e71302a38b36214efec1213882f573e3ea453c (patch) | |
| tree | d6c4f548ac29c94891a52444887a230a7c30d403 /addons | |
| parent | 3936d655c4bfd845b6ead724cecdc8e7c62816eb (diff) | |
| parent | 243a4ae5cbc07027cd3eaf0ead036b9d3586dea3 (diff) | |
[MERGE] forward port branch saas-12.2 up to 243a4ae5cbc
Diffstat (limited to 'addons')
67 files changed, 676 insertions, 229 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 487477dee6a..155064820bc 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -446,6 +446,7 @@ class AccountBankStatementLine(models.Model): """ Prepare the dict of values to balance the move. :param recordset move: the account.move to link the move line + :param dict move: a dict of vals of a account.move which will be created later :param float amount: the amount of transaction that wasn't already reconciled """ company_currency = self.journal_id.company_id.currency_id @@ -453,19 +454,23 @@ class AccountBankStatementLine(models.Model): st_line_currency = self.currency_id or statement_currency amount_currency = False st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False + if isinstance(move, dict): + amount_sum = sum(x[2].get('amount_currency', 0) for x in move['line_ids']) + else: + amount_sum = sum(x.amount_currency for x in move.line_ids) # We have several use case here to compare the currency and amount currency of counterpart line to balance the move: if st_line_currency != company_currency and st_line_currency == statement_currency: # company in currency A, statement in currency B and transaction in currency B # counterpart line must have currency B and correct amount is inverse of already existing lines - amount_currency = -sum([x.amount_currency for x in move.line_ids]) + amount_currency = -amount_sum elif st_line_currency != company_currency and statement_currency == company_currency: # company in currency A, statement in currency A and transaction in currency B # counterpart line must have currency B and correct amount is inverse of already existing lines - amount_currency = -sum([x.amount_currency for x in move.line_ids]) + amount_currency = -amount_sum elif st_line_currency != company_currency and st_line_currency != statement_currency: # company in currency A, statement in currency B and transaction in currency C # counterpart line must have currency B and use rate between B and C to compute correct amount - amount_currency = -sum([x.amount_currency for x in move.line_ids])/st_line_currency_rate + amount_currency = -amount_sum/st_line_currency_rate elif st_line_currency == company_currency and statement_currency != company_currency: # company in currency A, statement in currency B and transaction in currency A # counterpart line must have currency B and amount is computed using the rate between A and B @@ -474,9 +479,8 @@ class AccountBankStatementLine(models.Model): # last case is company in currency A, statement in currency A and transaction in currency A # and in this case counterpart line does not need any second currency nor amount_currency - return { + aml_dict = { 'name': self.name, - 'move_id': move.id, 'partner_id': self.partner_id and self.partner_id.id or False, 'account_id': amount >= 0 \ and self.statement_id.journal_id.default_credit_account_id.id \ @@ -487,22 +491,71 @@ class AccountBankStatementLine(models.Model): 'currency_id': statement_currency != company_currency and statement_currency.id or (st_line_currency != company_currency and st_line_currency.id or False), 'amount_currency': amount_currency, } + if isinstance(move, self.env['account.move'].__class__): + aml_dict['move_id'] = move.id + return aml_dict + @api.multi def fast_counterpart_creation(self): """This function is called when confirming a bank statement and will allow to automatically process lines without going in the bank reconciliation widget. By setting an account_id on bank statement lines, it will create a journal entry using that account to counterpart the bank account """ + payment_list = [] + move_list = [] + account_type_receivable = self.env.ref('account.data_account_type_receivable') + already_done_stmt_line_ids = [a['statement_line_id'][0] for a in self.env['account.move.line'].read_group([('statement_line_id', 'in', self.ids)], ['statement_line_id'], ['statement_line_id'])] + managed_st_line = [] for st_line in self: # Technical functionality to automatically reconcile by creating a new move line - if st_line.account_id and not st_line.journal_entry_ids.ids: - vals = { + if st_line.account_id and not st_line.id in already_done_stmt_line_ids: + managed_st_line.append(st_line.id) + # Create payment vals + total = st_line.amount + payment_methods = (total > 0) and st_line.journal_id.inbound_payment_method_ids or st_line.journal_id.outbound_payment_method_ids + currency = st_line.journal_id.currency_id or st_line.company_id.currency_id + partner_type = 'customer' if st_line.account_id.user_type_id == account_type_receivable else 'supplier' + payment_list.append({ + 'payment_method_id': payment_methods and payment_methods[0].id or False, + 'payment_type': total > 0 and 'inbound' or 'outbound', + 'partner_id': st_line.partner_id.id, + 'partner_type': partner_type, + 'journal_id': st_line.statement_id.journal_id.id, + 'payment_date': st_line.date, + 'state': 'reconciled', + 'currency_id': currency.id, + 'amount': abs(total), + 'communication': st_line._get_communication(payment_methods[0] if payment_methods else False), + 'name': st_line.statement_id.name or _("Bank Statement %s") % st_line.date, + }) + + # Create move and move line vals + move_vals = st_line._prepare_reconciliation_move(st_line.statement_id.name) + aml_dict = { 'name': st_line.name, 'debit': st_line.amount < 0 and -st_line.amount or 0.0, 'credit': st_line.amount > 0 and st_line.amount or 0.0, 'account_id': st_line.account_id.id, + 'partner_id': st_line.partner_id.id, + 'statement_line_id': st_line.id, } - st_line.process_reconciliation(new_aml_dicts=[vals]) + st_line._prepare_move_line_for_currency(aml_dict, st_line.date or fields.Date.context_today()) + move_vals['line_ids'] = [(0, 0, aml_dict)] + balance_line = self._prepare_reconciliation_move_line(move_vals, st_line.amount) + move_vals['line_ids'].append((0, 0, balance_line)) + move_list.append(move_vals) + + # Creates + payment_ids = self.env['account.payment'].create(payment_list) + for payment_id, move_vals in zip(payment_ids, move_list): + for line in move_vals['line_ids']: + line[2]['payment_id'] = payment_id.id + move_ids = self.env['account.move'].create(move_list) + move_ids.post() + + for move, st_line, payment in zip(move_ids, self.browse(managed_st_line), payment_ids): + st_line.write({'move_name': move.name}) + payment.write({'payment_reference': move.name}) def _get_communication(self, payment_method_id): return self.name or '' @@ -630,8 +683,6 @@ class AccountBankStatementLine(models.Model): # Create move line(s). Either matching an existing journal entry (eg. invoice), in which # case we reconcile the existing and the new move lines together, or being a write-off. if counterpart_aml_dicts or new_aml_dicts: - st_line_currency = self.currency_id or statement_currency - st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False # Create the move self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1 @@ -654,32 +705,12 @@ class AccountBankStatementLine(models.Model): # Complete dicts to create both counterpart move lines and write-offs to_create = (counterpart_aml_dicts + new_aml_dicts) - company = self.company_id date = self.date or fields.Date.today() for aml_dict in to_create: aml_dict['move_id'] = move.id aml_dict['partner_id'] = self.partner_id.id aml_dict['statement_line_id'] = self.id - if st_line_currency.id != company_currency.id: - aml_dict['amount_currency'] = aml_dict['debit'] - aml_dict['credit'] - aml_dict['currency_id'] = st_line_currency.id - if self.currency_id and statement_currency.id == company_currency.id and st_line_currency_rate: - # Statement is in company currency but the transaction is in foreign currency - aml_dict['debit'] = company_currency.round(aml_dict['debit'] / st_line_currency_rate) - aml_dict['credit'] = company_currency.round(aml_dict['credit'] / st_line_currency_rate) - elif self.currency_id and st_line_currency_rate: - # Statement is in foreign currency and the transaction is in another one - aml_dict['debit'] = statement_currency._convert(aml_dict['debit'] / st_line_currency_rate, company_currency, company, date) - aml_dict['credit'] = statement_currency._convert(aml_dict['credit'] / st_line_currency_rate, company_currency, company, date) - else: - # Statement is in foreign currency and no extra currency is given for the transaction - aml_dict['debit'] = st_line_currency._convert(aml_dict['debit'], company_currency, company, date) - aml_dict['credit'] = st_line_currency._convert(aml_dict['credit'], company_currency, company, date) - elif statement_currency.id != company_currency.id: - # Statement is in foreign currency but the transaction is in company currency - prorata_factor = (aml_dict['debit'] - aml_dict['credit']) / self.amount_currency - aml_dict['amount_currency'] = prorata_factor * self.amount - aml_dict['currency_id'] = statement_currency.id + self._prepare_move_line_for_currency(aml_dict, date) # Create write-offs for aml_dict in new_aml_dicts: @@ -729,6 +760,36 @@ class AccountBankStatementLine(models.Model): counterpart_moves.assert_balanced() return counterpart_moves + @api.multi + def _prepare_move_line_for_currency(self, aml_dict, date): + self.ensure_one() + company_currency = self.journal_id.company_id.currency_id + statement_currency = self.journal_id.currency_id or company_currency + st_line_currency = self.currency_id or statement_currency + st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False + company = self.company_id + + if st_line_currency.id != company_currency.id: + aml_dict['amount_currency'] = aml_dict['debit'] - aml_dict['credit'] + aml_dict['currency_id'] = st_line_currency.id + if self.currency_id and statement_currency.id == company_currency.id and st_line_currency_rate: + # Statement is in company currency but the transaction is in foreign currency + aml_dict['debit'] = company_currency.round(aml_dict['debit'] / st_line_currency_rate) + aml_dict['credit'] = company_currency.round(aml_dict['credit'] / st_line_currency_rate) + elif self.currency_id and st_line_currency_rate: + # Statement is in foreign currency and the transaction is in another one + aml_dict['debit'] = statement_currency._convert(aml_dict['debit'] / st_line_currency_rate, company_currency, company, date) + aml_dict['credit'] = statement_currency._convert(aml_dict['credit'] / st_line_currency_rate, company_currency, company, date) + else: + # Statement is in foreign currency and no extra currency is given for the transaction + aml_dict['debit'] = st_line_currency._convert(aml_dict['debit'], company_currency, company, date) + aml_dict['credit'] = st_line_currency._convert(aml_dict['credit'], company_currency, company, date) + elif statement_currency.id != company_currency.id: + # Statement is in foreign currency but the transaction is in company currency + prorata_factor = (aml_dict['debit'] - aml_dict['credit']) / self.amount_currency + aml_dict['amount_currency'] = prorata_factor * self.amount + aml_dict['currency_id'] = statement_currency.id + def _check_invoice_state(self, invoice): if invoice.state == 'in_payment' and all([payment.state == 'reconciled' for payment in invoice.mapped('payment_move_line_ids.payment_id')]): invoice.write({'state': 'paid'}) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index ac5b23e0988..22c45f3feed 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -87,12 +87,14 @@ class AccountMove(models.Model): if line.account_id.user_type_id.type in ('receivable', 'payable'): amount = abs(line.debit - line.credit) total_amount += amount - for partial_line in (line.matched_debit_ids + line.matched_credit_ids): - total_reconciled += partial_line.amount precision_currency = move.currency_id or move.company_id.currency_id if float_is_zero(total_amount, precision_rounding=precision_currency.rounding): move.matched_percentage = 1.0 else: + for line in move.line_ids: + if line.account_id.user_type_id.type in ('receivable', 'payable'): + for partial_line in (line.matched_debit_ids + line.matched_credit_ids): + total_reconciled += partial_line.amount move.matched_percentage = total_reconciled / total_amount @api.multi @@ -832,22 +834,21 @@ class AccountMoveLine(models.Model): In case of full reconciliation, all moves belonging to the reconciliation will belong to the same account_full_reconcile object. """ # Get first all aml involved - part_recs = self.env['account.partial.reconcile'].search(['|', ('debit_move_id', 'in', self.ids), ('credit_move_id', 'in', self.ids)]) - amls = self - todo = set(part_recs) + todo = self.env['account.partial.reconcile'].search_read(['|', ('debit_move_id', 'in', self.ids), ('credit_move_id', 'in', self.ids)], ['debit_move_id', 'credit_move_id']) + amls = set(self.ids) seen = set() while todo: - partial_rec = todo.pop() - seen.add(partial_rec) - for aml in [partial_rec.debit_move_id, partial_rec.credit_move_id]: - if aml not in amls: - amls += aml - for x in aml.matched_debit_ids | aml.matched_credit_ids: - if x not in seen: - todo.add(x) - partial_rec_ids = [x.id for x in seen] + aml_ids = [rec['debit_move_id'][0] for rec in todo if rec['debit_move_id']] + [rec['credit_move_id'][0] for rec in todo if rec['credit_move_id']] + amls |= set(aml_ids) + seen |= set([rec['id'] for rec in todo]) + todo = self.env['account.partial.reconcile'].search_read(['&', '|', ('credit_move_id', 'in', aml_ids), ('debit_move_id', 'in', aml_ids), '!', ('id', 'in', list(seen))], ['debit_move_id', 'credit_move_id']) + + partial_rec_ids = list(seen) if not amls: return + else: + amls = self.browse(list(amls)) + # If we have multiple currency, we can only base ourselve on debit-credit to see if it is fully reconciled currency = set([a.currency_id for a in amls if a.currency_id.id != False]) multiple_currency = False diff --git a/addons/base_address_extended/data/base_address_extended_data.xml b/addons/base_address_extended/data/base_address_extended_data.xml index 42a5a779589..ae25025fee2 100644 --- a/addons/base_address_extended/data/base_address_extended_data.xml +++ b/addons/base_address_extended/data/base_address_extended_data.xml @@ -7,5 +7,8 @@ <record id="base.mx" model="res.country"> <field eval="'%(street_name)s %(street_number)s/%(street_number2)s'" name="street_format" /> </record> + <record id="base.hr" model="res.country"> + <field eval="'%(street_name)s %(street_number)s %(street_number2)s'" name="street_format" /> + </record> </data> </odoo> diff --git a/addons/base_address_extended/models/base_address_extended.py b/addons/base_address_extended/models/base_address_extended.py index 0fd99cc8e63..ec788882d09 100644 --- a/addons/base_address_extended/models/base_address_extended.py +++ b/addons/base_address_extended/models/base_address_extended.py @@ -6,7 +6,7 @@ import re from odoo import api, fields, models, _ from odoo.exceptions import UserError -STREET_FIELDS = ('street_name', 'street_number', 'street_number2') +STREET_FIELDS = ['street_name', 'street_number', 'street_number2'] class ResCountry(models.Model): @@ -33,6 +33,10 @@ class Partner(models.Model): street_number2 = fields.Char('Door', compute='_split_street', help="Door Number", inverse='_set_street', store=True) + def _formatting_address_fields(self): + """Returns the list of address fields usable to format addresses.""" + return super(Partner, self)._formatting_address_fields() + self.get_street_fields() + def get_street_fields(self): """Returns the fields that can be used in a street format. Overwrite this function if you want to add your own fields.""" diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 0f964c41f39..8626d76acb9 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +import codecs import collections import unicodedata @@ -32,6 +33,12 @@ DEFAULT_IMAGE_REGEX = r"(?:http|https)://.*(?:png|jpe?g|tiff?|gif|bmp)" DEFAULT_IMAGE_CHUNK_SIZE = 32768 IMAGE_FIELDS = ["icon", "image", "logo", "picture"] _logger = logging.getLogger(__name__) +BOM_MAP = { + 'utf-16le': codecs.BOM_UTF16_LE, + 'utf-16be': codecs.BOM_UTF16_BE, + 'utf-32le': codecs.BOM_UTF32_LE, + 'utf-32be': codecs.BOM_UTF32_BE, +} try: import xlrd @@ -323,6 +330,13 @@ class Import(models.TransientModel): encoding = options.get('encoding') if not encoding: encoding = options['encoding'] = chardet.detect(csv_data)['encoding'].lower() + # some versions of chardet (e.g. 2.3.0 but not 3.x) will return + # utf-(16|32)(le|be), which for python means "ignore / don't strip + # BOM". We don't want that, so rectify the encoding to non-marked + # IFF the guessed encoding is LE/BE and csv_data starts with a BOM + bom = BOM_MAP.get(encoding) + if bom and csv_data.startswith(bom): + encoding = options['encoding'] = encoding[:-2] if encoding != 'utf-8': csv_data = csv_data.decode(encoding).encode('utf-8') diff --git a/addons/base_import/tests/test_csv_magic.py b/addons/base_import/tests/test_csv_magic.py index 0a35a4731c1..b7975337361 100644 --- a/addons/base_import/tests/test_csv_magic.py +++ b/addons/base_import/tests/test_csv_magic.py @@ -26,18 +26,27 @@ class TestEncoding(ImportCase): options.setdefault('separator', '\t') test_text = "text\tnumber\tdate\tdatetime\n%s\t1.23.45,67\t\t\n" % text for encoding in ['utf-8', 'utf-16', 'utf-32', *encodings]: + if isinstance(encoding, tuple): + encoding, es = encoding + else: + es = [encoding] preview = self._make_import( test_text.encode(encoding)).parse_preview(dict(options)) self.assertIsNone(preview.get('error')) guessed = preview['options']['encoding'] - self.assertIsNotNone(guessed, encoding) - self.assertEqual(codecs.lookup(guessed).name, codecs.lookup(encoding).name) + self.assertIsNotNone(guessed) + self.assertIn( + codecs.lookup(guessed).name, [ + codecs.lookup(e).name + for e in es + ] + ) def test_autodetect_encoding(self): """ Check that import preview can detect & return encoding """ - self._check_text("Iñtërnâtiônàlizætiøn", ['iso-8859-1']) + self._check_text("Iñtërnâtiônàlizætiøn", [('iso-8859-1', ['iso-8859-1', 'iso-8859-2'])]) self._check_text("やぶら小路の藪柑子。海砂利水魚の、食う寝る処に住む処、パイポパイポ パイポのシューリンガン。", ['eucjp', 'shift_jis', 'iso2022_jp']) diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index 705c327a08f..2a7cad9a703 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -807,6 +807,12 @@ QUnit.test('save two searches to dashboard', async function (assert) { await testUtils.dom.click(actionManager.$('.o_apply_filter')); // Add it to dashboard await testUtils.dom.click(actionManager.$('.o_favorites_menu_button')); + $('.o_search_options .dropdown-menu.o_favorites_menu').one('click', function (ev) { + // This handler is on the webClient + // But since the test suite doesn't have one + // We manually set it here + ev.stopPropagation(); + }); await testUtils.dom.click(actionManager.$('.o_add_to_board')); await testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button')); // Remove it @@ -819,6 +825,12 @@ QUnit.test('save two searches to dashboard', async function (assert) { await testUtils.dom.click(actionManager.$('.o_apply_filter')); // Add it to dashboard await testUtils.dom.click(actionManager.$('.o_favorites_menu_button')); + $('.o_search_options .dropdown-menu.o_favorites_menu').one('click', function (ev) { + // This handler is on the webClient + // But since the test suite doesn't have one + // We manually set it here + ev.stopPropagation(); + }); await testUtils.dom.click(actionManager.$('.o_add_to_board')); await testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button')); diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 2c0c2e32877..957bb1bdc61 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -406,8 +406,9 @@ class AlarmManager(models.AbstractModel): all_meetings = self.get_next_potential_limit_alarm('notification', partner_id=partner.id) time_limit = 3600 * 24 # return alarms of the next 24 hours - for meeting in self.env['calendar.event'].search([('id', 'in', list(all_meetings))]): # cannot browse - max_delta = all_meetings[meeting.id]['max_duration'] + for event_id in all_meetings: + max_delta = all_meetings[event_id]['max_duration'] + meeting = self.env['calendar.event'].browse(event_id) if meeting.recurrency: b_found = False last_found = False diff --git a/addons/calendar/models/mail_message.py b/addons/calendar/models/mail_message.py index 143bea2d135..58ac99e2825 100644 --- a/addons/calendar/models/mail_message.py +++ b/addons/calendar/models/mail_message.py @@ -26,7 +26,6 @@ class Message(models.Model): def _find_allowed_model_wise(self, doc_model, doc_dict): if doc_model == 'calendar.event': order = self._context.get('order', self.env[doc_model]._order) - records = self.env[doc_model].search([('id', 'in', list(doc_dict))]) - for virtual_id in records.get_recurrent_ids([], order=order): + for virtual_id in self.env[doc_model].with_context(active_test=False).search([('id', 'in', list(doc_dict))], order=order).ids: doc_dict.setdefault(virtual_id, doc_dict[get_real_ids(virtual_id)]) return super(Message, self)._find_allowed_model_wise(doc_model, doc_dict) diff --git a/addons/delivery/models/stock_picking.py b/addons/delivery/models/stock_picking.py index 6444f110d38..bdb092ed5c8 100644 --- a/addons/delivery/models/stock_picking.py +++ b/addons/delivery/models/stock_picking.py @@ -42,7 +42,7 @@ class StockPicking(models.Model): @api.one - @api.depends('move_line_ids') + @api.depends('move_line_ids', 'move_line_ids.result_package_id') def _compute_packages(self): self.ensure_one() packs = set() @@ -52,7 +52,7 @@ class StockPicking(models.Model): self.package_ids = list(packs) @api.one - @api.depends('move_line_ids') + @api.depends('move_line_ids', 'move_line_ids.result_package_id', 'move_line_ids.product_uom_id', 'move_line_ids.qty_done') def _compute_bulk_weight(self): weight = 0.0 for move_line in self.move_line_ids: diff --git a/addons/digest/data/digest_template_data.xml b/addons/digest/data/digest_template_data.xml index e3053b661d1..df64772f469 100644 --- a/addons/digest/data/digest_template_data.xml +++ b/addons/digest/data/digest_template_data.xml @@ -116,7 +116,7 @@ <table style="width: 100%; max-width: 600px; margin-top: 5px; border: 1px solid #e7e7e7;"> <tr> <td style="border-collapse: collapse; background-color: #ffffff; line-height: 21px; padding: 0px 20px;"><br/> - <div style="color: #3d466e; line-height: 23px;">${ctx['tip_description']|safe}</div> + <div style="color: #3d466e; line-height: 23px;">${tips | safe}</div> </td> </tr> </table> diff --git a/addons/google_account/models/google_service.py b/addons/google_account/models/google_service.py index 1797dd66bea..5292a490515 100644 --- a/addons/google_account/models/google_service.py +++ b/addons/google_account/models/google_service.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime +from pprint import pformat import json import logging @@ -183,13 +184,16 @@ class GoogleService(models.TransientModel): except: pass except requests.HTTPError as error: + # https://developers.google.com/calendar/v3/errors if error.response.status_code in (204, 404): status = error.response.status_code response = "" else: - _logger.exception("Bad google request : %s !", error.response.content) + req = json.loads(error.request.body) + res = error.response.json() + _logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", pformat(req), pformat(res)) if error.response.status_code in (400, 401, 410): - raise error + 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")) return (status, response, ask_time) diff --git a/addons/hr_contract/models/hr_contract.py b/addons/hr_contract/models/hr_contract.py index bf1c5fbd58f..cf7ec0d6549 100644 --- a/addons/hr_contract/models/hr_contract.py +++ b/addons/hr_contract/models/hr_contract.py @@ -29,16 +29,13 @@ class Employee(models.Model): """ Returns the contracts of the employee between date_from and date_to """ - # a contract is valid if it ends between the given dates - clause_1 = ['&', ('date_end', '<=', date_to), ('date_end', '>=', date_from)] - # OR if it starts between the given dates - clause_2 = ['&', ('date_start', '<=', date_to), ('date_start', '>=', date_from)] - # OR if it starts before the date_from and finish after the date_end (or never finish) - clause_3 = ['&', ('date_start', '<=', date_from), '|', ('date_end', '=', False), ('date_end', '>=', date_to)] - clause_final = expression.AND([ - [('employee_id', 'in', self.ids), ('state', 'in', states)], - expression.OR([clause_1, clause_2, clause_3])]) - return self.env['hr.contract'].search(clause_final) + return self.env['hr.contract'].search([ + '&', '&', '&', + ('employee_id', 'in', self.ids), + ('state', 'in', states), + ('date_start', '<=', date_to), + '|', ('date_end', '=', False), ('date_end', '>=', date_from) + ]) @api.model def _get_all_contracts(self, date_from, date_to, states=['open', 'pending']): diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py index d505192dfc8..1a1bacbb83b 100644 --- a/addons/hr_holidays/models/hr_leave_allocation.py +++ b/addons/hr_holidays/models/hr_leave_allocation.py @@ -12,6 +12,7 @@ from odoo import api, fields, models from odoo.addons.resource.models.resource import HOURS_PER_DAY from odoo.exceptions import AccessError, UserError from odoo.tools.translate import _ +from odoo.tools.float_utils import float_round _logger = logging.getLogger(__name__) @@ -72,6 +73,8 @@ class HolidaysAllocation(models.Model): number_of_hours_display = fields.Float( 'Duration (hours)', compute='_compute_number_of_hours_display', help="UX field allowing to see and modify the allocation duration, computed in hours.") + duration_display = fields.Char('Allocated (Days/Hours)', compute='_compute_duration_display', + help="Field allowing to see the allocation duration in days or hours depending on the type_request_unit") # details parent_id = fields.Many2one('hr.leave.allocation', string='Parent') linked_request_ids = fields.One2many('hr.leave.allocation', 'parent_id', string='Linked Requests') @@ -207,6 +210,16 @@ class HolidaysAllocation(models.Model): allocation.number_of_hours_display = allocation.number_of_days * (allocation.employee_id.resource_calendar_id.hours_per_day or HOURS_PER_DAY) @api.multi + @api.depends('number_of_hours_display', 'number_of_days_display') + def _compute_duration_display(self): + for allocation in self: + allocation.duration_display = '%g %s' % ( + (float_round(allocation.number_of_hours_display, precision_digits=2) + if allocation.type_request_unit == 'hour' + else float_round(allocation.number_of_days_display, precision_digits=2)), + _('hours') if allocation.type_request_unit == 'hour' else _('days')) + + @api.multi @api.depends('state', 'employee_id', 'department_id') def _compute_can_reset(self): for allocation in self: diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py index 9b92f171d9e..73cb99d98d7 100644 --- a/addons/hr_holidays/models/hr_leave_type.py +++ b/addons/hr_holidays/models/hr_leave_type.py @@ -172,10 +172,16 @@ class HolidaysType(models.Model): for request in requests: status_dict = result[request.holiday_status_id.id] - status_dict['virtual_remaining_leaves'] -= request.number_of_days + status_dict['virtual_remaining_leaves'] -= (request.number_of_hours_display + if request.leave_type_request_unit == 'hour' + else request.number_of_days) if request.state == 'validate': - status_dict['leaves_taken'] += request.number_of_days - status_dict['remaining_leaves'] -= request.number_of_days + status_dict['leaves_taken'] += (request.number_of_hours_display + if request.leave_type_request_unit == 'hour' + else request.number_of_days) + status_dict['remaining_leaves'] -= (request.number_of_hours_display + if request.leave_type_request_unit == 'hour' + else request.number_of_days) for allocation in allocations: status_dict = result[allocation.holiday_status_id.id] @@ -183,9 +189,15 @@ class HolidaysType(models.Model): # note: add only validated allocation even for the virtual # count; otherwise pending then refused allocation allow # the employee to create more leaves than possible - status_dict['virtual_remaining_leaves'] += allocation.number_of_days - status_dict['max_leaves'] += allocation.number_of_days - status_dict['remaining_leaves'] += allocation.number_of_days + status_dict['virtual_remaining_leaves'] += (allocation.number_of_hours_display + if allocation.type_request_unit == 'hour' + else allocation.number_of_days) + status_dict['max_leaves'] += (allocation.number_of_hours_display + if allocation.type_request_unit == 'hour' + else allocation.number_of_days) + status_dict['remaining_leaves'] += (allocation.number_of_hours_display + if allocation.type_request_unit == 'hour' + else allocation.number_of_days) return result @@ -273,7 +285,7 @@ class HolidaysType(models.Model): 'count': _('%g remaining out of %g') % ( float_round(record.virtual_remaining_leaves, precision_digits=2) or 0.0, float_round(record.max_leaves, precision_digits=2) or 0.0, - ) + ) + (_(' hours') if record.request_unit == 'hour' else _(' days')) } res.append((record.id, name)) return res diff --git a/addons/hr_holidays/views/hr_leave_allocation_views.xml b/addons/hr_holidays/views/hr_leave_allocation_views.xml index aad32a99128..0595df96407 100644 --- a/addons/hr_holidays/views/hr_leave_allocation_views.xml +++ b/addons/hr_holidays/views/hr_leave_allocation_views.xml @@ -175,7 +175,7 @@ <field name="department_id"/> <field name="holiday_status_id"/> <field name="name"/> - <field name="number_of_days" string="Allocated Days" sum="Total allocated days"/> + <field name="duration_display"/> <field name="message_needaction" invisible="1"/> <field name="state"/> <button string="Approve" name="action_approve" type="object" diff --git a/addons/hw_blackbox_be/controllers/main.py b/addons/hw_blackbox_be/controllers/main.py index 36efed12e59..34dc06369e5 100644 --- a/addons/hw_blackbox_be/controllers/main.py +++ b/addons/hw_blackbox_be/controllers/main.py @@ -92,7 +92,7 @@ class Blackbox(Thread): def _send_and_wait_for_ack(self, packet, serial): ack = 0 - MAX_RETRIES = 1 + MAX_RETRIES = 1 # no more than 9 while ack != 0x06 and int(chr(packet[4])) < MAX_RETRIES: serial.write(packet) @@ -104,7 +104,7 @@ class Blackbox(Thread): # message so it's safe to do it. Also it would be a pain # to have to throw this all the way back to js just so it # can increment the retry counter and then try again. - packet = packet[:4] + str(int(packet[4]) + 1) + packet[5:] + packet[4] += 1 if ack: ack = ord(ack) @@ -141,11 +141,11 @@ class Blackbox(Thread): if stx == chr(0x02) and etx == chr(0x03) and bcc and self._lrc(response) == ord(bcc): got_response = True - ser.write(chr(0x06)) + ser.write(chr(0x06).encode()) else: _logger.warning("received ACK but not a valid response, sending NACK...") sent_nacks += 1 - ser.write(chr(0x15)) + ser.write(chr(0x15).encode()) if not got_response: _logger.error("sent " + str(MAX_NACKS) + " NACKS without receiving response, giving up.") @@ -173,4 +173,9 @@ if isfile("/home/pi/registered_blackbox_be"): @http.route('/hw_proxy/request_serial/', type='json', auth='none', cors='*') def request_serial(self): - return subprocess.check_output("ifconfig eth0 | grep 'ether.*' | sed 's/://g' | awk '{print $2}'", shell=True).rstrip()[-7:] + try: + with open('/sys/class/net/eth0/address', 'rb') as f: + return f.read().rstrip().replace(b':', b'')[-7:] + except IOError as e: + _logger.warning("eth0 network interface MAC address could not be found") + return b'' diff --git a/addons/l10n_ch/i18n_extra/de.po b/addons/l10n_ch/i18n_extra/de.po index 278f2bf8a0f..825220cd143 100644 --- a/addons/l10n_ch/i18n_extra/de.po +++ b/addons/l10n_ch/i18n_extra/de.po @@ -1099,7 +1099,7 @@ msgstr "Zu zahlende MwSt. bei 3,7% (LSt)" #. module: l10n_ch #: model:account.tax.template,name:l10n_ch.vat_77 msgid "TVA due a 7.7% (TN)" -msgstr "Zu zahlende MwSt. bei 8,0% (NRZ)" +msgstr "Zu zahlende MwSt. bei 7,7% (NRZ)" #. module: l10n_ch #: model:account.tax.template,name:l10n_ch.vat_77_purchase_return diff --git a/addons/mail/models/ir_actions.py b/addons/mail/models/ir_actions.py index 40582b1d803..56f60069024 100644 --- a/addons/mail/models/ir_actions.py +++ b/addons/mail/models/ir_actions.py @@ -70,7 +70,7 @@ class ServerActions(models.Model): @api.model def run_action_followers_multi(self, action, eval_context=None): - Model = self.env[action.model_id.model] + Model = self.env[action.model_name] if self.partner_ids or self.channel_ids and hasattr(Model, 'message_subscribe'): records = Model.browse(self._context.get('active_ids', self._context.get('active_id'))) records.message_subscribe(self.partner_ids.ids, self.channel_ids.ids) @@ -83,7 +83,7 @@ class ServerActions(models.Model): When need to know it to skip these steps. Except if the computed field is supposed to trigger the action """ - records = self.env[action.model_id.model].browse( + records = self.env[action.model_name].browse( self._context.get('active_ids', self._context.get('active_id'))) old_values = action._context.get('old_values') if old_values: @@ -112,6 +112,7 @@ class ServerActions(models.Model): # with wrong values in subsequent operations cleaned_ctx = dict(self.env.context) cleaned_ctx.pop('default_type', None) + cleaned_ctx.pop('default_parent_id', None) action.template_id.with_context(cleaned_ctx).send_mail(self._context.get('active_id'), force_send=False, raise_exception=False) return False @@ -120,7 +121,7 @@ class ServerActions(models.Model): if not action.activity_type_id or not self._context.get('active_id') or self._is_recompute(action): return False - records = self.env[action.model_id.model].browse(self._context.get('active_ids', self._context.get('active_id'))) + records = self.env[action.model_name].browse(self._context.get('active_ids', self._context.get('active_id'))) vals = { 'summary': action.activity_summary or '', diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index b6bbe82407e..4b860440f6b 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -617,7 +617,8 @@ class Channel(models.Model): # direct_partner should be removed from channel info since we can find it from members and channel_type # we keep it know to avoid change tests and javascript direct_partner = channel_partners.filtered(lambda pc: pc.partner_id.id != self.env.user.partner_id.id) - info['direct_partner'] = [partner_infos[direct_partner[0].partner_id.id]] + if direct_partner: + info['direct_partner'] = [partner_infos[direct_partner[0].partner_id.id]] # add needaction and unread counter, since the user is logged info['message_needaction_counter'] = channel.message_needaction_counter info['message_unread_counter'] = channel.message_unread_counter diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index 968242ffe6a..296cf092efb 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -366,7 +366,7 @@ var Chatter = Widget.extend({ this._fetchAttachments().then(this._openAttachmentBox.bind(this)); } if (this.fields.thread) { - this.trigger_up('reload', { fieldNames: ['message_attachment_count'] }); + this.trigger_up('reload', { fieldNames: ['message_attachment_count'], keepChanges: true }); } }, /** diff --git a/addons/mail/static/src/scss/followers.scss b/addons/mail/static/src/scss/followers.scss index ddf5c945994..86cbc38c82c 100644 --- a/addons/mail/static/src/scss/followers.scss +++ b/addons/mail/static/src/scss/followers.scss @@ -31,7 +31,6 @@ } .o_followers_follow_button { - width: 85px; padding-right: 0; color: $body-color; font-weight: bold; diff --git a/addons/mrp/report/mrp_report_bom_structure.py b/addons/mrp/report/mrp_report_bom_structure.py index b95c1bcf658..8e709a7df67 100644 --- a/addons/mrp/report/mrp_report_bom_structure.py +++ b/addons/mrp/report/mrp_report_bom_structure.py @@ -194,7 +194,7 @@ class ReportBomStructure(models.AbstractModel): if line._skip_bom_line(product): continue if line.child_bom_id: - qty = line.product_uom_id._compute_quantity(line.product_qty * factor, line.child_bom_id.product_uom_id) + qty = line.product_uom_id._compute_quantity(line.product_qty * factor, line.child_bom_id.product_uom_id) / line.child_bom_id.product_qty sub_price = self._get_price(line.child_bom_id, qty, line.product_id) price += sub_price else: diff --git a/addons/mrp/tests/test_bom.py b/addons/mrp/tests/test_bom.py index 6e4ed69d0b0..cacdf65189d 100644 --- a/addons/mrp/tests/test_bom.py +++ b/addons/mrp/tests/test_bom.py @@ -628,3 +628,89 @@ class TestBoM(TestMrpCommon): report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_car.id, searchQty=1, searchVariant=red_car_without_gps.id) # Same math than before but without GPS self.assertEqual(report_values['lines']['total'], 210) + + def test_bom_report_recursive_bom(self): + """ Test report with recursive BoM and different quantities. + BoM 1: + product = Finished (units) + quantity = 100 units + - Semi-Finished 5 kg + + BoM 2: + product = Semi-Finished (kg) + quantity = 11 kg + - Assembly 2 dozens + + BoM 3: + product = Assembly (dozens) + quantity = 5 dozens + - Raw Material 4 litres (product.product 5$/litre) + + Check the Price for 80 units of Finished -> 2.92$: + """ + # Create a products templates + uom_unit = self.env.ref('uom.product_uom_unit') + uom_kg = self.env.ref('uom.product_uom_kgm') + uom_dozen = self.env.ref('uom.product_uom_dozen') + uom_litre = self.env.ref('uom.product_uom_litre') + + finished = self.env['product.product'].create({ + 'name': 'Finished', + 'type': 'product', + 'uom_id': uom_unit.id, + 'uom_po_id': uom_unit.id, + }) + + semi_finished = self.env['product.product'].create({ + 'name': 'Semi-Finished', + 'type': 'product', + 'uom_id': uom_kg.id, + 'uom_po_id': uom_kg.id, + }) + + assembly = self.env['product.product'].create({ + 'name': 'Assembly', + 'type': 'product', + 'uom_id': uom_dozen.id, + 'uom_po_id': uom_dozen.id, + }) + + raw_material = self.env['product.product'].create({ + 'name': 'Raw Material', + 'type': 'product', + 'uom_id': uom_litre.id, + 'uom_po_id': uom_litre.id, + 'standard_price': 5, + }) + + #Create bom + bom_finished = Form(self.env['mrp.bom']) + bom_finished.product_tmpl_id = finished.product_tmpl_id + bom_finished.product_qty = 100 + with bom_finished.bom_line_ids.new() as line: + line.product_id = semi_finished + line.product_uom_id = uom_kg + line.product_qty = 5 + bom_finished = bom_finished.save() + + bom_semi_finished = Form(self.env['mrp.bom']) + bom_semi_finished.product_tmpl_id = semi_finished.product_tmpl_id + bom_semi_finished.product_qty = 11 + with bom_semi_finished.bom_line_ids.new() as line: + line.product_id = assembly + line.product_uom_id = uom_dozen + line.product_qty = 2 + bom_semi_finished = bom_semi_finished.save() + + bom_assembly = Form(self.env['mrp.bom']) + bom_assembly.product_tmpl_id = assembly.product_tmpl_id + bom_assembly.product_qty = 5 + with bom_assembly.bom_line_ids.new() as line: + line.product_id = raw_material + line.product_uom_id = uom_litre + line.product_qty = 4 + bom_assembly = bom_assembly.save() + + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_finished.id, searchQty=80) + + self.assertAlmostEqual(report_values['lines']['total'], 2.92) diff --git a/addons/payment/static/src/js/payment_form.js b/addons/payment/static/src/js/payment_form.js index c90b07875df..6036f946005 100644 --- a/addons/payment/static/src/js/payment_form.js +++ b/addons/payment/static/src/js/payment_form.js @@ -122,6 +122,18 @@ publicWidget.registry.PaymentForm = publicWidget.Widget.extend({ } }, + disableButton: function (button) { + $(button).attr('disabled', true); + $(button).children('.fa-lock').removeClass('fa-lock'); + $(button).prepend('<span class="o_loader"><i class="fa fa-refresh fa-spin"></i> </span>'); + }, + + enableButton: function (button) { + $(button).attr('disabled', false); + $(button).children('.fa').addClass('fa-lock'); + $(button).find('span.o_loader').remove(); + }, + //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- @@ -184,10 +196,7 @@ publicWidget.registry.PaymentForm = publicWidget.Widget.extend({ return; } - $(button).attr('disabled', true); - $(button).children('.fa-plus-circle').removeClass('fa-plus-circle'); - $(button).prepend('<span class="o_loader"><i class="fa fa-refresh fa-spin"></i> </span>'); - + this.disableButton(button); var verify_validity = this.$el.find('input[name="verify_validity"]'); if (verify_validity.length>0) { @@ -225,14 +234,10 @@ publicWidget.registry.PaymentForm = publicWidget.Widget.extend({ } } // here we remove the 'processing' icon from the 'add a new payment' button - $(button).attr('disabled', false); - $(button).children('.fa').addClass('fa-plus-circle'); - $(button).find('span.o_loader').remove(); + self.enableButton(button); }).guardedCatch(function (error) { // if the rpc fails, pretty obvious - $(button).attr('disabled', false); - $(button).children('.fa').addClass('fa-plus-circle'); - $(button).find('span.o_loader').remove(); + self.enableButton(button); self.displayError( _t('Server Error'), @@ -300,6 +305,7 @@ publicWidget.registry.PaymentForm = publicWidget.Widget.extend({ } } else { // if the user is using an old payment then we just submit the form + this.disableButton(button); form.submit(); return new Promise(function () {}); } diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index b4144c4c9cd..52dc13a0a0f 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -237,11 +237,7 @@ class PosOrder(models.Model): # Oldlin trick invoice_line = InvoiceLine.sudo().new(inv_line) invoice_line._onchange_product_id() - invoice_line.invoice_line_tax_ids = invoice_line.invoice_line_tax_ids.filtered(lambda t: t.company_id.id == line.order_id.company_id.id).ids - fiscal_position_id = line.order_id.fiscal_position_id - if fiscal_position_id: - invoice_line.invoice_line_tax_ids = fiscal_position_id.map_tax(invoice_line.invoice_line_tax_ids, line.product_id, line.order_id.partner_id) - invoice_line.invoice_line_tax_ids = invoice_line.invoice_line_tax_ids.ids + invoice_line.invoice_line_tax_ids = [(6, False, line.tax_ids_after_fiscal_position.filtered(lambda t: t.company_id.id == line.order_id.company_id.id).ids)] # We convert a new id object back to a dictionary to write to # bridge between old and new api inv_line = invoice_line._convert_to_write({name: invoice_line[name] for name in invoice_line._cache}) diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 56f56dffb7f..10025258bde 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -880,6 +880,9 @@ exports.PosModel = Backbone.Model.extend({ }}).then(function () { resolveInvoiced(); resolveDone(); + }).guardedCatch(function (error) { + rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}}); + rejectDone(); }); } else { // The order has been pushed separately in batch when diff --git a/addons/point_of_sale/tests/test_point_of_sale_flow.py b/addons/point_of_sale/tests/test_point_of_sale_flow.py index d76c660d0fc..30f9f8eae84 100644 --- a/addons/point_of_sale/tests/test_point_of_sale_flow.py +++ b/addons/point_of_sale/tests/test_point_of_sale_flow.py @@ -671,3 +671,58 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): self.assertAlmostEqual(a, b) for a, b in zip(sorted(amount_currency_lines), [-855.0, -777.27, -81.82, -45.0, 1752.75]): self.assertAlmostEqual(a, b) + + def test_order_to_invoice_no_tax(self): + # I create a new PoS order with 2 units of PC1 at 450 EUR (Tax Incl) and 3 units of PCSC349 at 300 EUR. (Tax Excl) + self.pos_order_pos1 = self.PosOrder.create({ + 'company_id': self.company_id, + 'partner_id': self.partner1.id, + 'pricelist_id': self.partner1.property_product_pricelist.id, + 'lines': [(0, 0, { + 'name': "OL/0001", + 'product_id': self.product3.id, + 'price_unit': 450, + 'discount': 5.0, + 'qty': 2.0, + 'price_subtotal': 855, + 'price_subtotal_incl': 855, + }), (0, 0, { + 'name': "OL/0002", + 'product_id': self.product4.id, + 'price_unit': 300, + 'discount': 5.0, + 'qty': 3.0, + 'price_subtotal': 855, + 'price_subtotal_incl': 855, + })], + 'amount_tax': 855 * 2, + 'amount_total': 855 * 2, + 'amount_paid': 0.0, + 'amount_return': 0.0, + }) + + # I click on the "Make Payment" wizard to pay the PoS order + context_make_payment = {"active_ids": [self.pos_order_pos1.id], "active_id": self.pos_order_pos1.id} + self.pos_make_payment = self.PosMakePayment.with_context(context_make_payment).create({ + 'amount': 855 * 2, + }) + # I click on the validate button to register the payment. + context_payment = {'active_id': self.pos_order_pos1.id} + self.pos_make_payment.with_context(context_payment).check() + + # I check that the order is marked as paid and there is no invoice + # attached to it + self.assertEqual(self.pos_order_pos1.state, 'paid', "Order should be in paid state.") + self.assertFalse(self.pos_order_pos1.invoice_id, 'Invoice should not be attached to order.') + + # I generate an invoice from the order + res = self.pos_order_pos1.action_pos_order_invoice() + self.assertIn('res_id', res, "No invoice created") + + # I test that the total of the attached invoice is correct + invoice = self.env['account.invoice'].browse(res['res_id']) + self.assertAlmostEqual( + invoice.amount_total, self.pos_order_pos1.amount_total, places=2, msg="Invoice not correct") + + for iline in invoice.invoice_line_ids: + self.assertFalse(iline.invoice_line_tax_ids) diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml index d83063b9158..0287ef3b43a 100644 --- a/addons/sale/data/sale_demo.xml +++ b/addons/sale/data/sale_demo.xml @@ -4,7 +4,6 @@ <!-- We want to activate pay and sign by default for easier demoing. --> <record id="base.main_company" model="res.company"> - <field name="portal_confirmation_sign" eval="True"/> <field name="portal_confirmation_pay" eval="True"/> </record> diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py index 439061adafa..913995fa5c7 100644 --- a/addons/sale/models/res_company.py +++ b/addons/sale/models/res_company.py @@ -7,7 +7,7 @@ from odoo import api, fields, models, _ class ResCompany(models.Model): _inherit = "res.company" - portal_confirmation_sign = fields.Boolean(string='Online Signature') + portal_confirmation_sign = fields.Boolean(string='Online Signature', default=True) portal_confirmation_pay = fields.Boolean(string='Online Payment') quotation_validity_days = fields.Integer(default=30, string="Default Quotation Validity (Days)") diff --git a/addons/sale/models/res_config_settings.py b/addons/sale/models/res_config_settings.py index 43cdf1d8c15..a07e90db278 100644 --- a/addons/sale/models/res_config_settings.py +++ b/addons/sale/models/res_config_settings.py @@ -42,7 +42,7 @@ class ResConfigSettings(models.TransientModel): oldname='default_deposit_product_id', help='Default product used for payment advances') auto_done_setting = fields.Boolean("Lock Confirmed Sales", config_parameter='sale.auto_done_setting') - module_website_sale_digital = fields.Boolean("Sell digital products - provide downloadable content on your customer portal") + module_website_sale_digital = fields.Boolean("Digital Content") auth_signup_uninvited = fields.Selection([ ('b2b', 'On invitation'), diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 044e4600af1..d8556c26190 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -500,6 +500,9 @@ class SaleOrder(models.Model): invoices_origin = {} invoices_name = {} + # Keep track of the sequences of the lines + # To keep lines under their section + inv_line_sequence = 0 for order in self: group_key = order.id if grouped else (order.partner_invoice_id.id, order.currency_id.id) @@ -508,6 +511,7 @@ class SaleOrder(models.Model): # Create lines in batch to avoid performance problems line_vals_list = [] + # sequence is the natural order of order_lines for line in order.order_line: if line.display_type == 'line_section': pending_section = line @@ -529,14 +533,21 @@ class SaleOrder(models.Model): if line.qty_to_invoice > 0 or (line.qty_to_invoice < 0 and final): if pending_section: - line_vals_list.extend(pending_section.invoice_line_create_vals( + section_invoice = pending_section.invoice_line_create_vals( invoices[group_key].id, pending_section.qty_to_invoice - )) + ) + inv_line_sequence += 1 + section_invoice[0]['sequence'] = inv_line_sequence + line_vals_list.extend(section_invoice) pending_section = None - line_vals_list.extend(line.invoice_line_create_vals( + + inv_line_sequence += 1 + inv_line = line.invoice_line_create_vals( invoices[group_key].id, line.qty_to_invoice - )) + ) + inv_line[0]['sequence'] = inv_line_sequence + line_vals_list.extend(inv_line) if references.get(invoices.get(group_key)): if order not in references[invoices[group_key]]: diff --git a/addons/sale/static/src/js/sale_portal_sidebar.js b/addons/sale/static/src/js/sale_portal_sidebar.js index f9ba54ad661..815c8254e18 100644 --- a/addons/sale/static/src/js/sale_portal_sidebar.js +++ b/addons/sale/static/src/js/sale_portal_sidebar.js @@ -71,7 +71,7 @@ publicWidget.registry.SalePortalSidebar = PortalSidebar.extend({ if (!text) { break; } - lastLI = $("<li class='nav-item'>").append($('<a class="nav-link" href="#' + id + '"/>').text(text)).appendTo($bsSidenav); + lastLI = $("<li class='nav-item'>").append($('<a class="nav-link" style="max-width: 200px;" href="#' + id + '"/>').text(text)).appendTo($bsSidenav); lastUL = false; break; case "h3": @@ -84,7 +84,7 @@ publicWidget.registry.SalePortalSidebar = PortalSidebar.extend({ if (!lastUL) { lastUL = $("<ul class='nav flex-column'>").appendTo(lastLI); } - $("<li class='nav-item'>").append($('<a class="nav-link" href="#' + id + '"/>').text(text)).appendTo(lastUL); + $("<li class='nav-item'>").append($('<a class="nav-link" style="max-width: 200px;" href="#' + id + '"/>').text(text)).appendTo(lastUL); } break; } diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index 0b38a867a4a..bac4703a498 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -443,7 +443,8 @@ class SaleOrderLine(models.Model): def _update_line_quantity(self, values): precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - if self.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(self.mapped('qty_delivered')), precision_digits=precision) == -1: + line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu']) + if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1: raise UserError(_('You cannot decrease the ordered quantity below the delivered quantity.\n' 'Create a return first.')) super(SaleOrderLine, self)._update_line_quantity(values) diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py index af275ba9a3b..0e12add5f27 100644 --- a/addons/sale_timesheet/models/project_overview.py +++ b/addons/sale_timesheet/models/project_overview.py @@ -193,7 +193,7 @@ class Project(models.Model): rows_sale_line[sale_line_row_key][-1] = 0 rows_sale_order = {} # so -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted] - rows_sale_order_done_sold = dict.fromkeys(set(map_sol_so.values()) | set([None]), dict(sold=0.0, done=0.0)) # SO id -> {'sold':0.0, 'done': 0.0} + rows_sale_order_done_sold = {key : dict(sold=0.0, done=0.0) for key in set(map_sol_so.values()) | set([None])} # SO id -> {'sold':0.0, 'done': 0.0} for row_key, row_sale_line in rows_sale_line.items(): sale_order_id = row_key[0] # sale order row diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 83b62e84d86..e819d9cdfb9 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -4,6 +4,7 @@ from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError +from odoo.osv import expression from odoo.tools.float_utils import float_round from datetime import datetime import operator as py_operator @@ -179,33 +180,45 @@ class Product(models.Model): [('location_id.company_id', '=', False), ('location_dest_id.company_id', '=', company_id)], [('location_id.company_id', '=', company_id), ('location_dest_id.company_id', '=', False), ]) - location_ids = [] - if self.env.context.get('location', False): - if isinstance(self.env.context['location'], int): - location_ids = [self.env.context['location']] - elif isinstance(self.env.context['location'], str): - domain = [('complete_name', 'ilike', self.env.context['location'])] - if self.env.context.get('force_company', False): - domain += [('company_id', '=', self.env.context['force_company'])] - location_ids = self.env['stock.location'].search(domain).ids + + def _search_ids(model, values, force_company_id): + ids = set() + domain = [] + for item in values: + if isinstance(item, int): + ids.add(item) + else: + domain = expression.OR([[('name', 'ilike', item)], domain]) + if force_company_id: + domain = expression.AND([[('company_id', '=', force_company_id)], domain]) + if domain: + ids |= set(self.env[model].search(domain).ids) + return ids + + # We may receive a location or warehouse from the context, either by explicit + # python code or by the use of dummy fields in the search view. + # Normalize them into a list. + location = self.env.context.get('location') + if location and not isinstance(location, list): + location = [location] + warehouse = self.env.context.get('warehouse') + if warehouse and not isinstance(warehouse, list): + warehouse = [warehouse] + force_company = self.env.context.get('force_company', False) + # filter by location and/or warehouse + if warehouse: + w_ids = set(Warehouse.browse(_search_ids('stock.warehouse', warehouse, force_company)).mapped('view_location_id').ids) + if location: + l_ids = _search_ids('stock.location', location, force_company) + location_ids = w_ids & l_ids else: - location_ids = self.env.context['location'] + location_ids = w_ids else: - if self.env.context.get('warehouse', False): - if isinstance(self.env.context['warehouse'], int): - wids = [self.env.context['warehouse']] - elif isinstance(self.env.context['warehouse'], str): - domain = [('name', 'ilike', self.env.context['warehouse'])] - if self.env.context.get('force_company', False): - domain += [('company_id', '=', self.env.context['force_company'])] - wids = Warehouse.search(domain).ids - else: - wids = self.env.context['warehouse'] + if location: + location_ids = _search_ids('stock.location', location, force_company) else: - wids = Warehouse.search([]).ids + location_ids = set(Warehouse.search([]).mapped('view_location_id').ids) - for w in Warehouse.browse(wids): - location_ids.append(w.view_location_id.id) return self._get_domain_locations_new(location_ids, company_id=self.env.context.get('force_company', False), compute_child=self.env.context.get('compute_child', True)) def _get_domain_locations_new(self, location_ids, company_id=False, compute_child=True): diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml index be87a96529f..6b171b74ebd 100644 --- a/addons/survey/views/assets.xml +++ b/addons/survey/views/assets.xml @@ -4,10 +4,7 @@ <!-- survey assets --> <template id="survey_assets" name="Survey Results assets"> <script src="/web/static/lib/Chart/Chart.js"></script> - <script type="text/javascript" src="/web/static/src/js/public/public_root.js"/> - <script type="text/javascript" src="/web/static/src/js/public/public_root_instance.js"/> - <script type="text/javascript" src="/web/static/src/js/public/public_widget.js"/> - <script type="text/javascript" src="/web/static/src/js/services/session.js"></script> + <script type="text/javascript" src="/web/static/src/js/fields/field_utils.js"></script> <script type="text/javascript" src="/survey/static/src/js/survey_result.js" /> @@ -18,6 +15,16 @@ <script type="text/javascript" src="/survey/static/src/js/tour_test_certification_failure.js"></script> <script type="text/javascript" src="/survey/static/src/js/tour_test_survey_prefill.js"></script> + <link href="/survey/static/src/css/survey_print.css" rel="stylesheet" type="text/css"/> + <link href="/survey/static/src/css/survey_result.css" rel="stylesheet" type="text/css"></link> + </template> + + <template id="survey_web_assets" name="Survey Web Assets"> + <script type="text/javascript" src="/web/static/src/js/public/public_root.js"/> + <script type="text/javascript" src="/web/static/src/js/public/public_root_instance.js"/> + <script type="text/javascript" src="/web/static/src/js/public/public_widget.js"/> + <script type="text/javascript" src="/web/static/src/js/services/session.js"></script> + <t t-call="web._assets_helpers"> <t t-call="web._assets_frontend_helpers"/> </t> @@ -25,8 +32,6 @@ <link rel="stylesheet" type="text/less" href="/web/static/src/scss/fonts.scss" /> <!-- Some helper classes (like mt- mb-) are required for survey templates --> <link rel="stylesheet" type="text/less" href="/web_editor/static/src/scss/web_editor.common.scss"/> - <link href="/survey/static/src/css/survey_print.css" rel="stylesheet" type="text/css"/> - <link href="/survey/static/src/css/survey_result.css" rel="stylesheet" type="text/css"></link> <link rel="stylesheet" type="text/scss" href="/survey/static/src/scss/survey_templates.scss"/> </template> diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml index 26ac5981768..48156259f28 100644 --- a/addons/survey/views/survey_templates.xml +++ b/addons/survey/views/survey_templates.xml @@ -142,6 +142,7 @@ }; </script> <t t-call-assets="web.assets_common"/> + <t t-call-assets="survey.survey_web_assets"/> <t t-call-assets="survey.survey_assets"/> </t> <body> diff --git a/addons/transifex/models/ir_translation.py b/addons/transifex/models/ir_translation.py index c19f66c39c1..51edae39206 100644 --- a/addons/transifex/models/ir_translation.py +++ b/addons/transifex/models/ir_translation.py @@ -84,8 +84,7 @@ class IrTranslation(models.Model): 'project': project, 'lang': lang_code, 'module': translation.module, - 'src': werkzeug.url_quote_plus( - "text:'" + - translation.source[:50].replace("'", "\\'") + - "'"), + 'src': "text:'" + werkzeug.url_quote_plus( + translation.source[:50].replace("\n", "").replace("'", "") + ) + "'", } diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 68ac157868d..c27311d4df5 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -31,7 +31,9 @@ var qweb = core.qweb; var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMixin, { dependencies: ['notification'], - events: _.extend(KeyboardNavigationMixin.events, {}), + events: _.extend({}, KeyboardNavigationMixin.events, { + 'click .o_search_options .dropdown-menu': '_onClickDropDownMenu', + }), custom_events: { clear_uncommitted_changes: function (e) { this.clear_uncommitted_changes().then(e.data.callback); @@ -334,6 +336,16 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi //-------------------------------------------------------------------------- /** + * When clicking inside a dropdown to modify search options + * prevents the bootstrap dropdown to close on itself + * + * @private + * @param {Event} ev + */ + _onClickDropDownMenu: function (ev) { + ev.stopPropagation(); + }, + /** * Whenever the connection is lost, we need to notify the user. * * @private diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index c7a80864a15..607f49c8031 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -495,7 +495,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { additional_context: _.extend({}, data.context) }); } - return false; }, /** * FIXME: this logic should be rethought diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index dda3978635b..61a966972aa 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -204,7 +204,9 @@ var CalendarController = AbstractController.extend({ * @param {OdooEvent} event */ _onDropRecord: function (event) { - this._updateRecord(event.data); + this._updateRecord(_.extend({}, event.data, { + 'drop': true, + })); }, /** * @private 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 ba338a96e4d..0ad1b61e4d7 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -105,7 +105,9 @@ return AbstractModel.extend({ } if (this.mapping.date_delay) { - data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) { + data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + } } return data; diff --git a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js index a1da2925eb6..5ce53b20806 100644 --- a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js +++ b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js @@ -18,7 +18,6 @@ var ControlPanelRenderer = Renderer.extend({ get_action_info: '_onGetActionInfo', }, events: _.extend({}, Renderer.prototype.events, { - 'click.bs.dropdown .o_search_options .dropdown-menu': '_onDropdownClicked', 'click .o_searchview_more': '_onMore', }), @@ -368,15 +367,6 @@ var ControlPanelRenderer = Renderer.extend({ //-------------------------------------------------------------------------- /** - * Prevent the search dropdowns from closing when clicking inside them. - * - * @private - * @param {MouseEvent} ev - */ - _onDropdownClicked: function (ev) { - ev.stopPropagation(); - }, - /** * Toggle the search menus visibility. * * @private diff --git a/addons/web/static/src/scss/bootstrap_overridden_report.scss b/addons/web/static/src/scss/bootstrap_overridden_report.scss new file mode 100644 index 00000000000..a6e1cd23b32 --- /dev/null +++ b/addons/web/static/src/scss/bootstrap_overridden_report.scss @@ -0,0 +1,2 @@ +// remove Noto Color Emoji +$font-family-sans-serif: "Noto", "Lucida Grande", Helvetica, Verdana, Arial, sans-serif; diff --git a/addons/web/static/src/scss/layout_background.scss b/addons/web/static/src/scss/layout_background.scss index caeb1f0c2bf..93ac948b2ad 100644 --- a/addons/web/static/src/scss/layout_background.scss +++ b/addons/web/static/src/scss/layout_background.scss @@ -1,5 +1,5 @@ .o_background_footer, .o_background_header, .o_report_layout_background { - font-family: 'Lato'; + font-family: 'Lato', 'Noto'; color: gray('700'); } .o_background_header { diff --git a/addons/web/static/src/scss/layout_boxed.scss b/addons/web/static/src/scss/layout_boxed.scss index bf19b6fb7ae..2129f583b21 100644 --- a/addons/web/static/src/scss/layout_boxed.scss +++ b/addons/web/static/src/scss/layout_boxed.scss @@ -1,5 +1,5 @@ .o_boxed_footer, .o_boxed_header, .o_report_layout_boxed { - font-family: 'Lato'; + font-family: 'Lato' 'Noto'; color: gray('700'); font-size: 12px; } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index f9db8432275..564bbc8582e 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1369,13 +1369,14 @@ <div role="menuitem" class="dropdown-item-text o_favorite_name"> <input type="text" class="o_input"></input> </div> + <t t-set="favId" t-value="_.uniqueId('_checkbox_')" /> <div class="custom-control custom-checkbox"> - <input type="checkbox" id="o_favorite_use_by_default" class="custom-control-input"/> - <label for="o_favorite_use_by_default" class="custom-control-label">Use by default</label> + <input type="checkbox" t-attf-id="o_favorite_use_by_default#{favId}" class="custom-control-input"/> + <label t-attf-for="o_favorite_use_by_default#{favId}" class="custom-control-label">Use by default</label> </div> <div class="custom-control custom-checkbox"> - <input type="checkbox" id="o_favorite_share_all_users" class="custom-control-input"/> - <label for="o_favorite_share_all_users" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label> + <input type="checkbox" t-attf-id="o_favorite_share_all_users#{favId}" class="custom-control-input"/> + <label t-attf-for="o_favorite_share_all_users#{favId}" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label> </div> <div class="dropdown-item-text o_save_favorite"> <button type="button" class="btn btn-primary">Save</button> @@ -1559,7 +1560,7 @@ <t t-name="SwitchCompanyMenu"> <li class="o_switch_company_menu"> - <a role="button" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle" data-toggle="dropdown" data-display="static" aria-expanded="false" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span t-attf-class="#{widget.isMobile ? 'fa fa-building-o' : 'oe_topbar_name'}"/> </a> <div class="dropdown-menu dropdown-menu-right" role="menu"/> diff --git a/addons/web/static/src/xml/menu.xml b/addons/web/static/src/xml/menu.xml index 3838cc6c226..6a4749d1c25 100644 --- a/addons/web/static/src/xml/menu.xml +++ b/addons/web/static/src/xml/menu.xml @@ -15,7 +15,7 @@ --> <t t-name="AppsMenu"> <li class="dropdown"> - <a class="full" data-toggle="dropdown" href="#"> + <a class="full" data-toggle="dropdown" data-display="static" href="#"> <i class="fa fa-th-large"/> </a> <div class="dropdown-menu" role="menu"> @@ -77,7 +77,7 @@ </t> <t t-else=""> <li> - <a href="#" class="dropdown-toggle o-no-caret o_menu_header_lvl_1" t-att-data-menu-xmlid="second_level_menu.xmlid" data-toggle="dropdown" role="button" aria-expanded="false"> + <a href="#" class="dropdown-toggle o-no-caret o_menu_header_lvl_1" t-att-data-menu-xmlid="second_level_menu.xmlid" data-toggle="dropdown" data-display="static" role="button" aria-expanded="false"> <t t-esc="second_level_menu.name"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/web/static/tests/views/abstract_view_banner_tests.js b/addons/web/static/tests/views/abstract_view_banner_tests.js index ab668dd8c0d..e5aa14faccc 100644 --- a/addons/web/static/tests/views/abstract_view_banner_tests.js +++ b/addons/web/static/tests/views/abstract_view_banner_tests.js @@ -40,13 +40,33 @@ QUnit.module('Views', { QUnit.test("The banner should be fetched from the route", function (assert) { var done = assert.async(); - assert.expect(5); + assert.expect(6); - var banner_html = - '<div>' + - '<link type="text/css" href="' + test_css_url + '" rel="stylesheet">' + - '<div class="hello_banner"/>' + - '</div>'; + var banner_html =` + <div class="modal o_onboarding_modal o_technical_modal" tabindex="-1" role="dialog"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-footer"> + <a type="action" class="btn btn-primary" data-dismiss="modal" + data-toggle="collapse" href=".o_onboarding_container"> + Remove + </a> + </div> + </div> + </div> + </div> + <div class="o_onboarding_container collapse show"> + <div class="o_onboarding_wrap"> + <a href="#" data-toggle="modal" data-target=".o_onboarding_modal" + class="float-right o_onboarding_btn_close"> + <i class="fa fa-times" title="Close the onboarding panel" /> + </a> + </div> + <div> + <link type="text/css" href="` + test_css_url + `" rel="stylesheet"> + <div class="hello_banner">Here is the banner</div> + </div> + </div>`; createView({ View: TestView, @@ -60,7 +80,7 @@ QUnit.module('Views', { } return this._super(route, args); }, - }).then(function (view) { + }).then(async function (view) { var $banner = view.$('.hello_banner'); assert.strictEqual($banner.length, 1, "The view should contain the response from the controller."); @@ -73,6 +93,12 @@ QUnit.module('Views', { var $banner_link = $('link[href$="' + test_css_url + '"]', $banner); assert.strictEqual($banner_link.length, 0, "The stylesheet should have been removed from the banner."); + + await testUtils.dom.click(view.$('.o_onboarding_btn_close')); // click on close to remove banner + await testUtils.dom.click(view.$('.o_technical_modal .btn-primary:contains("Remove")')); // click on button remove from techinal modal + assert.strictEqual(view.$('.o_onboarding_container.show').length, 0, + "Banner should be removed from the view"); + view.destroy(); done(); }); diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index 1a10b487c3d..34e09541d54 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -42,6 +42,7 @@ QUnit.module('Views', { stop_date: {string: "stop date", type: "date"}, start: {string: "start datetime", type: "datetime"}, stop: {string: "stop datetime", type: "datetime"}, + delay: {string: "delay", type: "float"}, allday: {string: "allday", type: "boolean"}, partner_ids: {string: "attendees", type: "one2many", relation: 'partner', default: [[6, 0, [1]]]}, type: {string: "type", type: "integer"}, @@ -2383,6 +2384,50 @@ QUnit.module('Views', { await testUtils.nextTick(); assert.verifySteps(["2017-01-07 00:00:00"]); assert.strictEqual(calendar.$('.o_field_start_date').text().trim(), "01/07/2017"); + calendar.destroy(); + }); + + QUnit.test('drag and drop on month mode with date_start and date_delay', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start" date_delay="delay" mode="month">'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '<field name="delay"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "write") { + // delay should not be written at drag and drop + assert.equal(args.args[1].delay, undefined) + } + return this._super(route, args); + }, + }); + + // Create event (on 20 december) + var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "An event"); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + await testUtils.nextTick(); + + // Move event to another day (on 27 november) + await testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').first() + ); calendar.destroy(); }); diff --git a/addons/web/static/tests/views/search_view_tests.js b/addons/web/static/tests/views/search_view_tests.js index 4279f5b9c39..43282b52f33 100644 --- a/addons/web/static/tests/views/search_view_tests.js +++ b/addons/web/static/tests/views/search_view_tests.js @@ -1280,7 +1280,7 @@ QUnit.module('Search View', { }); QUnit.test('Customizing filter does not close the filter dropdown', async function (assert) { - assert.expect(3); + assert.expect(5); var self = this; _.each(this.data.partner.records.slice(), function (rec) { @@ -1304,6 +1304,13 @@ QUnit.module('Search View', { 'partner,false,search': '<search><field name="date_field"/></search>', }, res_id: 1, + intercepts: { + create_filter: function (ev) { + var data = ev.data; + assert.strictEqual(data.filter.name, 'Fire on the bayou'); + assert.strictEqual(data.filter.is_default, true); + }, + }, }); await testUtils.fields.many2one.clickOpenDropdown('bar'); @@ -1327,9 +1334,22 @@ QUnit.module('Search View', { $input.click(); await testUtils.nextTick(); }); - assert.isVisible($filterDropdown); + // Favorites Menu + var $modal = $('.modal'); + await testUtils.dom.click($modal.find('.o_favorites_menu_button')); + await testUtils.dom.click($modal.find('.o_add_favorite')); + $modal.find('.o_search_options .dropdown-menu').one('click', function (ev) { + // This handler is on the webClient + // But since the test suite doesn't have one + // We manually set it here + ev.stopPropagation(); + }); + await testUtils.fields.editInput($modal.find('div.o_favorite_name input'), 'Fire on the bayou'); + await testUtils.dom.click($modal.find('.o_add_favorite ~ div label:contains(Use by default)')); + await testUtils.dom.click($modal.find('.o_save_favorite button')); + form.destroy(); }); }); diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml index ea963fb926b..4865160be0e 100644 --- a/addons/web/views/report_templates.xml +++ b/addons/web/views/report_templates.xml @@ -5,7 +5,8 @@ <template id="report_assets_common"> <t t-call="web._assets_helpers"> <!-- FIXME web_editor ? --> - <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/bootstrap_overridden.scss"/> + <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/bootstrap_overridden_report.scss"/> + <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/bootstrap_overridden.scss"/> </t> <t t-call="web._assets_bootstrap"/> diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js b/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js index 74703b90e45..255561b02be 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js @@ -19,6 +19,7 @@ var DropzonePlugin = Plugins.dropzone.extend({ attachDragAndDropEvent: function () { this._super.apply(this, arguments); this.$dropzone.off('drop'); + this.$dropzone.on('dragenter', this._onDragenter.bind(this)); this.$dropzone.on('drop', this._onDrop.bind(this)); }, @@ -71,7 +72,7 @@ var DropzonePlugin = Plugins.dropzone.extend({ if (range.so >= dom.nodeLength(range.sc)) { $(range.sc).append(spinner); } else { - $(range.sc).before(range.sc.childNodes[range.so]); + $(range.sc.childNodes[range.so]).before(spinner); } } else { range.sc.splitText(range.so); @@ -151,7 +152,7 @@ var DropzonePlugin = Plugins.dropzone.extend({ route: '/web_editor/add_image_base64', params: { res_model: options.res_model, - res_id: options.res_id, + res_id: options.res_id || 0, image_base64: imageBase64.split(';base64,').pop(), filename: fileName, }, @@ -161,9 +162,26 @@ var DropzonePlugin = Plugins.dropzone.extend({ * @private * @param {JQueryEvent} e */ + _onDragenter: function (e) { + var range = this.context.invoke('editor.createRange'); + if (!this.editable.contains(range.sc)) { + var node = this.editable; + if (node && node.firstChild) { + node = node.firstChild; + } + range = this.context.invoke('editor.setRange', node, 0, node, 0); + range.select(); + } + }, + /** + * @private + * @param {JQueryEvent} e + */ _onDrop: function (e) { e.preventDefault(); + this.$editor.removeClass('dragover'); + if (this.options.disableDragAndDrop) { return; } diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js index 85ac5f222f4..e6a598a2813 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js @@ -43,6 +43,8 @@ var MediaPlugin = AbstractPlugin.extend({ 'dblclick .note-editable': '_onDblclick', }, + mousePosition: {}, + //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- diff --git a/addons/web_editor/static/src/scss/bootstrap_overridden.scss b/addons/web_editor/static/src/scss/bootstrap_overridden.scss index 4517852076f..d1644a1174e 100644 --- a/addons/web_editor/static/src/scss/bootstrap_overridden.scss +++ b/addons/web_editor/static/src/scss/bootstrap_overridden.scss @@ -55,5 +55,3 @@ $gray-700: gray('700') !default; $gray-800: gray('800') !default; $gray-900: gray('900') !default; $black: gray('black') !default; - -$font-family-sans-serif: "Noto", "Lucida Grande", Helvetica, Verdana, Arial, sans-serif; diff --git a/addons/web_tour/static/src/js/running_tour_action_helper.js b/addons/web_tour/static/src/js/running_tour_action_helper.js index 479f3a2ea78..a9bc1c220c0 100644 --- a/addons/web_tour/static/src/js/running_tour_action_helper.js +++ b/addons/web_tour/static/src/js/running_tour_action_helper.js @@ -87,7 +87,7 @@ var RunningTourActionHelper = core.Class.extend({ $options.prop("selected", false).removeProp("selected"); var $selectedOption = $options.filter(function () { return $(this).val() === text; }); if ($selectedOption.length === 0) { - $selectedOption = $options.filter(function () { return $(this).text() === text; }); + $selectedOption = $options.filter(function () { return $(this).text().trim() === text; }); } $selectedOption.prop("selected", true); this._click(values); diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js index 83c16278b6d..dda40102904 100644 --- a/addons/website/static/src/js/backend/dashboard.js +++ b/addons/website/static/src/js/backend/dashboard.js @@ -45,8 +45,8 @@ var Dashboard = AbstractAction.extend({ this._super(parent, context); this.date_range = 'week'; // possible values : 'week', 'month', year' - this.date_from = moment().subtract(1, 'week'); - this.date_to = moment(); + this.date_from = moment.utc().subtract(1, 'week'); + this.date_to = moment.utc(); this.dashboards_templates = ['website.dashboard_header', 'website.dashboard_content']; this.graphs = []; @@ -303,13 +303,13 @@ var Dashboard = AbstractAction.extend({ on_date_range_button: function(date_range) { if (date_range === 'week') { this.date_range = 'week'; - this.date_from = moment().subtract(1, 'weeks'); + this.date_from = moment.utc().subtract(1, 'weeks'); } else if (date_range === 'month') { this.date_range = 'month'; - this.date_from = moment().subtract(1, 'months'); + this.date_from = moment.utc().subtract(1, 'months'); } else if (date_range === 'year') { this.date_range = 'year'; - this.date_from = moment().subtract(1, 'years'); + this.date_from = moment.utc().subtract(1, 'years'); } else { console.log('Unknown date range. Choose between [week, month, year]'); return; diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 131ec4adaff..0429f21de5f 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -973,6 +973,7 @@ class WebsiteSale(http.Controller): return request.redirect('/shop') if order and not order.amount_total and not tx: + order.with_context(send_email=True).action_confirm() return request.redirect(order.get_portal_url()) # clean context and session, then redirect to the confirmation page diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index dfe942d2dd8..8228027fced 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -325,7 +325,7 @@ class SaleOrder(models.Model): 'view_id': composer_form_view_id, 'target': 'new', 'context': { - 'default_composition_mode': 'mass_mail', + 'default_composition_mode': 'mass_mail' if len(self.ids) > 1 else 'comment', 'default_res_id': self.ids[0], 'default_model': 'sale.order', 'default_use_template': bool(template_id), diff --git a/addons/website_sale_delivery/views/website_sale_delivery_templates.xml b/addons/website_sale_delivery/views/website_sale_delivery_templates.xml index d0c692177ba..573acdf0bc4 100644 --- a/addons/website_sale_delivery/views/website_sale_delivery_templates.xml +++ b/addons/website_sale_delivery/views/website_sale_delivery_templates.xml @@ -27,8 +27,14 @@ <label class="label-optional" t-field="delivery.name"/> <t t-set='badge_class' t-value="(delivery_nb != 1 and 'float-right ' or '') + 'badge badge-secondary'" /> <t t-if="delivery.delivery_type == 'fixed'"> - <span t-if="delivery.fixed_price > 0.0" t-att-class="badge_class" t-field="delivery.fixed_price" t-options='{"widget": "monetary", "from_currency": delivery.product_id.company_id.currency_id, "display_currency": website_sale_order.currency_id}'/> - <span t-else="" t-att-class="badge_class">Free</span> + <span t-if="delivery.fixed_price > 0.0" t-att-class="badge_class"> + <t t-set="monetary_options" t-value='{"widget": "monetary", + "from_currency": delivery.product_id.company_id.currency_id, + "display_currency": website_sale_order.currency_id}'/> + <t t-if="delivery.free_over" t-esc="delivery.rate_shipment(website_sale_order)['price']" t-options='monetary_options'/> + <t t-else="" t-esc="delivery.fixed_price" t-options='monetary_options'/> + </span> + <span t-else="" t-att-class="badge_class">Free</span> </t> <t t-else=""> <span t-attf-class="#{badge_class} o_wsale_delivery_badge_price">Select to compute delivery rate</span> diff --git a/addons/website_sale_link_tracker/controllers/backend.py b/addons/website_sale_link_tracker/controllers/backend.py index 79e3aa586f3..34d45f3fa29 100644 --- a/addons/website_sale_link_tracker/controllers/backend.py +++ b/addons/website_sale_link_tracker/controllers/backend.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime, time + from odoo import http from odoo.addons.website_sale.controllers.backend import WebsiteSaleBackend +from odoo.fields import Date from odoo.http import request @@ -10,8 +13,10 @@ class WebsiteSaleLinkTrackerBackend(WebsiteSaleBackend): @http.route() def fetch_dashboard_data(self, website_id, date_from, date_to): + datetime_from = datetime.combine(Date.from_string(date_from), time.min) + datetime_to = datetime.combine(Date.from_string(date_to), time.max) results = super(WebsiteSaleLinkTrackerBackend, self).fetch_dashboard_data(website_id, date_from, date_to) - results['dashboards']['sales']['utm_graph'] = self.fetch_utm_data(date_from, date_to) + results['dashboards']['sales']['utm_graph'] = self.fetch_utm_data(datetime_from, datetime_to) return results def fetch_utm_data(self, date_from, date_to): diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index edb00cc0e7b..b18987a2f10 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -119,9 +119,25 @@ class WebsiteSlides(WebsiteProfile): return values - def _get_slide_quiz_info(self, slide, quiz_done=False): + def _get_slide_quiz_partner_info(self, slide, quiz_done=False): return slide._compute_quiz_info(request.env.user.partner_id, quiz_done=quiz_done)[slide.id] + def _get_slide_quiz_data(self, slide): + slide_completed = slide.user_membership_id.sudo().completed + values = { + 'slide_questions': [{ + 'id': question.id, + 'question': question.question, + 'answers': [{ + 'id': answer.id, + 'text_value': answer.text_value, + 'is_correct': answer.is_correct if slide_completed else None + } for answer in question.sudo().answer_ids], + } for question in slide.question_ids] + } + values.update(self._get_slide_quiz_partner_info(slide)) + return values + # CHANNEL UTILITIES # -------------------------------------------------- @@ -489,7 +505,7 @@ class WebsiteSlides(WebsiteProfile): values = self._get_slide_detail(slide) # quiz-specific: update with karma and quiz information if slide.question_ids: - values.update(self._get_slide_quiz_info(slide)) + values.update(self._get_slide_quiz_data(slide)) # sidebar: update with user channel progress values['channel_progress'] = self._get_channel_progress(slide.channel_id, include_quiz=True) @@ -623,22 +639,7 @@ class WebsiteSlides(WebsiteProfile): if fetch_res.get('error'): return fetch_res slide = fetch_res['slide'] - quiz_info = self._get_slide_quiz_info(slide) - slide_completed = slide.user_membership_id.sudo().completed - return { - 'questions': [{ - 'id': question.id, - 'question': question.question, - 'answers': [{ - 'id': answer.id, - 'text_value': answer.text_value, - 'is_correct': answer.is_correct if slide_completed else None - } for answer in question.answer_ids], - } for question in slide.question_ids], - 'quizAttemptsCount': quiz_info['quiz_attempts_count'], - 'quizKarmaGain': quiz_info['quiz_karma_gain'], - 'quizKarmaWon': quiz_info['quiz_karma_won'], - } + return self._get_slide_quiz_data(slide) @http.route('/slides/slide/quiz/submit', type="json", auth="public", website=True) def slide_quiz_submit(self, slide_id, answer_ids): @@ -662,7 +663,7 @@ class WebsiteSlides(WebsiteProfile): user_good_answers = user_answers - user_bad_answers self._set_viewed_slide(slide, quiz_attempts_inc=True) - quiz_info = self._get_slide_quiz_info(slide, quiz_done=True) + quiz_info = self._get_slide_quiz_partner_info(slide, quiz_done=True) rank_progress = {} if not user_bad_answers: diff --git a/addons/website_slides/security/ir.model.access.csv b/addons/website_slides/security/ir.model.access.csv index ce9bcf2593d..82fbf8312ea 100644 --- a/addons/website_slides/security/ir.model.access.csv +++ b/addons/website_slides/security/ir.model.access.csv @@ -5,7 +5,7 @@ access_slide_slide_partner_all,slide.slide.partner.all,model_slide_slide_partner access_slide_slide_partner_system,slide.slide.partner.system,model_slide_slide_partner,website.group_website_publisher,1,1,1,1 access_slide_question_all,slide.question.all,model_slide_question,,1,0,0,0 access_slide_question_publisher,slide.question.publisher,model_slide_question,website.group_website_publisher,1,1,1,1 -access_slide_answer_all,slide.answer.all,model_slide_answer,,1,0,0,0 +access_slide_answer_all,slide.answer.all,model_slide_answer,,0,0,0,0 access_slide_answer_publisher,slide.answer.publisher,model_slide_answer,website.group_website_publisher,1,1,1,1 access_slide_tag_all,slide.tag.all,model_slide_tag,,1,0,0,0 access_slide_tag_publisher,slide.tag.publisher,model_slide_tag,website.group_website_publisher,1,1,1,1 diff --git a/addons/website_slides/static/src/js/slides_course_quiz.js b/addons/website_slides/static/src/js/slides_course_quiz.js index c166e24a782..8b02ee4beae 100644 --- a/addons/website_slides/static/src/js/slides_course_quiz.js +++ b/addons/website_slides/static/src/js/slides_course_quiz.js @@ -106,7 +106,12 @@ odoo.define('website_slides.quiz', function (require) { 'slide_id': self.slide.id, } }).then(function (quiz_data) { - self.quiz = quiz_data; + self.quiz = { + questions: quiz_data.slide_questions, + quizAttemptsCount: quiz_data.quiz_attempts_count, + quizKarmaGain: quiz_data.quiz_karma_gain, + quizKarmaWon: quiz_data.quiz_karma_won + }; }); }, diff --git a/addons/website_slides/static/src/xml/slide_quiz.xml b/addons/website_slides/static/src/xml/slide_quiz.xml index ef884ab2007..0f1f3626c7e 100644 --- a/addons/website_slides/static/src/xml/slide_quiz.xml +++ b/addons/website_slides/static/src/xml/slide_quiz.xml @@ -60,7 +60,7 @@ <b t-else="" class="my-0 h5">Done !</b> <span class="my-0 h5" style="line-height: 1"> <span role="button" title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2"> - + <t t-esc="widget.quiz.quizKarmaGain"/> XP + + <t t-if="! widget.slide.completed" t-esc="widget.quiz.quizKarmaGain"/><t t-else="" t-esc="widget.quiz.quizKarmaWon"/> XP </span> </span> </div> diff --git a/addons/website_slides/views/website_slides_templates_lesson.xml b/addons/website_slides/views/website_slides_templates_lesson.xml index 5978d63e8b5..6676cc4932f 100644 --- a/addons/website_slides/views/website_slides_templates_lesson.xml +++ b/addons/website_slides/views/website_slides_templates_lesson.xml @@ -426,27 +426,27 @@ t-att-data-quiz-karma-won="quiz_karma_won" t-att-data-has-next="1 if next_slide else 0" t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None"> - <div t-foreach="slide.question_ids" t-as="question" - t-att-class="'o_wslides_js_lesson_quiz_question mt-3 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))" t-att-data-question-id="question.id" t-att-data-title="question.question"> + <div t-foreach="slide_questions" t-as="question" + t-att-class="'o_wslides_js_lesson_quiz_question mt-3 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))" t-att-data-question-id="question['id']" t-att-data-title="question['question']"> <div class="h4"> - <small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question.question"/> + <small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question['question']"/> </div> <div class="list-group"> - <t t-foreach="question.answer_ids" t-as="answer"> - <a t-att-data-answer-id="answer.id" href="#" - t-att-data-text="answer.text_value" - t-att-class="'o_wslides_quiz_answer list-group-item list-group-item-action d-flex align-items-center %s' % ('list-group-item-success' if slide_completed and answer.is_correct else '')"> + <t t-foreach="question['answers']" t-as="answer"> + <a t-att-data-answer-id="answer['id']" href="#" + t-att-data-text="answer['text_value']" + t-att-class="'o_wslides_quiz_answer list-group-item list-group-item-action d-flex align-items-center %s' % ('list-group-item-success' if slide_completed and answer['is_correct'] else '')"> <label class="my-0 d-flex align-items-center justify-content-center mr-2"> <input type="radio" - t-att-name="question.id" - t-att-value="answer.id" + t-att-name="question['id']" + t-att-value="answer['id']" class="d-none" t-att-disabled="True if not slide.channel_id.is_member or slide_completed else ''"/> - <i t-att-class="'fa fa-circle text-400 %s' % ('d-none' if slide_completed and answer.is_correct else '')"/> + <i t-att-class="'fa fa-circle text-400 %s' % ('d-none' if slide_completed and answer['is_correct'] else '')"/> <i class="fa fa-times-circle text-danger d-none"></i> - <i t-att-class="'fa fa-check-circle text-success %s' % ('d-none' if not (slide_completed and answer.is_correct) else '')"></i> + <i t-att-class="'fa fa-check-circle text-success %s' % ('d-none' if not (slide_completed and answer['is_correct']) else '')"></i> </label> - <span t-esc="answer.text_value"/> + <span t-esc="answer['text_value']"/> </a> </t> </div> |
