diff --git a/addons/account/models/account.py b/addons/account/models/account.py index a73c5b253a6..b1d405a1a4f 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -648,6 +648,7 @@ class AccountTax(models.Model): price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include) """ self.ensure_one() + price_include = self._context.get('force_price_include', self.price_include) if self.amount_type == 'fixed': # Use copysign to take into account the sign of the base amount which includes the sign # of the quantity and the sign of the price_unit @@ -661,11 +662,11 @@ class AccountTax(models.Model): return math.copysign(quantity, base_amount) * self.amount else: return quantity * self.amount - if (self.amount_type == 'percent' and not self.price_include) or (self.amount_type == 'division' and self.price_include): + if (self.amount_type == 'percent' and not price_include) or (self.amount_type == 'division' and self.price_include): return base_amount * self.amount / 100 - if self.amount_type == 'percent' and self.price_include: + if self.amount_type == 'percent' and price_include: return base_amount - (base_amount / (1 + self.amount / 100)) - if self.amount_type == 'division' and not self.price_include: + if self.amount_type == 'division' and not price_include: return base_amount / (1 - self.amount / 100) - base_amount @api.multi @@ -699,13 +700,58 @@ class AccountTax(models.Model): 'analytic': boolean, }] } """ + + # 1) Flatten the taxes. + + def collect_taxes(self, all_taxes=None): + # Collect all the taxes recursively ordered by the sequence. + # Example: + # group | seq | sub-group | + # ------------|-----------| + # | 1 | | + # ------------|-----------| + # t | 2 | | seq | | + # | | | 4 | | + # | | | 5 | | + # | | | 6 | | + # | | | + # ------------|-----------| + # | 3 | | + # ------------|-----------| + # Result: 1-4-5-6-3 + if not all_taxes: + all_taxes = self.env['account.tax'] + for tax in self.sorted(key=lambda r: r.sequence): + if tax.amount_type == 'group': + all_taxes = collect_taxes(tax.children_tax_ids, all_taxes) + else: + all_taxes += tax + return all_taxes + + taxes = collect_taxes(self) + + # 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True + # with price_include=True + + base_excluded_flag = False # price_include=False && include_base_amount=True + included_flag = False # price_include=True + for tax in taxes: + if tax.price_include: + included_flag = True + elif tax.include_base_amount: + base_excluded_flag = True + if base_excluded_flag and included_flag: + raise UserError(_('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.')) + + # 3) Deal with the rounding methods + if len(self) == 0: company_id = self.env.user.company_id else: company_id = self[0].company_id if not currency: currency = company_id.currency_id - taxes = [] + # By default, for each tax, tax amount will first be computed # and rounded at the 'Account' decimal precision for each # PO/SO/invoice line and then these rounded amounts will be @@ -730,50 +776,93 @@ class AccountTax(models.Model): if not round_tax: prec += 5 - base_values = self.env.context.get('base_values') - if not base_values: - total_excluded = total_included = base = round(price_unit * quantity, prec) + # 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + # tax | base | amount | + # /\ ---------------------------- + # || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded + # || tax_2 | | | + # || tax_3 | | | + # || ... | .. | .. | + # ---------------------------- + + def recompute_base(base_amount, fixed_amount, percent_amount): + # Recompute the new base amount based on included fixed/percent amount and the current base amount. + # Example: + # tax | amount | + # ------------------ + # tax_1 | 10% | + # tax_2 | 15 | + # tax_3 | 20% | + # ------------------ + # if base_amount = 145, the new base is computed as: + # (145 - 15) / (1.0 + ((10 + 20) / 100.0)) = 130 / 1.3 = 100 + if fixed_amount == 0.0 and percent_amount == 0.0: + return base_amount + return round((base_amount - fixed_amount) / (1.0 + percent_amount / 100.0), prec) + + base = round(price_unit * quantity, prec) + + # For the computation of move lines, we could have a negative base value. + # In this case, compute all with positive values and negative them at the end. + if base < 0: + base = -base + sign = -1 else: - total_excluded, total_included, base = base_values + sign = 1 - # Sorting key is mandatory in this case. When no key is provided, sorted() will perform a - # search. However, the search method is overridden in account.tax in order to add a domain - # depending on the context. This domain might filter out some taxes from self, e.g. in the - # case of group taxes. - for tax in self.sorted(key=lambda r: r.sequence): - if tax.amount_type == 'group': - children = tax.children_tax_ids.with_context(base_values=(total_excluded, total_included, base)) - ret = children.compute_all(price_unit, currency, quantity, product, partner) - total_excluded = ret['total_excluded'] - base = ret['base'] if tax.include_base_amount else base - total_included = ret['total_included'] - tax_amount = total_included - total_excluded - taxes += ret['taxes'] - continue + # Keep track of the accumulated included fixed/percent amount. + incl_fixed_amount = incl_percent_amount = 0 + for tax in reversed(taxes): + if tax.include_base_amount: + base = recompute_base(base, incl_fixed_amount, incl_percent_amount) + incl_fixed_amount = incl_percent_amount = 0 + if tax.price_include: + if tax.amount_type == 'fixed': + incl_fixed_amount += tax.amount + elif tax.amount_type == 'percent': + incl_percent_amount += tax.amount + # Start the computation of accumulated amounts at the total_excluded value. + total_excluded = total_included = base = recompute_base(base, incl_fixed_amount, incl_percent_amount) - tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + # 5) Iterate the taxes in the sequence order to fill missing base/amount values. + # tax | base | amount | + # || ---------------------------- + # || tax_1 | OK | XXXX | + # || tax_2 | XXXX | XXXX | + # || tax_3 | XXXX | XXXX | + # \/ ... | .. | .. | + # ---------------------------- + taxes_vals = [] + for tax in taxes: + # Compute the amount of the tax but don't deal with the price_include because it's already + # took into account on the base amount except for 'division' tax: + # (tax.amount_type == 'percent' && not tax.price_include) + # == (tax.amount_type == 'division' && tax.price_include) + tax_amount = tax.with_context(force_price_include=False)._compute_amount(base, price_unit, quantity, product, partner) if not round_tax: tax_amount = round(tax_amount, prec) else: tax_amount = currency.round(tax_amount) - if tax.price_include: - total_excluded -= tax_amount - base -= tax_amount - else: - total_included += tax_amount - - # Keep base amount used for the current tax + # Suppose: + # seq | amount | incl | incl_base | base | amount + # ----------------------------------------------- + # 1 | 10 % | t | t | 100.0 | 10.0 + # ----------------------------------------------- + # ... the next computation must be done using 100.0 + 10.0 = 110.0 as base but + # the tax base of this tax will be 100.0. tax_base = base - if tax.include_base_amount: base += tax_amount - taxes.append({ + # The total_included amount is computed as the sum of total_excluded with all tax_amount + total_included += tax_amount + + taxes_vals.append({ 'id': tax.id, 'name': tax.with_context(**{'lang': partner.lang} if partner else {}).name, - 'amount': tax_amount, - 'base': tax_base, + 'amount': sign * tax_amount, + 'base': sign * tax_base, 'sequence': tax.sequence, 'account_id': tax.account_id.id, 'refund_account_id': tax.refund_account_id.id, @@ -781,10 +870,10 @@ class AccountTax(models.Model): }) return { - 'taxes': sorted(taxes, key=lambda k: k['sequence']), - 'total_excluded': currency.round(total_excluded) if round_total else total_excluded, - 'total_included': currency.round(total_included) if round_total else total_included, - 'base': base, + 'taxes': taxes_vals, + 'total_excluded': sign * (currency.round(total_excluded) if round_total else total_excluded), + 'total_included': sign * (currency.round(total_included) if round_total else total_included), + 'base': sign * base, } @api.model diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 39918ef33ac..5706eecc7a5 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -1316,6 +1316,9 @@ class AccountInvoiceLine(models.Model): self.price_unit = 0.0 domain['uom_id'] = [] else: + # Use the purchase uom by default + self.uom_id = self.product_id.uom_po_id + if part.lang: product = self.product_id.with_context(lang=part.lang) else: diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index 2de70ab949e..1b82c2d245c 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -154,7 +154,7 @@ class account_journal(models.Model): # optimization to read sum of balance from account_move_line account_ids = tuple(filter(None, [self.default_debit_account_id.id, self.default_credit_account_id.id])) if account_ids: - amount_field = 'balance' if not self.currency_id else 'amount_currency' + amount_field = 'balance' if (not self.currency_id or self.currency_id == self.company_id.currency_id) else 'amount_currency' query = """SELECT sum(%s) FROM account_move_line WHERE account_id in %%s AND date <= %%s;""" % (amount_field,) self.env.cr.execute(query, (account_ids, fields.Date.today(),)) query_results = self.env.cr.dictfetchall() diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index a0867695bd3..f724ef8dcd3 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -398,7 +398,7 @@ class AccountMoveLine(models.Model): help="The move of this entry line.", index=True, required=True, auto_join=True) narration = fields.Text(related='move_id.narration', string='Narration') ref = fields.Char(related='move_id.ref', string='Reference', store=True, copy=False, index=True) - payment_id = fields.Many2one('account.payment', string="Originator Payment", help="Payment that created this entry") + payment_id = fields.Many2one('account.payment', string="Originator Payment", help="Payment that created this entry", copy=False) statement_line_id = fields.Many2one('account.bank.statement.line', index=True, string='Bank statement line reconciled with this entry', copy=False, readonly=True) statement_id = fields.Many2one('account.bank.statement', related='statement_line_id.statement_id', string='Statement', store=True, help="The bank statement used for bank reconciliation", index=True, copy=False) diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index 84aa784fad3..5ea51d3077d 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -1,6 +1,7 @@ +# -*- coding: utf-8 -*- + from odoo.addons.account.tests.account_test_users import AccountTestUsers -from odoo.tests.common import TransactionCase -from odoo.tools import float_compare + import time @@ -67,47 +68,110 @@ class TestTax(AccountTestUsers): self.bank_account = self.bank_journal.default_debit_account_id self.expense_account = self.env['account.account'].search([('user_type_id.type', '=', 'payable')], limit=1) #Should be done by onchange later + def _check_compute_all_results(self, base, total_included, total_excluded, taxes, res): + self.assertAlmostEqual(res['base'], base) + self.assertAlmostEqual(res['total_included'], total_included) + self.assertAlmostEqual(res['total_excluded'], total_excluded) + for i in range(0, len(taxes)): + self.assertAlmostEqual(res['taxes'][i]['base'], taxes[i][0]) + self.assertAlmostEqual(res['taxes'][i]['amount'], taxes[i][1]) + def test_tax_group_of_group_tax(self): self.fixed_tax.include_base_amount = True - self.group_tax.include_base_amount = True - self.group_of_group_tax.include_base_amount = True res = self.group_of_group_tax.compute_all(200.0) - self.assertEquals(res['total_excluded'], 200.0) - # After calculation of first group - # base = 210 - # total_included = 231 - # Base of the first grouped is passed - # Base after the second group (220) is dropped. - # Base of the group of groups is passed out, - # so we obtain base as after first group - self.assertEquals(res['base'], 210.0) - self.assertEquals(res['total_included'], 263.0) + self._check_compute_all_results( + 220, # 'base' + 263, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + (210.0, 10.0), # | 1 | 10 | | t + (220.0, 22.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_group(self): res = self.group_tax.compute_all(200.0) - self.assertEquals(res['total_excluded'], 200.0) - self.assertEquals(res['total_included'], 230.0) - self.assertEquals(len(res['taxes']), 2) - self.assertEquals(res['taxes'][0]['amount'], 10.0) - self.assertEquals(res['taxes'][1]['amount'], 20.0) + self._check_compute_all_results( + 200, # 'base' + 230, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_percent_division(self): self.division_tax.price_include = True self.division_tax.include_base_amount = True + res_division = self.division_tax.compute_all(200.0) + self._check_compute_all_results( + 220, # 'base' + 220, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 20.0), # | 4 | 10/ | t | t + # --------------------------------------------------- + ], + res_division + ) self.percent_tax.price_include = False self.percent_tax.include_base_amount = False - res_division = self.division_tax.compute_all(200.0) res_percent = self.percent_tax.compute_all(200.0) - self.assertEquals(res_division['taxes'][0]['amount'], 20.0) - self.assertEquals(res_percent['taxes'][0]['amount'], 20.0) + self._check_compute_all_results( + 200, # 'base' + 220, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res_percent + ) self.division_tax.price_include = False self.division_tax.include_base_amount = False + res_division = self.division_tax.compute_all(200.0) + self._check_compute_all_results( + 200, # 'base' + 222.22, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 22.22), # | 4 | 10/ | | + # --------------------------------------------------- + ], + res_division + ) self.percent_tax.price_include = True self.percent_tax.include_base_amount = True - res_division = self.division_tax.compute_all(200.0) res_percent = self.percent_tax.compute_all(200.0) - self.assertEquals(res_division['taxes'][0]['amount'], 22.22) - self.assertEquals(res_percent['taxes'][0]['amount'], 18.18) + self._check_compute_all_results( + 200, # 'base' + 200, # 'total_included' + 181.82, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (181.82, 18.18), # | 3 | 10% | t | t + # --------------------------------------------------- + ], + res_percent + ) def test_tax_sequence_normalized_set(self): self.division_tax.sequence = 1 @@ -115,19 +179,53 @@ class TestTax(AccountTestUsers): self.percent_tax.sequence = 3 taxes_set = (self.group_tax | self.division_tax) res = taxes_set.compute_all(200.0) - self.assertEquals(res['taxes'][0]['amount'], 22.22) - self.assertEquals(res['taxes'][1]['amount'], 10.0) - self.assertEquals(res['taxes'][2]['amount'], 20.0) + self._check_compute_all_results( + 200, # 'base' + 252.22, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 22.22), # | 1 | 10/ | | + (200.0, 10.0), # | 2 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_include_base_amount(self): self.fixed_tax.include_base_amount = True res = self.group_tax.compute_all(200.0) - self.assertEquals(res['total_included'], 231.0) + self._check_compute_all_results( + 210, # 'base' + 231, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_currency(self): self.division_tax.amount = 15.0 res = self.division_tax.compute_all(200.0, currency=self.env.ref('base.VEF')) - self.assertAlmostEqual(res['total_included'], 235.2941) + self._check_compute_all_results( + 200, # 'base' + 235.2941, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 35.2941), # | 4 | 15/ | | + # --------------------------------------------------- + ], + res + ) def test_tax_move_lines_creation(self): """ Test that creating a move.line with tax_ids generates the tax move lines and adjust line amount when a tax is price_include """ @@ -172,3 +270,58 @@ class TestTax(AccountTestUsers): aml_with_taxes = move.line_ids.filtered(lambda l: set(l.tax_ids.ids) == set([self.group_tax.id, self.fixed_tax_bis.id])) self.assertEquals(len(aml_with_taxes), 1) self.assertEquals(aml_with_taxes.credit, 190) + + def test_advanced_taxes_computation_0(self): + '''Test more advanced taxes computation (see issue 34471).''' + tax_1 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + 'sequence': 1, + }) + tax_2 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_2', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 2, + }) + tax_3 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_3', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 3, + }) + tax_4 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_4', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 4, + }) + tax_5 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_5', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 5, + }) + taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5 + res = taxes.compute_all(132.0) + self._check_compute_all_results( + 110, # 'base' + 154, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 1 | 10% | t | t + (110.0, 11.0), # | 3 | 10% | | + (110.0, 11.0), # | 3 | 10% | t | + (110.0, 11.0), # | 3 | 10% | | + (110.0, 11.0), # | 3 | 10% | t | + # --------------------------------------------------- + ], + res + ) diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index d700753a934..0be10bf6da8 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -605,9 +605,18 @@ class Import(models.TransientModel): @api.multi def _parse_import_data(self, data, import_fields, options): + """ Lauch first call to _parse_import_data_recursive with an + empty prefix. _parse_import_data_recursive will be run + recursively for each relational field. + """ + return self._parse_import_data_recursive(self.res_model, '', data, import_fields, options) + + @api.multi + def _parse_import_data_recursive(self, model, prefix, data, import_fields, options): # Get fields of type date/datetime - all_fields = self.env[self.res_model].fields_get() + all_fields = self.env[model].fields_get() for name, field in all_fields.iteritems(): + name = prefix + name if field['type'] in ('date', 'datetime') and name in import_fields: # Parse date index = import_fields.index(name) @@ -624,7 +633,11 @@ class Import(models.TransientModel): raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, ustr(e.message))) except Exception, e: raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, ustr(e.message))) - + # Check if the field is in import_field and is a relational (followed by /) + # Also verify that the field name exactly match the import_field at the correct level. + elif any(name + '/' in import_field and name == import_field.split('/')[prefix.count('/')] for import_field in import_fields): + # Recursive call with the relational as new model and add the field name to the prefix + self._parse_import_data_recursive(field['relation'], name + '/', data, import_fields, options) elif field['type'] in ('float', 'monetary') and name in import_fields: # Parse float, sometimes float values from file have currency symbol or () to denote a negative value # We should be able to manage both case diff --git a/addons/base_import/tests/test_base_import.py b/addons/base_import/tests/test_base_import.py index a0031c192e9..55e5c0538c3 100644 --- a/addons/base_import/tests/test_base_import.py +++ b/addons/base_import/tests/test_base_import.py @@ -402,7 +402,33 @@ class test_convert_import_data(TransactionCase): # if results empty, no errors self.assertItemsEqual(results, []) + def test_parse_relational_fields(self): + """ Ensure that relational fields float and date are correctly + parsed during the import call. + """ + import_wizard = self.env['base_import.import'].create({ + 'res_model': 'res.partner', + 'file': 'name,parent_id/id,parent_id/date,parent_id/credit_limit\n' + '"foo","__export__.res_partner_1","2017年10月12日","5,69"\n', + 'file_type': 'text/csv' + }) + options = { + 'date_format': '%Y年%m月%d日', + 'quoting': '"', + 'separator': ',', + 'float_decimal_separator': ',', + 'float_thousand_separator': '.', + 'headers': True + } + data, import_fields = import_wizard._convert_import_data( + ['name', 'parent_id/.id', 'parent_id/date', 'parent_id/credit_limit'], + options + ) + result = import_wizard._parse_import_data(data, import_fields, options) + # Check if the data 5,69 as been correctly parsed. + self.assertEqual(float(result[0][-1]), 5.69) + self.assertEqual(str(result[0][-2]), '2017-10-12') def test_filtered(self): """ If ``False`` is provided as field mapping for a column, diff --git a/addons/crm/controllers/main.py b/addons/crm/controllers/main.py index d92758e961b..4f1a6960645 100644 --- a/addons/crm/controllers/main.py +++ b/addons/crm/controllers/main.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging from odoo.addons.mail.controllers.main import MailController from odoo import http +_logger = logging.getLogger(__name__) + class CrmController(http.Controller): @@ -12,8 +15,9 @@ class CrmController(http.Controller): comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token) if comparison and record: try: - record.case_mark_won() + record.action_set_won() except Exception: + _logger.exception("Could not mark crm.lead as won") return MailController._redirect_to_messaging() return redirect @@ -22,8 +26,9 @@ class CrmController(http.Controller): comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token) if comparison and record: try: - record.case_mark_lost() + record.action_set_lost() except Exception: + _logger.exception("Could not mark crm.lead as lost") return MailController._redirect_to_messaging() return redirect @@ -34,5 +39,6 @@ class CrmController(http.Controller): try: record.convert_opportunity(record.partner_id.id) except Exception: + _logger.exception("Could not convert crm.lead to opportunity") return MailController._redirect_to_messaging() return redirect diff --git a/addons/crm/report/crm_opportunity_report_views.xml b/addons/crm/report/crm_opportunity_report_views.xml index aa92f04089b..610fb112fc4 100644 --- a/addons/crm/report/crm_opportunity_report_views.xml +++ b/addons/crm/report/crm_opportunity_report_views.xml @@ -116,17 +116,10 @@ - - - - - - - diff --git a/addons/crm/static/description/index.html b/addons/crm/static/description/index.html index df8b9a447f8..6f4ab397d69 100644 --- a/addons/crm/static/description/index.html +++ b/addons/crm/static/description/index.html @@ -43,7 +43,7 @@

Automate activities based on your sales scripts: calls, meetings, mails and quotations. Get all information you need, right on the opportunity: page viewed, mails received, etc.

- + diff --git a/addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py b/addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py index 582926ab89a..a0765b0d5cf 100644 --- a/addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py +++ b/addons/hr_timesheet_attendance/report/hr_timesheet_attendance_report.py @@ -42,6 +42,7 @@ class TimesheetAttendance(models.Model): ts.unit_amount AS timesheet, date_trunc('day', ts.date) AS date FROM account_analytic_line AS ts + WHERE ts.project_id IS NOT NULL ) AS t GROUP BY t.user_id, t.date ORDER BY t.date diff --git a/addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml b/addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml index 7aba1d01c33..b465567946c 100644 --- a/addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml +++ b/addons/hr_timesheet_attendance/views/hr_timesheet_sheet_views.xml @@ -29,6 +29,7 @@ + [('project_id','!=',False)] diff --git a/addons/mass_mailing/static/src/js/mass_mailing_editor.js b/addons/mass_mailing/static/src/js/mass_mailing_editor.js index 5d475808589..cc0b9d2dd08 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_editor.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_editor.js @@ -402,4 +402,15 @@ odoo_top[callback+"_updown"] = function (value, fields_values, field_name) { if ($editable_area.html().indexOf('on_change_model_and_list') !== -1) { $editable_area.empty(); } +// Adding compatibility for the outlook compliance of mailings. +// Commit of such compatibility : a14f89c8663c9cafecb1cc26918055e023ecbe42 +options.registry.background.include({ + start: function() { + this._super(); + var $table_target = this.$target.find('table:first'); + if ($table_target) { + this.$target = $table_target; + } + } +}); }); diff --git a/addons/mrp/models/mrp_unbuild.py b/addons/mrp/models/mrp_unbuild.py index 5fc2f77b97f..fa4d34557d5 100644 --- a/addons/mrp/models/mrp_unbuild.py +++ b/addons/mrp/models/mrp_unbuild.py @@ -121,14 +121,20 @@ class MrpUnbuild(models.Model): for produce_move in produce_moves: if produce_move.has_tracking != 'none': original = original_quants.filtered(lambda quant: quant.product_id == produce_move.product_id) - if original and not original[0].lot_id: + if not original: raise UserError(_("You don't have in the stock the required lot/serial number for %s .") % (produce_move.product_id.name,)) - self.env['stock.move.lots'].create({ - 'move_id': produce_move.id, - 'lot_id': original[0].lot_id.id, - 'quantity_done': produce_move.product_uom_qty, - 'quantity': produce_move.product_uom_qty - }) + quantity_todo = produce_move.product_qty + for quant in original: + if quantity_todo <= 0: + break + move_quantity = min(quantity_todo, quant.qty) + self.env['stock.move.lots'].create({ + 'move_id': produce_move.id, + 'lot_id': quant.lot_id.id, + 'quantity_done': produce_move.product_id.uom_id._compute_quantity(move_quantity, produce_move.product_uom), + 'quantity': produce_move.product_id.uom_id._compute_quantity(move_quantity, produce_move.product_uom), + }) + quantity_todo -= move_quantity else: produce_move.quantity_done = produce_move.product_uom_qty produce_moves.move_validate() diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index fe74f19e643..486669b9e81 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -138,6 +138,14 @@ class StockMove(models.Model): self.check_move_lots() return res + def _propagate_cancel(self): + self.ensure_one() + if not self.move_dest_id.raw_material_production_id: + super(StockMove, self)._propagate_cancel() + elif self.move_dest_id.state == 'waiting': + # If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead) + self.move_dest_id.write({'state': 'confirmed'}) + @api.multi def action_cancel(self): if any(move.quantity_done for move in self): @@ -373,6 +381,10 @@ class StockMove(models.Model): self.sudo().unlink() return processed_moves + def _propagate_split(self, new_move, qty): + if not self.move_dest_id.raw_material_production_id: + super(StockMove, self)._propagate_split(new_move, qty) + def _generate_move_phantom(self, bom_line, quantity): if bom_line.product_id.type in ['product', 'consu']: return self.copy(default={ diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 18a6b436938..c954091cee6 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -625,7 +625,7 @@ class PosOrder(models.Model): return_picking = Picking.create(return_vals) return_picking.message_post(body=message) - for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_digits=l.product_id.uom_id.rounding)): + for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_rounding=l.product_id.uom_id.rounding)): moves |= Move.create({ 'name': line.name, 'product_uom': line.product_id.uom_id.id, diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 336e3b32848..27281342cde 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -202,7 +202,7 @@ exports.PosModel = Backbone.Model.extend({ }, },{ model: 'account.tax', - fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'], + fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids', 'sequence'], domain: null, loaded: function(self, taxes){ self.taxes = taxes; @@ -1400,72 +1400,138 @@ exports.Orderline = Backbone.Model.extend({ return tax; }, - _compute_all: function(tax, base_amount, quantity) { + /** + * Mirror JS method of: + * _compute_amount in addons/account/models/account.py + */ + _compute_all: function(tax, base_amount, quantity, price_include) { + if(price_include === undefined) + price_include = tax.price_include; if (tax.amount_type === 'fixed') { var sign_base_amount = base_amount >= 0 ? 1 : -1; return (Math.abs(tax.amount) * sign_base_amount) * quantity; } - if ((tax.amount_type === 'percent' && !tax.price_include) || (tax.amount_type === 'division' && tax.price_include)){ + if ((tax.amount_type === 'percent' && !price_include) || (tax.amount_type === 'division' && tax.price_include)){ return base_amount * tax.amount / 100; } - if (tax.amount_type === 'percent' && tax.price_include){ + if (tax.amount_type === 'percent' && price_include){ return base_amount - (base_amount / (1 + tax.amount / 100)); } - if (tax.amount_type === 'division' && !tax.price_include) { + if (tax.amount_type === 'division' && !price_include) { return base_amount / (1 - tax.amount / 100) - base_amount; } return false; }, + /** + * Mirror JS method of: + * compute_all in addons/account/models/account.py + * + * Read comments in the python side method for more details about each sub-methods. + */ compute_all: function(taxes, price_unit, quantity, currency_rounding, no_map_tax) { var self = this; - var list_taxes = []; + + // 1) Flatten the taxes. + + var _collect_taxes = function(taxes, all_taxes){ + taxes.sort(function (tax1, tax2) { + return tax1.sequence - tax2.sequence; + }); + _(taxes).each(function(tax){ + if(tax.amount_type === 'group') + all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes); + else + all_taxes.push(tax); + }); + return all_taxes; + } + var collect_taxes = function(taxes){ + return _collect_taxes(taxes, []); + } + + taxes = collect_taxes(taxes); + + // 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True + // with price_include=True + + var base_excluded_flag = false; // price_include=False && include_base_amount=True + var included_flag = false; // price_include=True + _(taxes).each(function(tax){ + if(tax.price_include) + included_flag = true; + else if(tax.include_base_amount) + base_excluded_flag = true + if(base_excluded_flag && included_flag) + throw new Error('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.'); + }); + + // 3) Deal with the rounding methods + var currency_rounding_bak = currency_rounding; if (this.pos.company.tax_calculation_rounding_method == "round_globally"){ currency_rounding = currency_rounding * 0.00001; } - var total_excluded = round_pr(price_unit * quantity, currency_rounding); - var total_included = total_excluded; - var base = total_excluded; - _(taxes).each(function(tax) { - if (!no_map_tax){ - tax = self._map_tax_fiscal_position(tax); - } - if (!tax){ - return; - } - if (tax.amount_type === 'group'){ - var ret = self.compute_all(tax.children_tax_ids, price_unit, quantity, currency_rounding); - total_excluded = ret.total_excluded; - base = ret.total_excluded; - total_included = ret.total_included; - list_taxes = list_taxes.concat(ret.taxes); - } - else { - var tax_amount = self._compute_all(tax, base, quantity); - tax_amount = round_pr(tax_amount, currency_rounding); - if (tax_amount){ - if (tax.price_include) { - total_excluded -= tax_amount; - base -= tax_amount; - } - else { - total_included += tax_amount; - } - if (tax.include_base_amount) { - base += tax_amount; - } - var data = { - id: tax.id, - amount: tax_amount, - name: tax.name, - }; - list_taxes.push(data); - } + var recompute_base = function(base_amount, fixed_amount, percent_amount){ + if(fixed_amount === 0.0 && percent_amount === 0.0) + return base_amount; + return round_pr((base_amount - fixed_amount) / (1.0 + percent_amount / 100.0), currency_rounding); + } + + // 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + + var base = round_pr(price_unit * quantity, currency_rounding); + var sign = 1; + if(base < 0){ + base = -base; + var sign = -1; + } + + var incl_fixed_amount = 0.0; + var incl_percent_amount = 0.0; + _(taxes.reverse()).each(function(tax){ + if(tax.include_base_amount){ + base = recompute_base(base, incl_fixed_amount, incl_percent_amount); + incl_fixed_amount = 0.0; + incl_percent_amount = 0.0; + } + if(tax.price_include){ + if(tax.amount_type === 'fixed') + incl_fixed_amount += tax.amount; + else if(tax.amount_type === 'percent') + incl_percent_amount += tax.amount; } }); + + base = recompute_base(base, incl_fixed_amount, incl_percent_amount); + var total_excluded = base; + var total_included = base; + + // 5) Iterate the taxes in the sequence order to fill missing base/amount values. + + var taxes_vals = []; + _(taxes.reverse()).each(function(tax){ + var tax_amount = self._compute_all(tax, base, quantity, false); + tax_amount = round_pr(tax_amount, currency_rounding); + + var tax_base = base; + + if(tax.include_base_amount) + base += tax_amount; + + total_included += tax_amount; + + var tax_vals = { + id: tax.id, + amount: sign * tax_amount, + name: tax.name, + }; + + taxes_vals.push(tax_vals); + }); + return { - taxes: list_taxes, + taxes: taxes_vals, total_excluded: round_pr(total_excluded, currency_rounding_bak), total_included: round_pr(total_included, currency_rounding_bak) }; diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 130613806f3..79899961997 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -864,7 +864,7 @@ class SaleOrderLine(models.Model): return price else: from_currency = self.order_id.company_id.currency_id - return from_currency.compute(product.lst_price, self.order_id.pricelist_id.currency_id) + return from_currency.compute(product[pricelist_item.base], self.order_id.pricelist_id.currency_id) @api.multi @api.onchange('product_id') diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 0455c5b95e3..30c988954d7 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -658,6 +658,15 @@ class StockMove(models.Model): if not no_prepare: self.check_recompute_pack_op() + def _propagate_cancel(self): + self.ensure_one() + if self.move_dest_id: + if self.propagate: + self.move_dest_id.action_cancel() + elif self.move_dest_id.state == 'waiting': + # If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead) + self.move_dest_id.write({'state': 'confirmed'}) + @api.multi def action_cancel(self): """ Cancels the moves and if all moves are cancelled it cancels the picking. """ @@ -674,12 +683,7 @@ class StockMove(models.Model): pass # procurements.search([('move_dest_id', '=', move.id)]).cancel() else: - if move.move_dest_id: - if move.propagate: - move.move_dest_id.action_cancel() - elif move.move_dest_id.state == 'waiting': - # If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead) - move.move_dest_id.write({'state': 'confirmed'}) + move._propagate_cancel() if move.procurement_id: procurements |= move.procurement_id @@ -919,6 +923,11 @@ class StockMove(models.Model): raise UserError(_('You can only delete draft moves.')) return super(StockMove, self).unlink() + def _propagate_split(self, new_move, qty): + if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'): + new_move_prop = self.move_dest_id.split(qty) + new_move.write({'move_dest_id': new_move_prop}) + @api.multi def split(self, qty, restrict_lot_id=False, restrict_partner_id=False): """ Splits qty from move move into a new move @@ -959,10 +968,7 @@ class StockMove(models.Model): # TDE CLEANME: used only in write in this file, to clean # ctx['do_not_propagate'] = True self.with_context(do_not_propagate=True, rounding_method='HALF-UP').write({'product_uom_qty': self.product_uom_qty - uom_qty}) - - if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'): - new_move_prop = self.move_dest_id.split(qty) - new_move.write({'move_dest_id': new_move_prop}) + self._propagate_split(new_move, qty) # returning the first element of list returned by action_confirm is ok because we checked it wouldn't be exploded (and # thus the result of action_confirm should always be a list of 1 element length) new_move.action_confirm() diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 9bb6044cbca..c7dd63fa804 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -8,7 +8,7 @@ from dateutil import relativedelta from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError, ValidationError -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT import logging @@ -806,7 +806,7 @@ class Orderpoint(models.Model): # These days will be substracted when creating the PO days += self.product_id._select_seller().delay or 0.0 date_planned = start_date + relativedelta.relativedelta(days=days) - return date_planned.strftime(DEFAULT_SERVER_DATE_FORMAT) + return date_planned.strftime(DEFAULT_SERVER_DATETIME_FORMAT) @api.multi def _prepare_procurement_values(self, product_qty, date=False, group=False): diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js index 8b42726ff7a..955b49437a8 100644 --- a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js +++ b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js @@ -206,6 +206,18 @@ define([ r.select(); } + // If only pasting a

element in an unique

element, only paste + // the

element text + var $p = $arch.children('p'); + var onlyAP = ($p.length === 1 && $arch.children().length === 1); + if (onlyAP) { + var $p1 = $(r.sc).closest('p'); + var $p2 = $(r.ec).closest('p'); + if ($p1.length && $p2.length && $p1[0] === $p2[0]) { + $arch.html($p.text()); + } + } + /* insert content */ diff --git a/addons/web_editor/static/src/js/rte.js b/addons/web_editor/static/src/js/rte.js index b0366583aea..c7dc268251d 100644 --- a/addons/web_editor/static/src/js/rte.js +++ b/addons/web_editor/static/src/js/rte.js @@ -429,7 +429,7 @@ var RTE = Widget.extend({ saveElement: function ($el, context) { // remove multi edition if ($el.data('oe-model')) { - var key = $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression'); + var key = $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression')+":"+$el.data('oe-xpath'); if (this.__saved[key]) return $.when(); this.__saved[key] = true; } diff --git a/addons/web_editor/static/src/js/rte.summernote.js b/addons/web_editor/static/src/js/rte.summernote.js index 4ab1e7bed06..a3bf079aa30 100644 --- a/addons/web_editor/static/src/js/rte.summernote.js +++ b/addons/web_editor/static/src/js/rte.summernote.js @@ -454,7 +454,7 @@ $.summernote.pluginEvents.visible = function (event, editor, layoutInfo) { if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) && $node.hasClass("o_editable") && !$node[0].children.length && - "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td".toUpperCase().indexOf($node[0].nodeName) === -1) { + "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td label".toUpperCase().indexOf($node[0].nodeName) === -1) { var p = $('


')[0]; $node.append( p ); range.createFromNode(p.firstChild).select(); diff --git a/addons/website_forum/views/forum.xml b/addons/website_forum/views/forum.xml index 76e269cd105..f236790b8ad 100644 --- a/addons/website_forum/views/forum.xml +++ b/addons/website_forum/views/forum.xml @@ -69,6 +69,7 @@ + diff --git a/addons/website_slides/static/src/js/slides_upload.js b/addons/website_slides/static/src/js/slides_upload.js index b8fbe164421..d451fd6eb64 100644 --- a/addons/website_slides/static/src/js/slides_upload.js +++ b/addons/website_slides/static/src/js/slides_upload.js @@ -150,6 +150,8 @@ var SlideDialog = Widget.extend({ _.each(data.items, function (obj) { page_content = page_content + obj.str + " "; }); + // page_content may contain null characters + page_content = page_content.replace(/\0/g, ""); self.index_content = self.index_content + page_number + ". " + page_content + '\n'; if (maxPages === page_number) { if (loaded) { diff --git a/odoo/addons/base/static/src/css/description.css b/odoo/addons/base/static/src/css/description.css index cbfa3d5e968..b1f436b872f 100644 --- a/odoo/addons/base/static/src/css/description.css +++ b/odoo/addons/base/static/src/css/description.css @@ -1,5 +1,4 @@ -@charset "utf-8"; -/* +/** * This CSS is for the html description of modules * TODO clean */ @@ -78,11 +77,11 @@ /* ------ BASE GRID CONSTRUCTS ----- */ .oe_page { margin: 0px auto 64px auto; - max-width: 992px; + max-width: 100%; } .oe_row { - width: 928px; + width: 100%; margin-top: 16px; margin-bottom: 16px; margin-left: auto; @@ -111,35 +110,35 @@ } .oe_span12 { - width: 928px; + width: 100%; } .oe_span10 { - width: 773px; + width: 83.33333%; } .oe_span9 { - width: 696px; + width: 75%; } .oe_span8 { - width: 618px; + width: 66.66667%; } .oe_span6 { - width: 464px; + width: 50%; } .oe_span4 { - width: 309px; + width: 33.33333%; } .oe_span3 { - width: 232px; + width: 25%; } .oe_span2 { - width: 154px; + width: 16.66667%; } [class*='oe_span'].oe_fit { @@ -162,28 +161,28 @@ width: auto; } .oe_row.oe_flex .oe_span12 { - max-width: 928px; + max-width: 100%; } .oe_row.oe_flex .oe_span10 { - max-width: 769px; + max-width: 83.33333%; } .oe_row.oe_flex .oe_span9 { - max-width: 692px; + max-width: 75%; } .oe_row.oe_flex .oe_span8 { - max-width: 614px; + max-width: 66.66667%; } .oe_row.oe_flex .oe_span6 { - max-width: 460px; + max-width: 50%; } .oe_row.oe_flex .oe_span4 { - max-width: 305px; + max-width: 33.33333%; } .oe_row.oe_flex .oe_span3 { - max-width: 228px; + max-width: 25%; } .oe_row.oe_flex .oe_span2 { - max-width: 150px; + max-width: 16.66667%; } .oe_mb0 { diff --git a/odoo/addons/base/static/src/css/description.sass b/odoo/addons/base/static/src/css/description.sass index 4fa2ed100d8..49be6054652 100644 --- a/odoo/addons/base/static/src/css/description.sass +++ b/odoo/addons/base/static/src/css/description.sass @@ -86,11 +86,11 @@ $v8_anchor_visited_color: rgb(91, 40, 79) .oe_page margin: 0px auto 64px auto - max-width: 992px + max-width: 100% .oe_row - width: 928px + width: 100% margin-top: 16px margin-bottom: 16px margin-left: auto @@ -107,14 +107,14 @@ $v8_anchor_visited_color: rgb(91, 40, 79) line-height: 0 height: 0 -$oe_span12_width: 928px -$oe_span10_width: 773px -$oe_span9_width: 696px -$oe_span8_width: 618px -$oe_span6_width: 464px -$oe_span4_width: 309px -$oe_span3_width: 232px -$oe_span2_width: 154px +$oe_span12_width: 100% +$oe_span10_width: 83.33333333% +$oe_span9_width: 75% +$oe_span8_width: 66.66666667% +$oe_span6_width: 50% +$oe_span4_width: 33.33333333% +$oe_span3_width: 25% +$oe_span2_width: 16.66666667% [class*='oe_span'] float: left @@ -155,19 +155,19 @@ $oe_span2_width: 154px .oe_span12 max-width: $oe_span12_width .oe_span10 - max-width: ($oe_span10_width +-4px) + max-width: $oe_span10_width .oe_span9 - max-width: ($oe_span9_width +-4px) - .oe_span8 - max-width: ($oe_span8_width +-4px) + max-width: $oe_span9_width + .oe_span8 + max-width: $oe_span8_width .oe_span6 - max-width: ($oe_span6_width +-4px) + max-width: $oe_span6_width .oe_span4 - max-width: ($oe_span4_width +-4px) + max-width: $oe_span4_width .oe_span3 - max-width: ($oe_span3_width +-4px) + max-width: $oe_span3_width .oe_span2 - max-width: ($oe_span2_width +-4px) + max-width: $oe_span2_width .oe_mb0 margin-bottom: 0px !important diff --git a/odoo/loglevels.py b/odoo/loglevels.py index 400f08e1a8f..0972dc5d2b4 100644 --- a/odoo/loglevels.py +++ b/odoo/loglevels.py @@ -94,7 +94,7 @@ def ustr(value, hint_encoding='utf-8', errors='strict'): def exception_to_unicode(e): if (sys.version_info[:2] < (2,6)) and hasattr(e, 'message'): return ustr(e.message) - if hasattr(e, 'args'): + if getattr(e, 'args', ()): return "\n".join((ustr(a) for a in e.args)) try: return unicode(e) diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index fbef3443929..8d6703a5d17 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -949,7 +949,8 @@ def trans_generate(lang, modules, cr): def get_module_from_path(path): for (mp, rec) in path_list: mp = os.path.join(mp, '') - if rec and path.startswith(mp) and os.path.dirname(path) != mp: + dirname = os.path.join(os.path.dirname(path), '') + if rec and path.startswith(mp) and dirname != mp: path = path[len(mp):] return path.split(os.path.sep)[0] return 'base' # files that are not in a module are considered as being in 'base' module