diff options
| author | Christophe Simonis <chs@odoo.com> | 2017-10-21 15:22:36 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2017-10-21 15:22:36 +0200 |
| commit | de987831da237ed5f9c96d50a9e51d083e072aa0 (patch) | |
| tree | ef714d2d98693eae3446620d28bb48314573018a | |
| parent | ea2a976f31b2611b6b4ff69f4a5cdc2ffc7798fe (diff) | |
| parent | d70518d04f8f8061c8c683ea4ba8c95ae631ae45 (diff) | |
[MERGE] forward port branch 11.0 up to d70518d04f8
151 files changed, 2215 insertions, 611 deletions
diff --git a/addons/account/data/mail_template_data.xml b/addons/account/data/mail_template_data.xml index 6507259b89c..d0365929333 100644 --- a/addons/account/data/mail_template_data.xml +++ b/addons/account/data/mail_template_data.xml @@ -72,12 +72,12 @@ from ${object.company_id.name}. <div> % set record = ctx.get('record') % set company = record and record.company_id or user.company_id -<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed" summary="o_mail_notification"> +<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed; border-collapse:separate;" summary="o_mail_notification"> <tbody> <!-- HEADER --> <tr> <td align="center" style="min-width: 590px;"> - <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;"> + <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;"> <tr> <td valign="middle"> <span style="font-size:20px; color:white; font-weight: bold;"> @@ -94,7 +94,7 @@ from ${object.company_id.name}. <!-- CONTENT --> <tr> <td align="center" style="min-width: 590px;"> - <table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px;"> + <table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px; border-collapse:separate;"> <tbody> <td valign="top" style="font-family:Arial,Helvetica,sans-serif; color: #555; font-size: 14px;"> ${object.body | safe} @@ -106,7 +106,7 @@ from ${object.company_id.name}. <!-- FOOTER --> <tr> <td align="center" style="min-width: 590px;"> - <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;"> + <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;"> <tr> <td valign="middle" align="left" style="color: #fff; padding-top: 10px; padding-bottom: 10px; font-size: 12px;"> ${company.name}<br/> diff --git a/addons/account/models/account.py b/addons/account/models/account.py index e1b20487b71..7d1344f7555 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -848,6 +848,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 @@ -861,11 +862,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 @@ -899,13 +900,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 @@ -930,50 +976,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 (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) + + 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 - - # 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 - - tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + sign = 1 + + # 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) + + # 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': round(sign * tax_base, prec), 'sequence': tax.sequence, 'account_id': tax.account_id.id, 'refund_account_id': tax.refund_account_id.id, @@ -981,10 +1070,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': round(sign * base, prec), } @api.model diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 715941ce02f..964b000cfc0 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -500,10 +500,10 @@ class AccountBankStatementLine(models.Model): if self.amount_currency and self.currency_id: amount = self.amount_currency amount_currency = self.amount - amount_currency_str = amount_currency > 0 and amount_currency or -amount_currency - amount_currency_str = formatLang(self.env, amount_currency_str, currency_obj=statement_currency) + amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=statement_currency) else: amount = self.amount + amount_currency = amount amount_currency_str = "" amount_str = formatLang(self.env, abs(amount), currency_obj=self.currency_id or statement_currency) @@ -525,6 +525,7 @@ class AccountBankStatementLine(models.Model): 'partner_name': self.partner_id.name, 'communication_partner_name': self.partner_name, 'amount_currency_str': amount_currency_str, # Amount in the statement currency + 'amount_currency': amount_currency, # Amount in the statement currency 'has_no_partner': not self.partner_id.id, } if self.partner_id: diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 1fae487036a..ffc572a2cab 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -1588,6 +1588,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 83b8ec1f824..66c8bb9f4a4 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -175,7 +175,7 @@ class account_journal(models.Model): # optimization to read sum of balance from account_move_line account_ids = tuple(ac for ac in [self.default_debit_account_id.id, self.default_credit_account_id.id] if ac) 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 ef60c1897a7..6a72ba478ea 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -698,19 +698,36 @@ class AccountMoveLine(models.Model): '|', ('date_maturity', 'like', str), '&', ('name', '!=', '/'), ('name', 'ilike', str) ] - try: - amount = float(str) - amount_domain = [ - '|', ('amount_residual', '=', amount), - '|', ('amount_residual_currency', '=', amount), - '|', ('amount_residual', '=', -amount), - '|', ('amount_residual_currency', '=', -amount), - '&', ('account_id.internal_type', '=', 'liquidity'), - '|', '|', ('debit', '=', amount), ('credit', '=', amount), ('amount_currency', '=', amount), - ] - str_domain = expression.OR([str_domain, amount_domain]) - except: - pass + if str[0] in ['-', '+']: + try: + amounts_str = str.split('|') + for amount_str in amounts_str: + amount = amount_str[0] == '-' and float(amount_str) or float(amount_str[1:]) + amount_domain = [ + '|', ('amount_residual', '=', amount), + '|', ('amount_residual_currency', '=', amount), + '|', (amount_str[0] == '-' and 'credit' or 'debit', '=', float(amount_str[1:])), + ('amount_currency', '=', amount), + ] + str_domain = expression.OR([str_domain, amount_domain]) + except: + pass + else: + try: + amount = float(str) + amount_domain = [ + '|', ('amount_residual', '=', amount), + '|', ('amount_residual_currency', '=', amount), + '|', ('amount_residual', '=', -amount), + '|', ('amount_residual_currency', '=', -amount), + '&', ('account_id.internal_type', '=', 'liquidity'), + '|', '|', '|', ('debit', '=', amount), ('credit', '=', amount), + ('amount_currency', '=', amount), + ('amount_currency', '=', -amount), + ] + str_domain = expression.OR([str_domain, amount_domain]) + except: + pass return str_domain def _domain_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, str=False): @@ -753,6 +770,7 @@ class AccountMoveLine(models.Model): for line in self: company_currency = line.account_id.company_id.currency_id + line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency ret_line = { 'id': line.id, 'name': line.name and line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name, @@ -769,7 +787,7 @@ class AccountMoveLine(models.Model): 'journal_id': [line.journal_id.id, line.journal_id.display_name], 'partner_id': line.partner_id.id, 'partner_name': line.partner_id.name, - 'currency_id': (line.currency_id and line.amount_currency) and line.currency_id.id or False, + 'currency_id': line_currency.id, } debit = line.debit @@ -784,7 +802,7 @@ class AccountMoveLine(models.Model): # Get right debit / credit: target_currency = target_currency or company_currency - line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency + amount_currency = debit - credit amount_currency_str = "" total_amount_currency_str = "" if line_currency != company_currency and target_currency == line_currency: @@ -809,9 +827,10 @@ class AccountMoveLine(models.Model): actual_debit = debit > 0 and amount or 0.0 actual_credit = credit > 0 and -amount or 0.0 currency = company_currency + if line_currency != target_currency: - target_currency.compute(total_amount, line_currency) - amount_currency_str = formatLang(self.env, target_currency.compute(abs(actual_debit or actual_credit), line_currency), currency_obj=line_currency) + amount_currency = target_currency.compute(total_amount, line_currency) + amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=line_currency) total_amount_currency_str = formatLang(self.env, target_currency.compute(total_amount, line_currency), currency_obj=line_currency) if currency != target_currency: ctx = context.copy() @@ -827,6 +846,7 @@ class AccountMoveLine(models.Model): ret_line['amount_str'] = amount_str ret_line['total_amount_str'] = total_amount_str ret_line['amount_currency_str'] = amount_currency_str + ret_line['amount_currency'] = amount_currency ret_line['total_amount_currency_str'] = total_amount_currency_str ret.append(ret_line) return ret diff --git a/addons/account/static/src/js/reconciliation/reconciliation_action.js b/addons/account/static/src/js/reconciliation/reconciliation_action.js index fabfee1ad00..bfd2e48972e 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_action.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_action.js @@ -20,6 +20,7 @@ var StatementAction = Widget.extend(ControlPanelMixin, { change_offset: '_onAction', change_partner: '_onAction', add_proposition: '_onAction', + search_balance_amount: '_onAction', remove_proposition: '_onAction', update_proposition: '_onAction', create_proposition: '_onAction', @@ -38,7 +39,11 @@ var StatementAction = Widget.extend(ControlPanelMixin, { // used to instanciate the action interface ActionRenderer: ReconciliationRenderer.StatementRenderer, // used to instanciate each widget line - LineRenderer: ReconciliationRenderer.LineRenderer + LineRenderer: ReconciliationRenderer.LineRenderer, + // used context params + params: ['statement_ids'], + // number of moves lines displayed in 'match' mode + limitMoveLines: 5, }, /** @@ -51,11 +56,25 @@ var StatementAction = Widget.extend(ControlPanelMixin, { this._super.apply(this, arguments); this.action_manager = parent; this.params = params; - this.model = new this.config.Model(this, {modelName: "account.bank.statement.line"}); + this.model = new this.config.Model(this, { + modelName: "account.bank.statement.line", + limitMoveLines: params.params && params.params.limitMoveLines || this.config.limitMoveLines, + }); this.widgets = []; if (!this.action_manager) { this.set_cp_bus(new Widget()); } + // Adding values from the context is necessary to put this information in the url via the action manager so that + // you can retrieve it if the person shares his url or presses f5 + _.each(params.params, function (value, name) { + params.context[name] = name.indexOf('_ids') !== -1 ? _.map((value+'').split(), parseFloat) : value; + }); + params.params = {}; + _.each(this.config.params, function (name) { + if (params.context[name]) { + params.params[name] = name.indexOf('_ids') !== -1 && _.isArray(params.context[name]) ? params.context[name].join() : params.context[name]; + } + }); }, /** @@ -305,8 +324,15 @@ var ManualAction = StatementAction.extend({ config: { Model: ReconciliationModel.ManualModel, ActionRenderer: ReconciliationRenderer.ManualRenderer, - LineRenderer: ReconciliationRenderer.ManualLineRenderer + LineRenderer: ReconciliationRenderer.ManualLineRenderer, + params: ['company_ids', 'mode', 'partner_ids', 'account_ids'], + limitMoveLines: 10, }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** * call 'validate' or 'autoReconciliation' model method then destroy the * reconcilied lines, update the not reconcilied and update the action diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index 880619411fe..41fb2d32003 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -66,6 +66,7 @@ var _t = core._t; * } * mv_lines: object - idem than reconciliation_proposition * offset: integer + * limitMoveLines: integer * filter: string * [createForm]: { * account_id: { @@ -95,8 +96,11 @@ var StatementModel = BasicModel.extend({ /** * @override + * + * @param {Widget} parent + * @param {object} options */ - init: function () { + init: function (parent, options) { this._super.apply(this, arguments); this.reconcileModels = []; this.lines = {}; @@ -104,6 +108,7 @@ var StatementModel = BasicModel.extend({ this.valuemax = 0; this.alreadyDisplayed = []; this.defaultDisplayQty = 10; + this.limitMoveLines = options && options.limitMoveLines || 5; }, //-------------------------------------------------------------------------- @@ -162,7 +167,9 @@ var StatementModel = BasicModel.extend({ * @returns {Deferred} */ changeFilter: function (handle, filter) { - this.getLine(handle).filter = filter; + var line = this.getLine(handle); + line.filter = filter; + line.offset = 0; return this._performMoveLine(handle); }, /** @@ -219,7 +226,7 @@ var StatementModel = BasicModel.extend({ * @returns {Deferred} */ changeOffset: function (handle, offset) { - this.getLine(handle).offset += offset; + this.getLine(handle).offset += (offset > 0 ? 1 : -1) * this.limitMoveLines; return this._performMoveLine(handle); }, /** @@ -360,14 +367,14 @@ var StatementModel = BasicModel.extend({ self.context.journal_id = statement.journal_id; _.each(statement.st_lines_ids, function (id) { self.lines[_.uniqueId('rline')] = { - 'id': id, - 'reconciled': false, - 'mode': 'inactive', - 'mv_lines': [], - 'offset': 0, - 'filter': "", - 'reconciliation_proposition': [], - 'reconcileModels': [] + id: id, + reconciled: false, + mode: 'inactive', + mv_lines: [], + offset: 0, + filter: "", + reconciliation_proposition: [], + reconcileModels: [], }; }); }); @@ -489,6 +496,25 @@ var StatementModel = BasicModel.extend({ } return def; }, + searchBalanceAmount: function (handle) { + var line = this.getLine(handle); + var amount = line.balance.amount; + var amount_str = _.str.sprintf('%.2f', Math.abs(amount)); + amount_str = (amount > '0' ? '-' : '+') + amount_str; + if (line.balance.amount_currency) { + var amount_currency = line.balance.amount_currency; + var amount_currency_str = _.str.sprintf('%.2f', Math.abs(amount_currency)); + amount_str += '|' + (amount_currency > '0' ? '-' : '+') + amount_currency_str; + } + if (amount_str === line.filter) { + line.filter = ''; + line.offset = 0; + return this.changeMode(handle, 'create'); + } + line.filter = amount_str; + line.offset = 0; + return this.changeMode(handle, 'match'); + }, /** * Force the partial reconciliation to display the reconciliate button. * This method should only be called when there is onely one proposition. @@ -728,7 +754,7 @@ var StatementModel = BasicModel.extend({ return !p.is_tax || p.link !== prop.id; }); - var args = [[prop.tax_id.id], prop.base_amount, line.st_line.currency_id]; + var args = [[prop.tax_id.id], prop.base_amount, formatOptions.currency_id]; tax_defs.push(self._rpc({ model: 'account.tax', method: 'json_friendly_compute_all', @@ -758,11 +784,7 @@ var StatementModel = BasicModel.extend({ }); })); } else { - var currencyData = { - currency_id: line.st_line.currency_id - }; - - prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, currencyData); + prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, formatOptions); prop.display = self._isDisplayedProposition(prop); prop.invalid = !self._isValid(prop); } @@ -774,19 +796,32 @@ var StatementModel = BasicModel.extend({ }); line.reconciliation_proposition = reconciliation_proposition; + var amount_currency = 0; var total = line.st_line.amount || 0; - _.each(line.reconciliation_proposition, function (prop) { + var isOtherCurrencyId = _.uniq(_.pluck(_.reject(reconciliation_proposition, 'invalid'), 'currency_id')); + isOtherCurrencyId = isOtherCurrencyId.length === 1 && !total && isOtherCurrencyId[0] !== formatOptions.currency_id ? isOtherCurrencyId[0] : false; + + _.each(reconciliation_proposition, function (prop) { if (!prop.invalid) { total -= prop.amount; + if (isOtherCurrencyId) { + amount_currency -= prop.amount_currency; + } } }); total = Math.round(total*1000)/1000 || 0; line.balance = { - 'amount': total, - 'amount_str': field_utils.format.monetary(Math.abs(total), {}, formatOptions), - 'account_code': self.accounts[line.st_line.open_balance_account_id], + amount: total, + amount_str: field_utils.format.monetary(Math.abs(total), {}, formatOptions), + currency_id: isOtherCurrencyId, + amount_currency: isOtherCurrencyId ? amount_currency : false, + amount_currency_str: isOtherCurrencyId ? field_utils.format.monetary(Math.abs(amount_currency), {}, { + currency_id: isOtherCurrencyId + }) : false, + account_code: self.accounts[line.st_line.open_balance_account_id], }; - line.balance.type = line.balance.amount ? (line.balance.amount > 0 && line.st_line.partner_id ? 0 : -1) : 1; + line.balance.type = (isOtherCurrencyId && amount_currency || line.balance.amount) ? + (line.balance.amount > 0 && line.st_line.partner_id ? 0 : -1) : 1; }); }, /** @@ -851,6 +886,7 @@ var StatementModel = BasicModel.extend({ return l.id === data.st_line.id; }); line.visible = true; + line.limitMoveLines = self.limitMoveLines; _.extend(line, data); self._formatLineProposition(line, line.reconciliation_proposition); if (!line.reconciliation_proposition.length) { @@ -989,7 +1025,7 @@ var StatementModel = BasicModel.extend({ }))); var filter = line.filter || ""; var offset = line.offset; - var limit = 6; + var limit = this.limitMoveLines+1; return this._rpc({ model: 'account.bank.statement.line', method: 'get_move_lines_for_reconciliation_widget', @@ -1196,6 +1232,8 @@ var ManualModel = StatementModel.extend({ if (line.reconciled) { return; } + line.filter = ""; + line.offset = 0; defs.push(self._performMoveLine(handle).then(function () { if(!line.mv_lines.length) { self.valuenow++; @@ -1243,7 +1281,7 @@ var ManualModel = StatementModel.extend({ */ _computeLine: function (line) { return this._super(line).then(function () { - var props = line.reconciliation_proposition; + var props = _.reject(line.reconciliation_proposition, 'invalid'); line.balance.type = -1; if (!line.balance.amount && props.length) { line.balance.type = 1; @@ -1265,15 +1303,16 @@ var ManualModel = StatementModel.extend({ */ _formatLine: function (type, data) { var line = this.lines[_.uniqueId('rline')] = _.extend(data, { - 'type': type, - 'reconciled': false, - 'mode': 'inactive', - 'offset': 0, - 'filter': "", - 'reconcileModels': [], - 'account_id': this._formatNameGet([data.account_id, data.account_name]), - 'st_line': data, - 'visible': true + type: type, + reconciled: false, + mode: 'inactive', + offset: 0, + limitMoveLines: this.limitMoveLines, + filter: "", + reconcileModels: [], + account_id: this._formatNameGet([data.account_id, data.account_name]), + st_line: data, + visible: true }); this._formatLineProposition(line, line.reconciliation_proposition); if (!line.reconciliation_proposition.length) { @@ -1337,7 +1376,7 @@ var ManualModel = StatementModel.extend({ }))); var filter = line.filter || ""; var offset = line.offset; - var limit = 6; + var limit = this.limitMoveLines+1; var args = [line.account_id.id, line.partner_id, excluded_ids, filter, offset, limit]; return this._rpc({ model: 'account.move.line', diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index f71f3d09011..9e72fdeb44f 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -250,7 +250,8 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { events: { 'click .accounting_view caption .o_buttons button': '_onValidate', 'click .accounting_view thead td': '_onTogglePanel', - 'click .accounting_view tfoot td': '_onShowPanel', + 'click .accounting_view tfoot td:not(.cell_left,.cell_right)': '_onShowPanel', + 'click tfoot .cell_left, tfoot .cell_right': '_onSearchBalanceAmount', 'input input.filter': '_onFilterChange', 'click .match_controls .fa-chevron-left:not(.disabled)': '_onPrevious', 'click .match_controls .fa-chevron-right:not(.disabled)': '_onNext', @@ -386,7 +387,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { // mv_lines var $mv_lines = this.$('.match table tbody').empty(); - _.each(state.mv_lines.slice(0,5), function (line) { + _.each(state.mv_lines.slice(0, state.limitMoveLines), function (line) { var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state})); if (!isNaN(line.id)) { $('<span class="line_info_button fa fa-info-circle"/>') @@ -395,13 +396,19 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { } $mv_lines.append($line); }); - this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= 5); + this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= state.limitMoveLines); this.$('.match .fa-chevron-left').toggleClass('disabled', !state.offset); this.$('.match').css('max-height', !state.mv_lines.length && !state.filter.length ? '0px' : ''); // balance + this.$('.popover').remove(); this.$('table tfoot').html(qweb.render("reconciliation.line.balance", {'state': state})); + // filter + if (_.str.strip(this.$('input.filter').val()) !== state.filter) { + this.$('input.filter').val(state.filter); + } + // create form if (state.createForm) { if (!this.fields.account_id) { @@ -593,6 +600,12 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { /** * @private */ + _onSearchBalanceAmount: function () { + this.trigger_up('search_balance_amount'); + }, + /** + * @private + */ _onShowPanel: function () { var mode = (this.$el.data('mode') === 'inactive' || this.$el.data('mode') === 'match') ? 'create' : 'match'; this.trigger_up('change_mode', {'data': mode}); @@ -637,13 +650,13 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { * @private */ _onPrevious: function () { - this.trigger_up('change_offset', {'data': -5}); + this.trigger_up('change_offset', {'data': -1}); }, /** * @private */ _onNext: function () { - this.trigger_up('change_offset', {'data': 5}); + this.trigger_up('change_offset', {'data': 1}); }, /** * @private diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml index 7b50cff9ebb..20ef80c45c8 100644 --- a/addons/account/static/src/xml/account_reconciliation.xml +++ b/addons/account/static/src/xml/account_reconciliation.xml @@ -25,7 +25,7 @@ <div class="notification_area"/> </div> <div class="o_reconciliation_lines"/> - <div t-if="widget._initialState.valuemax > widget._initialState.defaultDisplayQty"> + <div t-if="widget._initialState.valuemax > widget._initialState.defaultDisplayQty"> <button class="btn btn-default js_load_more">Load more</button> </div> <div class="pull-right text-muted">Tip: Hit CTRL-Enter to reconcile all the balanced items in the sheet.</div> @@ -129,8 +129,8 @@ <td class="cell_account_code"><t t-esc="state.balance.account_code"/></td> <td class="cell_due_date"></td> <td class="cell_label"><t t-if="state.balance.amount < 0">Create Write-off</t><t t-elif="state.st_line.partner_id">Open balance</t><t t-else="">Choose counterpart</t></td> - <td class="cell_left"><t t-if="state.balance.amount < 0"><t t-raw="state.balance.amount_str"/></t></td> - <td class="cell_right"><t t-if="state.balance.amount > 0"><t t-raw="state.balance.amount_str"/></t></td> + <td class="cell_left"><t t-if="state.balance.amount < 0"><span t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td> + <td class="cell_right"><t t-if="state.balance.amount > 0"><span t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td> <td class="cell_info_popover"></td> </tr> </t> diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index a8fa2c6f233..dda6ffa0664 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -475,7 +475,10 @@ var session = { var options = { context: { statement_ids: [4] - } + }, + params: { + limitMoveLines: 5, + }, }; Datas.params = { @@ -1051,7 +1054,7 @@ QUnit.module('account', { var clientAction = new ReconciliationClientAction.ManualAction(null, this.params.options); testUtils.addMockEnvironment(clientAction, { - 'data': this.params.data, + data: this.params.data, session: this.params.session, }); clientAction.appendTo($('#qunit-fixture')); diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index 84aa784fad3..b39d36f7bf3 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 @@ -27,6 +28,12 @@ class TestTax(AccountTestUsers): 'amount': 10, 'sequence': 3, }) + self.percent_tax_bis = self.tax_model.create({ + 'name': "Percent tax bis", + 'amount_type': 'percent', + 'amount': 21, + 'sequence': 3, + }) self.division_tax = self.tax_model.create({ 'name': "Division tax", 'amount_type': 'division', @@ -67,47 +74,125 @@ 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 + ) + self.percent_tax_bis.price_include = True + self.percent_tax_bis.include_base_amount = True + res_percent = self.percent_tax_bis.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'base' + 7.0, # 'total_included' + 5.79, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (5.79, 1.21), # | 3 | 21% | t | t + # --------------------------------------------------- + ], + res_percent + ) def test_tax_sequence_normalized_set(self): self.division_tax.sequence = 1 @@ -115,19 +200,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 +291,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/account/views/account_view.xml b/addons/account/views/account_view.xml index 20c63983ec4..db8accc8808 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -322,10 +322,12 @@ <field name="type_control_ids" widget="many2many_tags"/> <field name="account_control_ids" widget="many2many_tags"/> </group> - <group string="Miscellaneous" groups="account.group_account_user"> + <group string="Miscellaneous"> <field name="inbound_payment_method_ids" widget="many2many_checkboxes" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/> <field name="outbound_payment_method_ids" widget="many2many_checkboxes" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/> <field name="group_invoice_lines" attrs="{'invisible': [('type', 'not in', ['sale', 'purchase'])]}"/> + </group> + <group string="Accounting App Options" groups="account.group_account_user"> <field name="profit_account_id" attrs="{'invisible': [('type', '!=', 'cash')]}"/> <field name="loss_account_id" attrs="{'invisible': [('type', '!=', 'cash')]}"/> <field name="show_on_dashboard" groups="base.group_no_one"/> diff --git a/addons/account_asset/models/account_invoice.py b/addons/account_asset/models/account_invoice.py index fc4789d0706..dd22ea851f3 100644 --- a/addons/account_asset/models/account_invoice.py +++ b/addons/account_asset/models/account_invoice.py @@ -12,6 +12,16 @@ from odoo.addons import decimal_precision as dp class AccountInvoice(models.Model): _inherit = 'account.invoice' + @api.model + def _refund_cleanup_lines(self, lines): + result = super(AccountInvoice, self)._refund_cleanup_lines(lines) + for i, line in enumerate(lines): + for name, field in line._fields.items(): + if name == 'asset_category_id': + result[i][2][name] = False + break + return result + @api.multi def action_cancel(self): res = super(AccountInvoice, self).action_cancel() diff --git a/addons/auth_oauth/controllers/main.py b/addons/auth_oauth/controllers/main.py index 0d09f1e68aa..e231cd0f6d0 100644 --- a/addons/auth_oauth/controllers/main.py +++ b/addons/auth_oauth/controllers/main.py @@ -6,6 +6,7 @@ import logging import json +import werkzeug.urls import werkzeug.utils from werkzeug.exceptions import BadRequest @@ -148,7 +149,11 @@ class OAuthController(http.Controller): url = '/web#action=%s' % action elif menu: url = '/web#menu_id=%s' % menu - return login_and_redirect(*credentials, redirect_url=url) + resp = login_and_redirect(*credentials, redirect_url=url) + # Since /web is hardcoded, verify user has right to land on it + if werkzeug.urls.url_parse(resp.location).path == '/web' and not request.env.user.has_group('base.group_user'): + resp.location = '/' + return resp except AttributeError: # auth_signup is not installed _logger.error("auth_signup not installed on database %s: oauth sign up cancelled." % (dbname,)) diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 720cdc1e71a..542283903f1 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -597,9 +597,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.items(): + name = prefix + name if field['type'] in ('date', 'datetime') and name in import_fields: # Parse date index = import_fields.index(name) @@ -618,7 +627,11 @@ class Import(models.TransientModel): raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e)) except Exception as e: raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, e)) - + # 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 a59cfa9205c..55f3300da91 100644 --- a/addons/base_import/tests/test_base_import.py +++ b/addons/base_import/tests/test_base_import.py @@ -400,7 +400,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': u'name,parent_id/id,parent_id/date,parent_id/credit_limit\n' + u'"foo","__export__.res_partner_1","2017年10月12日","5,69"\n'.encode('utf-8'), + '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/base_vat/models/res_partner.py b/addons/base_vat/models/res_partner.py index dc547b2c422..2d2b37c939b 100644 --- a/addons/base_vat/models/res_partner.py +++ b/addons/base_vat/models/res_partner.py @@ -18,6 +18,12 @@ from odoo import api, models, _ from odoo.tools.misc import ustr from odoo.exceptions import ValidationError +_eu_country_vat = { + 'GR': 'EL' +} + +_eu_country_vat_inverse = {v: k for k, v in _eu_country_vat.items()} + _ref_vat = { 'at': 'ATU12345675', 'be': 'BE0477472701', @@ -79,6 +85,7 @@ class ResPartner(models.Model): # Foreign companies that trade with non-enterprises in the EU # may have a VATIN starting with "EU" instead of a country code. return True + country_code = _eu_country_vat_inverse.get(country_code, country_code) return bool(self.env['res.country'].search([('code', '=ilike', country_code)])) return check_func(vat_number) @@ -96,6 +103,19 @@ class ResPartner(models.Model): # country code or empty VAT number), so we fall back to the simple check. return self.simple_vat_check(country_code, vat_number) + @api.model + def fix_eu_vat_number(self, country_id, vat): + europe = self.env.ref('base.europe') + country = self.env["res.country"].browse(country_id) + if not europe: + europe = self.env["res.country.group"].search([('name', '=', 'Europe')], limit=1) + if europe and country and country.id in europe.country_ids.ids: + vat = re.sub('[^A-Za-z0-9]', '', vat).upper() + country_code = _eu_country_vat.get(country.code, country.code).upper() + if vat[:2] != country_code: + vat = country_code + vat + return vat + @api.constrains('vat', 'commercial_partner_country_id') def check_vat(self): if self.env.context.get('company_id'): diff --git a/addons/base_vat_autocomplete/models/res_partner.py b/addons/base_vat_autocomplete/models/res_partner.py index 3b25f99a8f1..41b40ec9c1d 100644 --- a/addons/base_vat_autocomplete/models/res_partner.py +++ b/addons/base_vat_autocomplete/models/res_partner.py @@ -6,6 +6,8 @@ import re from odoo import api, models +from suds.client import Client + _logger = logging.getLogger(__name__) try: @@ -14,6 +16,7 @@ except ImportError: _logger.warning('Python `stdnum` library not found, unable to call VIES service to detect address based on VAT number.') stdnum_vat = None + class ResPartner(models.Model): _inherit = 'res.partner' @@ -40,7 +43,17 @@ class ResPartner(models.Model): if not partner.vat: return {} if len(partner.vat) > 5 and partner.vat[:2].lower() in stdnum_vat.country_codes: - result = stdnum_vat.check_vies(partner.vat) + # Equivalent to stdnum_vat.check_vies(partner.vat). + # However, we want to add a custom timeout to the suds.client + # because by default, it's 120 seconds and this is to long. + try: + client = Client(stdnum_vat.vies_wsdl, timeout=5) + partner_vat = stdnum_vat.compact(partner.vat) + result = client.service.checkVat(partner_vat[:2], partner_vat[2:]) + except: + # Avoid blocking the client when the service is unreachable/unavailable + return {} + if not result['valid']: return {} diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 9c0ddc5c13f..37bd8e3e3ff 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -670,27 +670,27 @@ class Meeting(models.Model): 1) if user add duration for 2 hours, return : August-23-2013 at (04-30 To 06-30) (Europe/Brussels) 2) if event all day ,return : AllDay, July-31-2013 """ - timezone = self._context.get('tz') - if not timezone: - timezone = self.env.user.partner_id.tz or 'UTC' - timezone = tools.ustr(timezone).encode('utf-8') # make safe for str{p,f}time() + timezone = self._context.get('tz') or self.env.user.partner_id.tz or 'UTC' + timezone = pycompat.to_native(timezone) # make safe for str{p,f}time() # get date/time format according to context - format_date, format_time = self.with_context(tz=timezone)._get_date_formats() + format_date, format_time = self._get_date_formats() # convert date and time into user timezone - date = fields.Datetime.context_timestamp(self.with_context(tz=timezone), fields.Datetime.from_string(start)) - date_deadline = fields.Datetime.context_timestamp(self.with_context(tz=timezone), fields.Datetime.from_string(stop)) + self_tz = self.with_context(tz=timezone) + date = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(start)) + date_deadline = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(stop)) # convert into string the date and time, using user formats - date_str = date.strftime(format_date).decode('utf-8') - time_str = date.strftime(format_time).decode('utf-8') + to_text = pycompat.to_text + date_str = to_text(date.strftime(format_date)) + time_str = to_text(date.strftime(format_time)) if zallday: display_time = _("AllDay , %s") % (date_str) elif zduration < 24: duration = date + timedelta(hours=zduration) - duration_time = duration.strftime(format_time).decode('utf-8') + duration_time = to_text(duration.strftime(format_time)) display_time = _(u"%s at (%s To %s) (%s)") % ( date_str, time_str, @@ -698,8 +698,8 @@ class Meeting(models.Model): timezone, ) else: - dd_date = date_deadline.strftime(format_date).decode('utf-8') - dd_time = date_deadline.strftime(format_time).decode('utf-8') + dd_date = to_text(date_deadline.strftime(format_date)) + dd_time = to_text(date_deadline.strftime(format_time)) display_time = _(u"%s at %s To\n %s at %s (%s)") % ( date_str, time_str, 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/models/crm_lead.py b/addons/crm/models/crm_lead.py index 334b52be890..31a91c08885 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -268,6 +268,14 @@ class Lead(models.Model): # ---------------------------------------- @api.model + def name_create(self, name): + res = super(Lead, self).name_create(name) + + # update the probability of the lead if the stage is set to update it automatically + self.browse(res[0])._onchange_stage_id() + return res + + @api.model def create(self, vals): # set up context used to find the lead's sales channel which is needed # to correctly set the default stage_id diff --git a/addons/crm/report/crm_opportunity_report_views.xml b/addons/crm/report/crm_opportunity_report_views.xml index 6728604b8d8..24625320191 100644 --- a/addons/crm/report/crm_opportunity_report_views.xml +++ b/addons/crm/report/crm_opportunity_report_views.xml @@ -104,17 +104,10 @@ <filter string="Archived" name="archived" domain="[('active', '=', False)]"/> <group expand="0" string="Extended Filters"> <field name="partner_id" filter_domain="[('partner_id','child_of',self)]"/> - <field name="stage_id" domain="['|', ('team_id', '=', False), ('team_id', '=', 'team_id')]"/> - <field name="campaign_id"/> - <field name="medium_id"/> - <field name="source_id"/> - <field name="company_id" groups="base.group_multi_company"/> - <separator orientation="vertical"/> <field name="stage_id" widget="selection" domain="['|', ('team_id', '=', False), ('team_id', '=', 'team_id')]"/> <field name="campaign_id" widget="selection"/> <field name="medium_id" widget="selection"/> <field name="source_id" widget="selection"/> - <separator orientation="vertical"/> <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/> <newline/> <field name="create_date"/> 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 @@ <p class="oe_mt32 oe_mb32 text-justify"> 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. </p> - <img src="crm_sc_02b.png"> + <img class='img-responsive' src="crm_sc_02b.png"> </div> </div> </section> diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 60e7c350c70..a48a6dda3b0 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -385,7 +385,7 @@ domain="[('activity_ids.user_id', '=', uid)]"/> <separator/> <filter string="Late Activities" name="activities_overdue" - domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d'))]" + domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]" help="Show all records which has next action date is before today"/> <filter string="Today Activities" name="activities_today" domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> @@ -630,7 +630,7 @@ domain="[('activity_ids.user_id', '=', uid)]"/> <separator/> <filter string="Late Activities" name="activities_overdue" - domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d'))]" + domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]" help="Show all opportunities for which the next action date is before today"/> <filter string="Today Activities" name="activities_today" domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> @@ -640,7 +640,7 @@ <separator/> <filter string="Overdue Opportunities" name="overdue_opp" - domain="[('date_deadline', '<', context_today().strftime('%Y-%m-%d'))]" + domain="[('date_deadline', '<', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]" help="Show all opportunities with a deadline for which the next action date is before today"/> <filter string="To Close Today" name="close_today" diff --git a/addons/document/static/src/js/document.js b/addons/document/static/src/js/document.js index 5fc69141583..0af85179b49 100644 --- a/addons/document/static/src/js/document.js +++ b/addons/document/static/src/js/document.js @@ -5,6 +5,7 @@ var core = require('web.core'); var Dialog = require('web.Dialog'); var framework = require('web.framework'); var Sidebar = require('web.Sidebar'); +var field_utils = require('web.field_utils'); var _t = core._t; @@ -61,6 +62,7 @@ Sidebar.include({ */ _processAttachments: function (attachments) { //to display number in name if more then one attachment which has same name. + var self = this; _.chain(attachments) .groupBy(function (attachment) { return attachment.name; }) .each(function (attachment) { @@ -75,6 +77,10 @@ Sidebar.include({ if (a.type === "binary") { a.url = '/web/content/' + a.id + '?download=true'; } + a.create_date = field_utils.parse.datetime(a.create_date, 'create_date', {isUTC: true}); + a.create_date_string = field_utils.format.datetime(a.create_date, 'create_date', self.env.context.params); + a.write_date = field_utils.parse.datetime(a.write_date, 'write_date', {isUTC: true}); + a.write_date_string = field_utils.format.datetime(a.write_date, 'write_date', self.env.context.params); }); this.items.files = attachments; }, diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index cfbd6e77967..17cbc2b14bf 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -109,17 +109,16 @@ class HrExpense(models.Model): raise UserError(_("You cannot report twice the same line!")) if len(self.mapped('employee_id')) != 1: raise UserError(_("You cannot report expenses for different employees in the same report!")) - expense_sheet = self.env['hr.expense.sheet'].create({ - 'expense_line_ids': [(4, line.id) for line in self], - 'name': self[0].name if len(self.ids) == 1 else '', - 'employee_id': self[0].employee_id.id, - }) return { 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': 'hr.expense.sheet', 'target': 'current', - 'res_id': expense_sheet.id, + 'context': { + 'default_expense_line_ids': [line.id for line in self], + 'default_employee_id': self[0].employee_id.id, + 'default_name': self[0].name if len(self.ids) == 1 else '' + } } def _prepare_move_line(self, line): 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 046632c515d..3aee6568457 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 @@ <field name="view_ids" eval="[(5, 0, 0), (0, 0, {'view_mode': 'tree', 'view_id': ref('hr_timesheet.hr_timesheet_line_tree')}), (0, 0, {'view_mode': 'form', 'view_id': ref('hr_timesheet.hr_timesheet_line_form')})]"/> + <field name="domain">[('project_id','!=',False)]</field> </record> <record id="hr_timesheet_sheet_form" model="ir.ui.view"> diff --git a/addons/hw_proxy/controllers/main.py b/addons/hw_proxy/controllers/main.py index 02f083110e7..3d42918c2ad 100644 --- a/addons/hw_proxy/controllers/main.py +++ b/addons/hw_proxy/controllers/main.py @@ -18,9 +18,9 @@ _logger = logging.getLogger(__name__) # Those are the builtin raspberry pi USB modules, they should # not appear in the list of connected devices. BANNED_DEVICES = { - "0424:9514", # Standard Microsystem Corp. Builtin Ethernet module - "1d6b:0002", # Linux Foundation 2.0 root hub - "0424:ec00", # Standard Microsystem Corp. Other Builtin Ethernet module + "0424:9514", # Standard Microsystem Corp. Builtin Ethernet module + "1d6b:0002", # Linux Foundation 2.0 root hub + "0424:ec00", # Standard Microsystem Corp. Other Builtin Ethernet module } diff --git a/addons/iap/models/iap.py b/addons/iap/models/iap.py index 659ea1974c5..5e264dc6715 100644 --- a/addons/iap/models/iap.py +++ b/addons/iap/models/iap.py @@ -8,6 +8,7 @@ import werkzeug.urls import requests from odoo import api, fields, models, exceptions +from odoo.tools import pycompat _logger = logging.getLogger(__name__) @@ -57,8 +58,10 @@ def jsonrpc(url, method='call', params=None): e_class = InsufficientCreditError elif name == 'AccessError': e_class = exceptions.AccessError - else: + elif name == 'UserError': e_class = exceptions.UserError + else: + raise requests.exceptions.ConnectionError() e = e_class(message) e.data = response['error']['data'] raise e @@ -93,7 +96,7 @@ def charge(env, key, account_token, credit, description=None, credit_template=No except InsufficientCreditError as e: if credit_template: arguments = json.loads(e.args[0]) - arguments['body'] = env['ir.qweb'].render(credit_template) + arguments['body'] = pycompat.to_text(env['ir.qweb'].render(credit_template)) e.args = (json.dumps(arguments),) raise e try: diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index 36c26f56eb4..34789916a48 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -71,6 +71,7 @@ <field name="model_id" ref="mail.model_mail_message"/> <field name="auto_delete" eval="True"/> <field name="body_html"><![CDATA[ +% set company = ctx.get('company', user.company_id) <div> % if ctx.get('has_button_access'): <div itemscope itemtype="http://schema.org/EmailMessage"> @@ -110,7 +111,7 @@ % endif </td> <td valign="center" align="right"> - <img src="/logo.png" style="padding: 0px; margin: 0px; height: auto; max-width: 80px; max-height: 40px;" alt="${user.company_id.name}"> + <img src="/logo.png?company=${company.id}" style="padding: 0px; margin: 0px; height: auto; max-width: 80px; max-height: 40px;" alt="${company.name}"> </td> </tr> <tr> diff --git a/addons/mail/data/mail_shortcode_data.xml b/addons/mail/data/mail_shortcode_data.xml index 64f1aa7129f..afd0cf91146 100644 --- a/addons/mail/data/mail_shortcode_data.xml +++ b/addons/mail/data/mail_shortcode_data.xml @@ -103,8 +103,8 @@ </record> <record id="mail_smiley_angry" model="mail.shortcode"> <field name="source">3:(</field> - <field name="unicode_source">😈</field> - <field name="substitution"><img src='/mail/static/src/img/smiley/angry.png' alt='😈'/></field> + <field name="unicode_source">😠</field> + <field name="substitution"><img src='/mail/static/src/img/smiley/angry.png' alt='😠'/></field> <field name="description">Angry</field> <field name="shortcode_type">image</field> </record> diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 25b01ce80b5..a47a6ffac6b 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -251,7 +251,7 @@ class Channel(models.Model): author = self.env['res.partner'].browse(message_dict.get('author_id', False)) if not author or author not in self.channel_partner_ids: return { - 'error_mesage': _('restricted to channel members'), + 'error_message': _('restricted to channel members'), } return True return super(Channel, self)._alias_check_contact(message, message_dict, alias) diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index fd736269968..53ed1da740c 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -829,11 +829,13 @@ class MailThread(models.AbstractModel): def _routing_warn(self, error_message, warn_suffix, message_id, route, raise_exception): """ Tools method used in message_route_verify: whether to log a warning or raise an error """ - full_message = _('Routing mail with Message-Id %s: route %s: %s') % (message_id, route, error_message) + short_message = _("Mailbox unavailable - %s") % error_message + full_message = ('Routing mail with Message-Id %s: route %s: %s' % + (message_id, route, error_message)) + _logger.info(full_message + (warn_suffix and '; %s' % warn_suffix or '')) if raise_exception: - raise ValueError(full_message) - else: - _logger.info(full_message + warn_suffix and '; %s' % warn_suffix or '') + # sender should not see private diagnostics info, just the error + raise ValueError(short_message) def _routing_create_bounce_email(self, email_from, body_html, message): bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from @@ -1387,6 +1389,7 @@ class MailThread(models.AbstractModel): node.getparent().remove(node) if postprocessed: body = etree.tostring(root, pretty_print=False, encoding='UTF-8') + body = pycompat.to_native(body) return body, attachments def _message_extract_payload(self, message, save_original=False): @@ -1495,10 +1498,8 @@ class MailThread(models.AbstractModel): 'message_type': 'email', } if not isinstance(message, Message): - # message_from_string works on a native str, so on py2 we need to - # encode incoming unicode strings - if pycompat.PY2 and not isinstance(message, str): - message = message.encode('utf-8') + # message_from_string works on a native str + message = pycompat.to_native(message) message = email.message_from_string(message) message_id = message['message-id'] diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 0b7533c853d..3a81423a9b2 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -54,7 +54,6 @@ class Partner(models.Model): website_url = 'http://%s' % user.company_id.website if not user.company_id.website.lower().startswith(('http:', 'https:')) else user.company_id.website else: website_url = False - company_name = user.company_id.name model_name = False if message.model: @@ -74,9 +73,15 @@ class Partner(models.Model): if message.res_id and message.model in self.env: record = self.env[message.model].browse(message.res_id) + company = user.company_id; + if record and hasattr(record, 'company_id'): + company = record.company_id; + company_name = company.name; + return { 'signature': signature, 'website_url': website_url, + 'company': company, 'company_name': company_name, 'model_name': model_name, 'record': record, diff --git a/addons/mail/wizard/email_template_preview_view.xml b/addons/mail/wizard/email_template_preview_view.xml index 7d5cd1cb6d6..67068f5dc84 100644 --- a/addons/mail/wizard/email_template_preview_view.xml +++ b/addons/mail/wizard/email_template_preview_view.xml @@ -28,6 +28,9 @@ <field name="body_html" widget="html" readonly="1" nolabel="1" options='{"safe": True}'/> <field name="attachment_ids" widget="many2many_binary" readonly="1"/> + <footer> + <button string="Discard" class="btn-default" special="cancel"/> + </footer> </form> </field> </record> diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py index bcee631aad6..ab902ee7d75 100644 --- a/addons/mass_mailing/controllers/main.py +++ b/addons/mass_mailing/controllers/main.py @@ -17,10 +17,10 @@ class MassMailController(http.Controller): if mailing.exists(): res_id = res_id and int(res_id) res_ids = [] - if mailing.mailing_model_name == 'mail.mass_mailing.contact': + if mailing.mailing_model_name == 'mail.mass_mailing.list': contacts = request.env['mail.mass_mailing.contact'].sudo().search([ ('email', '=', email), - ('list_id', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids]) + ('list_ids', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids]) ]) res_ids = contacts.ids else: diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index ca2e739c35c..2f3666efc77 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -15,6 +15,16 @@ from odoo.tools.translate import html_translate _logger = logging.getLogger(__name__) +MASS_MAILING_BUSINESS_MODELS = [ + 'crm.lead', + 'event.registration', + 'hr.applicant', + 'res.partner', + 'event.track', + 'sale.order', + 'mail.mass_mailing.list', +] + class MassMailingTag(models.Model): """Model of categories of mass mailing, i.e. marketing, newsletter, ... """ _name = 'mail.mass_mailing.tag' @@ -320,7 +330,7 @@ class MassMailing(models.Model): default=lambda self: self.env['mail.message']._get_default_from()) # recipients mailing_model_real = fields.Char(compute='_compute_model', string='Recipients Real Model', default='mail.mass_mailing.contact', required=True) - mailing_model_id = fields.Many2one('ir.model', string='Recipients Model', domain="[('is_mail_thread', '=', True)]", + mailing_model_id = fields.Many2one('ir.model', string='Recipients Model', domain=[('model', 'in', MASS_MAILING_BUSINESS_MODELS)], default=lambda self: self.env.ref('mass_mailing.model_mail_mass_mailing_list').id) mailing_model_name = fields.Char(related='mailing_model_id.model', string='Recipients Model Name') mailing_domain = fields.Char(string='Domain', oldname='domain', default=[]) 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 a97f055f6c9..5190cd00838 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_editor.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_editor.js @@ -404,4 +404,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/mass_mailing/views/mass_mailing_views.xml b/addons/mass_mailing/views/mass_mailing_views.xml index 31f4664f552..4875421e812 100644 --- a/addons/mass_mailing/views/mass_mailing_views.xml +++ b/addons/mass_mailing/views/mass_mailing_views.xml @@ -415,7 +415,7 @@ </div> <label for="mailing_model_id" string="Recipients"/> <div> - <field name="mailing_model_id"/> + <field name="mailing_model_id" widget="selection"/> <field name="mailing_model_name" invisible="1"/> <field name="mailing_model_real" invisible="1"/> <field name="mailing_domain" widget="domain" diff --git a/addons/membership/models/account_invoice.py b/addons/membership/models/account_invoice.py index 73622ce0fb2..85b158e4959 100644 --- a/addons/membership/models/account_invoice.py +++ b/addons/membership/models/account_invoice.py @@ -36,7 +36,7 @@ class AccountInvoiceLine(models.Model): # Product line has changed to a membership product date_from = line.product_id.membership_date_from date_to = line.product_id.membership_date_to - if line.invoice_id.date_invoice > date_from and line.invoice_id.date_invoice < date_to: + if line.invoice_id.date_invoice > (date_from or '0000-00-00') and line.invoice_id.date_invoice < (date_to or '0000-00-00'): date_from = line.invoice_id.date_invoice MemberLine.create({ 'partner': line.invoice_id.partner_id.id, @@ -60,9 +60,9 @@ class AccountInvoiceLine(models.Model): invoice_line.product_id.membership and \ not MemberLine.search([('account_invoice_line', '=', invoice_line.id)]): # Product line is a membership product - date_from = invoice_line.product_id.membership_date_from or '0000-00-00' - date_to = invoice_line.product_id.membership_date_to or '0000-00-00' - if date_from < (invoice_line.invoice_id.date_invoice or '0000-00-00') < date_to: + date_from = invoice_line.product_id.membership_date_from + date_to = invoice_line.product_id.membership_date_to + if date_from and date_from < (invoice_line.invoice_id.date_invoice or '0000-00-00') < (date_to or '0000-00-00'): date_from = invoice_line.invoice_id.date_invoice MemberLine.create({ 'partner': invoice_line.invoice_id.partner_id.id, diff --git a/addons/membership/models/partner.py b/addons/membership/models/partner.py index 89da36bc273..348d4d04dfa 100644 --- a/addons/membership/models/partner.py +++ b/addons/membership/models/partner.py @@ -108,7 +108,7 @@ class Partner(models.Model): s = 4 if partner.member_lines: for mline in partner.member_lines: - if mline.date_to >= today and mline.date_from <= today: + if (mline.date_to or '0000-00-00') >= today and (mline.date_from or '0000-00-00') <= today: if mline.account_invoice_line.invoice_id: mstate = mline.account_invoice_line.invoice_id.state if mstate == 'paid': @@ -126,7 +126,7 @@ class Partner(models.Model): s = 3 if s == 4: for mline in partner.member_lines: - if mline.date_from < today and mline.date_to < today and mline.date_from <= mline.date_to and mline.account_invoice_line and mline.account_invoice_line.invoice_id.state == 'paid': + if (mline.date_from or '0000-00-00') < today and (mline.date_to or '0000-00-00') < today and (mline.date_from or '0000-00-00') <= (mline.date_to or '0000-00-00') and mline.account_invoice_line and mline.account_invoice_line.invoice_id.state == 'paid': s = 5 else: s = 6 diff --git a/addons/mrp/i18n/mrp.pot b/addons/mrp/i18n/mrp.pot index 617117deea1..0b2fac329be 100644 --- a/addons/mrp/i18n/mrp.pot +++ b/addons/mrp/i18n/mrp.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 11.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-10-10 11:34+0000\n" -"PO-Revision-Date: 2017-10-10 11:34+0000\n" +"POT-Creation-Date: 2017-10-16 11:13+0000\n" +"PO-Revision-Date: 2017-10-16 11:13+0000\n" "Last-Translator: <>\n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -423,7 +423,6 @@ msgstr "" #: model:ir.actions.act_window,name:mrp.template_open_bom #: model:ir.model.fields,field_description:mrp.field_product_product_bom_ids #: model:ir.model.fields,field_description:mrp.field_product_template_bom_ids -#: model:ir.ui.view,arch_db:mrp.mrp_bom_line_tree_view #: model:ir.ui.view,arch_db:mrp.mrp_bom_tree_view #: model:ir.ui.view,arch_db:mrp.product_product_form_view_bom_button #: model:ir.ui.view,arch_db:mrp.product_template_form_view_bom_button @@ -431,11 +430,6 @@ msgid "Bill of Materials" msgstr "" #. module: mrp -#: model:ir.actions.act_window,name:mrp.mrp_bom_line_action_from_bom -msgid "Bill of Materials Structure" -msgstr "" - -#. module: mrp #: model:ir.model.fields,help:mrp.field_mrp_production_bom_id msgid "Bill of Materials allow you to define the list of required raw materials to make a finished product." msgstr "" @@ -1203,6 +1197,21 @@ msgid "If the manufacturing order is unlocked you can add to the initial demand. msgstr "" #. module: mrp +#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view +msgid "If the product is a finished product: When processing a sales\n" +" order for this product, the delivery order will contain the raw\n" +" materials, instead of the finished product." +msgstr "" + +#. module: mrp +#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view +msgid "If the product is a semi-finished product: When processing a\n" +" manufacturing order that contains that product as component,\n" +" the raw materials of that product will be added to the\n" +" manufacturing order of the final product." +msgstr "" + +#. module: mrp #: model:ir.ui.view,arch_db:mrp.mrp_workcenter_kanban #: model:ir.ui.view,arch_db:mrp.stock_production_type_kanban #: model:ir.ui.view,arch_db:mrp.view_mrp_production_filter @@ -1301,15 +1310,11 @@ msgstr "" #. module: mrp #: model:ir.ui.view,arch_db:mrp.view_mrp_bom_filter +#: selection:mrp.bom,type:0 msgid "Kit" msgstr "" #. module: mrp -#: model:ir.model.fields,help:mrp.field_mrp_bom_type -msgid "Kit (Phantom): When processing a sales order for this product, the delivery order will contain the raw materials, instead of the finished product." -msgstr "" - -#. module: mrp #: model:product.product,name:mrp.product_product_laptop_charger #: model:product.template,name:mrp.product_product_laptop_charger_product_template #: model:stock.inventory.line,product_name:mrp.stock_inventory_line_product_laptop_charger @@ -2602,11 +2607,6 @@ msgid "Settings" msgstr "" #. module: mrp -#: selection:mrp.bom,type:0 -msgid "Ship this product as a set of components (kit)" -msgstr "" - -#. module: mrp #: code:addons/mrp/models/mrp_unbuild.py:89 #, python-format msgid "Should have a lot for the finished product" @@ -3207,12 +3207,6 @@ msgid "When a procurement has a ‘produce’ route with a operation type set, i msgstr "" #. module: mrp -#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view -msgid "When processing a sales order for this product, the delivery order\n" -" will contain the raw materials, instead of the finished product." -msgstr "" - -#. module: mrp #: model:ir.model.fields,help:mrp.field_stock_warehouse_manufacture_to_resupply msgid "When products are manufactured, they can be manufactured in this warehouse." msgstr "" diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index f41e4a5a111..4d9a0e5bcbe 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -24,9 +24,8 @@ class MrpBom(models.Model): help="If the active field is set to False, it will allow you to hide the bills of material without removing it.") type = fields.Selection([ ('normal', 'Manufacture this product'), - ('phantom', 'Ship this product as a set of components (kit)')], 'BoM Type', - default='normal', required=True, - help="Kit (Phantom): When processing a sales order for this product, the delivery order will contain the raw materials, instead of the finished product.") + ('phantom', 'Kit')], 'BoM Type', + default='normal', required=True) product_tmpl_id = fields.Many2one( 'product.template', 'Product', domain="[('type', 'in', ['product', 'consu'])]", required=True) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 59bafc27487..4a56ef217bc 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -107,7 +107,7 @@ class MrpProduction(models.Model): copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, domain=[('scrapped', '=', False)]) finished_move_line_ids = fields.One2many( - 'stock.move.line', compute='_compute_lines', inverse='_inverse_lines', + 'stock.move.line', compute='_compute_lines', inverse='_inverse_lines', string="Finished Product" ) workorder_ids = fields.One2many( 'mrp.workorder', 'production_id', 'Work Orders', diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index 19b56e17c2e..299f96c1b61 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -29,9 +29,20 @@ <group> <field name="code"/> <field name="type" widget="radio"/> - <p colspan="2" class="oe_grey" attrs="{'invisible': [('type','=','normal')]}"> - When processing a sales order for this product, the delivery order - will contain the raw materials, instead of the finished product. + <p colspan="2" class="oe_grey oe_edit_only" attrs="{'invisible': [('type','=','normal')]}"> + <ul> + <li> + If the product is a finished product: When processing a sales + order for this product, the delivery order will contain the raw + materials, instead of the finished product. + </li> + <li> + If the product is a semi-finished product: When processing a + manufacturing order that contains that product as component, + the raw materials of that product will be added to the + manufacturing order of the final product. + </li> + </ul> </p> <field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/> </group> diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 42a3806dc0e..3fdf010c2af 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -131,7 +131,7 @@ </field> </page> <page string="Finished Products"> - <field name="finished_move_line_ids" attrs="{'readonly': [('is_locked', '=', True)], 'invisible': [('finished_move_line_ids', '=', [])]}"> + <field name="finished_move_line_ids" context="{'form_view_ref': 'mrp.view_finisehd_move_line'}" attrs="{'readonly': [('is_locked', '=', True)], 'invisible': [('finished_move_line_ids', '=', [])]}"> <tree default_order="done_move" editable="bottom" create="0" delete="0" decoration-muted="state in ('done', 'cancel')"> <field name="product_id" readonly="1"/> <field name="lot_id" groups="stock.group_production_lot" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}" attrs="{'invisible': [('lots_visible', '=', False)]}"/> diff --git a/addons/mrp/views/mrp_workorder_views.xml b/addons/mrp/views/mrp_workorder_views.xml index 994b4e1dd68..a3522f85999 100644 --- a/addons/mrp/views/mrp_workorder_views.xml +++ b/addons/mrp/views/mrp_workorder_views.xml @@ -140,13 +140,13 @@ <page string="Current Production"> <group> <group> - <field name="qty_producing" string="Current Qty" attrs="{'readonly': ['|', ('product_tracking', '=', 'serial'), ('state', 'in', ('done', 'cancel'))]}" readonly="1"/> - <field name="final_lot_id" context="{'default_product_id': product_id}" domain="['|', '&', ('product_id', '=', product_id), ('product_id.tracking', '!=', 'serial'), ('use_next_on_work_order_id', '=', id)]" attrs="{'invisible': [('product_tracking', '=', 'none')]}" groups="stock.group_production_lot" readonly="1"/> + <field name="qty_producing" string="Current Qty" attrs="{'readonly': ['|', ('product_tracking', '=', 'serial'), ('state', 'in', ('done', 'cancel'))]}"/> + <field name="final_lot_id" context="{'default_product_id': product_id}" domain="['|', '&', ('product_id', '=', product_id), ('product_id.tracking', '!=', 'serial'), ('use_next_on_work_order_id', '=', id)]" attrs="{'invisible': [('product_tracking', '=', 'none')]}" groups="stock.group_production_lot"/> <field name="product_tracking" invisible="1"/> </group> </group> <field name="move_raw_ids" invisible="1"/> - <field name="active_move_line_ids" attrs="{'invisible': [('active_move_line_ids', '=', [])]}" readonly="1"> + <field name="active_move_line_ids" attrs="{'invisible': [('active_move_line_ids', '=', [])]}"> <tree editable="bottom" create="0" delete="0"> <field name="product_id"/> <field name="product_uom_qty" readonly="1" string="To Consume"/> diff --git a/addons/mrp/views/stock_move_views.xml b/addons/mrp/views/stock_move_views.xml index f1007b8d26b..a6c2a07417a 100644 --- a/addons/mrp/views/stock_move_views.xml +++ b/addons/mrp/views/stock_move_views.xml @@ -63,6 +63,28 @@ </field> </record> + <record id="view_finisehd_move_line" model="ir.ui.view"> + <field name="name">mrp.finished.move.line.form</field> + <field name="model">stock.move.line</field> + <field name="arch" type="xml"> + <form string="Finished Product"> + <group> + <group> + <field name="product_id" readonly="1"/> + <label for="qty_done" string ="Quantity"/> + <div class="o_row"> + <span><field name="qty_done" readonly="1" nolabel="1"/></span> + <span>/</span> + <span><field name="product_uom_qty" readonly="1" nolabel="1"/></span> + <span><field name="product_uom_id" attrs="{'readonly': [('id', '!=', False)]}" nolabel="1"/></span> + </div> + <field name="lot_id" string="Lot/Serial number" groups="stock.group_production_lot" readonly="1" attrs="{'invisible': [('lots_visible', '=', False)]}"/> + </group> + </group> + </form> + </field> + </record> + <menuitem id="menu_mrp_traceability" name="Traceability: Lots" parent="menu_mrp_reporting" diff --git a/addons/mrp/wizard/change_production_qty.py b/addons/mrp/wizard/change_production_qty.py index 526a12d8ffc..bf447feee61 100644 --- a/addons/mrp/wizard/change_production_qty.py +++ b/addons/mrp/wizard/change_production_qty.py @@ -39,7 +39,7 @@ class ChangeProductionQty(models.TransientModel): def change_prod_qty(self): for wizard in self: production = wizard.mo_id - produced = sum(production.move_finished_ids.mapped('quantity_done')) + produced = sum(production.move_finished_ids.filtered(lambda m: m.product_id == production.product_id).mapped('quantity_done')) if wizard.product_qty < produced: raise UserError(_("You have already processed %d. Please input a quantity higher than %d ")%(produced, produced)) production.write({'product_qty': wizard.product_qty}) diff --git a/addons/mrp_byproduct/models/mrp_production.py b/addons/mrp_byproduct/models/mrp_production.py index 91917da03cc..05584d0d019 100644 --- a/addons/mrp_byproduct/models/mrp_production.py +++ b/addons/mrp_byproduct/models/mrp_production.py @@ -3,6 +3,8 @@ from odoo import api, models +from odoo.tools import float_round + class MrpProduction(models.Model): _description = 'Production' @@ -42,3 +44,39 @@ class MrpProduction(models.Model): for sub_product in production.bom_id.sub_products: production._create_byproduct_move(sub_product) return res + + +class MrpProductProduce(models.TransientModel): + _name = "mrp.product.produce" + _description = "Record Production" + _inherit = "mrp.product.produce" + + @api.multi + def check_finished_move_lots(self): + """ Handle by product tracked """ + by_product_moves = self.production_id.move_finished_ids.filtered(lambda m: m.product_id != self.product_id and m.product_id.tracking != 'none' and m.state not in ('done', 'cancel')) + for by_product_move in by_product_moves: + rounding = by_product_move.product_uom.rounding + quantity = float_round(self.product_qty * by_product_move.unit_factor, precision_rounding=rounding) + values = { + 'move_id': by_product_move.id, + 'product_id': by_product_move.product_id.id, + 'production_id': self.production_id.id, + 'product_uom_id': by_product_move.product_uom.id, + 'location_id': by_product_move.location_id.id, + 'location_dest_id': by_product_move.location_dest_id.id, + } + if by_product_move.product_id.tracking == 'lot': + values.update({ + 'product_uom_qty': quantity, + 'qty_done': quantity, + }) + self.env['stock.move.line'].create(values) + else: + values.update({ + 'product_uom_qty': 1.0, + 'qty_done': 1.0, + }) + for i in range(0, int(quantity)): + self.env['stock.move.line'].create(values) + return super(MrpProductProduce, self).check_finished_move_lots() diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py index 55cad84fd9d..27b36a902bf 100644 --- a/addons/payment_stripe/controllers/main.py +++ b/addons/payment_stripe/controllers/main.py @@ -53,9 +53,16 @@ class StripeController(http.Controller): """ Create a payment transaction Expects the result from the user input from checkout.js popup""" - tx = request.env['payment.transaction'].sudo().browse( - int(request.session.get('sale_transaction_id') or request.session.get('website_payment_tx_id', False)) - ) + TX = request.env['payment.transaction'] + tx = None + if post.get('tx_ref'): + tx = TX.sudo().search([('reference', '=', post['tx_ref'])]) + if not tx: + tx_id = (post.get('tx_id') or request.session.get('sale_transaction_id') or + request.session.get('website_payment_tx_id')) + tx = TX.sudo().browse(int(tx_id)) + if not tx: + raise werkzeug.exceptions.NotFound() response = tx._create_stripe_charge(tokenid=post['tokenid'], email=post['email']) _logger.info('Stripe: entering form_feedback with post data %s', pprint.pformat(response)) if response: diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py index e53901b71b4..232ec6d9d43 100644 --- a/addons/payment_stripe/models/payment.py +++ b/addons/payment_stripe/models/payment.py @@ -43,13 +43,13 @@ class PaymentAcquirerStripe(models.Model): 'amount': tx_values.get('amount'), 'currency': tx_values.get('currency') and tx_values.get('currency').name or '', 'currency_id': tx_values.get('currency') and tx_values.get('currency').id or '', - 'address_line1': tx_values['partner_address'], - 'address_city': tx_values['partner_city'], - 'address_country': tx_values['partner_country'] and tx_values['partner_country'].name or '', - 'email': tx_values['partner_email'], - 'address_zip': tx_values['partner_zip'], - 'name': tx_values['partner_name'], - 'phone': tx_values['partner_phone'], + 'address_line1': tx_values.get('partner_address'), + 'address_city': tx_values.get('partner_city'), + 'address_country': tx_values.get('partner_country') and tx_values['partner_country'].name or '', + 'email': tx_values.get('partner_email'), + 'address_zip': tx_values.get('partner_zip'), + 'name': tx_values.get('partner_name'), + 'phone': tx_values.get('partner_phone'), } temp_stripe_tx_values['returndata'] = stripe_tx_values.pop('return_url', '') @@ -107,14 +107,15 @@ class PaymentTransactionStripe(models.Model): charge_params = { 'amount': int(self.amount if self.currency_id.name in INT_CURRENCIES else self.amount*100), 'currency': self.currency_id.name, - 'metadata[reference]': self.reference + 'metadata[reference]': self.reference, + 'description': self.reference, } if acquirer_ref: charge_params['customer'] = acquirer_ref if tokenid: charge_params['card'] = str(tokenid) if email: - charge_params['receipt_email'] = email + charge_params['receipt_email'] = email.strip() r = requests.post(api_url_charge, auth=(self.acquirer_id.stripe_secret_key, ''), params=charge_params, @@ -156,12 +157,17 @@ class PaymentTransactionStripe(models.Model): transaction record. """ reference = data.get('metadata', {}).get('reference') if not reference: - error_msg = _( - 'Stripe: invalid reply received from provider, missing reference. Additional message: %s' - % data.get('error', {}).get('message', '') - ) - _logger.error(error_msg) + stripe_error = data.get('error', {}).get('message', '') + _logger.error('Stripe: invalid reply received from stripe API, looks like ' + 'the transaction failed. (error: %s)', stripe_error or 'n/a') + error_msg = _("We're sorry to report that the transaction has failed.") + if stripe_error: + error_msg += " " + (_("Stripe gave us the following info about the problem: '%s'") % + stripe_error) + error_msg += " " + _("Perhaps the problem can be solved by double-checking your " + "credit card details, or contacting your bank?") raise ValidationError(error_msg) + tx = self.search([('reference', '=', reference)]) if not tx: error_msg = (_('Stripe: no order found for reference %s') % reference) @@ -231,6 +237,7 @@ class PaymentTokenStripe(models.Model): 'card[exp_month]': str(values['cc_expiry'][:2]), 'card[exp_year]': str(values['cc_expiry'][-2:]), 'card[cvc]': values['cvc'], + 'card[name]': values['cc_holder_name'], } r = requests.post(url_token, auth=(payment_acquirer.stripe_secret_key, ''), @@ -239,8 +246,12 @@ class PaymentTokenStripe(models.Model): token = r.json() if token.get('id'): customer_params = { - 'source': token['id'] + 'source': token['id'], + 'description': values['cc_holder_name'] } + if values.get('partner_id'): + partner = self.env['res.partner'].browse(values['partner_id']) + customer_params['email'] = partner.email and partner.email.strip() r = requests.post(url_customer, auth=(payment_acquirer.stripe_secret_key, ''), params=customer_params, diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index c0b9ef3d5a8..759ef95b422 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -14,120 +14,111 @@ odoo.define('payment_stripe.stripe', function(require) { 'RWF', 'KRW', 'VUV', 'VND', 'XOF' ]; - function getStripeHandler(parentElement) - { - var handler = StripeCheckout.configure({ - key: $("input[name='stripe_key']", parentElement).val(), - image: $("input[name='stripe_image']", parentElement).val(), - locale: 'auto', - token: function(token, args) { - handler.isTokenGenerate = true; - ajax.jsonRpc("/payment/stripe/create_charge", 'call', { - tokenid: token.id, - email: token.email, - amount: $("input[name='amount']", parentElement).val(), - acquirer_id: $("#acquirer_stripe", parentElement).val(), - currency: $("input[name='currency']", parentElement).val(), - invoice_num: $("input[name='invoice_num']", parentElement).val(), - return_url: $("input[name='return_url']", parentElement).val() - }).done(function(data){ - handler.isTokenGenerate = false; - window.location.href = data; - }).fail(function(){ - var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.message; - var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')})); - wizard.appendTo($('body')).modal({'keyboard': true}); - }); - }, - }); - return handler; + if ($.blockUI) { + // our message needs to appear above the modal dialog + $.blockUI.defaults.baseZ = 2147483647; //same z-index as StripeCheckout + $.blockUI.defaults.css.border = '0'; + $.blockUI.defaults.css["background-color"] = ''; + $.blockUI.defaults.overlayCSS["opacity"] = '0.9'; } - $(document).ready(function (){ - if (!$('.o_payment_form').length) { - return $.Deferred().reject("DOM doesn't contain '.o_payment_form'"); - } - - $('#o_payment_form_pay').on('click', function(ev){ - // we retrieve the payment form - var parent_form = ev.target.form; - // then the checked radio - var checked_radio = $('input[type="radio"]:checked', parent_form); - // check if there's one checked radio - if(checked_radio.length != 1) { - return; - } - // if there's a checked radio, we retrieve the usefull data - var acquirer_id = checked_radio.data('acquirerId'); - var provider = checked_radio.data('provider'); - var is_form_payment = checked_radio.data('form-payment') === "True"; - // now we check if the user has clicked on stripe radio button and wants to pay via the checkout form - if(provider != "stripe" || is_form_payment !== true) { - return; + var handler = StripeCheckout.configure({ + key: $("input[name='stripe_key']").val(), + image: $("input[name='stripe_image']").val(), + locale: 'auto', + token: function(token, args) { + handler.isTokenGenerate = true; + if ($.blockUI) { + var msg = _t("Just one more second, confirming your payment..."); + $.blockUI({ + 'message': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' + + ' <br />' + msg + + '</h2>' + }); } - // from here we retrieve the inputs that contains the data we need - var provider_form = $("#o_payment_form_acq_" + acquirer_id, parent_form); + ajax.jsonRpc("/payment/stripe/create_charge", 'call', { + tokenid: token.id, + email: token.email, + amount: $("input[name='amount']").val(), + acquirer_id: $("#acquirer_stripe").val(), + currency: $("input[name='currency']").val(), + invoice_num: $("input[name='invoice_num']").val(), + tx_ref: $("input[name='invoice_num']").val(), + return_url: $("input[name='return_url']").val() + }).always(function(){ + if ($.blockUI) { + $.unblockUI(); + } + }).done(function(data){ + handler.isTokenGenerate = false; + window.location.href = data; + }).fail(function(){ + var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.arguments && arguments[1].data.arguments[0]; + var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')})); + wizard.appendTo($('body')).modal({'keyboard': true}); + }); + }, + }); - var so_id = $("input[name='return_url']", provider_form).val().match(/quote\/([0-9]+)/) || undefined; - var access_token = $("input[name='return_url']", provider_form).val().match(/quote\/([0-9]+)\/([0-9a-zA-Z\-]+)/) || undefined; - if (so_id) { - so_id = parseInt(so_id[1]); - } - if(access_token) { - access_token = access_token[2]; - } + $('#pay_stripe').on('click', function(e) { + // Open Checkout with further options + if(!$(this).find('i').length) + $(this).append('<i class="fa fa-spinner fa-spin"/>'); + $(this).attr('disabled','disabled'); - if ($('.o_website_payment').length !== 0) { - var currency = $("input[name='currency']", provider_form).val(); - var amount = parseFloat($("input[name='amount']", provider_form).val() || '0.0'); - if (!_.contains(int_currencies, currency)) { - amount = amount*100; - } + var $form = $(e.currentTarget).parents('form'); + var acquirer_id = $(e.currentTarget).closest('div.oe_sale_acquirer_button,div.oe_quote_acquirer_button,div.o_website_payment_new_payment'); + acquirer_id = acquirer_id.data('id') || acquirer_id.data('acquirer_id'); + if (! acquirer_id) { + return false; + } - ajax.jsonRpc('/website_payment/transaction', 'call', { - reference: $("input[name='invoice_num']", provider_form).val(), - amount: amount, - currency_id: currency, - acquirer_id: acquirer_id - }) - var handler = getStripeHandler(provider_form); - handler.open({ - name: $("input[name='merchant']", provider_form).val(), - description: $("input[name='invoice_num']", provider_form).val(), - currency: currency, - amount: amount, - }); - } else { - var currency = $("input[name='currency']", provider_form).val(); - var amount = parseFloat($("input[name='amount']", provider_form).val() || '0.0'); - if (!_.contains(int_currencies, currency)) { - amount = amount*100; - } + var so_token = $("input[name='token']").val(); + var so_id = $("input[name='return_url']").val().match(/quote\/([0-9]+)/) || undefined; + if (so_id) { + so_id = parseInt(so_id[1]); + } - var url = '/shop/payment/transaction/'; - if(so_id) { - url += so_id + '/'; - if(access_token) { - url += access_token; - } - } + e.preventDefault(); - ajax.jsonRpc(url, 'call', { - acquirer_id: acquirer_id - }, {'async': false}).then(function (data) { - try { - provider_form.html(data); - } - catch(err) { } // here it will catch an error saying that payment_stripe.stripe is already started - var handler = getStripeHandler(provider_form); - handler.open({ - name: $("input[name='merchant']", provider_form).val(), - description: $("input[name='invoice_num']", provider_form).val(), - currency: currency, - amount: amount, - }); - }); + var currency = $("input[name='currency']").val(); + var currency_id = $("input[name='currency_id']").val(); + var amount = parseFloat($("input[name='amount']").val() || '0.0'); + + + if ($('.o_website_payment').length !== 0) { + var create_tx = ajax.jsonRpc('/website_payment/transaction', 'call', { + reference: $("input[name='invoice_num']").val(), + amount: amount, // exact amount, not stripe cents + currency_id: currency_id, + acquirer_id: acquirer_id + }); + } + else if ($('.o_website_quote').length !== 0) { + var url = _.str.sprintf("/quote/%s/transaction/%s/%s", so_id, acquirer_id, so_token); + var create_tx = ajax.jsonRpc(url, 'call', {}).then(function (data) { + try { $form.html(data); } catch (e) {}; + }); + } + else { + var create_tx = ajax.jsonRpc('/shop/payment/transaction/' + acquirer_id, 'call', { + so_id: so_id, + so_token: so_token + }).then(function (data) { + try { $form.html(data); } catch (e) {}; + }); + } + create_tx.done(function () { + if (!_.contains(int_currencies, currency)) { + amount = amount*100; } + handler.open({ + name: $("input[name='merchant']").val(), + description: $("input[name='invoice_num']").val(), + email: $("input[name='email']").val(), + currency: currency, + amount: amount, + }); }); }); }); diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 6d5b1649dc3..fd150d96a61 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -223,7 +223,7 @@ class PosOrder(models.Model): # 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}) - inv_line.update(price_unit=line.price_unit, discount=line.discount) + inv_line.update(price_unit=line.price_unit, discount=line.discount, name=inv_name) return InvoiceLine.sudo().create(inv_line) def _create_account_move_line(self, session=None, move=None): @@ -650,7 +650,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 611cc9146de..fffd394244f 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -206,7 +206,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; @@ -1646,72 +1646,137 @@ 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); + + var recompute_base = function(base_amount, fixed_amount, percent_amount){ + if(fixed_amount === 0.0 && percent_amount === 0.0) + return base_amount; + return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0); + } + + // 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; } - 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); - } + 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/portal/static/src/less/portal_frontend.less b/addons/portal/static/src/less/portal_frontend.less index 33c2bf0969b..ebfc117b9dc 100644 --- a/addons/portal/static/src/less/portal_frontend.less +++ b/addons/portal/static/src/less/portal_frontend.less @@ -72,3 +72,7 @@ text-align: center; } } + +.label-optional { + font-weight: normal; +} diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 7cb351818fc..3a8a64aba48 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -261,7 +261,7 @@ <div class="clearfix" /> <div t-attf-class="form-group #{error.get('street') and 'has-error' or ''} col-lg-6"> - <label class="control-label label-optional" for="street">Street</label> + <label class="control-label" for="street">Street</label> <input type="text" name="street" class="form-control" t-att-value="street or partner.street"/> </div> <div t-attf-class="form-group #{error.get('city') and 'has-error' or ''} col-lg-6"> diff --git a/addons/product/models/product.py b/addons/product/models/product.py index b854925701e..61704ad44f8 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -283,7 +283,8 @@ class ProductProduct(models.Model): for value in product.attribute_value_ids: if value.attribute_id in attributes: raise ValidationError(_('Error! It is not allowed to choose more than one value for a given attribute.')) - attributes |= value.attribute_id + if value.attribute_id.create_variant: + attributes |= value.attribute_id return True @api.onchange('uom_id', 'uom_po_id') diff --git a/addons/purchase/models/stock.py b/addons/purchase/models/stock.py index 64aa739daaa..4edd90aafbb 100644 --- a/addons/purchase/models/stock.py +++ b/addons/purchase/models/stock.py @@ -31,6 +31,19 @@ class StockMove(models.Model): created_purchase_line_id = fields.Many2one('purchase.order.line', 'Created Purchase Order Line', ondelete='set null', readonly=True, copy=False) + @api.model + def _prepare_merge_moves_distinct_fields(self): + distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields() + distinct_fields.append('purchase_line_id') + return distinct_fields + + @api.model + def _prepare_merge_move_sort_method(self, move): + move.ensure_one() + keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move) + keys_sorted.append(move.purchase_line_id.id) + return keys_sorted + @api.multi def _get_price_unit(self): """ Returns the unit price for the move""" @@ -148,4 +161,4 @@ class Orderpoint(models.Model): res = super(Orderpoint, self)._quantity_in_progress() for poline in self.env['purchase.order.line'].search([('state','in',('draft','sent','to approve')),('orderpoint_id','in',self.ids)]): res[poline.orderpoint_id.id] += poline.product_uom._compute_quantity(poline.product_qty, poline.orderpoint_id.product_uom, round=False) - return res
\ No newline at end of file + return res diff --git a/addons/purchase/tests/__init__.py b/addons/purchase/tests/__init__.py index 924cfc8c823..fd93f8e2b61 100644 --- a/addons/purchase/tests/__init__.py +++ b/addons/purchase/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_onchange_product_id from . import test_purchase_order from . import test_create_picking from . import test_purchase_lead_time +from . import test_stockvaluation diff --git a/addons/purchase/tests/test_stockvaluation.py b/addons/purchase/tests/test_stockvaluation.py new file mode 100644 index 00000000000..09359417738 --- /dev/null +++ b/addons/purchase/tests/test_stockvaluation.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import time +from datetime import datetime + +from odoo.tests.common import TransactionCase +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + + +class TestStockValuation(TransactionCase): + def setUp(self): + super(TestStockValuation, self).setUp() + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + self.partner_id = self.env.ref('base.res_partner_1') + self.product1 = self.env.ref('product.product_product_8') + Account = self.env['account.account'] + self.stock_input_account = Account.create({ + 'name': 'Stock Input', + 'code': 'StockIn', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + }) + self.stock_output_account = Account.create({ + 'name': 'Stock Output', + 'code': 'StockOut', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + }) + self.stock_valuation_account = Account.create({ + 'name': 'Stock Valuation', + 'code': 'Stock Valuation', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + }) + self.stock_journal = self.env['account.journal'].create({ + 'name': 'Stock Journal', + 'code': 'STJTEST', + 'type': 'general', + }) + self.product1.categ_id.write({ + 'property_stock_account_input_categ_id': self.stock_input_account.id, + 'property_stock_account_output_categ_id': self.stock_output_account.id, + 'property_stock_valuation_account_id': self.stock_valuation_account.id, + 'property_stock_journal': self.stock_journal.id, + }) + + def test_change_unit_cost_average_1(self): + """ Confirm a purchase order and create the associated receipt, change the unit cost of the + purchase order before validating the receipt, the value of the received goods should be set + according to the last unit cost. + """ + self.product1.product_tmpl_id.cost_method = 'average' + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + + # the unit price of the purchase order line is copied to the in move + self.assertEquals(move1.price_unit, 100) + + # update the unit price on the purchase order line + po1.order_line.price_unit = 200 + + # the unit price on the stock move is not directly updated + self.assertEquals(move1.price_unit, 100) + + # validate the receipt + res_dict = picking1.button_validate() + wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) + wizard.process() + + # the unit price of the stock move has been updated to the latest value + self.assertEquals(move1.price_unit, 200) + + self.assertEquals(self.product1.stock_value, 2000) + + def test_standard_price_change_1(self): + """ Confirm a purchase order and create the associated receipt, change the unit cost of the + purchase order and the standard price of the product before validating the receipt, the + value of the received goods should be set according to the last standard price. + """ + self.product1.product_tmpl_id.cost_method = 'standard' + + # set a standard price + self.product1.product_tmpl_id.standard_price = 10 + + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 11.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + + # the move's unit price reflects the purchase order line's cost even if it's useless when + # the product's cost method is standard + self.assertEquals(move1.price_unit, 11) + + # set a new standard price + self.product1.product_tmpl_id.standard_price = 12 + + # the unit price on the stock move is not directly updated + self.assertEquals(move1.price_unit, 11) + + # validate the receipt + res_dict = picking1.button_validate() + wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) + wizard.process() + + # the unit price of the stock move has been updated to the latest value + self.assertEquals(move1.price_unit, 12) + + self.assertEquals(self.product1.stock_value, 120) + + def test_change_currency_rate_average_1(self): + """ Confirm a purchase order in another currency and create the associated receipt, change + the currency rate, validate the receipt and then check that the value of the received goods + is set according to the last currency rate. + """ + usd_currency = self.env.ref('base.USD') + self.env.user.company_id.currency_id = usd_currency.id + + eur_currency = self.env.ref('base.EUR') + + self.product1.product_tmpl_id.cost_method = 'average' + + # default currency is USD, create a purchase order in EUR + po1 = self.env['purchase.order'].create({ + 'partner_id': self.partner_id.id, + 'currency_id': eur_currency.id, + 'order_line': [ + (0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_qty': 10.0, + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': 100.0, + 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }), + ], + }) + po1.button_confirm() + + picking1 = po1.picking_ids[0] + move1 = picking1.move_lines[0] + + # convert the price unit in the company currency + price_unit_usd = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=False) + + # the unit price of the move is the unit price of the purchase order line converted in + # the company's currency + self.assertEqual(move1.price_unit, price_unit_usd) + + # change the rate of the currency + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y-%m-%d'), + 'rate': 2.0, + 'currency_id': eur_currency.id, + 'company_id': po1.company_id.id, + }) + eur_currency._compute_current_rate() + price_unit_usd_new_rate = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=False) + + # the new price_unit is lower than th initial because of the rate's change + self.assertLess(price_unit_usd_new_rate, price_unit_usd) + + # the unit price on the stock move is not directly updated + self.assertEquals(move1.price_unit, price_unit_usd) + + # validate the receipt + res_dict = picking1.button_validate() + wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id')) + wizard.process() + + # the unit price of the stock move has been updated to the latest value + self.assertEquals(move1.price_unit, price_unit_usd_new_rate) + + self.assertAlmostEqual(self.product1.stock_value, price_unit_usd_new_rate * 10, delta=0.1) diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index b50faa855fc..6d215d7160a 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -721,7 +721,7 @@ class ResourceCalendarLeaves(models.Model): date_from = fields.Datetime('Start Date', required=True) date_to = fields.Datetime('End Date', required=True) tz = fields.Selection( - _tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz), + _tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz or 'UTC'), help="Timezone used when encoding the leave. It is used to correctly" "localize leave hours when computing time intervals.") resource_id = fields.Many2one( diff --git a/addons/sale/__init__.py b/addons/sale/__init__.py index 59347877995..ba3e6977f3c 100644 --- a/addons/sale/__init__.py +++ b/addons/sale/__init__.py @@ -20,6 +20,6 @@ def uninstall_hook(cr, registry): if 'crm.team' in env: recs = env['crm.team'].search([]) for rec in recs: - rec._onchange_team_type() + rec._onchange_team_type() - cr.after("commit", partial(update_dashboard_graph_model, cr.dbname))
\ No newline at end of file + cr.after("commit", partial(update_dashboard_graph_model, cr.dbname)) diff --git a/addons/sale/data/mail_template_data.xml b/addons/sale/data/mail_template_data.xml index effcd48d714..e83ed891611 100644 --- a/addons/sale/data/mail_template_data.xml +++ b/addons/sale/data/mail_template_data.xml @@ -72,13 +72,13 @@ from ${object.company_id.name}. % set record = ctx.get('record') % set company = record and record.company_id or user.company_id <body style="margin: 0; padding: 0;"> - <table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed" summary="o_mail_notification"> + <table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed; border-collapse:separate;" summary="o_mail_notification"> <tbody> <!-- HEADER --> <tr> <td align="center" style="min-width: 590px;"> - <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;"> + <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;"> <tr> <td valign="middle"> <span style="font-size:20px; color:white; font-weight: bold;"> @@ -96,7 +96,7 @@ from ${object.company_id.name}. <!-- CONTENT --> <tr> <td align="center" style="min-width: 590px;"> - <table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px;"> + <table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px; border-collapse:separate;"> <tbody> <td valign="top" style="font-family:Arial,Helvetica,sans-serif; color: #555; font-size: 14px;"> ${object.body | safe} @@ -109,7 +109,7 @@ from ${object.company_id.name}. <!-- FOOTER --> <tr> <td align="center" style="min-width: 590px;"> - <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;"> + <table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;"> <tr> <td valign="middle" align="left" style="color: #fff; padding-top: 10px; padding-bottom: 10px; font-size: 12px;"> ${company.name}<br/> diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 177deb30c50..2fd6a90797e 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -693,6 +693,14 @@ class SaleOrderLine(models.Model): 'price_subtotal': taxes['total_excluded'], }) + @api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered') + def _compute_product_updatable(self): + for line in self: + if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)): + line.product_updatable = False + else: + line.product_updatable = True + @api.depends('product_id.invoice_policy', 'order_id.state') def _compute_qty_delivered_updateable(self): for line in self: @@ -852,6 +860,7 @@ class SaleOrderLine(models.Model): discount = fields.Float(string='Discount (%)', digits=dp.get_precision('Discount'), default=0.0) product_id = fields.Many2one('product.product', string='Product', domain=[('sale_ok', '=', True)], change_default=True, ondelete='restrict', required=True) + product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True) product_uom_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0) product_uom = fields.Many2one('product.uom', string='Unit of Measure', required=True) # Non-stored related field to allow portal user to see the image of the product he has ordered @@ -964,7 +973,8 @@ 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) + product_price = product[pricelist_item.base] if (pricelist_item and pricelist_item.base != 'pricelist') else product.lst_price + return from_currency.compute(product_price, self.order_id.pricelist_id.currency_id) @api.multi @api.onchange('product_id') diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index e0892afed10..eee80eedc17 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -21,10 +21,13 @@ class TestSaleOrder(TestSale): 'pricelist_id': self.env.ref('product.list0').id, }) self.assertEqual(so.amount_total, sum([2 * p.list_price for p in self.products.values()]), 'Sale: total amount is wrong') - + so.order_line._compute_product_updatable() + self.assertTrue(so.order_line[0].product_updatable) # send quotation so.force_quotation_send() self.assertTrue(so.state == 'sent', 'Sale: state after sending is wrong') + so.order_line._compute_product_updatable() + self.assertTrue(so.order_line[0].product_updatable) # confirm quotation so.action_confirm() @@ -38,7 +41,8 @@ class TestSaleOrder(TestSale): self.assertEqual(inv.amount_total, sum([2 * p.list_price if p.invoice_policy == 'order' else 0 for p in self.products.values()]), 'Sale: invoice total amount is wrong') self.assertTrue(so.invoice_status == 'no', 'Sale: SO status after invoicing should be "nothing to invoice"') self.assertTrue(len(so.invoice_ids) == 1, 'Sale: invoice is missing') - + so.order_line._compute_product_updatable() + self.assertFalse(so.order_line[0].product_updatable) # deliver lines except 'time and material' then invoice again for line in so.order_line: line.qty_delivered = 2 if line.product_id.expense_policy=='no' else 0 diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 1367f5344ff..dce70b92f63 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -239,9 +239,10 @@ <form string="Sales Order Lines"> <group> <group> + <field name="product_updatable" invisible="1"/> <field name="product_id" context="{'partner_id':parent.partner_id, 'quantity':product_uom_qty, 'pricelist':parent.pricelist_id, 'uom':product_uom, 'company_id': parent.company_id}" - attrs="{'readonly': [('qty_invoiced', '>', 0)]}" + attrs="{'readonly': [('product_updatable', '=', False)]}" force_save="1" /> <field name="layout_category_id" groups="sale.group_sale_layout"/> @@ -294,8 +295,9 @@ </form> <tree string="Sales Order Lines" editable="bottom" decoration-info="invoice_status=='to invoice'"> <field name="sequence" widget="handle"/> + <field name="product_updatable" invisible="1"/> <field name="product_id" - attrs="{'readonly': [('qty_invoiced', '>', 0)]}" + attrs="{'readonly': [('product_updatable', '=', False)]}" context="{'partner_id':parent.partner_id, 'quantity':product_uom_qty, 'pricelist':parent.pricelist_id, 'uom':product_uom, 'company_id': parent.company_id}" /> <field name="layout_category_id" groups="sale.group_sale_layout"/> diff --git a/addons/sale_stock/models/account_invoice.py b/addons/sale_stock/models/account_invoice.py index 930e5cf0bb2..4b9212beb8f 100644 --- a/addons/sale_stock/models/account_invoice.py +++ b/addons/sale_stock/models/account_invoice.py @@ -51,7 +51,7 @@ class AccountInvoiceLine(models.Model): qty_to_consider = invoiced_qty - qty_done qty_to_consider = min(qty_to_consider, quantity - qty_delivered) qty_delivered += qty_to_consider - average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + move.price_unit * qty_to_consider) / qty_delivered + average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + (-1 * move.price_unit) * qty_to_consider) / qty_delivered if qty_delivered == quantity: break return average_price_unit diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index aa55b213294..38a01917cf4 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -127,14 +127,24 @@ class SaleOrderLine(models.Model): and all(move.state in ['done', 'cancel'] for move in line.move_ids): line.invoice_status = 'invoiced' + @api.depends('move_ids') + def _compute_product_updatable(self): + for line in self: + if not line.move_ids: + super(SaleOrderLine, line)._compute_product_updatable() + else: + line.product_updatable = False + @api.multi @api.depends('product_id') def _compute_qty_delivered_updateable(self): - for line in self: - if line.product_id.type not in ('consu', 'product'): - super(SaleOrderLine, line)._compute_qty_delivered_updateable() - else: - line.qty_delivered_updateable = False + # prefetch field before filtering + self.mapped('product_id') + # on consumable or stockable products, qty_delivered_updateable defaults + # to False; on other lines use the original computation + lines = self.filtered(lambda line: line.product_id.type not in ('consu', 'product')) + lines = lines.with_prefetch(self._prefetch) + super(SaleOrderLine, lines)._compute_qty_delivered_updateable() @api.onchange('product_id') def _onchange_product_id_set_customer_lead(self): @@ -225,7 +235,7 @@ class SaleOrderLine(models.Model): if line.state != 'sale' or not line.product_id.type in ('consu','product'): continue qty = 0.0 - for move in line.move_ids: + for move in line.move_ids.filtered(lambda r: r.state != 'cancel'): qty += move.product_qty if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0: continue diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py index aed1a510c9e..c29193ce541 100644 --- a/addons/sale_stock/models/stock.py +++ b/addons/sale_stock/models/stock.py @@ -13,6 +13,19 @@ class StockMove(models.Model): _inherit = "stock.move" sale_line_id = fields.Many2one('sale.order.line', 'Sale Line') + @api.model + def _prepare_merge_moves_distinct_fields(self): + distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields() + distinct_fields.append('sale_line_id') + return distinct_fields + + @api.model + def _prepare_merge_move_sort_method(self, move): + move.ensure_one() + keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move) + keys_sorted.append(move.sale_line_id.id) + return keys_sorted + def _action_done(self): result = super(StockMove, self)._action_done() for line in self.mapped('sale_line_id'): diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py index 4880c8fdd12..3655cf61807 100644 --- a/addons/sale_stock/tests/test_sale_stock.py +++ b/addons/sale_stock/tests/test_sale_stock.py @@ -80,7 +80,11 @@ class TestSaleStock(TestSale): for sol in self.so.order_line: sol.product_id.invoice_policy = 'order' # confirm our standard so, check the picking + self.so.order_line._compute_product_updatable() + self.assertTrue(self.so.order_line[0].product_updatable) self.so.action_confirm() + self.so.order_line._compute_product_updatable() + self.assertFalse(self.so.order_line[0].product_updatable) self.assertTrue(self.so.picking_ids, 'Sale Stock: no picking created for "invoice on order" stockable products') # let's do an invoice for a deposit of 5% adv_wiz = self.env['sale.advance.payment.inv'].with_context(active_ids=[self.so.id]).create({ diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index e65f7580af7..0cb9db63bae 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -16,6 +16,7 @@ have real delivered quantities in sales orders. 'website': 'https://www.odoo.com/page/warehouse', 'depends': ['sale_management', 'hr_timesheet'], 'data': [ + 'security/sale_timesheet_security.xml', 'views/account_invoice_views.xml', 'views/sale_order_views.xml', 'views/product_views.xml', diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 2095abee936..1f3e9b01103 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -55,8 +55,8 @@ class SaleTimesheetController(http.Controller): dashboard_values['hours'][billable_type] = float_round(data.get('unit_amount'), precision_rounding=hour_rounding) dashboard_values['hours']['total'] += float_round(data.get('unit_amount'), precision_rounding=hour_rounding) # rates - dashboard_values['rates'][billable_type] = float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding) - dashboard_values['rates']['total'] += float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding) + dashboard_values['rates'][billable_type] = round(data.get('unit_amount') / dashboard_total_hours * 100, 2) + dashboard_values['rates']['total'] += round(data.get('unit_amount') / dashboard_total_hours * 100, 2) # money_amount so_lines = values['timesheet_lines'].mapped('so_line') diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 1eda2b9aa7a..3fe382701d3 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -52,3 +52,12 @@ class ProductTemplate(models.Model): def _onchange_service_tracking(self): if self.service_tracking != 'task_global_project': self.project_id = False + + @api.onchange('type') + def _onchange_type(self): + if self.type == 'service': + self.invoice_policy = 'order' + self.service_type = 'timesheet' + elif self.type == 'consu': + self.invoice_policy = 'order' + self.service_type = 'manual' diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index cddb6f6abd9..40dc9e2dd2d 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -144,6 +144,14 @@ class SaleOrderLine(models.Model): for so_line in self: so_line.is_service = so_line.product_id.type == 'service' + @api.depends('product_id.type') + def _compute_product_updatable(self): + for line in self: + if line.product_id.type == 'service' and line.state == 'sale': + line.product_updatable = False + else: + super(SaleOrderLine, line)._compute_product_updatable() + @api.model def create(self, values): line = super(SaleOrderLine, self).create(values) diff --git a/addons/sale_timesheet/security/sale_timesheet_security.xml b/addons/sale_timesheet/security/sale_timesheet_security.xml new file mode 100644 index 00000000000..f038e964ad8 --- /dev/null +++ b/addons/sale_timesheet/security/sale_timesheet_security.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<odoo> + + <data noupdate="1"> + + <record id="account_analytic_line_rule_billing_user" model="ir.rule"> + <field name="name">account.analytic.line.billing.user</field> + <field name="model_id" ref="analytic.model_account_analytic_line"/> + <field name="domain_force">[('project_id', '=', False)]</field> + <field name="groups" eval="[(4, ref('account.group_account_invoice'))]"/> + <field name="perm_create" eval="1"/> + <field name="perm_write" eval="1"/> + <field name="perm_unlink" eval="1"/> + <field name="perm_read" eval="0"/> + </record> + + </data> + +</odoo> diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 388a3830122..81d9aaecc10 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -25,7 +25,11 @@ class TestSaleService(CommonTest): 'pricelist_id': self.pricelist_usd.id, } sale_order = self.env['sale.order'].create(sale_order_vals) + sale_order.order_line._compute_product_updatable() + self.assertTrue(sale_order.order_line[0].product_updatable) sale_order.action_confirm() + sale_order.order_line._compute_product_updatable() + self.assertFalse(sale_order.order_line[0].product_updatable) self.assertEqual(sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation') # check task creation diff --git a/addons/sms/wizard/send_sms.py b/addons/sms/wizard/send_sms.py index 25fa0828934..7aa76205a9f 100644 --- a/addons/sms/wizard/send_sms.py +++ b/addons/sms/wizard/send_sms.py @@ -3,7 +3,7 @@ import logging -from odoo import api, fields, models +from odoo import api, fields, models, _ from odoo.exceptions import UserError from odoo.addons.iap.models import iap diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 00ecb6d6118..2aa444223b1 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -500,7 +500,7 @@ class ProductTemplate(models.Model): done_moves = self.env['stock.move'].search([('product_id', 'in', updated.mapped('product_variant_ids').ids)], limit=1) if done_moves: raise UserError(_("You can not change the unit of measure of a product that has already been used in a done stock move. If you need to change the unit of measure, you may deactivate this product.")) - if 'type' in vals: + if any('type' in vals and vals['type'] != prod_tmpl.type for prod_tmpl in self): existing_move_lines = self.env['stock.move.line'].search([ ('product_id', 'in', self.mapped('product_variant_ids').ids), ('state', 'in', ['partially_available', 'assigned']), diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 7bfa320bb50..f37554cf583 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -514,25 +514,35 @@ class StockMove(models.Model): 'origin': origin, } + @api.model + def _prepare_merge_moves_distinct_fields(self): + return [ + 'product_id', 'price_unit', 'product_packaging', + 'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id' + ] + + @api.model + def _prepare_merge_move_sort_method(self, move): + move.ensure_one() + return [ + move.product_id.id, move.price_unit, move.product_packaging.id, move.product_uom.id, + move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id + ] + def _merge_moves(self): """ This method will, for each move in `self`, go up in their linked picking and try to find in their existing moves a candidate into which we can merge the move. :return: Recordset of moves passed to this method. If some of the passed moves were merged into another existing one, return this one and not the (now unlinked) original. """ - distinct_fields = ['product_id', 'price_unit', 'product_packaging', - 'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id'] - - def _keys_sorted(move): - return move.product_id.id, move.price_unit, move.product_packaging.id, move.product_uom.id,\ - move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id + distinct_fields = self._prepare_merge_moves_distinct_fields() # Move removed after merge moves_to_unlink = self.env['stock.move'] moves_to_merge = [] for picking in self.mapped('picking_id'): # First step find move to merge. - for k, g in groupby(sorted(picking.move_lines, key=_keys_sorted), key=itemgetter(*distinct_fields)): + for k, g in groupby(sorted(picking.move_lines, key=self._prepare_merge_move_sort_method), key=itemgetter(*distinct_fields)): moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft')) # If we have multiple records we will merge then in a single one. if len(moves) > 1: @@ -912,6 +922,8 @@ class StockMove(models.Model): if any(move.state == 'done' for move in self): raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'.')) for move in self: + if move.state == 'cancel': + continue move._do_unreserve() siblings_states = (move.move_dest_ids.mapped('move_orig_ids') - move).mapped('state') if move.propagate: diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 33faf25e3b7..7a67e07b1cb 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -788,11 +788,11 @@ class Picking(models.Model): if operations: package = self.env['stock.quant.package'].create({}) for operation in operations: - if float_compare(operation.qty_done, operation.product_qty, precision_rounding=operation.product_uom_id.rounding) >= 0: + if float_compare(operation.qty_done, operation.product_uom_qty, precision_rounding=operation.product_uom_id.rounding) >= 0: operation_ids |= operation else: quantity_left_todo = float_round( - operation.product_qty - operation.qty_done, + operation.product_uom_qty - operation.qty_done, precision_rounding=operation.product_uom_id.rounding, rounding_method='UP') new_operation = operation.copy( diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index 5d5631c4069..5ab89e8fca1 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -244,7 +244,7 @@ class StockMove(models.Model): self.ensure_one() if self._is_in(): if self.product_id.cost_method in ['fifo', 'average']: - price_unit = self.price_unit or self._get_price_unit() + price_unit = self._get_price_unit() value = price_unit * (quantity or self.product_qty) vals = { 'price_unit': price_unit, diff --git a/addons/stock_landed_costs/__manifest__.py b/addons/stock_landed_costs/__manifest__.py index 767c538c8bb..436a601b9bc 100644 --- a/addons/stock_landed_costs/__manifest__.py +++ b/addons/stock_landed_costs/__manifest__.py @@ -24,10 +24,10 @@ This module allows you to easily add extra costs on pickings and decide the spli 'views/res_config_settings_views.xml', ], 'test': [ - # '../account/test/account_minimal_test.xml', - # '../stock_account/test/stock_valuation_account.xml', - # 'test/stock_landed_costs.yml', - #'test/stock_landed_costs_rounding.yml', + '../account/test/account_minimal_test.xml', + '../stock_account/test/stock_valuation_account.xml', + 'test/stock_landed_costs.yml', + 'test/stock_landed_costs_rounding.yml', ], 'installable': True, 'auto_install': False, diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py index a95b9ac73c6..f61014caf0c 100644 --- a/addons/stock_landed_costs/models/stock_landed_cost.py +++ b/addons/stock_landed_costs/models/stock_landed_cost.py @@ -93,10 +93,21 @@ class LandedCost(models.Model): for line in cost.valuation_adjustment_lines.filtered(lambda line: line.move_id): # Prorate the value at what's still in stock cost_to_add = (line.move_id.remaining_qty / line.move_id.product_qty) * line.additional_landed_cost - line.move_id.landed_cost_value += cost_to_add - line.move_id.remaining_value += cost_to_add - line._create_accounting_entries(move, line.move_id.product_qty - line.move_id.remaining_qty) + new_landed_cost_value = line.move_id.landed_cost_value + line.additional_landed_cost + line.move_id.write({ + 'landed_cost_value': new_landed_cost_value, + 'remaining_value': line.move_id.remaining_value + cost_to_add, + 'price_unit': (line.move_id.value + new_landed_cost_value) / line.move_id.product_qty, + }) + # `remaining_qty` is negative if the move is out and delivered proudcts that were not + # in stock. + qty_out = 0 + if line.move_id._is_in(): + qty_out = line.move_id.product_qty - line.move_id.remaining_qty + elif line.move_id._is_out(): + qty_out = line.move_id.product_qty + line._create_accounting_entries(move, qty_out) move.assert_balanced() cost.write({'state': 'done', 'account_move_id': move.id}) diff --git a/addons/stock_landed_costs/test/stock_landed_costs.yml b/addons/stock_landed_costs/test/stock_landed_costs.yml index cecf5a23668..f98ca67a312 100644 --- a/addons/stock_landed_costs/test/stock_landed_costs.yml +++ b/addons/stock_landed_costs/test/stock_landed_costs.yml @@ -1,7 +1,7 @@ - In order to test the landed costs feature of stock, I create a landed cost, confirm it and check its account move created - - I create 2 products with different volume and gross weight and configure them for real_time valuation and real price costing method + I create 2 products with different volume and gross weight and configure them for real_time valuation and fifo costing method - !record {model: product.product, id: product_landed_cost_1}: name: "LC product 1" @@ -34,6 +34,15 @@ location_id: stock.stock_location_stock location_dest_id: stock.stock_location_customers - + Confirm and assign picking +- + !python {model: stock.picking, id: picking_landed_cost_1}: | + self.env.user.company_id.anglo_saxon_accounting = True + self.action_confirm() + self.action_assign() + self.move_lines.quantity_done = 5 + self.button_validate() +- !record {model: stock.picking, id: picking_landed_cost_2}: name: 'LC_pick_2' picking_type_id: stock.picking_type_out @@ -45,6 +54,14 @@ location_id: stock.stock_location_stock location_dest_id: stock.stock_location_customers - + Confirm and assign picking +- + !python {model: stock.picking, id: picking_landed_cost_2}: | + self.action_confirm() + self.action_assign() + self.move_lines.quantity_done = 10 + self.button_validate() +- I create a landed cost for those 2 pickings - !record {model: stock.landed.cost, id: stock_landed_cost_1}: @@ -73,7 +90,6 @@ - !python {model: stock.landed.cost, id: stock_landed_cost_1}: | self.compute_landed_cost() - - I check the valuation adjustment lines - @@ -106,4 +122,4 @@ !assert {model: stock.landed.cost, id: stock_landed_cost_1}: - state == 'done' - account_move_id - - len(account_move_id.line_ids) == 16 + - len(account_move_id.line_ids) == 48 diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 17ca031a48f..67db357ec05 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -18,13 +18,14 @@ import os import re import sys import time +import zlib + import werkzeug import werkzeug.utils import werkzeug.wrappers -import zlib +import werkzeug.wsgi +from werkzeug.urls import url_decode, iri_to_uri from xml.etree import ElementTree -from werkzeug import url_decode -from werkzeug import iri_to_uri import odoo @@ -33,11 +34,11 @@ from odoo.api import call_kw, Environment from odoo.modules import get_resource_path from odoo.tools import crop_image, topological_sort, html_escape, pycompat from odoo.tools.translate import _ -from odoo.tools.misc import str2bool, xlwt +from odoo.tools.misc import str2bool, xlwt, file_open from odoo.tools.safe_eval import safe_eval from odoo import http from odoo.http import content_disposition, dispatch_rpc, request, \ - serialize_exception as _serialize_exception + serialize_exception as _serialize_exception, Response from odoo.exceptions import AccessError, UserError from odoo.models import check_method_name @@ -513,21 +514,24 @@ class WebClient(http.Controller): @http.route('/web/webclient/locale/<string:lang>', type='http', auth="none") def load_locale(self, lang): magic_file_finding = [lang.replace("_", '-').lower(), lang.split('_')[0]] - addons_path = http.addons_manifest['web']['addons_path'] - # load momentjs locale - momentjs_locale = "" for code in magic_file_finding: try: - with open(os.path.join(addons_path, 'web', 'static', 'lib', 'moment', 'locale', code + '.js'), 'r') as f: - momentjs_locale = f.read() - # we found a locale matching so we can exit - break + return http.Response( + werkzeug.wsgi.wrap_file( + request.httprequest.environ, + file_open('web/static/lib/moment/locale/%s.js' % code, 'rb') + ), + content_type='application/javascript; charset=utf-8', + headers=[('Cache-Control', 'max-age=36000')], + direct_passthrough=True, + ) except IOError: - continue + _logger.debug("No moment locale for code %s", code) - # return the content of the locale - headers = [('Content-Type', 'application/javascript'), ('Cache-Control', 'max-age=%s' % (36000))] - return request.make_response(momentjs_locale, headers) + return request.make_response("", headers=[ + ('Content-Type', 'application/javascript'), + ('Cache-Control', 'max-age=36000'), + ]) @http.route('/web/webclient/qweb', type='http', auth="none", cors="*") def qweb(self, mods=None, db=None): diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js index d69574d2e10..0ee6d2ad1ed 100644 --- a/addons/web/static/src/js/core/ajax.js +++ b/addons/web/static/src/js/core/ajax.js @@ -212,6 +212,9 @@ var loadJS = (function () { * @param {Function} [options.success] callback in case of download success * @param {Function} [options.error] callback in case of request error, provided with the error body * @param {Function} [options.complete] called after both ``success`` and ``error`` callbacks have executed + * @returns {boolean} a false value means that a popup window was blocked. This + * mean that we probably need to inform the user that something needs to be + * changed to make it work. */ function get_file(options) { // need to detect when the file is done downloading (not used @@ -232,7 +235,12 @@ function get_file(options) { var url = options.session.url(options.url, params); if (options.complete) { options.complete(); } - return window.open(url); + var w = window.open(url); + if (!w || w.closed || typeof w.closed === 'undefined') { + // popup was blocked + return false; + } + return true; } var $form, $form_data = $('<div>'); @@ -309,6 +317,7 @@ function get_file(options) { } }; timer = setTimeout(waitLoop, CHECK_INTERVAL); + return true; }; function post (controller_url, data) { diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js index 64ebeeb1794..983b26d3606 100644 --- a/addons/web/static/src/js/core/misc.js +++ b/addons/web/static/src/js/core/misc.js @@ -173,6 +173,23 @@ function ReloadContext (parent, action) { } core.action_registry.add("reload_context", ReloadContext); +// In Internet Explorer, document doesn't have the contains method, so we make a +// polyfill for the method in order to be compatible. +if (!document.contains) { + document.contains = function contains (node) { + if (!(0 in arguments)) { + throw new TypeError('1 argument is required'); + } + + do { + if (this === node) { + return true; + } + } while (node = node && node.parentNode); + + return false; + }; +} return { blockUI: blockUI, diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js index 0ecea92fb71..c3181a7abe6 100644 --- a/addons/web/static/src/js/core/session.js +++ b/addons/web/static/src/js/core/session.js @@ -278,7 +278,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { options.data.session_id = this.session_id; } options.session = this; - ajax.get_file(options); + return ajax.get_file(options); }, /** * (re)loads the content of a session: db name, username, user id, session diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 6392870dc90..8eae8f5b8f1 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -394,7 +394,7 @@ var LinkButton = AbstractField.extend({ var FieldDate = InputField.extend({ className: "o_field_date", tagName: "span", - supportedFieldTypes: ['date'], + supportedFieldTypes: ['date', 'datetime'], /** * @override @@ -453,12 +453,13 @@ var FieldDate = InputField.extend({ * @override * @private * @param {Moment|false} value + * @returns {boolean} */ _isSameValue: function (value) { if (value === false) { return this.value === false; } - return value.isSame(this.value); + return value.isSame(this.value, 'day'); }, /** * Instantiates a new DateWidget datepicker. @@ -497,7 +498,16 @@ var FieldDateTime = FieldDate.extend({ var value = this.datewidget.getValue(); return value && value.add(-this.getSession().getTZOffset(value), 'minutes'); }, - + /** + * @override + * @private + */ + _isSameValue: function (value) { + if (value === false) { + return this.value === false; + } + return value.isSame(this.value); + }, /** * Instantiates a new DateTimeWidget datepicker rather than DateWidget. * @@ -966,7 +976,7 @@ var FieldPhone = FieldEmail.extend({ * @override */ getFocusableElement: function () { - if (this._canCall()) { + if (this.mode !== 'readonly' || this._canCall()) { return this._super.apply(this, arguments); } return $(); @@ -1171,7 +1181,6 @@ var FieldBinaryImage = AbstractFieldBinary.extend({ supportedFieldTypes: ['binary'], _render: function () { var self = this; - var attrs = this.attrs; var url = this.placeholder; if (this.value) { if (!utils.is_bin_size(this.value)) { @@ -1186,11 +1195,7 @@ var FieldBinaryImage = AbstractFieldBinary.extend({ }); } } - var $img = $('<img>').attr('src', url); - $img.css({ - width: this.nodeOptions.size ? this.nodeOptions.size[0] : attrs.img_width || attrs.width, - height: this.nodeOptions.size ? this.nodeOptions.size[1] : attrs.img_height || attrs.height, - }); + var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url})); this.$('> img').remove(); this.$el.prepend($img); $img.on('error', function () { @@ -2175,6 +2180,15 @@ var FieldDomain = AbstractField.extend({ //-------------------------------------------------------------------------- /** + * A domain field is always set since the false value is considered to be + * equal to "[]" (match all records). + * + * @override + */ + isSet: function () { + return true; + }, + /** * @override isValid from AbstractField.isValid * Parsing the char value is not enough for this field. It is considered * valid if the internal domain selector was built correctly and that the diff --git a/addons/web/static/src/js/report/qwebactionmanager.js b/addons/web/static/src/js/report/qwebactionmanager.js index 3c8fee859e3..cc875882ce2 100644 --- a/addons/web/static/src/js/report/qwebactionmanager.js +++ b/addons/web/static/src/js/report/qwebactionmanager.js @@ -21,7 +21,7 @@ var WKHTMLTOPDF_MESSAGES = { }; var trigger_download = function (session, response, c, action, options) { - session.get_file({ + return session.get_file({ url: '/report/download', data: {data: JSON.stringify(response)}, complete: framework.unblockUI, @@ -107,7 +107,10 @@ ActionManager.include({ report_urls['qweb-pdf'], action.report_type, //The 'root' report is considered the maine one, so we use its type for all the others. ]; - trigger_download(self.getSession(), response, c, current_action, options); + var success = trigger_download(self.getSession(), response, c, current_action, options); + if (!success) { + self.do_warn(_t('Warning'), _t('A popup window with your report was blocked. You may need to change your browser settings to allow popup windows for this page.'), true); + } treated_actions.push(current_action); current_action = current_action.next_report_to_generate; diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 6884442039e..1df118ecfd9 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -875,7 +875,10 @@ var BasicModel = AbstractModel.extend({ // id never changes, and should not be written delete record._changes.id; } - var changes = self._generateChanges(record, {viewType: options.viewType}); + var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'}); + + // id field should never be written/changed + delete changes.id; if (method === 'create') { var fieldNames = record.getFieldNames(); @@ -1551,6 +1554,9 @@ var BasicModel = AbstractModel.extend({ removedDef = this._applyX2ManyChange(record, fieldName, { operation: 'DELETE', ids: _.map(removedIds, function (resID) { + if (resID in list._cache) { + return list._cache[resID]; + } return _.findWhere(listData, {res_id: resID}).id; }), }); @@ -2455,7 +2461,7 @@ var BasicModel = AbstractModel.extend({ changes = _.extend({}, record.data, record._changes); } else { changes = _.extend({}, record._changes); - }; + } var withReadonly = options.withReadonly || false; var commands = this._generateX2ManyCommands(record, { changesOnly: 'changesOnly' in options ? options.changesOnly : true, @@ -2492,6 +2498,7 @@ var BasicModel = AbstractModel.extend({ changes[fieldName] = false; } } + return changes; }, /** @@ -2608,6 +2615,7 @@ var BasicModel = AbstractModel.extend({ _.each(relRecordUpdated, function (relRecord) { var changes = self._generateChanges(relRecord, options); if (!_.isEmpty(changes)) { + delete changes.id; var command = x2ManyCommands.update(relRecord.res_id, changes); commands[fieldName].push(command); } @@ -2638,7 +2646,12 @@ var BasicModel = AbstractModel.extend({ // this is a new id relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); changes = this._generateChanges(relRecord, options); - commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + if ('id' in changes) { + delete changes.id; + commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes)); + } else { + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + } } } if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { @@ -3294,11 +3307,17 @@ var BasicModel = AbstractModel.extend({ * This method is quite important: it is supposed to perform the /onchange * rpc and apply the result. * + * The changes that triggered the onchange are assumed to have already been + * applied to the record. + * * @param {Object} record * @param {string[]} fields changed fields * @param {string} [viewType] current viewType. If not set, we will assume * main viewType from the record - * @returns {Deferred} + * @returns {Deferred} Note: this deferred cannot fail. It is either in the + * 'success' or 'pending' state. If the onchange rpc fails, it will be + * resolved with an empty dictionary. Note that the initial change was + * already applied. */ _performOnChange: function (record, fields, viewType) { var self = this; @@ -3318,7 +3337,9 @@ var BasicModel = AbstractModel.extend({ var context = this._getContext(record, options); var currentData = this._generateOnChangeData(record, {changesOnly: false}); - return self._rpc({ + var def = $.Deferred(); + + self._rpc({ model: record.model, method: 'onchange', args: [idList, currentData, fields, onchangeSpec, context], @@ -3343,9 +3364,12 @@ var BasicModel = AbstractModel.extend({ record._domains = _.extend(record._domains, result.domain); } return self._applyOnChange(result.value, record).then(function () { - return result; + def.resolve(result); }); + }).fail(function () { + def.resolve({}); }); + return def; }, /** * Once a record is created and some data has been fetched, we need to do 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 4168d901065..08d03c037f0 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -257,13 +257,20 @@ var CalendarController = AbstractController.extend({ id = id && parseInt(id).toString() === id ? parseInt(id) : id; if (!this.eventOpenPopup) { - this.do_action({ - type: 'ir.actions.act_window', - res_id: id, - res_model: this.modelName, - views: [[this.formViewId || false, 'form']], - target: 'current', - context: event.context || self.context, + this._rpc({ + model: self.modelName, + method: 'get_formview_id', + //The event can be called by a view that can have another context than the default one. + args: [[id], event.context || self.context], + }).then(function (viewId) { + self.do_action({ + type:'ir.actions.act_window', + res_id: id, + res_model: self.modelName, + views: [[viewId || false, 'form']], + target: 'current', + context: event.context || self.context, + }); }); return; } diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js index b9711d1d392..0ddf2f0a129 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -807,7 +807,15 @@ var FormRenderer = BasicRenderer.extend({ // Attach the tooltips on the fields' label _.each(this.allFieldWidgets[this.state.id], function (widget) { var idForLabel = self.idsForLabels[widget.name]; + // We usually don't support multiple widgets for the same field on the + // same view but it is the case with the new settings view on V11.0. + // Therefore, we need to retrieve the correct label since it could be + // displayed multiple times on the view, otherwise, for example the + // enterprise label will be displayed as many times as the field + // exists on settings. + var $widgets = self.$('.o_field_widget[name=' + widget.name + ']'); var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); + $label = $label.eq($widgets.index(widget.$el)); if (config.debug || widget.attrs.help || widget.field.help) { self._addFieldTooltip(widget, $label); } diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 9027fc3c984..b0ddbdcd385 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -136,7 +136,9 @@ ListRenderer.include({ currentRowID = this.state.data[this.currentRow].id; currentWidget = this.allFieldWidgets[currentRowID][this.currentFieldIndex]; focusedElement = currentWidget.getFocusableElement().get(0); - selectionRange = dom.getSelectionRange(focusedElement); + if (currentWidget.formatType !== 'boolean') { + selectionRange = dom.getSelectionRange(focusedElement); + } } var oldData = this.state.data; @@ -179,7 +181,9 @@ ListRenderer.include({ currentRowID = self.state.data[newRowIndex].id; currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex]; focusedElement = currentWidget.getFocusableElement().get(0); - dom.setSelectionRange(focusedElement, selectionRange); + if (selectionRange) { + dom.setSelectionRange(focusedElement, selectionRange); + } }); } }); @@ -585,12 +589,18 @@ ListRenderer.include({ if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) { return $.Deferred().reject(); } + // _activateFieldWidget might trigger an onchange, + // which requires currentFieldIndex to be set + // so that the cursor can be restored + var oldFieldIndex = self.currentFieldIndex; + self.currentFieldIndex = fieldIndex; fieldIndex = self._activateFieldWidget(record, fieldIndex, { inc: 1, wrap: wrap, event: options && options.event, }); if (fieldIndex < 0) { + self.currentFieldIndex = oldFieldIndex; return $.Deferred().reject(); } self.currentFieldIndex = fieldIndex; diff --git a/addons/web/static/src/less/modal.less b/addons/web/static/src/less/modal.less index 1d95ddc219b..95a14420773 100644 --- a/addons/web/static/src/less/modal.less +++ b/addons/web/static/src/less/modal.less @@ -52,6 +52,8 @@ .modal-body { overflow: auto; + // fix iOS issue https://github.com/scottjehl/Device-Bugs/issues/8 + -webkit-transform: translate3d(0, 0, 0); min-height: 0; &.o_dialog_error { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index e1f955370d3..7da3f448378 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -335,12 +335,12 @@ <t t-if="item.create_uid and item.create_uid[0]" t-set="item.title"> <t t-raw="item.title"/><br/> <b>Created by : </b><br/> - <t t-raw="item.create_uid[1] + ' ' + item.create_date"/> + <t t-raw="item.create_uid[1]"/> <t t-esc="item.create_date_string" /> </t> <t t-if="item.create_uid and item.write_uid and item.create_uid[0] != item.write_uid[0]" t-set="item.title"> <t t-raw="item.title"/><br/> <b>Modified by : </b><br/> - <t t-raw="item.write_uid[1] + ' ' + item.write_date"/> + <t t-raw="item.write_uid[1]"/> <t t-esc="item.write_date_string"/> </t> </t> <a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url"> @@ -861,8 +861,8 @@ t-att-src='url' t-att-border="widget.readonly ? 0 : 1" t-att-name="widget.name" - t-att-width="widget.node.attrs.img_width || widget.node.attrs.width" - t-att-height="widget.node.attrs.img_height || widget.node.attrs.height"/> + t-att-width="widget.nodeOptions.size ? widget.nodeOptions.size[0] : widget.attrs.img_width || widget.attrs.width" + t-att-height="widget.nodeOptions.size ? widget.nodeOptions.size[1] : widget.attrs.img_height || widget.attrs.height"/> </t> <t t-name="FieldBinaryFile"> <a t-if="widget.mode === 'readonly'" href="javascript:void(0)" class="o_form_uri"/> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index f01f2d8b589..1e623e54d9a 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -1462,6 +1462,42 @@ QUnit.module('basic_fields', { } }); + QUnit.module('FieldImage'); + + QUnit.test('image fields are correctly rendered', function (assert) { + assert.expect(5); + + this.data.partner.records[0].document = 'myimage'; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="document" widget="image" options="{\'size\': [90, 90]}"/> ' + + '</form>', + res_id: 1, + mockRPC: function (route) { + if (route === 'data:image/png;base64,myimage') { + assert.ok(true, "should called the correct route"); + return $.when('wow'); + } + return this._super.apply(this, arguments); + }, + }); + + assert.ok(form.$('div[name="document"]').hasClass('o_field_image'), + "the widget should have the correct class"); + assert.strictEqual(form.$('div[name="document"] > img').length, 1, + "the widget should contain an image"); + assert.ok(form.$('div[name="document"] > img').hasClass('img-responsive'), + "the image should have the correct class"); + assert.strictEqual(form.$('div[name="document"] > img').attr('width'), "90", + "the image should correctly set its attributes"); + form.destroy(); + + }); + QUnit.module('JournalDashboardGraph'); QUnit.test('graph dashboard widget is rendered correctly', function (assert) { @@ -1844,6 +1880,39 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('do not trigger a field_changed for datetime field with date widget', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="datetime" widget="date"/></form>', + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M:%S', + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + assert.step(args.method); + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('.o_datepicker_input').val(), '02/08/2017', + 'the date should be correct'); + + form.$('input[name="datetime"]').val('02/08/2017').trigger('input').trigger('change'); + form.$buttons.find('.o_form_button_save').click(); + + assert.verifySteps(['read']); // should not have save as nothing changed + + form.destroy(); + }); + QUnit.module('FieldDatetime'); QUnit.test('datetime field in form view', function (assert) { @@ -2828,6 +2897,33 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('use TAB to navigate to a phone field', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="display_name"/>' + + '<field name="foo" widget="phone"/>' + + '</group>' + + '</sheet>' + + '</form>', + }); + + form.$('input[name=display_name]').click(); + assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement, + "display_name should be focused"); + form.$('input[name="display_name"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB})); + assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, + "foo should be focused"); + + form.destroy(); + }); + QUnit.module('PriorityWidget'); QUnit.test('priority widget when not set', function (assert) { @@ -3939,7 +4035,7 @@ QUnit.module('basic_fields', { }); QUnit.test('domain field: handle false domain as []', function (assert) { - assert.expect(2); + assert.expect(3); this.data.partner.records[0].foo = false; this.data.partner.fields.bar.type = "char"; @@ -3965,11 +4061,13 @@ QUnit.module('basic_fields', { return this._super.apply(this, arguments); }, res_id: 1, - viewOptions: { - mode: 'edit', - }, }); + assert.strictEqual(form.$('.o_field_widget[name=foo]:not(.o_field_empty)').length, 1, + "there should be a domain field, not considered empty"); + + form.$buttons.find('.o_form_button_edit').click(); + var $warning = form.$('.o_field_widget[name=foo] .text-warning'); assert.strictEqual($warning.length, 0, "should not display that the domain is invalid"); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 641546b3fc1..2d328f0b7ff 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1064,14 +1064,14 @@ QUnit.module('relational_fields', { }); QUnit.test('list in form: default_get with x2many create and onchange', function (assert) { - assert.expect(1); + assert.expect(2); this.data.partner.onchanges.turtles = function (obj) { assert.deepEqual( obj.turtles, [ - [0, 2, {turtle_foo: 'blip', id: 2}], - [0, 3, {turtle_foo: 'kawa', id: 3}] + [1, 2, {turtle_foo: 'blip'}], + [1, 3, {turtle_foo: 'kawa'}] ], "should have properly created the x2many command list"); }; @@ -1094,10 +1094,21 @@ QUnit.module('relational_fields', { if (args.method === 'default_get') { return $.when({turtles: [[6, 0, [2,3]]]}); } + if (args.method === 'create') { + // it would be even better if we did not send the current + // unchanged state with the command 1, but this seems more + // difficult. + assert.deepEqual(args.args[0].turtles, [ + [1, 2, {turtle_foo: 'blip'}], + [1, 3, {turtle_foo: 'kawa'}] + ], 'should send proper commands to create method'); + } return this._super.apply(this, arguments); }, }); + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); }); @@ -6248,6 +6259,35 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('checkbox in an x2many that triggers an onchange', function (assert) { + assert.expect(1); + + this.data.partner.onchanges = { + bar: function () {} + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="bar"/>' + + '</tree>' + + '</field>' + + '</form>', + }); + + form.$('.o_field_x2many_list_row_add a').click(); + + form.$('.o_field_widget[name=bar] input').click(); + assert.notOk(form.$('.o_field_widget[name=bar] input').prop('checked'), + "the checkbox should be unticked"); + + form.destroy(); + }); + QUnit.test('one2many with default value: edit line to make it invalid', function (assert) { assert.expect(3); @@ -6284,6 +6324,66 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('display correct value after validation error', function (assert) { + assert.expect(4); + + this.data.partner.onchanges.turtles = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'onchange') { + // we simulate a validation error. In the 'real' web client, + // the server error will be used by the session to display + // an error dialog. From the point of view of the basic + // model, the deferred is just rejected. + return $.Deferred().reject(); + } + if (args.method === 'write') { + assert.deepEqual(args.args[1].turtles[0], [1,2,{turtle_foo: 'foo'}], + 'should send the "bad" value'); + // we simulate a validation error + return $.Deferred().reject(); + } + return this._super.apply(this, arguments); + }, + viewOptions: {mode: 'edit'}, + res_id: 1, + }); + + assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'blip', + "initial text should be correct"); + form.$('.o_data_row .o_data_cell:nth(0)').click(); + form.$('.o_field_widget[name=turtle_foo]').val('foo').trigger('input'); + + // we try to validate the line. This triggers an onchange which will be + // rejected. The line will be returned to readonly mode, but with the + // new invalid value. + form.$el.click(); + + assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'foo', + "turtle_foo text should now be foo (invalid value)"); + + // we make sure here that when we save, the values are the current + // values displayed in the field. + form.$buttons.find('.o_form_button_save').click(); + assert.strictEqual(form.mode, 'edit', "form view should still be in edit mode"); + + form.destroy(); + }); + + QUnit.module('FieldMany2Many'); QUnit.test('many2many kanban: edition', function (assert) { @@ -7128,6 +7228,42 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('default_get, onchange, onchange on m2m', function (assert) { + assert.expect(1); + + this.data.partner.onchanges.int_field = function (obj) { + if (obj.int_field === 2) { + assert.deepEqual(obj.timmy, [ + [6, false, [12]], + [1, 12, {display_name: 'gold'}] + ]); + } + obj.timmy = [ + [5], + [1, 12, {display_name: 'gold'}] + ]; + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="timmy">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '</field>' + + '<field name="int_field"/>' + + '</sheet>' + + '</form>', + }); + + form.$('.o_field_widget[name=int_field]').val(2).trigger('input'); + form.destroy(); + }); + QUnit.module('FieldStatus'); QUnit.test('static statusbar widget on many2one field', function (assert) { @@ -8288,6 +8424,32 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('widget many2many_checkboxes: start non empty, then remove twice', function (assert) { + assert.expect(2); + + this.data.partner.records[0].timmy = [12,14]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<group><field name="timmy" widget="many2many_checkboxes"/></group>' + + '</form>', + res_id: 1, + viewOptions: {mode: 'edit'}, + }); + + form.$('div.o_field_widget div.o_checkbox input').eq(0).click(); + form.$('div.o_field_widget div.o_checkbox input').eq(1).click(); + form.$buttons.find('.o_form_button_save').click(); + assert.notOk(form.$('div.o_field_widget div.o_checkbox input').eq(0).prop('checked'), + "first checkbox should not be checked"); + assert.notOk(form.$('div.o_field_widget div.o_checkbox input').eq(1).prop('checked'), + "second checkbox should not be checked"); + + form.destroy(); + }); + QUnit.module('FieldMany2ManyBinaryMultiFiles'); QUnit.test('widget many2many_binary', function (assert) { diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index 8cb30291f24..dfcbdba5985 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -110,6 +110,14 @@ var MockServer = Class.extend({ console.log('%c[rpc] response' + route, 'color: blue; font-weight: bold;', JSON.parse(resultString)); } return JSON.parse(resultString); + }).fail(function (result) { + var errorString = JSON.stringify(result || false); + if (logLevel === 1) { + console.log('Mock: (ERROR)' + route, JSON.parse(errorString)); + } else if (logLevel === 2) { + console.log('%c[rpc] response (error) ' + route, 'color: orange; font-weight: bold;', JSON.parse(errorString)); + } + return JSON.parse(errorString); }); }, diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index ecf02be9c4a..ed9e416a302 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -975,6 +975,12 @@ QUnit.module('Views', { viewOptions: { initialDate: initialDate, }, + mockRPC: function (route, args) { + if (args.method === "get_formview_id") { + return $.Deferred().resolve('A view'); + } + return this._super(route, args); + }, }); // click on an existing event to open the form view @@ -985,7 +991,7 @@ QUnit.module('Views', { type: "ir.actions.act_window", res_id: 4, res_model: "event", - views: [[false, "form"]], + views: [['A view', "form"]], target: "current", context: {} }, @@ -1103,6 +1109,12 @@ QUnit.module('Views', { viewOptions: { initialDate: initialDate, }, + mockRPC: function (route, args) { + if (args.method === "get_formview_id") { + return $.when(false); + } + return this._super(route, args); + }, }); assert.strictEqual(calendar.$('.fc-resizer').length, 0, "should not have resize button"); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index ac45b6de256..989c67e78dd 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -2649,6 +2649,44 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('default_get, onchange which fails, should still work', function (assert) { + assert.expect(1); + + this.data.partner.onchanges.foo = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="foo"/>' + + '</form>', + mockRPC: function (route, args) { + if (args.method === 'onchange') { + // we simulate a validation error. In the 'real' web client, + // the server error will be used by the session to display + // an error dialog. From the point of view of the basic + // model, the deferred is just rejected. + return $.Deferred().reject(); + } + return this._super.apply(this, arguments); + }, + }); + + // this test checks that a form view is still rendered when the server + // onchange fails (for example, because of a validation error, or, more + // likely, a bug in the onchange code). This is quite rare, but if the + // onchange fails, we still want to display the form view (with the error + // dialog from the session/crashmanager). Otherwise, we could be in the + // situation where a user clicks on a button, it should open a wizard, + // but something fails and the wizard is not even rendered. In that + // case, there is nothing that the user could do. + assert.strictEqual(form.$('.o_field_widget[name="foo"]').val(), 'My little Foo Value', + "should display proper default value"); + + form.destroy(); + }); + QUnit.test('attrs are properly transmitted to new records', function (assert) { assert.expect(2); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index cdc29635cd1..9b222f8563d 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -252,7 +252,7 @@ <head> <meta charset="utf-8"/> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/> - <meta name="viewport" content="width=device-width, initial-scale=1"/> + <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/> <title t-esc="title or 'Odoo'"/> <link type="image/x-icon" rel="shortcut icon" t-att-href="x_icon or '/web/static/src/img/favicon.ico'"/> 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 <p/> element in an unique <p/> element, only paste + // the <p/> 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/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js index 2486fea9b81..bf83e5b8dec 100644 --- a/addons/web_editor/static/src/js/backend/fields.js +++ b/addons/web_editor/static/src/js/backend/fields.js @@ -399,7 +399,7 @@ var FieldTextHtml = AbstractField.extend({ var layoutInfo = this.editor.rte.editable().data('layoutInfo'); $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); } - this.editor.snippetsMenu.cleanForSave(); + this.editor.snippetsMenu && this.editor.snippetsMenu.cleanForSave(); this._setValue(this.$content.html()); return this._super.apply(this, arguments); }, diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js index f97a97b8b46..4d2f6fe977a 100644 --- a/addons/web_editor/static/src/js/editor/rte.js +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -654,6 +654,28 @@ var RTEWidget = Widget.extend({ // whitespace characters (so Tripleclicking then typing text will remove // the whole paragraph instead of its content). // http://stackoverflow.com/questions/38467334/why-does-google-chrome-always-add-space-after-selected-text + if ($.browser.chrome === true && event.originalEvent.detail === 3) { + var currentSelection = range.create(); + if (currentSelection.sc.parentNode === currentSelection.ec) { + _selectSC(currentSelection); + } else if (currentSelection.eo === 0) { + var $hasNext = $(currentSelection.sc).parent(); + while (!$hasNext.next().length && !$hasNext.is('body')) { + $hasNext = $hasNext.parent(); + } + if ($hasNext.next()[0] === currentSelection.ec) { + _selectSC(currentSelection); + } + } + } + function _selectSC(selection) { + range.create(selection.sc, selection.so, selection.sc, selection.sc.length).select(); + } + + // To Fix Google Chrome Tripleclick Issue, which selects the ending + // whitespace characters (so Tripleclicking then typing text will remove + // the whole paragraph instead of its content). + // http://stackoverflow.com/questions/38467334/why-does-google-chrome-always-add-space-after-selected-text if ($.browser.chrome === true && ev.originalEvent.detail === 3) { var currentSelection = range.create(); if (currentSelection.sc.parentNode === currentSelection.ec) { diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js index 141fb6ea229..7f82df2cb28 100644 --- a/addons/web_editor/static/src/js/editor/rte.summernote.js +++ b/addons/web_editor/static/src/js/editor/rte.summernote.js @@ -446,7 +446,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 span".toUpperCase().indexOf($node[0].nodeName) === -1) { + "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span label".toUpperCase().indexOf($node[0].nodeName) === -1) { var p = $('<p><br/></p>')[0]; $node.append( p ); range.createFromNode(p.firstChild).select(); @@ -761,7 +761,7 @@ eventHandler.attach = function (oLayoutInfo, options) { * Open Link Dialog on double click on a link/button. * Shows a tooltip on click to say to the user he can double click. */ - create_dblclick_feature("a[href], .btn", function () { + create_dblclick_feature("a[href], a.btn, button.btn", function () { eventHandler.modules.linkDialog.show(oLayoutInfo); }); diff --git a/addons/web_editor/static/src/less/web_editor.common.less b/addons/web_editor/static/src/less/web_editor.common.less index 57c06c6f513..cfa9f683198 100644 --- a/addons/web_editor/static/src/less/web_editor.common.less +++ b/addons/web_editor/static/src/less/web_editor.common.less @@ -23,7 +23,9 @@ html, body { /* ----- GENERIC LAYOUTING HELPERS ---- */ /* table */ #wrapwrap, .o_editable { - table.table { + // Only style editor-made tables (shop/portal/... tables are not supposed to + // use table-bordered...) + table.table.table-bordered { table-layout: fixed; td { min-width: 20px; diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index 433fc5e297a..c737f064e1a 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -105,11 +105,12 @@ class Http(models.AbstractModel): req_page = request.httprequest.path domain = [('url', '=', req_page), '|', ('website_ids', 'in', request.website.id), ('website_ids', '=', False)] + pages = request.env['website.page'].search(domain) - if not request.website.is_publisher: - domain += [('is_visible', '=', True)] + if not request.website.is_publisher(): + pages = pages.filtered('is_visible') - mypage = request.env['website.page'].search(domain, limit=1) + mypage = pages[0] if pages else False _, ext = os.path.splitext(req_page) if mypage: return request.render(mypage.view_id.id, { diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js index cf837b9df7c..7104a323040 100644 --- a/addons/website/static/src/js/content/website_root.js +++ b/addons/website/static/src/js/content/website_root.js @@ -39,6 +39,7 @@ var WebsiteRoot = BodyManager.extend({ 'click .js_change_lang': '_onLangChangeClick', 'click .js_publish_management .js_publish_btn': '_onPublishBtnClick', 'submit .js_website_submit_form': '_onWebsiteFormSubmit', + 'click .js_disable_on_click': '_onDisableOnClick', }), custom_events: _.extend({}, BodyManager.prototype.custom_events || {}, { animation_start_demand: '_onAnimationStartDemand', @@ -224,6 +225,16 @@ var WebsiteRoot = BodyManager.extend({ $btn.button('loading'); }); }, + /** + * Called when the root is notified that the button should be + * disabled after the first click. + * + * @private + * @param {Event} ev + */ + _onDisableOnClick: function (ev) { + $(ev.currentTarget).addClass('disabled'); + }, }); return { diff --git a/addons/website/static/src/less/website.backendDashboard.less b/addons/website/static/src/less/website.backendDashboard.less index 21d90c81d24..f920418e9fc 100644 --- a/addons/website/static/src/less/website.backendDashboard.less +++ b/addons/website/static/src/less/website.backendDashboard.less @@ -15,13 +15,12 @@ h2, h4 { text-transform: uppercase; margin-top: 0; + color: @odoo-brand-primary; } h4 { margin: 0 0 0 8px; } - h2, h4 { color: @odoo-brand-primary; } - - .col-md-5, .col-md-7 { + .col-md-5, .col-md-7, .col-md-12 { padding: 15px; .js_field_selection { diff --git a/addons/website/views/res_config_settings_views.xml b/addons/website/views/res_config_settings_views.xml index 624c65dd04f..8ef61e3db6c 100644 --- a/addons/website/views/res_config_settings_views.xml +++ b/addons/website/views/res_config_settings_views.xml @@ -128,7 +128,7 @@ </div> <div class="col-xs-12 col-md-6 o_setting_box"> <div class="o_setting_left_pane"> - <field name="module_website_version"/> + <field name="module_website_version" widget="upgrade_boolean"/> </div> <div class="o_setting_right_pane"> <label for="module_website_version"/> diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index bb097eb0ef9..38fde186043 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -233,6 +233,10 @@ </xpath> </template> +<record id="portal.portal_show_sign_in" model="ir.ui.view"> + <field name="customize_show" eval="True"/> +</record> + <!-- Features template --> <template id="login_layout" inherit_id="web.login_layout" name="Website Login Layout"> <xpath expr="t" position="replace"> @@ -574,7 +578,7 @@ <div class="container"> <div class="well mt32"> <p>This page does not exists, but you can create it as you are administrator of this site.</p> - <a class="btn btn-primary" t-attf-href="/website/add/#{ path }#{ from_template and '?template=%s' % from_template }">Create Page</a> + <a class="btn btn-primary js_disable_on_click" t-attf-href="/website/add/#{ path }#{ from_template and '?template=%s' % from_template }">Create Page</a> </div> <div class="text-center text-muted">Edit the content below this line to adapt the default "page not found" page.</div> </div> diff --git a/addons/website_forum/controllers/main.py b/addons/website_forum/controllers/main.py index 5f315491074..94043cdb0b0 100644 --- a/addons/website_forum/controllers/main.py +++ b/addons/website_forum/controllers/main.py @@ -332,7 +332,7 @@ class WebsiteForum(http.Controller): if post_type == 'question' and not post.get('post_name', ''): return request.render('website.http_error', {'status_code': _('Bad Request'), 'status_message': _('Title should not be empty.')}) if post.get('content', '') == '<p><br></p>': - return werkzeug.utils.redirect("/forum/%s/question/%s?nocontent=1" % (slug(forum), post_parent and slug(post_parent))) + return request.render('website.http_error', {'status_code': _('Bad Request'), 'status_message': _('Question should not be empty.')}) post_tag_ids = forum._tag_to_write_vals(post.get('post_tags', '')) diff --git a/addons/website_forum/i18n/website_forum.pot b/addons/website_forum/i18n/website_forum.pot index ba9d4eef535..99a8e934959 100644 --- a/addons/website_forum/i18n/website_forum.pot +++ b/addons/website_forum/i18n/website_forum.pot @@ -2450,6 +2450,12 @@ msgid "Title must not be empty" msgstr "" #. module: website_forum +#: code:addons/website_forum/controllers/main.py:325 +#, python-format +msgid "Question should not be empty." +msgstr "" + +#. module: website_forum #: code:addons/website_forum/controllers/main.py:324 #: code:addons/website_forum/controllers/main.py:395 #, python-format diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py index e8349e0cb9e..b9da18ffee3 100644 --- a/addons/website_forum/models/forum.py +++ b/addons/website_forum/models/forum.py @@ -12,7 +12,7 @@ from werkzeug.exceptions import Forbidden from odoo import api, fields, models, modules, tools, SUPERUSER_ID, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import pycompat +from odoo.tools import pycompat, misc _logger = logging.getLogger(__name__) @@ -38,10 +38,8 @@ class Forum(models.Model): @api.model def _get_default_faq(self): - fname = modules.get_module_resource('website_forum', 'data', 'forum_default_faq.html') - with open(fname, 'r') as f: + with misc.file_open('website_forum/data/forum_default_faq.html', 'r') as f: return f.read() - return False # description and use name = fields.Char('Forum Name', required=True, translate=True) diff --git a/addons/website_forum/views/forum.xml b/addons/website_forum/views/forum.xml index b78560c01b9..490c9f9f9bb 100644 --- a/addons/website_forum/views/forum.xml +++ b/addons/website_forum/views/forum.xml @@ -69,6 +69,7 @@ <group> <group> <field name="karma_ask"/> + <field name="karma_answer"/> <field name="karma_upvote"/> <field name="karma_downvote"/> <field name="karma_edit_own"/> diff --git a/addons/website_quote/models/__init__.py b/addons/website_quote/models/__init__.py index 08bb5a18917..43d640bca5f 100644 --- a/addons/website_quote/models/__init__.py +++ b/addons/website_quote/models/__init__.py @@ -4,4 +4,5 @@ from .import sale_order from .import sale_quote from .import product_template +from . import ir_model_fields from .import res_config_settings diff --git a/addons/website_quote/models/ir_model_fields.py b/addons/website_quote/models/ir_model_fields.py new file mode 100644 index 00000000000..55643edfe0d --- /dev/null +++ b/addons/website_quote/models/ir_model_fields.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + +# !! Attention, this is a hack of a hack, do not try this at home !! +# This is done because website_quote defines the website_description +# field which is also defined by website_sale, but these two modules +# are independent of each other, the part of the ORM that generates +# xmlids does not support this case therefore it only creates a single +# pair of xmlids for whichever module is installed first, therefore +# when uninstalling any of the two modules the website_descripion field +# will be deleted from the db and the other module won't be able to +# use this field, resulting in a crash and data-loss. +# See opw-776464 for more details. +# +# This is hotfixed by overriding the unlink method so as to not delete +# the website_description field if website_quote is uninstalled, it's +# an ugly hack but it works and big changes can't be done to the ORM +# in stable versions, Odoo v12+ will properly fix this + + +class IrModelFields(models.Model): + _inherit = "ir.model.fields" + + @api.multi + def unlink(self): + # Prevent the deletion of the field "website_description" + self = self.filtered( + lambda rec: not ( + rec.model in ('product.product', 'product.template') and + rec.name == 'website_description' + ) + ) + return super(IrModelFields, self).unlink() diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index e23da2bcf89..9180e67d061 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -400,9 +400,10 @@ class WebsiteSale(http.Controller): return {} value = order._cart_update(product_id=product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty) + if not order.cart_quantity: request.website.sale_reset() - return {} + return value order = request.website.sale_get_order() value['cart_quantity'] = order.cart_quantity @@ -503,6 +504,8 @@ class WebsiteSale(http.Controller): # vat validation Partner = request.env['res.partner'] if data.get("vat") and hasattr(Partner, "check_vat"): + if data.get("country_id"): + data["vat"] = Partner.fix_eu_vat_number(data.get("country_id"), data.get("vat")) partner_dummy = Partner.new({ 'vat': data['vat'], 'country_id': (int(data['country_id']) diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py index 1f9c38d727f..8cc73414b40 100644 --- a/addons/website_sale/models/__init__.py +++ b/addons/website_sale/models/__init__.py @@ -8,5 +8,6 @@ from . import product from . import res_country from . import res_partner from . import sale_order +from . import ir_model_fields from . import website from . import res_config_settings diff --git a/addons/website_sale/models/ir_model_fields.py b/addons/website_sale/models/ir_model_fields.py new file mode 100644 index 00000000000..e109ab809b5 --- /dev/null +++ b/addons/website_sale/models/ir_model_fields.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + +# !! Attention, this is a hack of a hack, do not try this at home !! +# This is done because website_sale defines the website_description +# field which is also defined by website_quote, but these two modules +# are independent of each other, the part of the ORM that generates +# xmlids does not support this case therefore it only creates a single +# pair of xmlids for whichever module is installed first, therefore +# when uninstalling any of the two modules the website_descripion field +# will be deleted from the db and the other module won't be able to +# use this field, resulting in a crash and data-loss. +# See opw-776464 for more details. +# +# This is hotfixed by overriding the unlink method so as to not delete +# the website_description field if website_sale is uninstalled, it's +# an ugly hack but it works and big changes can't be done to the ORM +# in stable versions, Odoo v12+ will properly fix this + + +class IrModelFields(models.Model): + _inherit = "ir.model.fields" + + @api.multi + def unlink(self): + # Prevent the deletion of the field "website_description" + self = self.filtered( + lambda rec: not ( + rec.model in ('product.product', 'product.template') and + rec.name == 'website_description' + ) + ) + return super(IrModelFields, self).unlink() diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index 45154eae1ef..a93f9d6ce81 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -5,6 +5,7 @@ from odoo.addons import decimal_precision as dp from odoo.tools import pycompat from odoo.tools.translate import html_translate +from odoo.tools import float_is_zero class ProductStyle(models.Model): @@ -130,6 +131,7 @@ class ProductTemplate(models.Model): website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price')) website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price')) + website_price_difference = fields.Boolean('Website price difference', compute='_website_price') def _website_price(self): # First filter out the ones that have no variant: @@ -139,6 +141,7 @@ class ProductTemplate(models.Model): for template, product in pycompat.izip(self, self.mapped('product_variant_id')): template.website_price = product.website_price template.website_public_price = product.website_public_price + template.website_price_difference = product.website_price_difference def _default_website_sequence(self): self._cr.execute("SELECT MIN(website_sequence) FROM %s" % self._table) @@ -179,6 +182,7 @@ class Product(models.Model): website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price')) website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price')) + website_price_difference = fields.Boolean('Website price difference', compute='_website_price') def _website_price(self): qty = self._context.get('quantity', 1.0) @@ -195,6 +199,8 @@ class Product(models.Model): for p, p2 in pycompat.izip(self, self2): taxes = partner.property_account_position_id.map_tax(p.taxes_id.filtered(lambda x: x.company_id == company_id)) p.website_price = taxes.compute_all(p2.price, pricelist.currency_id, quantity=qty, product=p2, partner=partner)[ret] + price_without_pricelist = taxes.compute_all(p.list_price, pricelist.currency_id)[ret] + p.website_price_difference = False if float_is_zero(price_without_pricelist - p.website_price, precision_rounding=pricelist.currency_id.rounding) else True p.website_public_price = taxes.compute_all(p2.lst_price, quantity=qty, product=p2, partner=partner)[ret] @api.multi diff --git a/addons/website_sale/static/src/css/website_sale.css b/addons/website_sale/static/src/css/website_sale.css index 66735871e06..2fd9a68563a 100644 --- a/addons/website_sale/static/src/css/website_sale.css +++ b/addons/website_sale/static/src/css/website_sale.css @@ -555,11 +555,6 @@ ul.wizard li:last-child .chevron { display: none; } -/* Shipping & Billing */ -.label-optional { - font-weight: normal; -} - /* Collapse category */ #o_shop_collapse_category li a { display: inline-block; diff --git a/addons/website_sale/static/src/css/website_sale.sass b/addons/website_sale/static/src/css/website_sale.sass index 3b9ceb7fdd9..6b8c5a155e6 100644 --- a/addons/website_sale/static/src/css/website_sale.sass +++ b/addons/website_sale/static/src/css/website_sale.sass @@ -488,10 +488,6 @@ ul.wizard .chevron display: none -/* Shipping & Billing */ -.label-optional - font-weight: normal - /* Collapse category */ #o_shop_collapse_category li a diff --git a/addons/website_sale/static/src/js/website_sale_payment.js b/addons/website_sale/static/src/js/website_sale_payment.js index 8ac46658da9..a3c2816da1c 100644 --- a/addons/website_sale/static/src/js/website_sale_payment.js +++ b/addons/website_sale/static/src/js/website_sale_payment.js @@ -4,10 +4,10 @@ odoo.define('website_sale.payment', function (require) { $(document).ready(function () { // If option is enable if ($("#checkbox_cgv").length) { - var enabling_button = function() { + $("#checkbox_cgv").change(function() { $("button#o_payment_form_pay").prop("disabled", !this.checked); - }; - $("#checkbox_cgv").click(enabling_button).each(enabling_button); + }); + $('#checkbox_cgv').trigger('change'); } }); diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 0df77aaecf5..f2a2f6c4f83 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -108,7 +108,7 @@ </h5> <div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price" t-if="product.product_variant_ids"> <b> - <t t-if="(compute_currency(product.lst_price) - product.website_price ) > 0.01 and website.get_current_pricelist().discount_policy=='without_discount'"> + <t t-if="product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'"> <del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" /> </t> @@ -581,7 +581,7 @@ <div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price mt16"> <h4 class="oe_price_h4 css_editable_mode_hidden"> <span class="text-danger oe_default_price" style="text-decoration: line-through; white-space: nowrap;" - t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" t-att-style="'text-decoration: line-through; white-space: nowrap; ' + '' if (compute_currency(product.lst_price) - product.website_price) > 0.01 and website.get_current_pricelist().discount_policy == 'without_discount' else 'display: none;'" + t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" t-att-style="'text-decoration: line-through; white-space: nowrap; ' + '' if product.website_price_difference and website.get_current_pricelist().discount_policy == 'without_discount' else 'display: none;'" /> <b class="oe_price" style="white-space: nowrap;" t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> <span itemprop="price" style="display:none;" t-esc="product.website_price"/> @@ -965,7 +965,7 @@ <div class="text-muted hidden-xs" t-field="product.description_sale" /> </td> <td class='td-price'> - <t t-if="(compute_currency(product.lst_price) - product.website_price) > 0.01 and website.get_current_pricelist().discount_policy=='without_discount'"> + <t t-if="product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'"> <del class="text-danger mr8" style="white-space: nowrap;" t-field="product.lst_price" t-options="{'widget': 'monetary','from_currency': product.currency_id, 'display_currency': website.currency_id}" /> </t> <span t-field="product.website_price" style="white-space: nowrap;" t-options="{'widget': 'monetary','display_currency': website.currency_id}" /> diff --git a/addons/website_sale_stock/models/sale_order.py b/addons/website_sale_stock/models/sale_order.py index ecc653f98d1..4ac8a67a871 100644 --- a/addons/website_sale_stock/models/sale_order.py +++ b/addons/website_sale_stock/models/sale_order.py @@ -25,9 +25,10 @@ class SaleOrder(models.Model): # Make sure line still exists, it may have been deleted in super()_cartupdate because qty can be <= 0 if line.exists() and new_val['quantity']: line.warning_stock = _('You ask for %s products but only %s is available') % (cart_qty, new_val['quantity']) + values['warning'] = line.warning_stock else: self.warning_stock = _("Some products became unavailable and your cart has been updated. We're sorry for the inconvenience.") - + values['warning'] = self.warning_stock return values @api.multi diff --git a/addons/website_sale_stock_options/views/website_sale_stock_templates.xml b/addons/website_sale_stock_options/views/website_sale_stock_templates.xml index 8f34365277f..d5709c8556c 100644 --- a/addons/website_sale_stock_options/views/website_sale_stock_templates.xml +++ b/addons/website_sale_stock_options/views/website_sale_stock_templates.xml @@ -3,7 +3,7 @@ <!-- Shopping Cart Lines --> <template id="website_sale_stock_modal" inherit_id="website_sale_options.modal" name="Stocks Modal"> <xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes"> - <attribute name='t-att-data-max'>max(product.sudo().virtual_available - product.cart_qty, 1)</attribute> + <attribute name='t-att-data-max'>max(product.sudo().virtual_available - product.cart_qty, 1) if product.type == "product" and product.inventory_availability in ["always", "threshold"] else None</attribute> </xpath> <xpath expr="//div[hasclass('css_quantity')]" position="after"> <div class='availability_messages'/> diff --git a/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js b/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js index c1e9a001034..8f0c3ec0359 100644 --- a/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js +++ b/addons/website_sale_wishlist/static/src/js/website_sale_wishlist.js @@ -134,7 +134,14 @@ var ProductWishlist = Widget.extend({ }); add_to_cart.then(function(resp) { - $('.my_cart_quantity').text(resp.cart_quantity || '* 1'); + if (resp.warning) { + if (! $('#data_warning').length) { + $('.wishlist-section').prepend('<div class="mt16 alert alert-danger alert-dismissable" role="alert" id="data_warning"></div>'); + } + var cart_alert = $('.wishlist-section').parent().find('#data_warning'); + cart_alert.html('<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button> ' + resp.warning); + } + $('.my_cart_quantity').html(resp.cart_quantity || '<i class="fa fa-warning" /> '); }); return add_to_cart; }, diff --git a/addons/website_slides/models/slides.py b/addons/website_slides/models/slides.py index b7632e96527..c598af5104d 100644 --- a/addons/website_slides/models/slides.py +++ b/addons/website_slides/models/slides.py @@ -133,7 +133,7 @@ class Channel(models.Model): """ op = operator == "=" and "inselect" or "not inselect" # don't use param named because orm will add other param (test_active, ...) - return [('id', op, (req, (self._uid)))] + return [('id', op, (req, (self._uid, )))] @api.one @api.depends('visibility', 'group_ids', 'upload_group_ids') diff --git a/addons/website_slides/static/src/js/slides_upload.js b/addons/website_slides/static/src/js/slides_upload.js index 7145558ecfc..b3953f12571 100644 --- a/addons/website_slides/static/src/js/slides_upload.js +++ b/addons/website_slides/static/src/js/slides_upload.js @@ -149,6 +149,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/addons/website_theme_install/views/views.xml b/addons/website_theme_install/views/views.xml index 19593023695..656c8f731ca 100644 --- a/addons/website_theme_install/views/views.xml +++ b/addons/website_theme_install/views/views.xml @@ -86,6 +86,7 @@ action = { </record> <record id="theme_install_todo" model="ir.actions.todo"> <field name="action_id" ref="theme_install_todo_action"/> + <field name="sequence">0</field> </record> <!-- Add link to theme install in default customize modal --> diff --git a/doc/cla/corporate/acsone.md b/doc/cla/corporate/acsone.md index cb97dc6b20d..1060d7ccc02 100644 --- a/doc/cla/corporate/acsone.md +++ b/doc/cla/corporate/acsone.md @@ -25,3 +25,4 @@ Philippe Schmidt philippe.schmidt@acsone.eu https://github.com/phschmidt Stéphane Bidoul stephane.bidoul@acsone.eu https://github.com/sbidoul Thomas Binsfeld thomas.binsfeld@acsone.eu https://github.com/ThomasBinsfeld Benjamin Willig benjamin.willig@acsone.eu https://github.com/benwillig +François Honoré francois.honore@acsone.eu https://github.com/acsonefho diff --git a/doc/cla/corporate/savoirfairelinux.md b/doc/cla/corporate/savoirfairelinux.md index 784518b093e..6dca085c4ce 100644 --- a/doc/cla/corporate/savoirfairelinux.md +++ b/doc/cla/corporate/savoirfairelinux.md @@ -1,4 +1,4 @@ -Canada, France, 2017-05-12 +Canada, France, 2017-10-17 Savoir-faire Linux agrees to the terms of the Odoo Corporate Contributor License Agreement v1.0. @@ -8,17 +8,22 @@ declaration. Signed, -Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard +João Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf List of contributors: Agathe Mollé agathe.molle@savoirfairelinux.com https://github.com/Ehtaga Bruno Joliveau bruno.joliveau@savoirfairelinux.com https://github.com/bjoliveau David Dufresne david.dufresne@savoirfairelinux.com https://github.com/dufresnedavid -Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard -Joao Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf -Julie Moussu julie.mousse@savoirfairelinux.com https://github.com/JulieSFL -Pierre Lamarche pierre.lamarche@savoirfairelinux.com https://github.com/plamarche +Istvan SZALAÏ istvan.szalai@savoirfairelinux.com https://github.com/ventilooo +João Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf +Julien Jezequel-Breard julien.jezequel-breard@savoirfairelinux.com https://github.com/jjbreard +Quentin Lavallée-Bourdeau quentin.lavallee@savoirfairelinux.com https://github.com/qtiplb +Rim Ben Dhaou rim.bendhaou@savoirfairelinux.com https://github.com/rimbendhaou +Yasmine El Mrini yasmine.elmrini@savoirfairelinux.com https://github.com/yasmineelmrini +Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard (up to 2017-10-17) +Julie Moussu julie.mousse@savoirfairelinux.com https://github.com/JulieSFL (up to 2017-10-17) +Pierre Lamarche pierre.lamarche@savoirfairelinux.com https://github.com/plamarche (up to 2017-10-17) David Cormier david.cormier@savoirfairelinux.com https://github.com/cormier (up to 2017-05-12) Davin Baragiotta davin.baragiotta@savoirfairelinux.com https://github.com/giotta (up to 2017-05-12) El Hadji Dem elhadji.dem@savoirfairelinux.com https://github.com/ehdem (up to 2017-05-12) diff --git a/doc/cla/individual/felixonmars.md b/doc/cla/individual/felixonmars.md new file mode 100644 index 00000000000..dc45cf3f625 --- /dev/null +++ b/doc/cla/individual/felixonmars.md @@ -0,0 +1,11 @@ +China, 2017-10-19 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Felix Yan felixonmars@archlinux.org https://github.com/felixonmars diff --git a/odoo/addons/base/ir/ir_actions.py b/odoo/addons/base/ir/ir_actions.py index e24ebedba1b..982a0364089 100644 --- a/odoo/addons/base/ir/ir_actions.py +++ b/odoo/addons/base/ir/ir_actions.py @@ -602,18 +602,19 @@ class IrActionsTodo(models.Model): def create(self, vals): todo = super(IrActionsTodo, self).create(vals) if todo.state == "open": - todo.ensure_one_open_todo() + self.ensure_one_open_todo() return todo + @api.multi def write(self, vals): res = super(IrActionsTodo, self).write(vals) if vals.get('state', '') == 'open': - for todo in self: - todo.ensure_one_open_todo() + self.ensure_one_open_todo() return res + @api.model def ensure_one_open_todo(self): - open_todo = self.search(['&', ('state', '=', 'open'), ('id', '!=', self.id)]) + open_todo = self.search([('state', '=', 'open')], order='sequence asc, id desc', offset=1) if open_todo: open_todo.write({'state': 'done'}) diff --git a/odoo/addons/base/ir/ir_http.py b/odoo/addons/base/ir/ir_http.py index 85af389b0fb..27d759e5461 100644 --- a/odoo/addons/base/ir/ir_http.py +++ b/odoo/addons/base/ir/ir_http.py @@ -342,7 +342,7 @@ class IrHttp(models.AbstractModel): # cache etag = bool(request) and request.httprequest.headers.get('If-None-Match') - retag = '"%s"' % hashlib.md5(last_update.encode('utf-8')).hexdigest() + retag = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest() status = status or (304 if etag == retag else 200) headers.append(('ETag', retag)) headers.append(('Cache-Control', 'max-age=%s' % (STATIC_CACHE if unique else 0))) diff --git a/odoo/addons/base/ir/ir_sequence.py b/odoo/addons/base/ir/ir_sequence.py index 105f0f9bbf4..f1005cc6c44 100644 --- a/odoo/addons/base/ir/ir_sequence.py +++ b/odoo/addons/base/ir/ir_sequence.py @@ -324,6 +324,12 @@ class IrSequenceDateRange(models.Model): for seq in self: seq.write({'number_next': seq.number_next_actual or 0}) + @api.model + def default_get(self, fields): + result = super(IrSequenceDateRange, self).default_get(fields) + result['number_next_actual'] = 1 + return result + date_from = fields.Date(string='From', required=True) date_to = fields.Date(string='To', required=True) sequence_id = fields.Many2one("ir.sequence", string='Main Sequence', required=True, ondelete='cascade') diff --git a/odoo/addons/base/module/wizard/base_import_language.py b/odoo/addons/base/module/wizard/base_import_language.py index c0aca1669bd..9f7338ed592 100644 --- a/odoo/addons/base/module/wizard/base_import_language.py +++ b/odoo/addons/base/module/wizard/base_import_language.py @@ -29,7 +29,7 @@ class BaseLanguageImport(models.TransientModel): def import_lang(self): this = self[0] this = this.with_context(overwrite=this.overwrite) - with TemporaryFile('w+') as buf: + with TemporaryFile('w+b') as buf: try: buf.write(base64.decodestring(this.data)) 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/addons/base/static/src/js/res_config_settings.js b/odoo/addons/base/static/src/js/res_config_settings.js index 47b4bf3b8c7..9af3e9c782b 100644 --- a/odoo/addons/base/static/src/js/res_config_settings.js +++ b/odoo/addons/base/static/src/js/res_config_settings.js @@ -294,7 +294,7 @@ var BaseSettingRenderer = FormRenderer.extend({ var settingBox = $(this).closest('.o_setting_box'); if (!settingBox.hasClass('o_invisible_modifier')) { settingBox.removeClass('o_hidden'); - $(this).html(self._wordHighlighter($(this).text(), self.searchText)); + $(this).html(self._wordHighlighter($(this).html(), self.searchText)); } else { self.inVisibleCount++; } diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index 73ba36f4813..72aac0bc6d5 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -736,6 +736,30 @@ class TestFields(common.TransactionCase): self.assertEqual(len(discussion.important_messages), 2) self.assertEqual(len(discussion.very_important_messages), 2) + def test_70_x2many_write(self): + discussion = self.env.ref('test_new_api.discussion_0') + Message = self.env['test_new_api.message'] + # There must be 3 messages, 0 important + self.assertEqual(len(discussion.messages), 3) + self.assertEqual(len(discussion.important_messages), 0) + self.assertEqual(len(discussion.very_important_messages), 0) + discussion.important_messages = [(0, 0, { + 'body': 'What is the answer?', + 'important': True, + })] + # There must be 4 messages, 1 important + self.assertEqual(len(discussion.messages), 4) + self.assertEqual(len(discussion.important_messages), 1) + self.assertEqual(len(discussion.very_important_messages), 1) + discussion.very_important_messages |= Message.new({ + 'body': '42', + 'important': True, + }) + # There must be 5 messages, 2 important + self.assertEqual(len(discussion.messages), 5) + self.assertEqual(len(discussion.important_messages), 2) + self.assertEqual(len(discussion.very_important_messages), 2) + class TestX2many(common.TransactionCase): def test_search_many2many(self): diff --git a/odoo/addons/test_pylint/tests/test_pylint.py b/odoo/addons/test_pylint/tests/test_pylint.py index 86d193b161d..f5ac83ffb24 100644 --- a/odoo/addons/test_pylint/tests/test_pylint.py +++ b/odoo/addons/test_pylint/tests/test_pylint.py @@ -27,6 +27,8 @@ class TestPyLint(TransactionCase): 'W0123', # eval used 'W0101', # unreachable code + 'mixed-indentation', + # py3k checks 'print-statement', 'backtick', diff --git a/odoo/loglevels.py b/odoo/loglevels.py index 4380ff29427..bbf7204607b 100644 --- a/odoo/loglevels.py +++ b/odoo/loglevels.py @@ -95,7 +95,7 @@ def ustr(value, hint_encoding='utf-8', errors='strict'): def exception_to_unicode(e): - if hasattr(e, 'args'): + if getattr(e, 'args', ()): return "\n".join((ustr(a) for a in e.args)) try: return text_type(e) diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index dddd7c936cb..714d0734f56 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -932,7 +932,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 @@ -1043,7 +1044,9 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, # Make a reader for the POT file and be somewhat defensive for the # stable branch. - if fileobj.name.endswith('.po'): + + # when fileobj is a TemporaryFile, its name is an interget in P3, a string in P2 + if isinstance(fileobj.name, str) and fileobj.name.endswith('.po'): try: # Normally the path looks like /path/to/xxx/i18n/lang.po # and we try to find the corresponding diff --git a/requirements.txt b/requirements.txt index ee6c39b586d..f003d714858 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ Babel==2.3.4 decorator==4.0.10 docutils==0.12 -ebaysdk==2.1.4 +ebaysdk==2.1.5 feedparser==5.2.1 gevent==1.1.2 greenlet==0.4.10 diff --git a/setup/win32/setup.nsi b/setup/win32/setup.nsi index 10dbc9e52d8..948c0486905 100755 --- a/setup/win32/setup.nsi +++ b/setup/win32/setup.nsi @@ -8,7 +8,7 @@ !include 'FileFunc.nsh'
!include 'LogicLib.nsh'
!include 'Sections.nsh'
-!include 'LogicLib.nsh'
+!include 'x64.nsh'
!macro IfKeyExists ROOT MAIN_KEY KEY
# This macro comes from http://nsis.sourceforge.net/Check_for_a_Registry_Key
@@ -216,9 +216,16 @@ Section $(TITLE_OpenERP_Server) SectionOpenERP_Server SetOutPath "$INSTDIR\server"
File /r /x "${POSTGRESQL_EXE_FILENAME}" /x "wkhtmltopdf" "..\..\*"
+ SetOutPath "$INSTDIR\vcredist"
+ File /r "..\..\..\vcredist\*.exe"
+
# Install Visual C redistribuable files
DetailPrint "Installing Visual C++ redistributable files"
- nsExec::Exec '"$INSTDIR\service\vcredist\vcredist_x86.exe" /q'
+ ${If} ${RunningX64}
+ nsExec::Exec '"$INSTDIR\vcredist\vc_redist.x64.exe" /q'
+ ${Else}
+ nsExec::Exec '"$INSTDIR\vcredist\vc_redist.x86.exe" /q'
+ ${EndIf}
SetOutPath "$INSTDIR\thirdparty"
File /r "${STATIC_PATH}\wkhtmltopdf\*"
@@ -241,8 +248,13 @@ Section $(TITLE_OpenERP_Server) SectionOpenERP_Server DetailPrint "Installing Windows service"
nsExec::ExecTOLog '"$INSTDIR\python\python.exe" "$INSTDIR\server\odoo-bin" --stop-after-init --logfile "$INSTDIR\server\odoo.log" -s'
- nsExec::ExecToLog '"$INSTDIR\nssm\win32\nssm.exe" install ${SERVICENAME} "$INSTDIR\python\python.exe" "\"$INSTDIR\server\odoo-bin\""'
- nsExec::ExecToLog '"$INSTDIR\nssm\win32\nssm.exe" set AppDirectory "$\"$INSTDIR\server$\""'
+ ${If} ${RunningX64}
+ nsExec::ExecToLog '"$INSTDIR\nssm\win64\nssm.exe" install ${SERVICENAME} "$INSTDIR\python\python.exe" "\"$INSTDIR\server\odoo-bin\""'
+ nsExec::ExecToLog '"$INSTDIR\nssm\win64\nssm.exe" set ${SERVICENAME} AppDirectory "$\"$INSTDIR\server$\""'
+ ${Else}
+ nsExec::ExecToLog '"$INSTDIR\nssm\win32\nssm.exe" install ${SERVICENAME} "$INSTDIR\python\python.exe" "\"$INSTDIR\server\odoo-bin\""'
+ nsExec::ExecToLog '"$INSTDIR\nssm\win32\nssm.exe" set ${SERVICENAME} AppDirectory "$\"$INSTDIR\server$\""'
+ ${EndIf}
nsExec::Exec "net stop ${SERVICENAME}"
sleep 2
|
